Product Code Design: Identity, Migration & After
ตั้งแต่คำถามพื้นฐาน (intelligent code vs surrogate key) ผ่าน migration runbook ไปจนถึงภาพ before/after — ทุกขั้นตอนของการออกแบบ product code identity · From the fundamental identity question through migration runbook to the final before/after view.
สารบัญ · Table of Contents
คำถามหลัก · The Core Question
มีปรัชญาออกแบบ identifier อยู่ 2 ขั้ว และ GENCODE กับ Odoo เลือกคนละขั้ว:
| Smart Code (GENCODE) | Surrogate Key (Odoo) | |
|---|---|---|
| ตัวระบุหลัก · Identity | CRS-SUR-INI-NOSE-SEMI-001-DRDO (รหัสมีความหมาย) | id = 4471 (เลข int ไร้ความหมาย) |
| ความหมายอยู่ที่ไหน | ฝังใน string คั่นด้วย dash | อยู่ในคอลัมน์/ความสัมพันธ์แยก (categ_id) |
| มนุษย์อ่านออก | ✓ อ่านออกทันที | ✗ ต้อง join หา name |
| เครื่องใช้เป็น key | string ยาว แปรผันได้ | ✓ int สั้น คงที่ตลอดกาล |
หัวใจที่ค้นพบจาก source จริงของ Odoo 19: Odoo เก็บ "ตัวตน" เป็น id โง่ ๆ แล้วทำให้รหัสที่มนุษย์อ่านเป็นแค่ "projection" (computed field) — ไม่ใช่ตัว key
// odoo19-core: product/models/product_template.py
categ_id = fields.Many2one('product.category', ...) // หมวด = ความสัมพันธ์ (ไม่ใช่ string ที่ parse)
default_code = fields.Char('Internal Reference',
compute='_compute_default_code', ...) // รหัสมนุษย์ = computed projection
barcode = fields.Char(compute='_compute_barcode', ...) // บาร์โค้ด = computed projection
นี่คือบทเรียนสำคัญ: Odoo มีรหัสที่มนุษย์อ่านได้ (default_code, barcode, account code) — แต่มันเป็นภาพฉายของข้อมูล ไม่ใช่ตัวตน ตัวตนคือ id ที่ไม่มีวันเปลี่ยน
NWFTH = จุดที่ 3 (verified จาก TFCLIVE)
แต่จริง ๆ มันเป็น spectrum 3 จุด ไม่ใช่ 2 ขั้ว — NWFTH (BatchMaster ERP, ใช้งานจริงหลายปี) พิสูจน์ทางสายกลาง:
| ระบบ | Master-data key | FK ใน transaction | ledger key |
|---|---|---|---|
| GENCODE | smart dash code (แตก segment) | full_code (text) | — |
| NWFTH | Itemkey nvarchar(18) — meaningful แต่ atomic | Itemkey (OELIN/POLIN อ้างด้วย string นี้) | InTransID int (surrogate!) |
| Odoo | surrogate id + code เป็น projection | product_id (int) | id (int) |
ข้อค้นพบที่ยืนยันคำแนะนำของบทนี้พอดี: NWFTH ใช้ meaningful key (Itemkey) สำหรับ master data แต่ใช้ surrogate int (InTransID) สำหรับ ledger ที่ volume สูง (Mintxdh) — นี่คือ "meaningful key สำหรับตัวตน, surrogate สำหรับปริมาณ" ที่ ERP จริงเลือกใช้มาแล้ว ดังนั้น GENCODE ไม่ผิดที่ใช้ smart code — แต่ควรเดินตาม NWFTH/Odoo: surrogate สำหรับ transaction volume
ตารางเทียบหลายมิติ · Multi-Dimension Comparison
| มิติ · Dimension | GENCODE Smart Code | Odoo Surrogate | ใครได้เปรียบ |
|---|---|---|---|
| Query หมวดหมู่ | LIKE 'CRS-SUR-%' (prefix เท่านั้น) | WHERE categ_id = X (indexed) | Odoo |
| Query ช่องกลาง (เช่น หมอ) | LIKE '%-DRDO%' — สแกนทั้งตาราง ⚠️ | WHERE doctor_id = X (indexed) | Odoo |
| เปลี่ยนการจัดหมวด | รหัสเปลี่ยน → reference พังหมด | แก้ field เดียว id คงเดิม | Odoo |
| ความซ้ำ/collision | ต้องมี VERSION (-V002) แก้ | id ไม่มีวันซ้ำ | Odoo |
| FK ใน transaction | string 41 ตัว (อ้วน) | int 4 byte (ผอม) | Odoo |
| Delimiter เปราะ | dash ชนค่าที่มี dash ได้ | ไม่มี parsing | Odoo |
| Schema evolution | เปลี่ยน format = migrate ทุกรหัส (v3.6→v3.9) | id ไม่เคยเปลี่ยน format | Odoo |
| มนุษย์อ่าน/พูดทางโทรศัพท์ | ✓ อ่านออกทันที self-documenting | ต้องเปิดดู name | GENCODE |
| มิติวิเคราะห์ (analytic) | ✓ ทุก segment = แกนรายงานพร้อมใช้ | ต้อง tag analytic แยก | GENCODE |
| ภาษากลาง (ไทย/อังกฤษ) | ✓ ตัวย่อ language-neutral | name ต้องแปลทุกภาษา | เสมอ |
สรุปสั้น: smart code ชนะเรื่อง คน (อ่าน พูด รายงานวิเคราะห์) แต่แพ้เรื่อง เครื่อง (query, mutate, reference, scale) — และ "เครื่อง" คือสิ่งที่ ERP 100 สาขาต้องแบก
มิติ 1 — Query & Filter: ปัญหาช่องกลาง
นี่คือจุดอ่อนเชิงเทคนิคที่ใหญ่ที่สุดของ smart code: คุณ query ได้เร็วเฉพาะ "prefix" ของรหัส
-- หา "ศัลยกรรมทั้งหมด" — เร็ว (prefix ใช้ B-tree index ได้)
WHERE full_code LIKE 'CRS-SUR-%'
-- หา "งานหมอโดทุกหมวด" — ช้า! (wildcard นำหน้า = สแกนทั้งตาราง)
WHERE full_code LIKE 'CRS-SUR-%-DRDO%' -- index ช่วยไม่ได้
PostgreSQL B-tree index บน full_code ช่วย LIKE ที่ยึดซ้าย ('CRS-SUR-%') ได้ แต่ช่วย LIKE ที่มี wildcard นำหน้าไม่ได้เลย การ query ช่องกลาง (DIFFERENTIATOR, SEQ, VARIANT) จึงกลายเป็น full scan
ข่าวดี: glyph-oracle รู้ปัญหานี้แล้ว — schema เก็บทั้ง full_code และคอลัมน์แยก (type, subtype, category, differentiator, seq, variant) ดังนั้น query จริงใช้คอลัมน์ที่ index ได้ เหมือน Odoo — ตรงนี้ทำถูกแล้ว
แต่เกิดความเสี่ยงใหม่: เก็บ 2 ตัวแทน (รหัส + คอลัมน์) ต้องตรงกันเสมอ ถ้าใครแก้ category แต่ไม่แก้ full_code → ข้อมูลขัดกัน นี่คือสิ่งที่ Odoo เลี่ยงด้วยการมี source เดียว (relation) แล้ว compute รหัส
มิติ 2 — Mutability: รหัสที่เปลี่ยนตามแอตทริบิวต์
หลักการ data modeling: ตัวระบุควร "นิ่งและไร้ความหมาย", แอตทริบิวต์ควร "เปลี่ยนได้และแยกออกมา" smart code ทำตรงข้าม — มันเอาแอตทริบิวต์ที่เปลี่ยนได้ (หมวด เทคนิค หมอ) มาเป็นตัวระบุ
ผลคือ: วันที่คอร์สถูกจัดหมวดใหม่ (NOSE → FACE) รหัสเปลี่ยนจาก CRS-SUR-INI-NOSE-... เป็น CRS-SUR-INI-FACE-... แต่ full_code คือ business key ที่ transaction/mapping อ้างถึง → reference พังหมด หรือทำไม่ได้ หรือต้อง soft-delete ตัวเก่าแล้วสร้างใหม่ (เสียความต่อเนื่อง "นี่คือของชิ้นเดิม")
Odoo: เปลี่ยน categ_id จาก A เป็น B — id เท่าเดิม ประวัติ transaction ทั้งหมดยังชี้ id เดิม ไม่มีอะไรพัง การจัดหมวดใหม่ = แก้ field เดียว
นี่คือโรคคลาสสิกของ intelligent key: รหัสฝังแอตทริบิวต์ที่เปลี่ยนได้ → พอแอตทริบิวต์เปลี่ยน ตัวตนก็พัง ที่ ERP รัน 100 สาขาหลายปี สินค้า/บริการจะถูกจัดหมวดใหม่แน่นอน
มิติ 3 — Collision: ทำไมต้องมี VERSION
GENCODE มี segment ที่ 8 (VERSION = -V002, -V003) ไว้แก้ปัญหารหัสซ้ำ — สองสิ่งที่ต่างกันแต่ได้รหัสเดียวกันเพราะ "แอตทริบิวต์ที่เข้ารหัสตรงกันหมด แต่ต่างกันตรงที่ไม่ได้เข้ารหัส (ราคา/แคมเปญ)"
มองให้ลึก: VERSION มีอยู่เพราะ smart code เอา "ตัวตน" ไปผูกกับ "แอตทริบิวต์" ถ้าใช้ surrogate key เครื่องจักร dedup/collision/VERSION ทั้งชุดจะหายไปเลย — เพราะ id สองตัวต่างกันเสมอ ไม่ว่าแอตทริบิวต์จะเหมือนกันแค่ไหน
Odoo ไม่มีคำว่า collision ในระดับ identity — สินค้า 2 ชิ้นที่แอตทริบิวต์เหมือนกันเป๊ะ ก็แค่ id คนละตัว
มิติ 4 — Delimiter (dash) เปราะตรงไหน
การคั่นด้วย dash มีจุดเปราะเฉพาะตัว:
- ค่าที่มี dash — ถ้าแบรนด์หรือเทคนิคมี dash (เช่น "COL-LAGEN") การ
split('-')พังทันที Odoo ไม่เจอปัญหานี้เพราะค่าอยู่ในคอลัมน์ ไม่ parse - จำนวน segment ไม่คงที่ — CRS = 7-8 ช่อง (VERSION optional), SVC = 5, PRD = 5 parser ต้องรู้ layer ก่อนถึง parse ได้ และต้องรับมือ arity ที่แปรผัน
- ตำแหน่งผูกความหมาย — ช่องที่ 5 = DIFFERENTIATOR เสมอ ถ้าจะแทรกช่องใหม่ตรงกลาง = breaking change ทุกรหัส
- dash ทำงานซ้อน — เป็นทั้งตัวคั่น segment และตัวคั่น prefix ใน JERA (
C-0001) ความหมายซ้อนกัน
บทเรียน Odoo: เมื่อไม่เข้ารหัสความหมายลงใน string ที่ต้อง parse คุณจะไม่เจอปัญหา escaping, variable-arity, หรือ position-coupling เลย
มุมมอง Downstream — Purchase / Sales / Inventory
เมื่อเอา GENCODE ไปใช้ใน transaction line คำถามคือ เก็บอะไรเป็น FK?
| Module | Hot path คือ | ควรอ้างอิง | บทบาทของ smart code |
|---|---|---|---|
| Inventory | ยอดคงเหลือ (product, location) | surrogate id | แสดงผล + ค้นหา ไม่ใช่ FK |
| Sales | order line → product | surrogate id | แสดงบนใบ/หน้าจอ |
| Purchase | vendor ↔ product mapping | surrogate id | รหัส vendor แยกต่างหาก (supplierinfo) |
ถ้า transaction เก็บ full_code (41 ตัว) เป็น FK: ที่ 100 สาขา × transaction หลายล้านแถว ความต่างระหว่าง FK แบบ int4 กับ varchar(41) มหาศาล — index ใหญ่ขึ้นหลายเท่า join ช้าลง และถ้ารหัสเปลี่ยน (จัดหมวดใหม่) FK ทุกแถวค้าง
สูตร: transaction อ้างอิง surrogate (id / jera numeric) เสมอ; แสดง smart code + name ให้คน Odoo แยกชัด: เครื่องใช้ id, คนเห็น default_code + display_name
มุมมอง Accounting — รหัสไปอยู่ตรงไหนในบัญชี
บัญชีเป็นจุดที่ต้องระวังที่สุด เพราะมี "รหัส" หลายชนิดที่ทำหน้าที่ต่างกัน Odoo แยกชัดมาก:
// odoo19-core: account/models/account_account.py
code = fields.Char(size=64, compute='_compute_code', search=..., inverse=...) // รหัสผังบัญชี (411000) = display/grouping
account_type = fields.Selection(...) // ประเภทเชิงหน้าที่ (asset/income/...)
// account/models/account_move_line.py
analytic_distribution = fields.Json(...) // มิติบริหาร (by หมอ/สาขา/โครงการ)
บทเรียนสำคัญ 3 ข้อสำหรับ GENCODE → Accounting:
- อย่าเอา smart code ไปเป็น GL account code — ผังบัญชี (411000) คือโครงสร้างงบการเงิน คนละเรื่องกับ taxonomy ของสินค้า/บริการ
- เอา segment ไปเป็น "analytic dimensions" แทน — นี่คือจุดที่ smart code เก่งที่สุด! TYPE/CATEGORY/DOCTOR คือแกนรายงานบริหารที่อยากได้พอดี ("รายได้ต่อหมอ", "รายได้ต่อหมวดหัตถการ") map ลง
analytic_distributionของ Odoo ได้ตรง ๆ - double-entry ไม่เกี่ยวกับรหัส — เดบิต=เครดิตทำงานบน account + amount ไม่ว่ารหัสสินค้าจะ smart หรือ dumb
กล่าวคือ: smart code = analytic/management dimension ชั้นเยี่ยม แต่ = GL account key ที่แย่ แยกสองบทบาทนี้ให้ขาด
มุมมอง Dashboard / UX — จุดที่ smart code เปล่งประกาย
นี่คือมิติที่ smart code ได้เปรียบจริง — แต่ก็มีนัยที่ต้องเข้าใจ:
smart code ชนะเรื่อง:
- คนอ่าน
CRS-SUR-INI-NOSE-SEMI-001-DRDOเข้าใจทันที;id=4471ไม่สื่ออะไร - พูดทางโทรศัพท์/cross-ref กับ JERA POS ได้ (เหตุผลที่ Wind ออกแบบ C-0001)
- self-documenting ใน export, log, report ดิบ
- search-as-you-type: พิมพ์ "NOSE" กรองได้ทันที
แต่ Odoo ให้ UX ที่ดีโดยไม่ต้องมี smart key:
- แสดง
display_name("เสริมจมูก Semi-Open หมอโด") ไม่ใช่ id — อ่านง่ายกว่ารหัสด้วยซ้ำ และแปลภาษาได้ - faceted filter จากคอลัมน์ที่ index — กรองตามหมวด/หมอด้วย chip ที่มีสี ไอคอน คำแปล (ดีกว่า parse รหัส)
- ผู้ใช้ไม่เคยเห็นหรือพิมพ์ id เลย — เห็นแต่ name กับ filter
สรุปมิติ UX: smart code ให้ "identifier ที่อ่านได้" ฟรี — มีค่าจริงในบริบทคลินิก (พนักงาน cross-ref JERA, พูดด้วยเสียง) แต่ dashboard ที่ขัดเงาแล้ว UX ที่ดีมาจาก display_name + faceted filter ซึ่งฉายจากคอลัมน์ ไม่ใช่จากการ parse รหัส
GENCODE อยู่ตรงไหนแล้ว · Where GENCODE Already Stands
ข่าวดีที่สุด: GENCODE เดินมาครึ่งทางแล้ว — glyph-oracle schema เก็บทั้งสองอย่าง: full_code (smart) และคอลัมน์ decomposed (type, category, differentiator, ...) ที่ query ได้
ดังนั้นข้อเสนอไม่ใช่ "ทิ้ง smart code" — มันมีค่าจริงสำหรับงานคน — แต่เป็นเรื่องวินัย:
graph TB COL["คอลัมน์ decomposed
(type, category, differentiator...)
= source of truth · indexed · mutable"] ID["surrogate id / jera
= ตัวตน · immutable · FK ของ transaction"] CODE["full_code (smart)
= generated projection
สำหรับคนอ่าน/ค้นหา/พูด"] COL -->|generate| CODE COL --> ID ID -->|อ้างอิงโดย| TX["Purchase / Sale / Inventory / Accounting"] CODE -->|แสดงผลให้คน| TX
เป้าหมาย: คอลัมน์เป็น source of truth, full_code เป็น generated column (Postgres generated column / computed — เหมือน default_code ของ Odoo 19 ที่เป็น compute field) → สองตัวแทนไม่มีวันขัดกัน; transaction อ้าง surrogate; segment → analytic dimensions
คำตัดสิน · The Verdict
- เก็บ smart code ไว้ — มันเป็นสินทรัพย์จริงสำหรับงานคน (JERA cross-ref, พูดด้วยเสียง, รายงานวิเคราะห์, ภาษากลาง)
- แต่ลดบทบาทมันจาก "ตัวตน" เป็น "ภาพฉาย" — ทำให้
full_codeเป็น generated column ฉายจากคอลัมน์ ไม่ใช่ business key อิสระ (ตรงกับ Odoo 19 ที่default_code/account.code/barcodeเป็น compute field) - คอลัมน์ decomposed = source of truth — query/filter/mutate ที่นี่ (index ได้, จัดหมวดใหม่ได้โดยไม่พังตัวตน)
- transaction อ้างอิง surrogate (id/jera int) ไม่ใช่ varchar 41 ตัว — ผอม เร็ว ไม่ค้างเมื่อรหัสเปลี่ยน
- Accounting: segment → analytic dimensions (เก่งสุด); อย่าเอาไปเป็น GL account code
- VERSION/collision คือ "กลิ่น" ว่า identity ผูกกับ attribute — surrogate key ทำให้กลไกชุดนี้หายไปเอง
- หลักการเดียว: identity ต้องนิ่งและไร้ความหมาย; ความหมายต้องเปลี่ยนได้และแยกออกมา; รหัสที่คนอ่านคือภาพฉายของความหมาย
Generated Column คืออะไร · What It Means
generated column คือคอลัมน์ที่ ฐานข้อมูลคำนวณค่าให้เองจากคอลัมน์อื่นในแถวเดียวกัน เราเขียนค่าลงไปตรง ๆ ไม่ได้ — DB ปฏิเสธ มันจึง ไม่มีวันขัดกับแหล่งข้อมูลต้นทาง
A generated column is computed by the DB from other columns in the same row. You cannot write to it directly, so it can never diverge from its source.
full_code text GENERATED ALWAYS AS (
'CRS-'||type||'-'||subtype||'-'||category||'-'
||differentiator||'-'||seq||'-'||variant
||COALESCE('-'||version,'')
) STORED
STORED= คำนวณแล้วเก็บลง disk จริง (index ได้) — PostgreSQL 12+ รองรับ;VIRTUALเพิ่งมาใน PG 18 ดังนั้นบน Supabase ใช้ STORED- นิพจน์อ้างได้เฉพาะคอลัมน์ในแถวเดียวกัน, ต้อง immutable, ห้าม subquery/ตารางอื่น
- เทียบ Odoo: นี่คือ
default_codeที่เป็น compute field — แต่บังคับที่ชั้น DB แข็งกว่า app discipline
ทำไมต้อง migrate · Why
ตอนนี้ (จาก source src/db/pg/registry.ts): full_code เป็น text().notNull().unique() ที่ app เขียนค่าเอง คู่ขนานกับคอลัมน์ segment (type, subtype, category, differentiator, seq, variant, version) → 2 source of truth ที่ต้อง sync เอง
และจาก src/db/pg/mappings.ts: crs_svc_mapping.crs_code และ svc_prd_mapping.svc_code/prd_code ล้วน FK ไปที่ full_code (text) ไม่ใช่ id — ตรงกับ anti-pattern ที่ ch33 ชี้
การ migrate มี 2 phase: Phase 1 ทำ full_code เป็น generated (กันขัดกัน) — เสี่ยงต่ำ ทำก่อน; Phase 2 ย้าย FK ไป id (แก้ mutability จริง)
Phase 1 · Step 0 — ด่านพิสูจน์ (อย่าข้าม)
ก่อนแตะอะไร ต้องพิสูจน์ว่านิพจน์สร้างค่าตรงกับ full_code ที่มีอยู่ 100% ถ้าไม่ตรง = เจอ bug divergence ในข้อมูลจริงแล้ว (ต้อง reconcile ก่อน) หรือสูตรผิด
SELECT id, full_code,
'CRS-'||type||'-'||subtype||'-'||category||'-'||differentiator
||'-'||seq||'-'||variant||COALESCE('-'||version,'') AS expected
FROM gencode.course_registry
WHERE full_code IS DISTINCT FROM
'CRS-'||type||'-'||subtype||'-'||category||'-'||differentiator
||'-'||seq||'-'||variant||COALESCE('-'||version,'');
-- ต้องได้ 0 แถว จึงทำต่อ
ถ้า > 0 แถว: แต่ละแถวคือจุดที่ full_code กับ segment ขัดกันจริงในโปรดักชัน — สืบว่าฝั่งไหนถูก (ปกติ segment) แล้วแก้ก่อน นี่คือบทเรียน "falsify before fix" (/sop-debug) + "verify before claiming" ของ NWFTH
Phase 1 · Step 1 — สลับเป็น generated (ใน 1 transaction)
PostgreSQL ไม่มีคำสั่งแปลงคอลัมน์ธรรมดาเป็น generated ตรง ๆ ต้อง drop แล้ว add ใหม่ และเพราะ full_code มี FK ชี้อยู่ ต้องปลด FK ก่อน — ทำทั้งหมดใน transaction เดียว (atomic, rollback ได้ถ้าพลาด):
BEGIN;
-- 0) ดูชื่อ constraint/FK/index จริงก่อน: \d gencode.course_registry
-- 1) ปลด FK ที่ชี้มา full_code
ALTER TABLE gencode.crs_svc_mapping DROP CONSTRAINT crs_svc_mapping_crs_code_fkey;
-- 2) ปลด unique + index (รวม trigram search index ถ้ามี)
ALTER TABLE gencode.course_registry DROP CONSTRAINT course_registry_full_code_key;
-- 3) drop คอลัมน์เดิม
ALTER TABLE gencode.course_registry DROP COLUMN full_code;
-- 4) เพิ่มกลับเป็น GENERATED
ALTER TABLE gencode.course_registry
ADD COLUMN full_code text
GENERATED ALWAYS AS (
'CRS-'||type||'-'||subtype||'-'||category||'-'||differentiator
||'-'||seq||'-'||variant||COALESCE('-'||version,'')
) STORED;
-- 5) สร้าง unique + index คืน
ALTER TABLE gencode.course_registry ADD CONSTRAINT course_registry_full_code_key UNIQUE (full_code);
-- (สร้าง GIN trigram index คืนถ้าเดิมมี)
-- 6) สร้าง FK คืน
ALTER TABLE gencode.crs_svc_mapping
ADD CONSTRAINT crs_svc_mapping_crs_code_fkey
FOREIGN KEY (crs_code) REFERENCES gencode.course_registry(full_code);
-- ตรวจแล้วค่อย COMMIT (หรือ ROLLBACK เพื่อทดสอบรอบแรก)
COMMIT;
เพราะ Step 0 พิสูจน์แล้วว่า generated reproduces ค่าเดิมเป๊ะ — full_code ที่ DB คำนวณใหม่จึงเท่าเดิมทุกแถว FK ที่ crs_svc_mapping ชี้อยู่ยัง match → Step 6 ผ่าน ถ้าไม่ตรง transaction จะ fail แล้ว rollback เอง (ปลอดภัยโดยดีไซน์)
ทำซ้ำ 3 ตาราง: course_registry (CRS, 7-8 ช่อง), service_registry (SVC, 5 ช่อง), product_registry (PRD, 5 ช่อง) — แต่ละตารางนิพจน์ต่างกันตาม format
Phase 1 · ฝั่ง Drizzle + Edge Cases
glyph-oracle ใช้ Drizzle — ต้องอัปเดต schema ให้ตรง DB ใน commit เดียวกัน (บทเรียน: schema-as-code ต้อง mirror DB จริง ไม่งั้น build แตกตอน query):
// src/db/pg/registry.ts
fullCode: text("full_code").generatedAlwaysAs(
sql`'CRS-'||type||'-'||subtype||'-'||category||'-'||differentiator||'-'||seq||'-'||variant||coalesce('-'||version,'')`
).notNull().unique(),
drizzle-kit มัก generate แปลงคอลัมน์เดิม→generated ไม่ครบ → เขียนเป็น custom SQL migration (Step 1 ข้างบน) แทน
Edge cases ที่ต้องระวัง:
- segment เป็น NULL — ใน SQL
NULL || x = NULLทั้ง string จะกลายเป็น NULL คอลัมน์ segment ต้อง NOT NULL (ของ CRS เป็น NOT NULL อยู่แล้ว) หรือห่อ COALESCE ทุกช่อง - VERSION optional —
COALESCE('-'||version,'')จัดการ NULL→ไม่มี suffix ต้องตรงกับที่ app format ทุกวันนี้เป๊ะ - legacy
chk='X'— คอลัมน์ chk เก่า (checksum) ไม่อยู่ใน full_code v3.9 ตรวจว่าไม่มีแถวไหน full_code ลงท้าย-Xที่สร้างจาก chk; ถ้ามี ต้อง normalize ก่อน (จะโผล่ที่ Step 0) - per-layer — prefix ('CRS-'/'SVC-'/'PRD-') และจำนวนช่องต่างกัน 3 ตาราง 3 นิพจน์
สิ่งที่ Phase 1 เปิดโปง: Mutability
ข้อค้นพบสำคัญ: พอ full_code เป็น generated และยังเป็น FK target — การแก้ segment (เช่นจัดหมวด NOSE→FACE) จะทำให้ full_code เปลี่ยนอัตโนมัติ → ละเมิด FK ที่ crs_svc_mapping ชี้อยู่ (เว้นแต่ตั้ง ON UPDATE CASCADE)
นี่ไม่ใช่ bug — มันคือความจริงของ ch33 ที่ถูกทำให้มองเห็น: ตราบใดที่ "รหัสที่ฉายจาก attribute" ยังเป็น FK target การเปลี่ยน attribute ก็สั่นสะเทือน reference ทั้งหมด generated column ทำให้ความเชื่อมโยงนี้ซื่อสัตย์ — และบังคับให้เราไป Phase 2
Phase 2 · ย้าย FK ไป surrogate id
การแก้จริงคือให้ mapping อ้าง id (int คงที่) แทน full_code (text ที่ฉายจาก attribute) — ทำแบบ backfill ปลอดภัย ไม่มี downtime:
-- 1) เพิ่มคอลัมน์ FK ใหม่ (int) แบบ nullable ก่อน
ALTER TABLE gencode.crs_svc_mapping ADD COLUMN crs_id integer;
-- 2) backfill จากรหัสเดิม
UPDATE gencode.crs_svc_mapping m
SET crs_id = c.id
FROM gencode.course_registry c
WHERE c.full_code = m.crs_code;
-- 3) บังคับ NOT NULL + FK ไป id
ALTER TABLE gencode.crs_svc_mapping ALTER COLUMN crs_id SET NOT NULL;
ALTER TABLE gencode.crs_svc_mapping
ADD CONSTRAINT crs_svc_mapping_crs_id_fkey
FOREIGN KEY (crs_id) REFERENCES gencode.course_registry(id);
-- 4) อัปเดตโค้ดอ่าน/เขียนให้ใช้ crs_id; เมื่อทุกที่เลิกใช้ crs_code แล้ว
-- ค่อย DROP FK + คอลัมน์ crs_code (Nothing-is-Deleted: เก็บ snapshot ก่อน)
ผลลัพธ์: เปลี่ยน segment ได้อิสระ — full_code regenerate ใหม่, แต่ id ไม่เปลี่ยน reference บน id จึงไม่มีวันค้าง นี่คือโมเดล Odoo เต็มรูปแบบ (id = ตัวตน, code = ภาพฉาย)
โบนัส: FK เป็น int4 แทน varchar(41) → index เล็กลงมาก join เร็วขึ้น สำคัญที่ 100 สาขา (ch32)
วินัยการทดสอบ · Testing Discipline
- ห้องแล็บ migration — รันทั้ง migration ใน
BEGIN; ... ROLLBACK;บน copy ของ prod ก่อน ตรวจผล แล้วค่อยทำจริง (เทียบ odoo shell +cr.rollback()ของ erp-oracle) - ทดสอบ artifact ที่ merge แล้ว — รัน migration ที่ผ่าน PR จริง end-to-end ไม่ใช่ hand-patch DB (บทเรียน NWFTH)
- ห้าม ALTER prod โดยไม่อนุมัติ — แสดง SQL จริงให้เจ้าของอนุมัติก่อน (ALTER/DROP COLUMN = destructive)
- verify หลังทำ: rerun Step 0 (ต้อง 0), ลอง INSERT (ต้องสร้าง full_code อัตโนมัติ), ลองเขียน full_code ตรง ๆ (ต้องถูกปฏิเสธ), ตรวจ FK valid,
npm run build(schema sync) - Drizzle mirror — แก้ TS schema ใน commit เดียวกับ migration เสมอ
สรุป · Checklist
- generated column = DB คำนวณ full_code จาก segment ให้เอง เขียนตรงไม่ได้ → ขัดกันไม่ได้ (เหมือน Odoo default_code compute field)
- Step 0 พิสูจน์ก่อนเสมอ: นิพจน์ต้อง reproduce full_code เดิม 100% (IS DISTINCT FROM ต้องได้ 0 แถว)
- Phase 1: drop FK→drop unique→drop column→add GENERATED→recreate unique/index/FK ใน 1 transaction; ทำ 3 ตาราง (CRS/SVC/PRD)
- Phase 1 เปิดโปง mutability → นำไป Phase 2: ย้าย FK จาก full_code (text) ไป id (int) แบบ backfill
- ผล: segment เปลี่ยนได้อิสระ, id ไม่ค้าง, FK int ผอม/เร็วที่ 100 สาขา
- ทดสอบใน transaction+rollback บน prod copy ก่อน; อัปเดต Drizzle schema commit เดียวกัน; ห้าม ALTER prod ไม่อนุมัติ
ปัญหาของวันนี้ · What is Wrong Today
GENCODE ออกแบบ ดีกว่าที่คิด — มีทั้ง decomposed columns (type, subtype, category...) และ full_code อยู่แล้ว ปัญหาคือ บทบาท ของแต่ละอัน:
graph TD
subgraph TODAY["Today: full_code = Writable + FK Target"]
SEG1["Decomposed Segments
type=SUR, subtype=INI,
category=NOSE, differentiator=SEMI,
seq=001, variant=DRDO"]
FC1["full_code = 'CRS-SUR-INI-NOSE-SEMI-001-DRDO'
(writable, independent text column)"]
MAP1["crs_svc_mapping
crsCode TEXT FK → fullCode"]
MAP2["svc_prd_mapping
svcCode TEXT FK → fullCode"]
SEG1 -.->|"might diverge!"| FC1
FC1 -->|"TEXT FK (slow, fragile)"| MAP1
FC1 -->|"TEXT FK (slow, fragile)"| MAP2
end
3 Problems
- Text FK Performance — ทุก JOIN ใช้ text comparison (41+ chars) แทน integer (4 bytes) เมื่อ transaction tables เพิ่มขึ้น (sale_order_line, inventory_move, journal_entry_line) ทุก FK ที่ชี้ full_code จะช้าขึ้นเรื่อย ๆ · Odoo ที่ scale ถึงหลายสิบล้าน users ใช้ integer FK ล้วน (ch35 verified from pg_constraint)
- Rename = Cascade Update — ถ้าเปลี่ยน segment (เช่น Juvelook ย้ายจาก SKB → BIO) ต้อง UPDATE full_code ของ course_registry + UPDATE ทุก FK ใน mapping tables + ทุก transaction table ที่อ้าง ยิ่งมี transaction เยอะ ยิ่ง UPDATE นาน + เสี่ยง lock · ถ้า FK ชี้
id(int) → rename segment → full_code auto-regenerate → FK ไม่กระทบเลย - Segments vs full_code อาจ Diverge — full_code เป็น writable column ที่เขียนแยกจาก segments ถ้าใครแก้ segment โดยไม่ regenerate full_code → ข้อมูลไม่ตรง (segments บอก SUR แต่ full_code บอก SKN) ถ้า full_code เป็น GENERATED column → ไม่มีทาง diverge
Recommended Design · ออกแบบใหม่
graph TD
subgraph AFTER["After: Segments = Source of Truth, id = FK, full_code = Projection"]
SEG2["Decomposed Segments
(Source of Truth)
type, subtype, category,
differentiator, seq, variant
B-tree indexed, queryable, mutable"]
FC2["full_code = GENERATED ALWAYS AS
('CRS-' || type || '-' || subtype || ...)
STORED — read-only, auto-sync"]
ID2["id (integer PK)
GENERATED ALWAYS AS IDENTITY
= FK target for ALL transactions"]
SALE["sale_order_line
course_id INT FK → id"]
INVMV["inventory_move
product_id INT FK → id"]
JEL["journal_entry_line
product_id INT FK → id"]
SEG2 -->|"auto-compute"| FC2
ID2 -->|"INT FK (fast, stable)"| SALE
ID2 -->|"INT FK"| INVMV
ID2 -->|"INT FK"| JEL
end
Three Roles, Clearly Separated
| Component | Role | Who Uses It | Mutable? |
|---|---|---|---|
| Decomposed Segments (type, subtype, category, differentiator, seq, variant, version) | Source of Truth — ข้อมูลจริงที่แก้ไขได้ | Backend logic, query, filter, reportingWHERE type='SUR' AND variant='DRDO' → B-tree index | Yes — แก้ segment ได้ full_code auto-regenerate |
| full_code (text, GENERATED ALWAYS AS STORED) | Display Projection — รหัสที่คนอ่าน | UI, receipt, POS display, JERA sync พนักงานเห็น CRS-SUR-INI-NOSE-SEMI-001-DRDO | No — read-only, computed จาก segments |
| id (integer, GENERATED ALWAYS AS IDENTITY) | FK Target — ตัวอ้างอิงใน transactions | sale_order_line, inventory_move, journal_entry_line ทุก transaction table ชี้ id ไม่เคยชี้ full_code | No — immutable, never changes |
หลักการ: คนอ่าน full_code (meaningful, readable) · เครื่องชี้ id (fast, stable) · data อยู่ที่ segments (single source of truth)
3-System Comparison · เปรียบเทียบ 3 ระบบ (Live-Queried)
| Dimension | GENCODE (Today) | GENCODE (Recommended) | Odoo 19 (772 tables, live) | NWFTH (822 tables, live) |
|---|---|---|---|---|
| Identifier | full_code text (writable) | id int (surrogate) | id int (surrogate) | Itemkey nvarchar(18) (meaningful) |
| Human code | full_code = FK target | full_code = GENERATED (display only) | default_code varchar (nullable label) | Itemkey = FK target |
| FK in transactions | text FK (41+ chars, slow) | int FK (4 bytes, fast) | int FK (ทุก FK → id) | text FK (Itemkey ทุกตาราง) |
| Segment query | decomposed columns + index ✅ | decomposed columns + index ✅ | categ_id FK (ต้อง JOIN) | Itemtyp/ItemSubtyp (3-char) |
| Rename-safe | ❌ rename = UPDATE ทุก FK | ✅ rename segment → full_code auto-regen, FK ไม่กระทบ | ✅ change default_code, FK ยัง id | ❌ rename Itemkey = UPDATE ทุกตาราง |
| Divergence risk | ❌ segments กับ full_code อาจไม่ตรง | ✅ GENERATED = ไม่มีทาง diverge | N/A (no compound code) | N/A (Itemkey = atomic) |
| Scale proven | ~15 tables, 1 branch | designed for 100+ branches | 772 tables, millions of users | 822 tables, years in production |
Insight: GENCODE recommended design = best of both worlds — meaningful code ที่คนอ่านออก (จุดแข็งเหมือน NWFTH Itemkey) + surrogate FK ที่เร็วและ stable (จุดแข็งเหมือน Odoo id) + decomposed segments ที่ query ได้ (จุดแข็งที่ Odoo และ NWFTH ไม่มี)
CRS 7-Segment Deep Dive · โครงสร้างแต่ละ Segment
จาก course_registry schema จริง + CRS-CODE.md spec v3.9:
| Position | Segment | Column | Example | Role | Query Pattern |
|---|---|---|---|---|---|
| 1 | Prefix | (fixed 'CRS') | CRS | ระบุว่าเป็น Course layer | — |
| 2 | TYPE | type | SUR / SKN / TRT / VIT / MSC | ใครทำ? (หมอ/therapist/IV) | WHERE type='SUR' = ทุกศัลยกรรม |
| 3 | SUBTYPE | subtype | INI / OPT / PKG / REV | ซื้อแบบไหน? (ครั้งแรก/เสริม/package/review) | WHERE subtype='PKG' = ทุก package |
| 4 | CATEGORY | category | NOSE / FIL / BTX / EYE | หัตถการอะไร / สินค้าอะไร | WHERE category='FIL' = ทุกฟิลเลอร์ |
| 5 | DIFFERENTIATOR | differentiator | SEMI / RST / CLAS | ต่างจากอันอื่นในหมวดตรงไหน? (technique/brand) | SUR: technique (SEMI/OPEN) · SKN: brand (RST/EPTQ) |
| 6 | SEQ | seq | 001 / 002 | ลำดับ (ป้องกันซ้ำ) | ปกติ 001 ไม่ต้อง query |
| 7 | VARIANT | variant | DRDO / DRBALL / 4CC / BASE | SUR = หมอ · SKN = ปริมาณ/grade | WHERE variant='DRDO' = ทุกอย่างหมอ Do |
| 8* | VERSION (optional) | version | V002 / V003 | Dedup — เมื่อมี collision ราคาต่าง | เฉพาะ kept duplicates เท่านั้น |
จุดแข็งที่ไม่มีใน Odoo/NWFTH: Segments ที่ decompose แล้ว query ได้ตรง ๆ — "ทุก SUR ของหมอ Do" = WHERE type='SUR' AND variant='DRDO' (B-tree index, O(log n)) แทนที่จะ LIKE '%DRDO%' (full scan) · Odoo ต้อง JOIN ผ่าน categ_id → product.category tree; NWFTH ใช้ Itemtyp 3 chars แต่ไม่ละเอียดเท่า 7 segments
PRD + SVC — ต่างจาก CRS อย่างไร
GENCODE มี 3 registries แต่ละตัว segment ต่างกัน:
| Registry | Segments | Columns ใน DB จริง | Example |
|---|---|---|---|
| CRS (Course) | 7 segments + optional VERSION | type, subtype, category, differentiator, seq, variant, version | CRS-SUR-INI-NOSE-SEMI-001-DRDO |
| SVC (Service) | 5 segments | type, category, item, qualifier | SVC-SUR-NOSE-SEMI-DRDO |
| PRD (Product) | 5 segments | grp, type, item, spec + bomType | PRD-AES-FIL-RST-CLAS (Restylane Classic) |
ทุก registry ใช้ pattern เดียวกัน: segments = source of truth, full_code = generated projection, id = FK target · PRD เพิ่ม bomType (FIXED/VARIABLE) — สำหรับสินค้าที่จำนวนใช้ไม่แน่นอน (เช่น Botox buffet = VARIABLE)
Mapping Tables — เปลี่ยน FK จาก text → int
graph LR
subgraph BEFORE["Before: Text FK"]
CRS_B["course_registry
full_code = 'CRS-SUR-...'"]
MAP_B["crs_svc_mapping
crsCode TEXT → full_code"]
CRS_B -->|"TEXT FK
(41 chars, slow)"| MAP_B
end
subgraph AFTER2["After: Integer FK"]
CRS_A["course_registry
id = 42
full_code = GENERATED"]
MAP_A["crs_svc_mapping
course_id INT → id"]
CRS_A -->|"INT FK
(4 bytes, fast)"| MAP_A
end
Migration Path · วิธี Migrate (2 Phases)
Phase 1: full_code → GENERATED ALWAYS AS STORED
เปลี่ยน full_code จาก writable column เป็น generated column:
- Verify: ตรวจว่า full_code ปัจจุบันตรงกับ segments ทุกแถว (
WHERE full_code != 'CRS-' || type || '-' || subtype || ...= 0 rows) - Alter:
ALTER TABLE course_registry DROP COLUMN full_code; ALTER TABLE course_registry ADD COLUMN full_code text GENERATED ALWAYS AS (...) STORED; - Unique constraint:
CREATE UNIQUE INDEX ON course_registry (full_code); - Update Drizzle schema: เปลี่ยน field definition ใน registry.ts
ทำเหมือนกันสำหรับ service_registry + product_registry
Phase 2: FK จาก text → integer
- Add new column:
ALTER TABLE crs_svc_mapping ADD COLUMN course_id integer REFERENCES course_registry(id); - Backfill:
UPDATE crs_svc_mapping SET course_id = (SELECT id FROM course_registry WHERE full_code = crs_svc_mapping.crs_code); - Set NOT NULL:
ALTER TABLE crs_svc_mapping ALTER COLUMN course_id SET NOT NULL; - Drop old FK:
ALTER TABLE crs_svc_mapping DROP COLUMN crs_code; - Add ON DELETE: FK constraint with RESTRICT (ห้ามลบ course ถ้ายังมี mapping อ้าง)
ทำเหมือนกันสำหรับ svc_prd_mapping (svc_code → service_id, prd_code → product_id)
| Phase | Risk | Downtime | Reversible |
|---|---|---|---|
| Phase 1 (Generated Column) | Low — ข้อมูลไม่เปลี่ยน แค่ทำให้ auto-compute | ไม่มี (ALTER online) | Yes — drop generated, add back writable |
| Phase 2 (FK migration) | Medium — ต้อง backfill + drop old column | ไม่มี (add→backfill→set NOT NULL→drop เป็นขั้น) | Yes — add old column back + backfill reverse |
Real Example · ตัวอย่างจริง Before vs After
Before (Today)
| Table | Column | Value | Problem |
|---|---|---|---|
| course_registry | full_code | 'CRS-SUR-INI-NOSE-SEMI-001-DRDO' | writable, might diverge from segments |
| course_registry | type | 'SUR' | source of truth but full_code doesn't auto-sync |
| crs_svc_mapping | crs_code | 'CRS-SUR-INI-NOSE-SEMI-001-DRDO' | TEXT FK — 34 bytes, slow JOIN |
After (Recommended)
| Table | Column | Value | Benefit |
|---|---|---|---|
| course_registry | id | 42 | PK, immutable, FK target |
| course_registry | type | 'SUR' | source of truth — แก้ได้ |
| course_registry | full_code | 'CRS-SUR-INI-NOSE-SEMI-001-DRDO' | GENERATED — auto-sync, read-only |
| crs_svc_mapping | course_id | 42 | INT FK — 4 bytes, fast JOIN, rename-safe |
Scenario: Juvelook ย้ายหมวดจาก SKB → BIO
| Step | Before (Today) | After (Recommended) |
|---|---|---|
| 1. แก้ segment | UPDATE category='BIO' WHERE item='JVLK' | UPDATE category='BIO' WHERE item='JVLK' |
| 2. แก้ full_code | UPDATE full_code='PRD-AES-BIO-JVLK-CC' manually | ไม่ต้องทำ — GENERATED auto-update |
| 3. แก้ FK ใน mappings | UPDATE ทุกแถวใน svc_prd_mapping ที่ prd_code='PRD-AES-SKB-JVLK-CC' | ไม่ต้องทำ — FK ชี้ id ไม่ใช่ full_code |
| Risk | ลืมแก้ 1 ตาราง = data inconsistency | ไม่มี risk — auto + stable |
JERA POS Integration · รหัส 20 ตัวอักษร
JERA POS รองรับ code แค่ 20 ตัวอักษร — GENCODE full_code ยาว 30+ ตัว ดังนั้นมี jera_code (varchar 20) เป็น compressed version:
| Layer | full_code (display) | jera_code (POS) | id (FK) |
|---|---|---|---|
| CRS | CRS-SUR-INI-NOSE-SEMI-001-DRDO | C-0042 | 42 |
| SVC | SVC-SUR-NOSE-SEMI-DRDO | S-0015 | 15 |
| PRD | PRD-AES-FIL-RST-CLAS | AES-FIL-RST-CLAS | 7 |
jera_code ใช้ pattern C-{id} (CRS) / S-{id} (SVC) — derived จาก id ที่เป็น IDENTITY · PRD ใช้ JERA display format (drop prefix PRD, max 20 chars) · Chrome Extension + mapper engine ใน glyph-oracle ทำ mapping JERA ↔ GENCODE อยู่แล้ว
After migration: jera_code ก็ควรเป็น GENERATED เหมือน full_code — ไม่ต้องเขียนมือ ไม่มีทาง diverge
UX Reality Check — ปัญหาจริงของการให้ User จำ full_code
ปัญหาที่ต้องพูดตรง ๆ: ถ้าออกแบบให้พนักงานต้อง จำ หรือ พิมพ์ CRS-SUR-INI-NOSE-SEMI-001-DRDO (35 ตัวอักษร) ในการทำงานประจำวัน — มันจะเป็นปัญหาจริง
ทำไมให้ user จำ full_code ไม่ work
- Cognitive Load สูงเกิน — จิตวิทยาการรับรู้ (Miller's Law) บอกว่าคนจำ string ได้ ~7 chunks ± 2 ·
CRS-SUR-INI-NOSE-SEMI-001-DRDOมี 7 segments ก็จริง แต่แต่ละ segment 2-5 ตัวอักษร + dash = รวม 35 chars เกิน working memory ของคนส่วนใหญ่ · พนักงานที่เข้าใจ structure จะ decode ได้ (SUR=ศัลยกรรม, NOSE=จมูก) แต่ พนักงานใหม่จะจำไม่ได้เลย - Error-prone — พิมพ์ 35 ตัวอักษร ผิด 1 ตัว (SEMI → SIMI, DRDO → DROD) = ไม่เจอสินค้า = เสียเวลา = frustration · ยิ่งเร่งยิ่งผิด POS ที่ต้องเร็วจะ error rate สูง
- Phone communication — พูดทางโทรศัพท์ "C-R-S ขีด S-U-R ขีด I-N-I ขีด N-O-S-E ขีด S-E-M-I ขีด 0-0-1 ขีด D-R-D-O" ใช้เวลา 15 วินาที เทียบกับ "คอร์สจมูกเซมิหมอ Do" 3 วินาที หรือ "C-0042" 2 วินาที
- Scale ไม่ได้ — 378 PRD + CRS + SVC = 500+ codes รวม ไม่มีมนุษย์คนไหนจำ 500 codes 35 ตัวอักษรได้ แม้แต่คนที่ทำงาน 10 ปีก็จำได้แค่ 20-30 codes ที่ใช้บ่อย
แต่ — full_code มีค่าในฐานะ "Readable Reference"
full_code ไม่ได้ไร้ค่า — มันมีค่ามากในฐานะ reference ที่อ่านแล้วเข้าใจ เหมือน file path (/home/user/documents/report.pdf) ไม่มีใครจำ path แต่เห็นแล้วรู้ทันทีว่าอยู่ตรงไหน · เวลาเห็น CRS-SUR-INI-NOSE-SEMI-001-DRDO คนที่เข้าใจ GENCODE จะอ่านออกว่า "คอร์สศัลยกรรมจมูกเซมิ หมอ Do" ทันที — แต่ อ่านออก ≠ จำได้
คำแนะนำ: อย่าให้ user ต้อง "จำ" — ให้ user "ค้นหา" แทน
graph LR
subgraph WRONG["❌ ออกแบบผิด: ให้ user จำ/พิมพ์ full_code"]
U1["พนักงานต้องจำ
CRS-SUR-INI-NOSE-SEMI-001-DRDO"]
U1 --> E1["พิมพ์ผิด → ไม่เจอ → เสียเวลา"]
U1 --> E2["พนักงานใหม่ จำไม่ได้ → ถามคนอื่น"]
end
subgraph RIGHT["✅ ออกแบบถูก: ให้ user ค้นหา/เลือก"]
U2["พนักงานพิมพ์ 'จมูก'
หรือกด filter SUR → NOSE"]
U2 --> R1["ระบบ fuzzy match → แสดงรายการ"]
R1 --> R2["เลือกจากรายการ → เห็น full_code เป็น reference"]
end
| Daily Workflow | วิธีที่ผิด (จำ code) | วิธีที่ถูก (ค้นหา) | ทำไม |
|---|---|---|---|
| POS ขายของ | พิมพ์ CRS-SUR-INI-NOSE-SEMI-001-DRDO | พิมพ์ 'จมูก' หรือ scan barcode C-0042 | เร็วกว่า 10 เท่า ไม่มี typo |
| โทรศัพท์ถามรหัส | สะกด 35 ตัวอักษร | บอก 'C-0042' หรือ 'คอร์สจมูกเซมิ' | 3 วินาที vs 15 วินาที |
| เช็คสต็อก | พิมพ์ PRD-AES-FIL-RST-CLAS | พิมพ์ 'Restylane' แล้วเลือก | Fuzzy match จำชื่อยี่ห้อง่ายกว่า code |
| สร้างคอร์สใหม่ | จำ format 7 segment | เลือก TYPE → SUBTYPE → CATEGORY จาก dropdown | ระบบช่วยประกอบ code ไม่ต้องจำ format |
| Report / Dashboard | ดู column full_code 35 chars | ดู TYPE + CATEGORY + VARIANT (3 columns สั้น ๆ) | อ่านง่าย pivot ได้ |
UX Design ที่ถูกต้อง — 5 หลักการ
- Search First, Code Second — ทุกหน้าจอ ช่อง search อยู่บนสุด พิมพ์ชื่อไทย/ชื่อยี่ห้อ/ชื่อหมอ → fuzzy match แสดงผลลัพธ์ที่มี full_code เป็น secondary info (สีเทาเล็ก ๆ ข้าง ๆ ชื่อ)
- Filter Buttons, Not Code Memory — แทนที่จะจำ TYPE=SUR ให้มีปุ่ม "ศัลยกรรม" "ผิวหนัง" "IV" "Treatment" กดกรองได้ทันที ไม่ต้องรู้ว่า SUR คืออะไร
- Autocomplete + Recent — เคยขาย "คอร์สจมูกเซมิ หมอ Do" แล้ว ครั้งหน้าพิมพ์ "จมู" ระบบ suggest ทันที + แสดง 10 รายการล่าสุดที่ user ขาย
- Barcode Scan = Zero Typing — ฉลากสินค้ามี barcode (JERA code / QR) scan แล้วเด้งขึ้นมาทันที ไม่ต้องจำ ไม่ต้องพิมพ์
- Code-Assisted Creation — เมื่อสร้างคอร์สใหม่ ระบบให้เลือก TYPE → SUBTYPE → CATEGORY จาก dropdown แล้วประกอบ full_code ให้อัตโนมัติ ไม่ต้องพิมพ์ code มือ (glyph-oracle Course Creator ทำแบบนี้อยู่แล้ว)
full_code แสดงที่ไหน (ยังมีค่า)
- ใบเสร็จ / Receipt — แสดงเป็น reference เล็ก ๆ ใต้ชื่อไทย (ลูกค้าไม่ต้องอ่าน แต่ back office ใช้ trace ได้)
- Backend Admin / Audit — เห็น full_code ชัดเจนสำหรับ IT / ผู้ตรวจสอบ
- Excel Export — ส่ง full_code เป็น 1 column (reference) + segments แยก columns (สำหรับ pivot)
- Error Messages / Support — "ไม่เจอสินค้า CRS-SUR-INI-NOSE-SEMI-001-DRDO" ← อ่านแล้วรู้ทันทีว่าอะไรผิด
สรุป: full_code ออกแบบมาดี (readable, meaningful) แต่ บทบาทที่ถูกต้อง = readable reference ไม่ใช่ daily identifier ที่ต้องจำ · Daily workflow ใช้: ชื่อไทย + search + filter + barcode scan + JERA code · full_code เป็น "อ่านแล้วเข้าใจ" ไม่ใช่ "จำแล้วพิมพ์"
Multi-Representation Strategy — สินค้าเดียว หลายหน้า
สินค้าเดียวกันมี หลายหน้า สำหรับแต่ละบริบท:
graph TB DB["Database
id = 42
(เครื่องใช้)"] FC["full_code
CRS-SUR-INI-NOSE-SEMI-001-DRDO
(35 chars — IT/audit)"] JR["jera_code
C-0042
(6 chars — POS/receipt/barcode)"] TH["ชื่อไทย
เสริมจมูก เซมิ หมอ Do
(คนใช้ คนพูดโทรศัพท์)"] BI["BI display
SUR / NOSE / DRDO
(chart label / pivot)"] DB --- FC DB --- JR DB --- TH DB --- BI
| Context | What to Show | Example | Length | Why |
|---|---|---|---|---|
| Database FK | id (integer) | 42 | 4 bytes | Fast JOIN, stable, never shown to humans |
| POS / Receipt / Label | JERA code | C-0042 | 6 chars | JERA POS limit 20 chars, compact, scannable |
| Barcode on product | JERA code as barcode | C-0042 (barcode) | 6 chars | Fits Code 39 / Code 128 / any scanner |
| Phone / Conversation | ชื่อไทย | เสริมจมูก หมอ Do | natural | พนักงานพูดได้ ลูกค้าเข้าใจ |
| POS Search | ชื่อ + filter buttons | พิมพ์ 'จมูก' หรือกด filter 'SUR' | — | ไม่ต้องจำรหัส กดกรองได้ |
| Power BI Pivot | Segment columns | TYPE=SUR, CATEGORY=NOSE | 3-5 chars/col | แต่ละ segment = 1 column = pivot ได้ |
| Chart Label | Short composite | SUR / NOSE / DrDo | 17 chars | อ่านออก ไม่ overlap |
| Excel Export | Decomposed columns | col A=SUR, col B=INI, col C=NOSE... | 3-5 chars/col | filter + pivot + VLOOKUP per segment |
| Backend / Debug / Audit | full_code | CRS-SUR-INI-NOSE-SEMI-001-DRDO | 35 chars | Developers + audit trail + traceability |
หลักการ: full_code ไม่ใช่สิ่งที่คนใช้ทุกวัน — มันคือ audit trail ที่อ่านออก สิ่งที่คนเห็นทุกวันคือ ชื่อไทย + JERA code + filter buttons · รหัส 35 ตัวอักษรจึง ไม่เป็นปัญหา ในแง่ UX เพราะไม่มีใครต้องจำหรือพิมพ์มัน
Barcode Compatibility — Code 39 / Code 128 / QR Code
GENCODE ใช้ uppercase + dash (-) + ตัวเลข — compatible กับ barcode ทุกมาตรฐาน แต่ ความยาว เป็นข้อจำกัด:
| Barcode Standard | Character Set | Practical Max Length | GENCODE full_code (35 chars) | JERA code (6-16 chars) |
|---|---|---|---|---|
| Code 39 | A-Z, 0-9, dash, space, ., $, /, +, % | 20-25 chars (reliable scan) | TOO LONG — barcode กว้างเกินสแกน | PERFECT ✅ (6 chars CRS, 16 chars PRD) |
| Code 128 | Full ASCII (128 chars) | 48+ chars (compact) | ใช้ได้ แต่ barcode กว้าง (~3 นิ้ว) | PERFECT ✅ |
| QR Code | Full Unicode, binary | 4,296 chars | PERFECT ✅ + ใส่ metadata ได้ (lot, expiry, URL) | PERFECT ✅ |
คำแนะนำ: ใช้ JERA code สำหรับ Barcode, QR สำหรับ Compliance
- ฉลากสินค้า (PRD): Code 39 หรือ Code 128 พิมพ์
AES-FIL-RST-CLAS(16 chars) — สแกนได้ทุกเครื่อง ฉลากไม่กว้างเกิน - Receipt / POS: Code 128 พิมพ์ JERA code
C-0042(6 chars) — compact สุด - Compliance label (อ.ย., lot tracking): QR Code ที่ encode: JERA code + lot number + expiry date + URL ไปหน้ารายละเอียดใน glyph-oracle — สแกน QR แล้วเห็นข้อมูลครบ
- ห้าม: barcode
full_code(35+ chars) — Code 39 จะกว้าง ~5 นิ้ว สแกนไม่ได้ในระยะปกติ
graph LR
subgraph LABEL["Product Label"]
BC["Code 128
AES-FIL-RST-CLAS
(16 chars, 1.5 inch)"]
QR["QR Code
C-0042 + Lot: 2026-A
+ Exp: 2027-06
+ URL: glyph.yd/p/42"]
NM["ชื่อ: Restylane Classic
(human readable)"]
end
Power BI + Excel + Charts — Analytics-Ready Design
รหัส 35 ตัวอักษรใน pivot table = column กว้างเกิน อ่านไม่ออก — แต่ GENCODE มีทางออกที่ดีกว่า ERP อื่นทุกตัว:
GENCODE Advantage: Decomposed Columns = Built-in Dimensions
เพราะ GENCODE เก็บ segments แยก (type, subtype, category, differentiator, variant) แต่ละ segment คือ 1 dimension column ใน analytics:
| Analytics Tool | Wrong Way (full_code) | Right Way (segments) | Result |
|---|---|---|---|
| Excel Pivot Table | Row = full_code (35 chars, unreadable) | Row = TYPE Column = CATEGORY Filter = VARIANT (doctor) | Pivot สมบูรณ์ — กรองตามหมอ/หมวดได้ทันที |
| Power BI Matrix | 1 column = full_code (truncated, need tooltip) | Drill-down: TYPE → CATEGORY → DIFFERENTIATOR | Interactive drill — คลิก SUR → เห็น NOSE/EYE/CHIN |
| Chart Label | 'CRS-SUR-INI-NOSE-SEMI-001-DRDO' (overlap, unreadable) | 'SUR / NOSE / DrDo' (17 chars, clean) | Chart อ่านออก ไม่ overlap |
| Dashboard KPI | Group by full_code (flat, hundreds of bars) | Group by TYPE (5 bars) then drill to CATEGORY | Summary → detail (CEO → Manager → Staff) |
Excel Export Strategy
เมื่อ export ข้อมูลเป็น Excel ส่ง segments แยกเป็น columns ไม่ใช่ full_code column เดียว:
| id | type | subtype | category | differentiator | variant | name_th | full_code (ref only) |
|---|---|---|---|---|---|---|---|
| 42 | SUR | INI | NOSE | SEMI | DRDO | เสริมจมูก เซมิ หมอ Do | CRS-SUR-INI-NOSE-SEMI-001-DRDO |
| 43 | SUR | INI | NOSE | OPEN | DRDO | เสริมจมูก โอเพ่น หมอ Do | CRS-SUR-INI-NOSE-OPEN-001-DRDO |
| 44 | SKN | INI | FIL | RST | 4CC | ฟิลเลอร์ Restylane 4cc | CRS-SKN-INI-FIL-RST-001-4CC |
ผู้ใช้ Excel สร้าง Pivot Table: Row = type, Column = category, Value = SUM(revenue) → ได้ Revenue by Type × Category ทันที ไม่ต้อง formula แยก segment จาก full_code
เปรียบเทียบกับ Odoo / NWFTH
| System | Analytics Approach | Ease of Pivot |
|---|---|---|
| GENCODE | Decomposed segments = native columns | BEST ✅ — แต่ละ segment = 1 filter/pivot dimension โดยตรง |
| Odoo | categ_id FK → JOIN product.category tree | ต้อง JOIN + flatten tree hierarchy ก่อน pivot ได้ |
| NWFTH | Itemtyp + ItemSubtyp (3 chars each) | OK แต่แค่ 2 dimensions (ไม่ละเอียดเท่า 7 segments) |
สรุป: GENCODE ออกแบบ segments มาแล้ว = analytics-ready ตั้งแต่วันแรก — ไม่ต้องสร้าง dimension table แยก ไม่ต้อง ETL ไม่ต้อง parsing ข้อดีนี้ เป็นสิ่งที่ Odoo (ต้อง JOIN) และ NWFTH (แค่ 2 segments) ทำไม่ได้
Bottom Line · สรุป
- 7-segment design ถูกแล้ว — ไม่ต้องเปลี่ยน structure ของ code (CRS-SUR-INI-NOSE-SEMI-001-DRDO ยังใช้ได้ ยังอ่านออก ยัง meaningful)
- เปลี่ยนแค่บทบาทใน database: segments = source of truth · full_code = GENERATED projection · id = FK target
- ได้ best of both worlds: meaningful code ที่คนอ่านออก (เหมือน NWFTH Itemkey) + fast int FK (เหมือน Odoo id) + decomposed segments ที่ query ได้ (ดีกว่าทั้ง Odoo และ NWFTH)
- Migration ทำได้เลย: Phase 1 (generated column) = low risk, no downtime · Phase 2 (FK int) = medium risk, reversible · ทั้งสอง phase ไม่ต้องรอ module อื่น
- ทำก่อนเพิ่ม transaction tables — ถ้าสร้าง sale_order_line / inventory_move ก่อน migrate FK จะต้อง migrate ทั้ง master data + transaction tables = ยากกว่า 10 เท่า