Skip to content

9.6 · ERD — Leave & Approvals

โดเมน Leave จัดการการลา — ตั้งแต่พนักงานยื่นใบลา, ผู้อนุมัติเห็นชอบ, ไปจนถึงบันทึกประวัติ เป็น state machine ที่ค่อนข้างซับซ้อนเพราะมีหลายสถานะ + multi-level approval


Tables ใน domain

ตารางPKหน้าที่
LeaveDocumentwdLeaveId UUIDใบลาหลัก
Approver (shared)approverId UUIDผู้อนุมัติ — ใช้ร่วมกับ WDHospital
LeaveHistoryhistoryId UUIDaudit trail ของแต่ละ action
LeaveAttachFileattachFileId 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

58:60:vtrc-api/api/src/models/core/leave/leave.js
        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

10:17:vtrc-api/api/src/models/core/withdraw/approver.js
    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 ประกาศเป็น:

javascript
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 ที่ดูผิดปกติ

160:160:vtrc-api/api/src/models/core/leave/leave.js
    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 ประกาศ

49:51:vtrc-api/api/src/models/core/leave/leaveAattch.js
    // associate (ไม่มี)


    return LeaveAttachFile

LeaveAttachFile.associate ไม่มี → relation เป็น logical เท่านั้น (ผ่าน wdLeaveId)

ผลกระทบ — query include ไม่ได้:

javascript
// ไม่ 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.level vs Approver.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 ทั้งระบบ


ไป 8.7 ERD — Welfare withdrawal