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

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)
ตัวระบุหลัก · IdentityCRS-SUR-INI-NOSE-SEMI-001-DRDO (รหัสมีความหมาย)id = 4471 (เลข int ไร้ความหมาย)
ความหมายอยู่ที่ไหนฝังใน string คั่นด้วย dashอยู่ในคอลัมน์/ความสัมพันธ์แยก (categ_id)
มนุษย์อ่านออก✓ อ่านออกทันที✗ ต้อง join หา name
เครื่องใช้เป็น keystring ยาว แปรผันได้✓ 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 keyFK ใน transactionledger key
GENCODEsmart dash code (แตก segment)full_code (text)
NWFTHItemkey nvarchar(18) — meaningful แต่ atomicItemkey (OELIN/POLIN อ้างด้วย string นี้)InTransID int (surrogate!)
Odoosurrogate id + code เป็น projectionproduct_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

มิติ · DimensionGENCODE Smart CodeOdoo 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 ใน transactionstring 41 ตัว (อ้วน)int 4 byte (ผอม)Odoo
Delimiter เปราะdash ชนค่าที่มี dash ได้ไม่มี parsingOdoo
Schema evolutionเปลี่ยน format = migrate ทุกรหัส (v3.6→v3.9)id ไม่เคยเปลี่ยน formatOdoo
มนุษย์อ่าน/พูดทางโทรศัพท์✓ อ่านออกทันที self-documentingต้องเปิดดู nameGENCODE
มิติวิเคราะห์ (analytic)✓ ทุก segment = แกนรายงานพร้อมใช้ต้อง tag analytic แยกGENCODE
ภาษากลาง (ไทย/อังกฤษ)✓ ตัวย่อ language-neutralname ต้องแปลทุกภาษาเสมอ

สรุปสั้น: 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?

ModuleHot path คือควรอ้างอิงบทบาทของ smart code
Inventoryยอดคงเหลือ (product, location)surrogate idแสดงผล + ค้นหา ไม่ใช่ FK
Salesorder line → productsurrogate idแสดงบนใบ/หน้าจอ
Purchasevendor ↔ product mappingsurrogate 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:

  1. อย่าเอา smart code ไปเป็น GL account code — ผังบัญชี (411000) คือโครงสร้างงบการเงิน คนละเรื่องกับ taxonomy ของสินค้า/บริการ
  2. เอา segment ไปเป็น "analytic dimensions" แทน — นี่คือจุดที่ smart code เก่งที่สุด! TYPE/CATEGORY/DOCTOR คือแกนรายงานบริหารที่อยากได้พอดี ("รายได้ต่อหมอ", "รายได้ต่อหมวดหัตถการ") map ลง analytic_distribution ของ Odoo ได้ตรง ๆ
  3. 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

  1. เก็บ smart code ไว้ — มันเป็นสินทรัพย์จริงสำหรับงานคน (JERA cross-ref, พูดด้วยเสียง, รายงานวิเคราะห์, ภาษากลาง)
  2. แต่ลดบทบาทมันจาก "ตัวตน" เป็น "ภาพฉาย" — ทำให้ full_code เป็น generated column ฉายจากคอลัมน์ ไม่ใช่ business key อิสระ (ตรงกับ Odoo 19 ที่ default_code/account.code/barcode เป็น compute field)
  3. คอลัมน์ decomposed = source of truth — query/filter/mutate ที่นี่ (index ได้, จัดหมวดใหม่ได้โดยไม่พังตัวตน)
  4. transaction อ้างอิง surrogate (id/jera int) ไม่ใช่ varchar 41 ตัว — ผอม เร็ว ไม่ค้างเมื่อรหัสเปลี่ยน
  5. Accounting: segment → analytic dimensions (เก่งสุด); อย่าเอาไปเป็น GL account code
  6. VERSION/collision คือ "กลิ่น" ว่า identity ผูกกับ attribute — surrogate key ทำให้กลไกชุดนี้หายไปเอง
  7. หลักการเดียว: 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 optionalCOALESCE('-'||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

  1. ห้องแล็บ migration — รันทั้ง migration ใน BEGIN; ... ROLLBACK; บน copy ของ prod ก่อน ตรวจผล แล้วค่อยทำจริง (เทียบ odoo shell + cr.rollback() ของ erp-oracle)
  2. ทดสอบ artifact ที่ merge แล้ว — รัน migration ที่ผ่าน PR จริง end-to-end ไม่ใช่ hand-patch DB (บทเรียน NWFTH)
  3. ห้าม ALTER prod โดยไม่อนุมัติ — แสดง SQL จริงให้เจ้าของอนุมัติก่อน (ALTER/DROP COLUMN = destructive)
  4. verify หลังทำ: rerun Step 0 (ต้อง 0), ลอง INSERT (ต้องสร้าง full_code อัตโนมัติ), ลองเขียน full_code ตรง ๆ (ต้องถูกปฏิเสธ), ตรวจ FK valid, npm run build (schema sync)
  5. Drizzle mirror — แก้ TS schema ใน commit เดียวกับ migration เสมอ

สรุป · Checklist

  1. generated column = DB คำนวณ full_code จาก segment ให้เอง เขียนตรงไม่ได้ → ขัดกันไม่ได้ (เหมือน Odoo default_code compute field)
  2. Step 0 พิสูจน์ก่อนเสมอ: นิพจน์ต้อง reproduce full_code เดิม 100% (IS DISTINCT FROM ต้องได้ 0 แถว)
  3. Phase 1: drop FK→drop unique→drop column→add GENERATED→recreate unique/index/FK ใน 1 transaction; ทำ 3 ตาราง (CRS/SVC/PRD)
  4. Phase 1 เปิดโปง mutability → นำไป Phase 2: ย้าย FK จาก full_code (text) ไป id (int) แบบ backfill
  5. ผล: segment เปลี่ยนได้อิสระ, id ไม่ค้าง, FK int ผอม/เร็วที่ 100 สาขา
  6. ทดสอบใน 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

  1. 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)
  2. 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 ไม่กระทบเลย
  3. Segments vs full_code อาจ Diverge — full_code เป็น writable column ที่เขียนแยกจาก segments ถ้าใครแก้ segment โดยไม่ regenerate full_code → ข้อมูลไม่ตรง (segments บอก SUR แต่ full_code บอก SKN) ถ้า full_code เป็น GENERATED column → ไม่มีทาง diverge
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

