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

Deep Dives: Account Move, Inventory, Product Architecture

สี่เรื่องลึก รวมเป็นหนึ่ง reference chapter — จาก account_move 72 คอลัมน์ ผ่าน FK path เต็มรูป ไปจนถึง concurrency ระดับ inventory และสถาปัตยกรรม product · Four deep dives as one reference chapter.

สารบัญ · Table of Contents

72 คอลัมน์จริง — จัดกลุ่มตามบทบาท · 72 Real Columns, Grouped

จาก information_schema จริงของ Odoo 19 PostgreSQL 18:

กลุ่ม · Groupคอลัมน์หลัก · Key Columnsชนิด · Types
Identityid, name, ref, state, move_typeint, varchar, varchar, varchar, varchar
Sequencesequence_number int, sequence_prefix, secure_sequence_number int, inalterable_hashป้องกันการปลอมแปลง
Relationsjournal_id, company_id, partner_id, currency_id, fiscal_position_idint FK ทั้งหมด
Invoiceinvoice_date, invoice_date_due, invoice_origin, invoice_source_email, payment_referencedate, varchar
Amounts (11 ตัว!)amount_untaxed, amount_tax, amount_total, amount_residual, + _signed/_in_currency_signed variantsnumeric ทั้งหมด
State flagsposted_before, made_sequence_gap, is_manually_modified, checked, is_move_sentboolean
Auto-postauto_post varchar, auto_post_until date, auto_post_origin_id intrecurring entries
Marketingcampaign_id, source_id, medium_id, team_idUTM tracking ในเอกสารบัญชี
Datasending_data jsonb, narration texte-invoice payload + notes

สังเกต: 11 คอลัมน์ amount — Odoo คำนวณทุกมุมมอง (untaxed/tax/total/residual, signed/unsigned, company-currency/document-currency) แล้วเก็บไว้ ไม่คำนวณซ้ำ · Odoo pre-computes every amount perspective as stored fields — no recalculation at query time.

Immutable Hash Chain — ป้องกันปลอมแปลง · Anti-Tampering

คอลัมน์ที่อ่าน source ไม่เจอแต่ live DB เปิดเผย:

  • inalterable_hash (varchar) — hash chain ที่ ไม่สามารถแก้ไขได้ เมื่อ post แล้ว ทุก entry ถูก hash ต่อจาก entry ก่อนหน้า (เหมือน blockchain แบบง่าย) · once posted, each entry is chained to the previous via hash — tampering breaks the chain
  • secure_sequence_number (int) — ลำดับที่ hash ต่อกัน; made_sequence_gap (bool) — flag ที่บอกว่ามีช่องว่างในลำดับ (สำหรับ audit)

ทำไมสำคัญสำหรับ GENCODE: คลินิกที่ต้องปฏิบัติตามกฎหมายภาษีไทย (e-Tax) ต้องมีลำดับเลขที่ต่อเนื่อง + พิสูจน์ว่าไม่ถูกแก้ไขย้อนหลัง — hash chain ของ Odoo คือ template สำเร็จรูป

เปรียบเทียบ: NWFTH ใช้ ตาราง "H" (history tables) เก็บเอกสารก่อนแก้ครบทุกคอลัมน์ แต่ไม่มี cryptographic proof — ถ้ามีคนแก้ตาราง H โดยตรง ก็ไม่มีวิธีรู้ · hash chain = cryptographic; H tables = comprehensive but no tamper-proof

account_move_line — 66 คอลัมน์ + analytic_distribution JSONB

บรรทัดเดบิต/เครดิตทุกบรรทัดมี 66 คอลัมน์จริง — หัวใจคือ:

กลุ่ม · Groupคอลัมน์ · Columnsข้อสังเกต · Note
Double-entrydebit, credit, balancenumeric ทั้งหมด — balance = debit - credit
Reconciliationreconciled bool, full_reconcile_id, matching_number, amount_residual/_currencyจับคู่ invoice กับ payment อัตโนมัติ
Parent stateparent_state varchardenormalized — สำเนา state จาก account_move (เร็วกว่า JOIN)
Product linkproduct_id, product_uom_id, quantity, price_unit, discountFK → product_product + detail
Analyticanalytic_distribution jsonbการกระจายต้นทุน → ใจความสำคัญ (ด้านล่าง)
Taxtax_line_id, tax_group_id, tax_repartition_line_id, tax_base_amountภาษีระดับบรรทัด
Displaydisplay_type varcharproduct / line_section / line_note — กรอง accountable lines ด้วย display_type='product'
COGScogs_origin_id intCost of Goods Sold traceability
Purchasepurchase_line_id intFK → purchase_order_line (bridge)

