เปรียบเทียบ 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:
| System | Maturity | บทบาทในการเทียบ · Role |
|---|---|---|
| Odoo 19 | 15+ ปี · ผู้ใช้หลายสิบล้านทั่วโลก | Gold standard — best-practice template ที่เรานำมา adapt |
| NWFTH (BatchMaster) | หลายปีใน production · 822 ตาราง (MSSQL, verified) | ERP จริงในสนาม — meaningful-key + 2-part ledger |
| GENCODE | 2 สัปดาห์ · 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
| มิติ · Dimension | Odoo 19 | NWFTH (BatchMaster) | GENCODE (วันนี้) |
|---|---|---|---|
| หน่วย master data | product.template + product.product | INMAST (169 cols) | CRS / SVC / PRD codes (3 ชั้น) |
| ตัวระบุ · Identifier | DB id (surrogate) + default_code | Itemkey nvarchar(18) (meaningful) | รหัสคือ identifier (semantic) |
| จัดหมวด · Classify | categ_id (relation) | Itemtyp/ItemSubtyp (3-char codes) | TYPE/CATEGORY segments |
| เลขเอกสาร · Numbering | ir.sequence (PG native หรือ no_gap) | dedicated sequence tables | nextJera = MAX(id)+1 ⚠️ |
| History / audit | mail.thread mixin (ฟรี) | parallel "H" tables (OELINH...) | audit_log table (มีช่องโหว่) |
| Money / Qty precision | Monetary / Float | decimal(22,6) / float | float4 ⚠️ (precision risk) |
| Multi-branch | company_id + parent_id | 1 DB ต่อ environment | core_branches (ไม่ค่อยใช้) |
| Transactions | sale/purchase/pos.order, account.move | OEHDR/OELIN, POHDR/POLIN, APHDR/APLIN | ยังไม่มี (codes เท่านั้น) |
| Inventory ledger | stock.move → stock.quant | Mintxdh (event) → INLOC (balance) | ยังไม่มี |
| Accounting | account.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.
Identity & Numbering — จุดเปราะที่สุด · The Weakest Link
เรื่องเลขที่เอกสาร/รหัส คือจุดที่ 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 |
|---|---|---|---|
| 1 | nextJera MAX(id)+1 race | MEDIUM | Odoo ir.sequence / NWFTH reservation |
| 2 | CRS existence check นอก transaction (TOCTOU) | LOW | NWFTH: check ภายใน transaction |
| 3 | soft-deleted code สร้างซ้ำไม่ได้ (ไม่ filter is_active) | MEDIUM | NWFTH: reactivate logic |
| 4 | ไม่มี write-ordering ใน transaction | LOW | NWFTH: shared-table writes LAST |
| 5 | ขาด B-tree index บน is_active/type/updated_at | LOW (ตอนนี้) | Odoo: auto-index FKs |
| 6 | BOM DELETE ไม่ถูก audit | LOW | Odoo: ledger IS the audit |
| 7 | quantity เป็น float4 (precision risk) | LOW | ทุกระบบ: ใช้ numeric/decimal |
| 8 | is_active ไม่มี CHECK (0,1) | LOW | Odoo: boolean type |
| 9 | dual status fields (status + is_active) | LOW | Odoo: 1 active field |
| 10 | FK ไม่มี ON DELETE discipline | LOW | Odoo: explicit ondelete ทุก relation |
ไม่มีอันไหน fatal วันนี้ (ตารางยังเล็ก) แต่ทุกอันคือหนี้ที่ทบต้นเมื่อขยายขนาด — และทุกอันมีสูตรแก้จากระบบที่ผ่านสนามมาแล้ว
สรุป · Checklist
- Odoo = gold standard (15 ปี, ผู้ใช้หลายสิบล้าน); NWFTH = พิสูจน์ concurrency จริง; GENCODE = ระบบที่เรา adapt เข้าหา
- GENCODE เด่นเรื่อง semantic code (รหัสมีความหมายในตัว) — เหนือกว่า default_code ของ Odoo
- แต่ GENCODE เป็น master data ล้วน — ขาด 2 ledger (inventory + accounting) ที่ทำให้เป็น ERP
- จุดเปราะที่สุด:
nextJeraMAX(id)+1 — Odoo แก้ด้วย ir.sequence มา 15 ปีแล้ว - 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 19 | PostgreSQL 18 (container tni-odoo19-db, healthy) | tni-db MCP — read-only SQL |
| Live NWFTH | TFCLIVE · 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_move | 72 | เอกสารบัญชีสารพัดประเภท (polymorphic) |
account_move_line | 66 | บรรทัด debit/credit (double-entry) |
stock_move → stock_quant | 52 → 20 | event การเคลื่อนของ → ยอดสด |
sale_order / _line | 52 / 51 | ใบขาย |
purchase_order / _line | 40 / 34 | ใบซื้อ |
product_template / product_product | 53 / 17 | master (template / variant) |
res_company | 155 | บริษัท / สาขา |
ir_sequence | 16 | บริการแจกเลข |
ตัวระบุ & 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)· FKproduct_tmpl_id → product_template(id) ON DELETE CASCADEsale_order_line: FKproduct_id → product_product(id) ON DELETE RESTRICTaccount_move_line: FKmove_id → account_move(id) ON DELETE CASCADE,account_id → account_account(id) RESTRICT,product_id → product_product(id) RESTRICTstock_move: FKproduct_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) | Itemkey | nvarchar(18) — meaningful PK |
OELIN (sale line) | Itemkey · PK Ordno+RowNum | nvarchar(18) |
POLIN (purchase line) | Itemkey | nvarchar(18) |
Mintxdh (inv ledger) | ItemKey · surrogate InTransID int | varchar(18) |
LotMaster (lot) | ItemKey | varchar(18) |
INLOC (balance) | Itemkey | nvarchar(18) |
NWFTH ใช้ surrogate (InTransID int) เฉพาะที่ ledger แบบ append-only เท่านั้น — ที่เหลืออ้างด้วยกุญแจที่มีความหมาย
ข้อสรุปที่พิสูจน์แล้ว: ใน Odoo ทุก FK ชี้ integer ไร้ความหมาย; ใน NWFTH ทุกการอ้างสินค้าชี้
Itemkeyที่คนอ่านออก · GENCODE ใช้ text FKcrsCode → 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
| มิติ · Dimension | Odoo 19 (live) | NWFTH / BatchMaster (live) | GENCODE |
|---|---|---|---|
| ตัวระบุสินค้า · Item identity | product_product.id int (surrogate) + default_code (nullable) | Itemkey nvarchar(18) — meaningful | fullCode text (semantic) + id identity |
| อ้างสินค้าในบรรทัด · Item FK | product_id → product_product(id) | Itemkey nvarchar(18) ทุกตาราง | crsCode → courseRegistry.fullCode |
| ชื่อหลายภาษา · i18n text | name jsonb | nvarchar (ภาษาเดียว) | text (ภาษาเดียว) |
| ค่าต่อสาขา · Per-company value | jsonb company-dependent (standard_price) | ต่อแถว / ตารางแยก | — |
| เงิน · Money | numeric (account) | decimal(22,6) / float | float4 ⚠️ |
| จำนวน · Qty | numeric (stock_move/quant) | float | float4 ⚠️ |
| Audit / history | create/write uid+date (แตะล่าสุด) + mail.thread | ตาราง "H" เก็บประวัติ ครบ | audit_log |
| วินัย FK · ON DELETE | ระบุชัดทุก relation (CASCADE/RESTRICT/SET NULL) | บังคับที่ชั้น app | ยังไม่มี ON DELETE ⚠️ |
| แจกเลข · Numbering | ir_sequence.implementation (standard | no_gap) | sequence tables | nextJera MAX(id)+1 ⚠️ |
| สมุดสต็อก · Inv ledger | stock_move → stock_quant (reserved_quantity) | Mintxdh → INLOC/LotMaster (QtyCommitSales) | ยังไม่มี |
| สมุดเงิน · Acct ledger | account_move/line (debit/credit/balance) | Mintxdh GL (NLAcct/INAcct) + AP/AR | ยังไม่มี |
| สาขา · Branch | res_company.parent_id (ลำดับชั้น) | 1 DB ต่อ environment | core_branches |
สรุป — Live Query เปลี่ยนอะไร · What Querying Changed
- ตอนนี้ทั้งสองระบบ "queried" ไม่ใช่ "remembered" — Odoo จาก live PostgreSQL 18, NWFTH จาก live TFCLIVE
- surrogate-vs-meaningful พิสูจน์จาก constraint จริง: Odoo FK ชี้
(id)ทุกตัว; NWFTH FK ชี้Itemkeynvarchar(18) ทุกตัว — GENCODE อยู่ฝั่ง NWFTH (ดู ch33) - Odoo มี ON DELETE discipline ครบทุก relation — ยืนยันจุดอ่อน #10 ของ ch30 ด้วยข้อมูลจริง
- 2 ledger ของจริงเชื่อมกันด้วย FK
stock_move.account_move_id— GENCODE ยังขาดทั้งคู่ (ch31 / ch32) - รูปแบบใหม่ที่ยืมได้: 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.