ComponentRoleWho Uses ItMutable?
Decomposed Segments
(type, subtype, category, differentiator, seq, variant, version)
Source of Truth — ข้อมูลจริงที่แก้ไขได้Backend logic, query, filter, reporting
WHERE 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 — ตัวอ้างอิงใน transactionssale_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)

DimensionGENCODE (Today)GENCODE (Recommended)Odoo 19 (772 tables, live)NWFTH (822 tables, live)
Identifierfull_code text (writable)id int (surrogate)id int (surrogate)Itemkey nvarchar(18) (meaningful)
Human codefull_code = FK targetfull_code = GENERATED (display only)default_code varchar (nullable label)Itemkey = FK target
FK in transactionstext FK (41+ chars, slow)int FK (4 bytes, fast)int FK (ทุก FK → id)text FK (Itemkey ทุกตาราง)
Segment querydecomposed 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 = ไม่มีทาง divergeN/A (no compound code)N/A (Itemkey = atomic)
Scale proven~15 tables, 1 branchdesigned for 100+ branches772 tables, millions of users822 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:

PositionSegmentColumnExampleRoleQuery Pattern
1Prefix(fixed 'CRS')CRSระบุว่าเป็น Course layer
2TYPEtypeSUR / SKN / TRT / VIT / MSCใครทำ? (หมอ/therapist/IV)WHERE type='SUR' = ทุกศัลยกรรม
3SUBTYPEsubtypeINI / OPT / PKG / REVซื้อแบบไหน? (ครั้งแรก/เสริม/package/review)WHERE subtype='PKG' = ทุก package
4CATEGORYcategoryNOSE / FIL / BTX / EYEหัตถการอะไร / สินค้าอะไรWHERE category='FIL' = ทุกฟิลเลอร์
5DIFFERENTIATORdifferentiatorSEMI / RST / CLASต่างจากอันอื่นในหมวดตรงไหน? (technique/brand)SUR: technique (SEMI/OPEN) · SKN: brand (RST/EPTQ)
6SEQseq001 / 002ลำดับ (ป้องกันซ้ำ)ปกติ 001 ไม่ต้อง query
7VARIANTvariantDRDO / DRBALL / 4CC / BASESUR = หมอ · SKN = ปริมาณ/gradeWHERE variant='DRDO' = ทุกอย่างหมอ Do
8*VERSION (optional)versionV002 / V003Dedup — เมื่อมี 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 ต่างกัน:

RegistrySegmentsColumns ใน DB จริงExample
CRS (Course)7 segments + optional VERSIONtype, subtype, category, differentiator, seq, variant, versionCRS-SUR-INI-NOSE-SEMI-001-DRDO
SVC (Service)5 segmentstype, category, item, qualifierSVC-SUR-NOSE-SEMI-DRDO
PRD (Product)5 segmentsgrp, type, item, spec + bomTypePRD-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:

  1. Verify: ตรวจว่า full_code ปัจจุบันตรงกับ segments ทุกแถว (WHERE full_code != 'CRS-' || type || '-' || subtype || ... = 0 rows)
  2. Alter: ALTER TABLE course_registry DROP COLUMN full_code; ALTER TABLE course_registry ADD COLUMN full_code text GENERATED ALWAYS AS (...) STORED;
  3. Unique constraint: CREATE UNIQUE INDEX ON course_registry (full_code);
  4. Update Drizzle schema: เปลี่ยน field definition ใน registry.ts

