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

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 ของ productBotox®ได้ — เป็น 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 19id int SERIALdefault_code (computed)product_id int
NWFTHItemkey nvarchar(18)*Itemkey (meaningful)Itemkey + InTransID int (ledger)
GENCODE (ควร)id int IDENTITYfull_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 fieldOdoo fieldหมายเหตุ
full_codeproduct.template.default_codeinternal reference ที่มนุษย์เห็น
jera_code (C-0001)product.template.barcodescan ได้ที่ POS
type (CRS/SVC/PRD)product.type (consu/service/product)layer → Odoo product type
category + subtypeproduct.category (categ_id)hierarchical category
differentiatorproduct.attribute + product.attribute.valueOdoo variant system
variant (doctor code)analytic_distribution (JSON)management reporting dimension
SVC → PRD linksmrp.bom (Bill of Materials)service = recipe ของ products ที่ใช้
name_th / name_enproduct.template.name + ir.translationmultilingual product name
is_activeproduct.template.activearchive/unarchive
vendor recordsproduct.supplierinfovendor × 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 เลือกถูกแล้ว:

  1. Surrogate key (id int) เป็น PK + FK — identity ที่ไม่เคยเปลี่ยน
  2. Typed columns เป็น source of truth — query ได้, index ได้, validate ได้แต่ละช่อง
  3. Generated label (full_code) เป็น projection — มนุษย์อ่าน, ไม่ต้อง maintain แยก
  4. Vendor/manufacturer/MAH แยกเป็น relation — ไม่ฝังใน code
  5. 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

#GENCODEOdoo 19TypeDirection
1id (surrogate PK)product.template.idint → intGENCODE → Odoo (create)
2full_code (generated)default_code (char)text → charGENCODE → Odoo
3jera_code (C-0001)barcode (char)text → charGENCODE → Odoo
4type (CRS/SVC/PRD)detailed_type (selection)enum → selectionGENCODE → Odoo
5category + subtypecateg_id (many2one)text → FKGENCODE → Odoo
6differentiatorattribute_line_idstext → o2mGENCODE → Odoo
7variant (doctor)analytic_distributiontext → JSONGENCODE → Odoo
8SVC→PRD linksmrp.bom + bom_line_idsrelation → o2mGENCODE → Odoo
9name_th / name_enname + ir.translationtext → char+i18nGENCODE → Odoo
10vendor recordsproduct.supplierinforelation → o2mGENCODE → Odoo

ภาพรวม: 10 Changes × 3 Priority Levels

การจะนำ GENCODE product_registry (PRD layer) ไป sync กับ Odoo ได้จริง ต้องแก้ schema ทั้ง foundation (โครงสร้างพื้นฐาน), columns (เพิ่ม field ที่ Odoo ต้องการ), และ tables (ตารางใหม่ที่ยังไม่มี) — รวม 10 รายการ แบ่งเป็น 3 ระดับความเร่งด่วน:

