12.3 · Welfare / WDHospital
บทนี้เจาะลึก business logic ของระบบเบิกค่ารักษาพยาบาล (Welfare / WDHospital) — state machine ที่ซับซ้อนที่สุดในระบบ VTRC, payment export 3 ระบบ (KBank, SmartCredit, FMIS), และการ import ผลจ่ายจากธนาคาร
ภาพรวม
| ด้าน | ค่า |
|---|---|
| ไฟล์ controller หลัก | lib/controllers/withdraw/hospital.js (~3,200 บรรทัด) |
| ไฟล์ export | lib/controllers/excel/excel.js, lib/repositories/excel/{kPayrollDataType,smartCreditDataType,fmisDataType}.js |
| Repository | lib/repositories/withdraw/{hospital,slip,export,historyAction,approver,delegate}.js |
| Forward controller | centralize/controllers/hrmi.js (ใช้ getMedical) |
| State machine | 10 สถานะ (มากที่สุดในระบบ) |
| Approval | single-level (ผู้อนุมัติจาก MasterApprovers table) |
| Payment | 3 channels (KBank, SmartCredit, FMIS) + cash |
State machine — 10 สถานะ
createWDHospital
│
▼
[DRAFT] ──────── submit ──────► [PENDING]
│ │
│ ┌───────┼───────┬──────────┐
│ ▼ ▼ ▼ ▼
│ [MOREINFO] [REJECT] [APPROVE] [CANCELDOC]
│ │ │
│ └── edit ──►[PENDING] ├── PAID ──► [SUCCESS]
│ │ └── [FAILED]
│ │
│ ├── PAIDCASH ──► [CASH]
│ │
│ └── EDIT (แก้ยอด)
│
└────────── cancel ──► [CANCELDOC]Transition map (action label ภาษาไทย)
const getActionStatus = (currentStatus, previousStatus) => {
const ACTION_STATUS = {
"DRAFT": {
"PENDING": "ทำเรื่องเบิก",
"DRAFT": "บันทึกร่าง",
"CANCELDOC": "ยกเลิกรายการเบิก",
},
"PENDING": {
"MOREINFO": "เอกสารไม่ครบถ้วน",
"APPROVE": "อนุมัติรายการเบิก",
"REJECT": "ไม่อนุมัติรายการเบิก",
"CANCELDOC": "ยกเลิกรายการเบิก"
},
"MOREINFO": {
"PENDING": "แก้ไข",
"CANCELDOC": "ยกเลิกรายการเบิก",
},
"APPROVE": {
"CANCELDOC": "ยกเลิกรายการเบิก",
"EDIT": "แก้ไขจำนวนเงินที่เบิกได้",
"PAIDCASH": "ทำเรื่องจ่ายเงินสด",
"PAID": "ทำเรื่องจ่าย"
},
"PAID": {
"SUCCESS": "ดำเนินการโอนเงิน",
"FAILED": "ดำเนินการโอนเงิน",
"APPROVE": "ยกเลิกเอกสารจ่าย"
},
"FAILED": {
"CASH": "ทำเรื่องจ่ายเงินสด",
"CANCEL": "ยกเลิกการจ่ายเงินสด"
},
"PAIDCASH": {
"CASH": "ดำเนินการสำเร็จ",
"CANCEL": "ยกเลิกการจ่ายเงินสด"
},
}1. การสร้าง — createWDHospital
ที่อยู่: hospital.js:1357
Flow
1. รับ args:
- empCode, orgCode, hospitalCode
- slipNo, slipDate, slipAmount
- totalCurrentWithdraw (ยอดที่จะเบิก — มาจาก client)
- medicalExpenseId (ความสัมพันธ์ — บุตร, คู่สมรส)
- wdSlipId ( FK ไฟล์ใบเสร็จ)
↓
2. validate:
- checkSlipDate — ใบเสร็จต้องไม่เก่ากว่า 1 ปี
- ตรวจ slipNo ซ้ำในโรงพยาบาลเดียวกัน
- checkResignationRight — พ้นสภาพเกิน 30 วัน → ห้ามเบิก
- checkMedicalExpenseRight — บุตรอายุ > 20 ปี → ยกเลิกสิทธิ์
↓
3. generate withdrawNo format `MD[YYMM]-[6-digit]`
↓
4. INSERT WDHospital + WDHistory (status=DRAFT)
↓
5. INSERT WDSlip (เก็บ slipNo/slipDate/slipAmount)
↓
6. INSERT WDApprove (รายชื่อผู้อนุมัติ)
↓
7. ถ้า status === 'PENDING':
a. notify approvers2. Validation — checkSlipDate
ที่อยู่: hospital.js:2745-2826
Rules
| Rule | ความหมาย | แหล่งข้อมูล |
|---|---|---|
| อายุใบเสร็จ ≤ 1 ปี | ใบเสร็จต้องไม่เก่ากว่า 1 ปีนับจากวันเอกสาร | hospital.js:2784, 2822-2826 |
| SlipNo ไม่ซ้ำ | ใบเสร็จเลขเดียวกันในโรงพยาบาลเดียวกัน = ผิด | hospital.js:2776-2782, 2818-2820 |
| วันที่เอกสาร | ห้ามล่วงหน้า | hospital.js:2765 |
| สิทธิ์บุตร | อายุ ≤ 20 ปี | hospital.js:3126 (checkMedicalExpenseRight) |
| พ้นสภาพ | ห้ามเกิน 30 วันหลังลาออก | hospital.js:3158, 3172 (checkResignationRight) |
สิทธิ์การเบิก — vtrc-api ไม่ได้คำนวณเอง
// hospital.js:2748 (commented out — ไม่ทำงาน)
// if (args.totalCurrentWithdraw > 8000) {
// throw responError('AMOUNT_OVER_LIMIT', {}, null, "ยอดที่เบิกได้ไม่เกิน 8000 บาท")
// }vtrc-api ไม่ได้คำนวณ cap หรือเปอร์เซ็นต์เบิก — ยอดเบิกมาจาก client ทั้งหมด และ cap logic (ถ้ามี) น่าจะอยู่ที่ cu-central-api หรือ frontend
3. Approval — single-level จาก MasterApprovers
const conditionMasterApprover = {
where: {
orgCode: args.orgCode,
isActive: 1,
type: "MEDICAL"
}
}
let approvers = await MasterApprover.findAllMasterApprover(conditionMasterApprover)ไม่มี multi-level เหมือน leave — ผู้อนุมัติคนใดคนหนึ่งใน MasterApprovers (type=MEDICAL) สามารถ approve/reject ได้
Permission scope
// hospital.js:107-120 (concept)
// 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'] }
}4. Payment export — 3 channels
ที่อยู่: routes.js:772-833 + lib/controllers/excel/excel.js:26
Flow
1. HR คลิก "export" → /api/wdHospital/export?id=...
↓
2. createExportFile(condition) สร้างไฟล์ 3 ชนิด:
a. KBank (kPayroll) — fixed-width text TIS-620 encoding
b. SmartCredit — fixed-width text TIS-620 encoding
c. FMIS — Excel (.xlsx)
↓
3. บีบอัดเป็น ZIP พร้อม PDF reports (sumOrganize, sumCoA, ...)
↓
4. ส่งกลับให้ HR ดาวน์โหลด
↓
5. INSERT WDExport (เก็บ exportCode + รายการที่ export)ตัวอย่าง route
const { excelBuffer, kPayrollText, smartCreditText, exportCode, WDExportData } = await createExportFile(condition)
const bufferKcrash = iconv.encode(kPayrollText, 'TIS-620')
const bufferSmartCredit = iconv.encode(smartCreditText, 'TIS-620')
if (kPayrollText.length > 179) {
archive.append(bufferKcrash, { name: `${exportCode}_KBank_welfare.txt` })
}
if (smartCreditText.length > 83) {
archive.append(bufferSmartCredit, { name: `${exportCode}_Smartcredit_welfare.txt` })
}
archive.append(excelBuffer, { name: `${exportCode}_FMIS.xlsx` })Magic thresholds
| Threshold | ความหมาย | ที่มา |
|---|---|---|
kPayrollText.length > 179 | skip KBank file ถ้าว่างเกินไป | routes.js:803 |
smartCreditText.length > 83 | skip SmartCredit file ถ้าว่างเกินไป | routes.js:807 |
Encoding — TIS-620 สำคัญ
ไฟล์ KBank และ SmartCredit เป็น fixed-width text ที่ต้องเข้ารหัส TIS-620 (เพราะระบบเก่าของธนาคาร) — ใช้ iconv-lite:
iconv.encode(text, 'TIS-620')ถ้า encode ผิด (เช่น UTF-8) ตัวอักษรไทยจะเพี้ยน และธนาคารจะปฏิเสธไฟล์
5. Import ผลจ่าย — importUpdateStatus
ที่อยู่: hospital.js:2160
หลังจากที่ธนาคารโอนเงินแล้ว จะส่งไฟล์ผลจ่ายกลับมา — HR อัปโหลดไฟล์เข้าระบบ:
1. HR อัปโหลดไฟล์ผลจ่าย (ผ่าน GraphQL Upload)
↓
2. parse ไฟล์ (fixed-width text)
↓
3. map bank code → statusKcash (config.js):
- filter by element.status === statusKcash[].id
- ใช้ statusByKcash[0].status เป็น WDHospital.status (SUCCESS หรือ FAILED)
- เก็บ importStatus = bank code id, importLabel = labelTH
↓
4. UPDATE WDHospital status:
- id '9' → status='SUCCESS'
- id '6','7','8','10' → status='FAILED'
↓
5. INSERT WDHistory บันทึกผลจ่าย
↓
6. notify เจ้าของเอกสาร:
- SUCCESS → "โอนเงินสำเร็จ"
- FAILED → "โอนเงินไม่สำเร็จ กรุณาติดต่อ..."statusKcash mapping
ที่มา: vtrc-api/api/src/config.js lines 488–518 — ids ที่มีจริงมีแค่ '6'–'10' (ไม่มี id '0')
| id | label | labelTH | status |
|---|---|---|---|
6 | ISSUED and/or STOPPAY WITH DUPLICATE | การดำเนินการและ/หรือหยุดการจ่ายจากการทำซ้ำ | FAILED |
7 | CANCELLED | ยกเลิกรายการ | FAILED |
8 | STOPPAY | หยุดการจ่าย | FAILED |
9 | PAID | โอนสำเร็จ | SUCCESS |
10 | UNSUCCESS CREDIT | โอนไม่สำเร็จ | FAILED |
Import (hospital.js:2210-2235) ใช้ statusKcash.filter(value => value.id === element.status) แล้วเขียน status: statusByKcash[0].status และ importLabel: statusByKcash[0].labelTH
6. Notification triggers
แต่ละ status transition ส่ง notification:
| Action | Notify ใคร | ตัวอย่างข้อความ |
|---|---|---|
| submit → PENDING | approvers | "รออนุมัติการเบิก..." |
| need more info → MOREINFO | เจ้าของ | "เอกสารไม่ครบ..." |
| reject → REJECT | เจ้าของ | "ไม่อนุมัติการเบิก..." |
| paid → PAID | เจ้าของ | "รอการโอนเงิน" |
| paid cash → PAIDCASH | เจ้าของ | "รอรับเงินสด" |
| success → SUCCESS | เจ้าของ | "โอนเงินสำเร็จ" |
| failed → FAILED | เจ้าของ | "โอนเงินไม่สำเร็จ" |
ที่มา: hospital.js:1566-1649
MEDICAL_NOTI_TYPE
const MEDICAL_NOTI_TYPE = {
detail: "MEDICAL_DETAIL",
approving: "MEDICAL_APPROVING"
}7. Helper functions
getWithdrawNo — generate running no
const getWithdrawNo = async (context) => {
// format MD[YYMM]-[6-digit]
// reset เมื่อเดือนเปลี่ยน
}getFiscalYear — ปีงบประมาณไทย
const getFiscalYear = (date) => {
const payDate = new Date(date)
const month = payDate.getMonth() + 1
// เดือน ม.ค. - ก.ย. เป็นปีงบประมาณปัจจุบัน
// ตั้งแต่เดือน ต.ค. เป็นปีต่อไป
if (month < 10) {
return payDate.getFullYear()
} else {
return payDate.getFullYear() + 1
}
}getDiffYear — คำนวณอายุ
const getDiffYear = (birthDate, calDate) => {
return Math.floor((calDate - birthDate) / 3.15576e+10) // ms/year
}3.15576e+10 = มิลลิวินาทีต่อปี (365.25 วัน)
getNextMonthDate — เดือนถัดไป
const getNextMonthDate = (startDate) => {
// handle month wrap (12 → 1, year + 1)
}ใช้สำหรับเช็ค "พ้นสภาพเกิน 30 วัน" — คือ resignationDate + 1 เดือน
8. Magic numbers / hardcoded values
| Value | ความหมาย | ที่มา |
|---|---|---|
'88' orgCode | empType = "บำนาญ" | hospital.js:1266, 1388 |
'02', '03' orgCode | permission scope พิเศษ | hospital.js:107-120 |
1 ปี (slip date) | อายุใบเสร็จสูงสุด | hospital.js:2784 |
30 วัน (resignation) | ระยะเวลาหลังพ้นสภาพที่ยังเบิกได้ | hospital.js:3158 |
20 ปี (child age) | อายุบุตรสูงสุดที่มีสิทธิ์ | hospital.js:3126 |
8000 บาท | cap ยอดเบิก (ถูก comment ปิด) | hospital.js:2748 |
> 179, > 83 | threshold สำหรับ skip empty export file | routes.js:803, 807 |
3.15576e+10 | ms/year สำหรับคำนวณอายุ | hospital.js:3144 |
9. Cross-service calls
| vtrc-api function | cu-central-api | วัตถุประสงค์ |
|---|---|---|
getMedical() | getMedical | ดึงข้อมูลสิทธิ์ + ความสัมพันธ์ + birthDate |
employeeDetailCD | employeeProfile | ดึง profile พนักงาน (resignationDate, workingStatus) |
10. Payment lifecycle — ตัวอย่าง end-to-end
Day 1: พนักงานสร้างคำขอ → DRAFT
Day 1: submit → PENDING → notify approver
Day 3: approver อนุมัติ → APPROVE → notify เจ้าของ "อนุมัติแล้ว"
Day 5: HR export ไฟล์ → สร้าง KBank.txt + SmartCredit.txt + FMIS.xlsx
Day 5: status ทุกรายการ → PAID → notify "รอการโอนเงิน"
Day 8: ธนาคารโอนเงินเสร็จ → ส่งไฟล์ผลจ่ายกลับ
Day 8: HR import ไฟล์ → รายการสำเร็จ → SUCCESS / รายการ fail → FAILED
Day 8: notify เจ้าของแต่ละรายการ
(FAILED → CASH ได้ ถ้า HR เลือกจ่ายเงินสดแทน — ACTION_STATUS: FAILED→CASH = "ทำเรื่องจ่ายเงินสด"; ไม่ใช่ PAIDCASH)11. ข้อระวังทั้งหมด
1. Validation ลดลง (cap ถูกปิด)
- cap ยอดเบิก 8,000 บาทถูก comment ปิด
- ปัจจุบัน client ส่งยอดมาเท่าไรระบบรับหมด
- ความเสี่ยง: พนักงานเบิกเกินสิทธิ์ได้
2. Magic org codes กระจายหลายจุด
'02', '03', '88', '0734' ฝังใน controller หลายจุด — ถ้า org code เปลี่ยน ต้องแก้ทุกที่
3. Encoding TIS-620
ถ้าใครแก้โค้ดส่วน export และใช้ UTF-8 โดยไม่ระวัง — ไฟล์จะใช้งานไม่ได้
4. ไฟล์ controller ใหญ่มาก
hospital.js 3,200 บรรทัด — ยากต่อการ maintain ควรแยกเป็นหลายไฟล์ตาม concern (validation, payment, notification)
5. Dead code ใน hospital.js
มี block ที่ comment ปิดหลายจุด (hospital.js:1690-1738) — ควรลบหรือย้ายไป git history