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

เปรียบเทียบ Schema: Odoo ⋈ NWFTH ⋈ GENCODE

สามระบบ สามปรัชญาการออกแบบ — Odoo 19 (772 tables), NWFTH BatchMaster (822 tables), GENCODE (emerging) — เปรียบเทียบ schema จริง จาก live query เพื่อหา pattern ที่ใช้ซ้ำได้ · Three systems compared with live-queried data to find reusable patterns.

สารบัญ · Table of Contents

ทำไมต้องเทียบ · Why Compare

GENCODE เพิ่งอายุ 2 สัปดาห์ แต่จะกลายเป็นรากฐานของระบบ ERP เต็มรูปแบบ การออกแบบ schema วันนี้จะตามหลอกหลอนไปอีกหลายปี ดังนั้นเราเรียนจากระบบที่พิสูจน์แล้ว:

GENCODE is 2 weeks old but will become a full ERP foundation. Today's schema decisions will haunt the system for years — so we learn from what is already proven:

SystemMaturityบทบาทในการเทียบ · Role
Odoo 1915+ ปี · ผู้ใช้หลายสิบล้านทั่วโลกGold standard — best-practice template ที่เรานำมา adapt
NWFTH (BatchMaster)หลายปีใน production · 822 ตาราง (MSSQL, verified)ERP จริงในสนาม — meaningful-key + 2-part ledger
GENCODE2 สัปดาห์ · PostgreSQL (Supabase)ระบบที่เรากำลังปรับปรุง · the target of adaptation

หลักการ: อย่าออกแบบ ERP จากศูนย์ — Odoo แก้ปัญหาเหล่านี้มา 15 ปีแล้ว เรา adapt ไม่ใช่ reinvent · Don't design an ERP from scratch — Odoo solved these problems over 15 years. Adapt, don't reinvent.

Master Data: สินค้าและบริการ · Product & Service Models

หัวใจของทุก ERP คือ master data — "อะไรคือสิ่งที่เราขาย/ใช้" ทั้ง 3 ระบบแก้ปัญหานี้ต่างกัน:

Odoo — Template / Variant

Odoo แยกเป็น 2 ชั้น: product.template (สินค้าเชิงนามธรรม เช่น "เสื้อยืด") และ product.product (variant จริง เช่น "เสื้อยืด สีแดง ไซส์ M") พร้อม product.category และ uom.uom (หน่วยนับ) รหัสภายในคือ default_code (แค่ string ธรรมดา) — ตัวระบุจริงคือ database id

NWFTH — Item Master (BatchMaster ERP)

NWFTH (Newly Weds Foods) รัน BatchMaster ERP บน MSSQL — item master จริงคือ INMAST (169 คอลัมน์) ตัวระบุ (primary key) คือ Itemkey nvarchar(18)เป็น "รหัสที่มีความหมาย" ไม่ใช่ surrogate int จัดหมวดด้วย Itemtyp/ItemSubtyp (nvarchar 3 ตัว), PriceGrp, Abckey; หน่วยนับ Stockuomcode + conversion factors (จาก source จริง TFCLIVE)

ข้อค้นพบสำคัญ: NWFTH ใช้ meaningful key (Itemkey) เป็นตัวระบุ master data — ปรัชญาเดียวกับ GENCODE ไม่ใช่ surrogate แบบ Odoo (ดู ch33)

GENCODE — 3-Layer Semantic Code

ตัว รหัส GENCODE เองคือตัวระบุที่มีความหมาย (CRS/SVC/PRD) — PRD-AES-FIL-RST-CLAS อ่านออกได้ว่าคืออะไร เหมือน Itemkey ของ NWFTH แต่ละเอียดกว่า (แตกเป็น segment)

The GENCODE code is a semantic identifier — like NWFTH's meaningful Itemkey but parsed into segments, richer than Odoo's flat default_code. แต่ข้อแลกเปลี่ยน: GENCODE code ไม่มี transactional state (ไม่มีราคา ไม่มี stock) — เป็น master data ล้วน ๆ ตรงนี้คือช่องว่างที่ต้องเติมเพื่อเป็น ERP

ตารางเทียบ Schema · Schema Side-by-Side

