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 (PKInTransIDsurrogate int,TrnTyp/TrnSubTypรหัสประเภท,TrnQty float,TrnAmt decimal(22,6)) →INLOC(114 cols) = ยอดคงเหลือ (Qtyonhand,Qtycommitsales= reservation, 7 cost methods) เทียบตรงกับ Odoostock.move → stock.quant - GL bridge — แต่ละแถว
Mintxdhพก GL account (NLAcct/INAcct nvarchar(50)) → โพสต์เข้าบัญชี - เอกสารอ้างของด้วย Itemkey —
OELIN(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 codes | Orders (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 |
|---|---|---|---|
| 1 | Polymorphic document | 1 ตาราง account.move = 7 ชนิด ผ่าน move_type | 1 ตาราง gencode_document + doc_type แทนหลายตารางแยก |
| 2 | Double-entry invariant | _check_balanced: Σเดบิต=Σเครดิต ไม่งั้น post ไม่ได้ | การเคลื่อนสต็อก+เงินเป็น entry ที่สมดุล → บัญชีถูกเสมอ |
| 3 | Event-sourced inventory | stock.move=event; stock.quant=ยอดที่คำนวณ | ledger เป็น append-only; คำนวณยอด PRD; ต่อยอดจาก svc_prd_mapping |
| 4 | Reservation column | stock.quant.reserved_quantity จองตอน confirm | คอลัมน์ reserved = NWFTH Layer-1 ฝังใน schema |
| 5 | Audit mixin | _inherit='mail.thread' → audit ฟรี | tracking mixin/trigger แทน audit_log ที่มีช่องโหว่ |
| 6 | Computed+stored fields | @api.depends recompute อัตโนมัติ | แก้ display name ที่ sync มือ → generated column / recompute hook |
| 7 | ir.sequence service | standard (lock-free) vs no_gap (FOR UPDATE) | ฆ่า nextJera; derive C-{id} จาก IDENTITY (ch32) |
| 8 | company_id + record rules | company_id + parent_id + ir.rule row-level | branch_id เป็น dimension หลัก + Postgres RLS |
| 9 | External IDs | module.xmlid แยกจาก auto-increment PK | รหัส GENCODE คือ external ID ที่ดีอยู่แล้ว — ขยายสู่ config |
| 10 | ondelete discipline | ทุก relation ประกาศ restrict/cascade/set null | เติม ON DELETE ให้ FK ของ GENCODE |
| 11 | Fiscal lock date | fiscalyear_lock_date กันโพสต์ย้อนงวดปิด | period locking สำหรับการเงินคลินิก |
| 12 | POS 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
- Odoo เชื่อมทุก module ผ่าน 2 ledger:
account.move(เงิน, double-entry) +stock.move/quant(ของ, event-sourced) - 1 ตาราง account.move ทำ 7 ชนิดเอกสารผ่าน
move_type— adapt เป็นgencode_document+doc_type - POS รวมทั้ง session แล้ว post บัญชีครั้งเดียว — กลเม็ดสเกลสำคัญ
- NWFTH: ตารางร่วมต้องมี boundary — "own your own data"
- GENCODE มี
svc_prd_mappingเป็นเมล็ด inventory layer อยู่แล้ว - 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:
- ใช้ PostgreSQL
GENERATED ALWAYS AS IDENTITY(มีอยู่แล้ว) แล้ว derivejera_code = "C-" + idหลัง insert — id มาจาก DB เอง ไม่มี race เลย (ทาง lock-free แบบ standard) - ถ้าต้องการเลขต่อสาขาแบบไม่มีช่องว่าง: ใช้ PostgreSQL
SEQUENCEแยกต่อ (branch, type, period) — sharding ทำให้ 100 สาขาไม่แย่ง sequence เดียวกัน - เลิก 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 | ตอน execute | UPDATE 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 · แผนดูแลระยะยาว
- ให้ GENCODE เป็น master data ต่อไป — อย่าให้มันกลายเป็น ERP ทั้งก้อน แยก service ชัด (gencode-api, sales-order-api, …) ตาม ecosystem.md ที่วางไว้ — ตรงกับ module separation ของ Odoo
- ledger คือ audit — append-only event log ทำให้ audit ฟรี ไม่ต้องมี
audit_logแยกที่มีช่องโหว่ - migration ที่มีวินัย + ทดสอบ — บทเรียน Odoo/NWFTH: อย่า ALTER production โดยไม่ทดสอบ end-to-end; รัน migration ที่ merge แล้วจริง ไม่ใช่ hand-patch DB
- ขยาย CI parity gate สู่ schema — GENCODE มี 408/408 gold gate อยู่แล้ว (สัญชาตญาณถูก) เพิ่มการ verify ทุก ORM field ตรงกับ schema จริง (บทเรียน Odoo 19: เขียน field ที่ถูกลบไปแล้วจาก memory)
- schema-verify ก่อนเขียน — แม้แต่ Odoo ยังลงโทษการเดาชื่อ field; verify
\d tableก่อนเสมอ
★ Recommendation Matrix — ทำอะไรก่อน · Prioritized
| จุดอ่อน GENCODE | Odoo แก้ยังไง | NWFTH แก้ยังไง | ทำเลย · Action | Pri |
|---|---|---|---|---|
| nextJera MAX(id)+1 race | ir.sequence (native nextval) | sequence table, fail-loud | derive C-{id} จาก IDENTITY post-insert; shard no_gap ต่อ (branch,type,period) | P0 |
| ไม่มี inventory ledger | stock.move → stock.quant | LotMaster + LotTransaction | append-only move ledger; คำนวณยอด; ต่อจาก svc_prd_mapping | P1 |
| ไม่มี accounting | account.move double-entry | Mintxdh GL | double-entry ledger ถ้าทำบิลเอง | P1 |
| concurrency = retry อย่างเดียว | FOR UPDATE on quant | 3-layer reserve/soft/optimistic | reservation model + transaction สั้น | P1 |
| connection pool | — | rollback-on-checkout | reset connection; tx สั้น; mind Supabase pooler | P1 |
| multi-branch ไม่ค่อยใช้ | company_id + parent_id + ir.rule | 1 DB/env | branch_id เป็น dimension หลัก + partition + RLS | P1 |
| post ทีละ transaction | POS session batch | — | batch post ต่อ session/กะ | P2 |
| ตารางโต | auto-index + partition | covering index | Postgres partition ตาม branch/time; read replica | P2 |
| audit มีช่องโหว่ | ledger IS audit (mail.thread) | LotTransaction | ledger append-only เป็น audit; หรือ tracking mixin | P2 |
| 10 schema findings | boolean/ondelete/numeric | covering index | CHECK is_active; ondelete ทุก FK; numeric แทน float; B-tree index | P2 |
สรุป · Checklist
- P0 ทำก่อนเพื่อน: ฆ่า
nextJeraMAX(id)+1 → deriveC-{id}จาก PostgreSQL IDENTITY (race หาย, lock หาย) - เพิ่ม 2 ledger (inventory + accounting) แบบ append-only event-sourced ตาม Odoo
- concurrency: ใช้โมเดล 3 ชั้นของ NWFTH (reserve→soft→optimistic) แก้ก่อนถึง DB; transaction < 500ms
- batch posting ต่อ session (กลเม็ด POS); partition ตาม branch_id; read replica สำหรับ report
- connection pool: rollback-on-checkout, tx สั้น (บทเรียน bb8 poisoning ของ NWFTH)
- maintainability: GENCODE เป็น master data, ledger คือ audit, migration มีวินัย, ขยาย CI gate สู่ schema
- อย่า 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:
sale_order.action_confirm()— เปลี่ยน state draft→sale, trigger procurementsale_stock: _action_launch_stock_rule()— สร้าง stock.picking + stock.move จาก sale_order_linestock_picking.button_validate()— user ยืนยันส่งของ → เรียกmove_ids._action_done()stock_move._action_done()— update stock.quant (ตัด/เพิ่มยอด) + สร้าง account.move (journal entry) ผ่าน stock_accountaccount_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 หลายสาขา):
| มิติ | Odoo | NWFTH | GENCODE (เลือกได้) |
|---|---|---|---|
| Branch isolation | 1 DB + company_id + ir.rule | 1 DB per env | 1 DB + branch_id + PG RLS |
| Cross-branch query | ง่าย (same DB, change company) | ต้อง linked server | ง่าย (same DB) |
| Lock dates | 5 ระดับ (fiscal/tax/sale/purchase/hard) | app-level | ยังไม่มี |
| Scale pattern | parent_id hierarchy + record rules | separate DBs | parent_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)