2 · Core pipeline — เบิกเงินช่วยเหลือภัยพิบัติแบบ end-to-end
บทนี้ trace flow "เงินช่วยเหลือภัยพิบัติ" (disaster relief withdrawal) ตั้งแต่ HTTP request มาถึง benefit-api จนถึงการสร้างรายการจ่ายเงินและเขียนข้อมูลกลับไป schema legacy vtrc เพราะเป็น flow เดียวใน repo ที่กระทบทุก layer ของระบบ (middleware → guard → pipe → controller → service → repository → TypeORM transaction → HTTP call ข้าม service → cross-database write) และมี code citation ชัดเจนที่สุด
Pipeline ภาพรวม
ทุก request ที่เข้ามาจะผ่านลำดับ layer ต่อไปนี้ตามที่ตั้งค่าไว้ใน benefit-api/src/main.ts (branch: prod):25-33
HTTP request
│
▼
[NestExpressApplication] useStaticAssets(uploads/) — ถ้า path ตรงจะตอบกลับเลย
│
▼
CORS middleware app.enableCors({ origin: '*' })
│
▼
HttpExceptionFilter (global) ถูกใช้ตอน throw เท่านั้น
│
▼
JwtAuthGuard (global) ถ้า @Public() → skip; ถ้าไม่ → verify JWT ด้วย SECRET_KEY
│
▼
ValidationPipe (global) whitelist + forbidNonWhitelisted — strip field ที่ไม่อยู่ใน DTO
│
▼
Controller (@Controller('disaster')) จับ path + HTTP method แม็ปเข้า handler
│
▼
Service (DisasterService) business logic + แม็ป entity
│
▼
EntityManager (connection 'main') TypeORM — query ไป schema benefit
│ ├─ 'vtrc' connection ใช้ตอน cancel (เขียนข้ามไป WDHospitals)
│ └─ 'hrmi' connection ใช้ตอน checkEmpWDUtil เช็คสิทธิ์เบิก
│
▼
SQL Server / MariaDBlayer ที่ไม่มีในระบบนี้คือ interceptor ที่กำหนดเอง — repo ไม่มี @UseInterceptors() ใด ๆ ที่เขียนขึ้นเอง มีแค่ global filter/pipe/guard ที่ลงทะเบียนใน main.ts เท่านั้น การ transform response ทำใน service ตรง ๆ ทั้งหมด
Flow ของ disaster relief แบบ end-to-end
Employee ─► POST /benefit-api/api/v1/disaster/create_disaster
│ (DisasterController.createDisaster)
▼
DisasterService.create()
│
├─ validateData() stub เสมอ — return { status: true }
├─ checkEmpWDUtil.checkEmployeeByWDDate query ไป HRMI เช็คสิทธิ์เบิก
├─ entityManager.findOne(MDisasterCondition)
├─ uploadService.create() เก็บไฟล์แนบที่ local disk ./uploads/disaster/YYYY-MM-DD
├─ running.generateRunningNo(...) สร้าง documentNo รูปแบบ WDS-XXXXX
└─ entityManager.save(WfDisasterHD) insert header + document + attachFile + history (cascade)
│
▼
Approver ─► POST /benefit-api/api/v1/approveDisaster/saveApprove
│ (ApproveDisasterService.saveApprove)
│
├─ entityManager.findOne(WfDisasterHD)
├─ entityManager.findOne(MasterChartOfAccount) ดึง coaNo สำหรับ welfareTypeId=2
├─ entityManager.transaction(async (tx) => {
│ ├─ tx.save(WfDisasterHD) update สถานะ approve + แนบไฟล์เพิ่ม
│ ├─ tx.save(WfDisasterHistories) audit trail
│ │
│ └─ ถ้า approveAction === 'approve':
│ ├─ axios.get(`${centralizeServer}api/employee/getEmployeeDisaster`)
│ ├─ axios.get(`${CENTRALIZE_SERVER}api/employee/bankAccount`)
│ └─ paymentService.addPayments(savePayment) docType=2, status=WAITING
│ })
│
▼
PaymentsService.addPayments()
│
├─ duplicate check (docId + docUUID + docType + isCancel=false)
├─ entityManager.transaction(...) save PaymentTransactionEntity + History
│
▼
ผู้จ่ายเงิน ─► POST /benefit-api/api/v1/payments/update/WAITTRANSFER
│ (PaymentsService.updatePayments)
│
├─ findOne(PaymentTransactionEntity)
├─ ถ้า bankAccountNo === '' → axios.get CENTRALIZE_SERVER/api/employee/bankAccount อีกครั้ง
└─ entityManager.transaction update payment + insert history
│
▼
exportPaymentReport module ─► รวมรายการ WAITTRANSFER เป็นไฟล์ → FMIS ผ่าน SFTPหลักฐานที่อ้างใน diagram นี้มีดังนี้ — benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):170-277 (create), benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):35-278 (saveApprove), benefit-api/src/modules/payments/payments.service.ts (branch: prod):61-93 (addPayments), benefit-api/src/modules/payments/payments.service.ts (branch: prod):116-149 (updatePayments WAITTRANSFER)
Step 1 — สร้างเอกสาร (create)
request เข้าผ่าน POST /benefit-api/api/v1/disaster/create_disaster ที่ DisasterController.createDisaster (benefit-api/src/modules/disaster/disaster.controller.ts (branch: prod):36-39) handler ไม่มี @Public() จึงถูก guard ด้วย JwtAuthGuard ปกติ — ถ้า client ไม่ส่ง Authorization: Bearer <jwt> จะได้ 401 Unauthorized ก่อนเข้า method
handler รับ CreateDisasterHdDto ผ่าน @Body() และ UserType ผ่าน @Request() { user } (ซึ่ง user มาจาก JwtStrategy.validate ที่ return payload กลับมา) แล้วเรียก DisasterService.create(body, user)
ภายใน service (benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):170-277) มีขั้นตอนดังนี้
async create(body: CreateDisasterHdDto, user: UserType) {
try {
const validate = await this.validateData(body);
if (!validate.status) {
throw new BadRequestException(validate.message);
}
const disasterType = await this.entityManager.findOne(MDisasterCondition, {
where: { id: body.disasterTypeId },
});
if (disasterType == null) {
throw new BadRequestException('ไม่พบ Disaster Type');
}
const dataCheckWD = await this.checkEmpWDUtil.checkEmployeeByWDDate(
body.empCode,
body.disasterStartDate.toString(),
user.profile.sourceDB,
);
if (dataCheckWD) {
// ... business rule cross-org ถูก comment ปิดไว้
} else {
throw new BadRequestException(`รหัสบุคลากร ${body.empCode} ไม่มีสิทธิ์เบิก`);
}
// ... map document, attachFile (ใช้ uploadService.create), history
const hd: WfDisasterHD = {
...hdSave,
documentNo: await this.running.generateRunningNo(WfDisasterHD, 'documentNo', '00001', 'WDS'),
// ...
};
return this.entityManager.save(WfDisasterHD, hd);
} catch (error) {
throw new BadRequestException(error);
}
}validateData() (benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):361-366) เป็น stub ที่ return { status: true, message: '' } เสมอโดยไม่ตรวจอะไรจริง แปลว่าฝั่ง backend ไม่มี validation นอกเหนือจาก DTO decorator ของ class-validator ที่ติดอยู่บน CreateDisasterHdDto (ดู benefit-api/src/dto/disaster.dto.ts) — ฝั่ง service พึ่งพา DTO + ValidationPipe (ที่ตั้ง forbidNonWhitelisted: true ใน main.ts:33) และ frontend เท่านั้น
checkEmpWDUtil.checkEmployeeByWDDate (benefit-api/libs/utils/check-emp-wd.util.ts (branch: prod):12-314) เป็นการ query ข้อมูลพนักงานจาก connection hrmi (SQL Server) เพื่อยืนยันว่าในวันที่เกิดภัยพิบัติ พนักงานคนนี้ยังทำงานอยู่ในหน่วยงานที่มีสิทธิ์เบิก — query ดึง IdentityCard จาก vtrcAllProfile ก่อน (benefit-api/libs/utils/check-emp-wd.util.ts (branch: prod):22) แล้ว join hrEmpWorkProfile, emOrgUnit_new_reStructure, centralizedProfileOrdering_reStructure_all_cdb_nbc_table เพื่อหา OrgUnit/Divi/Dept ในช่วงวันที่ระบุ (benefit-api/libs/utils/check-emp-wd.util.ts (branch: prod):28-208) — ถ้าผลลัพธ์ว่าง service จะ throw BadRequestException('รหัสบุคลากร ${empCode} ไม่มีสิทธิ์เบิก')
โค้ดส่วน comment ใน benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):191-203 แสดงเงื่อนไข cross-org validation ที่ถูก comment ปิดไว้ — เดิมมี business rule ที่บล็อกการเบิกข้ามหน่วยงาน (e.g. dbHRMI_CU_H_rep ต้องเบิกในกลุ่มตัวเองเท่านั้น) แต่ปัจจุบันปิดอยู่ ไม่ชัดว่าตั้งใจปิดหรือ leftover จาก debug (UNVERIFIED — ต้องถามทีม business)
running.generateRunningNo(WfDisasterHD, 'documentNo', '00001', 'WDS') (benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):258) สร้าง running number รูปแบบ WDS-XXXXX โดย query หาค่า documentNo ล่าสุดในตารางแล้ว +1
this.entityManager.save(WfDisasterHD, hd) (benefit-api/src/modules/disaster/disaster.service.ts (branch: prod):272) เขียนทีเดียวทั้ง header + cascade relation (disasterDocument, disasterAttactFile, disasterHistories) เพราะ entity WfDisasterHD ตั้งค่า cascade: true ไว้ — transaction เดียวทำทุกอย่าง
Step 2 — อนุมัติ (saveApprove)
หลังกรอกเอกสารแล้ว approver ยิง POST /benefit-api/api/v1/approveDisaster/saveApprove ไปที่ ApproveDisasterService.saveApprove (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):35-278) ขั้นตอนภายในมีดังนี้
async saveApprove(body: SaveApproveDto, user: UserType) {
try {
const masterChartOfAccountData = await this.entityManager.findOne(MasterChartOfAccount, {
where: { welfareTypeId: 2 },
});
const findDisasterId = await this.entityManager.findOne(WfDisasterHD, {
where: { id: body.disasterId },
});
if (!findDisasterId) throw new BadRequestException('ไม่พบ DisasterId นี้ ในระบบ');
// map updateHD และ base64 → file (ผ่าน base64ToFile ใน libs/common)
await this.entityManager.transaction(async (transection) => {
const wfDisaster = await transection.save(WfDisasterHD, updateHD);
await transection.save(WfDisasterHistories, {
refId: body.disasterId,
action: 'approve',
// ...
});
if (body.approveAction === 'approve') {
const empDetail = await this.axios.get<EmpDetailInterface[]>(
`${this.config.get('centralizeServer')}api/employee/getEmployeeDisaster`,
{ params: { empCode: findDisasterId.empCode, disasterStartDate: findDisasterId.disasterStartDate } },
);
const bankAccounts = await this.axios.get(
`${this.config.get('CENTRALIZE_SERVER')}api/employee/bankAccount`,
{ params: { empCode: empDetail.data[0].empCode, /* ... */ } },
);
const empBankAccount: EmployeeBankAccount = bankAccounts.data[0];
const savePayment = { docType: 2, /* ... */ };
await this.paymentService.addPayments(savePayment);
}
});
return { message: 'success' };
} catch (error) {
throw new BadRequestException(error);
}
}flow นี้เป็นจุดสำคัญเพราะมันกระโดดข้าม service 3 ตัวภายใน transaction เดียว คือ (a) centralizeServer (อ่าน env จาก config.get('centralizeServer') — แม็ปไปที่ env CENTRALIZE_SERVER ใน config/configuration.ts:6), (b) PaymentsService.addPayments (ภายใน benefit-api เอง), และ (c) โดยนัยคือสุดท้ายเขียนเข้า paymentTransaction ใน schema benefit
approveDisasterService.axios.get ทั้งสองครั้ง (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):185-197) เป็น HTTP call sequential ไม่มี timeout ที่กำหนดเอง ไม่มี retry ไม่มี circuit breaker — ถ้า centralizeServer ตอบช้าหรือ down transaction ทั้งก้อนจะ throw และ rollback เฉพาะฝั่ง benefit schema (SQL Server transaction) แต่ HTTP call ที่ยิงไปแล้วไม่มีทาง compensate กลับ
empDetail.data[0] ถูกเข้าถึงโดยไม่เช็ค array ว่างก่อน (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):193) — ถ้า centralize คืน array เปล่าจะ throw TypeError ที่ครอบด้วย catch เดียวกันจึงกลายเป็น BadRequestException ทั่วไป ไม่มี error code เฉพาะให้ debug pattern เดียวกันนี้ปรากฏหลายสิบครั้งทั่ว repo (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):229-261) ถ้า centralize เปลี่ยน response shape จะพังทั้งระบบพร้อมกัน
Step 3 — สร้าง payment (addPayments)
PaymentsService.addPayments (benefit-api/src/modules/payments/payments.service.ts (branch: prod):61-93) รับ DTO RequestPaymentTransactionDto แล้วเช็ค duplicate ก่อน save
async addPayments(body: RequestPaymentTransactionDto) {
try {
const condition: { [key: string]: any } = {
docId: body.docId,
docUUID: body.docUUID,
docType: body.docType,
isCancel: false,
};
// case doctype === ตกทอด ต้อง where bankAccountNo
if (body.docType >= 5) {
condition.bankAccountNo = body.bankAccountNo;
}
const anyPayment = await this.entityManager.find(PaymentTransactionEntity, { where: condition });
if (anyPayment.length > 0) throw new BadRequestException('Duplicate Payment transaction.');
await this.entityManager.transaction(async (transaction) => {
const payment = await transaction.save(PaymentTransactionEntity, body);
await transaction.save(PaymentTransactionHistoryEntity, {
...body,
paymentTransactionId: payment.id,
});
});
return { message: 'Success.' };
} catch (error) {
throw new BadRequestException(error);
}
}duplicate check ใช้ docId + docUUID + docType + isCancel=false (benefit-api/src/modules/payments/payments.service.ts (branch: prod):64-76) — ถ้า docType >= 5 (มรดกตกทอด) จะเพิ่มเงื่อนไข bankAccountNo เข้าไปด้วยเพราะเอกสารประเภทนี้แตกเป็นหลายผู้รับต่อเอกสารเดียว เป็น business rule ที่ hardcode >= 5 ไว้ในโค้ด ไม่มี enum อธิบาย เสี่ยงต่อการเข้าใจผิดเมื่อเพิ่ม docType ใหม่ในอนาคต
PaymentTransactionEntity (benefit-api/src/entities/paymentTransaction.entity.ts (branch: prod):7-210) มี field มากกว่า 50 column ครอบคลุมข้อมูลพนักงาน ผู้รับเงิน บัญชีธนาคาร COA และ audit trail — entity เดียวเก็บข้อมูลข้าม docType ทั้งหมด 1-5 ทำให้การเปลี่ยนแปลง field ของ entity นี้มี blast radius กว้างมาก
Step 4 — อัพเดตสถานะ payment (updatePayments)
หลัง approval เสร็จ payment จะอยู่ในสถานะ WAITING เมื่อผู้จ่ายเงินตรวจสอบแล้วจะยิง POST /benefit-api/api/v1/payments/update/WAITTRANSFER เพื่อเปลี่ยนสถานะเป็นพร้อมโอน (benefit-api/src/modules/payments/payments.service.ts (branch: prod):116-149)
case PaymentTransactionStatusEnum.WAITTRANSFER:
const id = body.id;
delete body.id;
let paymentObj = { ...body, paymentStatus: status.toString() };
const payment = await this.entityManager.findOne(PaymentTransactionEntity, { where: { id: body.id } });
if (payment.bankAccountNo === '' || payment.bankAccountNo === null) {
const response = await this.http.get(`${this.config.get('CENTRALIZE_SERVER')}api/employee/bankAccount`, {
params: { empCode: body.empCode, empDeptCode: body.empDeptCode, empWorkingStatus: body.empWorkingStatus },
});
const bankAccounts: EmployeeBankAccount[] = response.data;
const bank: EmployeeBankAccount = bankAccounts[0];
paymentObj = {
...body,
bankAccountNo: bank.bookNo,
bankBranch: bank.bankBranchCode,
bankCode: bank.bankCode,
bankName: bank.bankName,
};
}
await this.entityManager.transaction(async (transaction) => {
const payment = await transaction.update(PaymentTransactionEntity, { id }, paymentObj);
await transaction.save(PaymentTransactionHistoryEntity, { ...paymentObj, paymentTransactionId: id });
});
break;จุดสังเกต — findOne(PaymentTransactionEntity, { where: { id: body.id } }) (benefit-api/src/modules/payments/payments.service.ts (branch: prod):124) ถูกเรียก หลังจาก delete body.id ไปแล้ว (benefit-api/src/modules/payments/payments.service.ts (body.id line 117-118)) — เป็นไปได้ว่า findOne นี้จะ return undefined เสมอเพราะ body.id ถูกลบไปแล้ว (บั๊กเงียบ — ต้อง verify)
Step 5 — Cross-database write เข้า legacy vtrc schema
เมื่อ cancel เอกสารเบิกที่มี docType === 1 (WdHospitals) PaymentsService.cancelWelfare (benefit-api/src/modules/payments/payments.service.ts (branch: prod):618-763) หรือ cashPayment (benefit-api/src/modules/payments/payments.service.ts (branch: prod):358-616) จะเปิด transaction ที่สอง บน connection vtrc (คนละ schema กับ benefit) แล้ว insert แถวใหม่ลงตาราง WDHospitals และ WDHistories ของ legacy core ด้วย raw SQL string interpolation ทั้งหมด ไม่ใช้ parameterized query
await this.entityManager.transaction(async (transaction) => {
return await this.vtrcEntityManager.transaction(async (vtrcTran) => {
await transaction.save(PaymentTransactionEntity, /* ... */);
for (const item of body.list) {
switch (item.docType) {
case '1':
const payment = await transaction.findOne(PaymentTransactionEntity, {
where: { id: item.paymentTransactionId },
});
if (payment.docUUID) {
const wdHospital: WDHospitalsObj[] = await vtrcTran.query(
`select wdHospitalId, documentNo, ... from WDHospitals where wdHospitalId = '${payment.docUUID}' and isHistory = 0`,
);
// ... แม็ป field ทั้งหมดเป็น string literal
await vtrcTran.query(
`INSERT INTO WDHospitals (wdHospitalId, documentNo, ...) VALUES ('${pkHis}', ${documentNo}, ...)`,
);
await vtrcTran.query(
`INSERT INTO WDHistories (historyId, ...) VALUES ('${uuidv4()}', null, '${pkHis}', '${user.profile.empCode}', ...)`,
);
}
break;
case '3':
if (item.docUUID)
await this.axios.post(
`${this.config.get('vtrcServer')}cancelStudyEducation`,
{ studyEducationFeeId: item.docUUID, cancelReason: body.cancelReason },
{ headers: { Authorization: headers['authorization'], apiKey: headers['apikey'] } },
);
break;
}
}
});
});นี่คือหลักฐานว่า benefit-api เขียนเข้า database ของ vtrc-api (legacy core) โดยตรง ไม่ผ่าน GraphQL API ใด ๆ ของ vtrc-api — เป็น coupling แบบ shared-database ที่ไม่ปรากฏชัดจากการอ่าน app.module.ts เพียงอย่างเดียว ต้องเจาะเข้า service layer ถึงจะเห็น
การที่ transaction เปิดสองอันซ้อนกัน (connection main ข้างนอก, connection vtrc ข้างใน) แปลว่าถ้าฝั่ง vtrc fail หลังจากที่ฝั่ง benefit commit เสร็จแล้ว จะไม่มีทาง rollback ข้อมูลฝั่ง benefit กลับ (distributed transaction inconsistency)
case docType === '3' (StudyLeave) มี pattern ต่างออกไปคือใช้ HTTP call ไป vtrcServer/cancelStudyEducation (benefit-api/src/modules/payments/payments.service.ts (branch: prod):734-748) พร้อม forward header Authorization + apiKey — เป็นหลักฐานยืนยันชัดเจนว่า benefit-api เรียก vtrc-api ผ่าน HTTP จริง ไม่ใช่แค่ผ่าน shared database เท่านั้น
Module-to-module call graph
disaster.module ────────► uploadFiles.module (เก็บไฟล์แนบ)
────────► running.util (generate documentNo)
────────► check-emp-wd.util (เช็คสิทธิ์เบิกผ่าน HRMI)
────────► dropdown.util (lookup status name)
approveDisaster.module ─► payments.module (addPayments)
─► (HTTP) centralizeServer (getEmployeeDisaster, bankAccount)
─► (HTTP) vtrcServer (listTermFeeForApprover)
payments.module ────────► (HTTP) centralizeServer (bankAccount lookup)
────────► (HTTP) vtrcServer (cancelStudyEducation)
────────► (HTTP) VTRC_COMMON_SERVER (getFileArray — welfare attachment)
────────► connection 'vtrc' (raw INSERT ไป WDHospitals/WDHistories)
────────► connection 'hrmi' (raw query vtrcAllProfile — checkEmpDetail)
exportPaymentReport ────► payments.module (lookup WAITTRANSFER list)
──► (SFTP) FMIS (upload ไฟล์ export)
──► puppeteer (render PDF slip)
crontab ────────────────► funeral.task.service (import/update HRMI)
─────────► inheritance.task.service (import/update HRMI)
batchJob ───────────────► funeral.task.service (manual trigger)
─────────► inheritance.task.service (manual trigger)หมายเหตุ — call graph นี้ build จากการไล่ import ใน module file และการ grep หา axios. / this.config.get( ใน service ทั้งหมด — service ที่อยู่ใน module เดียวกันเรียกกันโดยตรงผ่าน DI (ไม่ต้อง wire พิเศษ) ส่วน cross-module call เช่น approveDisaster → payments ต้อง import PaymentsModule ใน ApproveDisasterModule ก่อน
Integration points กับ service อื่น
benefit-api พูดคุยกับ service ภายนอก 4 ทางหลัก (read เพิ่มเติมใน benefit-api/config/configuration.ts (branch: prod):6-7 ที่ประกาศ env CENTRALIZE_SERVER และ VTRC_SERVER)
centralize-api (env CENTRALIZE_SERVER และ centralizeServer) — ถูกเรียกผ่าน axios.get จากหลายจุด เช่น benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):185,191 (api/employee/getEmployeeDisaster, api/employee/bankAccount) และ benefit-api/src/modules/payments/payments.service.ts (branch: prod):127 (lookup bank account ตอน WAITTRANSFER) — ทุกจุดเป็น HTTP GET ไม่มี authentication header ใด ๆ ติดมาด้วย ทำให้ centralize-api ต้องการ @Public() ทุก route (ดู volume 3)
vtrc-api (env VTRC_SERVER และ vtrcServer) — ถูกเรียกผ่าน axios.get/axios.post ใน 2 จุดคือ cancelStudyEducation (benefit-api/src/modules/payments/payments.service.ts (branch: prod):588-600 และ benefit-api/src/modules/payments/payments.service.ts (branch: prod):734-748) และ listTermFeeForApprover (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):388-401) — ทุกจุด forward header Authorization + apiKey ที่รับมาจาก client ผ่าน @Headers() ใน controller แปลว่า benefit-api ทำตัวเป็น pass-through JWT ไปยัง vtrc-api โดยตรง
vtrc-common (env VTRC_COMMON_SERVER) — ถูกเรียกเพื่ออ่านไฟล์ attachment ใน benefit-api/src/modules/payments/payments.service.ts (branch: prod):901-912 (vtrc-common/api/v1/file/getFileArray) — ใช้กับ module disbursement และ payments.getOtherWelfareDetailById เท่านั้น ไม่ใช้กับ disaster หรือ wdhospitals ซึ่งใช้ UploadService เขียน local disk แทน
vtrc database (connection vtrc ใน app.module.ts:46-51) — ไม่ใช่ HTTP call แต่เป็น cross-database write ผ่าน TypeORM query() raw SQL string ทั้งหมด ใน cancelWelfare, cashPayment, checkEmpDetail, updateEmpDetail, และ findLastOrder — เป็น coupling ที่ซ่อนอยู่ใน service layer ไม่ปรากฏใน module declaration
hrmi database (connection hrmi ใน app.module.ts:52-57) — ใช้ใน CheckEmpWDUtil.checkEmployeeByWDDate (benefit-api/libs/utils/check-emp-wd.util.ts:22) และใน PaymentsService.checkEmpDetail/updateEmpDetail (benefit-api/src/modules/payments/payments.service.ts (branch: prod):1048-1114) — ทุก query เป็น raw SQL string interpolation ที่รับค่าจาก user โดยตรง (เช่น ${empCode}, ${identityCard}) เสี่ยง SQL injection หาก input ไม่ผ่านการ validate
FMIS (SFTP server) — ถูกเรียกโดย exportPaymentReport module เพื่อส่งไฟล์ export รายการจ่ายเงิน ใช้ library ssh2-sftp-client และ sftp-upload (ดูรายละเอียดในบท export/report)
Flow อื่นที่ pattern คล้ายกัน (ไม่ trace ละเอียด)
WdHospitals (ค่ารักษาพยาบาล) — WdhospitalsController.add, update/:status, detail/:id (benefit-api/src/modules/wdhospitals/wdhospitals.controller.ts (branch: prod):24-40) เป็น @Public() ทั้ง 3 endpoint ต่างจาก disaster ที่ auth ทุก endpoint — เพราะ WdHospitals ถูกเรียกจาก vtrc-api ผ่าน HTTP ไม่ใช่ user จึงไม่มี JWT ติดมา การ audit ว่าใครเป็นคนสร้างจึงตกเป็นความรับผิดชอบของ vtrc-api ไม่ใช่ benefit-api
Funeral/Inheritance — ไม่มี controller ที่ถูกเรียกจาก HTTP โดยตรง เป็น cron-only flow ที่ import ข้อมูลจาก HRMI เข้ามาเองผ่าน CronTabService.importDataFromHRMI และ updateDataToHRMI (benefit-api/src/crontab/cronTab.service.ts (branch: prod):22,37) — ดูรายละเอียดใน 05 Cron jobs
StudyLeave (ค่าธรรมเนียมการศึกษา) — payment ประเภทนี้ (docType === 3) มีจุดพิเศษคือตอน cancel จะยิง axios.post ไปที่ vtrcServer/cancelStudyEducation (benefit-api/src/modules/payments/payments.service.ts (branch: prod):588-600) — เป็นหลักฐานยืนยันว่า benefit-api และ vtrc-api แบ่งปันความรับผิดชอบข้อมูล StudyLeave กันอยู่ benefit-api เก็บ payment transaction แต่ vtrc-api เก็บ header ของเอกสาร
เคล็ดลับตอนทำงานจริง
ก่อนแก้ payment flow ไหน — เช็คก่อนว่า docType นั้นมี logic เฉพาะใน switch/case ที่ไหนบ้าง เพราะ switch (item.docType) กระจายอยู่ใน payments.service.ts ทั้งใน cancelWelfare (benefit-api/src/modules/payments/payments.service.ts (branch: prod):635) และ cashPayment (benefit-api/src/modules/payments/payments.service.ts (branch: prod):488) พร้อมกัน — ไม่มี central mapping table ของ docType → behavior การเพิ่ม docType ใหม่ต้องแก้ทั้งสอง method + เพิ่ม case ใน approveDisaster ถ้าเกี่ยวข้อง
empDetail.data[0] ปรากฏซ้ำหลายสิบครั้งทั่ว repo โดยไม่เช็ค index ว่ามีอยู่จริง — ถ้า centralize เปลี่ยน response shape จะพังทั้งระบบพร้อมกัน แนะนำให้สร้าง helper กลาง เช่น centralizeUtil.getEmployeeDisaster() ที่ return nullable และบังคับให้ caller เช็ค
การยิง HTTP ไป service อื่นภายใน TypeORM transaction (ตามที่เห็นใน saveApprove และ cancelWelfare) เป็น anti-pattern ที่น่ากังวล เพราะ network latency จะยืด transaction timeout และ HTTP call ไม่มีทาง rollback — ถ้าต้อง refactor ในอนาคตให้แยก "save payment transaction" ออกจาก "call centralize" โดยใช้ pattern outbox หรือ event-driven
ไป บท 3 Persistence →