มิติ · DimensionOdoo 19NWFTH (BatchMaster)GENCODE (วันนี้)
หน่วย master dataproduct.template + product.productINMAST (169 cols)CRS / SVC / PRD codes (3 ชั้น)
ตัวระบุ · IdentifierDB id (surrogate) + default_codeItemkey nvarchar(18) (meaningful)รหัสคือ identifier (semantic)
จัดหมวด · Classifycateg_id (relation)Itemtyp/ItemSubtyp (3-char codes)TYPE/CATEGORY segments
เลขเอกสาร · Numberingir.sequence (PG native หรือ no_gap)dedicated sequence tablesnextJera = MAX(id)+1 ⚠️
History / auditmail.thread mixin (ฟรี)parallel "H" tables (OELINH...)audit_log table (มีช่องโหว่)
Money / Qty precisionMonetary / Floatdecimal(22,6) / floatfloat4 ⚠️ (precision risk)
Multi-branchcompany_id + parent_id1 DB ต่อ environmentcore_branches (ไม่ค่อยใช้)
Transactionssale/purchase/pos.order, account.moveOEHDR/OELIN, POHDR/POLIN, APHDR/APLINยังไม่มี (codes เท่านั้น)
Inventory ledgerstock.move → stock.quantMintxdh (event) → INLOC (balance)ยังไม่มี
Accountingaccount.move (double-entry)GL accounts (NLAcct/INAcct) ใน Mintxdh + AP/ARยังไม่มี

NWFTH (verified จาก TFCLIVE) ใช้ meaningful key + ตาราง transaction/ledger ครบ; GENCODE ยังขาด ledger ทั้งสอง — รายละเอียดบท ch31 หมายเหตุ: BME warehouse apps (PNMAST, Cust_*) เป็น custom layer บน BatchMaster ไม่ใช่ ERP core

ดูเวอร์ชัน live query ทั้งสองฝั่ง (Odoo จาก PostgreSQL จริง + NWFTH จาก TFCLIVE) พร้อม constraint PK/FK จริง ได้ที่ ch35 — บทนี้วางแนวคิด, ch35 พิสูจน์ด้วยข้อมูลจาก database ที่รันอยู่ · For the both-sides-live-queried version with real PK/FK constraints, see ch35.

เรื่องเลขที่เอกสาร/รหัส คือจุดที่ GENCODE เปราะที่สุดเมื่อขยายขนาด:

Odoo มี ir.sequence เป็น service กลาง — มี 2 แบบ: standard (ใช้ PostgreSQL native sequence, lock-free) และ no_gap (ล็อกแถวเดียว, สำหรับเลขที่ต้องไม่มีช่องว่างตามกฎหมาย เช่นใบกำกับภาษี)

GENCODE ใช้ nextJera() = อ่าน MAX(id) แล้ว +1 ในหน่วยความจำ + retry 5 ครั้งถ้าชน — เป็น read-modify-write ที่ไม่มี lock เลย เมื่อ 100 สาขายิงพร้อมกันจะพังทันที (รายละเอียด + วิธีแก้ในบท ch32)

GENCODE's nextJera() is a read-modify-write with no lock — it collapses under concurrency. Odoo solved this 15 years ago with ir.sequence. This is the single clearest scalability landmine (full fix in ch32).

จุดอ่อน Schema ที่พบใน GENCODE · 10 GENCODE Findings

การวิเคราะห์ schema ของ glyph-oracle เชิงลึกพบ 10 จุด แต่ละจุดมีระบบที่พิสูจน์แล้วแก้ไว้:

#จุดอ่อน · Weaknessความเสี่ยงใครแก้ไว้แล้ว · Who solved it
1nextJera MAX(id)+1 raceMEDIUMOdoo ir.sequence / NWFTH reservation
2CRS existence check นอก transaction (TOCTOU)LOWNWFTH: check ภายใน transaction
3soft-deleted code สร้างซ้ำไม่ได้ (ไม่ filter is_active)MEDIUMNWFTH: reactivate logic
4ไม่มี write-ordering ใน transactionLOWNWFTH: shared-table writes LAST
5ขาด B-tree index บน is_active/type/updated_atLOW (ตอนนี้)Odoo: auto-index FKs
6BOM DELETE ไม่ถูก auditLOWOdoo: ledger IS the audit
7quantity เป็น float4 (precision risk)LOWทุกระบบ: ใช้ numeric/decimal
8is_active ไม่มี CHECK (0,1)LOWOdoo: boolean type
9dual status fields (status + is_active)LOWOdoo: 1 active field
10FK ไม่มี ON DELETE disciplineLOWOdoo: explicit ondelete ทุก relation

ไม่มีอันไหน fatal วันนี้ (ตารางยังเล็ก) แต่ทุกอันคือหนี้ที่ทบต้นเมื่อขยายขนาด — และทุกอันมีสูตรแก้จากระบบที่ผ่านสนามมาแล้ว