Priorityขอบเขตเวลาประมาณเหตุผล
P0Schema Foundation (4 items)~1 dayก่อน plug อะไรเลย — ถ้าไม่แก้ตรงนี้ Odoo จะ reject data ตั้งแต่ row แรก
P1Add Columns for Odoo (2 items)1–2 daysOdoo modules (Inventory, Sales, Purchase) ต้องการ field เหล่านี้ — ไม่มีก็ sync ไม่ครบ
P2Build 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 TypeSizeJOIN SpeedRename Impact
text full_code (41 chars)~45 bytes/rowstring compare O(n)UPDATE ทุก FK row
integer id4 bytes/rowint 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 TypeON DELETEตัวอย่าง Odooตัวอย่าง GENCODE
Master data FKRESTRICTsale_order_line.product_id → product.productsvc_prd_mapping.product_id → product_registry
Audit / optional FKSET NULLstock_move.write_uid → res_usersproduct_registry.created_by → users
Ownership FKCASCADEproduct.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 19numeric (via ORM Float with digits)accounting ต้อง exact — Σdebit = Σcredit
NWFTH BatchMasterdecimal(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 FieldTypeValuesGENCODE Column ใหม่ทำไมต้องมี
detailed_typevarchar(16)'product' / 'consu' / 'service'detailed_typeOdoo ใช้แยก storable (สร้าง stock.quant) vs consumable vs service
trackingvarchar(8)'none' / 'lot' / 'serial'trackingกำหนดว่า product ต้อง track lot/serial หรือไม่ (cold chain = 'lot')
list_pricenumeric(16,4)ราคาขายlist_priceOdoo sales module ดึง default price จากตรงนี้
standard_pricenumeric(16,4)ราคาทุนstandard_priceOdoo stock valuation + COGS calculation
weightnumeric(10,3)น้ำหนัก (kg)weightOdoo 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 fieldOdoo field (product.supplierinfo)หมายเหตุ
product_idproduct_tmpl_id / product_idOdoo link ที่ template level; GENCODE ไม่มี template → link ที่ product ตรง
vendor_namepartner_id → res.partner.nameOdoo ใช้ FK ไป res.partner; GENCODE ยังไม่มี partner table
pricepriceราคาซื้อจาก vendor นี้
min_qtymin_qtyminimum order quantity
lead_time_daysdelayวันที่ต้องรอหลัง PO
is_preferredsequence (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 fieldOdoo field (stock.lot)หมายเหตุ
lot_namenameunique per product
expiration_dateexpiration_dateFEFO: First Expired First Out
use_dateuse_datebest before
removal_dateremoval_dateforced recall date
alert_datealert_datenotification 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 fieldOdoo equivalentหมายเหตุ
branch_idcompany_id (res.company)Odoo ใช้ company_id + ir.rule สำหรับ branch isolation (ch40)
is_activeactive field + ir.ruleOdoo ใช้ company-specific property
par_levelproduct.product.reordering_min_qty (stock.warehouse.orderpoint)Odoo เก็บใน orderpoint rules
local_costir.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;
ColumnOdoo equivalentใช้ทำอะไร
manufacturerproduct.template field (custom)ระบุโรงงานผลิต — บังคับสำหรับ อย.
mahไม่มี built-in (custom field)ผู้รับอนุญาตนำเข้า — Thai FDA requires
thai_reg_noไม่มี built-in (custom field)เลขทะเบียน อย. เช่น "1A 2/62 (NBC)"
gtinproduct.product.barcodeGlobal 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หมายเหตุ
idGENCODE PK; Odoo มี id ของตัวเอง
full_code (GENERATED)default_codeGENCODE → Odoosync เป็น internal reference
typecateg_id (product.category)GENCODE → Odoomap type+category → Odoo category tree
name_thname (jsonb, key 'th_TH')GENCODE → OdooOdoo 19 ใช้ jsonb สำหรับ translatable fields
detailed_typedetailed_typeGENCODE → Odoostorable/consumable/service — set at product creation, Odoo authoritative after sync
trackingtrackingGENCODE → Odoonone/lot/serial — set at product creation, Odoo authoritative after sync
list_pricelist_priceGENCODE → Odoosale price
standard_pricestandard_priceGENCODE → Odoo (initial); Odoo computes aftercost — Odoo SSOT updates via stock valuation, GENCODE sets initial value only
weightweightGENCODE → Odookg
uom_id (FK → uom_uom)uom_id (FK → uom.uom)map by namematch UOM by name/category
gtinbarcodeGENCODE → OdooEAN/GTIN barcode
item_vendor rowsseller_ids (product.supplierinfo)GENCODE → Odoovendor list per product
product_lot rowsstock.lotGENCODE → Odoo (create); Odoo SSOT afterlot/serial records — front-end creates, Odoo owns lifecycle
product_branch rowscompany_id + ir.ruleGENCODE → Odoobranch-specific activation

Timeline Estimate

PriorityItemsเวลาDependenciesVerify
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 daysP0 done (FK ต้องเป็น int ก่อน)all products have detailed_type + uom_id
P2#7 item_vendor
#8 product_lot
#9 product_branch
#10 regulatory
2–3 daysP0+P1 donetables created, FK intact, seed data loaded

รวม: 4–6 days สำหรับ schema ครบ (ไม่รวม application code / sync logic / testing)

Verification Checklist

#CheckCommandExpected
1full_code = GENERATED\d product_registry → check "GENERATED"column shows GENERATED ALWAYS AS ... STORED
2No text FK remainingSELECT conname FROM pg_constraint WHERE confrelid = 'product_registry'::regclass;all FK columns are integer type
3ON DELETE correctSELECT conname, confdeltype FROM pg_constraint WHERE conrelid = 'svc_prd_mapping'::regclass;'r' (RESTRICT) for master FKs
4No float columns for qty/priceSELECT column_name, data_type FROM information_schema.columns WHERE table_name='product_registry' AND data_type='real';0 rows
5Odoo fields presentSELECT 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
6UOM tables + FK\d uom_uom + \d product_registryuom_id FK exists
7PLANNED tables exist\dt item_vendor product_lot product_branch3 tables listed
8Regulatory columnsSELECT 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 ที่ดีอยู่แล้ว:

  1. 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
  2. Segment dictionaries (type_dict, category_dict, etc.) — ไม่ต้องแก้ · เป็น GENCODE-specific lookup สำหรับ code generation · Odoo ไม่ต้องรู้จัก dictionaries — แค่ได้ผลลัพธ์ (full_code as default_code)
  3. jera_code — ไม่ต้องแก้ · เป็น POS integration layer ระหว่าง GENCODE ↔ JERA · Odoo มี POS module ของตัวเอง — ถ้า migrate POS ค่อยจัดการ
  4. 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 = หน้าบ้าน เขียนทะลุเข้า OdooWind 2026-06-22
Product code3-layer CRS/SVC/PRD; surrogate id = PK/FK; full_code = GENERATED projection; dash = cosmeticch46, 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) — ห้าม floatch44, ch58
Inventory ledgerevent-sourced (stock.move→stock.quant), SKIP LOCKED + merge, reserved_quantitych38, ch44
Accounting ledgerdouble-entry, _check_balanced (Σdebit=Σcredit), inalterable_hash chainch36, ch44
Branch isolationsingle DB + company_id/branch_id + Row-Level Security + 5 lock datesch40, ch44, ch45
Schema พร้อม Odooproduct_registry P0/P1/P2 (GENERATED, int FK, numeric, UOM, lot, item_vendor, product_branch, regulatory)ch58
POS mappingJERA ↔ GENCODE auto-map (Chrome ext + mapper), jera_code = C-{id} ≤ 20 charsch45, ch46

ทุกคำถามข้างล่าง สมมุติฐานเหล่านี้เป็นจริง และต่อยอดจากมัน

3. ชะตาของ GENCODE DB & หน้าบ้าน–หลังบ้าน Contract

กรอบ: เมื่อ Odoo เป็น SSOT แล้ว คำถามแรกสุดคือ GENCODE DB (glyph-oracle/Neon ที่ถือ product_registry วันนี้) จะอยู่ในรูปไหน — นี่คือ fork ที่กำหนด integration ทั้งหมด

ตัดสินใจแล้ว (Wind): Odoo authoritative; GENCODE/JERA เขียนทะลุเข้า Odoo ทางเดียว

คำถามเปิด:

  1. GENCODE DB จะเป็น (ก) write-through/cache บางๆ (ยังมินต์รหัสที่ GENCODE แล้ว push เข้า Odoo ทันที) หรือ (ข) ปลดระวาง (ย้าย logic ตั้งรหัสไปเป็น Odoo module แล้วสร้าง product ตรงใน Odoo)?
  2. ใครเป็นเจ้าของ "การสร้างสินค้า/คอร์สใหม่" — Course Creator UI ของ GENCODE หรือหน้าจอ Odoo? ถ้า GENCODE ยังเป็นคนสร้าง แล้วใครกด "อนุมัติ" ให้เข้า Odoo?
  3. Contract หน้าบ้าน↔หลังบ้านเป็นแบบ real-time API (REST/XML-RPC ทุกครั้งที่ขาย) หรือ batch? JERA ที่หน้าเคาน์เตอร์ต้องทำงานต่อได้ไหมถ้า Odoo ล่ม (offline-first)?
  4. เมื่อ 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

กรอบ: Odoo isolate ข้อมูลด้วย company_id — แต่ "company" = นิติบุคคล ไม่ใช่ "สาขา" การเลือกผิดตรงนี้แก้ทีหลังแพงมาก

ตัดสินใจแล้ว: single central DB + branch isolation ผ่าน RLS (ch40/ch44)

คำถามเปิด:

  1. Youngdo เป็น นิติบุคคลเดียว (1 Odoo company, สาขา = res.company child/branch) หรือ หลายนิติบุคคล (แต่ละสาขา/แฟรนไชส์จดทะเบียนแยก = Odoo multi-company + inter-company)?
  2. ต้องออก งบการเงินรวม (consolidated) ข้ามนิติบุคคลไหม หรือแต่ละนิติบุคคลแยกงบ?
  3. มีโมเดล แฟรนไชส์ ในแผนไหม (P&L แยก + royalty)? ถ้ามี เริ่มเมื่อไหร่ (กระทบ multi-company ตั้งแต่วันแรกไหม)?
  4. เลขผู้เสียภาษี/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)