ทำเหมือนกันสำหรับ service_registry + product_registry

Phase 2: FK จาก text → integer

  1. Add new column: ALTER TABLE crs_svc_mapping ADD COLUMN course_id integer REFERENCES course_registry(id);
  2. Backfill: UPDATE crs_svc_mapping SET course_id = (SELECT id FROM course_registry WHERE full_code = crs_svc_mapping.crs_code);
  3. Set NOT NULL: ALTER TABLE crs_svc_mapping ALTER COLUMN course_id SET NOT NULL;
  4. Drop old FK: ALTER TABLE crs_svc_mapping DROP COLUMN crs_code;
  5. Add ON DELETE: FK constraint with RESTRICT (ห้ามลบ course ถ้ายังมี mapping อ้าง)

ทำเหมือนกันสำหรับ svc_prd_mapping (svc_code → service_id, prd_code → product_id)

PhaseRiskDowntimeReversible
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)

TableColumnValueProblem
course_registryfull_code'CRS-SUR-INI-NOSE-SEMI-001-DRDO'writable, might diverge from segments
course_registrytype'SUR'source of truth but full_code doesn't auto-sync
crs_svc_mappingcrs_code'CRS-SUR-INI-NOSE-SEMI-001-DRDO'TEXT FK — 34 bytes, slow JOIN

After (Recommended)

TableColumnValueBenefit
course_registryid42PK, immutable, FK target
course_registrytype'SUR'source of truth — แก้ได้
course_registryfull_code'CRS-SUR-INI-NOSE-SEMI-001-DRDO'GENERATED — auto-sync, read-only
crs_svc_mappingcourse_id42INT FK — 4 bytes, fast JOIN, rename-safe

Scenario: Juvelook ย้ายหมวดจาก SKB → BIO

StepBefore (Today)After (Recommended)
1. แก้ segmentUPDATE category='BIO' WHERE item='JVLK'UPDATE category='BIO' WHERE item='JVLK'
2. แก้ full_codeUPDATE full_code='PRD-AES-BIO-JVLK-CC' manuallyไม่ต้องทำ — GENERATED auto-update
3. แก้ FK ใน mappingsUPDATE ทุกแถวใน 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:

Layerfull_code (display)jera_code (POS)id (FK)
CRSCRS-SUR-INI-NOSE-SEMI-001-DRDOC-004242
SVCSVC-SUR-NOSE-SEMI-DRDOS-001515
PRDPRD-AES-FIL-RST-CLASAES-FIL-RST-CLAS7

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

  1. 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=จมูก) แต่ พนักงานใหม่จะจำไม่ได้เลย
  2. Error-prone — พิมพ์ 35 ตัวอักษร ผิด 1 ตัว (SEMI → SIMI, DRDO → DROD) = ไม่เจอสินค้า = เสียเวลา = frustration · ยิ่งเร่งยิ่งผิด POS ที่ต้องเร็วจะ error rate สูง
  3. 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 วินาที
  4. 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 หลักการ

  1. Search First, Code Second — ทุกหน้าจอ ช่อง search อยู่บนสุด พิมพ์ชื่อไทย/ชื่อยี่ห้อ/ชื่อหมอ → fuzzy match แสดงผลลัพธ์ที่มี full_code เป็น secondary info (สีเทาเล็ก ๆ ข้าง ๆ ชื่อ)
  2. Filter Buttons, Not Code Memory — แทนที่จะจำ TYPE=SUR ให้มีปุ่ม "ศัลยกรรม" "ผิวหนัง" "IV" "Treatment" กดกรองได้ทันที ไม่ต้องรู้ว่า SUR คืออะไร
  3. Autocomplete + Recent — เคยขาย "คอร์สจมูกเซมิ หมอ Do" แล้ว ครั้งหน้าพิมพ์ "จมู" ระบบ suggest ทันที + แสดง 10 รายการล่าสุดที่ user ขาย
  4. Barcode Scan = Zero Typing — ฉลากสินค้ามี barcode (JERA code / QR) scan แล้วเด้งขึ้นมาทันที ไม่ต้องจำ ไม่ต้องพิมพ์
  5. 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
