9.7 · ERD — Welfare withdrawal (WDHospital)
โดเมนนี้คือ financial flow ที่สำคัญที่สุดของระบบ — พนักงานเบิกค่ารักษาพยาบาล → ส่งห่อง chain → export ไปธนาคาร เงินจริงไหลผ่านตรงนี้ เลยต้องระวังเป็นพิเศษ
บทนี้เป็น ERD ที่ใหญ่ที่สุดใน Volume — มี relation มาก + state machine ซับซ้อน (13 สถานะ)
Tables ใน domain
| ตาราง | PK | หน้าที่ |
|---|---|---|
WDHospital | wdHospitalId UUID | ใบเบิกหลัก (พนักงาน / ผู้รับการรักษา / ยอดเงิน) |
WDSlip | wdSlipId UUID | ใบเสร็จที่แนบมากับใบเบิก |
WDHospitalDocument | documentId UUID | เอกสารแนบอื่นๆ (ใบรับรองแพทย์, สำเนาบัตร) |
WDHistory | historyId UUID | audit trail |
WDExport | wdExportId UUID | batch export ไปธนาคาร (KBANK / OTHER) |
Approver | approverId UUID | ผู้อนุมัติ (shared กับ LeaveDocument) |
MasterApprover | approverId UUID | config ว่า orgCode ไหนมี approver ใคร |
Sickness | sicknessId STRING | master data ของโรค |
Hospital | hospitalId UUID | master data ของโรงพยาบาล |
HospitalBankAccount | hospitalBankAccountId UUID | บัญชีธนาคารของโรงพยาบาล |
Delegate | UUID | ผู้รับมอบฉันทะ (ใช้ตอน sign บางกรณี) |
ERD — ASCII vertical flow
vtrc schema (db)
┌─────────────────────────────────────────────────────────────┐
│ │
│ WDHospital │
│ ├── wdHospitalId (PK, UUID) │
│ ├── empCode / empFirstName / empLastName / empFullName │
│ ├── empType / workingStatus / resignationDate │
│ ├── withdrawFullName / withdrawFirstName / withdrawLastName │
│ ├── withdrawIdCard (STRING — บัตรประชาชนผู้ป่วย) │
│ ├── relation (STRING — self|spouse|child|parent) │
│ ├── age (INTEGER) │
│ ├── orgCode / orgName / department / division │
│ ├── totalWithdraw (DECIMAL 12,2 — ยอดรวมที่เบิก) │
│ ├── totalCurrentWithdraw (DECIMAL 12,2) │
│ ├── previousCurrentWithdraw (DECIMAL 12,2) │
│ ├── status (ENUM — 13 ค่า!) │
│ ├── level (INTEGER — current approval level) │
│ ├── isHistory (INTEGER) │
│ ├── sicknessId (FK) + sicknessName (STRING — legacy) │
│ ├── hospitalId (FK) + hospitalName (STRING — legacy) │
│ ├── hospitalBankAccountId (FK) │
│ ├── treatmentStartDate / treatmentEndDate (DATE) │
│ ├── effectivePayDate (DATE) │
│ ├── wdExportId (FK → WDExport — null ถ้ายังไม่ export) │
│ ├── refId (UUID — link ไป record ก่อนแก้) │
│ ├── documentNo / withdrawNo (STRING) │
│ ├── bank (ENUM 'KBANK' | 'OTHER') │
│ ├── cashFlg (STRING) │
│ ├── coaCodeName (STRING — ซ้ำ! declare สองครั้ง) │
│ └── refDocument (STRING) │
│ │ │
│ │ hasMany │
│ ▼ │
│ WDSlip (ใบเสร็จ) │
│ ├── wdSlipId (PK, UUID) │
│ ├── wdHospitalId (FK logical) │
│ ├── slipNo / numberSlip (STRING) │
│ ├── date (DATE) │
│ ├── totalPrice (DECIMAL 12,2 — ยอดรวมใบเสร็จ) │
│ ├── totalSlipWithdraw (DECIMAL 12,2 — ยอดที่เบิกจากใบเสร็จนี้) │
│ └── fileId (FK → File) │
│ │
│ WDHospitalDocument (เอกสารแนบ) │
│ ├── documentId (PK, UUID) │
│ ├── wdHospitalId (FK logical) │
│ └── fileId (FK → File) │
│ │
│ Approver (shared กับ LeaveDocument) │
│ ├── approverId (PK, UUID) │
│ ├── wdHospitalId (FK logical) │
│ ├── wdLeaveId (FK logical ของฝั่ง leave — null ถ้าเป็น WD) │
│ ├── empCode (STRING) │
│ ├── level / role / canEdit / isAction │
│ │
│ WDHistory │
│ ├── historyId (PK, UUID) │
│ ├── refTaskHistoryId (UUID — targetKey) │
│ ├── payload (JSON) │
│ └── ... │
│ │
│ WDExport (batch ไปธนาคาร) │
│ ├── wdExportId (PK, UUID) │
│ ├── exportCode (STRING — unique) │
│ ├── years (STRING) │
│ ├── payDate (DATE) │
│ ├── isImport (INTEGER — 0|1, นำเข้าแล้วหรือยัง) │
│ ├── isCancel (INTEGER — 0|1, ยกเลิก batch หรือยัง) │
│ ├── cancelNote / cancelBy / cancelDate │
│ ├── totalExport / totalImport (INTEGER — count) │
│ ├── wdHospitalIds (TEXT — JSON list ของ hospital ids) │
│ ├── exportBy (UUID) │
│ └── totalCurrentWithdraw (DECIMAL 12,2 — ยอดรวม batch) │
│ │
│ MasterApprover (config) │
│ ├── approverId (PK, UUID) │
│ ├── empCode (STRING) │
│ ├── userId (STRING — ผิด type! ควรเป็น UUID) │
│ ├── orgCode (STRING — key หลัก) │
│ ├── orgName (STRING) │
│ ├── isActive (BOOLEAN) │
│ ├── type (ENUM "MEDICAL") │
│ └── createdBy / updatedBy │
│ │
│ Sickness (master) │
│ ├── sicknessId (PK, STRING) │
│ └── sicknessName (STRING) │
│ │
│ Hospital (master) │
│ ├── hospitalId (PK, UUID) │
│ ├── hospitalCode / hospitalName / hospitalShortName │
│ ├── affiliation / affiliationNew (STRING — legacy migration)│
│ ├── province / address / phoneNumber │
│ ├── isDeleted (BOOLEAN — soft delete) │
│ │ │ │
│ │ │ hasMany │
│ │ ▼ │
│ │ HospitalBankAccount │
│ │ ├── hospitalBankAccountId (PK, UUID) │
│ │ ├── hospitalId (FK → Hospital) │
│ │ ├── accountNo / accountName (STRING) │
│ │ └── bank (ENUM) │
│ │ │
└─────────────────────────────────────────────────────────────┘State machine — ENUM 13 ค่า
WDHospital.status เป็น ENUM ที่ยาวที่สุดในระบบ
| ค่า | ความหมาย |
|---|---|
DRAFT | ร่าง (ยังไม่ส่ง) |
PENDING | รออนุมัติ |
MOREINFO | ต้องการข้อมูลเพิ่ม |
APPROVE | อนุมัติแล้ว (รอ export) |
REJECT | ปฏิเสธ |
CANCEL | ยกเลิก |
CANCELDOC | ยกเลิกเอกสาร |
PAID | จ่ายเงินแล้ว (หลัง export) |
PAIDCASH | จ่ายเงินสดแล้ว |
CASH | รอจ่ายเงินสด |
EDIT | แก้ไข (หลัง approve แล้วกลับมาแก้) |
SUCCESS | สำเร็จ (final) |
FAILED | ล้มเหลว (export fail) |
submit
DRAFT ─────────────► PENDING ──────► APPROVE ──────► PAID ──────► SUCCESS
│ │ │ │
│ reject │ cancel │ cash │
▼ ▼ ▼ ▼
REJECT CANCEL PAIDCASH (end)
moreinfo edit
PENDING ──────────► MOREINFO ─────► (resubmit) APPROVE ─────► EDIT ─► (re-approve)(transition จริงซับซ้อนกว่านี้ — นี่คือ overview)
Duplicated columns — bug ที่ซ่อนอยู่
WDHospital มี column coaCodeName และ isJuristic declare สองครั้งในไฟล์เดียวกัน
// hospital.js (WDHospital)
coaCodeName: { type: Sequelize.STRING, ... } // บรรทัด ~14
// ... 100 บรรทัด ...
coaCodeName: { type: Sequelize.STRING, ... } // บรรทัด ~138 (ซ้ำ)Sequelize define() merge attribute declarations — declaration ตัวหลังชนะ ตัวแรกหายไปอย่างเงียบ
ผลกระทบ:
- ถ้า declaration ทั้งสองเหมือนกันทุกประการ → ไม่มีผล
- ถ้าต่างกัน (เช่น
allowNullต่างกัน) → behavior ผิดไปจากที่คาด - code review ที่ไม่สังเกต → bug ที่หายากมาก
Double belongsTo(Hospital) — migration half-step
models.WDHospital.belongsTo(models.Hospital, { as: "hospitalWithName", foreignKey: 'hospitalName', targetKey: 'hospitalId' })
models.WDHospital.belongsTo(models.Hospital, { as: "hospital", foreignKey: 'hospitalId', targetKey: 'hospitalId' })WDHospital reference Hospital สองทาง:
hospital— join ผ่านhospitalId(UUID FK)hospitalWithName— join ผ่านhospitalName(string)
อย่างเดียวกันกับ Sickness:
models.WDHospital.belongsTo(models.Sickness, { as: "sicknessWithName", foreignKey: 'sicknessName', targetKey: 'sicknessId' })
models.WDHospital.belongsTo(models.Sickness, { as: "sickness", foreignKey: 'sicknessId', targetKey: 'sicknessId' })เหตุผล — migration ครึ่งทาง
เดิม:
- WDHospital เก็บ
hospitalName(string) โดยตรง ไม่มี FK
ทีหลัง:
- เพิ่ม
hospitalId(UUID) เป็น reference ที่ถูกต้อง - แต่ยังไม่ลบ
hospitalNameเพราะ:- record เก่าที่
hospitalIdnull ยังอ่านได้ผ่านhospitalName - ไม่มี migration script ที่จะ backfill
hospitalIdจากhospitalName
- record เก่าที่
ผลกระทบ:
- บาง query ใช้
hospitalId, บาง query ใช้hospitalName→ inconsistent - ถ้าชื่อโรงพยาบาลเปลี่ยน →
hospitalWithNamejoin ไม่ติด (string เปลี่ยน) - ไม่รู้ว่าข้อมูลใหม่ใช้ relation ไหน — ต้องเช็กที่ controller
MasterApprover — config-driven approver chain
export default (db, Sequelize) => {
var MasterApprover = db.define('MasterApprover', {
// ...
empCode: { type: Sequelize.STRING, allowNull: false },
userId: { type: Sequelize.STRING, allowNull: false }, // ← ผิด type! ควรเป็น UUID
orgCode: { type: Sequelize.STRING, allowNull: false },
orgName: { type: Sequelize.STRING, allowNull: false },
isActive: { type: Sequelize.BOOLEAN, ... },
type: { type: Sequelize.ENUM("MEDICAL"), ... },config ว่า orgCode ไหนมี approver ใคร — ใช้ตอน auto-generate Approver row ตอนสร้าง WDHospital
การ join:
models.WDHospital.hasMany(models.MasterApprover, { as: 'masterApprover', foreignKey: 'orgCode', targetKey: 'orgCode' })WDHospital.orgCode ←→ MasterApprover.orgCode — ไม่ใช่ PK join แต่ใช้ business key
Bug — userId type ผิด
userId declare เป็น STRING แต่ทุกที่ในระบบใช้ UUID — ทำให้:
- index size ใหญ่กว่าที่ควร
- ไม่ enforce format
- ถ้ามีการ cast เป็น UUID ใน query → silent fail
WDSlip มี relation ซ้อน
WDSlip.associate = (models) => {
models.WDSlip.belongsTo(models.File, { as: "slipFile", foreignKey: 'fileId' })
models.WDSlip.hasMany(models.File, { as: "slipMultiFile", foreignKey: 'wdSlipId' })
models.WDSlip.belongsTo(models.WDHospital, { as: "wdHospital", foreignKey: 'wdHospitalId'})
}WDSlip reference File สองแบบ:
slipFile— ไฟล์เดียวผ่านfileId(legacy)slipMultiFile— หลายไฟล์ผ่านwdSlipId(ใหม่)
อีก half-step migration — เดิม slip มีไฟล์เดียว ทีหลังเปลี่ยนเป็นหลายไฟล์ แต่ยังไม่ลบ relation เดิม
Money columns — ถูกต้อง
สำคัญมาก — field ที่เกี่ยวกับเงินทั้งหมดใช้ DECIMAL(12,2) ไม่ใช่ FLOAT
| Field | Type |
|---|---|
WDHospital.totalWithdraw | DECIMAL(12,2) |
WDHospital.totalCurrentWithdraw | DECIMAL(12,2) |
WDHospital.previousCurrentWithdraw | DECIMAL(12,2) |
WDSlip.totalPrice | DECIMAL(12,2) |
WDSlip.totalSlipWithdraw | DECIMAL(12,2) |
WDExport.totalCurrentWithdraw | DECIMAL(12,2) |
รองรับยอดสูงสุด 999,999,999,999.99 (เกินพอ)
WDExport.wdHospitalIds — JSON-encoded list
wdHospitalIds: {
type: Sequelize.TEXT,
allowNull: true
},เก็บ list ของ hospital ID ใน TEXT column เป็น JSON — เช่น ["uuid1","uuid2","uuid3"]
ผลกระทบ:
- query "export batch นี้มี hospital อะไรบ้าง" → ต้อง parse JSON ในแอป
- ไม่ enforce relation จริง → ถ้า hospital ถูกลบ IDs ยังค้าง
- index ไม่ได้
เคล็ดลับตอนทำงานจริง
ถ้า query WDHospital แล้ว hospital relation null
- เช็กว่าใช้ alias
hospital(FK hospitalId) หรือhospitalWithName(string hospitalName) - ถ้า record เก่า
hospitalIdอาจจะ null — ใช้hospitalWithNameแทน
ถ้าเจอ WDHospital ที่ status ไม่ logical (เช่น PAID แต่ยังไม่ APPROVE)
state inconsistency — เกิดจาก manual update ใน DB หรือ bug ใน controller ต้อง manual fix
ถ้า approver chain ไม่ครบ
- เช็ก
MasterApproverของ orgCode นั้น — ถ้าไม่มี row → chain ไม่ generate - ถ้า
MasterApprover.isActive = false→ ข้าม
อย่าเชื่อ totalWithdraw ใน WDHospital
ยอดรวมควรเป็น sum ของ WDSlip.totalSlipWithdraw ทั้งหมด — ถ้าไม่ตรงแปลว่ามี denormalized field ที่ไม่ sync
ถ้า export batch fail
เช็ก WDExport.isImport — ถ้าเป็น 0 แปลว่ายังไม่ import กลับมาจากธนาคาร ถ้า isCancel = 1 แปลว่า batch ถูกยกเลิก แต่ WDHospital ใน batch จะกลับไป status APPROVE