คำถามเปิด:

  1. วันนี้มี กี่สาขาจริง? go-live เฟส 1 จะเปิดกี่สาขา?
  2. มี "virtual branch" ไหม (เช่น IV drip lounge ในพื้นที่พาร์ทเนอร์, mobile service) ที่ไม่ใช่คลินิกเต็มรูป?
  3. แต่ละสาขาเป็น warehouse แยกใน Odoo (stock แยก) ใช่ไหม? มี central warehouse/คลังกลางไหม?
  4. ลำดับเลขเอกสาร — 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)

คำถามเปิด:

  1. การขายเริ่มที่ไหน — JERA POS เสมอ, หรือมี "นัดล่วงหน้า/ใบเสนอราคา" ที่เป็น Odoo sale.order ก่อนลูกค้ามา?
  2. รับ มัดจำ/เงินดาวน์ (deposit) ไหม? ลงบัญชียังไง (เงินรับล่วงหน้า)?
  3. มี open bill / เพิ่มรายการกลางทรีตเมนต์ (add-on ระหว่างทำหัตถการ) ไหม? JERA รองรับไหม?
  4. ส่วนลด — ใครอนุมัติได้แค่ไหน (discount approval matrix)? ขายต่ำกว่าทุนได้ไหม?
  5. คืนเงิน/ยกเลิก — credit note flow เป็นยังไง (เกี่ยวกับ deferred revenue + คืน stock, ดู domain 7)?
  6. มี 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