analytic_distribution JSONB — รายได้ต่อหมอ/ต่อสาขา

analytic_distribution เป็น jsonb บน account_move_line — ไม่ใช่ตารางแยก เก็บเป็น key=analytic_account_id, value=percentage (เช่น {"3": 60, "7": 40} = 60% ไปศูนย์ต้นทุน A, 40% ไปศูนย์ต้นทุน B)

เกี่ยวกับ GENCODE โดยตรง: ถ้า GENCODE มี analytic account ต่อหมอ/ต่อสาขา/ต่อหมวดบริการ ทุก journal line จะกระจาย revenue อัตโนมัติ → ได้ "รายได้ต่อหมอ" ฟรี ไม่ต้องเขียน report logic พิเศษ

เปรียบเทียบ: NWFTH ใช้ Mintxdh.NLAcct/INAcct (nvarchar 50) — GL account เดียวต่อ transaction, ไม่มี multi-account distribution

Auto-Rule: AccountAnalyticDistributionModel (จาก haiku source swarm)

Odoo มี AccountAnalyticDistributionModel — กฎอัตโนมัติที่กำหนด analytic distribution ตาม product/partner/account โดยไม่ต้องเลือกมือ · GENCODE ใช้ได้: กฎ = "ถ้า GENCODE TYPE segment = AES → distribution 100% ไป analytic account หมอ A" → ทุก invoice line ที่ขาย AES ได้ per-doctor revenue ฟรี

คำเตือนจาก POS batching (สำคัญมาก): POS session close รวมทุกบิลเป็น account.move เดียว — ถ้า pos.order.line ไม่มี analytic_distribution ก่อนปิดรอบ ข้อมูล per-doctor/per-category จะหายไป (batch ยุบรวมหมด) · GENCODE ต้องเซ็ต analytic ที่ POS line ก่อน session close ไม่งั้นรายได้ต่อหมอจะสูญ

Audit Trail — mail_tracking_value vs NWFTH ตาราง H

Odoo 19 มี 50 ตาราง mail_* (สำรวจจาก live information_schema) — ระบบ audit ฝังอยู่ลึก:

  • mail_message — บันทึกทุก event (model=ชื่อตาราง, res_id=แถว, message_type, subtype_id)
  • mail_tracking_valuefield-level change tracking: field_id, old_value_char/_integer/_float, new_value_char/_integer/_float, field_info jsonb

วิธีใช้: model ที่ _inherit = 'mail.thread' + field ที่มี tracking=True → ทุกการเปลี่ยนแปลงถูกบันทึกอัตโนมัติ แสดงเป็น timeline ใน UI

มิติOdoo mail.threadNWFTH ตาราง HGENCODE audit_log
ระดับField-level (รู้ว่าฟิลด์ไหนเปลี่ยนจากอะไรเป็นอะไร)Document-level (เก็บเอกสารก่อนแก้ทั้งแถว)Event-level
ฟื้นสถานะเดิมได้?ได้บางส่วน (ต้องรวม tracking values)ได้ครบ (แถว H คือ snapshot เต็ม)ได้บางส่วน
ต้นทุนจัดเก็บต่ำ (เก็บแค่ delta)สูง (ซ้ำทั้งแถว)ต่ำ
Tamper-proofไม่ (แก้ mail_message ตรงได้)ไม่ (แก้ตาราง H ตรงได้)ไม่
โบนัสฟรีเมื่อ inheritNothing-is-Deleted ของจริง

ข้อแนะนำ GENCODE: ใช้ทั้งสองแนว — field-level tracking (ถูก, ทุกวัน) + periodic full-row snapshots (แพงกว่า, ตอน post/close) + inalterable_hash chain (tamper-proof)

ir_sequence จริง — SQL + 19 Sequences Live · Numbering in Practice

no_gap SQL จริง (จาก ir_sequence.py line 58-64, verified):

# _update_nogap (line 58-64)
SELECT number_next FROM ir_sequence WHERE id=%s FOR UPDATE NOWAIT
UPDATE ir_sequence SET number_next=number_next+%s WHERE id=%s

