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):
| Layer | Role | ทำอะไร |
|---|---|---|
| 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
| Ledger | Odoo (proven) | NWFTH (proven) | GENCODE (ต้องสร้าง) |
|---|---|---|---|
| Inventory Ledger | stock_move (Event) → stock_quant (Balance)+ reserved_quantity (Reservation) | Mintxdh (Event) → INLOC (Balance)+ QtyCommitSales (Reservation) | ยังไม่มี (มี svc_prd_mapping เป็น seed) |
| Accounting Ledger | account_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"]
| Phase | Action | Business Outcome |
|---|---|---|
| Today | GENCODE Master Data พร้อมใช้ | ทุกระบบเรียกของชิ้นเดียวกันถูกต้อง Unified Product Language |
| Near-Term | Fix Sequence (PG IDENTITY + no_gap) + Inventory Ledger + Accounting Ledger | รองรับหลายสาขา + เห็น Stock Balance / Profit per Doctor / per Branch แบบ real-time |
| Long-Term | Full ERP: Sale / Purchase / Inventory / Accounting / POS / Branch Isolation | ระบบเดียวคุม 100 สาขา ด้วย Single Central Database + Row-Level Security |
Evidence Base · ที่มาของข้อมูล
ทุกข้อสรุปในบทนี้มาจาก live database query ไม่ใช่จากความจำ:
| Source | Method | Key Numbers |
|---|---|---|
| Odoo 19 | Live PostgreSQL 18 (tni-db MCP) + Source Code (odoo19-core, 624 addons) | 772 tables, 9 modules installed, pg_constraint FK/PK verified |
| NWFTH BatchMaster | Live TFCLIVE MSSQL (nwfth-sql MCP, SELECT only) | 822 tables, INFORMATION_SCHEMA columns verified |
| GENCODE | Source 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 · บรรทัดสรุป
- Strength: GENCODE เป็น Semantic Product Master ที่ดี — Meaningful Key approach ถูกต้อง (แม้แต่ BatchMaster ก็ใช้ Meaningful
Itemkey) - P0 Fix: เปลี่ยน
nextJera()เป็น PostgreSQL IDENTITY ก่อนขยายสาขา — Sequence Generation คือ Scalability Bottleneck ชิ้นเดียว - Two Ledgers: Inventory Ledger (Event-Sourced) + Accounting Ledger (Double-Entry) ที่ทำให้เป็น ERP จริง — Odoo + NWFTH พิสูจน์โครงสร้างนี้มาแล้ว adapt ไม่ reinvent
- Branch Model: Single Central Database + company_id/branch_id + PostgreSQL Row-Level Security (ดีกว่า Odoo ir.rule ที่เป็น app-level) + 5 Lock Dates
- Revenue per Doctor: Analytic Distribution (JSONB per Journal Line) + Auto-Rules = ได้ Revenue attribution ฟรี ตั้งแต่ระบบ Ledger เสร็จ
- 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
| Dimension | Today (GENCODE) | Target (ERP) | Proven By |
|---|---|---|---|
| Tables | ~15 | ~40-60 | Odoo 772 / NWFTH 822 |
| Transaction tables | 0 | 8+ (orders, moves, entries) | ch37-ch40 |
| Ledgers | 0 | 2 (Inventory + Accounting) | ch30, ch37, ch38 |
| Concurrency handling | nextJera MAX+1 (broken) | SKIP LOCKED + PG IDENTITY | ch32, ch38 |
| Branch isolation | core_branches (unused) | branch_id + PostgreSQL RLS | ch40 |
| Money precision | real (float4) | numeric | ch30 |
| FK discipline | No ON DELETE | CASCADE/RESTRICT/SET NULL every FK | ch35, ch39 |
Module 1: Sales (ระบบขาย) — The Revenue Side
Tables to Create
| Table | Role | Key Columns | Proven By |
|---|---|---|---|
sale_order | Sales Order Header | id PK IDENTITY, name (sequence), customer_id FK, branch_id FK, state (draft/confirmed/done/cancel), date_order, amount_total numeric | Odoo sale_order (52 cols) |
sale_order_line | Sales Order Detail | order_id FK CASCADE, crs_code FK → courseRegistry.fullCode, quantity numeric, price_unit numeric, analytic_distribution JSONB | Odoo sale_order_line (51 cols) |
Auto-BOM Explosion — จุดแข็งเฉพาะของ GENCODE
เมื่อ confirm Sale Order ที่ขาย CRS course:
- อ่าน
crs_svc_mapping→ ได้ SVC services ที่ต้องส่งมอบ - อ่าน
svc_prd_mapping(Bill of Materials) → ได้ PRD items + quantity ที่ต้องตัดสต็อก - สร้าง
inventory_moveper PRD item (ตัด stock อัตโนมัติ) - สร้าง
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 การขายกระจายเป็นหลายบริการ + หลายสินค้า:
| Document | Meaning | Verify |
|---|---|---|
| Sale Order (ใบสั่งขาย) | ลูกค้าซื้อ CRS course อะไร จำนวนเท่าไหร่ | product_uom_qty = สิ่งที่สัญญาว่าจะให้ |
| Delivery / Service Completion | SVC ที่ทำจริง + PRD ที่ตัด stock จริง (ผ่าน BOM) | qty_delivered = สิ่งที่ส่งมอบจริง |
| Customer Invoice (ใบแจ้งหนี้) | เงินที่เรียกเก็บลูกค้า | qty_invoiced = สิ่งที่เก็บเงินแล้ว |
Invariant: qty_delivered ≤ product_uom_qty (ส่งมอบไม่เกินที่สั่ง) + qty_invoiced ≤ qty_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
| Table | Role | Key Columns | Proven By |
|---|---|---|---|
purchase_order | Purchase Order Header | id PK, vendor_id FK, branch_id FK, state (draft/confirmed/received/billed/cancel), date_order, amount_total numeric | Odoo purchase_order (40 cols) |
purchase_order_line | Purchase Order Detail | order_id FK CASCADE, prd_code FK → productRegistry.fullCode, quantity numeric, price_unit numeric | Odoo purchase_order_line (34 cols) |
Receiving → Inventory + Accounts Payable
เมื่อรับของ:
- สร้าง
inventory_move(inbound: supplier location → warehouse location) - Update
inventory_balance(+quantity) - สร้าง
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
| Table | Role | Key Columns | Proven By |
|---|---|---|---|
inventory_move | Event (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 NULL | Odoo stock_move (52 cols, 21 FK) |
inventory_balance | Running Balance | product_id + location_id + lot_id + branch_id (composite), quantity numeric, reserved_quantity numeric | Odoo 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
| Table | Role | Key Columns | Proven By |
|---|---|---|---|
journal_entry | Polymorphic Document | id 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_number | Odoo account_move (72 cols) |
journal_entry_line | Debit/Credit Lines | entry_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 FK | Odoo account_move_line (66 cols) |
account_account | Chart of Accounts | id 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_type | Document Type | เมื่อไหร่ |
|---|---|---|
entry | Journal Entry (ทั่วไป) | Manual adjustment |
sale_invoice | Customer Invoice (ใบแจ้งหนี้) | เมื่อขาย CRS course |
sale_credit | Customer Credit Note | เมื่อ refund |
purchase_bill | Vendor Bill (ใบวางบิล) | เมื่อรับ invoice จาก supplier |
purchase_credit | Vendor Credit Note | เมื่อ supplier refund |
payment | Payment (การชำระ) | เมื่อรับ/จ่ายเงิน |
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 เข้าด้วยกัน
| Component | What | How | Proven 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 Isolation | 100 สาขาใน 1 DB | branch_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 Dates | Period Locking per domain | fiscal_lock_date, tax_lock_date, sale_lock_date, purchase_lock_date, hard_lock_date — ห้ามแก้ entry ก่อนวันล็อก (จาก res_company live) | ch39 |
| FK Discipline | ON DELETE ทุก FK ตั้งแต่วันแรก | CASCADE (ลบ parent → ลบ children), RESTRICT (ห้ามลบถ้ายังอ้าง), SET NULL (ลบ parent → FK เป็น null) เลือกตาม semantic (Odoo มี 21 FK บน stock_move ทุกตัวมี ON DELETE) | ch35, ch39 |
| Numeric Precision | numeric ไม่ใช่ float4 | เงิน + จำนวน ทั้งหมดเป็น numeric — Odoo ใช้ numeric, NWFTH ใช้ decimal(22,6) GENCODE ยังเป็น real (float4) ต้องเปลี่ยนก่อนบันทึกเงิน | ch30 |
| Audit Trail | 3-Layer Audit | Layer 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 Patterns | i18n + per-branch + analytic | ชื่อหลายภาษา: name JSONB ต้นทุนต่อสาขา: standard_price JSONB Revenue attribution: analytic_distribution JSONB Custom fields: properties JSONB | ch35, 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)"]
| Phase | What | Tables to Create | Depends On | Verification |
|---|---|---|---|---|
| P0 (ด่วน) | Fix nextJera → PG IDENTITY + numeric + FK ON DELETE | ALTER existing tables | Nothing | nextJera removed; quantity/amount = numeric; every FK has ON DELETE |
| P1 | Inventory Module | inventory_move + inventory_balance + location | P0 | BOM consumption from svc_prd_mapping creates moves; balance updates; SKIP LOCKED under concurrent inserts |
| P2 | Sales Module | sale_order + sale_order_line | P1 | Confirm order → auto BOM explosion → inventory_move created; analytic_distribution flows |
| P3 | Accounting Module | journal_entry + journal_entry_line + account_account | P1, P2 | Every sale/purchase creates journal entry; check_balanced invariant enforced; hash chain works |
| P4 | Purchasing Module | purchase_order + purchase_order_line | P1, P3 | PO confirm → inbound move → AP entry; 3-way matching via purchase_line_id FK |
| P5 | Branch Isolation | ALTER all tables + RLS policies + lock date columns | P1-P4 | User 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.3 | numeric ทุกที่ (เงินและจำนวน) | ch30 |
MAX(id)+1 สำหรับ sequence | Race condition ภายใต้ concurrency — 2 users ได้เลขซ้ำ | PG IDENTITY (lock-free) + no_gap (FOR UPDATE NOWAIT) | ch30, ch32 |
| FK ไม่มี ON DELETE | Orphaned records / integrity violation เมื่อลบ parent | ระบุ CASCADE/RESTRICT/SET NULL ทุก FK ตั้งแต่วันแรก | ch35, ch39 |
| Set analytic หลัง POS session close | Session batching ยุบ granularity — per-doctor data สูญ | Set analytic_distribution บน POS line ก่อน session close | ch36 |
| App-level security แทน DB-level | Direct SQL bypass ir.rule ได้ — data leak | PostgreSQL RLS (Row-Level Security) ที่ DB enforce | ch40 |
| สร้างตารางแยกสำหรับ per-branch config | Table explosion — 100 สาขา × N config tables | JSONB company-dependent (1 column, key = branch_id) | ch35, ch39 |
| Audit แค่ last-touch (create/write date) | ไม่รู้ว่าใครเปลี่ยนอะไรเมื่อไหร่ | Field-level tracking + periodic snapshot + hash chain | ch36 |
| ออกแบบ ERP จากศูนย์ | ไม่มีเวลา 15 ปีแบบ Odoo | Adapt 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
| Priority | What | Business Impact | Risk 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 ต้องรู้
- GENCODE เป็น Product Master ที่ดีมาก — รหัสอ่านออก (Semantic Code) เป็นจุดแข็งจริง แม้แต่ ERP ระดับโลกก็ใช้แนวทางเดียวกัน ดังนั้นฐานถูกแล้ว ไม่ต้องเริ่มใหม่
- ระบบต้องเพิ่มแค่ 2 อย่าง เพื่อเป็น ERP จริง: Inventory Ledger (สมุดสต็อก) + Accounting Ledger (สมุดบัญชี) ทั้งสองมี template สำเร็จรูปจาก Odoo (15 ปี, หลายสิบล้าน users) — adapt ไม่ reinvent
- มีงานเร่งด่วน 1 ชิ้น: แก้วิธีแจกเลขรหัส ก่อนขยายสาขา ถ้าไม่แก้ = หลายสาขาแย่งเลขกัน = ข้อมูลเสียหาย
- ต้นทุนของการรอ: ยิ่งขยายสาขาก่อนเตรียมระบบ ยิ่งแก้ทีหลังแพง — แก้ตอนมีสาขาเดียว ถูกกว่าแก้ตอนมี 20 สาขา 10 เท่า
- Revenue per Doctor ได้ฟรี: ถ้าออกแบบ Analytic Distribution ถูกตั้งแต่วันแรก CEO จะเห็น "หมอ A ทำรายได้เท่าไหร่ สาขา B ทำรายได้เท่าไหร่" แบบ real-time ตั้งแต่ระบบ Ledger เสร็จ ไม่ต้องรอ BI/report พิเศษ