สรุป · Checklist

  1. Odoo = gold standard (15 ปี, ผู้ใช้หลายสิบล้าน); NWFTH = พิสูจน์ concurrency จริง; GENCODE = ระบบที่เรา adapt เข้าหา
  2. GENCODE เด่นเรื่อง semantic code (รหัสมีความหมายในตัว) — เหนือกว่า default_code ของ Odoo
  3. แต่ GENCODE เป็น master data ล้วน — ขาด 2 ledger (inventory + accounting) ที่ทำให้เป็น ERP
  4. จุดเปราะที่สุด: nextJera MAX(id)+1 — Odoo แก้ด้วย ir.sequence มา 15 ปีแล้ว
  5. 10 จุดอ่อน schema ทุกจุดมีสูตรแก้จาก Odoo/NWFTH — ดูบท ch31 (adapt) และ ch32 (scale)

ทำไมต้อง Query จริง · Why Query the Live Database

เซสชันที่แล้วเราอ่าน Odoo จาก source code (นิยาม model ใน Python) แต่อ่าน NWFTH จาก live query จริง — สองมาตรฐานไม่เท่ากัน บทนี้ปิดช่องว่างนั้น: query ทั้งสองระบบจากฐานข้อมูลที่รันอยู่จริง

Last session we read Odoo from source code (Python model definitions) but NWFTH from a real live query — an asymmetric standard. This chapter closes the gap: both systems are now queried from running databases.

การเชื่อมต่อ · Connectionระบบ · Systemเครื่องมือ · Tool
Live Odoo 19PostgreSQL 18 (container tni-odoo19-db, healthy)tni-db MCP — read-only SQL
Live NWFTHTFCLIVE · BatchMaster ERP (MSSQL, read-only)nwfth-sql MCP — SELECT only

ทำไมต่างจากการอ่าน source: source บอก "นิยาม model" แต่ live DB บอก "สิ่งที่ database สร้างจริง" — คอลัมน์ที่ระบบเติมให้เอง (id, create_uid, write_date), ชนิดข้อมูลจริง (jsonb!), และ constraint จริง (PK/FK + ON DELETE) · Source shows the model definition; the live DB shows what the engine actually materialized — auto-added columns, real types (jsonb), and real constraints.

ขอบเขตที่ query (ซื่อตรง): instance นี้ติดตั้ง base, product, sale, sale_management, purchase, stock, stock_account, account, analytic — ครบแกน ขาย/ซื้อ/สต็อก/บัญชี · POS และ MRP ไม่ได้ติดตั้งใน instance นี้ (มีใน Odoo ทั่วไป แต่ไม่ได้เปิดที่นี่ จึงไม่อ้างถึง)

ตาราง · Table (Odoo live)คอลัมน์จริง · Real colsบทบาท · Role
account_move72เอกสารบัญชีสารพัดประเภท (polymorphic)
account_move_line66บรรทัด debit/credit (double-entry)
stock_movestock_quant52 → 20event การเคลื่อนของ → ยอดสด
sale_order / _line52 / 51ใบขาย
purchase_order / _line40 / 34ใบซื้อ
product_template / product_product53 / 17master (template / variant)
res_company155บริษัท / สาขา
ir_sequence16บริการแจกเลข

ตัวระบุ & FK — พิสูจน์จาก Constraint จริง · Identity & FK, Proven Live

หัวใจของการเทียบ: Odoo อ้างอิงทุกอย่างด้วย integer ที่ไม่มีความหมาย (surrogate id); NWFTH อ้างอิงด้วย Itemkey ที่อ่านออก (meaningful key) — ตอนนี้พิสูจน์จาก constraint จริงในฐานข้อมูล ไม่ใช่การเดา

Odoo 19 — surrogate id ทุกตาราง (จาก pg_constraint จริง)

ทุก PK คือ PRIMARY KEY (id) และทุก FK ชี้ไปที่ (id):

  • product_product: PK (id) · FK product_tmpl_id → product_template(id) ON DELETE CASCADE
  • sale_order_line: FK product_id → product_product(id) ON DELETE RESTRICT
  • account_move_line: FK move_id → account_move(id) ON DELETE CASCADE, account_id → account_account(id) RESTRICT, product_id → product_product(id) RESTRICT
  • stock_move: FK product_id → product_product(id), account_move_id → account_move(id) ON DELETE SET NULL ← เส้นเชื่อม 2 สมุด

สังเกต: ON DELETE ระบุชัดทุก relation (CASCADE / RESTRICT / SET NULL) — ยืนยัน "จุดอ่อน #10" ของ ch30 ด้วยข้อมูลจริงว่า Odoo มีวินัยตรงนี้ที่ GENCODE ยังขาด