คำถามเปิด:

  1. Trigger การรับรู้รายได้ คืออะไร — ทำบริการเสร็จต่อครั้ง? แพทย์เซ็นยืนยัน? ปิด POS session? (ต้องเลือกอันเดียวให้ชัด)
  2. entry ตอน ขาย (Dr เงินสด / Cr เงินรับล่วงหน้า) และตอน ใช้แต่ละครั้ง (Dr เงินรับล่วงหน้า / Cr รายได้) — สัดส่วนต่อครั้งเท่ากันหมด หรือถ่วงน้ำหนักตามมูลค่าบริการ?
  3. คอร์สหมดอายุ/ใช้ไม่ครบ — รับรู้รายได้ส่วนที่เหลือเมื่อไหร่ (เมื่อหมดอายุ)? นโยบายคืนเงินคอร์สที่เหลือ?
  4. คืนเงินกลางคอร์ส — กลับรายการเงินรับล่วงหน้าส่วนที่ยังไม่ใช้ยังไง (เชื่อมกับ credit note domain 6)?
  5. 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)

คำถามเปิด:

  1. มีระบบ นัดหมาย/booking ไหม? เชื่อม Odoo (sale.order/appointment) หรือระบบนอก (LINE/หน้าเว็บ)? นัด = sale.order ล่วงหน้าไหม?
  2. "ตัดครั้ง" จากคอร์สเกิดที่ไหน — พนักงานกดใน JERA หรือ Odoo? ต้องมีลายเซ็น/ยืนยันแพทย์ไหม?
  3. จัดสรร แพทย์/ห้อง/เครื่อง ต่อ appointment ไหม (scheduling/capacity)? กระทบ revenue attribution ต่อแพทย์ (domain 12)
  4. No-show / เลื่อนนัด — มีค่าปรับไหม? กระทบคอร์สยังไง?
  5. เปลี่ยนรายการกลางคอร์ส (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)

