GENCODE→Odoo: การตัดสินใจ, Schema Migration & Open Questions
จากการตัดสินใจสำคัญ (Odoo = single source of truth) ผ่าน schema migration 10 ขั้นตอน ไปจนถึงคำถามที่ต้องปิดก่อนจบ product design — บทสุดท้ายของ YD-KM · From the Odoo SSOT decision through 10 schema changes to the open questions closing product design.
สารบัญ · Table of Contents
บริบท · Context
Discord thread #gencode-spec-with-glueboy เริ่มจากคำถามง่าย ๆ ว่า "ตั้งรหัสคอร์สยังไง" แล้ว evolve กลายเป็นการออกแบบ master data architecture เต็มรูปแบบ — 14 phases, 752 messages, ระหว่าง 8-21 มิถุนายน 2026 ผู้ร่วมออกแบบหลักคือ GlueBoy (PRD owner), Wind (ERP perspective), และ Oracle agents
ระหว่างทาง Wind ถูกถาม 5 คำถามจากมุม ERP จริง คำถามเหล่านี้สำคัญเพราะเป็นจุดที่ "ทฤษฎีการออกแบบรหัส" ชนกับ "ความเป็นจริงของระบบ ERP ที่รันมาหลายปี" — Odoo 19, NWFTH (BatchMaster), และ GENCODE ออกแบบคนละแบบ แต่ต้องทำงานร่วมกันได้
คำถามที่ 1: vendor/supplier อยู่ใน code ไหม?
คำตอบ: ไม่ — vendor เป็น table relation เท่านั้น ไม่ใช่ส่วนหนึ่งของ product code
Odoo 19 ใช้ product.supplierinfo เป็น many-to-many relation ระหว่าง product กับ vendor — product หนึ่งตัวมีได้หลาย vendor, vendor หนึ่งรายส่งได้หลาย product แต่ละ record มี price, lead time, minimum quantity เป็นของตัวเอง:
// Odoo: product.supplierinfo
product_tmpl_id → product.template (สินค้า)
partner_id → res.partner (vendor)
price decimal (ราคาของ vendor รายนี้)
min_qty decimal (ขั้นต่ำ)
delay int (lead time วัน)
NWFTH ก็แยก vendor ออกจาก Itemkey เช่นกัน — vendor อยู่ใน POLIN (Purchase Order Line) ไม่ได้ฝังใน item master
เหตุผลที่ต้องแยก: 1 product มีได้หลาย vendor (เจ้าประจำ, เจ้าสำรอง, เจ้าราคาดี) ถ้าฝัง vendor ลงใน code ทุกครั้งที่เปลี่ยน vendor ต้องเปลี่ยน code — สินค้าตัวเดิมได้รหัสใหม่ ซึ่งเป็น identity crisis
PRD ของ GENCODE locked format ถูกที่ไม่มี vendor ใน code — direction สอดคล้องกับทั้ง Odoo และ NWFTH
คำถามที่ 2: brand / trademark / manufacturer / MAH แยกยังไง?
คำตอบ: 4 entity ที่ต่างกันทั้งหมด — ห้ามรวมเป็นอันเดียว
| Entity | บทบาท | ตัวอย่าง (Botox) | อยู่ใน code? |
|---|---|---|---|
| Manufacturer | ผลิตจริง (โรงงาน) | AbbVie Inc. | ไม่ — เปลี่ยนได้ (ย้ายโรงงาน) |
| MAH (Marketing Authorization Holder) | ถือทะเบียนยา/เครื่องสำอาง | Allergan Thailand | ไม่ — regulatory entity |
| Trademark | ชื่อการค้า = identity ของ product | Botox® | ได้ — เป็น identity ที่มั่นคง |
| Vendor | ซื้อจากใคร (ตัวแทนจำหน่าย) | ABC Trading Co. | ไม่ — table relation (Q1) |
ตัวอย่างให้เห็นชัด: Botox (trademark) ผลิตโดย AbbVie (manufacturer) ที่ Westport, Ireland ถือทะเบียนโดย Allergan Thailand (MAH) ซื้อผ่าน ABC Trading (vendor) — สี่สิ่งนี้เปลี่ยนได้แยกกัน ถ้ารวมเป็นหนึ่ง code ทุกครั้งที่เปลี่ยน vendor หรือย้ายโรงงาน code ก็ต้องเปลี่ยน
Odoo 19 แยกชัด: product.template (สินค้า) + res.partner tagged เป็น manufacturer/supplier + product.supplierinfo (vendor relation) trademark คือ field บน product ไม่ใช่ entity แยก
ใน GENCODE: trademark สามารถเป็นส่วนหนึ่งของ code ได้ (เป็น identity ที่ค่อนข้างมั่นคง — Botox คือ Botox) ส่วน manufacturer, MAH, vendor ต้องเป็น table relation แยก
คำถามที่ 3: delimiter เป็นแค่ display only?
คำตอบ: ใช่ — dash เป็น cosmetic เท่านั้น source of truth คือ typed columns
หลักการสำคัญ: full_code ควรเป็น GENERATED ALWAYS AS — PostgreSQL computed column ที่ concatenate จาก typed columns อัตโนมัติ:
-- glyph-oracle target schema
full_code GENERATED ALWAYS AS (
type || '-' || subtype || '-' || category || '-' ||
differentiator || '-' || seq || '-' || variant
) STORED
ด้วยวิธีนี้:
- Source of truth = คอลัมน์แยก (
type,subtype,category, ...) ที่ index ได้, query ได้, validate ได้แต่ละช่อง - Dash = cosmetic separator ที่ DB สร้างให้อัตโนมัติ ไม่ต้อง parse ไม่ต้อง split
- ไม่มีวัน out-of-sync — ถ้าแก้
category,full_codeอัพเดทเอง (ต่างจากเก็บ 2 ที่แล้ว sync มือ)
Odoo 19 ใช้หลักเดียวกัน: default_code = compute='_compute_default_code' (Python computed, ไม่ใช่ DB generated แต่หลักการเดียวกัน — code เป็น projection ของ data ไม่ใช่ data เอง)
คำถามที่ 4: code ≠ PK?
คำตอบ: ถูก 100% — product code ต้องไม่เป็น primary key
ทั้ง 3 ระบบที่ศึกษายืนยัน:
| ระบบ | PK (ตัวตนจริง) | Code (มนุษย์อ่าน) | FK ใน transaction |
|---|---|---|---|
| Odoo 19 | id int SERIAL | default_code (computed) | product_id int |
| NWFTH | Itemkey nvarchar(18)* | Itemkey (meaningful) | Itemkey + InTransID int (ledger) |
| GENCODE (ควร) | id int IDENTITY | full_code (generated) | id int |
*NWFTH ใช้ meaningful key (Itemkey) เป็นทั้ง PK และ FK ใน order line — แต่ inventory ledger (Mintxdh) ใช้ InTransID int เป็น surrogate PK เพราะ volume สูง นี่คือ hybrid ที่พิสูจน์ว่า meaningful key ทำงานได้ใน master data แต่ surrogate ดีกว่าใน high-volume transactions
สิ่งที่ GENCODE ต้องทำ (P0): ย้าย FK จาก full_code text → id int ใน transaction tables ทั้งหมด ดู ch34 (Migration Runbook) สำหรับ step-by-step ไม่ใช่แค่เรื่อง performance — เป็นเรื่อง identity stability: ถ้า code เปลี่ยน (จัดหมวดใหม่) FK ที่ผูกกับ code พังหมด แต่ FK ที่ผูกกับ int id ไม่เคยพัง
คำถามที่ 5: ต่อ Odoo ได้ไหม?
คำตอบ: ได้ — ผ่าน mapping layer ที่แปลง field-to-field
GENCODE ไม่ต้องกลายเป็น Odoo module — แค่สร้าง sync API ที่ push ข้อมูลเข้า Odoo ทางเดียว สถาปัตยกรรม: Odoo = single source of truth (backend ทั้งหมด) · GENCODE + JERA = หน้าบ้าน (ตั้งรหัส/naming + POS) ที่เขียนทะลุเข้า Odoo · Data flows ONE WAY: front-end → Odoo · Odoo wins on conflict
Mapping หลัก:
| GENCODE field | Odoo field | หมายเหตุ |
|---|---|---|
full_code | product.template.default_code | internal reference ที่มนุษย์เห็น |
jera_code (C-0001) | product.template.barcode | scan ได้ที่ POS |
type (CRS/SVC/PRD) | product.type (consu/service/product) | layer → Odoo product type |
category + subtype | product.category (categ_id) | hierarchical category |
differentiator | product.attribute + product.attribute.value | Odoo variant system |
variant (doctor code) | analytic_distribution (JSON) | management reporting dimension |
| SVC → PRD links | mrp.bom (Bill of Materials) | service = recipe ของ products ที่ใช้ |
name_th / name_en | product.template.name + ir.translation | multilingual product name |
is_active | product.template.active | archive/unarchive |
| vendor records | product.supplierinfo | vendor × product × price |
สิ่งที่ต้อง build:
- Sync API (REST หรือ Odoo XML-RPC) — GENCODE creates/updates product → push to Odoo; Odoo transactions → pull back for reporting
- Idempotent upsert — ใช้
jera_codeเป็น matching key (เสถียรกว่าfull_codeที่เปลี่ยนได้) - Conflict resolution — Odoo wins on ALL conflicts (Odoo = SSOT); GENCODE/JERA push data one-way into Odoo
สรุป · Direction
Direction ที่ GENCODE PRD เลือกถูกแล้ว:
- Surrogate key (
idint) เป็น PK + FK — identity ที่ไม่เคยเปลี่ยน - Typed columns เป็น source of truth — query ได้, index ได้, validate ได้แต่ละช่อง
- Generated label (
full_code) เป็น projection — มนุษย์อ่าน, ไม่ต้อง maintain แยก - Vendor/manufacturer/MAH แยกเป็น relation — ไม่ฝังใน code
- Dash = cosmetic — DB สร้างให้, ไม่ต้อง parse
P0 ที่ต้องทำก่อน Odoo integration: FK migration จาก full_code text → id int ในทุก transaction table (ดู ch34 Runbook) ถ้าไม่ทำ ทุกครั้งที่จัดหมวดใหม่ = references พังหมด
5 คำถามนี้ยืนยันว่า GENCODE กำลังเดินบนเส้นทางที่ถูก — ปัญหาที่เหลือเป็นเรื่อง execution (migration, sync API) ไม่ใช่ direction
Mapping Architecture: GENCODE → Odoo Field Map
| # | GENCODE | Odoo 19 | Type | Direction |
|---|---|---|---|---|
| 1 | id (surrogate PK) | product.template.id | int → int | GENCODE → Odoo (create) |
| 2 | full_code (generated) | default_code (char) | text → char | GENCODE → Odoo |
| 3 | jera_code (C-0001) | barcode (char) | text → char | GENCODE → Odoo |
| 4 | type (CRS/SVC/PRD) | detailed_type (selection) | enum → selection | GENCODE → Odoo |
| 5 | category + subtype | categ_id (many2one) | text → FK | GENCODE → Odoo |
| 6 | differentiator | attribute_line_ids | text → o2m | GENCODE → Odoo |
| 7 | variant (doctor) | analytic_distribution | text → JSON | GENCODE → Odoo |
| 8 | SVC→PRD links | mrp.bom + bom_line_ids | relation → o2m | GENCODE → Odoo |
| 9 | name_th / name_en | name + ir.translation | text → char+i18n | GENCODE → Odoo |
| 10 | vendor records | product.supplierinfo | relation → o2m | GENCODE → Odoo |
ภาพรวม: 10 Changes × 3 Priority Levels
การจะนำ GENCODE product_registry (PRD layer) ไป sync กับ Odoo ได้จริง ต้องแก้ schema ทั้ง foundation (โครงสร้างพื้นฐาน), columns (เพิ่ม field ที่ Odoo ต้องการ), และ tables (ตารางใหม่ที่ยังไม่มี) — รวม 10 รายการ แบ่งเป็น 3 ระดับความเร่งด่วน:
| Priority | ขอบเขต | เวลาประมาณ | เหตุผล |
|---|---|---|---|
| P0 | Schema Foundation (4 items) | ~1 day | ก่อน plug อะไรเลย — ถ้าไม่แก้ตรงนี้ Odoo จะ reject data ตั้งแต่ row แรก |
| P1 | Add Columns for Odoo (2 items) | 1–2 days | Odoo modules (Inventory, Sales, Purchase) ต้องการ field เหล่านี้ — ไม่มีก็ sync ไม่ครบ |
| P2 | Build PLANNED Tables (4 items) | 2–3 days | ตารางที่ต้องสร้างใหม่ — vendor, lot, branch, regulatory |
P0 — Schema Foundation (ก่อน plug อะไรเลย)
1. full_code → GENERATED ALWAYS AS column
ปัจจุบัน full_code เป็น writable column ที่ application เขียนมือ — เสี่ยง diverge จาก typed segments (ch34 อธิบายปัญหาเต็ม) · Odoo ไม่มี concept นี้เลย — product.product.default_code ของ Odoo เป็น optional display field ไม่ใช่ compound key
ทำไมต้องแก้ก่อน: ถ้า full_code ไม่ตรงกับ segments → sync ไป Odoo จะเจอ mismatch → data integrity fail ตั้งแต่ day 1
-- Step 1: Verify ว่า full_code ปัจจุบันตรงกับ segments ทุกแถว
SELECT id, full_code,
'PRD-' || type || '-' || category || '-' || item || '-' || variant AS computed
FROM product_registry
WHERE full_code != 'PRD-' || type || '-' || category || '-' || item || '-' || variant;
-- ต้องได้ 0 rows
-- Step 2: Drop แล้วสร้างใหม่เป็น GENERATED
ALTER TABLE product_registry DROP COLUMN full_code;
ALTER TABLE product_registry
ADD COLUMN full_code text GENERATED ALWAYS AS (
'PRD-' || type || '-' || category || '-' || item || '-' || variant
) STORED;
-- Step 3: Unique constraint (ป้องกัน duplicate codes)
CREATE UNIQUE INDEX idx_product_registry_full_code ON product_registry (full_code);
ทำเหมือนกันสำหรับ course_registry (CRS) และ service_registry (SVC) — ดู runbook เต็มที่ ch34
2. Mapping FK → surrogate id (int) แทน text full_code
ปัจจุบัน mapping tables อ้าง full_code (text, 35-41 chars) — JOIN ช้า, rename = cascade UPDATE ทุก FK · Odoo ใช้ integer FK ล้วน (verified จาก pg_constraint ใน ch35)
เปรียบเทียบ performance:
| FK Type | Size | JOIN Speed | Rename Impact |
|---|---|---|---|
| text full_code (41 chars) | ~45 bytes/row | string compare O(n) | UPDATE ทุก FK row |
| integer id | 4 bytes/row | int compare O(1) | ไม่กระทบ FK เลย |
-- Phase 2 migration (ตาม ch34 runbook)
-- 1. Add new integer FK column
ALTER TABLE svc_prd_mapping ADD COLUMN product_id integer;
-- 2. Backfill จาก text → int
UPDATE svc_prd_mapping SET product_id = (
SELECT id FROM product_registry WHERE full_code = svc_prd_mapping.prd_code
);
-- 3. Set NOT NULL + constraint
ALTER TABLE svc_prd_mapping ALTER COLUMN product_id SET NOT NULL;
ALTER TABLE svc_prd_mapping
ADD CONSTRAINT fk_svc_prd_product FOREIGN KEY (product_id)
REFERENCES product_registry(id) ON DELETE RESTRICT;
-- 4. Drop old text FK column
ALTER TABLE svc_prd_mapping DROP COLUMN prd_code;
3. ON DELETE discipline: RESTRICT vs SET NULL
Odoo ใช้ pg_constraint อย่างเคร่งครัด — pattern ที่ verified จาก Odoo 19 source:
| FK Type | ON DELETE | ตัวอย่าง Odoo | ตัวอย่าง GENCODE |
|---|---|---|---|
| Master data FK | RESTRICT | sale_order_line.product_id → product.product | svc_prd_mapping.product_id → product_registry |
| Audit / optional FK | SET NULL | stock_move.write_uid → res_users | product_registry.created_by → users |
| Ownership FK | CASCADE | product.product.product_tmpl_id → product.template | ไม่มี (3-layer ไม่ inherit) |
กฎ: FK ที่ชี้ master data (product, service, course) = RESTRICT เสมอ — ห้ามลบ product ถ้ายังมี transaction อ้าง · FK ที่เป็น audit trail (who created/modified) = SET NULL — ลบ user ได้โดย audit history ไม่หาย
-- ตัวอย่าง: เพิ่ม ON DELETE ให้ FK ที่มีอยู่
ALTER TABLE svc_prd_mapping
DROP CONSTRAINT IF EXISTS fk_svc_prd_product,
ADD CONSTRAINT fk_svc_prd_product FOREIGN KEY (product_id)
REFERENCES product_registry(id) ON DELETE RESTRICT;
-- Audit FK
ALTER TABLE product_registry
ADD CONSTRAINT fk_prd_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL;
4. quantity real → numeric(16,4)
ปัญหา: GENCODE ใช้ real (float4) สำหรับ quantity — float มี rounding error ที่ยอมรับไม่ได้ใน accounting:
-- Float rounding ตัวอย่างจริง:
SELECT 0.1::real + 0.2::real; -- = 0.30000001192092896 (ไม่ใช่ 0.3!)
SELECT 0.1::numeric + 0.2::numeric; -- = 0.3 (exact)
ระบบอื่นใช้อะไร:
| ระบบ | Type สำหรับ qty/money | ทำไม |
|---|---|---|
| Odoo 19 | numeric (via ORM Float with digits) | accounting ต้อง exact — Σdebit = Σcredit |
| NWFTH BatchMaster | decimal(22,6) | industrial qty ต้อง precise ถึง 6 decimal |
| GENCODE (ปัจจุบัน) | real (float4) | ❌ rounding error = accounting fail |
| GENCODE (แก้) | numeric(16,4) | ✅ exact, รองรับ qty สูงสุด 999,999,999,999.9999 |
-- Migration: real → numeric(16,4)
ALTER TABLE product_registry
ALTER COLUMN quantity TYPE numeric(16,4) USING quantity::numeric(16,4);
-- สำหรับ price columns (ถ้ามี):
-- ALTER TABLE ... ALTER COLUMN price TYPE numeric(16,4);
ทำไม (16,4) ไม่ใช่ (22,6)?: NWFTH ใช้ (22,6) เพราะ industrial scale (chemical batches ต้อง precision 6 decimals) · คลินิกไม่ต้องการ 6 decimals — 4 เพียงพอ (0.0001 ml/mg) และ 16 digits total รองรับ scale ถึงพันล้าน
P1 — เพิ่ม Columns สำหรับ Odoo
5. Odoo Product Fields: detailed_type, tracking, list_price, standard_price, weight
Odoo product.template / product.product ต้องการ field เหล่านี้เพื่อทำงานกับ Inventory + Accounting modules:
| Odoo Field | Type | Values | GENCODE Column ใหม่ | ทำไมต้องมี |
|---|---|---|---|---|
detailed_type | varchar(16) | 'product' / 'consu' / 'service' | detailed_type | Odoo ใช้แยก storable (สร้าง stock.quant) vs consumable vs service |
tracking | varchar(8) | 'none' / 'lot' / 'serial' | tracking | กำหนดว่า product ต้อง track lot/serial หรือไม่ (cold chain = 'lot') |
list_price | numeric(16,4) | ราคาขาย | list_price | Odoo sales module ดึง default price จากตรงนี้ |
standard_price | numeric(16,4) | ราคาทุน | standard_price | Odoo stock valuation + COGS calculation |
weight | numeric(10,3) | น้ำหนัก (kg) | weight | Odoo delivery module ใช้คำนวณค่าส่ง |
-- Add Odoo-compatible columns
ALTER TABLE product_registry
ADD COLUMN detailed_type varchar(16) NOT NULL DEFAULT 'product'
CHECK (detailed_type IN ('product', 'consu', 'service')),
ADD COLUMN tracking varchar(8) NOT NULL DEFAULT 'none'
CHECK (tracking IN ('none', 'lot', 'serial')),
ADD COLUMN list_price numeric(16,4) NOT NULL DEFAULT 0,
ADD COLUMN standard_price numeric(16,4) NOT NULL DEFAULT 0,
ADD COLUMN weight numeric(10,3);
Default values: detailed_type = 'product' (storable) เพราะ PRD layer ส่วนใหญ่เป็นของจริงที่ track ใน inventory · Service items ใช้ 'service' (ไม่สร้าง stock.quant) · Consumables (เช่น ถุงมือ, สำลี) ใช้ 'consu'
6. UOM Dictionary Table — แทน free-text unit column
ปัจจุบัน GENCODE ใช้ free-text unit column (เช่น 'ml', 'cc', 'piece', 'box') — ไม่มี conversion factor ไม่มี grouping · Odoo ใช้ structured UOM system:
graph LR
subgraph ODOO["Odoo UOM System"]
CAT["uom.category
(Unit, Weight, Volume, Time)"]
UOM["uom.uom
name + factor + rounding"]
CAT -->|"1:N"| UOM
end
subgraph GENCODE_NEW["GENCODE UOM (ใหม่)"]
GCAT["uom_categories
id, name"]
GUOM["uom_uom
id, name, category_id,
factor, uom_type, rounding"]
GCAT -->|"1:N"| GUOM
GUOM -->|"FK"| PRD["product_registry.uom_id"]
end
-- สร้าง UOM dictionary tables
CREATE TABLE uom_categories (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(64) NOT NULL UNIQUE
);
CREATE TABLE uom_uom (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(64) NOT NULL,
category_id integer NOT NULL REFERENCES uom_categories(id) ON DELETE RESTRICT,
uom_type varchar(16) NOT NULL DEFAULT 'bigger'
CHECK (uom_type IN ('bigger', 'reference', 'smaller')),
factor numeric(20,10) NOT NULL DEFAULT 1.0,
rounding numeric(16,6) NOT NULL DEFAULT 0.01,
active boolean NOT NULL DEFAULT true
);
-- Seed initial categories (ตาม Odoo pattern)
INSERT INTO uom_categories (name) VALUES
('Unit'), ('Weight'), ('Volume'), ('Time'), ('Length');
-- Seed common UOMs
INSERT INTO uom_uom (name, category_id, uom_type, factor, rounding) VALUES
('Piece', 1, 'reference', 1.0, 1.0),
('Box', 1, 'bigger', 1.0, 1.0), -- factor set per product
('kg', 2, 'reference', 1.0, 0.001),
('g', 2, 'smaller', 0.001, 0.001),
('ml', 3, 'reference', 1.0, 0.01),
('cc', 3, 'reference', 1.0, 0.01), -- cc = ml
('L', 3, 'bigger', 1000.0, 0.001);
-- Add FK to product_registry
ALTER TABLE product_registry
ADD COLUMN uom_id integer REFERENCES uom_uom(id) ON DELETE RESTRICT;
Conversion factor: factor = จำนวน reference units ใน 1 unit นี้ · เช่น 1 kg = 1000 g → g.factor = 0.001 (1g = 0.001 kg) · Odoo ใช้ formula: qty_in_reference = qty * factor เสมอ
P2 — Build PLANNED Tables
7. item_vendor (Product × Vendor → Cost/Lead-time)
Maps to Odoo product.supplierinfo — เก็บว่าสินค้าแต่ละตัวซื้อจาก vendor ไหน ราคาเท่าไหร่ lead time กี่วัน:
CREATE TABLE item_vendor (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id integer NOT NULL REFERENCES product_registry(id) ON DELETE RESTRICT,
vendor_name varchar(128) NOT NULL,
vendor_code varchar(64), -- supplier's internal code for this item
price numeric(16,4) NOT NULL, -- purchase price
currency varchar(3) NOT NULL DEFAULT 'THB',
min_qty numeric(16,4) NOT NULL DEFAULT 1,
lead_time_days integer NOT NULL DEFAULT 7,
is_preferred boolean NOT NULL DEFAULT false,
date_start date,
date_end date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
| GENCODE field | Odoo field (product.supplierinfo) | หมายเหตุ |
|---|---|---|
| product_id | product_tmpl_id / product_id | Odoo link ที่ template level; GENCODE ไม่มี template → link ที่ product ตรง |
| vendor_name | partner_id → res.partner.name | Odoo ใช้ FK ไป res.partner; GENCODE ยังไม่มี partner table |
| price | price | ราคาซื้อจาก vendor นี้ |
| min_qty | min_qty | minimum order quantity |
| lead_time_days | delay | วันที่ต้องรอหลัง PO |
| is_preferred | sequence (lowest = preferred) | Odoo ใช้ ordering; GENCODE ใช้ boolean ง่ายกว่า |
8. product_lot (Lot Tracking + Expiry for FEFO Cold Chain)
Maps to Odoo stock.lot — จำเป็นสำหรับ cold chain (filler, botox, vaccine ต้อง track lot + expiry):
CREATE TABLE product_lot (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id integer NOT NULL REFERENCES product_registry(id) ON DELETE RESTRICT,
lot_name varchar(64) NOT NULL, -- lot number (e.g., 'LOT-2026-06-A')
expiration_date date, -- วันหมดอายุ → FEFO picking
use_date date, -- best before date
removal_date date, -- วันที่ต้องถอนออก (recall)
alert_date date, -- วันแจ้งเตือนก่อนหมดอายุ
quantity_on_hand numeric(16,4) NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (product_id, lot_name)
);
| GENCODE field | Odoo field (stock.lot) | หมายเหตุ |
|---|---|---|
| lot_name | name | unique per product |
| expiration_date | expiration_date | FEFO: First Expired First Out |
| use_date | use_date | best before |
| removal_date | removal_date | forced recall date |
| alert_date | alert_date | notification trigger |
FEFO Cold Chain: สินค้าเวชภัณฑ์ (Botox, Filler, Vaccines) ต้องจ่ายของที่ ใกล้หมดอายุก่อน (First Expired First Out) ไม่ใช่ FIFO ธรรมดา · Odoo Inventory module มี FEFO strategy built-in ที่ดึง expiration_date จาก stock.lot ตรง ๆ
9. product_branch (Per-Branch Active + Par-Level + Local Cost)
Maps to Odoo res.company + ir.rule (row-level security per branch) — เก็บว่าสินค้าไหน active ที่สาขาไหน par level เท่าไหร่:
CREATE TABLE product_branch (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id integer NOT NULL REFERENCES product_registry(id) ON DELETE RESTRICT,
branch_id integer NOT NULL, -- FK to branches table (ch32)
is_active boolean NOT NULL DEFAULT true,
par_level numeric(16,4), -- reorder point (เมื่อ qty ลดถึง par → auto PO)
max_level numeric(16,4), -- maximum stock level
local_cost numeric(16,4), -- branch-specific cost (if different from standard)
UNIQUE (product_id, branch_id)
);
| GENCODE field | Odoo equivalent | หมายเหตุ |
|---|---|---|
| branch_id | company_id (res.company) | Odoo ใช้ company_id + ir.rule สำหรับ branch isolation (ch40) |
| is_active | active field + ir.rule | Odoo ใช้ company-specific property |
| par_level | product.product.reordering_min_qty (stock.warehouse.orderpoint) | Odoo เก็บใน orderpoint rules |
| local_cost | ir.property (company-specific standard_price) | Odoo ใช้ property mechanism |
Multi-Branch Pattern: Odoo ใช้ res.company.child_ids ("Branches" — ch40 verified) + ir.rule row-level security · GENCODE ใช้ explicit product_branch join table ซึ่ง ง่ายกว่า (query ตรง, ไม่ต้อง property mechanism) แต่ sync ได้ 1:1 กับ Odoo pattern
10. Regulatory Columns: manufacturer, mah, thai_reg_no, gtin
Thai GPP (Good Pharmacy Practice) + อย. compliance ต้องการข้อมูลเหล่านี้สำหรับเวชภัณฑ์:
ALTER TABLE product_registry
ADD COLUMN manufacturer varchar(128), -- ผู้ผลิต
ADD COLUMN mah varchar(128), -- Marketing Authorization Holder
ADD COLUMN thai_reg_no varchar(32), -- เลขทะเบียน อย.
ADD COLUMN gtin varchar(14); -- Global Trade Item Number (barcode)
-- Index สำหรับ regulatory lookup
CREATE INDEX idx_product_registry_thai_reg ON product_registry (thai_reg_no)
WHERE thai_reg_no IS NOT NULL;
CREATE INDEX idx_product_registry_gtin ON product_registry (gtin)
WHERE gtin IS NOT NULL;
| Column | Odoo equivalent | ใช้ทำอะไร |
|---|---|---|
manufacturer | product.template field (custom) | ระบุโรงงานผลิต — บังคับสำหรับ อย. |
mah | ไม่มี built-in (custom field) | ผู้รับอนุญาตนำเข้า — Thai FDA requires |
thai_reg_no | ไม่มี built-in (custom field) | เลขทะเบียน อย. เช่น "1A 2/62 (NBC)" |
gtin | product.product.barcode | Global barcode สำหรับ supply chain |
Thai GPP Compliance: คลินิกที่ใช้เวชภัณฑ์ (Botox, Filler, ยา) ต้องเก็บ manufacturer + mah + registration number ตามกฎหมาย · gtin ใช้สำหรับ barcode scanning ใน supply chain (GTIN-13/14 = EAN barcode)
Mapping Table: GENCODE → Odoo Field
| GENCODE (product_registry) | Odoo (product.product / product.template) | Direction | หมายเหตุ |
|---|---|---|---|
id | — | — | GENCODE PK; Odoo มี id ของตัวเอง |
full_code (GENERATED) | default_code | GENCODE → Odoo | sync เป็น internal reference |
type | categ_id (product.category) | GENCODE → Odoo | map type+category → Odoo category tree |
name_th | name (jsonb, key 'th_TH') | GENCODE → Odoo | Odoo 19 ใช้ jsonb สำหรับ translatable fields |
detailed_type | detailed_type | GENCODE → Odoo | storable/consumable/service — set at product creation, Odoo authoritative after sync |
tracking | tracking | GENCODE → Odoo | none/lot/serial — set at product creation, Odoo authoritative after sync |
list_price | list_price | GENCODE → Odoo | sale price |
standard_price | standard_price | GENCODE → Odoo (initial); Odoo computes after | cost — Odoo SSOT updates via stock valuation, GENCODE sets initial value only |
weight | weight | GENCODE → Odoo | kg |
uom_id (FK → uom_uom) | uom_id (FK → uom.uom) | map by name | match UOM by name/category |
gtin | barcode | GENCODE → Odoo | EAN/GTIN barcode |
item_vendor rows | seller_ids (product.supplierinfo) | GENCODE → Odoo | vendor list per product |
product_lot rows | stock.lot | GENCODE → Odoo (create); Odoo SSOT after | lot/serial records — front-end creates, Odoo owns lifecycle |
product_branch rows | company_id + ir.rule | GENCODE → Odoo | branch-specific activation |
Timeline Estimate
| Priority | Items | เวลา | Dependencies | Verify |
|---|---|---|---|---|
| P0 | #1 GENERATED column #2 Surrogate FK #3 ON DELETE #4 numeric type | ~1 day | ไม่มี — ทำก่อนทุกอย่าง | 0 diverge rows, INT JOINs, constraint check, exact arithmetic |
| P1 | #5 Odoo fields #6 UOM dictionary | 1–2 days | P0 done (FK ต้องเป็น int ก่อน) | all products have detailed_type + uom_id |
| P2 | #7 item_vendor #8 product_lot #9 product_branch #10 regulatory | 2–3 days | P0+P1 done | tables created, FK intact, seed data loaded |
รวม: 4–6 days สำหรับ schema ครบ (ไม่รวม application code / sync logic / testing)
Verification Checklist
| # | Check | Command | Expected |
|---|---|---|---|
| 1 | full_code = GENERATED | \d product_registry → check "GENERATED" | column shows GENERATED ALWAYS AS ... STORED |
| 2 | No text FK remaining | SELECT conname FROM pg_constraint WHERE confrelid = 'product_registry'::regclass; | all FK columns are integer type |
| 3 | ON DELETE correct | SELECT conname, confdeltype FROM pg_constraint WHERE conrelid = 'svc_prd_mapping'::regclass; | 'r' (RESTRICT) for master FKs |
| 4 | No float columns for qty/price | SELECT column_name, data_type FROM information_schema.columns WHERE table_name='product_registry' AND data_type='real'; | 0 rows |
| 5 | Odoo fields present | SELECT column_name FROM information_schema.columns WHERE table_name='product_registry' AND column_name IN ('detailed_type','tracking','list_price','standard_price','weight'); | 5 rows |
| 6 | UOM tables + FK | \d uom_uom + \d product_registry | uom_id FK exists |
| 7 | PLANNED tables exist | \dt item_vendor product_lot product_branch | 3 tables listed |
| 8 | Regulatory columns | SELECT column_name FROM information_schema.columns WHERE table_name='product_registry' AND column_name IN ('manufacturer','mah','thai_reg_no','gtin'); | 4 rows |
สิ่งที่ไม่ต้องแก้
Schema changes ข้างบนเตรียม product_registry ให้ Odoo-ready — แต่สิ่งต่อไปนี้ ไม่ต้องแก้ เพราะเป็นหน้าที่ของ Odoo เอง หรือเป็น GENCODE architecture ที่ดีอยู่แล้ว:
- 3-Layer Architecture (CRS/SVC/PRD) — ไม่ต้องแก้ · Odoo ใช้ product.template → product.product (2 layers) แต่ GENCODE 3-layer (Course → Service → Product) ดีกว่าสำหรับ clinic workflow · Sync ทำที่ PRD layer → Odoo product.product · CRS/SVC เป็น GENCODE-only layers
- Segment dictionaries (type_dict, category_dict, etc.) — ไม่ต้องแก้ · เป็น GENCODE-specific lookup สำหรับ code generation · Odoo ไม่ต้องรู้จัก dictionaries — แค่ได้ผลลัพธ์ (full_code as default_code)
- jera_code — ไม่ต้องแก้ · เป็น POS integration layer ระหว่าง GENCODE ↔ JERA · Odoo มี POS module ของตัวเอง — ถ้า migrate POS ค่อยจัดการ
- Transaction tables (sale, inventory moves, journal entries) — ไม่ต้องสร้างเอง · นี่คือ Odoo's job:
sale.order,stock.move/stock.quant,account.move/account.move.lineเป็น Odoo core modules ที่ proven ถึงหลักล้าน transactions · GENCODE เตรียม master data (product registry) ให้พร้อม — Odoo จัดการ transactions เอง
graph TB
subgraph GENCODE["GENCODE Scope (Master Data)"]
REG["product_registry
+ item_vendor
+ product_lot
+ product_branch
+ uom_uom"]
style REG fill:#eef2ff,stroke:#4f46e5
end
subgraph ODOO["Odoo Scope (Transactions)"]
SO["sale.order
sale.order.line"]
SM["stock.move
stock.quant"]
AM["account.move
account.move.line"]
style SO fill:#f0fdf4,stroke:#16a34a
style SM fill:#f0fdf4,stroke:#16a34a
style AM fill:#f0fdf4,stroke:#16a34a
end
REG -->|"sync product_id"| SO
REG -->|"sync product_id"| SM
REG -->|"sync product_id"| AM
สรุป: Odoo = single source of truth สำหรับทุกอย่าง (master data + transactions) · GENCODE เป็นระบบหน้าบ้านที่สร้าง product data แล้ว push เข้า Odoo ทางเดียว · Schema changes ใน chapter นี้ทำให้ GENCODE พูดภาษาเดียวกับ Odoo — int FK, numeric types, matching field names — เพื่อ push ได้โดยไม่ต้อง transform
1. ช่องว่าง: Schema-Ready ≠ Design-Done · ทำไมต้อง Elicit ตอนนี้
ch26–ch58 วาง รากฐานเชิงเทคนิค ครบแล้ว — product code (ch46/ch57), ledger blueprint (ch44), schema พร้อม Odoo (ch58) แต่ "design จบ" ไม่ได้แปลว่า "เขียน schema เสร็จ" สิ่งที่ยังขาดคือ การตัดสินใจเชิงธุรกิจ ที่กำหนด configuration — ภาษีไทย, การรับรู้รายได้คอร์สจ่ายล่วงหน้า, โครงสร้างนิติบุคคล, workflow lot/หมดอายุ ฯลฯ ทั้งหมดเป็นเรื่องที่ คลินิกต้องเลือก ไม่ใช่เรื่องที่ code ตอบเองได้
สถาปัตยกรรมที่ล็อกแล้ว (Wind, 2026-06-22): Odoo = single source of truth (backend / main database — master data + stock + การขาย + บัญชีทั้งหมด authoritative ที่ Odoo) · GENCODE + JERA = หน้าบ้าน (GENCODE = ระบบตั้งรหัส/naming, JERA = POS หน้าเคาน์เตอร์) ที่อ่าน/เขียนทะลุเข้า Odoo · นี่ refine ทิศทางใน ch57 ("GENCODE wins for master data") ให้คมขึ้นเป็น "Odoo ชนะทุกอย่าง เพราะเป็น SSOT เดียว" — ตัดปัญหา dual-master sync ออกไป เหลือ flow ทางเดียว: หน้าบ้าน → Odoo
graph LR D["Discovery
Workshop"] --> FG["Fit-Gap
vs Odoo 19"] FG --> M["MoSCoW
Prioritize"] M --> RTM["REQ-YD
Traceability"] RTM --> SO["Sign-off
→ Build/Config"]
วิธีใช้บทนี้: แต่ละ domain ข้างล่างมีโครงเดียวกัน — กรอบ (ทำไมสำคัญ) → ตัดสินใจแล้ว (อย่าถามซ้ำ, อ้าง chapter) → คำถามเปิด (สิ่งที่ workshop ต้องปิด) → คำตอบกำหนด (config/design ที่ผูกกับคำตอบ + phase ตาม ch44) → คำแนะนำที่ปรึกษา (default ที่ผมเสนอ เพื่อให้ design เดินหน้า ไม่ใช่แค่ถามค้างไว้)
2. สิ่งที่ล็อกแล้ว — อย่าถามซ้ำ · What's Already Locked
ก่อนเปิด workshop ต้องรู้ว่าอะไร ตัดสินใจไปแล้ว — ถามซ้ำ = เสียเวลาและเปิดประเด็นที่ปิดไปแล้ว ตารางนี้คือฐานที่ไม่ต้อง elicit:
| หัวข้อ | ตัดสินใจแล้ว | อ้างอิง |
|---|---|---|
| สถาปัตยกรรมรวม | Odoo = single source of truth (backend); GENCODE + JERA = หน้าบ้าน เขียนทะลุเข้า Odoo | Wind 2026-06-22 |
| Product code | 3-layer CRS/SVC/PRD; surrogate id = PK/FK; full_code = GENERATED projection; dash = cosmetic | ch46, ch57 |
| Vendor/Manufacturer/MAH | แยกเป็น table relation ไม่ฝังใน code; trademark อยู่ใน code ได้ | ch57 |
| Sequence numbering | เลิก SELECT MAX+1 → PostgreSQL IDENTITY (internal) + no_gap FOR UPDATE NOWAIT (เลขเอกสารตามกฎหมาย) | ch41, ch44 |
| Precision เงิน/จำนวน | numeric(16,4) — ห้าม float | ch44, ch58 |
| Inventory ledger | event-sourced (stock.move→stock.quant), SKIP LOCKED + merge, reserved_quantity | ch38, ch44 |
| Accounting ledger | double-entry, _check_balanced (Σdebit=Σcredit), inalterable_hash chain | ch36, ch44 |
| Branch isolation | single DB + company_id/branch_id + Row-Level Security + 5 lock dates | ch40, ch44, ch45 |
| Schema พร้อม Odoo | product_registry P0/P1/P2 (GENERATED, int FK, numeric, UOM, lot, item_vendor, product_branch, regulatory) | ch58 |
| POS mapping | JERA ↔ GENCODE auto-map (Chrome ext + mapper), jera_code = C-{id} ≤ 20 chars | ch45, ch46 |
ทุกคำถามข้างล่าง สมมุติฐานเหล่านี้เป็นจริง และต่อยอดจากมัน
3. ชะตาของ GENCODE DB & หน้าบ้าน–หลังบ้าน Contract
กรอบ: เมื่อ Odoo เป็น SSOT แล้ว คำถามแรกสุดคือ GENCODE DB (glyph-oracle/Neon ที่ถือ product_registry วันนี้) จะอยู่ในรูปไหน — นี่คือ fork ที่กำหนด integration ทั้งหมด
ตัดสินใจแล้ว (Wind): Odoo authoritative; GENCODE/JERA เขียนทะลุเข้า Odoo ทางเดียว
คำถามเปิด:
- GENCODE DB จะเป็น (ก) write-through/cache บางๆ (ยังมินต์รหัสที่ GENCODE แล้ว push เข้า Odoo ทันที) หรือ (ข) ปลดระวาง (ย้าย logic ตั้งรหัสไปเป็น Odoo module แล้วสร้าง product ตรงใน Odoo)?
- ใครเป็นเจ้าของ "การสร้างสินค้า/คอร์สใหม่" — Course Creator UI ของ GENCODE หรือหน้าจอ Odoo? ถ้า GENCODE ยังเป็นคนสร้าง แล้วใครกด "อนุมัติ" ให้เข้า Odoo?
- Contract หน้าบ้าน↔หลังบ้านเป็นแบบ real-time API (REST/XML-RPC ทุกครั้งที่ขาย) หรือ batch? JERA ที่หน้าเคาน์เตอร์ต้องทำงานต่อได้ไหมถ้า Odoo ล่ม (offline-first)?
- เมื่อ field ชนกัน (เช่น ราคาใน JERA ถูกแก้ ขณะ Odoo มีราคาใหม่) — Odoo ชนะเสมอ ใช่ไหม? มี field ไหนที่ GENCODE ยังเป็นเจ้าของ (เช่นโครงสร้างรหัส)?
คำตอบกำหนด: sync architecture, ownership ของ master-data creation, offline resilience ของ POS, conflict policy — เป็น P0 ก่อน integration ใดๆ
คำแนะนำที่ปรึกษา: เลือก (ก) write-through ในเฟสแรก — เก็บ Course Creator/มินต์รหัสที่ GENCODE (UX ดีอยู่แล้ว) แต่ทุก commit เขียนเข้า Odoo เป็น authoritative ทันทีผ่าน API idempotent (matching key =
jera_code) · JERA POS ควร offline-first + queue แล้ว post เข้า Odoo เมื่อ online (คลินิกหยุดขายไม่ได้เมื่อเน็ตล่ม) · Odoo ชนะทุก field ยกเว้น "โครงสร้าง segment ของรหัส" ที่ GENCODE เป็นเจ้าของ definition
4. โครงสร้างนิติบุคคล & Company · Legal Entity Structure
กรอบ: Odoo isolate ข้อมูลด้วย company_id — แต่ "company" = นิติบุคคล ไม่ใช่ "สาขา" การเลือกผิดตรงนี้แก้ทีหลังแพงมาก
ตัดสินใจแล้ว: single central DB + branch isolation ผ่าน RLS (ch40/ch44)
คำถามเปิด:
- Youngdo เป็น นิติบุคคลเดียว (1 Odoo company, สาขา = res.company child/branch) หรือ หลายนิติบุคคล (แต่ละสาขา/แฟรนไชส์จดทะเบียนแยก = Odoo multi-company + inter-company)?
- ต้องออก งบการเงินรวม (consolidated) ข้ามนิติบุคคลไหม หรือแต่ละนิติบุคคลแยกงบ?
- มีโมเดล แฟรนไชส์ ในแผนไหม (P&L แยก + royalty)? ถ้ามี เริ่มเมื่อไหร่ (กระทบ multi-company ตั้งแต่วันแรกไหม)?
- เลขผู้เสียภาษี/VAT registration — ใบเดียวทั้งกลุ่ม หรือแยกตามนิติบุคคล?
คำตอบกำหนด: Odoo company config, ว่า branch_id หรือ company_id เป็น primary isolation key, ความจำเป็นของ Odoo multi-company/inter-company, โครงสร้าง COD — P5 (branch isolation) แต่ต้องรู้ตั้งแต่ P0
คำแนะนำที่ปรึกษา: ถ้าทุกสาขาอยู่ใต้นิติบุคคลเดียว → 1 Odoo company + branches (res.company child_ids) + RLS (ตรงกับ ch40, ง่ายสุด) · เก็บแฟรนไชส์เป็น "future multi-company" อย่าออกแบบ inter-company ตอนนี้ถ้ายังไม่มีนิติบุคคลที่ 2 จริง — over-engineering
5. สาขา & ขอบเขต Rollout · Branches & Phasing
กรอบ: design รองรับ 100 สาขา (ch32) แต่ rollout จริงต้อง scope — over-build เฟสแรกทำให้ go-live ช้า
ตัดสินใจแล้ว: scalability target 100 สาขา; วันนี้ ~1 สาขา (ch46)
คำถามเปิด:
- วันนี้มี กี่สาขาจริง? go-live เฟส 1 จะเปิดกี่สาขา?
- มี "virtual branch" ไหม (เช่น IV drip lounge ในพื้นที่พาร์ทเนอร์, mobile service) ที่ไม่ใช่คลินิกเต็มรูป?
- แต่ละสาขาเป็น warehouse แยกใน Odoo (stock แยก) ใช่ไหม? มี central warehouse/คลังกลางไหม?
- ลำดับเลขเอกสาร — prefix ต่อสาขา (BKK-INV-2026-0001 vs CNX-INV-…) ตาม ch45 ใช่ไหม?
คำตอบกำหนด: จำนวน warehouse, RLS policy scope, sequence sharding (branch,type,period), แผน rollout — P5
คำแนะนำที่ปรึกษา: go-live 1–2 สาขานำร่อง ก่อน, พิสูจน์ flow แล้วค่อยขยาย · ออกแบบ schema/RLS ให้รองรับ N สาขาตั้งแต่แรก (ทำแล้วใน ch40) แต่ config จริงเท่าที่มี — เลขเอกสาร prefix ต่อสาขาตั้งแต่วันแรก (ย้อนแก้ยาก)
6. การขาย & Order-to-Cash · Sales
กรอบ: หัวใจของคลินิกคือการขายคอร์ส/บริการที่หน้าเคาน์เตอร์ (JERA) แล้วโพสต์เข้า Odoo เป็น sale.order → invoice → payment
ตัดสินใจแล้ว: JERA = POS หน้าบ้าน; Odoo ถือ sale.order/invoice (SSOT); 3-way match ฝั่งขาย (ch44/ch45)
คำถามเปิด:
- การขายเริ่มที่ไหน — JERA POS เสมอ, หรือมี "นัดล่วงหน้า/ใบเสนอราคา" ที่เป็น Odoo sale.order ก่อนลูกค้ามา?
- รับ มัดจำ/เงินดาวน์ (deposit) ไหม? ลงบัญชียังไง (เงินรับล่วงหน้า)?
- มี open bill / เพิ่มรายการกลางทรีตเมนต์ (add-on ระหว่างทำหัตถการ) ไหม? JERA รองรับไหม?
- ส่วนลด — ใครอนุมัติได้แค่ไหน (discount approval matrix)? ขายต่ำกว่าทุนได้ไหม?
- คืนเงิน/ยกเลิก — credit note flow เป็นยังไง (เกี่ยวกับ deferred revenue + คืน stock, ดู domain 7)?
- มี gift voucher / บัตรของขวัญ ไหม? ลงบัญชีเป็น liability จนกว่าจะใช้?
คำตอบกำหนด: sale.order workflow, deposit/down-payment accounting, POS↔Odoo contract สำหรับ open bill, approval rules, credit-note design — P2
คำแนะนำที่ปรึกษา: ใช้ Odoo sale.order เป็น backbone, JERA post เข้า Odoo เมื่อปิดบิล · มัดจำ → เงินรับล่วงหน้า (liability) จนยืนยันบริการ · ทำ discount approval matrix 2-3 ขั้นตั้งแต่แรก (คลินิกความงามส่วนลดเยอะ เป็นจุดรั่ว)
7. Deferred Revenue / คอร์สจ่ายล่วงหน้า (TFRS 15) ⭐ ศูนย์กลาง
กรอบ: นี่คือโจทย์บัญชีที่นิยามคลินิก — ลูกค้าจ่ายคอร์ส 10 ครั้งวันนี้ แต่ยังไม่ได้รับบริการ เงินนั้น ยังไม่ใช่รายได้ เป็นหนี้สิน (Contract Liability) รับรู้รายได้ทีละครั้งที่ทำบริการ (TFRS 15 / IFRS 15) ทำผิด = งบกำไรเกินจริง + ภาษีผิด
ตัดสินใจแล้ว: ต้องทำ deferred revenue ตาม TFRS 15 (ch45 ระบุเป็น requirement) แต่ยังไม่มีใครออกแบบ entry/trigger
คำถามเปิด:
- Trigger การรับรู้รายได้ คืออะไร — ทำบริการเสร็จต่อครั้ง? แพทย์เซ็นยืนยัน? ปิด POS session? (ต้องเลือกอันเดียวให้ชัด)
- entry ตอน ขาย (Dr เงินสด / Cr เงินรับล่วงหน้า) และตอน ใช้แต่ละครั้ง (Dr เงินรับล่วงหน้า / Cr รายได้) — สัดส่วนต่อครั้งเท่ากันหมด หรือถ่วงน้ำหนักตามมูลค่าบริการ?
- คอร์สหมดอายุ/ใช้ไม่ครบ — รับรู้รายได้ส่วนที่เหลือเมื่อไหร่ (เมื่อหมดอายุ)? นโยบายคืนเงินคอร์สที่เหลือ?
- คืนเงินกลางคอร์ส — กลับรายการเงินรับล่วงหน้าส่วนที่ยังไม่ใช้ยังไง (เชื่อมกับ credit note domain 6)?
- VAT ของคอร์สจ่ายล่วงหน้า — ออก tax invoice เต็มจำนวนตอนรับเงิน หรือทยอยตามการรับรู้? (จุดที่กฎภาษีไทย vs TFRS อาจต่างกัน — domain 11)
คำตอบกำหนด: journal entry design, การผูก SVC session consumption → revenue recognition, deferred-revenue schedule, รายงานยอดคงเหลือคอร์ส — P3 (และเป็น opening balance ตอน migration, domain 16)
คำแนะนำที่ปรึกษา: trigger = "บริการเสร็จต่อครั้ง + แพทย์/พนักงานยืนยันใน Odoo" (auditable ที่สุด) · ถ่วงน้ำหนักตามมูลค่า SVC ของแต่ละครั้งถ้าคอร์สผสมหลายบริการ ไม่ใช่หาร N เท่าๆ กัน · ต้องให้นักบัญชีคลินิก + ที่ปรึกษาภาษีร่วม sign-off เพราะ VAT timing ไทยอาจบังคับออกใบกำกับเต็มตอนรับเงิน — ต้อง reconcile กับ TFRS
8. บริการ & นัดหมาย · Service Delivery & Booking
กรอบ: SVC layer = สิ่งที่ลูกค้าได้รับจริง การ "ตัดครั้ง" จากคอร์สคือ trigger ของทั้ง revenue recognition (domain 7) และการตัด stock (domain 9)
ตัดสินใจแล้ว: 3-layer CRS→SVC→PRD; SVC→PRD = BOM/recipe (ch39/ch57)
คำถามเปิด:
- มีระบบ นัดหมาย/booking ไหม? เชื่อม Odoo (sale.order/appointment) หรือระบบนอก (LINE/หน้าเว็บ)? นัด = sale.order ล่วงหน้าไหม?
- "ตัดครั้ง" จากคอร์สเกิดที่ไหน — พนักงานกดใน JERA หรือ Odoo? ต้องมีลายเซ็น/ยืนยันแพทย์ไหม?
- จัดสรร แพทย์/ห้อง/เครื่อง ต่อ appointment ไหม (scheduling/capacity)? กระทบ revenue attribution ต่อแพทย์ (domain 12)
- No-show / เลื่อนนัด — มีค่าปรับไหม? กระทบคอร์สยังไง?
- เปลี่ยนรายการกลางคอร์ส (course amendment — สลับบริการ, ส่วนต่างราคา) ตาม ch45 — workflow + audit เป็นยังไง?
คำตอบกำหนด: appointment module เลือกใช้/ต่อ, session-consumption UI, capacity scheduling, การผูก SVC→analytic (แพทย์) — P2/P3
คำแนะนำที่ปรึกษา: เริ่มจาก "ตัดครั้ง" ใน Odoo (auditable, เป็น SSOT) + ยืนยันโดยพนักงาน/แพทย์ · booking ภายนอก (LINE) ให้ feed เข้า Odoo เป็น draft sale.order · scheduling เต็มรูป (ห้อง/เครื่อง) เป็น Should-have เฟส 2 ถ้ายังจัดมือไหว
9. Lot / FEFO / Sub-lot / Cold Chain · Inventory Traceability
กรอบ: เวชภัณฑ์ (Botox, filler, vaccine) ต้อง track lot + วันหมดอายุ จ่ายของใกล้หมดก่อน (FEFO) และ recall ได้ — schema พร้อมแล้ว (product_lot, ch58) แต่ workflow จริงยังไม่นิยาม
ตัดสินใจแล้ว: product_lot table, tracking='lot', FEFO, near-expiry alert, cold-chain receiving (ch58/ch45)
คำถามเปิด:
- สร้าง lot ตอนไหน — ตอน รับของ (PO receipt) เสมอ ใช่ไหม? ใครกรอกเลข lot + วันหมดอายุ?
- Sub-lot / ขวดเปิดแล้ว (Botox เปิดแล้วหมดอายุ 24 ชม., ตัดเป็นยูนิต) ตาม ch45 — track อัตโนมัติหรือมือ? ใครเป็นคนเปิด (พยาบาลข้างเตียง)?
- FEFO บังคับโดยระบบ (ห้ามจ่าย lot ใหม่ถ้า lot เก่ายังอยู่) หรือแค่แนะนำ?
- Near-expiry alert — เตือนที่ 30/60/90 วันก่อนหมด ตาม ch45 ใช่ไหม? เตือนใคร?
- Cold chain — บันทึกอุณหภูมิตอนรับของไหม? ของเสีย/ทิ้ง (wastage) ลงบัญชียังไง?
- ผูก lot ↔ คนไข้ (ฉีด lot ไหนให้ใคร) เพื่อ recall — เก็บไหม? (กระทบ PDPA, domain 14)
คำตอบกำหนด: lot creation workflow, sub-lot model, FEFO removal strategy ใน Odoo, alert config, wastage accounting, lot-to-patient link — P1/P2
คำแนะนำที่ปรึกษา: lot สร้างตอนรับของเสมอ + FEFO บังคับใน Odoo (built-in) · sub-lot ขวดเปิด = pain จริงของกำไร ("hidden profit killer #1" ch45) ควรมี UI ตัดยูนิตข้างเตียงตั้งแต่เฟสแรก · ผูก lot↔คนไข้ "เก็บ" เพื่อ recall แต่จัดเป็นข้อมูลส่วนบุคคลอ่อนไหว (domain 14)
10. ต้นทุน & Landed Cost · Costing & Valuation
กรอบ: วิธีคิดต้นทุนกำหนด COGS journal + การตั้งราคา — filler นำเข้าจากเกาหลีมีค่าขนส่ง+ภาษีนำเข้า ต้องรวมเป็นต้นทุนจริง (landed cost)
ตัดสินใจแล้ว: standard_price/list_price columns พร้อม (ch58); landed cost เป็น requirement (ch45)
คำถามเปิด:
- วิธีคิดต้นทุน — Weighted Average (WAC), FIFO, หรือ Standard Cost? (เลือกอันเดียว กระทบ COGS entry)
- ของนำเข้า — รวม customs/duty/freight เข้าต้นทุนต่อหน่วย (Odoo Landed Cost) ไหม? คิดสกุลเงินไหน (FX)?
- ต้นทุนต่างต่อสาขาไหม (local_cost ใน product_branch, ch58) หรือต้นทุนเดียวทั้งกลุ่ม?
คำตอบกำหนด: Odoo costing method config, Landed Cost module, COGS journal design, multi-currency — P3
คำแนะนำที่ปรึกษา: WAC เหมาะกับคลินิก (ของซื้อหลายล็อตหลายราคา) + เปิด Odoo Landed Cost สำหรับของนำเข้า · ต้นทุนเดียวทั้งกลุ่มก่อน ใช้ local_cost เฉพาะสาขาที่ต่างจริง
11. จัดซื้อ & 3-Way Match · Procurement
กรอบ: ซื้อเวชภัณฑ์มูลค่าสูง + ของควบคุม ต้องมี control (PO approval, 3-way match PO↔รับของ↔บิล)
ตัดสินใจแล้ว: 3-way match ฝั่งซื้อ, consolidated PO หลายสาขา, item_vendor table (ch44/ch45/ch58)
คำถามเปิด:
- PO approval — กี่ขั้น/วงเงินเท่าไหร่ต่อขั้น (approval matrix)?
- 3-way match tolerance — ต่างกี่ % ถึง flag (เช่น รับของน้อยกว่าสั่ง, ราคาบิลต่างจาก PO)?
- มี consignment (ฝากขาย/วางของ) ไหม? ของควบคุม/ยาเสพติดต้องมีทะเบียนพิเศษไหม?
- รวม demand หลายสาขาเป็น PO เดียว (ch45) — auto reorder ที่ par level (product_branch) ไหม?
คำตอบกำหนด: purchase.order workflow, approval rules, match tolerance config, reordering rules — P4
คำแนะนำที่ปรึกษา: ใช้ Odoo Purchase + 3-way match มาตรฐาน, tolerance ~2-5% · approval matrix 2 ขั้น (หัวหน้าสาขา → ผู้จัดการจัดซื้อ) ตามวงเงิน · auto-reorder ที่ par level เป็น Should-have เฟส 2
12. บัญชีไทย: VAT / WHT / COA / l10n_th 🔴 ตัวบล็อกใหญ่สุด
กรอบ: ไม่มี chapter ไหนแตะภาษีไทยเลย — แต่ไม่มี Chart of Accounts + กฎ VAT/WHT ก็ออกแบบ journal entry อัตโนมัติไม่ได้ นี่คือ blocker ใหญ่สุดของ P3
ตัดสินใจแล้ว: double-entry + hash chain + period lock (ch44); WHT 3% + 50 ทวิ สำหรับค่าแพทย์เป็น requirement (ch45)
คำถามเปิด:
- คลินิกจด VAT ไหม? ออกใบกำกับภาษีเต็มรูป? บริการคลินิก/ยา ตัวไหน VAT 7% ตัวไหนยกเว้น (บริการแพทย์บางอย่างได้รับยกเว้น)?
- ใช้ Odoo l10n_th (Thai localization: COA, ภ.พ.30, ภ.ง.ด.) หรือ COA custom? เวอร์ชัน Community มี l10n_th ครบไหม?
- โครงสร้าง Chart of Accounts — ผังบัญชีคลินิก (รายได้บริการ/ขายสินค้า, COGS, เงินรับล่วงหน้า/deferred, AR, AP, VAT ขาย/ซื้อ, WHT ค้างจ่าย)?
- WHT — หัก ณ ที่จ่ายกรณีไหนบ้าง (ค่าแพทย์, ค่าเช่า, ค่าบริการ)? ออกหนังสือรับรอง 50 ทวิ + ภ.ง.ด.3/53 อัตโนมัติไหม?
- e-Tax invoice / e-Receipt (สรรพากร) — ต้องส่งไหม? (เชื่อม hash chain ch44)
- รอบบัญชี + period lock — ปิดงวดยังไง, ใครล็อกได้ (5 lock dates ch44)?
คำตอบกำหนด: account.account (COA), tax (account.tax VAT 7% + tax_group_id), WHT config, l10n_th module decision, e-Tax integration, fiscal calendar — P3 blocker
คำแนะนำที่ปรึกษา: เริ่มจาก l10n_th (COA ไทย + tax + ภ.พ.30 มาให้) แล้ว customize ทับ — ถูกกว่าสร้าง COA เอง · ต้องดึง นักบัญชีคลินิก + ที่ปรึกษาภาษี เข้า workshop domain นี้โดยตรง (เกินขอบเขตที่ design เดาเองได้) · จับ VAT timing ของคอร์สจ่ายล่วงหน้าให้ชน TFRS (domain 7) ตั้งแต่แรก
13. ค่าตอบแทนแพทย์ & Payroll · Doctor Commission
กรอบ: ค่าตอบแทนแพทย์ + WHT เป็นทั้งต้นทุนใหญ่และภาระภาษี โครงสร้างกำหนด AP/payroll workflow
ตัดสินใจแล้ว: revenue attribution ตามแพทย์ผ่าน analytic_distribution; WHT 3% + 50 ทวิ (ch45)
คำถามเปิด:
- แพทย์เป็น พนักงาน (payroll) หรือ ผู้รับจ้างอิสระ (vendor bill ต่อเดือน)? (กำหนด WHT + workflow)
- โครงสร้างค่าคอม — flat %, ขั้นบันได (tiered), หรือต่างตามประเภทบริการ?
- คิดคอมจาก revenue ที่รับรู้ หรือจากยอดขายคอร์ส (เกี่ยว deferred revenue domain 7)?
- แพทย์หมุนหลายสาขา (doctor rotation, ch45) — attribution ตามแพทย์ แต่ตัด stock ตามสาขาที่ทำ ใช่ไหม?
คำตอบกำหนด: employee vs vendor model, commission calc, AP/payroll, WHT certificate generation — P3/P4
คำแนะนำที่ปรึกษา: ส่วนใหญ่แพทย์คลินิก = ผู้รับจ้าง → vendor bill ต่อเดือน + WHT 3% (ออก 50 ทวิ จาก Odoo) · คิดคอมจาก revenue ที่รับรู้จริง (สอดคล้อง TFRS) ไม่ใช่ยอดขายคอร์ส เพื่อไม่จ่ายคอมล่วงหน้าก่อนทำบริการ
14. ราคา / สมาชิก / แต้ม / แคมเปญ · Pricing & Loyalty
กรอบ: ราคาคลินิกซับซ้อน — ราคาตามสาขา, ตามระดับสมาชิก, แคมเปญตามช่วง — ทั้งหมดต้องมาจาก Odoo (SSOT) ส่งให้ JERA
ตัดสินใจแล้ว: membership tier + loyalty point + campaign pricing เป็น requirement (ch45); ราคามาจาก Odoo (domain 3)
คำถามเปิด:
- มี price list กี่ระดับ — ราคาต่างตามสาขาไหม? ตามระดับสมาชิก (Silver/Gold/Platinum) ไหม?
- Loyalty point — สะสม/แลกยังไง? ตอนแลกลงบัญชีเป็นส่วนลด/ค่าใช้จ่ายการตลาด/liability?
- Campaign pricing — ตามช่วงวัน + กรองสินค้า/สาขา + tag เพื่อวัด ROI (ch45) — ใครตั้ง/อนุมัติ?
- ราคาสมาชิก vs walk-in ต่างกันยังไง? มีค่าสมาชิก/ต่ออายุไหม?
คำตอบกำหนด: Odoo pricelist config, loyalty module + point accounting, campaign rules + analytic tag — P2/P3
คำแนะนำที่ปรึกษา: ใช้ Odoo pricelist (รองรับ tier/สาขา/ช่วงเวลา native) เป็นเจ้าของราคา, JERA ดึงมาแสดง · loyalty point ลงเป็น liability ตอนสะสม รับรู้เป็นส่วนลดตอนแลก (ไม่ใช่ค่าใช้จ่ายตอนให้แต้ม)
15. POS: JERA ↔ Odoo · หน้าบ้าน–หลังบ้าน Operations
กรอบ: JERA = หน้าบ้านที่ขายจริง, Odoo = SSOT — operational contract ระหว่างสองตัวต้องชัด (ต่อจาก domain 3 แต่เน้น POS runtime)
ตัดสินใจแล้ว: JERA = POS หน้าบ้าน; Odoo authoritative; auto-map รหัสมีแล้ว (ch45/ch46)
คำถามเปิด:
- JERA โพสต์เข้า Odoo ต่อบิล (real-time) หรือ batch ตอนปิด session (POS session batching ch44)?
- การ attribute แพทย์/analytic ต้องติดไปกับบิล ก่อนปิด session (ch41 เตือน: ตั้งหลังปิดไม่ได้) — JERA ส่ง field นี้ไหม?
- JERA offline (เน็ตล่ม) — ขายต่อได้ไหม? queue แล้ว sync? (ต่อ domain 3 ข้อ 3)
- ช่องทางชำระเงิน — เงินสด/บัตร/โอน/QR/ผ่อน? เชื่อม payment gateway ไหน? reconcile กับ Odoo ยังไง?
- ในระยะยาว JERA จะถูกแทนด้วย Odoo POS ไหม หรืออยู่ถาวรเป็นหน้าบ้าน?
คำตอบกำหนด: sync mode (real-time/batch), analytic-on-line contract, offline resilience, payment methods + reconciliation — P2 (ขึ้นกับ domain 3)
คำแนะนำที่ปรึกษา: โพสต์ ต่อบิลแบบ near-real-time (queue ถ้า offline) เพื่อให้ Odoo เป็น SSOT ทันเวลา + บังคับ JERA แนบ analytic (แพทย์/สาขา) ทุกบรรทัดก่อนส่ง · เก็บ JERA เป็นหน้าบ้านถาวร (พนักงานคุ้นแล้ว) — Odoo POS เป็นทางเลือกอนาคต ไม่ใช่เฟสนี้
16. กำกับดูแล: อย. & PDPA · Regulatory & Patient Data
กรอบ: เวชภัณฑ์อยู่ใต้ อย. (ทะเบียน, recall) และข้อมูลคนไข้อยู่ใต้ PDPA — สองเรื่องนี้ constrain schema (lot-to-patient) และ retention
ตัดสินใจแล้ว: regulatory columns (manufacturer/MAH/thai_reg_no/gtin) มีแล้ว (ch58)
คำถามเปิด:
- recall workflow — เรียกคืนภายใน 24 ชม. (ช45) ต้องรู้ว่า lot ไหนไปสาขาไหน/คนไข้ไหน — เก็บ lot→patient เต็มไหม?
- มียา/ของ ควบคุมพิเศษ ที่ต้องรายงาน อย. เป็นงวดไหม?
- PDPA — ประวัติการรักษา + lot ที่ฉีดให้คนไข้ = ข้อมูลส่วนบุคคลอ่อนไหว ต้องมี consent log ไหม? เก็บได้นานแค่ไหน (retention)?
- มีข้อกำหนด data residency (ข้อมูลต้องอยู่ในไทย/cloud region ไหน) ไหม? กระทบ hosting ของ Odoo
คำตอบกำหนด: lot-to-patient linkage design, consent logging, retention policy, audit trail scope, hosting region — cross-cutting (P1 lot + NFR domain 17)
คำแนะนำที่ปรึกษา: เก็บ lot→patient เพื่อ recall (จำเป็นเชิงกฎหมาย) แต่ classify เป็นข้อมูลอ่อนไหว + consent + จำกัดสิทธิ์เข้าถึง · กำหนด retention ตาม PDPA + กฎเวชระเบียน · host Odoo ในไทย/region ที่ compliant ตั้งแต่แรก (ย้ายทีหลังแพง)
17. รายงาน & Non-Functional Requirements
กรอบ: รายงานตามกฎหมายไทย + KPI ผู้บริหาร + NFR (สิทธิ์, performance, DR) เป็นสิ่งที่มักถูกลืมจนใกล้ go-live
ตัดสินใจแล้ว: analytic ผ่าน segment columns/analytic_distribution (ch44/ch46); RLS per role (ch45)
คำถามเปิด:
- รายงานตามกฎหมาย — ภ.พ.30, ภ.ง.ด., รายงานภาษีซื้อ/ขาย ออกจาก Odoo ครบไหม?
- KPI ผู้บริหาร — revenue ต่อแพทย์/สาขา/หมวด, อัตรา utilization, ยอดคอร์สคงเหลือ — dashboard ที่ไหน?
- Roles & สิทธิ์ — ใครเห็น/ทำอะไรได้ (หัวหน้าสาขาเห็นเฉพาะสาขาตัวเอง, ผู้บริหารเห็นรวม)? segregation of duties?
- NFR — ผู้ใช้พร้อมกันกี่คน/กี่สาขา? uptime เป้าหมาย? backup/DR? เวลา response ที่ POS รับได้?
คำตอบกำหนด: report set, BI dashboard, Odoo access groups + RLS, infra sizing/SLA/DR plan — cross-cutting (P3 report, P5 RLS)
คำแนะนำที่ปรึกษา: ใช้รายงานภาษีจาก l10n_th (domain 12) เป็นฐาน · นิยาม role matrix + SoD ตั้งแต่ก่อน go-live (เพิ่มทีหลัง = เสี่ยง audit) · กำหนด SLA POS (เช่น ปิดบิล < 2 วินาที) เป็น acceptance criteria
18. Data Migration & Cutover · ยกของเข้าระบบ
กรอบ: ระบบใหม่ที่ดีพังได้ถ้ายกข้อมูลเก่าเข้าผิด — โดยเฉพาะ ยอดคอร์สคงเหลือ ที่เป็นทั้ง deferred revenue และภาระบริการ
ตัดสินใจแล้ว: GENCODE = naming, Odoo = SSOT (domain 3) — migration ปลายทางคือ Odoo
คำถามเปิด:
- ข้อมูลเก่าอยู่ที่ไหน (JERA เดิม, Excel, ระบบบัญชีเก่า)? คุณภาพข้อมูลเป็นยังไง?
- map แคตตาล็อกสินค้า/คอร์สเดิม → GENCODE/Odoo product ยังไง? ใครตรวจ?
- Opening balances — stock คงเหลือ + lot, ยอด GL/AR/AP, และ ยอดคอร์สคงเหลือ (deferred revenue) ต่อลูกค้า — ยกเข้ายังไง ณ วัน cutover?
- กลยุทธ์ cutover — big bang หรือ parallel run? ย้อนกลับได้ไหมถ้าพัง?
คำตอบกำหนด: migration source mapping, data cleansing, opening-balance journals, cutover plan — ก่อน go-live ทุกเฟส
คำแนะนำที่ปรึกษา: ยอดคอร์สคงเหลือต่อลูกค้าคือ migration item ที่เสี่ยงสุด — ต้อง reconcile กับ deferred-revenue policy (domain 7) ให้ตรงบาท · cutover แบบ parallel run 1 สาขานำร่อง ก่อนขยาย ปลอดภัยกว่า big bang
19. จัดลำดับ & Traceability · MoSCoW + REQ-YD Matrix
เมื่อ workshop ตอบคำถามข้างบนแล้ว แต่ละคำตอบกลายเป็น requirement ที่ต้อง trace ได้ — REQ → design → code → UAT → PR ตารางนี้คือโครงเริ่มต้น (priority ตาม ch44 phases):
| Domain | ตัวอย่าง REQ | MoSCoW | Phase (ch44) |
|---|---|---|---|
| 3 หน้าบ้าน–หลังบ้าน contract | REQ-YD-001 GENCODE write-through → Odoo SSOT | Must | P0 |
| 12 บัญชีไทย VAT/WHT/COA | REQ-YD-010 l10n_th + COA คลินิก | Must | P3 |
| 7 Deferred revenue | REQ-YD-020 รับรู้รายได้ต่อ session | Must | P3 |
| 9 Lot/FEFO/sub-lot | REQ-YD-030 sub-lot ขวดเปิด 24 ชม. | Must | P1 |
| 15 POS JERA↔Odoo | REQ-YD-040 near-real-time + analytic ก่อนปิด | Must | P2 |
| 14 Loyalty/campaign | REQ-YD-050 loyalty point accounting | Should | P3 |
| 8 Scheduling ห้อง/เครื่อง | REQ-YD-060 capacity scheduling | Could | P2 |
| 4 Multi-company แฟรนไชส์ | REQ-YD-070 inter-company | Won't (เฟสนี้) | future |
กฎ traceability: ทุก REQ-YD-NNN ต้องระบุครั้งเดียวใน SRS → อ้างใน UAT (REQ-id column) → ผูก PR SHA · "Must" ที่ยังไม่ปิดคำตอบ = go-live blocker
20. ผลลัพธ์: เอกสารที่ปิดได้หลังตอบ · Next Design Artifacts
เมื่อ 16 domain ได้คำตอบ + sign-off แล้ว product design "จบ" ผ่านการผลิต artifact ต่อไปนี้ (จากคำตอบ ไม่ใช่จากการเดา):
- Process flow diagrams — order-to-cash, procure-to-pay, session→revenue recognition, lot→recall (ต่อ domain)
- Odoo configuration workbook — companies/branches, journals, COA (l10n_th), taxes (VAT/WHT), products/UOM, pricelists, access groups
- Fit-Gap log — อะไร Odoo ทำได้ native vs ต้อง custom dev (เช่น sub-lot ขวดเปิด, deferred-revenue schedule, GENCODE write-through API)
- หน้าบ้าน–หลังบ้าน API contract — GENCODE/JERA → Odoo (endpoint, payload, idempotency, offline queue, conflict policy)
- Data migration plan — source map + opening-balance journals (รวมยอดคอร์สคงเหลือ)
- UAT scenarios — ผูก REQ-YD-NNN ทุกข้อ + กรณีไทย (VAT คอร์ส, WHT แพทย์, recall)
นี่คือเส้นทางจาก "schema พร้อม" (ch58) → "design จบ พร้อม build/config" — ผ่านการ ปิดคำถามเชิงธุรกิจ ไม่ใช่เพิ่ม architecture
21. Out of Scope
บทนี้ ไม่ครอบคลุม (เพื่อกัน scope creep ของ design เฟสแรก):
- HR/Payroll module เต็มรูป — ถ้าแพทย์เป็นผู้รับจ้าง (domain 13) ใช้ vendor bill พอ; payroll เต็มเป็นโปรเจกต์แยก
- CRM / marketing automation — booking ภายนอก (LINE) feed เข้าเป็น draft order พอ ยังไม่ทำ campaign automation เต็ม
- E-commerce / online store — นอกขอบเขต ERP core เฟสนี้
- Multi-company / แฟรนไชส์ inter-company — future (domain 4) เมื่อมีนิติบุคคลที่ 2 จริง
- Hardware spec (เครื่อง POS, เครื่องสแกน, ตู้เย็น cold chain) — เป็น procurement/infra แยก
- Odoo POS แทน JERA — JERA ยังเป็นหน้าบ้านถาวรเฟสนี้ (domain 15)
คำถามปิด Product Code Design — สำหรับถาม Designer
สถาปัตยกรรมหลัก (Architecture Spine): Odoo = Single Source of Truth (backend). GENCODE + JERA = หน้าบ้าน (front-of-house). Data flows ONE WAY: front-end → Odoo. Odoo wins on conflict.
คำถามด้านล่างนี้ออกแบบมาเพื่อให้ Wind นำไปถาม GENCODE designer โดยตรง — พิมพ์ออกมาได้เลย ทุกข้อมีเหตุผลว่าทำไมต้องถาม
1. CODE PURPOSE — ใครใช้ full_code ในงานจริง?
- ใครพิมพ์/สแกน full_code ในงานจริง? พนักงานเคาน์เตอร์? คลังสินค้า? หรือแค่ระบบ generate ให้ดูใน dashboard?
- ถ้าคนหน้าร้านใช้ — จำ 7-segment ได้ไหม? (โรงพยาบาลจริงใช้ AEQM0006 เพราะสั้น จำง่าย)
- JERA มี limit 20 ตัวอักษร — full_code ที่ยาว 35 ตัวอักษรใส่ JERA ได้ไหม?
- ทำไมต้องถาม: ถ้าไม่มีใครพิมพ์ full_code จริง → full_code เป็นแค่ display label ไม่ใช่ operational identifier → ออกแบบต่างกันมาก
2. INTELLIGENT CODE vs SURROGATE KEY — 7-segment เป็น label หรือ address?
- full_code (7-segment) ถูกใช้เป็น PK หรือ FK ใน table ไหนบ้าง? หรือทุก JOIN ใช้ int id?
- ถ้า FK อยู่ที่ full_code text → ต้อง migrate ไป int id ก่อน scale (text JOIN ช้ากว่า int JOIN 5-10x ที่ 5,000+ rows)
- โรงพยาบาลจริงไม่ encode ข้อมูลใน code เลย (AEQM0001 = running number, classification อยู่ใน column แยก กลุ่ม/ประเภท/หน่วยนับ) — ทำไม GENCODE ต้อง encode ข้อมูลเข้าไปใน code? ประโยชน์คุ้ม complexity ไหม?
- ทำไมต้องถาม: ถ้า segment เปลี่ยน → full_code เปลี่ยน → ถ้าเป็น FK ต้อง cascade update ทุก table = breaking change
3. MODULE INTEGRATION — แต่ละ module ต้องการ product code อย่างไร?
- Inventory: pick/pack/ship reference product ด้วยอะไร? barcode scan? ชื่อไทย? code?
- Sales: พนักงานค้นหา product ด้วยชื่อไทยหรือ code? autocomplete ใช้ field ไหน?
- GL (Accounting): ต้องการ product code ไหม หรือแค่ account code + journal entry?
- External systems: ประกัน/NHSO ใช้ code มาตรฐานอะไร? ต้อง map กับ GENCODE ไหม?
- ทำไมต้องถาม: แต่ละ module มี requirement ต่าง code ที่ดีต้องตอบทุก module ไม่ใช่แค่ classification
4. SCALABILITY — segment เปลี่ยนแล้วเกิดอะไร?
- ถ้า segment definition เปลี่ยน (เช่น เพิ่ม subtype ใหม่) → full_code เดิมเปลี่ยนตามไหม?
- ถ้าเปลี่ยนตาม = breaking change สำหรับทุก system ที่ reference full_code
- Product 5,000+ ตัว → query ด้วย text JOIN ไหวไหม vs int JOIN? (benchmark: text JOIN ช้ากว่า 5-10x)
- 100 สาขา × 5,000 products = 500,000 inventory records — full_code text index vs int PK index?
- ทำไมต้องถาม: scale ที่ออกแบบไว้ (100 สาขา) ต้องตอบคำถาม performance ตั้งแต่วันนี้
5. ข้อเสนอ 3-CODE ARCHITECTURE — designer เห็นด้วยไหม?
จากการวิเคราะห์ทั้งหมด เสนอ 3 code ที่แยกหน้าที่ชัดเจน:
| Code | Type | ใครใช้ | ตัวอย่าง | หน้าที่ |
|---|---|---|---|---|
id | int PK | ทุก module (FK) | 42 | identity สำหรับ JOIN ทุก table — ไม่เปลี่ยน ไม่ encode ข้อมูล |
jera_code | varchar auto-gen | POS / scan / label | C-00422 | running number สำหรับหน้าร้าน — สั้น จำง่าย scan ได้ (เหมือน JERA ปัจจุบัน) |
full_code | GENERATED column | dashboard / classification | CRS-SRG-NOS-... | display-only label สำหรับ dashboard — ไม่เป็น FK ไม่เป็น PK เปลี่ยนได้ตาม segment definition |
คำถามสำหรับ designer:
- เห็นด้วยกับการแยก 3 roles นี้ไหม? หรือ full_code ต้องทำหน้าที่อื่นด้วย?
- jera_code format (C-NNNNN) ใช้ต่อได้เลยไหม หรือต้องเปลี่ยน?
- full_code เป็น GENERATED column (computed จาก segment columns) — acceptable ไหม? หรือต้อง stored?
- ถ้า designer ยืนยันว่า full_code ต้องเป็น FK → ต้อง migrate plan ก่อน scale (ดู section "Migrate full_code เป็น Generated Column" ในบทนี้)
ข้อสรุป: ทำไม Odoo Absorb DB Concerns ทั้งหมด — GENCODE Supabase DB Retired
Architecture Decision (ตัดสินใจแล้ว 2026-06-22)
หลังจากวิเคราะห์ทั้ง ch26–ch59 และ Discord thread ทั้งหมด ข้อสรุปสุดท้ายชัดเจน: Odoo 19 = Single Source of Truth (SSOT) สำหรับทุก product master data ไม่มีข้อยกเว้น การตัดสินใจนี้ retire GENCODE Supabase Postgres DB ทั้งหมด — ไม่จำเป็นต้องมี database แยกอีกต่อไป เพราะ Odoo PostgreSQL ทำทุกอย่างได้ดีกว่า พร้อม ecosystem ที่ proven (inventory, sales, accounting, POS) ครบวงจร
สถาปัตยกรรมใหม่แบ่งหน้าที่ชัดเจน:
- Odoo 19 = backend เดียว ถือ
product.template/product.productทุกตัว + stock + การขาย + บัญชี ทั้งหมด authoritative ที่ Odoo - GENCODE Supabase Postgres DB = RETIRED — ไม่จำเป็นแล้ว ไม่ต้อง maintain, ไม่ต้อง sync, ไม่ต้อง worry เรื่อง dual-master conflict
- glyph-oracle เก็บแค่ parser/mapper logic (Thai free-text → GENCODE segments) — เปลี่ยน data layer จาก Supabase → Odoo JSON-RPC เท่านั้น parser ไม่แตะ, Chrome Extension ไม่แตะ
- JERA = read-only display layer (GET only, ไม่ POST ข้อมูลสินค้ากลับ) — JERA อ่านจาก Odoo แล้วแสดงผล POS ไม่ต้อง maintain product database เอง
Flow ใหม่: จาก Thai Free Text → Odoo Product (ครบวงจร)
ทุกครั้งที่ต้องสร้างหรือค้นหาสินค้า flow ทำงานแบบนี้:
- Step 1: User กรอก Thai free text ที่ JERA POS — เช่น "โบท็อกซ์ allergan 50u ฉีดหน้าผาก"
- Step 2: glyph-oracle Chrome Extension จับ text → GENCODE mapper parse เป็น segments (type, subtype, category, differentiator, seq, variant) — logic เดิมทุกประการ ไม่เปลี่ยนอะไร
- Step 3: glyph-oracle ยิง
JSON-RPCไป Odoo → lookupproduct.templateด้วย segment fields (ถ้ามีแล้ว = return, ถ้ายังไม่มี = create ใหม่พร้อม GENCODE segment fields ทั้งหมด) - Step 4: Extension auto-fill กลับ JERA form ให้ user เห็น —
jera_code+ ชื่อสินค้า +full_code(GENERATED display label)
ผลลัพธ์: Product อยู่ใน Odoo ทันที — inventory, sales, accounting, POS ต่อได้ทันที ไม่ต้อง sync ไม่ต้อง queue ไม่ต้อง reconcile เพราะ Odoo เป็น SSOT ตั้งแต่วินาทีแรกที่สร้าง
ทำไม DB Schema Concerns จบ — Odoo จัดการให้ทั้งหมด
ทุกข้อกังวลเรื่อง database schema ที่ถกกันมาตลอด ch26–ch59 Odoo ตอบได้หมด โดยไม่ต้องออกแบบเอง:
| เรื่องที่กังวล | Odoo จัดการยังไง |
|---|---|
| PK / auto-increment | id serial int — Odoo ORM สร้าง auto ทุก model, surrogate key ไม่เปลี่ยนตลอดชีวิต record |
| FK integrity | ทุก module (stock, sale, account) FK ไป product.template.id / product.product.id — int JOIN, ON DELETE RESTRICT, proven ถึงหลักล้าน records |
| Sequence / race condition | ir.sequence ใช้ PostgreSQL nextval() — lock-free, race-free, กำหนด prefix/suffix/padding ได้ต่อ company; no_gap mode ใช้ FOR UPDATE NOWAIT สำหรับเลขเอกสารที่กฎหมายบังคับต่อเนื่อง |
| Indexing | gencode_full_code + jera_code indexed ใน yd_gencode module — custom field index ผ่าน Odoo ORM index=True หรือ SQL CREATE INDEX ตรง ๆ |
| Multi-branch isolation | company_id field + ir.rule row-level security — user สาขา A เห็นเฉพาะ data ของสาขา A, ผู้บริหารเห็นรวม, ไม่ต้องเขียน RLS policy เอง |
| Concurrent writes | PostgreSQL row-level locking ผ่าน ORM — Odoo จัดการ write() / create() พร้อมกัน 100 สาขาได้ + SKIP LOCKED สำหรับ stock.quant (inventory) ที่ volume สูง |
สิ่งที่ต้องทำ — 3 Phases
การ transition จาก GENCODE Supabase → Odoo SSOT แบ่งเป็น 3 เฟสที่ทำได้ทีละขั้น:
Phase 1: Odoo yd_gencode module
- เพิ่ม fields ที่ขาดใน
product.template—name_en,version,qualifier,grp(GENCODE segment fields ที่ Odoo ยังไม่มี) - เขียน API controller สำหรับ glyph-oracle JSON-RPC — endpoint
/api/gencode/lookup(search by segments) และ/api/gencode/create(create product with segments) - ทำ
gencode_full_codeเป็น computed field (GENERATED จาก segment fields) + index
Phase 2: glyph-oracle — swap data layer
- เปลี่ยน Supabase calls → Odoo JSON-RPC ใน data layer ของ glyph-oracle (fetch/create/update product)
- Chrome Extension ไม่แตะ — มันเรียก glyph-oracle API เหมือนเดิม แค่ backend เบื้องหลังเปลี่ยนจาก Supabase เป็น Odoo
- Parser/mapper ไม่แตะ — Thai text → segments logic เดิมทุกประการ output เดิม แค่ปลายทางเปลี่ยน
Phase 3: One-time migration + retire Supabase
- Dump ข้อมูลจาก Supabase → import เข้า Odoo ผ่าน
yd_gencodemodule API (idempotent, matching key =jera_code) - Verify ว่า product ทุกตัวอยู่ใน Odoo ครบ — count match, field match, segment match
- Retire Supabase — ปิด connection, archive DB เป็น backup (Nothing is Deleted), ลบ Supabase config จาก glyph-oracle
Context จาก Screenshot โรงพยาบาลจริง
จากการศึกษา screenshot ระบบจริงของโรงพยาบาล ยืนยันว่า approach ที่เลือกถูกต้อง:
- โรงพยาบาลใช้
AEQM0001(prefix + running number) — ไม่ encode ข้อมูลใน code เลย code เป็นแค่ identifier สั้น ๆ ที่จำง่าย scan ง่าย - Classification อยู่ใน column แยก (กลุ่ม, ประเภท, หน่วยนับ) — ไม่ได้ยัดเข้าไปใน code แต่เป็น structured data ที่ query ได้, filter ได้, report ได้
- GENCODE 7-segment
full_code= GENERATED display label เท่านั้น ไม่ใช่ PK/FK — ใช้สำหรับ dashboard/classification view ไม่ใช่สำหรับ operational workflow jera_code(C-00422 format) = auto-generated running number สำหรับ POS/scan — สั้น จำง่าย เหมือน AEQM0001 ของโรงพยาบาล ใช้ที่หน้าเคาน์เตอร์จริง
สรุป: โรงพยาบาลที่รัน production จริง ยืนยันว่า surrogate key + running number + classification แยก column คือ pattern ที่ใช้งานได้จริง — ตรงกับสิ่งที่เราตัดสินใจ (Odoo id int PK + jera_code running number + full_code GENERATED label)