NWFTH / BatchMaster — meaningful Itemkey ทุกตาราง (จาก INFORMATION_SCHEMA จริง)

กุญแจสินค้าเดียวกัน Itemkey nvarchar(18) ถูกพกเข้าทุกตาราง transaction/ledger ในฐานะ FK — ไม่ใช่ surrogate int:

ตาราง · Tableคอลัมน์กุญแจ · Key columnชนิด · Type
INMAST (master)Itemkeynvarchar(18) — meaningful PK
OELIN (sale line)Itemkey · PK Ordno+RowNumnvarchar(18)
POLIN (purchase line)Itemkeynvarchar(18)
Mintxdh (inv ledger)ItemKey · surrogate InTransID intvarchar(18)
LotMaster (lot)ItemKeyvarchar(18)
INLOC (balance)Itemkeynvarchar(18)

NWFTH ใช้ surrogate (InTransID int) เฉพาะที่ ledger แบบ append-only เท่านั้น — ที่เหลืออ้างด้วยกุญแจที่มีความหมาย

ข้อสรุปที่พิสูจน์แล้ว: ใน Odoo ทุก FK ชี้ integer ไร้ความหมาย; ใน NWFTH ทุกการอ้างสินค้าชี้ Itemkey ที่คนอ่านออก · GENCODE ใช้ text FK crsCode → courseRegistry.fullCodeอยู่ฝั่งเดียวกับ NWFTH (semantic key) ไม่ใช่ Odoo (ดู ch33)

สองสมุดบัญชี ของจริง · The Two Ledgers, Live

ทั้ง Odoo และ NWFTH มี "2 สมุด" ที่ GENCODE ยังไม่มี — ตอนนี้ดูจากคอลัมน์จริง:

สมุดสต็อก · Inventory ledger

Odoo: stock_move (event — product_qty, product_uom_qty, quantity เป็น numeric, location_id/location_dest_id, state) ไหลเข้า stock_quant (ยอดสด — quantity, reserved_quantity, inventory_quantity, lot_id) · reserved_quantity คือการจองในตัว = Layer-1 ของ NWFTH

NWFTH: Mintxdh (event) → LotMaster/INLOC (QtyOnHand, QtyCommitSales = ยอดจอง) เป็น float

สมุดเงิน · Accounting ledger

Odoo: account_move (move_type, state, amount_total) + account_move_line (debit/credit/balance numeric, account_id, reconciled, parent_state) — double-entry · NWFTH: Mintxdh ถือ GL bridge (NLAcct/INAcct nvarchar(50)) + AP/AR

เส้นเชื่อม 2 สมุด · The wire between them

หลักฐาน live: stock_move.account_move_id → account_move(id) — ทุกการเคลื่อนของผูกกับ journal entry ของมันที่ระดับแถว · ใน NWFTH คอลัมน์เดียว (Mintxdh) ถือทั้ง txn สต็อกและบัญชี GL พร้อมกัน