คำถามเปิด:

  1. สร้าง lot ตอนไหน — ตอน รับของ (PO receipt) เสมอ ใช่ไหม? ใครกรอกเลข lot + วันหมดอายุ?
  2. Sub-lot / ขวดเปิดแล้ว (Botox เปิดแล้วหมดอายุ 24 ชม., ตัดเป็นยูนิต) ตาม ch45 — track อัตโนมัติหรือมือ? ใครเป็นคนเปิด (พยาบาลข้างเตียง)?
  3. FEFO บังคับโดยระบบ (ห้ามจ่าย lot ใหม่ถ้า lot เก่ายังอยู่) หรือแค่แนะนำ?
  4. Near-expiry alert — เตือนที่ 30/60/90 วันก่อนหมด ตาม ch45 ใช่ไหม? เตือนใคร?
  5. Cold chain — บันทึกอุณหภูมิตอนรับของไหม? ของเสีย/ทิ้ง (wastage) ลงบัญชียังไง?
  6. ผูก 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)

คำถามเปิด:

  1. วิธีคิดต้นทุน — Weighted Average (WAC), FIFO, หรือ Standard Cost? (เลือกอันเดียว กระทบ COGS entry)
  2. ของนำเข้า — รวม customs/duty/freight เข้าต้นทุนต่อหน่วย (Odoo Landed Cost) ไหม? คิดสกุลเงินไหน (FX)?
  3. ต้นทุนต่างต่อสาขาไหม (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)

คำถามเปิด:

  1. PO approval — กี่ขั้น/วงเงินเท่าไหร่ต่อขั้น (approval matrix)?
  2. 3-way match tolerance — ต่างกี่ % ถึง flag (เช่น รับของน้อยกว่าสั่ง, ราคาบิลต่างจาก PO)?
  3. มี consignment (ฝากขาย/วางของ) ไหม? ของควบคุม/ยาเสพติดต้องมีทะเบียนพิเศษไหม?
  4. รวม 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)

คำถามเปิด:

  1. คลินิกจด VAT ไหม? ออกใบกำกับภาษีเต็มรูป? บริการคลินิก/ยา ตัวไหน VAT 7% ตัวไหนยกเว้น (บริการแพทย์บางอย่างได้รับยกเว้น)?
  2. ใช้ Odoo l10n_th (Thai localization: COA, ภ.พ.30, ภ.ง.ด.) หรือ COA custom? เวอร์ชัน Community มี l10n_th ครบไหม?
  3. โครงสร้าง Chart of Accounts — ผังบัญชีคลินิก (รายได้บริการ/ขายสินค้า, COGS, เงินรับล่วงหน้า/deferred, AR, AP, VAT ขาย/ซื้อ, WHT ค้างจ่าย)?
  4. WHT — หัก ณ ที่จ่ายกรณีไหนบ้าง (ค่าแพทย์, ค่าเช่า, ค่าบริการ)? ออกหนังสือรับรอง 50 ทวิ + ภ.ง.ด.3/53 อัตโนมัติไหม?
  5. e-Tax invoice / e-Receipt (สรรพากร) — ต้องส่งไหม? (เชื่อม hash chain ch44)
  6. รอบบัญชี + 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)

