Knowledge Hubบทที่ 28
YD-KM · คลังความรู้คลินิก

ERP Patterns: Module Interconnection & Scaling

จาก pattern การเชื่อม module ใน Odoo สู่สถาปัตยกรรมที่รองรับ 100 สาขา — ครอบคลุม module interconnection, multi-branch isolation, และ sale flow backbone · From proven Odoo module patterns to 100-branch architecture.

สารบัญ · Table of Contents

2 Ledger กลางของ Odoo · The Two Universal Ledgers

ความลับของ Odoo: ไม่ว่าจะ module ไหน ทุกอย่างไหลลงสู่ 2 ledger กลาง

The secret of Odoo: no matter the module, everything flows into two universal ledgers:

graph TB
  SO[Sales Order] --> AM
  PO[Purchase Order] --> AM
  POS[POS Order] --> AM
  SO --> SM
  PO --> SM
  POS --> SM
  AM["account.move
(บัญชี · double-entry)"] SM["stock.move → stock.quant
(สต็อก · inventory ledger)"]
  • ทุกอย่างที่เป็นเงิน → account.move — invoice, bill, payment, POS sale, refund กลายเป็น account.move ทั้งหมด (double-entry, เดบิต=เครดิตเสมอ)
  • ทุกอย่างที่เป็นของจริง → stock.move — การเคลื่อนของทุกครั้งเป็น event; ยอดคงเหลือปัจจุบัน (stock.quant) คำนวณจาก event

นี่คือเหตุผลที่ Odoo รองรับธุรกิจทุกแบบด้วย schema เดียว — ไม่ว่าจะขายอะไร สุดท้ายมันคือ 2 ledger นี้

เอกสารไหลอย่างไร · The Document Flow

ตัวอย่างวงจรการขายใน Odoo — สังเกตว่าเอกสารอ้างถึงกันเป็นลูกโซ่:

graph LR
  SO["sale.order
(ใบสั่งขาย)"] --> PICK["stock.picking
+ stock.move (ส่งของ)"] PICK --> INV["account.move
move_type=out_invoice"] INV --> PAY["account.move
(payment)"]

Purchase ก็เหมือนกัน: purchase.order → inbound stock.move → vendor bill (account.move type in_invoice)

หัวใจที่ adapt ได้: account.move 1 ตารางทำหน้าที่ 7 ชนิดเอกสาร ผ่าน field move_type (ยืนยันจาก source จริง):

move_type = Selection([
  ('entry',       'Journal Entry'),
  ('out_invoice', 'Customer Invoice'),
  ('out_refund',  'Customer Credit Note'),
  ('in_invoice',  'Vendor Bill'),
  ('in_refund',   'Vendor Credit Note'),
  ('out_receipt', 'Sales Receipt'),
  ('in_receipt',  'Purchase Receipt'),
])

1 ตาราง 7 ชนิด แทนที่จะมี 7 ตารางแยก — นี่คือ pattern แรกที่ GENCODE ควร adapt

POS Session Batching — กลเม็ดสเกล · The Scale Trick

POS คือ surface ที่ volume สูงสุด (ขายหลายพันบิลต่อวันต่อสาขา) Odoo ไม่ post บัญชีทีละบิล — มัน รวมทั้ง session แล้ว post เป็น account.move เดียวตอนปิดรอบ

POS is the highest-volume surface. Odoo does NOT post accounting per-sale — it batches a whole session into ONE account.move at session close (pos.session).

ผลลัพธ์: แทนที่ DB จะรับ insert บัญชีหลายพันครั้ง มันรับครั้งเดียวต่อรอบ/ต่อกะ — ลด write contention มหาศาล นี่คือบทเรียนตรงสำหรับ GENCODE ที่ 100 สาขา (ดู ch32)

NWFTH เชื่อม module อย่างไร · NWFTH's Interconnection

ฝั่ง NWFTH (BatchMaster ERP, verified จาก TFCLIVE) มีโครงสร้าง ledger คล้าย Odoo มาก:

  • Inventory แบบ 2 ส่วนMintxdh (52 cols) = ledger การเคลื่อนไหว append-only (PK InTransID surrogate int, TrnTyp/TrnSubTyp รหัสประเภท, TrnQty float, TrnAmt decimal(22,6)) → INLOC (114 cols) = ยอดคงเหลือ (Qtyonhand, Qtycommitsales = reservation, 7 cost methods) เทียบตรงกับ Odoo stock.move → stock.quant
  • GL bridge — แต่ละแถว Mintxdh พก GL account (NLAcct/INAcct nvarchar(50)) → โพสต์เข้าบัญชี
  • เอกสารอ้างของด้วย ItemkeyOELIN (sales line) และ POLIN (PO line) อ้าง item ด้วย Itemkey nvarchar(18) (meaningful key) ตรง ๆ; AP = APHDR/APLIN

บทเรียน "Own Your Own Data" (จาก BME warehouse apps — custom layer บน BatchMaster): เคยมี unpick คำนวณ rollback จาก ledger ที่ใช้ร่วม (รวม record ของอีกระบบ) → ลบยอดผิด แก้โดยให้แต่ละ service คำนวณจาก allocation table ของตัวเอง

บทเรียนสำหรับ GENCODE: (1) inventory ที่ดี = ledger append-only (Mintxdh/stock.move) + balance (INLOC/stock.quant) — 2 ส่วน; (2) ตารางที่ใช้ร่วมหลาย service ต้องมี boundary ชัด เป็นเจ้าของข้อมูลตัวเอง

GENCODE มีอะไรแล้ว / ขาดอะไร · Has vs Lacks

มี · Hasขาด · Lacks
Master data: CRS / SVC / PRD codesOrders (sale/purchase/pos)
crs_svc_mapping (course→service)Inventory ledger (stock.move/quant)
svc_prd_mapping (service→product BOM)Accounting ledger (account.move)
audit_log (มีช่องโหว่)Cross-module posting

ข่าวดี: svc_prd_mapping (bill-of-materials: บริการใช้สินค้าอะไรบ้าง) คือเมล็ดพันธุ์ของ inventory layer อยู่แล้ว — มันบอกว่าแต่ละบริการกินสินค้าอะไร เท่าไหร่ เหลือแค่เติม "การเคลื่อนไหว" (moves) เข้าไป

The good news: svc_prd_mapping is already the seed of an inventory layer — it defines what each service consumes. It just needs movement events layered on top.

★ Odoo Patterns to Adopt — สูตร adapt เข้า GENCODE

นี่คือใจกลางของบทนี้: 12 design pattern ที่ Odoo พิสูจน์แล้ว แต่ละอันมีสูตรแปลงเข้า GENCODE

#Odoo Pattern (พิสูจน์แล้ว)กลไก · Mechanismนำมาใช้กับ GENCODE · Adapt to YD
1Polymorphic document1 ตาราง account.move = 7 ชนิด ผ่าน move_type1 ตาราง gencode_document + doc_type แทนหลายตารางแยก
2Double-entry invariant_check_balanced: Σเดบิต=Σเครดิต ไม่งั้น post ไม่ได้การเคลื่อนสต็อก+เงินเป็น entry ที่สมดุล → บัญชีถูกเสมอ
3Event-sourced inventorystock.move=event; stock.quant=ยอดที่คำนวณledger เป็น append-only; คำนวณยอด PRD; ต่อยอดจาก svc_prd_mapping
4Reservation columnstock.quant.reserved_quantity จองตอน confirmคอลัมน์ reserved = NWFTH Layer-1 ฝังใน schema
5Audit mixin_inherit='mail.thread' → audit ฟรีtracking mixin/trigger แทน audit_log ที่มีช่องโหว่
6Computed+stored fields@api.depends recompute อัตโนมัติแก้ display name ที่ sync มือ → generated column / recompute hook
7ir.sequence servicestandard (lock-free) vs no_gap (FOR UPDATE)ฆ่า nextJera; derive C-{id} จาก IDENTITY (ch32)
8company_id + record rulescompany_id + parent_id + ir.rule row-levelbranch_id เป็น dimension หลัก + Postgres RLS
9External IDsmodule.xmlid แยกจาก auto-increment PKรหัส GENCODE คือ external ID ที่ดีอยู่แล้ว — ขยายสู่ config
10ondelete disciplineทุก relation ประกาศ restrict/cascade/set nullเติม ON DELETE ให้ FK ของ GENCODE
11Fiscal lock datefiscalyear_lock_date กันโพสต์ย้อนงวดปิดperiod locking สำหรับการเงินคลินิก
12POS session batchingหลายพันบิล → 1 account.move ตอนปิดรอบbatch post ต่อ session/กะ (ดู ch32)

Pattern 1, 8 ยืนยันจาก source จริง: res.company.child_ids ถูกตั้งชื่อว่า "Branches" ตรง ๆ — Odoo รองรับสาขาผ่าน company hierarchy มาแต่ไหนแต่ไร และ _check_balanced ราคา "The entry is not balanced." คือ invariant ที่ทำให้บัญชี Odoo เชื่อถือได้

สรุป · Checklist

  1. Odoo เชื่อมทุก module ผ่าน 2 ledger: account.move (เงิน, double-entry) + stock.move/quant (ของ, event-sourced)
  2. 1 ตาราง account.move ทำ 7 ชนิดเอกสารผ่าน move_type — adapt เป็น gencode_document + doc_type
  3. POS รวมทั้ง session แล้ว post บัญชีครั้งเดียว — กลเม็ดสเกลสำคัญ
  4. NWFTH: ตารางร่วมต้องมี boundary — "own your own data"
  5. GENCODE มี svc_prd_mapping เป็นเมล็ด inventory layer อยู่แล้ว
  6. 12 Odoo pattern พร้อมสูตร adapt — เริ่มจาก polymorphic doc, event-sourced ledger, ir.sequence

สถานการณ์ · The Scenario

วันที่ Youngdo มี 100 สาขา ทุกสาขายิง transaction เข้า PostgreSQL กลางตัวเดียวพร้อมกัน — ที่ไหนจะพังก่อน? มี 5 hotspot:

When Youngdo has 100 branches all hitting one central PostgreSQL simultaneously — where does it break first? Five hotspots:

graph TB
  B1[สาขา 1] --> DB
  B2[สาขา 2] --> DB
  B3[...] --> DB
  B100[สาขา 100] --> DB
  DB["PostgreSQL กลาง
1. เลขเอกสาร 2. ยอดสต็อก
3. volume 4. connection pool 5. ขนาดตาราง"]

Hotspot 1 — เลขเอกสาร · Document Numbering ⚠️ P0

นี่คือระเบิดลูกแรก nextJera() ของ GENCODE ทำแบบนี้:

// glyph-oracle: generate.ts (ปัจจุบัน)
SELECT jera_code FROM course_registry ORDER BY id DESC LIMIT 1
// → "C-0042" → +1 ในหน่วยความจำ → "C-0043"
// → INSERT ... ถ้าชน UNIQUE ให้ retry สูงสุด 5 ครั้ง

ที่ 100 สาขา: ทุก request อ่าน "ตัวสุดท้าย" ตัวเดียวกัน → ชนกันหมด → retry 5 ครั้งก็ไม่พอ → 409 error เกลื่อน นี่คือ read-modify-write ที่ไม่มี lock

Odoo แก้มา 15 ปีแล้วด้วย ir.sequence 2 แบบ (ยืนยันจาก source ir_sequence.py):

// แบบ standard — lock-free, รองรับ concurrency สูง
SELECT nextval('ir_sequence_NNN')   // PostgreSQL native sequence

// แบบ no_gap — สำหรับเลขที่ห้ามมีช่องว่าง (ใบกำกับภาษี)
SELECT number_next FROM ir_sequence_NNN WHERE id=%s FOR UPDATE NOWAIT
UPDATE ir_sequence_NNN SET number_next=number_next+increment WHERE id=%s

สูตรแก้ GENCODE:

  1. ใช้ PostgreSQL GENERATED ALWAYS AS IDENTITY (มีอยู่แล้ว) แล้ว derive jera_code = "C-" + id หลัง insert — id มาจาก DB เอง ไม่มี race เลย (ทาง lock-free แบบ standard)
  2. ถ้าต้องการเลขต่อสาขาแบบไม่มีช่องว่าง: ใช้ PostgreSQL SEQUENCE แยกต่อ (branch, type, period) — sharding ทำให้ 100 สาขาไม่แย่ง sequence เดียวกัน
  3. เลิก MAX(id)+1 + retry loop โดยสิ้นเชิง — retry loop ขยายปัญหา ไม่ได้แก้

ทำไม derive จาก id ถึงชนะ: id ถูกจ่ายโดย PostgreSQL แบบ atomic (เหมือน nextval) — ไม่มีสอง transaction ได้ id เดียวกัน ดังนั้น C-{id} ไม่ซ้ำโดยธรรมชาติ

Hotspot 2 — ยอดสต็อก · Inventory Balance Rows P1

ถ้า GENCODE เพิ่ม inventory layer (ซึ่งต้องเพิ่มเพื่อเป็น ERP) แถวยอดคงเหลือคือจุดแย่งกัน — ถ้า 100 สาขาขายสินค้าตัวเดียวกัน ทุกสาขา UPDATE แถวเดียวกัน → serialize

Odoo ล็อกแถว quant ตอนจอง (ยืนยันจาก stock_quant.py):

// stock.quant — ล็อกแถวยอดคงเหลือตอน reserve
quant = quants.try_lock_for_update(allow_referencing=True, limit=1)

แต่ NWFTH มีคำตอบที่ดีกว่าสำหรับ concurrency สูง — โมเดล 3 ชั้น (พิสูจน์กับ pickers 30+ คนพร้อมกัน):

Layerเมื่อไหร่แก้ contentionลดได้
1. Reservationตอนสร้าง order/runจองยอดล่วงหน้า แต่ละระบบเห็นแต่ของตัวเอง~80%
2. Soft lockตอน assign งานล็อก application-level + หมดอายุ 10 นาที~15%
3. Optimistic retryตอน executeUPDATE WHERE qty>=needed AND version=expected~5%

สูตรแก้ GENCODE: (1) partition สต็อกตาม branch_id — 100 สาขาไม่แตะแถวเดียวกันเว้นแต่แชร์สต็อกจริง (2) สำหรับสต็อกที่แชร์ ใช้โมเดล reservation ของ NWFTH แก้ contention ก่อนถึง DB (3) transaction สั้น < 500ms

Hotspot 3 — Volume & Batching P1

100 สาขา × หลายร้อยบิล/วัน = write นับล้านต่อวัน สองหลักการ:

  • Batch posting — เหมือน Odoo POS: รวมหลายรายการแล้ว post บัญชี/สต็อกครั้งเดียวต่อ session/กะ ไม่ใช่ทีละบิล ลด write contention มหาศาล
  • Append-only ledger — INSERT scale ได้ดี (ไม่แย่ง lock แถวเดิม); UPDATE แถว hot ไม่ scale ใช้ event log แล้วคำนวณยอด (เหมือน stock.move → stock.quant)

Append-only ยังให้ audit ฟรี — ทุก event อยู่ในประวัติ (ตรงกับหลัก "Nothing is Deleted")

Hotspot 4 — Connection Pool P1

บทเรียนราคาแพงจาก NWFTH (BME-Putaway, 2026-05-14): async task ที่ถูก cancel (client หลุด, timeout) คืน connection กลับ pool ทั้งที่ยังค้าง transaction เปิดถือ lock อยู่ connection เน่าค้างได้ถึง 29 นาที → pool หมด → ทั้งแอปค้าง

NWFTH lesson: a cancelled async task returns a poisoned connection (open tx holding locks) to the pool → pool exhaustion → app freeze.

สูตรแก้ GENCODE (บน Supabase pooler):

  • รัน rollback ตอน checkout: IF open-transaction THEN ROLLBACK ทุกครั้งที่หยิบ connection
  • transaction สั้นที่สุด — อย่าถือ connection คร่อม await ที่ cancel ได้
  • calibrate timeout ให้ตรงกับงานจริง (NWFTH: timeout สั้นกว่าคู่แข่ง = fail ทุกครั้ง)

Hotspot 5 — ขนาดตาราง · Table Growth P2

100 สาขา × หลายปี = transaction tables ใหญ่มหาศาล สูตร:

  • Postgres declarative partitioning ตาม branch_id หรือช่วงเวลา — query เร็วเพราะแตะ partition เดียว
  • Read replica สำหรับ report/dashboard — แยกภาระอ่านออกจาก write primary (Supabase รองรับ)
  • Master data เล็กและแชร์ — รหัส GENCODE (CRS/SVC/PRD) เป็น catalog กลางตัวเดียว ทุกสาขาใช้ร่วม ไม่โต ส่วนที่โตคือ transactions ซึ่ง partition แยก

Maintainability Roadmap · แผนดูแลระยะยาว

  1. ให้ GENCODE เป็น master data ต่อไป — อย่าให้มันกลายเป็น ERP ทั้งก้อน แยก service ชัด (gencode-api, sales-order-api, …) ตาม ecosystem.md ที่วางไว้ — ตรงกับ module separation ของ Odoo
  2. ledger คือ audit — append-only event log ทำให้ audit ฟรี ไม่ต้องมี audit_log แยกที่มีช่องโหว่
  3. migration ที่มีวินัย + ทดสอบ — บทเรียน Odoo/NWFTH: อย่า ALTER production โดยไม่ทดสอบ end-to-end; รัน migration ที่ merge แล้วจริง ไม่ใช่ hand-patch DB
  4. ขยาย CI parity gate สู่ schema — GENCODE มี 408/408 gold gate อยู่แล้ว (สัญชาตญาณถูก) เพิ่มการ verify ทุก ORM field ตรงกับ schema จริง (บทเรียน Odoo 19: เขียน field ที่ถูกลบไปแล้วจาก memory)
  5. schema-verify ก่อนเขียน — แม้แต่ Odoo ยังลงโทษการเดาชื่อ field; verify \d table ก่อนเสมอ

★ Recommendation Matrix — ทำอะไรก่อน · Prioritized

จุดอ่อน GENCODEOdoo แก้ยังไงNWFTH แก้ยังไงทำเลย · ActionPri
nextJera MAX(id)+1 raceir.sequence (native nextval)sequence table, fail-loudderive C-{id} จาก IDENTITY post-insert; shard no_gap ต่อ (branch,type,period)P0
ไม่มี inventory ledgerstock.move → stock.quantLotMaster + LotTransactionappend-only move ledger; คำนวณยอด; ต่อจาก svc_prd_mappingP1
ไม่มี accountingaccount.move double-entryMintxdh GLdouble-entry ledger ถ้าทำบิลเองP1
concurrency = retry อย่างเดียวFOR UPDATE on quant3-layer reserve/soft/optimisticreservation model + transaction สั้นP1
connection poolrollback-on-checkoutreset connection; tx สั้น; mind Supabase poolerP1
multi-branch ไม่ค่อยใช้company_id + parent_id + ir.rule1 DB/envbranch_id เป็น dimension หลัก + partition + RLSP1
post ทีละ transactionPOS session batchbatch post ต่อ session/กะP2
ตารางโตauto-index + partitioncovering indexPostgres partition ตาม branch/time; read replicaP2
audit มีช่องโหว่ledger IS audit (mail.thread)LotTransactionledger append-only เป็น audit; หรือ tracking mixinP2
10 schema findingsboolean/ondelete/numericcovering indexCHECK is_active; ondelete ทุก FK; numeric แทน float; B-tree indexP2

สรุป · Checklist

  1. P0 ทำก่อนเพื่อน: ฆ่า nextJera MAX(id)+1 → derive C-{id} จาก PostgreSQL IDENTITY (race หาย, lock หาย)
  2. เพิ่ม 2 ledger (inventory + accounting) แบบ append-only event-sourced ตาม Odoo
  3. concurrency: ใช้โมเดล 3 ชั้นของ NWFTH (reserve→soft→optimistic) แก้ก่อนถึง DB; transaction < 500ms
  4. batch posting ต่อ session (กลเม็ด POS); partition ตาม branch_id; read replica สำหรับ report
  5. connection pool: rollback-on-checkout, tx สั้น (บทเรียน bb8 poisoning ของ NWFTH)
  6. maintainability: GENCODE เป็น master data, ledger คือ audit, migration มีวินัย, ขยาย CI gate สู่ schema
  7. อย่า reinvent — Odoo แก้ปัญหาเหล่านี้มา 15 ปี ผู้ใช้หลายสิบล้านพิสูจน์แล้ว เรา adapt

res_company.parent_id — Branch Hierarchy (live DB + source line 36-41)

จาก live query + source res_company.py line 36-41: parent_id + child_ids สร้าง company hierarchy

# res_company.py line 36-41
parent_id = fields.Many2one('res.company', string='Parent Company')
child_ids = fields.One2many('res.company', 'parent_id', string='Branches')
all_child_ids = fields.One2many('res.company', 'parent_id', context={'active_test': False})
parent_ids = fields.Many2many('res.company', compute='_compute_parent_ids')
root_id = fields.Many2one('res.company', compute='_compute_parent_ids')

สังเกต: child_ids ตั้งชื่อว่า "Branches" ตรง ๆ — Odoo ออกแบบมาให้รองรับสาขาตั้งแต่แรก · parent_ids/root_id = computed hierarchy chain · ใน instance นี้มี 1 company (TNI, parent_id=null) แต่ Odoo รองรับ N สาขาผ่าน:

res_company:
  id=1, name=HQ, parent_id=NULL  (บริษัทแม่)
  id=2, name=Branch-A, parent_id=1
  id=3, name=Branch-B, parent_id=1
  ...
  id=101, name=Branch-100, parent_id=1

ทุกตาราง ERP มี company_id int FK → res_company(id) ON DELETE RESTRICT — ทุกแถวระบุว่าเป็นของสาขาไหน

ir.rule — Row-Level Security (จาก source)

Odoo ไม่ได้แค่มี company_id — มี ir.rule ที่กรองแถวอัตโนมัติ:

domain_force = [
  '|',
  ('company_id', '=', False),     # shared data (no company)
  ('company_id', '=', company_id) # user's current company
]

ทุก query ที่ผ่าน ORM ถูก ir.rule inject WHERE clause อัตโนมัติ — user ที่ login ในสาขา A เห็นแค่ข้อมูลสาขา A + shared data · ไม่ต้องเขียน WHERE company_id=... ทุก query — framework ทำให้

เทียบกับ PostgreSQL RLS: ir.rule ทำงานที่ ORM layer (app-level) ไม่ใช่ DB-level — ข้อดีคือ flexible (เปลี่ยน rule ที่ UI), ข้อเสียคือ direct SQL bypass ได้ · GENCODE ที่รัน PostgreSQL ใช้ RLS (Row-Level Security) ที่ DB-level ได้ = ปลอดภัยกว่า

Sale Flow — method chain จาก source (จะ enrich จาก haiku agent)

เส้นทาง sale_order → stock → accounting ใน Odoo — proven จาก FK จริง (ch38) ตอนนี้เพิ่ม method chain:

  1. sale_order.action_confirm() — เปลี่ยน state draft→sale, trigger procurement
  2. sale_stock: _action_launch_stock_rule() — สร้าง stock.picking + stock.move จาก sale_order_line
  3. stock_picking.button_validate() — user ยืนยันส่งของ → เรียก move_ids._action_done()
  4. stock_move._action_done() — update stock.quant (ตัด/เพิ่มยอด) + สร้าง account.move (journal entry) ผ่าน stock_account
  5. account_move._post() — validate (balanced?), assign sequence number, set state=posted, compute hash chain

ทุกขั้นตอนเกิดใน 1 DB transaction — ถ้าขั้นใดพัง ทั้งหมด rollback (atomicity) · GENCODE ยังไม่มี transaction chain นี้ — ต้องสร้างตั้งแต่ order → movement → journal

graph TD
  A["sale_order
action_confirm()"] --> B["sale_stock
_action_launch_stock_rule()"] B --> C["stock_picking
+ stock_move created"] C --> D["button_validate()
→ _action_done()"] D --> E["stock_quant update
(SKIP LOCKED)"] D --> F["stock_account
→ account_move created"] F --> G["account_move._post()
hash chain + sequence"]

NWFTH Branch Model — 1 DB per Environment

NWFTH ใช้วิธีต่างจาก Odoo สิ้นเชิง — 1 database ต่อ 1 environment (ไม่ใช่ 1 DB หลายสาขา):

มิติOdooNWFTHGENCODE (เลือกได้)
Branch isolation1 DB + company_id + ir.rule1 DB per env1 DB + branch_id + PG RLS
Cross-branch queryง่าย (same DB, change company)ต้อง linked serverง่าย (same DB)
Lock dates5 ระดับ (fiscal/tax/sale/purchase/hard)app-levelยังไม่มี
Scale patternparent_id hierarchy + record rulesseparate DBsparent_id + RLS

สำหรับ GENCODE 100 สาขา: Odoo model (1 DB + row isolation) เหมาะกว่า NWFTH model (100 DB) — cross-branch reporting + centralized config + single deployment · ใช้ PG RLS แทน ir.rule (DB-level ปลอดภัยกว่า app-level)