2 statements: SELECT ล็อกแถว (NOWAIT = fail ทันทีถ้ามีคนล็อกอยู่) → UPDATE +1 → return number_next เดิม · account_move ไม่ได้ใช้ ir.sequence — ใช้ sequence_prefix + sequence_number ภายในตัวเอง (line 142-159 ยืนยัน move_type + tracking=True)

_check_balanced (line 2755-2770): context manager ที่ raise UserError("The entry is not balanced.") ถ้า Σdebit ≠ Σcredit — invariant ที่ทำให้บัญชี Odoo เชื่อถือได้

จาก ir_sequence จริงใน Odoo 19 instance นี้ — 19 active sequences:

ชื่อ · NameCodeImplementationPrefixPadcompany_id
Sales Ordersale.orderstandardS5
Purchase Orderpurchase.orderstandardP5
Paymentaccount.paymentstandardPAY5
Group Paymentsno_gapGROUP/%(year)s/5
Picking INstandardWH/IN/51
Picking OUTstandardWH/OUT/51
Picking INTstock.pickingstandardINT/5
Serial Numbersstock.lot.serialstandard(blank)7
Scrapstock.scrapstandardSP/51

สังเกต: เกือบทั้งหมดเป็น standard (PG native nextval, lock-free) — มีแค่ Group Payments ที่เป็น no_gap (ล็อกแถว, สำหรับเลขที่ต้องต่อเนื่อง) · prefix ใช้ %(year)s interpolation สำหรับเลขแบ่งตามปี

บทเรียนสำคัญ: Odoo ไม่ได้ใช้ no_gap ทุกที่ — ใช้เฉพาะที่กฎหมายบังคับ (เลขใบกำกับภาษี) ที่เหลือใช้ standard (เร็วกว่า, ไม่ล็อก) นี่คือวิธีที่ถูกต้องสำหรับ GENCODE: default = standard sequence, no_gap = เฉพาะเลขที่ต้องไม่มีช่องว่าง

เปรียบเทียบ: GENCODE nextJera() = MAX(id)+1 ไม่มี lock เลย → ต้องเปลี่ยนเป็น PG IDENTITY (lock-free) + sharded no_gap ต่อ (branch, type, period) สำหรับเลขที่ต้องเรียง

Odoo — FK Chain จาก pg_constraint จริง · Proven FK Chain

จาก pg_constraint จริง — เส้นทางทุกจุดมี FK พิสูจน์ได้:

graph TD
  SO["sale_order
(52 cols)"] -->|"stock_picking.sale_id
→ sale_order(id)
ON DELETE SET NULL"| SP["stock_picking
(30 cols)"] SP -->|"stock_move.picking_id
→ stock_picking(id)"| SM["stock_move
(52 cols)"] SM -->|"stock_move_line.move_id
→ stock_move(id)
ON DELETE SET NULL"| SML["stock_move_line
(24 cols)"] SM -->|"stock_move.account_move_id
→ account_move(id)
ON DELETE SET NULL"| AM["account_move
(72 cols)"] AM -->|"account_move_line.move_id
→ account_move(id)
ON DELETE CASCADE"| AML["account_move_line
(66 cols)"] AML -->|"account_move_line.account_id
→ account_account(id)
ON DELETE RESTRICT"| AA["account_account
(16 cols)"] SM -.->|"sale_line_id
→ sale_order_line"| SOL["sale_order_line
(51 cols)"] AML -.->|"product_id
→ product_product(id)
ON DELETE RESTRICT"| PP["product_product"] PAYMENT["account_payment"] -->|"move_id → account_move(id)
ON DELETE SET NULL"| AM

สังเกต:

  • ON DELETE discipline ทุกจุด — CASCADE (ลบ parent → ลบ children), RESTRICT (ห้ามลบถ้ายังอ้างอยู่), SET NULL (ลบ parent → FK เป็น null) เลือกตามความหมาย
  • line-level traceability: stock_move.sale_line_id ชี้กลับ sale_order_line โดยตรง — ไม่ต้อง JOIN ผ่าน picking
  • สมุดสต็อก ↔ สมุดเงิน: เชื่อมด้วย stock_move.account_move_id — ทุกการเคลื่อนของมี journal entry คู่
  • account_payment.move_id → account_move — payment ไม่ใช่ entity แยก แค่ wrapper ของ account_move

Purchase Chain — ซื้อ → รับของ → AP

graph TD
  PO["purchase_order
(40 cols)"] -->|"picking_type_id
→ stock_picking_type(id)"| SP2["stock_picking
(inbound)"] SP2 --> SM2["stock_move"] SM2 -->|"purchase_line_id"| POL["purchase_order_line
(34 cols)"] SM2 -->|"account_move_id"| AM2["account_move
move_type=in_invoice"] AM2 --> AML2["account_move_line
(purchase_line_id !)"]

สังเกต: account_move_line.purchase_line_id (จาก live DB จริง) — invoice line ชี้กลับ PO line โดยตรง ทำให้ 3-way matching (PO ↔ receipt ↔ invoice) เป็นไปได้ที่ระดับบรรทัด

เปรียบเทียบ: stock_move มีทั้ง sale_line_id และ purchase_line_id — เป็นจุดศูนย์กลางที่เชื่อม buy-side กับ sell-side ผ่าน inventory

NWFTH — Sale→Inventory→GL Chain (จาก TFCLIVE จริง)

ฝั่ง NWFTH มีเส้นทางเดียวกัน แต่สถาปัตยกรรมต่าง — พิสูจน์จาก INFORMATION_SCHEMA TFCLIVE:

graph TD
  OH["OEHDR
(147 cols)
Ordno nvarchar(8)
Custkey nvarchar(25)"] --> OL["OELIN
(118 cols)
Ordno + RowNum (PK)
Itemkey nvarchar(18)"] OL -->|"fulfill"| MT["Mintxdh
(52 cols)
InTransID int (surrogate)
ItemKey varchar(18)
SysDocID → Ordno
Location → ToLocation"] MT -->|"GL bridge"| GL["GL Posting
NLAcct nvarchar(50)
INAcct nvarchar(50)"] MT -->|"update balance"| IL["INLOC
(114 cols)
Itemkey + Location
QtyOnHand, QtyCommitSales"] OH -->|"history"| OHH["OEHDRH (full copy)"] OL -->|"history"| OLH["OELINH (full copy)"]

ความต่าง:

มิติOdooNWFTH
FK ระหว่าง moduleinteger surrogate + ON DELETEmeaningful key (Itemkey/Ordno) + app-level
Document traceabilitystock_move.sale_line_id (direct FK)Mintxdh.SysDocID (text ref to Ordno)
GL linkstock_move.account_move_id (FK)Mintxdh.NLAcct/INAcct (GL codes inline)
Historymail_tracking_value (field delta)ตาราง H (full row copy)
Location modellocation_id/location_dest_id (tree)Location/ToLocation varchar(5)

stock_move — จุดศูนย์กลาง 52 คอลัมน์ · The Hub Table

stock_move เป็น hub ที่เชื่อมทุก module — นี่คือคอลัมน์ที่น่าสนใจจาก live DB (นอกเหนือจากที่รู้แล้ว):

คอลัมน์ · Columnชนิด · Typeความหมาย · Meaning
sale_line_idint FKชี้กลับ sale_order_line — line-level traceability
purchase_line_idint FKชี้กลับ purchase_order_line
account_move_idint FKjournal entry ที่สร้างจาก move นี้ (สมุดสต็อก → สมุดเงิน)
valuenumericมูลค่าสินค้าที่เคลื่อน (stock valuation)
is_in / is_out / is_dropshipbooleanทิศทาง — รับ/ส่ง/ส่งตรง
originvarcharเอกสารต้นทาง (เช่น SO00001)
reservation_datedateวันที่จอง
pickedbooleanหยิบของแล้วหรือยัง
propagate_cancelbooleanยกเลิก move นี้ → ยกเลิก move ถัดไปด้วย
warehouse_idint FKคลังสินค้า

Insight สำคัญ: stock_move ถือ FK ไปทุกทิศ — sale, purchase, inventory, accounting, warehouse — มันคือ junction table ของทั้ง ERP

GENCODE ยังขาดอะไร · What GENCODE Needs to Build This Chain

GENCODE วันนี้มี master data (CRS/SVC/PRD) + mappings — แต่ยังไม่มี transaction chain:

  1. ตาราง Order — sale_order equivalent (ลูกค้าซื้อ CRS course) ที่อ้าง fullCode เป็น FK
  2. ตาราง Movement — stock_move equivalent (ตัดสต็อก PRD ตาม BOM ใน svc_prd_mapping)
  3. ตาราง Journal Entry — account_move equivalent (debit/credit, move_type polymorphic)
  4. FK chain ที่ชัด — order → movement → journal entry → account, พร้อม ON DELETE discipline
  5. analytic_distribution JSONB — กระจายรายได้ต่อหมอ/สาขา/หมวด อัตโนมัติ

svc_prd_mapping คือ BOM ที่พร้อมใช้ — เหลือแค่ wrap ด้วย transaction chain ที่สร้างจาก template ของ Odoo ทั้งหมดนี้ proven จาก FK จริง

stock_quant — 20 คอลัมน์ ยอดสดจริง · The Live Balance Table

จาก information_schema จริง — stock_quant มี 20 คอลัมน์:

คอลัมน์ชนิดบทบาท
idint PKsurrogate
product_idint FK→ product_product(id)
location_idint FKตำแหน่งในคลัง
lot_idint FKLot/Serial number
company_idint FKบริษัท/สาขา
package_idint FKกล่อง/แพ็ค (consignment)
owner_idint FKเจ้าของ (consignment tracking)
quantitynumericยอดจริงในมือ
reserved_quantitynumericยอดที่จองไว้ (confirmed SO ที่ยังไม่ส่ง)
inventory_quantitynumericยอดนับจริง (physical count)
inventory_diff_quantitynumericส่วนต่าง (quantity - inventory_quantity)
inventory_quantity_setbooleanเคยนับจริงหรือยัง
in_datetimestampวันที่เข้า (FIFO/FEFO)
accounting_datedateวันที่ทางบัญชี

สังเกต: 1 แถว = 1 product + 1 location + 1 lot + 1 package + 1 owner + 1 company — granular ถึงระดับ lot/serial × location × owner GENCODE ยังไม่มีตัวเทียบเท่า

available = quantity - reserved_quantity

ของที่ขายได้จริง = quantity - reserved_quantity — เป็น invariant ที่ทุก query ใช้ · reserve = เพิ่ม reserved_quantity; unreserve = ลด

Concurrency: try_lock_for_update — SKIP LOCKED (แก้ไขจาก source จริง)

จาก odoo/orm/models.py line 5590-5620 + stock_quant.py line 1082 — Odoo ป้องกัน oversell ด้วย SKIP LOCKED ไม่ใช่ NOWAIT:

-- stock_quant ใช้ NO KEY UPDATE + SKIP LOCKED
SELECT id FROM stock_quant
WHERE product_id = %s AND location_id = %s
FOR NO KEY UPDATE SKIP LOCKED

SKIP LOCKED = ถ้าแถวถูกล็อกอยู่ → ข้ามไปเลย (return empty set) ไม่รอ ไม่ error · จากนั้น _update_available_quantity เห็นว่าไม่ได้แถว → สร้างแถว quant ใหม่ สำหรับ product+location เดียวกัน · ระบบ periodic _merge_quants() จะรวมแถวซ้ำกลับเป็นแถวเดียวภายหลัง

นี่คือ optimistic creation + periodic merge — ไม่ใช่ pessimistic fail-fast:

  1. พยายามล็อกแถว quant ที่มีอยู่ (SKIP LOCKED)
  2. ล็อกได้ → update quantity/reserved_quantity ในแถวเดิม
  3. ล็อกไม่ได้ (มีคนอื่นล็อกอยู่) → สร้างแถวใหม่ (ข้ามไม่ block)
  4. _merge_quants() (background) → รวมแถวซ้ำกลับเป็นแถวเดียว

เปรียบเทียบกับ ir_sequence no_gap: numbering ใช้ FOR UPDATE NOWAIT (ถ้าชน = error ทันที → retry) เพราะเลขต้องเรียงต่อกัน สร้างซ้ำไม่ได้; แต่ inventory ใช้ SKIP LOCKED เพราะแถว quant ซ้ำรวมได้ — Odoo เลือก strategy ตาม semantic ของข้อมูล ไม่ใช่ใช้กลไกเดียวกันทุกที่

บทเรียนสำหรับ GENCODE: สต็อก = SKIP LOCKED + merge (throughput สูง, ไม่ block); เลขเอกสาร = NOWAIT + retry (ต้องไม่ซ้ำ)

NWFTH INLOC — 114 คอลัมน์ + QtyCommitSales

ฝั่ง NWFTH จาก TFCLIVE จริง — INLOC (Item-Location balance) มี 114 คอลัมน์:

คอลัมน์ชนิดเทียบ Odoo
Itemkeynvarchar(18)product_id (แต่ meaningful)
QtyOnHandfloatstock_quant.quantity
QtyCommitSalesfloatstock_quant.reserved_quantity
Location (ใน PK)stock_quant.location_id

NWFTH: available = QtyOnHand - QtyCommitSales — เหมือน Odoo ทุกประการ เป็น pattern เดียวกัน (reserve column on the balance row)

3-Layer Reservation (จาก BME warehouse apps บน BatchMaster)

NWFTH ใช้ 3 ชั้นซ้อน (จากประสบการณ์จริงของ BME custom warehouse apps):

  1. Layer 1 (80% cases): QtyCommitSales reservation — จองตอน confirm order (= Odoo reserved_quantity)
  2. Layer 2 (15%): soft-lock ด้วย advisory lock ระดับ connection — ป้องกัน 2 คนจองของเดียวกันพร้อมกัน
  3. Layer 3 (5%): optimistic retry — ถ้า race condition เกิดจริง ระบบ retry อัตโนมัติ

เปรียบเทียบ: Odoo ใช้ FOR UPDATE NOWAIT (pessimistic, fail-fast) ทั้งหมด; NWFTH ใช้ optimistic เป็นหลัก pessimistic เป็น fallback — ทั้งสองวิธีพิสูจน์แล้วว่าใช้ได้ GENCODE เลือกได้

Event-Sourced Pattern — stock_move (7 states) vs Mintxdh

stock_move มี 7 states (จาก source line 107-115 ยืนยันโดย haiku agent):

draftconfirmed/waitingpartially_available/assigneddone | cancel

_action_done (line 2112-2180) คือ core: ยืนยัน draft → ยกเลิก qty=0 → update quant ผ่าน move_line → เขียน state=done → trigger downstream moves → สร้าง backorder ถ้ายังเหลือ → สร้าง journal entry (ถ้า stock_account installed)

ทั้งสองระบบใช้ event-sourced inventory:

graph LR
  subgraph Odoo
    SM3["stock_move
(event: qty, from→to)"] --> SQ["stock_quant
(balance: quantity,
reserved_quantity)"] end subgraph NWFTH MT3["Mintxdh
(event: TrnQty, Location→ToLocation)"] --> IL3["INLOC
(balance: QtyOnHand,
QtyCommitSales)"] end
มิติOdoo stock_moveNWFTH Mintxdh
Event PKid int (surrogate)InTransID int (surrogate)
Item refproduct_id int FKItemKey varchar(18)
From/Tolocation_id/location_dest_id int FK (tree)Location/ToLocation varchar(5)
Qty typenumericfloat
GL linkaccount_move_id int FKNLAcct/INAcct nvarchar(50)
Source docorigin varchar + sale_line_id/purchase_line_id FKSysDocID nvarchar(20) + SysLinSq smallint
Trn typeis_in/is_out/is_dropship boolTrnTyp varchar(1) + TrnSubTyp varchar(3)

สรุปสำหรับ GENCODE: inventory layer ต้องมี 2 ส่วน — event table (append-only, มี from/to location, product ref, GL link, source doc traceability) + balance table (quantity, reserved_quantity, per product+location+lot). ทั้ง Odoo และ NWFTH ใช้แบบนี้ — มันคือ pattern ที่พิสูจน์แล้ว

Table Count เทียบ: Odoo 772 vs NWFTH 822 · Scale Comparison

จาก live query ทั้งสองฝั่ง:

ระบบจำนวนตาราง (live)ปรัชญา
Odoo 19 (instance นี้)772 (จาก information_schema PostgreSQL)modular — ติดตั้งแค่ที่ใช้ (9 modules installed ที่นี่)
NWFTH BatchMaster (TFCLIVE)822 (จาก INFORMATION_SCHEMA MSSQL)monolithic — ทุก module มาพร้อมกัน
GENCODE (glyph-oracle)~15master data เท่านั้น — ยังไม่มี transaction/ledger

ข้อสังเกต: ความซับซ้อนของ ERP จริงอยู่ที่ 700-800+ ตาราง ทั้งสองระบบ — GENCODE ที่ ~15 ตารางยังอยู่ในช่วง "master data" ล้วน ๆ การเติบโตเป็น ERP จริงหมายความว่าจะเพิ่มตาราง 50-100+ สำหรับ transaction/ledger/audit ที่ยังขาด

Odoo: Template / Variant Split (จาก live DB)

จาก information_schema จริง — Odoo แยกสินค้าเป็น 2 ชั้น:

product_template — 48 คอลัมน์ (สินค้าเชิงนามธรรม)

กลุ่มคอลัมน์ที่น่าสนใจชนิดจริง
Identityname, default_code, typejsonb, varchar, varchar
i18n (5 fields)description, description_sale, description_purchase, description_picking/out/injsonb ทั้งหมด
Company-dependent (7 fields)property_account_income_id, property_account_expense_id, responsible_id, property_stock_*, property_price_difference_account_idjsonb (properties)
Customproduct_propertiesjsonb (user-defined)
Pricelist_price (ราคาขาย, shared)numeric
Stockis_storable, tracking, lot_sequence_idboolean, varchar, int
Policyinvoice_policy, expense_policy, purchase_methodvarchar
Variant flaghas_configurable_attributesboolean

product_product — 12 คอลัมน์เท่านั้น (variant จริง)

คอลัมน์ชนิดบทบาท
product_tmpl_idint FK → product_template (CASCADE)ชี้ template แม่
default_codevarcharรหัสภายใน (override จาก template ได้)
barcodevarcharbarcode
combination_indicesvarcharkey ของ attribute combo ที่สร้าง variant นี้
standard_pricejsonbต้นทุนต่อบริษัท/สาขา (company-dependent)
volume / weightnumericoverride จาก template
lot_properties_definitionjsonbcustom lot fields

Key insight: template (หนัก 48 cols) ถือ config ทั้งหมด; variant (เบา 12 cols) ถือแค่ override — FK ทุกอย่างชี้ variant (product_product.id) ไม่เคยชี้ template โดยตรง

Variant Generation — 5 attribute tables

Odoo มี 5 ตารางสำหรับ variant generation (จาก information_schema): product_attribute, product_attribute_value, product_attribute_custom_value, product_attribute_product_template_rel, product_attribute_value_product_template_attribute_line_rel — Odoo สร้าง variant ทุก combination โดยอัตโนมัติ

NWFTH: INMAST Flat — 169 คอลัมน์เดียวจบ

NWFTH (BatchMaster) ไม่มี template/variant — ทุกอย่างอยู่ใน INMAST ตารางเดียว 169 คอลัมน์:

มิติINMAST (NWFTH)product_template+product (Odoo)
จำนวนคอลัมน์169 (1 ตาราง)48 + 12 = 60 (2 ตาราง)
Variantไม่มี (1 แถว = 1 item)attribute combination → auto-generate
PKItemkey nvarchar(18) (meaningful)id int (surrogate)
จัดหมวดItemtyp/ItemSubtyp nvarchar(3)categ_id FK → product.category tree
i18nภาษาเดียว (nvarchar)jsonb per-field
Per-company valueต่อแถว / ตารางแยกjsonb properties
Barcode(ในชุด Itemkey)barcode varchar

ข้อดีของ flat: query ง่าย ไม่ต้อง JOIN template←→variant · ข้อเสีย: 169 cols ใน 1 ตาราง ขยายยาก (ทุก variant ใหม่ = แถวใหม่ full 169 cols)

GENCODE: 3-Layer Architecture — CRS / SVC / PRD

GENCODE ไม่เหมือนทั้งคู่ — แยกเป็น 3 ชั้นตามบทบาท ไม่ใช่ตาม template/variant:

graph TD
  CRS["CRS · Course
(ลูกค้าซื้ออะไร)"] -->|"crs_svc_mapping"| SVC["SVC · Service
(ลูกค้าได้รับอะไร)"] SVC -->|"svc_prd_mapping (BOM)"| PRD["PRD · Product
(คลินิกใช้อะไร)"]
ชั้นบทบาทเทียบ Odooเทียบ NWFTH
CRSBilling — สิ่งที่ลูกค้าซื้อ/จ่ายsale.order.lineOELIN (sale line)
SVCDelivery — สิ่งที่ลูกค้าได้รับstock.picking (service)
PRDInventory — สิ่งที่คลินิกบริโภคproduct_product (storable)INMAST (item)

ข้อสังเกตสำคัญ: GENCODE แยกตามมุมมองธุรกิจ (billing/delivery/inventory) ไม่ใช่ตาม data structure (template/variant) — ทั้งสองแนวถูกต้อง แต่ serve คนละ use case Odoo optimize สำหรับ e-commerce (variant = สี/ไซส์); GENCODE optimize สำหรับคลินิก (course = หลายบริการ → หลายสินค้า)

JSONB Explosion — Odoo 19 Properties Pattern

ค้นพบใหม่จาก live DB: Odoo 19 ใช้ jsonb ถึง 13+ คอลัมน์ในตาราง product — ผสม 3 รูปแบบ:

  1. i18nname, description_* (5 fields): {"en_US": "...", "th_TH": "..."}
  2. Company-dependentproperty_account_*, responsible_id, standard_price (8+ fields): {"1": value_for_company_1, "2": value_for_company_2}
  3. Custom propertiesproduct_properties, lot_properties_definition: user-defined fields without schema change

เกี่ยวกับ 100 สาขาโดยตรง: company-dependent properties หมายความว่า product เดียวกันมีราคาต้นทุน/บัญชี/ผู้รับผิดชอบ ต่างกันต่อสาขา โดยไม่ต้องมีตารางแยก — 1 แถว product_product เก็บ config ของทุกสาขาไว้ใน jsonb

Pattern ที่ GENCODE ยืมได้: ราคา/ต้นทุน/config ต่อสาขาเก็บเป็น jsonb property ใน PRD code → ไม่ต้องสร้าง product_branch_config table แยก

5 Lock Dates — Period Locking ละเอียดกว่าที่คิด

จาก res_company live query — Odoo 19 มี 5 lock dates (ไม่ใช่แค่ 1!):

Lock dateชนิดป้องกันอะไร
fiscalyear_lock_datedateป้องกันแก้ไข journal entries ก่อนวันนี้ (ปิดงบ)
tax_lock_datedateป้องกันแก้ไข entries ที่มีภาษี ก่อนวันนี้ (ปิดภาษี)
sale_lock_datedateป้องกันแก้ไข sales ก่อนวันนี้
purchase_lock_datedateป้องกันแก้ไข purchases ก่อนวันนี้
hard_lock_datedateห้ามแก้เด็ดขาด (even advisors/admins)

NWFTH ไม่มี lock date ระดับ DB (บังคับที่ app layer); GENCODE ยังไม่มีเลย

สำคัญสำหรับ 100 สาขา: ถ้าสาขาหนึ่งปิดงบเดือนแล้ว ต้องไม่ให้ใครแก้ย้อนหลัง — 5 lock dates ของ Odoo แยกได้ละเอียด (ขาย/ซื้อ/ภาษี/งบ/hard) เป็น template สำเร็จ

stock_move — 21 FK Relations จริง · The Full Constellation

stock_move มี 21 FK จาก pg_constraint จริง — เชื่อมไปทุกทิศ:

FK column→ TargetON DELETE
product_idproduct_productRESTRICT
sale_line_idsale_order_lineSET NULL
purchase_line_idpurchase_order_lineSET NULL
account_move_idaccount_moveSET NULL
picking_idstock_pickingSET NULL
picking_type_idstock_picking_typeSET NULL
location_idstock_locationRESTRICT
location_dest_idstock_locationRESTRICT
warehouse_idstock_warehouseSET NULL
company_idres_companyRESTRICT
partner_idres_partnerSET NULL
product_uomuom_uomRESTRICT
rule_idstock_ruleRESTRICT
origin_returned_move_idstock_move (self)SET NULL
scrap_idstock_scrapSET NULL
orderpoint_idstock_warehouse_orderpointSET NULL
+ 5 morecreate/write_uid, location_final, packaging_uom, restrict_partnerSET NULL

21 FK ทุกตัวมี ON DELETE — stock_move คือ junction table ที่เชื่อม ERP ทั้งระบบ: ขาย + ซื้อ + สต็อก + บัญชี + warehouse + partner + return + scrap · GENCODE ยังไม่มี movement table — เมื่อสร้าง ต้อง FK discipline แบบนี้ตั้งแต่วันแรก

sale_order_line ก็มี analytic_distribution jsonb (46 cols live) — analytic distribution ไหลตั้งแต่ใบขาย → stock_move → account_move_line ครบ chain