คำถามเปิด:

  1. แพทย์เป็น พนักงาน (payroll) หรือ ผู้รับจ้างอิสระ (vendor bill ต่อเดือน)? (กำหนด WHT + workflow)
  2. โครงสร้างค่าคอม — flat %, ขั้นบันได (tiered), หรือต่างตามประเภทบริการ?
  3. คิดคอมจาก revenue ที่รับรู้ หรือจากยอดขายคอร์ส (เกี่ยว deferred revenue domain 7)?
  4. แพทย์หมุนหลายสาขา (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)

คำถามเปิด:

  1. มี price list กี่ระดับ — ราคาต่างตามสาขาไหม? ตามระดับสมาชิก (Silver/Gold/Platinum) ไหม?
  2. Loyalty point — สะสม/แลกยังไง? ตอนแลกลงบัญชีเป็นส่วนลด/ค่าใช้จ่ายการตลาด/liability?
  3. Campaign pricing — ตามช่วงวัน + กรองสินค้า/สาขา + tag เพื่อวัด ROI (ch45) — ใครตั้ง/อนุมัติ?
  4. ราคาสมาชิก 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)

คำถามเปิด:

  1. JERA โพสต์เข้า Odoo ต่อบิล (real-time) หรือ batch ตอนปิด session (POS session batching ch44)?
  2. การ attribute แพทย์/analytic ต้องติดไปกับบิล ก่อนปิด session (ch41 เตือน: ตั้งหลังปิดไม่ได้) — JERA ส่ง field นี้ไหม?
  3. JERA offline (เน็ตล่ม) — ขายต่อได้ไหม? queue แล้ว sync? (ต่อ domain 3 ข้อ 3)
  4. ช่องทางชำระเงิน — เงินสด/บัตร/โอน/QR/ผ่อน? เชื่อม payment gateway ไหน? reconcile กับ Odoo ยังไง?
  5. ในระยะยาว 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)

คำถามเปิด:

  1. recall workflow — เรียกคืนภายใน 24 ชม. (ช45) ต้องรู้ว่า lot ไหนไปสาขาไหน/คนไข้ไหน — เก็บ lot→patient เต็มไหม?
  2. มียา/ของ ควบคุมพิเศษ ที่ต้องรายงาน อย. เป็นงวดไหม?
  3. PDPA — ประวัติการรักษา + lot ที่ฉีดให้คนไข้ = ข้อมูลส่วนบุคคลอ่อนไหว ต้องมี consent log ไหม? เก็บได้นานแค่ไหน (retention)?
  4. มีข้อกำหนด 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)

คำถามเปิด:

  1. รายงานตามกฎหมาย — ภ.พ.30, ภ.ง.ด., รายงานภาษีซื้อ/ขาย ออกจาก Odoo ครบไหม?
  2. KPI ผู้บริหาร — revenue ต่อแพทย์/สาขา/หมวด, อัตรา utilization, ยอดคอร์สคงเหลือ — dashboard ที่ไหน?
  3. Roles & สิทธิ์ — ใครเห็น/ทำอะไรได้ (หัวหน้าสาขาเห็นเฉพาะสาขาตัวเอง, ผู้บริหารเห็นรวม)? segregation of duties?
  4. 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

คำถามเปิด:

  1. ข้อมูลเก่าอยู่ที่ไหน (JERA เดิม, Excel, ระบบบัญชีเก่า)? คุณภาพข้อมูลเป็นยังไง?
  2. map แคตตาล็อกสินค้า/คอร์สเดิม → GENCODE/Odoo product ยังไง? ใครตรวจ?
  3. Opening balances — stock คงเหลือ + lot, ยอด GL/AR/AP, และ ยอดคอร์สคงเหลือ (deferred revenue) ต่อลูกค้า — ยกเข้ายังไง ณ วัน cutover?
  4. กลยุทธ์ 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ตัวอย่าง REQMoSCoWPhase (ch44)
