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 |
|---|---|---|
| Identity | id, name, ref, state, move_type | int, varchar, varchar, varchar, varchar |
| Sequence | sequence_number int, sequence_prefix, secure_sequence_number int, inalterable_hash | ป้องกันการปลอมแปลง |
| Relations | journal_id, company_id, partner_id, currency_id, fiscal_position_id | int FK ทั้งหมด |
| Invoice | invoice_date, invoice_date_due, invoice_origin, invoice_source_email, payment_reference | date, varchar |
| Amounts (11 ตัว!) | amount_untaxed, amount_tax, amount_total, amount_residual, + _signed/_in_currency_signed variants | numeric ทั้งหมด |
| State flags | posted_before, made_sequence_gap, is_manually_modified, checked, is_move_sent | boolean |
| Auto-post | auto_post varchar, auto_post_until date, auto_post_origin_id int | recurring entries |
| Marketing | campaign_id, source_id, medium_id, team_id | UTM tracking ในเอกสารบัญชี |
| Data | sending_data jsonb, narration text | e-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 chainsecure_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-entry | debit, credit, balance | numeric ทั้งหมด — balance = debit - credit |
| Reconciliation | reconciled bool, full_reconcile_id, matching_number, amount_residual/_currency | จับคู่ invoice กับ payment อัตโนมัติ |
| Parent state | parent_state varchar | denormalized — สำเนา state จาก account_move (เร็วกว่า JOIN) |
| Product link | product_id, product_uom_id, quantity, price_unit, discount | FK → product_product + detail |
| Analytic | analytic_distribution jsonb | การกระจายต้นทุน → ใจความสำคัญ (ด้านล่าง) |
| Tax | tax_line_id, tax_group_id, tax_repartition_line_id, tax_base_amount | ภาษีระดับบรรทัด |
| Display | display_type varchar | product / line_section / line_note — กรอง accountable lines ด้วย display_type='product' |
| COGS | cogs_origin_id int | Cost of Goods Sold traceability |
| Purchase | purchase_line_id int | FK → 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_value— field-level change tracking:field_id,old_value_char/_integer/_float,new_value_char/_integer/_float,field_infojsonb
วิธีใช้: model ที่ _inherit = 'mail.thread' + field ที่มี tracking=True → ทุกการเปลี่ยนแปลงถูกบันทึกอัตโนมัติ แสดงเป็น timeline ใน UI
| มิติ | Odoo mail.thread | NWFTH ตาราง H | GENCODE audit_log |
|---|---|---|---|
| ระดับ | Field-level (รู้ว่าฟิลด์ไหนเปลี่ยนจากอะไรเป็นอะไร) | Document-level (เก็บเอกสารก่อนแก้ทั้งแถว) | Event-level |
| ฟื้นสถานะเดิมได้? | ได้บางส่วน (ต้องรวม tracking values) | ได้ครบ (แถว H คือ snapshot เต็ม) | ได้บางส่วน |
| ต้นทุนจัดเก็บ | ต่ำ (เก็บแค่ delta) | สูง (ซ้ำทั้งแถว) | ต่ำ |
| Tamper-proof | ไม่ (แก้ mail_message ตรงได้) | ไม่ (แก้ตาราง H ตรงได้) | ไม่ |
| โบนัส | ฟรีเมื่อ inherit | Nothing-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:
| ชื่อ · Name | Code | Implementation | Prefix | Pad | company_id |
|---|---|---|---|---|---|
| Sales Order | sale.order | standard | S | 5 | — |
| Purchase Order | purchase.order | standard | P | 5 | — |
| Payment | account.payment | standard | PAY | 5 | — |
| Group Payments | — | no_gap | GROUP/%(year)s/ | 5 | — |
| Picking IN | — | standard | WH/IN/ | 5 | 1 |
| Picking OUT | — | standard | WH/OUT/ | 5 | 1 |
| Picking INT | stock.picking | standard | INT/ | 5 | — |
| Serial Numbers | stock.lot.serial | standard | (blank) | 7 | — |
| Scrap | stock.scrap | standard | SP/ | 5 | 1 |
สังเกต: เกือบทั้งหมดเป็น 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)"]
ความต่าง:
| มิติ | Odoo | NWFTH |
|---|---|---|
| FK ระหว่าง module | integer surrogate + ON DELETE | meaningful key (Itemkey/Ordno) + app-level |
| Document traceability | stock_move.sale_line_id (direct FK) | Mintxdh.SysDocID (text ref to Ordno) |
| GL link | stock_move.account_move_id (FK) | Mintxdh.NLAcct/INAcct (GL codes inline) |
| History | mail_tracking_value (field delta) | ตาราง H (full row copy) |
| Location model | location_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_id | int FK | ชี้กลับ sale_order_line — line-level traceability |
purchase_line_id | int FK | ชี้กลับ purchase_order_line |
account_move_id | int FK | journal entry ที่สร้างจาก move นี้ (สมุดสต็อก → สมุดเงิน) |
value | numeric | มูลค่าสินค้าที่เคลื่อน (stock valuation) |
is_in / is_out / is_dropship | boolean | ทิศทาง — รับ/ส่ง/ส่งตรง |
origin | varchar | เอกสารต้นทาง (เช่น SO00001) |
reservation_date | date | วันที่จอง |
picked | boolean | หยิบของแล้วหรือยัง |
propagate_cancel | boolean | ยกเลิก move นี้ → ยกเลิก move ถัดไปด้วย |
warehouse_id | int 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:
- ตาราง Order — sale_order equivalent (ลูกค้าซื้อ CRS course) ที่อ้าง
fullCodeเป็น FK - ตาราง Movement — stock_move equivalent (ตัดสต็อก PRD ตาม BOM ใน
svc_prd_mapping) - ตาราง Journal Entry — account_move equivalent (debit/credit, move_type polymorphic)
- FK chain ที่ชัด — order → movement → journal entry → account, พร้อม ON DELETE discipline
- 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 คอลัมน์:
| คอลัมน์ | ชนิด | บทบาท |
|---|---|---|
id | int PK | surrogate |
product_id | int FK | → product_product(id) |
location_id | int FK | ตำแหน่งในคลัง |
lot_id | int FK | Lot/Serial number |
company_id | int FK | บริษัท/สาขา |
package_id | int FK | กล่อง/แพ็ค (consignment) |
owner_id | int FK | เจ้าของ (consignment tracking) |
quantity | numeric | ยอดจริงในมือ |
reserved_quantity | numeric | ยอดที่จองไว้ (confirmed SO ที่ยังไม่ส่ง) |
inventory_quantity | numeric | ยอดนับจริง (physical count) |
inventory_diff_quantity | numeric | ส่วนต่าง (quantity - inventory_quantity) |
inventory_quantity_set | boolean | เคยนับจริงหรือยัง |
in_date | timestamp | วันที่เข้า (FIFO/FEFO) |
accounting_date | date | วันที่ทางบัญชี |
สังเกต: 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:
- พยายามล็อกแถว quant ที่มีอยู่ (SKIP LOCKED)
- ล็อกได้ → update
quantity/reserved_quantityในแถวเดิม - ล็อกไม่ได้ (มีคนอื่นล็อกอยู่) → สร้างแถวใหม่ (ข้ามไม่ block)
_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 |
|---|---|---|
Itemkey | nvarchar(18) | product_id (แต่ meaningful) |
QtyOnHand | float | stock_quant.quantity |
QtyCommitSales | float | stock_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):
- Layer 1 (80% cases):
QtyCommitSalesreservation — จองตอน confirm order (= Odooreserved_quantity) - Layer 2 (15%): soft-lock ด้วย advisory lock ระดับ connection — ป้องกัน 2 คนจองของเดียวกันพร้อมกัน
- 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):
draft → confirmed/waiting → partially_available/assigned → done | 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_move | NWFTH Mintxdh |
|---|---|---|
| Event PK | id int (surrogate) | InTransID int (surrogate) |
| Item ref | product_id int FK | ItemKey varchar(18) |
| From/To | location_id/location_dest_id int FK (tree) | Location/ToLocation varchar(5) |
| Qty type | numeric | float |
| GL link | account_move_id int FK | NLAcct/INAcct nvarchar(50) |
| Source doc | origin varchar + sale_line_id/purchase_line_id FK | SysDocID nvarchar(20) + SysLinSq smallint |
| Trn type | is_in/is_out/is_dropship bool | TrnTyp 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) | ~15 | master 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 คอลัมน์ (สินค้าเชิงนามธรรม)
| กลุ่ม | คอลัมน์ที่น่าสนใจ | ชนิดจริง |
|---|---|---|
| Identity | name, default_code, type | jsonb, varchar, varchar |
| i18n (5 fields) | description, description_sale, description_purchase, description_picking/out/in | jsonb ทั้งหมด |
| Company-dependent (7 fields) | property_account_income_id, property_account_expense_id, responsible_id, property_stock_*, property_price_difference_account_id | jsonb (properties) |
| Custom | product_properties | jsonb (user-defined) |
| Price | list_price (ราคาขาย, shared) | numeric |
| Stock | is_storable, tracking, lot_sequence_id | boolean, varchar, int |
| Policy | invoice_policy, expense_policy, purchase_method | varchar |
| Variant flag | has_configurable_attributes | boolean |
product_product — 12 คอลัมน์เท่านั้น (variant จริง)
| คอลัมน์ | ชนิด | บทบาท |
|---|---|---|
product_tmpl_id | int FK → product_template (CASCADE) | ชี้ template แม่ |
default_code | varchar | รหัสภายใน (override จาก template ได้) |
barcode | varchar | barcode |
combination_indices | varchar | key ของ attribute combo ที่สร้าง variant นี้ |
standard_price | jsonb | ต้นทุนต่อบริษัท/สาขา (company-dependent) |
volume / weight | numeric | override จาก template |
lot_properties_definition | jsonb | custom 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 |
| PK | Itemkey 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 |
|---|---|---|---|
| CRS | Billing — สิ่งที่ลูกค้าซื้อ/จ่าย | sale.order.line | OELIN (sale line) |
| SVC | Delivery — สิ่งที่ลูกค้าได้รับ | stock.picking (service) | — |
| PRD | Inventory — สิ่งที่คลินิกบริโภค | 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 รูปแบบ:
- i18n —
name,description_*(5 fields):{"en_US": "...", "th_TH": "..."} - Company-dependent —
property_account_*,responsible_id,standard_price(8+ fields):{"1": value_for_company_1, "2": value_for_company_2} - Custom properties —
product_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_date | date | ป้องกันแก้ไข journal entries ก่อนวันนี้ (ปิดงบ) |
tax_lock_date | date | ป้องกันแก้ไข entries ที่มีภาษี ก่อนวันนี้ (ปิดภาษี) |
sale_lock_date | date | ป้องกันแก้ไข sales ก่อนวันนี้ |
purchase_lock_date | date | ป้องกันแก้ไข purchases ก่อนวันนี้ |
hard_lock_date | date | ห้ามแก้เด็ดขาด (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 | → Target | ON DELETE |
|---|---|---|
product_id | product_product | RESTRICT |
sale_line_id | sale_order_line | SET NULL |
purchase_line_id | purchase_order_line | SET NULL |
account_move_id | account_move | SET NULL |
picking_id | stock_picking | SET NULL |
picking_type_id | stock_picking_type | SET NULL |
location_id | stock_location | RESTRICT |
location_dest_id | stock_location | RESTRICT |
warehouse_id | stock_warehouse | SET NULL |
company_id | res_company | RESTRICT |
partner_id | res_partner | SET NULL |
product_uom | uom_uom | RESTRICT |
rule_id | stock_rule | RESTRICT |
origin_returned_move_id | stock_move (self) | SET NULL |
scrap_id | stock_scrap | SET NULL |
orderpoint_id | stock_warehouse_orderpoint | SET NULL |
| + 5 more | create/write_uid, location_final, packaging_uom, restrict_partner | SET 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