9.6 · ERD — Leave & Approvals
โดเมน Leave จัดการการลา — ตั้งแต่พนักงานยื่นใบลา, ผู้อนุมัติเห็นชอบ, ไปจนถึงบันทึกประวัติ เป็น state machine ที่ค่อนข้างซับซ้อนเพราะมีหลายสถานะ + multi-level approval
Tables ใน domain
| ตาราง | PK | หน้าที่ |
|---|---|---|
LeaveDocument | wdLeaveId UUID | ใบลาหลัก |
Approver (shared) | approverId UUID | ผู้อนุมัติ — ใช้ร่วมกับ WDHospital |
LeaveHistory | historyId UUID | audit trail ของแต่ละ action |
LeaveAttachFile | attachFileId UUID | ไฟล์แนบ (เช่น ใบรับรองแพทย์) |
ERD — ASCII vertical flow
vtrc schema (db)
┌─────────────────────────────────────────────────────────────┐
│ │
│ LeaveDocument │
│ ├── wdLeaveId (PK, UUID) │
│ ├── employeeName (STRING) │
│ ├── empCode (STRING — link ไป HRMI, ไม่ใช่ userId!) │
│ ├── department / division / orgCode / orgName │
│ ├── leaveType (STRING) │
│ ├── leaveTypeLabel (STRING) │
│ ├── leaveStartDate (DATE) │
│ ├── leaveEndDate (DATE) │
│ ├── leaveDay (FLOAT — จำนวนวัน) │
│ ├── leaveHour (INTEGER) │
│ ├── leaveStartTime / leaveEndTime (STRING) │
│ ├── durationType (STRING) │
│ ├── status (ENUM — 6 ค่า + typo) │
│ ├── level (INTEGER — current approval level) │
│ ├── orderNo (INTEGER) │
│ ├── noteApprove (STRING) │
│ ├── isHistory (INTEGER — 0|1, flag) │
│ ├── refId (UUID — link ไป record ก่อนหน้า) │
│ ├── shiftId (UUID — link ไป HRMI shift) │
│ ├── docId / docNo (STRING) │
│ ├── description (STRING) │
│ ├── clientType (ENUM 'WEB_ADMIN' | 'WEB_CLIENT') │
│ ├── createLeaveBy (UUID) │
│ ├── sourceDB (STRING) │
│ └── operatorFullname (STRING) │
│ │ │
│ │ hasMany (Approver ใช้ shared table) │
│ ▼ │
│ Approver (shared กับ WDHospital) │
│ ├── approverId (PK, UUID) │
│ ├── wdLeaveId (UUID — FK logical) │
│ ├── wdHospitalId (UUID — FK logical ของฝั่ง withdrawal) │
│ ├── empCode (STRING — link ไป HRMI) │
│ ├── level (INTEGER) │
│ ├── role (STRING) │
│ ├── canEdit (BOOLEAN) │
│ └── isAction (INTEGER — 0|1, ทำ action แล้วหรือยัง) │
│ │
│ LeaveDocument.belongsTo(LeaveHistory) │
│ │ │
│ ▼ │
│ LeaveHistory │
│ ├── historyId (PK, UUID) │
│ ├── taskId (UUID — link ไป task ของ record) │
│ ├── refTaskHistoryId (UUID — targetKey สำหรับ belongsTo) │
│ ├── empCode (STRING) │
│ ├── userId (UUID) │
│ ├── empCodeDelegate / userIdDelegate (delegate approval) │
│ ├── action (STRING) │
│ ├── date (DATE) │
│ └── status (ENUM — เดียวกับ LeaveDocument) │
│ │
│ LeaveAttachFile (no relation declared — logical only) │
│ ├── attachFileId (PK, UUID) │
│ ├── wdLeaveId (UUID — logical FK) │
│ ├── fileName / fileType / fileSize (FLOAT) │
│ ├── path (STRING — disk path) │
│ └── description / documentType │
│ │
└─────────────────────────────────────────────────────────────┘State machine — ENUM ที่มี typo
status: {
type: Sequelize.ENUM('PEDDING_CANCLE', 'PENDING', 'MOREINFO', 'APPROVE', 'REJECT', 'CANCEL'),
allowNull: true
},| ค่าใน ENUM | ความหมาย | typo? |
|---|---|---|
PEDDING_CANCLE | รอยกเลิก (pending cancel) | ทั้งสองคำพิมพ์ผิด |
PENDING | รออนุมัติ | ถูก |
MOREINFO | ต้องการข้อมูลเพิ่ม | ถูก |
APPROVE | อนุมัติแล้ว | ถูก (แต่ผิด convention — ปกติใช้ APPROVED) |
REJECT | ปฏิเสธ | ถูก (แต่ผิด convention — ปกติใช้ REJECTED) |
CANCEL | ยกเลิก | ถูก (แต่ผิด convention — ปกติใช้ CANCELLED) |
PEDDING_CANCLE ติดอยู่ใน production schema → ทุก consumer ต้องเรียกชื่อเดียวกัน ถ้าจะแก้ต้อง migration ข้อมูลทั้งตาราง + deploy code ทุกจุดพร้อมกัน เลยไม่คุ้มที่จะแก้
State machine — transition map
┌─────────────────────────────────────┐
│ │
▼ │
┌─────────┐ submit ┌──────────┐ │
│ (new) │ ───────────► │ PENDING │ │
└─────────┘ └────┬─────┘ │
│ │
┌──────────────────┼─────────┐ │
│ │ │ │
▼ ▼ ▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐
│ APPROVE │ │ REJECT │ │ MOREINFO │
└────┬─────┘ └──────────┘ └─────┬────┘
│ │
│ user ขอยกเลิก │ user ส่งข้อมูลเพิ่ม
▼ ▼
┌──────────────────┐ ┌──────────┐
│ PEDDING_CANCLE │ │ PENDING │ (กลับมารออนุมัติ)
└────┬─────────────┘ └──────────┘
│ approver ตัดสินใจ
├──► CANCEL (อนุมัติการยกเลิก)
└──► APPROVE (ปฏิเสธการยกเลิก — ยืนยันการลา)PEDDING_CANCLE เป็น state ชั่วคราว — user ขอยกเลิกหลังจาก approved แล้ว รอให้ approver ตัดสินใจอีกครั้ง
Multi-level approval — level + orderNo
LeaveDocument อันหนึ่งอาจจะมี Approver 3 คน:
├── Approver { empCode: 'A001', level: 1, orderNo: 1 } ← หัวหน้าแผนก
├── Approver { empCode: 'B002', level: 2, orderNo: 1 } ← ผู้อำนวยการกอง
└── Approver { empCode: 'C003', level: 3, orderNo: 1 } ← ผู้อำนวยการสำนักlevel— ลำดับชั้นของ approval (1 = ต่ำสุด)orderNo— ลำดับภายใน level (เผื่อกรณีมี approver หลายคนใน level เดียวกัน)LeaveDocument.levelบันทึก level ปัจจุบันที่รอ action
Workflow: approver level 1 อนุมัติ → document level เลื่อนเป็น 2 → approver level 2 เห็น → ...
ถ้า approver คนใดปฏิเสธ → status เปลี่ยนเป็น REJECT → flow จบ
Approver table — ใช้ร่วมกับ WDHospital
wdHospitalId: {
type: Sequelize.UUID,
allowNull: true
},
wdLeaveId: {
type: Sequelize.UUID,
allowNull: true
},Approver เป็น "polymorphic" — บาง row reference wdLeaveId, บาง row reference wdHospitalId
ผลกระทบ:
- query "ใครเป็น approver ของ leave นี้" →
WHERE wdLeaveId = ? - query "ใครเป็น approver ของ withdrawal นี้" →
WHERE wdHospitalId = ? - แต่ไม่มี DB constraint บังคับว่า row ต้องมีอย่างน้อยหนึ่งในสอง field นี้ → อาจจะมี row ที่ null ทั้งคู่ (orphan)
- index แยก
[{ fields: ['wdHospitalId'] }, { fields: ['wdLeaveId'] }]
Anti-pattern ของ polymorphic แบบนี้
Sequelize hasMany ของ WDHospital ประกาศเป็น:
models.WDHospital.hasMany(models.Approver, { as: 'approver', foreignKey: 'wdHospitalId' })แต่ฝั่ง LeaveDocument.hasMany(Approver) ใช้ foreignKey: 'wdLeaveId' — relation สองตัวไปที่ table เดียวกัน ทำให้โหลด Approver ทั้งหมดของ user นึงยาก (ต้อง filter ทั้งสอง field)
LeaveHistory — audit trail
LeaveHistory เก็บ action ทุกครั้งที่เกิดกับ leave document:
LeaveHistory:
├── { historyId: 'h1', taskId: 'leave-123', action: 'CREATE', status: 'PENDING', date: '2026-07-09 10:00' }
├── { historyId: 'h2', taskId: 'leave-123', action: 'APPROVE', status: 'PENDING', date: '2026-07-09 11:00' }
├── { historyId: 'h3', taskId: 'leave-123', action: 'APPROVE', status: 'APPROVE', date: '2026-07-09 12:00' }
└── { historyId: 'h4', taskId: 'leave-123', action: 'CANCEL_REQUEST', status: 'PEDDING_CANCLE', date: '2026-07-09 13:00' }taskId = LeaveDocument.wdLeaveId — link กลับไป document ต้นทาง refTaskHistoryId — targetKey สำหรับ belongsTo ที่ LeaveDocument ประกาศ
Relation ที่ดูผิดปกติ
models.LeaveDocument.belongsTo(models.LeaveHistory, { as: "history", foreignKey: 'wdLeaveId', targetKey: 'refTaskHistoryId' })LeaveDocument belongsTo LeaveHistory — แปลกเพราะปกติ document มีหลาย history (history เป็น child)
สาเหตุ — belongsTo แบบนี้ใช้สำหรับ query latest history ของ document โดยใช้ targetKey: 'refTaskHistoryId' — Sequelize จะ join แบบ latest matching
ผลกระทบ — ถ้ามีหลาย history row ที่ refTaskHistoryId ตรงกัน → join จะ return แค่ row แรก (ไม่ deterministic)
LeaveAttachFile — ไม่มี relation ประกาศ
// associate (ไม่มี)
return LeaveAttachFileLeaveAttachFile.associate ไม่มี → relation เป็น logical เท่านั้น (ผ่าน wdLeaveId)
ผลกระทบ — query include ไม่ได้:
// ไม่ work:
LeaveDocument.findOne({ include: [{ model: LeaveAttachFile }] })
// ต้อง query แยก:
const doc = await LeaveDocument.findOne(...)
const files = await LeaveAttachFile.findAll({ where: { wdLeaveId: doc.wdLeaveId } })Employee-keyed ไม่ใช่ User-keyed
สังเกตว่า LeaveDocument.empCode เป็น STRING — ไม่ใช่ UUID ของ user
LeaveDocument
├── empCode: 'E12345' ← EmpCode ของ HRMI
└── createLeaveBy: 'uuid...' ← UUID ของ vtrc User (ผู้ยื่นใบลา)Leave document ไม่ได้ link ตรงไป User แต่ link ผ่าน empCode ซึ่งเป็น HRMI key
ผลกระทบ:
- query "user คนนี้มีใบลาอะไรบ้าง" → ต้อง join
User.refId(เก็บ EmpCode) →LeaveDocument.empCode - multi-profile user ที่มีหลาย EmpCode → leave document อาจจะกระจายหลาย code
- ถ้าพนักงานย้ายกอง → empCode เปลี่ยน → historical leave ยังอยู่ใน empCode เก่า
เคล็ดลับตอนทำงานจริง
ถ้า query ใบลาแล้วไม่เจอ
เช็กว่า query ด้วยอะไร — ถ้าใช้ userId จะไม่เจอ (เพราะ leave-keyed ด้วย empCode) ต้อง resolve empCode จาก user ก่อน
ถ้า approver ไม่เห็นใบลาที่รออนุมัติ
- เช็ก
LeaveDocument.levelvsApprover.level— ต้องตรงกัน - เช็ก
Approver.isAction— ถ้าเป็น 1 แปลว่า action ไปแล้ว จะไม่เห็นในรอบถัดไป - เช็ก
LeaveDocument.status— ต้องเป็นPENDINGหรือPEDDING_CANCLE
ถ้าเจอใบลาที่ status ไม่ตรงกับ Approver
state inconsistency — อาจจะเกิดจาก race condition ถ้า approver 2 คนกดพร้อมกัน ใน controller ไม่มี row lock
อย่าลืมว่า LeaveAttachFile ไม่มี relation
query include ไม่ได้ — ต้องแยก fetch
อย่าแก้ typo PEDDING_CANCLE
ทุก consumer (frontend, backoffice, controller, typeDefs) ใช้ชื่อนี้ → การแก้คือ breaking change ทั้งระบบ