ContextWhat to ShowExampleLengthWhy
Database FKid (integer)424 bytesFast JOIN, stable, never shown to humans
POS / Receipt / LabelJERA codeC-00426 charsJERA POS limit 20 chars, compact, scannable
Barcode on productJERA code as barcodeC-0042 (barcode)6 charsFits Code 39 / Code 128 / any scanner
Phone / Conversationชื่อไทยเสริมจมูก หมอ Donaturalพนักงานพูดได้ ลูกค้าเข้าใจ
POS Searchชื่อ + filter buttonsพิมพ์ 'จมูก' หรือกด filter 'SUR'ไม่ต้องจำรหัส กดกรองได้
Power BI PivotSegment columnsTYPE=SUR, CATEGORY=NOSE3-5 chars/colแต่ละ segment = 1 column = pivot ได้
Chart LabelShort compositeSUR / NOSE / DrDo17 charsอ่านออก ไม่ overlap
Excel ExportDecomposed columnscol A=SUR, col B=INI, col C=NOSE...3-5 chars/colfilter + pivot + VLOOKUP per segment
Backend / Debug / Auditfull_codeCRS-SUR-INI-NOSE-SEMI-001-DRDO35 charsDevelopers + 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 StandardCharacter SetPractical Max LengthGENCODE full_code (35 chars)JERA code (6-16 chars)
Code 39A-Z, 0-9, dash, space, ., $, /, +, %20-25 chars (reliable scan)TOO LONG — barcode กว้างเกินสแกนPERFECT ✅ (6 chars CRS, 16 chars PRD)
Code 128Full ASCII (128 chars)48+ chars (compact)ใช้ได้ แต่ barcode กว้าง (~3 นิ้ว)PERFECT ✅
QR CodeFull Unicode, binary4,296 charsPERFECT ✅ + ใส่ 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 ToolWrong Way (full_code)Right Way (segments)Result
Excel Pivot TableRow = full_code
(35 chars, unreadable)
Row = TYPE
Column = CATEGORY
Filter = VARIANT (doctor)
Pivot สมบูรณ์ — กรองตามหมอ/หมวดได้ทันที
Power BI Matrix1 column = full_code
(truncated, need tooltip)
Drill-down: TYPE → CATEGORY → DIFFERENTIATORInteractive 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 KPIGroup 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 เดียว:

idtypesubtypecategorydifferentiatorvariantname_thfull_code (ref only)
42SURININOSESEMIDRDOเสริมจมูก เซมิ หมอ DoCRS-SUR-INI-NOSE-SEMI-001-DRDO
43SURININOSEOPENDRDOเสริมจมูก โอเพ่น หมอ DoCRS-SUR-INI-NOSE-OPEN-001-DRDO
44SKNINIFILRST4CCฟิลเลอร์ Restylane 4ccCRS-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

SystemAnalytics ApproachEase of Pivot
GENCODEDecomposed segments = native columnsBEST ✅ — แต่ละ segment = 1 filter/pivot dimension โดยตรง
Odoocateg_id FK → JOIN product.category treeต้อง JOIN + flatten tree hierarchy ก่อน pivot ได้
NWFTHItemtyp + ItemSubtyp (3 chars each)OK แต่แค่ 2 dimensions (ไม่ละเอียดเท่า 7 segments)

สรุป: GENCODE ออกแบบ segments มาแล้ว = analytics-ready ตั้งแต่วันแรก — ไม่ต้องสร้าง dimension table แยก ไม่ต้อง ETL ไม่ต้อง parsing ข้อดีนี้ เป็นสิ่งที่ Odoo (ต้อง JOIN) และ NWFTH (แค่ 2 segments) ทำไม่ได้

Bottom Line · สรุป

  1. 7-segment design ถูกแล้ว — ไม่ต้องเปลี่ยน structure ของ code (CRS-SUR-INI-NOSE-SEMI-001-DRDO ยังใช้ได้ ยังอ่านออก ยัง meaningful)
  2. เปลี่ยนแค่บทบาทใน database: segments = source of truth · full_code = GENERATED projection · id = FK target
  3. ได้ best of both worlds: meaningful code ที่คนอ่านออก (เหมือน NWFTH Itemkey) + fast int FK (เหมือน Odoo id) + decomposed segments ที่ query ได้ (ดีกว่าทั้ง Odoo และ NWFTH)
  4. Migration ทำได้เลย: Phase 1 (generated column) = low risk, no downtime · Phase 2 (FK int) = medium risk, reversible · ทั้งสอง phase ไม่ต้องรอ module อื่น
  5. ทำก่อนเพิ่ม transaction tables — ถ้าสร้าง sale_order_line / inventory_move ก่อน migrate FK จะต้อง migrate ทั้ง master data + transaction tables = ยากกว่า 10 เท่า