12.1 · Workflow pattern ร่วม
บทนี้อธิบายแพทเทิร์นการทำงานเดียวกันที่ทุก domain (leave, welfare, fund, study) ใช้ — ก่อนเข้าไปดูรายละเอียดของแต่ละ domain ในบทถัดไป ควรเข้าใจแพทเทิร์นนี้ก่อน
ภาพรวม — ทำไมถึงมีแพทเทิร์นร่วม
ระบบ VTRC จัดการเอกสาร 4 ประเภทหลักที่มีลักษณะเหมือนกัน:
| Domain | ตัวอย่างเอกสาร |
|---|---|
| Leave | ใบลา |
| Welfare | ใบเบิกค่ารักษาพยาบาล |
| Fund | คำขอเปลี่ยนผู้รับผลประโยชน์ / เปลี่ยนอัตราสมทบ |
| Study | คำขอลาศึกษา |
เอกสารทั้ง 4 ประเภทใช้โครงสร้างเดียวกัน — มี parent table, history table, status enum, approval chain, และ notification trigger
1. Parent + History pattern
ทุก domain มีตารางหลัก (parent) + ตารางประวัติ (history) คู่กัน
| Domain | Parent table | History table |
|---|---|---|
| Leave | LeaveDocument | LeaveHistory |
| Welfare | WDHospital | WDHistory |
| Fund | FundRequests | FundRequestHistory |
| Study | StudyLeaveRequest | StudyHistory |
ลักษณะเฉพาะของ parent table
Parent table columns (common):
├── id (PK, auto-increment)
├── <domain>Id (UUID สำหรับอ้างอิงภายนอก)
├── empCode (เจ้าของเอกสาร)
├── status (DRAFT, PENDING, APPROVE, REJECT, CANCEL, ...)
├── docNo (เลขที่เอกสาร, format ตาม domain เช่น LEVYYMM-NNNNNN)
├── orderNo (increment ใช้สำหรับ versioning)
├── isHistory (flag 0 = current, 1 = archived)
├── refId ( FK ไป parent id ตัวก่อนหน้า — สำหรับ versioning)
├── createdBy, updatedBy
├── createdAt, updatedAt
└── domain-specific columnsลักษณะเฉพาะของ history table
History table columns (common):
├── id (PK)
├── taskId ( FK ไป parent.id)
├── refTaskHistoryId ( FK ไป history.id ตัวก่อนหน้า — chain)
├── empCode (ผู้กระทำ action)
├── status (สถานะหลัง action)
├── action (กริยา — "submit", "approve", "reject", "cancel")
├── level (ระดับการอนุมัติ — เฉพาะ leave)
├── noteApprove (ความเห็น)
├── createdAt2. การ update ไม่ได้ "update" จริง — เป็น insert + archive
สิ่งที่ดูแปลกตอนแรก — ทุก action ไม่ได้ update row ของ parent ตรง ๆ
แต่ทำงานเป็น 2 ขั้น:
1. INSERT ใหม่ใน parent table (orderNo = oldOrderNo + 1, refId = oldId)
แล้ว SET old row เป็น isHistory = 1
2. INSERT ใน history table บันทึก action ที่เกิดขึ้นตัวอย่างจาก leave (hrmi.js:1753-1886):
// (simplified concept)
const lastRecord = await LeaveHistory.findOne({ where: { taskId: id }, order: [['level','DESC']] })
// 1. archive current row + insert new
await LeaveDocument.update({ isHistory: 1 }, { where: { leaveDocumentId: id } })
await LeaveDocument.create({ ...currentData, status: newStatus, orderNo: oldOrderNo + 1, refId: id })
// 2. insert history
await LeaveHistory.create({ taskId: id, status: newStatus, action, empCode, noteApprove })ทำไมต้องทำเช่นนี้
- เก็บประวัติทุกครั้งที่เปลี่ยนแปลง โดยไม่ต้อง query audit log แยก
- สามารถ "ย้อนกลับ" ดูสถานะเอกสารในอดีตได้
- ตรวจสอบได้ว่าใครทำอะไร เมื่อไร
ข้อเสีย
- ข้อมูลใน DB บวมเร็ว — ทุก action เพิ่ม 1 row ใน parent + 1 row ใน history
- query "latest version" ต้อง filter
isHistory = 0เสมอ
3. Status enum + transition table
แต่ละ domain มี enum ของ status ที่กำหนดไว้ใน config.js หรือใน controller:
statusHrmi = [
{ statusId: 'W', statusLabel: 'PENDING' },
{ statusId: 'Y', statusLabel: 'APPROVE' },
{ statusId: 'N', statusLabel: 'REJECT' },
{ statusId: 'C', statusLabel: 'CANCEL' },
{ statusId: 'CR', statusLabel: 'CREATE' },
{ statusId: 'A', statusLabel: 'ALL' },
{ statusId: 'D', statusLabel: 'DRAF' }
],Transition แบบ map
domain ที่ซับซ้อน (Welfare) มี transition map ชัดเจน:
// ตัวอย่าง — getActionStatus (lib/controllers/withdraw/hospital.js:2912-2948)
const ACTION_STATUS = {
"DRAFT": {
"PENDING": "ทำเรื่องเบิก",
"DRAFT": "บันทึกร่าง",
"CANCELDOC": "ยกเลิกรายการเบิก",
},
"PENDING": {
"MOREINFO": "เอกสารไม่ครบถ้วน",
"APPROVE": "อนุมัติรายการเบิก",
"REJECT": "ไม่อนุมัติรายการเบิก",
"CANCELDOC": "ยกเลิกรายการเบิก"
},
// ...
}domain อื่น ๆ (leave, fund, study) ใช้ enum + if/else ใน controller แทน transition map
4. Approval chain — 2 แบบ
แบบที่ 1 — multi-level (Leave)
[เอกสาร PENDING]
│
▼
approver level 1
│
├── approve → promote ไป level ถัดไป (PENDING level 2)
│ │
│ ▼
│ approver level 2
│ │
│ ├── approve → ... เช่นเดิม
│ └── reject → REJECT
│
└── reject → REJECT
ถ้าไม่มี level ถัดไป → APPROVE (สำเร็จ)ตัวอย่าง leave (hrmi.js:1886-1916):
if (args.approveStatus === "Y") {
// ปรับ approver ปัจจุบัน
await approver.updateAction(conditionUpdateApprover)
// ลบ record approver ที่ไม่จำเป็น
await approver.destroyByCondition(conditionUpdateApprover)
// ดูว่ามี approver ใน level ถัดไปหรือไม่
conditionCheckApprover.where.level = lastRecord.level + 1
countApprover = await approver.findByConditions(conditionCheckApprover)
if (countApprover.length > 0) {
// มี level ถัดไป → เลื่อนไปอีกขั้น
setData = { ...setData, level: lastRecord.level + 1, status: "PENDING", approveStatus: "W" }
} else {
// ไม่มี → อนุมัติสำเร็จ
setData = { ...setData, level: lastRecord.level + 1, status: "APPROVE", approveStatus: "Y" }
}
}รายชื่อ approver ในแต่ละ level มาจาก cu-central-api ผ่าน hrmiGetApproverCD (hrmi.js:56)
แบบที่ 2 — single-level (Welfare, Fund, Study)
[เอกสาร PENDING]
│
▼
approver (หลายคน แต่ทำงานที่ level เดียวกัน — ใคร approve ก่อน จบ)
│
├── approve → APPROVE
└── reject → REJECTรายชื่อ approver มาจาก MasterApprovers table (org-based) — ดูรายละเอียดใน บท 11.3
5. Notification trigger — ทุก status change
ทุก action ที่เปลี่ยน status จะ trigger notification ผ่าน sendNotificationByThirdParty(args, context, type):
// ตัวอย่างจาก leave (hrmi.js:1628-1638)
await sendNotificationByThirdParty({
title: "รออนุมัติการลา",
body: `เอกสารการลาเลขที่ ${docNo} รอการอนุมัติ...`,
redirect_path: `Leave/Confirm/${docId}`,
notificationGroup: "LEAVE",
users: [{ empCode: approverEmpCode }] // ผู้รับ
}, context, "notification")Trigger mapping
| Domain | Action | Notify ใคร |
|---|---|---|
| Leave | submit → PENDING | approvers ของ level ปัจจุบัน |
| Leave | approve (final) → APPROVE | เจ้าของเอกสาร |
| Leave | reject → REJECT | เจ้าของเอกสาร |
| Welfare | submit → PENDING | approvers |
| Welfare | need more info → MOREINFO | เจ้าของเอกสาร |
| Welfare | reject → REJECT | เจ้าของเอกสาร |
| Welfare | paid → PAID | เจ้าของเอกสาร ("รอการโอนเงิน") |
| Delegate | create | ผู้รับมอบ |
ดูรายละเอียดใน บท 11.9
6. DocNo generation — running format
แต่ละ domain มี format เลขที่เอกสารเป็น PREFIX + YYMM + running 6 หลัก:
| Domain | Format | ตัวอย่าง |
|---|---|---|
| Leave | LEV[YYMM]-[6-digit] | LEV2507-000123 |
| Welfare | MD[YYMM]-[6-digit] | MD2507-000456 |
| Study | ผ่าน runningNo.js repository | (format แตกต่างกัน) |
ตัวอย่าง welfare (hospital.js:2858):
const getWithdrawNo = async (context) => {
// ดึง running ล่าสุด + 1
// reset เมื่อเดือนเปลี่ยน
}ข้อระวัง — race condition
- การ generate docNo ไม่ได้ใช้ DB sequence หรือ lock
- ถ้า 2 request เข้ามาพร้อมกัน — อาจได้เลขซ้ำ
- vtrc-api ไม่ได้จัดการเรื่องนี้ (อาจใช้ unique constraint ใน DB ช่วย)
7. Permission scope — org-based
ทุก domain มีการกรองข้อมูลตามหน่วยงาน (org) ของ user:
| Domain | Scope rule | ไฟล์ |
|---|---|---|
| Leave | org unit code | hrmi.js |
| Welfare | orgCode '02'/'03' เห็นเฉพาะของตัวเอง | hospital.js:107-120 |
| Fund | ตาม currentProfile | fund.js |
| Study | admin เห็นทุก org, non-admin เห็นเฉพาะ | studyLeaveRequest.js:100-108 |
ตัวอย่าง welfare (hospital.js:107-120):
// approver orgCode '02'/'03' เห็นเฉพาะ org ตัวเอง
// approver orgCode อื่น ๆ เห็นยกเว้น 02/03
if (userOrgCode === '02' || userOrgCode === '03') {
condition.where.orgCode = userOrgCode
} else {
condition.where.orgCode = { [Op.notIn]: ['02', '03'] }
}8. Cross-service forward pattern
ทุก domain เรียก cu-central-api ผ่าน helper เดียวกัน:
Controller (lib/controllers/<domain>/<domain>.js)
↓
Centralize controller (centralize/controllers/<domain>.js)
↓
GraphqlCDWithToken(query, token, vars, sessionId, retryCount, apiKey)
↓
cu-central-api (GraphQL)Helper function
export const hrmiGetMedicalCD = async (token, params = {}) => {
const variables = { ...params }
const data = await GraphqlCDWithToken(
Queries.getMedical, token, variables, null, 0, API_CENTRALIZE_KEY
)
return data
}ดูรายละเอียดใน Volume 12 บท 10.9
9. Validation pattern
แต่ละ domain มี validation ก่อนสร้าง/อัปเดต:
| Domain | Validation | ไฟล์ |
|---|---|---|
| Leave | maxLeaveDay, minLeaveDay, earlyLeaveDay, backLeaveDay, reqAttachDay | hrmi.js:1283-1332 |
| Welfare | slip date (≤ 1 ปี), slip no duplicate, doc date ไม่ล่วงหน้า, อายุบุตร (≤ 20 ปี) | hospital.js:2745-2826 |
| Fund | change date window (ProvidentfundChangeDateFrom/To) | fund.js:123-127 |
Magic org codes — ข้อระวังสำคัญ
หลาย validation มีเงื่อนไขพิเศษตาม org unit:
| Org code | เงื่อนไขพิเศษ | ไฟล์ |
|---|---|---|
0734 | ข้าม validation earlyLeaveDay/backLeaveDay | hrmi.js:1302 |
07xx | Plasma branch — dropdown ต่าง | hrmi.js:210-220 |
88 | empType = "บำนาญ" | hospital.js:1266, 1388 |
02, 03 | permission scope | hospital.js:107-120 |
สรุปแพทเทิร์นร่วม
เมื่อเจาะเข้าไปในแต่ละ domain ในบทถัดไป ให้สังเกตว่าทุก domain ใช้ส่วนประกอบเดียวกัน:
1. Parent + History table (insert + archive แทน update)
2. Status enum + transition (ชัดเจนใน Welfare, กระจายใน Leave)
3. Approval chain (multi-level ใน Leave, single-level ในอันอื่น)
4. Notification trigger ทุก status change
5. DocNo generation (running + reset รายเดือน)
6. Permission scope (org-based)
7. Cross-service forward (GraphqlCDWithToken)
8. Validation + magic org codesเมื่อเข้าใจแพทเทิร์นเหล่านี้แล้ว — การอ่าน controller ขนาดใหญ่ (เช่น hospital.js 3,200 บรรทัด) จะง่ายขึ้นมาก เพราะโครงสร้างส่วนใหญ่ซ้ำกัน