Skip to content

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) คู่กัน

DomainParent tableHistory table
LeaveLeaveDocumentLeaveHistory
WelfareWDHospitalWDHistory
FundFundRequestsFundRequestHistory
StudyStudyLeaveRequestStudyHistory

ลักษณะเฉพาะของ 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 (ความเห็น)
├── createdAt

2. การ 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):

js
// (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:

430:458:vtrc-api/api/src/config.js
  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 ชัดเจน:

js
// ตัวอย่าง — 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):

js
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):

js
// ตัวอย่างจาก leave (hrmi.js:1628-1638)
await sendNotificationByThirdParty({
  title: "รออนุมัติการลา",
  body: `เอกสารการลาเลขที่ ${docNo} รอการอนุมัติ...`,
  redirect_path: `Leave/Confirm/${docId}`,
  notificationGroup: "LEAVE",
  users: [{ empCode: approverEmpCode }]   // ผู้รับ
}, context, "notification")

Trigger mapping

DomainActionNotify ใคร
Leavesubmit → PENDINGapprovers ของ level ปัจจุบัน
Leaveapprove (final) → APPROVEเจ้าของเอกสาร
Leavereject → REJECTเจ้าของเอกสาร
Welfaresubmit → PENDINGapprovers
Welfareneed more info → MOREINFOเจ้าของเอกสาร
Welfarereject → REJECTเจ้าของเอกสาร
Welfarepaid → PAIDเจ้าของเอกสาร ("รอการโอนเงิน")
Delegatecreateผู้รับมอบ

ดูรายละเอียดใน บท 11.9


6. DocNo generation — running format

แต่ละ domain มี format เลขที่เอกสารเป็น PREFIX + YYMM + running 6 หลัก:

DomainFormatตัวอย่าง
LeaveLEV[YYMM]-[6-digit]LEV2507-000123
WelfareMD[YYMM]-[6-digit]MD2507-000456
Studyผ่าน runningNo.js repository(format แตกต่างกัน)

ตัวอย่าง welfare (hospital.js:2858):

js
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:

DomainScope ruleไฟล์
Leaveorg unit codehrmi.js
WelfareorgCode '02'/'03' เห็นเฉพาะของตัวเองhospital.js:107-120
Fundตาม currentProfilefund.js
Studyadmin เห็นทุก org, non-admin เห็นเฉพาะstudyLeaveRequest.js:100-108

ตัวอย่าง welfare (hospital.js:107-120):

js
// 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

8:24:vtrc-api/api/src/centralize/controllers/hrmi.js
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 ก่อนสร้าง/อัปเดต:

DomainValidationไฟล์
LeavemaxLeaveDay, minLeaveDay, earlyLeaveDay, backLeaveDay, reqAttachDayhrmi.js:1283-1332
Welfareslip date (≤ 1 ปี), slip no duplicate, doc date ไม่ล่วงหน้า, อายุบุตร (≤ 20 ปี)hospital.js:2745-2826
Fundchange date window (ProvidentfundChangeDateFrom/To)fund.js:123-127

Magic org codes — ข้อระวังสำคัญ

หลาย validation มีเงื่อนไขพิเศษตาม org unit:

Org codeเงื่อนไขพิเศษไฟล์
0734ข้าม validation earlyLeaveDay/backLeaveDayhrmi.js:1302
07xxPlasma branch — dropdown ต่างhrmi.js:210-220
88empType = "บำนาญ"hospital.js:1266, 1388
02, 03permission scopehospital.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 บรรทัด) จะง่ายขึ้นมาก เพราะโครงสร้างส่วนใหญ่ซ้ำกัน


ขั้นตอนถัดไป

ไป บท 11.2 Leave (การลา)