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

Executive Blueprint: จากภาพรวมสู่แผนธุรกิจ

สามมุมมอง สามระดับ — Executive Summary สำหรับทีม, Blueprint สำหรับนักพัฒนา, Advisory Guide สำหรับ CEO — ครบจากภาพรวมสู่แผนธุรกิจ · Three views: team summary, developer blueprint, CEO advisory — the complete picture.

สารบัญ · Table of Contents

30-Second TL;DR

GENCODE วันนี้คือ Product Master Data — ระบบตั้งชื่อทุกสิ่งที่คลินิกขายและใช้ ให้ทุกระบบเข้าใจตรงกัน ทำได้ดีแล้ว

เป้าหมาย: ต่อยอดเป็น ERP Foundation ที่รองรับ 100 สาขา โดยเพิ่ม 2 Ledger ที่ยังขาด — Inventory Ledger (บันทึกของเข้า-ออก) และ Accounting Ledger (บันทึกเงินเข้า-ออก แบบ Double-Entry)

ไม่ต้องคิดเอง — Odoo (Global ERP, 15 ปี, ผู้ใช้หลายสิบล้าน, 772 tables) และ BatchMaster (ERP จริงที่ NWFTH ใช้มาหลายปี, 822 tables) พิสูจน์แล้วว่าโครงสร้างแบบไหนรอด เรา adapt ไม่ reinvent

GENCODE คืออะไร · What GENCODE Is

นึกภาพ Intelligent Barcode ที่อ่านแล้วเข้าใจทันที — ไม่ใช่ตัวเลขไร้ความหมาย แต่บอกได้ว่า "คอร์สเสริมจมูก เทคนิคเซมิ หมอโด" ระบบแบ่ง 3 ชั้น (3-Layer Architecture):

LayerRoleทำอะไร
CRS (Course)Billing — สิ่งที่ลูกค้าจ่ายเช่น คอร์สเสริมจมูก
SVC (Service)Delivery — สิ่งที่ลูกค้าได้รับเช่น หัตถการฉีดฟิลเลอร์
PRD (Product)Inventory — สิ่งที่คลินิกบริโภคเช่น ฟิลเลอร์ 1 cc

จุดแข็ง: Semantic Code (รหัสที่มีความหมาย) — พนักงานพูดทางโทรศัพท์ได้ เทียบกับ JERA POS ได้ จำง่าย แม้แต่ ERP ระดับโลกอย่าง BatchMaster ก็ใช้ Meaningful Key (Itemkey 18 ตัวอักษร) เหมือนกัน — แนวทางนี้ถูกต้อง (ch33, ch35 verified)

สิ่งที่ต้องเพิ่ม: Two Ledgers · The Missing Pieces

GENCODE วันนี้เป็น Master Data ล้วน ๆ — รู้ว่ามีอะไรขาย แต่ไม่รู้ "ของเหลือเท่าไหร่" และ "เงินเข้าออกเท่าไหร่" ต้องเพิ่ม 2 Ledger ที่ทั้ง Odoo และ NWFTH มี:

graph LR
  A["ลูกค้าซื้อ Course"] --> B["Inventory Ledger
(ตัด stock ตาม BOM)"] A --> C["Accounting Ledger
(บันทึก Debit = Credit)"] B --> D["รู้ของคงเหลือจริง
real-time"] C --> E["รู้ Revenue per Doctor
per Branch"] G["GENCODE
(Master Data)"] -.-> A G -.-> B G -.-> C
LedgerOdoo (proven)NWFTH (proven)GENCODE (ต้องสร้าง)
Inventory Ledgerstock_move (Event) → stock_quant (Balance)
+ reserved_quantity (Reservation)
Mintxdh (Event) → INLOC (Balance)
+ QtyCommitSales (Reservation)
ยังไม่มี
(มี svc_prd_mapping เป็น seed)
Accounting Ledgeraccount_move (72 cols, Double-Entry)
+ inalterable_hash (Tamper-Proof)
Mintxdh.NLAcct/INAcct (GL Bridge)
+ APHDR/APLIN (AP)
ยังไม่มี

ข้อดีของ GENCODE: svc_prd_mapping (Bill of Materials: บริการใช้สินค้าอะไรบ้าง) คือ seed ของ Inventory Ledger — เหลือแค่เติม Movement Events เข้าไป (ch31, ch38)

Scalability: 100 Branches · โตถึง 100 สาขา

เมื่อ 100 สาขาส่ง transaction เข้าฐานข้อมูลกลางพร้อมกัน มี 3 จุดวิกฤต:

1. Sequence Generation (P0 — ต้องแก้ก่อนเพื่อน)

ตอนนี้: nextJera() = SELECT MAX(id)+1 — read-modify-write ไม่มี lock (ch30, ch32)

วิธีแก้จาก Odoo: PostgreSQL IDENTITY (lock-free, ไม่แย่ง) สำหรับ internal ID + no_gap sequence (FOR UPDATE NOWAIT, ir_sequence.py line 58-64) สำหรับเลขที่กฎหมายบังคับต่อเนื่อง

2. Inventory Concurrency

Odoo: SKIP LOCKED + Optimistic Creation + Periodic Merge — ถ้าล็อกแถวไม่ได้ สร้างแถวใหม่แทน แล้ว merge ทีหลัง (ch39 corrected)

NWFTH: 3-Layer Reservation — Reserve 80% → Soft-Lock 15% → Optimistic Retry 5%

3. Branch Isolation

Odoo: company_id + parent_id hierarchy (child_ids = "Branches", res_company.py line 37) + ir.rule Row-Level Security (auto-inject WHERE clause) + 5 Lock Dates (fiscal / tax / sale / purchase / hard) (ch41)

GENCODE ควรใช้ PostgreSQL RLS (Row-Level Security ที่ DB-level) แทน Odoo ir.rule (app-level) — ปลอดภัยกว่า direct SQL bypass ไม่ได้

graph TB
  B1["Branch 1"] --> DB
  B2["Branch 2"] --> DB
  B3["..."] --> DB
  B100["Branch 100"] --> DB
  DB["Central Database
PostgreSQL + RLS"] DB --> R["Revenue per Doctor
per Branch
real-time"]

Maintainability: Long-Term Health · ดูแลง่ายระยะยาว

  • Modular Architecture — GENCODE โฟกัสที่ Master Data; Inventory / Accounting / Sales เป็น Module แยก เหมือน Odoo ที่แยก Sale / Stock / Account — แก้ตรงไหนไม่กระทบทั้งระบบ
  • Append-Only Ledger — ทุกการเคลื่อนไหวบันทึกแบบเพิ่มอย่างเดียว ไม่ลบ ตรวจสอบย้อนหลังได้ NWFTH ใช้ History Tables (full-row copy ทุกเอกสาร); Odoo ใช้ Field-Level Tracking (mail.thread, 50 tables) + Hash Chain (inalterable_hash = tamper-proof, ch37)
  • FK Discipline — Odoo มี ON DELETE (CASCADE/RESTRICT/SET NULL) ทุก FK (stock_move มี 21 FK!, ch40) GENCODE ยังไม่มี ON DELETE เลย — ต้องเพิ่มตั้งแต่วันแรก
  • Proven Patterns — 12 Odoo patterns พร้อม adapt (ch31): Polymorphic Document, Double-Entry Invariant, Event-Sourced Inventory, Reservation Column, Audit Mixin, ir.sequence, Record Rules, POS Session Batching ฯลฯ

Numeric Precision: Odoo ใช้ numeric; NWFTH ใช้ decimal(22,6); GENCODE ยังเป็น float4 — ต้องเปลี่ยนเป็น numeric ก่อนเริ่มบันทึกเงิน (ch30)

User Experience: Smart Code + Search · คนใช้ชอบไหม

Semantic Code = UX Strength — พนักงานพูดทางโทรศัพท์ได้ เทียบกับ JERA POS ได้ จำง่าย (ch33)

สิ่งที่ควรเพิ่ม (จาก Odoo): Name-Based Search + Filter UI — กดกรอง "หมอโด" "หมวดจมูก" ด้วยปุ่มที่มีสี ไม่ต้องพิมพ์รหัสยาว รหัสไว้ให้คนที่อยากอ้างอิงเร็ว ๆ

Analytic Distribution (ch37, จาก live DB): Odoo เก็บ JSONB บนทุก Journal Line — กระจาย Revenue ต่อหมอ/ต่อสาขาอัตโนมัติ + มี Auto-Rules ที่ set distribution ตาม product โดยไม่ต้องเลือกมือ GENCODE ใช้ segment (TYPE/CATEGORY) map เข้า Analytic Plan ได้ทันที = Revenue per Doctor ฟรี

คำเตือนสำคัญ (ch37): ถ้าใช้ POS Session Batching (รวมบิลเป็น 1 Journal Entry ตอนปิดรอบ) ต้อง set Analytic Distribution ก่อน Session Close — ไม่งั้น per-doctor granularity จะสูญ

The Roadmap · เส้นทางระยะยาว

graph LR
  T1["Today
Master Data
(Product Catalog)
GENCODE ready"] --> T2["Near-Term
+ Fix Sequence Generation
+ Add Inventory Ledger
+ Add Accounting Ledger"] T2 --> T3["Long-Term
Full ERP · 100 Branches
Sale / Purchase / Inventory
/ Accounting / POS"]
PhaseActionBusiness Outcome
TodayGENCODE Master Data พร้อมใช้ทุกระบบเรียกของชิ้นเดียวกันถูกต้อง Unified Product Language
Near-TermFix Sequence (PG IDENTITY + no_gap) + Inventory Ledger + Accounting Ledgerรองรับหลายสาขา + เห็น Stock Balance / Profit per Doctor / per Branch แบบ real-time
Long-TermFull ERP: Sale / Purchase / Inventory / Accounting / POS / Branch Isolationระบบเดียวคุม 100 สาขา ด้วย Single Central Database + Row-Level Security

Evidence Base · ที่มาของข้อมูล

ทุกข้อสรุปในบทนี้มาจาก live database query ไม่ใช่จากความจำ:

SourceMethodKey Numbers
Odoo 19Live PostgreSQL 18 (tni-db MCP) + Source Code (odoo19-core, 624 addons)772 tables, 9 modules installed, pg_constraint FK/PK verified
NWFTH BatchMasterLive TFCLIVE MSSQL (nwfth-sql MCP, SELECT only)822 tables, INFORMATION_SCHEMA columns verified
GENCODESource code (glyph-oracle + YD_GENCODE repos, cloned to ghq)~15 tables, Supabase PostgreSQL

สิ่งที่ live query เปิดเผย (ที่อ่าน source ไม่เห็น): product_template.name = JSONB (i18n), product_product.standard_price = JSONB (per-company cost), stock_quant ใช้ SKIP LOCKED (ไม่ใช่ NOWAIT), account_move มี 72 columns + inalterable_hash (tamper-proof chain), 50 mail_* tables = audit subsystem (ch35-38)

Bottom Line · บรรทัดสรุป

  1. Strength: GENCODE เป็น Semantic Product Master ที่ดี — Meaningful Key approach ถูกต้อง (แม้แต่ BatchMaster ก็ใช้ Meaningful Itemkey)
  2. P0 Fix: เปลี่ยน nextJera() เป็น PostgreSQL IDENTITY ก่อนขยายสาขา — Sequence Generation คือ Scalability Bottleneck ชิ้นเดียว
  3. Two Ledgers: Inventory Ledger (Event-Sourced) + Accounting Ledger (Double-Entry) ที่ทำให้เป็น ERP จริง — Odoo + NWFTH พิสูจน์โครงสร้างนี้มาแล้ว adapt ไม่ reinvent
  4. Branch Model: Single Central Database + company_id/branch_id + PostgreSQL Row-Level Security (ดีกว่า Odoo ir.rule ที่เป็น app-level) + 5 Lock Dates
  5. Revenue per Doctor: Analytic Distribution (JSONB per Journal Line) + Auto-Rules = ได้ Revenue attribution ฟรี ตั้งแต่ระบบ Ledger เสร็จ
  6. Cost of Delay: ยิ่งขยายสาขาก่อนแก้ Sequence + เพิ่ม Ledger ยิ่งแก้ทีหลังยากและแพง — แก้ตอนยังเล็กถูกและปลอดภัย

Blueprint Overview — วันนี้ vs เป้าหมาย

GENCODE วันนี้มี ~15 tables ทั้งหมดเป็น Master Data — ยังไม่มี Transaction, Movement, หรือ Ledger ใด ๆ บทนี้ออกแบบเส้นทางจาก Master Data ไปสู่ Full ERP:

graph LR
  subgraph TODAY["Today: Master Data Only (~15 tables)"]
    CRS["CRS
Course Registry"] SVC["SVC
Service Registry"] PRD["PRD
Product Registry"] CRS -->|"crs_svc_mapping"| SVC SVC -->|"svc_prd_mapping
(BOM)"| PRD end subgraph TARGET["Target: Full ERP (4 Modules + 2 Ledgers)"] SO["Sales Module"] PO["Purchasing Module"] INV["Inventory Ledger
(Event-Sourced)"] ACC["Accounting Ledger
(Double-Entry)"] SO --> INV SO --> ACC PO --> INV PO --> ACC INV -.->|"journal_entry_id FK"| ACC end TODAY -->|"Phase 0-5"| TARGET
DimensionToday (GENCODE)Target (ERP)Proven By
Tables~15~40-60Odoo 772 / NWFTH 822
Transaction tables08+ (orders, moves, entries)ch37-ch40
Ledgers02 (Inventory + Accounting)ch30, ch37, ch38
Concurrency handlingnextJera MAX+1 (broken)SKIP LOCKED + PG IDENTITYch32, ch38
Branch isolationcore_branches (unused)branch_id + PostgreSQL RLSch40
Money precisionreal (float4)numericch30
FK disciplineNo ON DELETECASCADE/RESTRICT/SET NULL every FKch35, ch39

Module 1: Sales (ระบบขาย) — The Revenue Side

Tables to Create

TableRoleKey ColumnsProven By
sale_orderSales Order Headerid PK IDENTITY, name (sequence), customer_id FK, branch_id FK, state (draft/confirmed/done/cancel), date_order, amount_total numericOdoo sale_order (52 cols)
sale_order_lineSales Order Detailorder_id FK CASCADE, crs_code FK → courseRegistry.fullCode, quantity numeric, price_unit numeric, analytic_distribution JSONBOdoo sale_order_line (51 cols)

Auto-BOM Explosion — จุดแข็งเฉพาะของ GENCODE

เมื่อ confirm Sale Order ที่ขาย CRS course:

  1. อ่าน crs_svc_mapping → ได้ SVC services ที่ต้องส่งมอบ
  2. อ่าน svc_prd_mapping (Bill of Materials) → ได้ PRD items + quantity ที่ต้องตัดสต็อก
  3. สร้าง inventory_move per PRD item (ตัด stock อัตโนมัติ)
  4. สร้าง journal_entry (บันทึก Revenue + COGS)

นี่คือสิ่งที่ svc_prd_mapping ถูกออกแบบมาเพื่อทำ — BOM พร้อมใช้อยู่แล้ว เหลือแค่ trigger chain

Analytic Distribution — Revenue per Doctor ฟรี

ทุก sale_order_line ถือ analytic_distribution JSONB — set โดย Auto-Rule ตาม GENCODE segment:

  • GENCODE TYPE segment → Analytic Plan "Service Type"
  • GENCODE CATEGORY segment (doctor) → Analytic Plan "Provider"
  • ตัวอย่าง: {"doctor_account_42": 100} = 100% Revenue ไปหมอ A

คำเตือน POS (ch36): ถ้าใช้ POS Session Batching (หลายบิล → 1 Journal Entry ตอนปิดรอบ) ต้อง set analytic_distribution บน POS line ก่อน session close — ไม่งั้น per-doctor granularity สูญ

Sales 3-Way Matching — สั่ง ↔ ส่งมอบ ↔ เก็บเงิน

ฝั่งซื้อมี 3-way matching (PO ↔ Receipt ↔ Vendor Bill) — ฝั่งขายก็มี และสำหรับคลินิกยิ่งสำคัญกว่าเพราะ BOM explosion ทำให้ 1 การขายกระจายเป็นหลายบริการ + หลายสินค้า:

DocumentMeaningVerify
Sale Order (ใบสั่งขาย)ลูกค้าซื้อ CRS course อะไร จำนวนเท่าไหร่product_uom_qty = สิ่งที่สัญญาว่าจะให้
Delivery / Service CompletionSVC ที่ทำจริง + PRD ที่ตัด stock จริง (ผ่าน BOM)qty_delivered = สิ่งที่ส่งมอบจริง
Customer Invoice (ใบแจ้งหนี้)เงินที่เรียกเก็บลูกค้าqty_invoiced = สิ่งที่เก็บเงินแล้ว

Invariant: qty_deliveredproduct_uom_qty (ส่งมอบไม่เกินที่สั่ง) + qty_invoicedqty_delivered (เก็บเงินไม่เกินที่ส่งมอบ) — anomaly ใดก็ตาม = flag ทันที

Odoo พิสูจน์ด้วย FK จริง (live-queried): stock_move.sale_line_id → sale_order_line (Delivery ↔ Order) + Invoice flow links back to sale_order_line — ทั้ง 3 ค่า (product_uom_qty, qty_delivered, qty_invoiced) อยู่บน sale_order_line (46 cols, live DB)

สำหรับคลินิก (เฉพาะ GENCODE): 1 Sale Order (CRS course) → BOM explosion → หลาย Delivery lines (SVC services + PRD items) — 3-way matching ต้อง aggregate ระดับ BOM: "ขาย 1 course → ส่งมอบครบ 3 services + ตัด stock 5 items → เก็บเงินครบ" ถ้า service ใดยังไม่ส่งมอบ → qty_delivered ต่ำกว่า → invoice ไม่ครบ → dashboard เตือน

graph LR
  SO3["Sale Order
product_uom_qty = 1 course"] --> BOM3["BOM Explosion
3 SVC + 5 PRD"] BOM3 --> DEL["Delivery
qty_delivered per SVC/PRD"] DEL --> INV["Invoice
qty_invoiced"] SO3 -.->|"Match: ordered = delivered = invoiced?"| CHECK["3-Way Check
flag anomaly"]

Module 2: Purchasing (ระบบซื้อ) — The Supply Side

Tables to Create

TableRoleKey ColumnsProven By
purchase_orderPurchase Order Headerid PK, vendor_id FK, branch_id FK, state (draft/confirmed/received/billed/cancel), date_order, amount_total numericOdoo purchase_order (40 cols)
purchase_order_linePurchase Order Detailorder_id FK CASCADE, prd_code FK → productRegistry.fullCode, quantity numeric, price_unit numericOdoo purchase_order_line (34 cols)

Receiving → Inventory + Accounts Payable

เมื่อรับของ:

  1. สร้าง inventory_move (inbound: supplier location → warehouse location)
  2. Update inventory_balance (+quantity)
  3. สร้าง journal_entry (type = purchase_bill) → Debit Inventory, Credit AP

3-Way Matching — PO ↔ Receipt ↔ Vendor Bill

Odoo พิสูจน์ว่าใช้ได้ด้วย FK ตรง: journal_entry_line.purchase_line_id → purchase_order_line(id) — ทำให้ match บรรทัด PO กับบรรทัด bill ได้โดยตรง ไม่ต้อง manual reconcile

NWFTH: POHDR (129 cols) / POLIN (113 cols) → receive → Mintxdh (inventory + GL) → APHDR/APLIN (AP) — flow เดียวกัน table ชื่อต่าง

Module 3: Inventory (ระบบสต็อก) — Event-Sourced Ledger

Tables to Create

TableRoleKey ColumnsProven By
inventory_moveEvent (Append-Only)id PK IDENTITY, product_id FK RESTRICT, from_location_id FK, to_location_id FK, quantity numeric, state (draft/confirmed/assigned/done/cancel), source_doc_type, source_doc_id, sale_line_id FK, purchase_line_id FK, journal_entry_id FK SET NULLOdoo stock_move (52 cols, 21 FK)
inventory_balanceRunning Balanceproduct_id + location_id + lot_id + branch_id (composite), quantity numeric, reserved_quantity numericOdoo stock_quant (20 cols)

Event-Sourced Pattern

inventory_move = append-only event log; inventory_balance = derived running total ทั้ง Odoo และ NWFTH ใช้ pattern เดียวกัน:

graph LR
  SALE["Sale confirmed"] --> MOVE["inventory_move
(event: -qty from warehouse)"] RECEIVE["Goods received"] --> MOVE2["inventory_move
(event: +qty to warehouse)"] MOVE --> BAL["inventory_balance
quantity += delta"] MOVE2 --> BAL MOVE --> JE["journal_entry
(COGS / Inventory)"]

Reservation — available = quantity - reserved_quantity

เมื่อ confirm Sale Order → เพิ่ม reserved_quantity ใน inventory_balance ของที่ขายได้จริง = quantity - reserved_quantity

Concurrency — SKIP LOCKED (ไม่ใช่ NOWAIT)

จาก Odoo source (corrected ch38): ใช้ SELECT ... FOR NO KEY UPDATE SKIP LOCKED

  • ล็อกแถว balance ได้ → update quantity/reserved in-place
  • ล็อกไม่ได้ (มีคนอื่นล็อกอยู่) → สร้างแถวใหม่ (skip, don't block)
  • Periodic merge_balances() → รวมแถวซ้ำกลับเป็นแถวเดียว

ใช้กับ inventory เท่านั้น — Sequence numbering ใช้ FOR UPDATE NOWAIT (ต่างกัน เพราะเลขซ้ำไม่ได้ แต่แถว balance ซ้ำรวมได้)

BOM-Driven Consumption — svc_prd_mapping คือ seed

GENCODE มี svc_prd_mapping อยู่แล้ว (SVC → PRD + quantity) — นี่คือ Bill of Materials ที่ระบุว่าบริการแต่ละตัวใช้สินค้าอะไรเท่าไหร่ เมื่อ deliver SVC → auto-create inventory_move per PRD item ตาม BOM

Module 4: Accounting (ระบบบัญชี) — Double-Entry Ledger

Tables to Create

TableRoleKey ColumnsProven By
journal_entryPolymorphic Documentid PK IDENTITY, name (sequence, no_gap), move_type (sale_invoice/purchase_bill/payment/entry/credit_note/debit_note), state (draft/posted/cancel), branch_id FK, date, amount_total numeric, inalterable_hash, secure_sequence_numberOdoo account_move (72 cols)
journal_entry_lineDebit/Credit Linesentry_id FK CASCADE, account_id FK RESTRICT, debit numeric, credit numeric, balance numeric (= debit - credit), product_id FK, analytic_distribution JSONB, reconciled boolean, amount_residual numeric, sale_line_id FK, purchase_line_id FKOdoo account_move_line (66 cols)
account_accountChart of Accountsid PK, code varchar unique, name JSONB (i18n), type (asset/liability/equity/income/expense)Odoo account_account (16 cols)

Polymorphic Document — 1 Table, Many Types

journal_entry.move_type discriminator (จาก Odoo account_move.move_type line 142-159):

move_typeDocument Typeเมื่อไหร่
entryJournal Entry (ทั่วไป)Manual adjustment
sale_invoiceCustomer Invoice (ใบแจ้งหนี้)เมื่อขาย CRS course
sale_creditCustomer Credit Noteเมื่อ refund
purchase_billVendor Bill (ใบวางบิล)เมื่อรับ invoice จาก supplier
purchase_creditVendor Credit Noteเมื่อ supplier refund
paymentPayment (การชำระ)เมื่อรับ/จ่ายเงิน

Double-Entry Invariant — Σdebit = Σcredit

ทุก journal_entry ต้องผ่าน check_balanced ก่อน post:

-- Invariant (Odoo account_move.py line 2755-2770)
IF SUM(debit) != SUM(credit) THEN
  RAISE 'The entry is not balanced.'
END IF

ถ้า Σdebit ≠ Σcredit → reject ทันที ไม่ save ไม่ post — invariant นี้ทำให้บัญชีถูกต้องเสมอ โดยไม่ต้อง reconcile ทีหลัง

Tamper-Proof Hash Chain

ทุก posted entry ถูก hash ต่อจาก entry ก่อนหน้า (inalterable_hash) — ถ้ามีใครแก้ entry เก่า hash chain จะแตก ตรวจจับได้ทันที สำคัญสำหรับ e-Tax compliance ไทย

Reconciliation — Invoice ↔ Payment Matching

journal_entry_line.reconciled boolean + amount_residual numeric — เมื่อ payment match invoice ครบ → reconciled = true, amount_residual = 0 ทำได้อัตโนมัติ (match by partner + amount) หรือ manual

Infrastructure: Connective Tissue — สิ่งที่ผูก 4 Module เข้าด้วยกัน

ComponentWhatHowProven By
Sequence Serviceฆ่า nextJera() ทันทีPG IDENTITY (lock-free) สำหรับ internal ID + no_gap sequence (FOR UPDATE NOWAIT, ir_sequence.py:58-64) สำหรับเลขที่กฎหมายบังคับต่อเนื่อง Shard per (branch, type, period)ch32, ch36
Branch Isolation100 สาขาใน 1 DBbranch_id FK on every table + PostgreSQL RLS (Row-Level Security) — DB-level ปลอดภัยกว่า Odoo ir.rule (app-level) res_company.child_ids = 'Branches' (line 37)ch40
5 Lock DatesPeriod Locking per domainfiscal_lock_date, tax_lock_date, sale_lock_date, purchase_lock_date, hard_lock_date — ห้ามแก้ entry ก่อนวันล็อก (จาก res_company live)ch39
FK DisciplineON DELETE ทุก FK ตั้งแต่วันแรกCASCADE (ลบ parent → ลบ children), RESTRICT (ห้ามลบถ้ายังอ้าง), SET NULL (ลบ parent → FK เป็น null) เลือกตาม semantic (Odoo มี 21 FK บน stock_move ทุกตัวมี ON DELETE)ch35, ch39
Numeric Precisionnumeric ไม่ใช่ float4เงิน + จำนวน ทั้งหมดเป็น numeric — Odoo ใช้ numeric, NWFTH ใช้ decimal(22,6) GENCODE ยังเป็น real (float4) ต้องเปลี่ยนก่อนบันทึกเงินch30
Audit Trail3-Layer AuditLayer 1: Field-level tracking (cheap, ทุกวัน) Layer 2: Full-row snapshot (ตอน post/close) Layer 3: Hash chain (tamper-proof) NWFTH ใช้ H-tables (full copy); Odoo ใช้ mail.thread (field delta)ch36, ch38
JSONB Patternsi18n + per-branch + analyticชื่อหลายภาษา: name JSONB ต้นทุนต่อสาขา: standard_price JSONB Revenue attribution: analytic_distribution JSONB Custom fields: properties JSONBch35, ch39

The Complete Transaction Chain — เส้นทางเต็มรูปจาก FK จริง

Sale Flow (Revenue)

graph TD
  SO["sale_order
action_confirm()"] --> BOM["BOM Explosion
crs_svc_mapping → svc_prd_mapping"] BOM --> IM["inventory_move
(per PRD item, -qty)"] IM --> IB["inventory_balance
quantity -= consumed
SKIP LOCKED"] SO --> JE["journal_entry
move_type = sale_invoice"] IM -.->|"journal_entry_id FK"| JE JE --> JEL["journal_entry_line
Debit: Receivable
Credit: Revenue
analytic_distribution JSONB"] JEL --> REC["Reconciliation
match with payment"]

Purchase Flow (Supply)

graph TD
  PO2["purchase_order
button_confirm()"] --> IM2["inventory_move
(inbound, +qty)"] IM2 --> IB2["inventory_balance
quantity += received"] PO2 --> JE2["journal_entry
move_type = purchase_bill"] IM2 -.->|"journal_entry_id FK"| JE2 JE2 --> JEL2["journal_entry_line
Debit: Inventory
Credit: AP
purchase_line_id FK"] JEL2 --> MATCH["3-Way Match
PO ↔ Receipt ↔ Bill"]

The Wire Between Ledgers

inventory_move.journal_entry_id → journal_entry(id) ON DELETE SET NULL

ทุกการเคลื่อนของสต็อกผูกกับ Journal Entry — ตรวจสอบ audit trail ได้ทั้ง 2 ทิศ: "ของเคลื่อนเพราะ entry ไหน?" และ "entry นี้มาจากการเคลื่อนของอะไร?"

Odoo: stock_move.account_move_id → account_move(id) (live FK verified, ch37)

NWFTH: Mintxdh ถือทั้ง inventory event + GL codes (NLAcct/INAcct) ในแถวเดียว

Phased Roadmap — ลำดับที่แนะนำ (Consultant Recommendation)

graph LR
  P0["Phase 0
Foundation Fix
(1-2 weeks)"] --> P1["Phase 1
Inventory Module
(3-4 weeks)"] P1 --> P2["Phase 2
Sales Module
(3-4 weeks)"] P2 --> P3["Phase 3
Accounting Module
(4-6 weeks)"] P3 --> P4["Phase 4
Purchasing Module
(2-3 weeks)"] P4 --> P5["Phase 5
Branch Isolation
(2-3 weeks)"]
PhaseWhatTables to CreateDepends OnVerification
P0 (ด่วน)Fix nextJera → PG IDENTITY + numeric + FK ON DELETEALTER existing tablesNothingnextJera removed; quantity/amount = numeric; every FK has ON DELETE
P1Inventory Moduleinventory_move + inventory_balance + locationP0BOM consumption from svc_prd_mapping creates moves; balance updates; SKIP LOCKED under concurrent inserts
P2Sales Modulesale_order + sale_order_lineP1Confirm order → auto BOM explosion → inventory_move created; analytic_distribution flows
P3Accounting Modulejournal_entry + journal_entry_line + account_accountP1, P2Every sale/purchase creates journal entry; check_balanced invariant enforced; hash chain works
P4Purchasing Modulepurchase_order + purchase_order_lineP1, P3PO confirm → inbound move → AP entry; 3-way matching via purchase_line_id FK
P5Branch IsolationALTER all tables + RLS policies + lock date columnsP1-P4User in branch A cannot see branch B data; lock dates prevent old-period edits; JSONB per-branch config works

ทำไม Inventory ก่อน Sales: Sales ต้อง consume inventory (BOM explosion) ดังนั้น Inventory Ledger ต้องพร้อมก่อน Odoo ก็ออกแบบแบบนี้ — sale_stock module depends on stock

ทำไม Accounting หลัง Sales: Accounting ต้อง link กับ sale/purchase — ต้องมี transaction ก่อน ถึงจะสร้าง journal entry ได้ แต่ Accounting อาจ implement parallel กับ Sales ได้ถ้าแยกทีม

What NOT to Do — Anti-Patterns จากการวิจัย

ทุกข้อนี้มาจากข้อผิดพลาดจริง ไม่ใช่ทฤษฎี:

Anti-PatternทำไมผิดทำแทนReference
ใช้ float/real เก็บเงินFloating-point rounding errors — 0.1 + 0.2 ≠ 0.3numeric ทุกที่ (เงินและจำนวน)ch30
MAX(id)+1 สำหรับ sequenceRace condition ภายใต้ concurrency — 2 users ได้เลขซ้ำPG IDENTITY (lock-free) + no_gap (FOR UPDATE NOWAIT)ch30, ch32
FK ไม่มี ON DELETEOrphaned records / integrity violation เมื่อลบ parentระบุ CASCADE/RESTRICT/SET NULL ทุก FK ตั้งแต่วันแรกch35, ch39
Set analytic หลัง POS session closeSession batching ยุบ granularity — per-doctor data สูญSet analytic_distribution บน POS line ก่อน session closech36
App-level security แทน DB-levelDirect SQL bypass ir.rule ได้ — data leakPostgreSQL RLS (Row-Level Security) ที่ DB enforcech40
สร้างตารางแยกสำหรับ per-branch configTable explosion — 100 สาขา × N config tablesJSONB company-dependent (1 column, key = branch_id)ch35, ch39
Audit แค่ last-touch (create/write date)ไม่รู้ว่าใครเปลี่ยนอะไรเมื่อไหร่Field-level tracking + periodic snapshot + hash chainch36
ออกแบบ ERP จากศูนย์ไม่มีเวลา 15 ปีแบบ OdooAdapt proven patterns — Odoo (772 tables) + NWFTH (822 tables) พิสูจน์แล้วch30-ch40

How to Read This Chapter · วิธีอ่านบทนี้

บทนี้แบ่งเป็น 6 หมวด ตามบทบาทในธุรกิจ แต่ละหมวดมี:

  • Recommendations — คำแนะนำหลักที่ควรทำ (bullet points ชัดเจน)
  • Use Case Scenarios — สถานการณ์จริงที่อาจเกิดขึ้น พร้อมคำตอบว่าระบบต้อง design อย่างไร

ทุกคำแนะนำมาจากระบบ ERP ที่ใช้งานจริง: Odoo (ผู้ใช้ทั่วโลกหลายสิบล้านคน 15 ปี) และ BatchMaster (ERP ที่โรงงานอาหาร NWFTH ใช้มาหลายปี, 822 tables) — ไม่ใช่ทฤษฎี แต่เป็นสิ่งที่คนอื่นลองแล้วรอด

graph TB
  subgraph DOMAINS["6 Business Domains"]
    INF["1. Infrastructure
(รากฐานระบบ)"] SAL["2. Sales
(ระบบขาย)"] PUR["3. Purchasing
(ระบบซื้อ)"] INVT["4. Inventory
(ระบบสต็อก)"] ACCT["5. Accounting
(ระบบบัญชี)"] GROW["6. Growth & Scale
(การเติบโต)"] end INF --> SAL INF --> PUR SAL --> INVT PUR --> INVT INVT --> ACCT ACCT --> GROW

1. Infrastructure — รากฐานระบบ

Recommendations

  • แก้วิธีแจกเลขรหัสก่อนสิ่งอื่นใด — ระบบปัจจุบันแจกเลขแบบ "ดูเลขล่าสุด แล้ว +1" ซึ่งเมื่อหลายคนใช้พร้อมกันจะแย่งเลขกัน ต้องเปลี่ยนให้ฐานข้อมูลแจกเลขเองแบบอัตโนมัติ (มาตรฐานที่ Odoo ใช้มา 15 ปี) — นี่คืองานเดียวที่ต้องทำก่อนขยายสาขา
  • ใช้ทศนิยมแม่นยำสำหรับตัวเลขเงินและจำนวน — ระบบปัจจุบันเก็บตัวเลขแบบ "ประมาณ" (floating point) ซึ่งรวมเงินแล้วอาจคลาดเคลื่อนสตางค์ ต้องเปลี่ยนเป็นทศนิยมแม่นยำก่อนเริ่มบันทึกการเงิน
  • ออกแบบให้ทุกข้อมูลระบุ "สาขา" ตั้งแต่วันแรก — แม้วันนี้มีสาขาเดียว ทุกรายการต้องมีช่อง "สาขาไหน" เพื่อที่เมื่อขยายสาขาจะไม่ต้องแก้ระบบ
  • บันทึกประวัติการเปลี่ยนแปลงทุกอย่าง — ทุกครั้งที่มีการแก้ไขข้อมูล ระบบต้องบันทึกว่า "ใครแก้ อะไร เมื่อไหร่ จากอะไรเป็นอะไร" โดยอัตโนมัติ เพื่อการตรวจสอบย้อนหลังและป้องกันการทุจริต

Use Case Scenarios

  • Scenario: "เปิดสาขาใหม่แล้วเลขใบเสร็จซ้ำกับสาขาเดิม" → ระบบต้อง: แจกเลขแยกต่อสาขาอัตโนมัติ (เช่น BKK-INV-2026-0001 vs CNX-INV-2026-0001) ไม่ใช่ใช้เลขร่วมกันทั้งเครือ
  • Scenario: "ผู้จัดการสาขา A เห็นข้อมูลยอดขายสาขา B" → ระบบต้อง: มี Row-Level Security ที่ฐานข้อมูล — ผู้ใช้แต่ละสาขาเห็นแค่ข้อมูลสาขาตัวเอง + ข้อมูลที่ share ให้ดู ผู้บริหารเห็นทุกสาขา
  • Scenario: "พนักงานแก้ไขราคาในระบบแล้วไม่มีร่องรอย" → ระบบต้อง: มี Audit Trail อัตโนมัติ — ทุกการเปลี่ยนแปลงบันทึก field-level (ใคร เมื่อไหร่ จากเท่าไหร่เป็นเท่าไหร่) + Hash Chain ที่ปลอมแปลงไม่ได้ (ตรง e-Tax requirement ด้วย)
  • Scenario: "หมอ Do ลาออก ลูกค้า 30 คนยังมีคอร์สค้างส่งมอบ ต้องย้ายไปหมอ Ball" → ระบบต้อง: Reassign doctor ในคอร์สที่ค้าง + Revenue attribution หลังจากย้ายต้องไปหมอ Ball + บันทึก audit ว่าย้ายจากหมอไหนไปหมอไหนเมื่อไหร่ ลูกค้าต้องได้รับแจ้ง · ทำไมสำคัญ: ถ้าไม่มี audit trail ตรงนี้ commission หมอ Do กับ Ball จะผิด (ch36 Analytic Distribution)
  • Scenario: "Juvelook เคยจัดเป็น Skin Booster แต่ อ.ย. ประกาศใหม่เป็น Biostimulator ต้องย้ายหมวด" → ระบบต้อง: เปลี่ยน segment ใน master data (จาก SKB เป็น BIO) แล้วรหัสทั้งหมดที่อ้าง Juvelook update อัตโนมัติ (Generated Column pattern จาก ch34) — ประวัติเก่ายังอ้าง SKB ใน audit, ของใหม่เป็น BIO
  • Scenario: "JERA POS ส่งข้อมูลมาเป็นรหัสย่อ 20 ตัวอักษร แต่ระบบหลังบ้านใช้รหัส GENCODE เต็ม 30+ ตัวอักษร" → ระบบต้อง: Map JERA code (C-0001) ↔ GENCODE (CRS-SUR-INI-NOSE-SEMI-001-DRDO) อัตโนมัติ ทุก transaction ที่เข้ามาจาก POS ต้อง resolve เป็น GENCODE เต็มก่อนบันทึก (GENCODE มี Chrome Extension + mapper ที่ทำตรงนี้อยู่แล้ว)

2. Sales — ระบบขาย

Recommendations

  • ระบบขายต้องเชื่อมกับ BOM อัตโนมัติ — เมื่อขายคอร์ส 1 ตัว ระบบต้องรู้ทันทีว่าคอร์สนี้ใช้บริการอะไรบ้าง และแต่ละบริการใช้สินค้าอะไรเท่าไหร่ (GENCODE มี BOM นี้อยู่แล้ว — เป็นจุดแข็ง)
  • ทุกบรรทัดขายต้องระบุ "หมอ / สาขา / หมวดบริการ" — เพื่อรายงาน Revenue per Doctor / per Branch / per Category ได้ทันที โดยไม่ต้องเขียน report พิเศษ (ใช้ Analytic Distribution แบบ Odoo)
  • 3-Way Matching ฝั่งขาย: สั่ง ↔ ส่งมอบ ↔ เก็บเงิน — ทุกการขายต้องตรวจสอบ 3 จุด: จำนวนที่สั่ง = จำนวนที่ส่งมอบบริการ = จำนวนที่เก็บเงิน ถ้าไม่ตรง → ระบบ flag ทันที
  • POS ต้อง set ข้อมูลหมอ/หมวดก่อนปิดรอบ — ถ้าไม่ set ข้อมูล per-doctor ก่อนปิด POS session รายละเอียดจะหายไปถาวร (POS รวมบิลเป็นก้อนเดียวตอนปิดรอบ)

Use Case Scenarios

  • Scenario: "ลูกค้าซื้อคอร์สเสริมจมูก 3 ครั้ง แต่มาทำแค่ 2 ครั้ง" → ระบบต้อง: แสดงว่ายังค้างส่งมอบ 1 ครั้ง (3-Way Matching: สั่ง 3, ส่งมอบ 2, เก็บเงินแล้ว 3) → dashboard เตือน "คอร์สค้างส่งมอบ" ผู้จัดการเห็นทันที
  • Scenario: "ผู้บริหารถาม: หมอ A ทำรายได้เท่าไหร่เดือนนี้?" → ระบบต้อง: ตอบได้ทันทีจาก Analytic Distribution — ทุกบรรทัดขายถือข้อมูลหมอไว้ ไม่ต้อง query ซับซ้อน ได้ Revenue per Doctor แบบ real-time
  • Scenario: "คลินิกมีทั้ง walk-in (POS) และ booking ล่วงหน้า (Sale Order)" → ระบบต้อง: POS = ขาย + บันทึกเงินทันที; Sale Order = จอง + ส่งมอบเป็นครั้ง ๆ + เก็บเงินทีหลังได้ ทั้งสองแบบไหลเข้า Ledger เดียวกัน เห็นยอดรวมที่เดียว
  • Scenario: "ลูกค้าซื้อ package แล้วอยากเปลี่ยนบริการในคอร์ส" → ระบบต้อง: CRS→SVC mapping เปลี่ยนได้ แต่ต้องบันทึก Change Log ว่า "เปลี่ยนจาก SVC-A เป็น SVC-B เมื่อไหร่ โดยใคร" เพื่อ audit trail
  • Scenario: "ลูกค้าซื้อคอร์สฉีด Restylane Classic 5 ครั้ง ทำไป 2 ครั้ง อยากเปลี่ยนเป็น Juvederm Volift 3 ครั้งที่เหลือ" → ระบบต้อง: Course Amendment — คำนวณราคาส่วนต่าง (Restylane vs Juvederm อาจราคาต่างกัน) + เปลี่ยน BOM ของครั้งที่ 3-5 (ตัดสต็อก Juvederm แทน Restylane) + ออก Credit Note สำหรับส่วนเดิม + Invoice ใหม่ในราคา Juvederm + บันทึก audit ทั้งหมด · ทำไมสำคัญ: ถ้าไม่มี amendment flow จะ manual adjust ทำให้ stock กับ revenue ไม่ตรง
  • Scenario: "ลูกค้าซื้อ Allergan Botox 100 units (Buffet) ฉีดหน้าผาก 20U + คาง 15U + กราม 30U = ใช้ 65U เหลือ 35U ไว้ครั้งหน้า" → ระบบต้อง: Partial-Use Tracking — เปิด vial 100U → track ทุก unit ที่ใช้ per area per session → balance 35U ค้างในบัตรลูกค้า ครั้งหน้ามาใช้ต่อได้ (vial เปิดแล้ว ใช้ได้ภายใน 2 สัปดาห์) · ทำไมสำคัญ: ถ้าไม่ track unit level จะไม่รู้ว่าเหลือเท่าไหร่ ขาดทุนจากของเสียหรือหาย (สาเหตุ #1 ของ profit leak ในคลินิก — American Med Spa Association)
  • Scenario: "ลูกค้าจ่ายคอร์สเสริมจมูก 120,000 บาทล่วงหน้า ทำไป 0 ครั้ง ปิดงบสิ้นเดือน" → ระบบต้อง: TFRS 15 (IFRS 15) Deferred Revenue — เงิน 120,000 บาทยังเป็น Contract Liability (หนี้สินตามสัญญา) ไม่ใช่รายได้ จนกว่าจะทำหัตถการจริง พอทำเสร็จ → recognize revenue 120,000 ทั้งก้อน · ถ้าเป็นคอร์ส 5 ครั้ง ทำไป 2 → recognize 48,000 (2/5) defer 72,000 · ทำไมสำคัญ: ถ้า recognize revenue ตอนรับเงิน ตัวเลขกำไรจะสูงเกินจริง ผู้สอบบัญชี flag + สรรพากรตรวจไม่ผ่าน
  • Scenario: "ลูกค้า walk-in ฉีด Restylane Classic 1cc หมอทำแล้วบอกว่าควรเพิ่มอีก 1cc ระหว่างทำ" → ระบบต้อง: POS Mid-Treatment Add-On — เพิ่มรายการได้ระหว่าง session ยังไม่ปิดบิล → ตัดสต็อกเพิ่ม 1cc → ปรับยอด invoice ทันที · ทำไมสำคัญ: ถ้า POS ปิดบิลไม่ได้ก่อนหมอเสร็จ = ข้อมูลไม่ตรง ต้องมีกลไก "open bill" ที่เพิ่มรายการได้
  • Scenario: "ลูกค้ามีบัตร Gold Member ได้ส่วนลด 15% ทุกคอร์ส + สะสมแต้ม" → ระบบต้อง: Membership Tier Pricing — ราคาเปลี่ยนตามระดับสมาชิก (Silver 5% / Gold 15% / Platinum 25%) + สะสม Point ทุกการซื้อ + ใช้ Point แลกบริการได้ Point ที่ใช้ต้องบันทึกเป็น Discount Entry ในบัญชี ไม่ใช่ "หายไป"

3. Purchasing — ระบบซื้อ

Recommendations

  • ทุกการซื้อต้องผ่าน Purchase Order — ไม่ซื้อแบบไม่มีเอกสาร เพื่อควบคุมงบประมาณและตรวจสอบได้
  • 3-Way Matching ฝั่งซื้อ: สั่งซื้อ ↔ รับของ ↔ ใบวางบิล — เมื่อ supplier ส่งของ ต้องตรวจสอบ: จำนวนที่สั่ง = จำนวนที่รับ = จำนวนที่ถูกเรียกเก็บ ถ้า supplier คิดเกินหรือส่งไม่ครบ → ระบบ flag ทันที
  • การรับของต้อง update สต็อกอัตโนมัติ — เมื่อกด "รับของ" ระบบต้องเพิ่มจำนวนในคลังทันที + สร้างรายการบัญชี (AP) อัตโนมัติ ไม่ต้องบันทึกซ้ำ
  • Vendor Management: ผูก supplier กับสินค้า — ระบบต้องจำว่าสินค้าแต่ละตัวซื้อจาก supplier ไหน ราคาเท่าไหร่ lead time กี่วัน เพื่อ auto-suggest ตอนสั่งซื้อ

Use Case Scenarios

  • Scenario: "สั่งฟิลเลอร์ 100 กล่อง แต่ supplier ส่งมา 95 กล่อง แล้วคิดเงิน 100 กล่อง" → ระบบต้อง: 3-Way Matching จับ: สั่ง 100 / รับ 95 / เรียกเก็บ 100 → flag "ส่งขาด 5 + คิดเกิน 5" ก่อนอนุมัติจ่าย ผู้จัดการเห็นทันที
  • Scenario: "ราคาฟิลเลอร์ขึ้น 10% แต่ไม่มีใครรู้จนกว่าจะดูใบเสร็จ" → ระบบต้อง: เปรียบเทียบราคาใน PO กับราคาใน vendor master อัตโนมัติ ถ้าเกิน threshold (เช่น >5%) → alert ก่อนอนุมัติ PO
  • Scenario: "มีหลายสาขาสั่งของจาก supplier เดียวกัน อยากรวม PO เพื่อต่อราคา" → ระบบต้อง: Consolidated Purchase — รวม demand จากหลายสาขาเป็น PO เดียว ส่งให้ supplier รับของที่คลังกลาง แล้วกระจายไปสาขา
  • Scenario: "Supplier ขึ้นราคา Restylane Classic 10% กลางเดือนมิถุนายน PO ที่สั่งไว้ก่อนขึ้นราคายังไม่ได้รับของ" → ระบบต้อง: PO เก่ายังใช้ราคาเดิม (ล็อกตอน confirm) · PO ใหม่ใช้ราคาใหม่ · ต้นทุนสินค้าใช้ Weighted-Average Costing (ต้นทุนเฉลี่ยถ่วงน้ำหนัก) → ต้นทุนค่อย ๆ ปรับขึ้นตามของที่รับใหม่ ไม่กระโดดทีเดียว · Dashboard แสดง Price Trend per item เห็นแนวโน้มราคาทุก supplier
  • Scenario: "ฟิลเลอร์ต้องเก็บ 2-8°C ขนส่งจาก supplier มาคลังกลาง แล้วกระจายไปสาขา" → ระบบต้อง: Cold Chain Compliance — Receiving Checklist (ตรวจอุณหภูมิ + บันทึก) → ถ้า chain ขาด (อุณหภูมิเกิน) → Reject + แจ้ง supplier → ถ้า OK → สร้าง Lot พร้อม Expiry Date + Temperature Log · ทำไมสำคัญ: Filler ที่เก็บผิดอุณหภูมิอาจเป็นอันตรายต่อลูกค้า + เสี่ยงถูก อ.ย. สั่งปิด
  • Scenario: "Allergan Botox สั่งขั้นต่ำ 20 กล่อง lead time 4 สัปดาห์ แต่สาขาเปิดใหม่ต้องการแค่ 5 กล่อง" → ระบบต้อง: Reorder Point + Safety Stock per Branch → คลังกลางสั่งรวม MOQ 20 กล่อง → กระจายไปสาขาตาม demand (5 ให้สาขาใหม่ + 15 เก็บคลังกลาง) → Auto-Replenishment Alert เมื่อ stock ต่ำกว่า safety level
  • Scenario: "สั่ง EPTQ S300 filler จากเกาหลี ต้องมีใบ อ.ย. + Customs Clearance + ภาษีนำเข้า" → ระบบต้อง: Import PO Workflow — แนบเอกสาร อ.ย. + Customs Declaration + Certificate of Analysis → คำนวณ Landed Cost (ราคาสินค้า + ค่าขนส่ง + ภาษี + ค่า Customs) → ต้นทุนจริง = Landed Cost ÷ จำนวน · ทำไมสำคัญ: ถ้าไม่คำนวณ Landed Cost ต้นทุนจะต่ำเกินจริง กำไรที่เห็นเป็น "กำไรหลอก"

4. Inventory — ระบบสต็อก

Recommendations

  • ทุกการเคลื่อนไหวของสินค้าต้องบันทึก — ของเข้า (รับจาก supplier) ของออก (ใช้ในบริการ) ของย้าย (สาขา A → B) ทุกรายการต้องเป็น event ที่ลบไม่ได้ (Append-Only Ledger) ตรวจสอบย้อนหลังได้ตลอด
  • มี Reservation System — เมื่อลูกค้าจองคอร์ส ระบบต้อง "จอง" สินค้าที่ต้องใช้ไว้ ยังไม่ตัดสต็อก แต่คนอื่นจะเห็นว่า "ของเหลือ = ในมือ - ที่จองแล้ว" ป้องกัน oversell
  • BOM-Driven Consumption — เมื่อส่งมอบบริการ ระบบต้องตัดสต็อกสินค้าตาม Bill of Materials อัตโนมัติ (GENCODE มี BOM อยู่แล้ว — เป็นจุดแข็ง)
  • รองรับ Lot / Batch Tracking — สินค้าคลินิก (ฟิลเลอร์ โบท็อกซ์ ยา) ต้อง track lot/batch สำหรับ expiry date และ recall
  • ของที่ใกล้หมดอายุต้อง alert — ระบบต้อง flag สินค้าที่จะหมดอายุใน 30/60/90 วัน

Use Case Scenarios

  • Scenario: "ลูกค้า 10 คนจองคอร์สเดียวกัน แต่ฟิลเลอร์เหลือแค่ 8 ชิ้น" → ระบบต้อง: Reservation System จอง 8 ชิ้นให้ 8 คนแรก คนที่ 9-10 เห็น "Stock Insufficient" ทันที ไม่ต้องรอจนวันนัดแล้วของไม่พอ
  • Scenario: "อ.ย. recall ฟิลเลอร์ lot 2026-A เพราะปัญหาคุณภาพ" → ระบบต้อง: Lot Tracking → query ทันทีว่า lot นี้อยู่สาขาไหน ใช้ไปกับลูกค้าคนไหนบ้าง เหลือ stock กี่ชิ้น → recall ตรงจุด
  • Scenario: "สาขา A ของเหลือเยอะ สาขา B ของขาด" → ระบบต้อง: Internal Transfer — ย้ายของจากสาขา A ไป B ผ่าน Inventory Move (บันทึก from/to location) สต็อกทั้งสองสาขา update real-time
  • Scenario: "ตรวจนับสต็อกแล้วจำนวนไม่ตรงกับระบบ" → ระบบต้อง: Physical Count + Adjustment — บันทึกจำนวนที่นับจริง ระบบคำนวณส่วนต่าง สร้าง Adjustment Entry อัตโนมัติ ไม่ลบ record เดิม (Append-Only) + Variance Report ที่แสดง "ของหายกี่ชิ้น มูลค่าเท่าไหร่" เพื่อสอบสวน
  • Scenario: "เปิด Nabota 100U ใช้ลูกค้า A ฉีด 30 units เหลือ 70 units ใน vial จะใช้กับลูกค้า B ได้ภายใน 24 ชม." → ระบบต้อง: Sub-Lot Tracking — เปิด vial → สร้าง "sub-lot" (opened vial) ที่มี Expiry 24 ชม. นับจากเปิด + Balance 100U → ใช้ลูกค้า A 30U (balance → 70U) → ใช้ลูกค้า B 40U ภายใน 24 ชม. (balance → 30U) → หมด 24 ชม. ไม่ได้ใช้ → auto-write-off 30U เป็น Wastage Entry · ทำไมสำคัญ: Botox เปิดแล้วต้องใช้ภายใน 24 ชม. ถ้าไม่ track unit-level จะไม่รู้ว่าเหลือเท่าไหร่ ของเสีย (wastage) จะไม่ถูกบันทึก = ต้นทุนหาย = กำไรเกินจริง (เป็น Hidden Profit Killer อันดับ 1 ของ Med Spa)
  • Scenario: "Restylane Lyft lot 2026-A หมดอายุใน 30 วัน เหลือ 12 กล่องกระจายอยู่ 3 สาขา (สยาม 5, ทองหล่อ 4, อารีย์ 3)" → ระบบต้อง: Near-Expiry Alert (30/60/90 วัน) + FEFO (First-Expiry-First-Out) → ระบบแนะนำ "โอนจาก 3 สาขามารวมที่สาขาที่ขายเร็วที่สุด" เพื่อใช้ให้หมดก่อนหมดอายุ → ถ้าหมดอายุจริง → Expiry Write-Off Entry อัตโนมัติ (ลดสต็อก + บันทึกค่าเสียโอกาส)
  • Scenario: "อ.ย. recall Neuramis Deep lot NRM-2026-B เพราะปัญหาคุณภาพ ต้อง trace ภายใน 24 ชม." → ระบบต้อง: Full Lot Traceability — query ทันที: lot NRM-2026-B อยู่สาขาไหนกี่ชิ้น (stock on hand) + ใช้ไปกับลูกค้าคนไหนบ้าง (ผ่าน Inventory Move → Sale Order → Customer) + ใครเป็นหมอที่ฉีด (ผ่าน SVC performer) → สร้างรายชื่อลูกค้าที่ต้องแจ้ง + Quarantine stock ที่เหลือทันที · ทำไมสำคัญ: อ.ย. กำหนดให้ recall trace ได้ภายใน 24 ชม. ถ้าไม่มี Lot Tracking ต้อง recall ทั้งยี่ห้อ = เสียหายมหาศาล
  • Scenario: "สาขาสยามหมด Restylane Classic กลางวัน มีลูกค้านัดอีก 3 คน ต้องยืมจากสาขาทองหล่อด่วน" → ระบบต้อง: Emergency Inter-Branch Transfer — ผู้จัดการสาขาสยามสร้าง Transfer Request → ผู้จัดการทองหล่อ approve ใน app → Dispatch Rider รับของ → ทองหล่อ confirm "ส่งออก" (stock ลด) → สยาม confirm "รับเข้า" (stock เพิ่ม) → ทั้งสองสาขาเห็น balance real-time · ต้องมี Inventory Move event ที่บันทึก from=ทองหล่อ to=สยาม ลบไม่ได้

5. Accounting — ระบบบัญชี

Recommendations

  • ทุก transaction ต้องสร้าง Journal Entry อัตโนมัติ — เมื่อขาย → บันทึกรายได้; เมื่อซื้อ → บันทึก AP; เมื่อรับเงิน → ตัด AR คนไม่ต้องทำบัญชีซ้ำ
  • Double-Entry Accounting ตั้งแต่วันแรก — ทุก entry ต้อง Debit = Credit (ห้าม save ถ้าไม่สมดุล) นี่คือ invariant ที่ทำให้บัญชีถูกต้องเสมอ ตรวจสอบง่าย
  • Tamper-Proof Hash Chain สำหรับ e-Tax — ทุก entry ที่ post แล้วต้อง hash ต่อจาก entry ก่อนหน้า ถ้ามีใครแก้ entry เก่า chain แตก ตรวจจับได้ — ตรง requirement e-Tax ของสรรพากรไทย
  • Period Locking ต้องละเอียด — ปิดงบเดือนไหนแล้ว ต้องล็อกห้ามแก้ย้อนหลัง แยก 5 ระดับ: บัญชีทั่วไป / ภาษี / ขาย / ซื้อ / ล็อกเด็ดขาด
  • Reconciliation อัตโนมัติ — Invoice กับ Payment ต้อง match กันอัตโนมัติ (ตาม partner + จำนวนเงิน) เห็น "ค้างชำระ" แบบ real-time

Use Case Scenarios

  • Scenario: "ลูกค้าจ่ายเงินแล้ว แต่ระบบยังแสดงว่าค้างชำระ" → ระบบต้อง: Auto-Reconciliation — เมื่อบันทึก Payment ระบบ match กับ Invoice ที่ยังค้างอัตโนมัติ → "ค้างชำระ" เป็น 0 ทันที ไม่ต้อง manual
  • Scenario: "สรรพากรมาตรวจ ขอดูใบกำกับภาษีย้อนหลัง 3 ปี" → ระบบต้อง: เลขที่ใบกำกับต่อเนื่อง (No-Gap Sequence) + Hash Chain ที่พิสูจน์ได้ว่าไม่มีการแก้ไขย้อนหลัง + Period Lock ที่ปิดงวดแล้วแก้ไม่ได้
  • Scenario: "ปิดงบเดือนพฤษภาคมแล้ว แต่พนักงานลงบิลย้อนเป็นเดือนพฤษภาคม" → ระบบต้อง: Period Lock — ปิดงบเดือนไหนแล้ว ห้ามบันทึก entry ก่อนวันที่ล็อก ต้องลงเดือนปัจจุบันเท่านั้น
  • Scenario: "อยากเห็น Profit per Doctor / per Branch แบบ real-time" → ระบบต้อง: Analytic Distribution — ทุกบรรทัด Journal Entry ถือข้อมูลว่า revenue/cost นี้เป็นของหมอไหน สาขาไหน → pivot report ได้ทันที ไม่ต้องรอปิดงบ
  • Scenario: "จ่ายค่าหมอ Do 30% ของรายได้หัตถการเดือนนี้ ต้องหัก WHT 3% + ออก 50 ทวิ" → ระบบต้อง: Commission Calculation — ดึง revenue ของหมอ Do จาก Analytic Distribution (ไม่ต้อง manual นับ) → คำนวณ commission 30% → หัก Withholding Tax 3% → สร้าง Journal Entry (Debit: Commission Expense, Credit: AP Doctor + WHT Payable) + generate หนังสือรับรองหัก ณ ที่จ่าย (50 ทวิ) · ทำไมสำคัญ: ถ้าคำนวณ commission มือ จะช้า + ผิดพลาด + ลืมหัก WHT = โดนสรรพากรปรับ
  • Scenario: "CEO ถาม: หมอ Do ทำรายได้เท่าไหร่ที่สาขาสยาม vs ทองหล่อ แยกตามหมวดบริการ เดือนนี้?" → ระบบต้อง: Multi-Dimensional Pivot — Analytic Distribution ถือข้อมูล 3 มิติ (Doctor × Branch × Service Category) → pivot ได้ทุกมุม: revenue per doctor, per branch, per category, หรือ doctor × branch cross-tab · ตัวอย่าง: หมอ Do สาขาสยาม ผ่าตัดจมูก 500K + ฟิลเลอร์ 200K; สาขาทองหล่อ ผ่าตัดจมูก 300K + โบท็อกซ์ 150K
  • Scenario: "ลูกค้าซื้อคอร์ส 50,000 ใช้ไป 3/5 ครั้ง ขอ refund ส่วนที่เหลือ" → ระบบต้อง: Partial Refund Flow — สร้าง Credit Note 20,000 (2/5 ของ 50K ที่ยังไม่ส่งมอบ) + Reverse Deferred Revenue ที่ค้างอยู่ 20,000 + Cancel Inventory Reservation สำหรับ 2 ครั้งที่เหลือ + จ่ายเงินคืน (Payment Entry) → ทุกอย่างอัตโนมัติไม่ต้อง manual adjust 4 จุด
  • Scenario: "สาขาสยามปิดงบเดือนพฤษภาคมแล้ว แต่สาขาทองหล่อยังค้าง CEO อยากเห็นตัวเลขรวมเร็ว" → ระบบต้อง: Per-Branch Lock Dates + Provisional Close — สาขาสยาม lock ที่ 31 พ.ค. (ห้ามแก้) / สาขาทองหล่อยัง open → Consolidated Report แสดง "สยาม: finalized 2.5M + ทองหล่อ: provisional 1.8M (pending close)" → CEO เห็นภาพรวมทันทีแม้ยังปิดไม่ครบทุกสาขา

6. Growth and Scale — การเติบโตถึง 100 สาขา

Recommendations

  • ฐานข้อมูลกลาง 1 ตัว สำหรับทุกสาขา — ไม่แยก database ต่อสาขา (แยกแล้ว cross-branch report ยาก consolidate ยาก) ใช้ Row-Level Security แบ่งข้อมูลแทน
  • Config ต่อสาขาไม่ต้องสร้างตารางใหม่ — ราคาต้นทุน, บัญชี, ผู้รับผิดชอบ ต่างกันต่อสาขา เก็บในช่อง "per-branch" เดียว (JSONB pattern ของ Odoo) ไม่ต้องสร้างตารางแยก 100 ตัว
  • POS Batching สำหรับสาขาที่ขายเยอะ — POS ไม่ post บัญชีทีละบิล แต่รวมทั้ง session แล้ว post ครั้งเดียวตอนปิดรอบ ลด load ฐานข้อมูลมหาศาล (Odoo ใช้มา 15 ปี)
  • Multilingual Support ตั้งแต่วันแรก — ชื่อสินค้า / คำอธิบาย ต้องเก็บเป็น "JSONB per language" (ไทย + อังกฤษ + ภาษาอื่น) ไม่ต้อง redesign ทีหลัง

Use Case Scenarios

  • Scenario: "เปิดสาขาใหม่ 5 แห่งในเดือนเดียว" → ระบบต้อง: เพิ่ม "สาขา" ในระบบ = เพิ่มแถวเดียวในตาราง Branch + กำหนด config per-branch (ราคาต้นทุน, บัญชี, lock dates) ไม่ต้อง deploy database ใหม่ ไม่ต้องติดตั้งระบบใหม่
  • Scenario: "CEO อยากเห็น dashboard รวมยอดทุกสาขาแบบ real-time" → ระบบต้อง: ฐานข้อมูลเดียว → query รวมทุกสาขาได้ทันที ไม่ต้อง ETL / consolidate จากหลาย database
  • Scenario: "สาขาหาดใหญ่มีราคาต้นทุนสินค้าต่างจากกรุงเทพ" → ระบบต้อง: Per-Branch Cost — สินค้าเดียวกันมีต้นทุนต่างกันต่อสาขาได้ ไม่ต้องสร้างสินค้าซ้ำ
  • Scenario: "ลูกค้าต่างชาติอ่านชื่อบริการไม่ออก" → ระบบต้อง: Multilingual — ชื่อบริการแสดงเป็นภาษาของ user (ไทย/อังกฤษ/จีน) จากข้อมูลเดียว
  • Scenario: "100 สาขาขายพร้อมกัน ระบบจะช้าไหม?" → ระบบต้อง: ใช้ SKIP LOCKED สำหรับ inventory (ไม่ block) + POS session batching (ลด write) + connection pool ที่ recycle อัตโนมัติ + read replica สำหรับ report — proven pattern ที่ Odoo + NWFTH ใช้จริง
  • Scenario: "เปิดแฟรนไชส์ให้คนนอก ใช้ GENCODE เหมือนกัน แต่ราคาต้นทุน/ขายต่างกัน ต้องแยก P&L" → ระบบต้อง: Franchise Branch Type — share master data (GENCODE codes, BOM, product catalog) แต่ isolate financials (แต่ละ franchise มี cost/price/P&L ของตัวเอง ผ่าน JSONB per-branch) + Franchise Royalty Calculation (% ของ revenue per branch → auto journal entry) + HQ ดู consolidated dashboard แต่ไม่แก้ไข branch data ได้ · ทำไมสำคัญ: franchise model ต้องแยก P&L ชัดเจน เพราะ franchise owner ต้องเห็นแค่กำไรของตัวเอง ไม่ใช่ทั้งเครือ
  • Scenario: "หมอ Do ทำที่สาขาสยามจันทร์-พุธ ทองหล่อพฤหัส-ศุกร์ อารีย์เสาร์" → ระบบต้อง: Doctor Rotation Schedule — กำหนดตารางหมอต่อสาขาต่อวัน + Appointment ผูกกับ doctor + branch + Revenue attribution ติดตามหมอไม่ว่าจะทำที่สาขาไหน (Analytic = doctor account ไม่ใช่ branch account) + สต็อกตัดที่สาขาที่ทำจริง (branch-of-service) · ตัวอย่าง: หมอ Do ทำจมูกที่สยามวันจันทร์ → revenue ไปหมอ Do, stock ตัดที่สยาม; ทำฟิลเลอร์ที่ทองหล่อวันพฤหัส → revenue ไปหมอ Do, stock ตัดที่ทองหล่อ
  • Scenario: "โปรโมชัน Valentine ลด 20% ทุกคอร์สฟิลเลอร์ ทุกสาขา 1-14 กุมภาพันธ์" → ระบบต้อง: Campaign Pricing Rule — กำหนด: product filter (GENCODE TYPE=SKN + CATEGORY=FIL) + date range (1-14 ก.พ.) + branch scope (ทุกสาขา) + discount 20% → POS/Sale Order auto-apply ไม่ต้องตั้งราคาใหม่ทีละตัว → Revenue tracked with campaign tag → สิ้นเดือนรายงาน "Valentine campaign revenue vs cost vs incremental profit" ได้ · ทำไมสำคัญ: ถ้าไม่มี campaign tag จะไม่รู้ว่า campaign คุ้มไหม ขาดทุนหรือกำไร ตัดสินใจครั้งหน้าไม่ได้
  • Scenario: "เปิดสาขาเชียงใหม่ ต้อง setup อะไรบ้างในระบบ?" → ระบบต้อง: New Branch Ramp-Up Checklist — 1. สร้าง Branch ในระบบ (เพิ่มแถวเดียว ไม่ต้อง deploy ใหม่) → 2. กำหนดต้นทุนต่อสาขา (JSONB per-branch cost — ราคาอาจต่างจาก กทม.) → 3. กำหนดหมอ + ตาราง rotation → 4. ตั้ง Lock Dates (5 ระดับ) → 5. Initial Stock Transfer จากคลังกลาง (Inventory Move: central → เชียงใหม่) → 6. ตั้ง POS Config (printer, payment methods) → 7. ทดสอบ end-to-end (ขาย 1 คอร์ส → ตัดสต็อก → journal entry ออกอัตโนมัติ?) → 8. Go-Live · ทั้งหมดนี้ไม่ต้องยุ่งกับ code หรือ database ใหม่ เพราะใช้ 1 DB + Branch ID + RLS

Priority Matrix — ทำอะไรก่อน-หลัง

graph TD
  P0["P0: Infrastructure Fix
(แก้วิธีแจกเลข + ทศนิยมแม่นยำ)
1-2 สัปดาห์"] P1["P1: Inventory + Sales
(สต็อก + ระบบขาย + BOM)
6-8 สัปดาห์"] P2["P2: Accounting
(บัญชี Double-Entry + Hash Chain)
4-6 สัปดาห์"] P3["P3: Purchasing + Scale
(ระบบซื้อ + Branch Isolation)
4-6 สัปดาห์"] P0 --> P1 P1 --> P2 P2 --> P3
PriorityWhatBusiness ImpactRisk if Delayed
P0 (ด่วน)แก้วิธีแจกเลข + ทศนิยม + ระบุสาขาทุกตารางเปิดทางให้ขยายสาขาได้ขยายสาขาแล้วเลขซ้ำ เงินคลาดเคลื่อน
P1 (สำคัญ)Inventory Ledger + Sales Module + BOM automationเห็น stock real-time + Revenue per Doctor + ป้องกัน oversellขาย oversell + ไม่รู้ของเหลือ + ไม่มี revenue attribution
P2 (สำคัญ)Accounting Ledger + Hash Chain + Period Lockบัญชีถูกต้อง + e-Tax compliance + ปิดงบได้บัญชีผิด + สรรพากรตรวจไม่ผ่าน + แก้ย้อนหลังได้
P3 (เติบโต)Purchasing + 3-Way Match + Branch Isolationควบคุมต้นทุน + ป้องกัน fraud + รองรับ 100 สาขาซื้อแพง + supplier คิดเกิน + สาขาเห็นข้อมูลข้ามกัน

Bottom Line — สิ่งที่ CEO ต้องรู้

  1. GENCODE เป็น Product Master ที่ดีมาก — รหัสอ่านออก (Semantic Code) เป็นจุดแข็งจริง แม้แต่ ERP ระดับโลกก็ใช้แนวทางเดียวกัน ดังนั้นฐานถูกแล้ว ไม่ต้องเริ่มใหม่
  2. ระบบต้องเพิ่มแค่ 2 อย่าง เพื่อเป็น ERP จริง: Inventory Ledger (สมุดสต็อก) + Accounting Ledger (สมุดบัญชี) ทั้งสองมี template สำเร็จรูปจาก Odoo (15 ปี, หลายสิบล้าน users) — adapt ไม่ reinvent
  3. มีงานเร่งด่วน 1 ชิ้น: แก้วิธีแจกเลขรหัส ก่อนขยายสาขา ถ้าไม่แก้ = หลายสาขาแย่งเลขกัน = ข้อมูลเสียหาย
  4. ต้นทุนของการรอ: ยิ่งขยายสาขาก่อนเตรียมระบบ ยิ่งแก้ทีหลังแพง — แก้ตอนมีสาขาเดียว ถูกกว่าแก้ตอนมี 20 สาขา 10 เท่า
  5. Revenue per Doctor ได้ฟรี: ถ้าออกแบบ Analytic Distribution ถูกตั้งแต่วันแรก CEO จะเห็น "หมอ A ทำรายได้เท่าไหร่ สาขา B ทำรายได้เท่าไหร่" แบบ real-time ตั้งแต่ระบบ Ledger เสร็จ ไม่ต้องรอ BI/report พิเศษ