3 หน้าบ้าน–หลังบ้าน contractREQ-YD-001 GENCODE write-through → Odoo SSOTMustP0
12 บัญชีไทย VAT/WHT/COAREQ-YD-010 l10n_th + COA คลินิกMustP3
7 Deferred revenueREQ-YD-020 รับรู้รายได้ต่อ sessionMustP3
9 Lot/FEFO/sub-lotREQ-YD-030 sub-lot ขวดเปิด 24 ชม.MustP1
15 POS JERA↔OdooREQ-YD-040 near-real-time + analytic ก่อนปิดMustP2
14 Loyalty/campaignREQ-YD-050 loyalty point accountingShouldP3
8 Scheduling ห้อง/เครื่องREQ-YD-060 capacity schedulingCouldP2
4 Multi-company แฟรนไชส์REQ-YD-070 inter-companyWon'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 ต่อไปนี้ (จากคำตอบ ไม่ใช่จากการเดา):

  1. Process flow diagrams — order-to-cash, procure-to-pay, session→revenue recognition, lot→recall (ต่อ domain)
  2. Odoo configuration workbook — companies/branches, journals, COA (l10n_th), taxes (VAT/WHT), products/UOM, pricelists, access groups
  3. Fit-Gap log — อะไร Odoo ทำได้ native vs ต้อง custom dev (เช่น sub-lot ขวดเปิด, deferred-revenue schedule, GENCODE write-through API)
  4. หน้าบ้าน–หลังบ้าน API contract — GENCODE/JERA → Odoo (endpoint, payload, idempotency, offline queue, conflict policy)
  5. Data migration plan — source map + opening-balance journals (รวมยอดคอร์สคงเหลือ)
  6. 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 ที่แยกหน้าที่ชัดเจน:

CodeTypeใครใช้ตัวอย่างหน้าที่
idint PKทุก module (FK)42identity สำหรับ JOIN ทุก table — ไม่เปลี่ยน ไม่ encode ข้อมูล
jera_codevarchar auto-genPOS / scan / labelC-00422running number สำหรับหน้าร้าน — สั้น จำง่าย scan ได้ (เหมือน JERA ปัจจุบัน)
full_codeGENERATED columndashboard / classificationCRS-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 ทำงานแบบนี้:

  1. Step 1: User กรอก Thai free text ที่ JERA POS — เช่น "โบท็อกซ์ allergan 50u ฉีดหน้าผาก"
  2. Step 2: glyph-oracle Chrome Extension จับ text → GENCODE mapper parse เป็น segments (type, subtype, category, differentiator, seq, variant) — logic เดิมทุกประการ ไม่เปลี่ยนอะไร
  3. Step 3: glyph-oracle ยิง JSON-RPC ไป Odoo → lookup product.template ด้วย segment fields (ถ้ามีแล้ว = return, ถ้ายังไม่มี = create ใหม่พร้อม GENCODE segment fields ทั้งหมด)
  4. 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-incrementid 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 conditionir.sequence ใช้ PostgreSQL nextval() — lock-free, race-free, กำหนด prefix/suffix/padding ได้ต่อ company; no_gap mode ใช้ FOR UPDATE NOWAIT สำหรับเลขเอกสารที่กฎหมายบังคับต่อเนื่อง
Indexinggencode_full_code + jera_code indexed ใน yd_gencode module — custom field index ผ่าน Odoo ORM index=True หรือ SQL CREATE INDEX ตรง ๆ
Multi-branch isolationcompany_id field + ir.rule row-level security — user สาขา A เห็นเฉพาะ data ของสาขา A, ผู้บริหารเห็นรวม, ไม่ต้องเขียน RLS policy เอง
Concurrent writesPostgreSQL 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.templatename_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_gencode module 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)