graph LR
  S["ขาย / รับของ"] --> M["stock_move
(event)"] M --> Q["stock_quant
(ยอดสด + reserved)"] S --> A["account_move
(debit = credit)"] M -. "account_move_id (FK)" .-> A

3 ค้นพบใหม่จาก Live DB (ที่อ่าน source ไม่เห็น) · 3 Findings the Source-Read Missed

การ query จริงเปิด 3 อย่างที่อ่าน source แล้วไม่เห็น — ล้วนเป็นดีไซน์ Odoo สมัยใหม่:

1. ข้อความหลายภาษาเก็บเป็น JSONB · Translatable text = JSONB

product_template.name ชนิดจริงคือ jsonb (เก็บเป็น {"en_US": "..."} และเพิ่มคีย์ th_TH เมื่อเปิดหลายภาษา) — i18n ฝังที่ระดับคอลัมน์ 1 แถวต่อสินค้า · NWFTH ใช้ nvarchar ภาษาเดียว; GENCODE ภาษาเดียว

2. ค่าต่อบริษัทเก็บเป็น JSONB · Company-dependent = JSONB

product_product.standard_price (ต้นทุน) ชนิดจริงคือ jsonb — Odoo เก็บ ต้นทุนต่อบริษัท/สาขาไว้ในคอลัมน์เดียว (key = company) ส่วน list_price ยังเป็น numeric (ใช้ร่วม) · เกี่ยวกับ 100 สาขาโดยตรง: เก็บค่าต่อสาขาได้โดยไม่ต้องมีตารางแยก — รูปแบบที่ GENCODE ยืมไปใช้กับ ราคา/ต้นทุน ต่อสาขาได้

3. คอลัมน์ audit = แค่ "แตะล่าสุด" · Audit columns are last-touch only

ทุกตารางมี create_uid/create_date/write_uid/write_date — แต่ write_* ถูก เขียนทับทุกครั้งที่แก้ (เก็บแค่ใคร/เมื่อไหร่ ล่าสุด) ประวัติเต็มต้องเปิด mail.thread tracking (ตาราง mail_message/mail_tracking_value แยก) · ตรงนี้ NWFTH แข็งกว่า: ตาราง "H" (OELINH, POHDRH...) เก็บเอกสารเวอร์ชันก่อนหน้า ครบ = Nothing-is-Deleted ของจริง

โบนัส: ir_sequence.implementation (varchar) คือสวิตช์จริงระหว่าง standard (PG nextval, lock-free) กับ no_gap (ล็อกแถว) — ยืนยันวิธีแก้ใน ch32 · account_move_line.parent_state = สำเนา state ของ move (denormalize เพื่อความเร็ว)

ตารางเทียบ Schema ระดับคอลัมน์ (Live ทั้งสองฝั่ง) · Column-Level Side-by-Side

มิติ · DimensionOdoo 19 (live)NWFTH / BatchMaster (live)GENCODE
ตัวระบุสินค้า · Item identityproduct_product.id int (surrogate) + default_code (nullable)Itemkey nvarchar(18) — meaningfulfullCode text (semantic) + id identity
อ้างสินค้าในบรรทัด · Item FKproduct_id → product_product(id)Itemkey nvarchar(18) ทุกตารางcrsCode → courseRegistry.fullCode
ชื่อหลายภาษา · i18n textname jsonbnvarchar (ภาษาเดียว)text (ภาษาเดียว)
ค่าต่อสาขา · Per-company valuejsonb company-dependent (standard_price)ต่อแถว / ตารางแยก
เงิน · Moneynumeric (account)decimal(22,6) / floatfloat4 ⚠️
จำนวน · Qtynumeric (stock_move/quant)floatfloat4 ⚠️
Audit / historycreate/write uid+date (แตะล่าสุด) + mail.threadตาราง "H" เก็บประวัติ ครบaudit_log
วินัย FK · ON DELETEระบุชัดทุก relation (CASCADE/RESTRICT/SET NULL)บังคับที่ชั้น appยังไม่มี ON DELETE ⚠️
แจกเลข · Numberingir_sequence.implementation (standard | no_gap)sequence tablesnextJera MAX(id)+1 ⚠️
สมุดสต็อก · Inv ledgerstock_move → stock_quant (reserved_quantity)Mintxdh → INLOC/LotMaster (QtyCommitSales)ยังไม่มี
สมุดเงิน · Acct ledgeraccount_move/line (debit/credit/balance)Mintxdh GL (NLAcct/INAcct) + AP/ARยังไม่มี
สาขา · Branchres_company.parent_id (ลำดับชั้น)1 DB ต่อ environmentcore_branches

สรุป — Live Query เปลี่ยนอะไร · What Querying Changed

  1. ตอนนี้ทั้งสองระบบ "queried" ไม่ใช่ "remembered" — Odoo จาก live PostgreSQL 18, NWFTH จาก live TFCLIVE
  2. surrogate-vs-meaningful พิสูจน์จาก constraint จริง: Odoo FK ชี้ (id) ทุกตัว; NWFTH FK ชี้ Itemkey nvarchar(18) ทุกตัว — GENCODE อยู่ฝั่ง NWFTH (ดู ch33)
  3. Odoo มี ON DELETE discipline ครบทุก relation — ยืนยันจุดอ่อน #10 ของ ch30 ด้วยข้อมูลจริง
  4. 2 ledger ของจริงเชื่อมกันด้วย FK stock_move.account_move_id — GENCODE ยังขาดทั้งคู่ (ch31 / ch32)
  5. รูปแบบใหม่ที่ยืมได้: JSONB per-company (ค่าต่อสาขา) และ JSONB i18n (ชื่อหลายภาษา) — modern-Odoo ที่อ่าน source ไม่เห็น

บทเรียน: MCP ที่ถืออยู่แล้วไม่ query คือการตรวจสอบที่ "ข้าม" ไป — บทนี้คือฝั่ง Odoo ที่ถูก query จริงเสียที ครบทั้งสองด้านของการเทียบ · A live connection you hold but don't query is a verification skipped. This chapter is the Odoo half, finally queried.