3 · Persistence
benefit-api ใช้ TypeORM เป็น ORM ตัวเดียว (ไม่มี Mongoose) แต่เชื่อมต่อ MSSQL 3 schema คู่ขนาน ผ่าน connection แยกกัน — main (schema benefit), vtrc (schema vtrc ของ legacy core), hrmi (schema dbHRMI_Center ของศูนย์กลาง HR) — โดยที่ TypeORM @Entity() ผูกอยู่กับ connection main เท่านั้น ส่วน vtrc และ hrmi เข้าถึงผ่าน raw SQL string ล้วน ๆ ไม่มี entity หรือ type safety ให้
การลงทะเบียนทั้งสาม connection อยู่ที่ benefit-api/src/app.module.ts (branch: prod):40-57
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
name: 'main',
useFactory: (configService: ConfigService) => configService.get('db'),
inject: [ConfigService],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
name: 'vtrc',
useFactory: (configService: ConfigService) => configService.get('vtrcDB'),
inject: [ConfigService],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
name: 'hrmi',
useFactory: (configService: ConfigService) => configService.get('dbHRMI'),
inject: [ConfigService],
}),ค่า synchronize เปิดเฉพาะ NODE_ENV === 'dev' ที่ connection main (benefit-api/config/configuration.ts (branch: prod):16) — ใน prod จึงต้อง migrate schema ด้วยมือ/สคริปต์แยก ไม่พบ migration folder ใน repo (benefit-api/sql/ มีเพียง ad-hoc script) UNVERIFIED ว่า schema migration จัดการที่ไหนในขั้นตอน deploy
Connection vtrc/hrmi ตั้ง synchronize: false และ autoLoadEntities: false เสมอ (benefit-api/config/configuration.ts (branch: prod):33-34, 51-52) — เป็นการ read/write ผ่าน EntityManager.query() อย่างเดียว
สถาปัตยกรรม schema สามชั้น
| Connection | DB schema | วิธีเข้าถึง | บทบาท |
|---|---|---|---|
main | benefit | TypeORM entity + repository + query builder | ตาราง transactional ของระบบ benefit (ทุก withdrawal, payment, export) |
vtrc | vtrc | raw SQL string-interpolated ผ่าน EntityManager.query() | อ่าน/เขียนตาราง legacy WDHospitals, WDHistories, MasterApprovers, HospitalBankAccounts, Banks |
hrmi | dbHRMI_Center | raw SQL string-interpolated | ดึงข้อมูล HR กลาง (เงินตกทอด, ฌาปนกิจ) จาก view centralizedProfileOrdering_reStructure_all_cdb_nbc, vtrcAllProfile, HrFuneralHD, HrInheritanceHD |
หลักฐานการ inject ทั้งสาม manager — benefit-api/src/modules/payments/payments.service.ts (branch: prod):49-57
constructor(
@InjectEntityManager('main')
private readonly entityManager: EntityManager,
@InjectEntityManager('vtrc')
private readonly vtrcEntityManager: EntityManager,
private readonly config: ConfigService,
@InjectEntityManager('hrmi')
private readonly hrmiEntityManager: EntityManager,
) {}Base class
ทุก entity ใน benefit-api/src/entities/ extend หนึ่งในสอง base class ที่ทำหน้าที่เดียวกันแต่เขียนแยกกัน:
BaseBenefitEntity(benefit-api/src/entities/baseBenefit.entity.ts (branch: prod):3-24) — มีid,createdAt,createdByEmpcode,createdByFullname,updatedAt,updatedByEmpcode,updatedByFullname(default'System Admin')BaseEntity(benefit-api/src/entities/base.ts (branch: prod):3-21) — มีid,createdAt,createdBy,updatedAt,updatedBy,isDeleted(bit default 0)
ความไม่สอดคล้อง (quality issue QUAL-BENEFIT-01): isDeleted มีเฉพาะ BaseEntity ส่วน entity ที่ extend BaseBenefitEntity (ซึ่งเป็นกลุ่ม payment/welfare/export ที่ใหญ่กว่า) ต้องประกาศ isDeleted เองในแต่ละ entity (เช่น WelfareHeader.isDeleted — benefit-api/src/entities/welfareHeader.entity.ts (branch: prod):144-145) หรือไม่มีเลย การสืบทอดจึงไม่ได้รับประกัน soft-delete field ทุกที่
Entity ทั้งหมด (28 ตัว)
ทั้งหมดอยู่ใน benefit-api/src/entities/ (branch: prod) และผูกกับ connection main (schema benefit) เว้นแต่ระบุไว้เป็นอย่างอื่น
กลุ่ม Disaster (เงินช่วยเหลือภัยพิบัติ)
| Entity class | @Entity(name) | บทบาท | ความสัมพันธ์ |
|---|---|---|---|
WfDisasterHD | wfDisasterHd | header เอกสารเบิกภัยพิบัติ — เก็บข้อมูลผู้เบิก, ที่อยู่ทรัพย์สิน, ระยะเวลาภัยพิบัติ, มูลค่าความเสียหาย, สถานะอนุมัติ | 1:N → wfDisasterDocument, wfDisasterHistories, mAttachFile (cascade); N:1 → mDisasterCondition |
WfDisasterDocument | wfDisasterDocument | checklist เอกสารประกอบแต่ละรายการ (บัตรประชาชน, ทะเบียนบ้าน, รูป, หนังสือรับรอง) | N:1 → wfDisasterHd |
WfDisasterHistories | wfDisasterHistories | log การเปลี่ยนสถานะ (action, status, actionBy, actionDate) | N:1 → wfDisasterHd ผ่าน refId |
MAttachfile | mAttachFile | ไฟล์แนบ polymorphic — มี relatedTable + relatedId แทน FK จริง | ผูกกับ wfDisasterHd ผ่าน relatedTable = 'WfDisasterHd' |
MDisasterCondition | mDisasterCondition | master ประเภทภัยพิบัติ (อัคคีภัย, อุทกภัย, วาตภัย, ...) | referenced by wfDisasterHd.disasterTypeId |
WfSubmitterAndApprover | wfSubmitterAndApprover | จับคู่ผู้ยื่น ↔ ผู้อนุมัติ ใช้ในใบรายงานภัยพิบัติ | polymorphic ผ่าน reletedTable = 'wfDisasterHd' |
หลักฐาน — benefit-api/src/entities/disasterHd.entity.ts (branch: prod):8-184, attachFile.entity.ts (branch: prod):6-36, disasterApprover.entity.ts (branch: prod):4-50
ตัวอย่าง schema WfDisasterHD แสดงให้เห็นการ denormalize ข้อมูลพนักงาน + ที่อยู่ในตารางเดียว:
@Entity('wfDisasterHd')
export class WfDisasterHD extends BaseEntity {
@ManyToOne(() => MDisasterCondition, (condition) => condition.disasterHd)
@JoinColumn({ name: 'disasterTypeId' })
mDisasterCondition?: MDisasterCondition;
@Column({ nullable: false })
disasterTypeId?: number;
@Column({ type: 'varchar', length: 50, nullable: true })
refDocument?: string;
@Column({ type: 'date', nullable: true })
approveDate?: Date;
@Column({ type: 'varchar', length: 50, nullable: true })
documentNo?: string;
@Column({ type: 'varchar', length: 50, nullable: false })
approveStatus?: string;
@Column({ type: 'date', nullable: false })
submitDate?: Date;
@Column({ type: 'date', nullable: true })
paymentDate?: Date;
@Column({ type: 'date', nullable: false })
disasterStartDate?: Date;
@Column({ type: 'date', nullable: false })
disasterEndDate?: Date;
@Column({ type: 'date', nullable: true })
payCashDate?: Date;
@Column({ type: 'date', nullable: true })
approvePaymentDate?: Date;
// -------------------------------------------------------
@Column({ type: 'varchar', length: 36, nullable: false })
empId?: string;
@Column({ type: 'varchar', length: 50, nullable: false })
empCode?: string;
@Column({ type: 'varchar', length: 50, nullable: false })
empTitle?: string;
@Column({ type: 'varchar', length: 255, nullable: false })
empFirstName?: string;
@Column({ type: 'varchar', length: 255, nullable: false })
empLastName?: string;
// ... org/divi/dept/position/employeeType fields ...
// ... address fields (homeNo, subDistrict, district, province, postCode) ...
// ... disaster fields (ownerName, homeType, damageValue, totalCurrentWithdraw) ...
}กลุ่ม WdHospitals (ค่ารักษาพยาบาล)
| Entity class | @Entity(name) | บทบาท | ความสัมพันธ์ |
|---|---|---|---|
WdHospitalsEntity | wdHospitals | header การเบิกค่ารักษา — มีข้อมูลพนักงาน + ผู้รับเงิน (denormalized) + โรงพยาบาล + รายการอาการป่วย | 1:N → WdHospitalsHistoryEntity, WdDocumentsEntity, WdHospitalsSicksEntity (cascade) |
WdHospitalsHistoryEntity | wdHospitalsHistory | snapshot ทุกครั้งที่เปลี่ยน status (DRAFT → PENDING → APPROVE → CANCELDOC) | N:1 → wdHospitals |
WdDocumentsEntity | wdDocuments | metadata ไฟล์แนบ (สลิป, ใบรับรองแพทย์, อื่น ๆ) | N:1 → wdHospitals |
WdHospitalsSicksEntity | wdHospitalSicks | รายการอาการ/โรคที่เบิกต่อ 1 withdrawal | N:1 → wdHospitals |
หลักฐาน — benefit-api/src/entities/wdHospitals.entity.ts (branch: prod):7-221, wdHospitalsHistory.entity.ts, wdDocuments.entity.ts, wdHospitalSicks.entity.ts
ข้อสังเกตสำคัญ (data duplication CORR-BENEFIT-01) — schema vtrc มีตาราง WDHospitals (ตัวพิมพ์ใหญ่) และ WDHistories อยู่อีกชุดหนึ่งซึ่งเป็นตารางจริงของ legacy vtrc-api entity WdHospitalsEntity ในบทนี้คือตารางฝั่ง benefit.wdHospitals (connection main) ส่วนที่ service เขียนด้วย raw SQL ไปคือตาราง vtrc.WDHospitals (connection vtrc) — นี่คือข้อมูลซ้ำ 2 ที่ ที่ไม่มี sync mechanism นอกเหนือจาก raw SQL ตอน cancel/update ใน payments.service.ts (ดูรายละเอียดด้านล่างในส่วน raw SQL)
กลุ่ม Welfare (ฌาปนกิจ / ตกทอด / สวัสดิการอื่น — ใช้ตารางร่วมกัน)
ทั้งกลุ่มนี้ถูกออกแบบให้รองรับ welfare หลายประเภทในตารางเดียวกัน คั่นด้วย field docType (number)
| Entity class | @Entity(name) | บทบาท |
|---|---|---|
WelfareHeader | welfareHeader | header สวัสดิการรวม — มี docType (1-5), welfareOrigId (UUID อ้างอิงฝั่ง source), paymentStatus, ข้อมูลพนักงาน + ผู้รับเงิน (denormalized) |
WelfareDetail | welfareDetail | รายการย่อยเงินตั้งต้น (receiptAmount, welfareOrigDetId) |
WelfareBankDetail | welfareBankDetail | เลขบัญชีผู้รับผลประโยชน์กรณีตกทอด |
WelfareAttachmentEntity | welfareAttachment | ไฟล์แนบ |
WelfareHistoryEntity | welfareHistory | log การเปลี่ยนสถานะ (denormalize ข้อมูล header ทั้งก้อนมาด้วย) |
WelfareResult | i3_Welfare_Result | ผลการโอนเงินที่ sync กลับไป schema hrmi โดย cron job ฌาปนกิจ/ตกทอด |
หลักฐาน — benefit-api/src/entities/welfareHeader.entity.ts (branch: prod):7-191, welfareHistory.entity.ts (branch: prod):4-180
WelfareHeader มี 4 relation ขาออกแบบ cascade:
@OneToMany(() => WelfareDetail, (detail) => detail.welfareHeader, { cascade: true })
welfareDetail?: WelfareDetail[];
@OneToMany(() => WelfareBankDetail, (bankDetail) => bankDetail.welfareHeader, { cascade: true })
welfareBankDetail?: WelfareBankDetail[];
@OneToMany(() => WelfareAttachmentEntity, (attachment) => attachment.welfareHeader, { cascade: true })
welfareAttachment?: WelfareAttachmentEntity[];
@OneToMany(() => WelfareHistoryEntity, (wh) => wh.welfareHeader, { cascade: true })
welfareHistory?: WelfareHistoryEntity[];
}docType เป็น shared enum ที่ควบคุม flow ทั้งระบบ แต่ ไม่มีการประกาศเป็น TypeScript enum ที่ entity พบเป็น literal number กระจายใน service (1=WdHospitals, 2=Disaster, 3=StudyLeave, 4/5=Funeral/Inheritance ตามลำดับที่เห็นใน payments.service.ts switch-case — benefit-api/src/modules/payments/payments.service.ts (branch: prod):488-606) — UNVERIFIED 100% เพราะไม่มี single source of truth ให้ยืนยัน เป็นการอนุมานจาก context การใช้งาน (quality issue QUAL-BENEFIT-02)
กลุ่ม Payment (ตารางกลางที่ทุก withdrawal ต้องผ่าน)
กลุ่มนี้เป็นหัวใจของระบบ benefit — ทุกประเภท withdrawal (ค่ารักษา, ภัยพิบัติ, ฌาปนกิจ, ตกทอด, ลาศึกษา) ต้อง map มาเป็น PaymentTransactionEntity หนึ่งแถวเสมอ เพื่อให้ระบบ export/payment/disbursement/reports ทำงานร่วมกันได้
| Entity class | @Entity(name) | บทบาท |
|---|---|---|
PaymentTransactionEntity | paymentTransaction | ตารางกลาง — เก็บทั้งข้อมูลผู้เบิก (emp*) และผู้รับเงิน (withdraw*) ปนกันในตารางเดียว (~90 field) |
PaymentTransactionHistoryEntity | paymentTransactionHistory | snapshot ทุกครั้งที่ status เปลี่ยน (denormalize ข้อมูล header ทั้งก้อน) |
WdExportPaymentEntity | wdExportPaymentTransaction | รอบ export ไป FMIS/ธนาคาร (exportCode, years, status, totalExport, totalImportSuccessed) |
WdExportPaymentHistoryEntity | wdExportPaymentTransactionHistory | snapshot รอบ export |
WdExportPaymentDetailEntity | wdExportPaymentTransactionDT | junction ระหว่าง export round ↔ payment (1 export : N payment) |
fmisEntity | fmis | log ไฟล์ที่ export ไป FMIS ผ่าน SFTP (fileName, fileStatus, runningNo) |
fmisDTEntity | fmisDT | log record ภายในไฟล์ FMIS (recordNo, descriptionSet) |
หลักฐาน — benefit-api/src/entities/paymentTransaction.entity.ts (branch: prod):6-210, paymentTransactionHistory.entity.ts (branch: prod):5-205, wdExportPayment.entity.ts (branch: prod):5-198
PaymentTransactionEntity เป็นตัวอย่างชัดเจนที่สุดของ denormalization pattern — มีทั้งข้อมูลผู้เบิกและผู้รับเงินในตารางเดียว ทำให้ query search ต้องเขียน WHERE ยาวมากทุกจุด:
@Entity('paymentTransaction')
export class PaymentTransactionEntity extends BaseBenefitEntity {
@Column({ type: 'int', nullable: true })
docId?: number;
@Column({ nullable: true })
docUUID: string;
@Column({ type: 'varchar', length: 255, nullable: true })
docNo?: string;
@Column({ type: 'int', nullable: true })
docType?: number;
@Column({ type: 'varchar', length: 255, nullable: false })
docTypeName?: string;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: false })
slipAmount?: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: false })
payerAmount?: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: false })
approveAmount?: number;
@Column({ type: 'varchar', length: 255, nullable: false })
refDocument?: string;
@Column({ type: 'datetime', nullable: true })
payDate?: Date;
@Column({ type: 'varchar', length: 255, nullable: true, default: 'WAITING' })
paymentStatus?: string;
@Column({ type: 'bit', nullable: true })
isCancel?: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
accountPeriod?: string;
@Column({ type: 'datetime' })
docDate?: Date;
@Column({ type: 'varchar', length: 255, nullable: false })
paymentType?: string;
// ... bankAccountId, bankAccountNo, bankBranch, bankCode, bankName ...
// ... budgetCode, coaNo, coaName, requestDetail, transferType ...
// ... empCode, empTitle, empFirstName, empMiddleName, empLastName ...
// ... empIdentityCard, empPassportNo, empType, empLevelCode ...
// ... empOrgCode, empDiviCode, empDeptCode (และ name คู่ทุกอัน) ...
// ... withdrawTitle, withdrawFirstName, withdrawLastName, withdrawIdentityCard ...
}paymentStatus enum string ที่พบในโค้ด — WAITING, WAITTRANSFER, WAITCASH, SUCCESS, SUCCESSCASH, FAILED, CANCEL — ประกาศเป็น const object ที่ benefit-api/config/configuration.ts (branch: prod):70-79 แต่ ไม่ได้บังคับที่ entity column (เป็น varchar(255) ปลอด ๆ) จึงไม่มี DB-level constraint ป้องกันค่าผิดปกติ
กลุ่ม Master data
| Entity class | @Entity(name) | บทบาท |
|---|---|---|
MDropdown | mDropdown | lookup กลาง (dropdownType, dropdownCode, dropdownNameTh, dropdownNameEn) — ใช้แทน hardcode string หลายที่ (เช่น WDSStatus, WDSHomeType, welfareStatus, documentType, transferType) |
MasterWelfareType | masterWelfareType | mapping welfareTypeCode → ชื่อประเภทสวัสดิการ + flag isVTRC แยกว่ามาจากฝั่ง vtrc-api หรือไม่ |
MasterChartOfAccount | masterChartOfAccount | COA (chart of account) ต่อประเภทสวัสดิการ + emp type + budget source + isPension flag — ใช้ตอน export ไป FMIS |
MasterPaidList | masterPaidList | รายการที่จ่ายแล้ว สำหรับหน้า report |
SetupPayor | setupPayor | ตั้งค่าผู้จ่ายเงิน (payor) ต่อประเภทสวัสดิการ |
BatchTransaction | batchTransaction | log การรัน cron/batch job (jobName, seqNo, totalRecords, batchStatus, startDateTime, endDateTime, errors) |
Sickness | sickness | master รายการโรค/อาการ |
หลักฐาน — benefit-api/src/entities/dropdown.entity.ts (branch: prod):4-23, masterWelfareType.entity.ts (branch: prod):4-47, masterChartOfAccount.entity.ts (branch: prod):5-76, batchTransaction.entity.ts (branch: prod):3-55
ความสัมพันธ์ระหว่าง entity (pattern)
TypeORM relation ใน benefit-api ใช้ 2 รูปแบบ:
1. Real @OneToMany / @ManyToOne ผ่าน FK
ใช้ในกรณีที่มี cascade save/detele ที่ต้องการ TypeORM จัดการให้ — เช่น WdHospitalsEntity → WdHospitalsHistoryEntity:
@OneToMany(() => WdHospitalsHistoryEntity, (wdHistory) => wdHistory.wdhospitals, {
cascade: true,
})
histories: WdHospitalsHistoryEntity[];
@OneToMany(() => WdDocumentsEntity, (wdDocuments) => wdDocuments.wdHospitals, {
cascade: true,
})
documents: WdDocumentsEntity[];2. Polymorphic relation ผ่าน relatedTable + relatedId
ไม่มี FK จริงระดับ DB — MAttachfile เป็นตัวอย่างชัดเจน (benefit-api/src/entities/attachFile.entity.ts (branch: prod):6-17):
@Entity('mAttachFile')
export class MAttachfile extends BaseEntity {
@ManyToOne(() => WfDisasterHD, (hd) => hd.disasterAttactFile)
@JoinColumn({ name: 'relatedId' })
disasterHd?: WfDisasterHD;
@Column({ type: 'int', nullable: true })
relatedId?: number;
@Column({ type: 'varchar', length: 50, nullable: false })
relatedTable?: string;TypeORM decorator @ManyToOne นี้เป็นเพียง "metadata" ที่ทำให้ leftJoinAndSelect('hd.disasterAttactFile', ...) ได้ใน query builder แต่ฝั่ง DB ไม่มี FK บังคับ หากมีการ insert ตารางอื่นที่ใช้ relatedId เดียวกันจะไม่มี referential integrity
WfSubmitterAndApprover ใช้รูปแบบเดียวกันผ่าน reletedTable (benefit-api/src/entities/disasterApprover.entity.ts (branch: prod):43 — สังเกตว่าชื่อ field สะกด releted ผิด เป็น legacy typo)
Query pattern ที่ใช้จริง
TypeORM repository / findAndCount
ใช้สำหรับ CRUD พื้นฐาน เช่น findAndCount ที่มี Like array ยาว ๆ สำหรับ search:
const [list, count] = await this.entityManager.findAndCount(WdHospitalsEntity, {
where: [
{ status: Not(Equal(WdHospitalsStatusEnum.DRAFT)), empCode: user.empCode },
{ refDocument: Like(`%${query.search}%`) },
{
withdrawNo: Like(`%${query.search}%`),
},
{
empCode: Like(`%${query.search}%`),
},
{
empFullName: Like(`%${query.search}%`),
},
{
withdrawFullName: Like(`%${query.search}%`),
},
{
orgCode: Like(`%${query.search}%`),
},
{
orgName: Like(`%${query.search}%`),
},
{
diviCode: Like(`%${query.search}%`),
},
{
diviName: Like(`%${query.search}%`),
},
],
});ข้อสังเกต — where: [...] แบบ array หมายถึง OR ระหว่างก้อน แต่ละก้อนคือ AND ของ field ในก้อน — pattern นี้ใช้ซ้ำในทุก list endpoint ของ wdhospitals/payments/welfare (ประมาณ 20+ จุด) เป็นการ duplicate query logic ที่น่าจะเป็น candidate สำหรับ refactor
TypeORM QueryBuilder
ใช้สำหรับ query ที่ต้อง join + filter + paginate + sort พร้อมกัน เช่น DisasterRepository.getByCondition:
const selectQuery = this.entityManager
.createQueryBuilder(WfDisasterHD, 'hd')
.leftJoinAndSelect('hd.mDisasterCondition', 'mDisasterCondition', 'mDisasterCondition.isDeleted = 0')
.where('hd.isDeleted = 0')
.skip(query.offset ?? 0)
.take(query.limit ?? 0)
.orderBy('hd.documentNo', 'DESC');
if (query.documentNo) {
selectQuery.andWhere('hd.documentNo like :documentNo', { documentNo: '%' + query.documentNo + '%' });
}
if (query.disasterType) {
selectQuery.andWhere('mDisasterCondition.disasterType = :disasterType', { disasterType: query.disasterType });
}
if (query.empCode) {
selectQuery.andWhere('hd.empCode like :empCode', { empCode: '%' + query.empCode + '%' });
}Raw SQL ผ่าน EntityManager.query()
ใช้ 2 กรณี:
กรณี A: เข้าถึง schema vtrc / hrmi (ไม่มี entity)
ตัวอย่างจาก payments.service.ts ที่ไปอ่าน/เขียนตาราง legacy WDHospitals ฝั่ง vtrc schema:
const wdHospital: WDHospitalsObj[] = await vtrcTran.query(
`select wdHospitalId,documentNo, withdrawNo, withdrawOrder, empFullName, empFirstName, empLastName, empType, resignationDate, workingStatus, withdrawFullName, withdrawFirstName, withdrawLastName, withdrawIdCard, relation, age, orgCode, orgName, department, division, withdrawBy, empCode, totalWithdraw, date, treatmentStartDate, treatmentEndDate, status, hospitalName, hospitalBankAccountId, sicknessId, hospitalId, hospitalType, sicknessName, patientType, privateCase, note, coaCodeName, totalCurrentWithdraw, previousCurrentWithdraw, noteApprove, payCashDate, effectivePayDate, payCashBy, orderNo, isHistory, isJuristic, bank, level, refId, importStatus, importLabel, wdExportId, exportRefCode, refDocument, cashFlg, createdAt, updatedAt from WDHospitals where wdHospitalId = '${payment.docUUID}' and isHistory = 0`
);ตัวอย่างจาก approvedisaster.service.ts ที่ไปอ่านตาราง MasterApprovers ฝั่ง vtrc เพื่อ check สิทธิ์ approver (benefit-api/src/modules/approveDisaster/approvedisaster.service.ts (branch: prod):415-417):
const approverData = await this.vtrcEntityManager.query(
`SELECT * FROM MasterApprovers ma WHERE empCode = ${query.approverEmpCode} AND isActive = 1`,
);ช่องโหว่ SQL injection — ทั้งสองกรณีข้างต้นใช้ string interpolation ${...} ตรง ๆ โดยไม่ผ่าน parameter binding หาก query.approverEmpCode หรือ payment.docUUID มาจาก user input โดยไม่ถูก validate จะสามารถ inject SQL ได้ (ดู 06 Scorecard SEC-BENEFIT-02)
กรณี B: Query ง่ายแบบเดียวที่ใช้ raw SQL แทน TypeORM เพราะขี้เกียจ map entity
พบว่ามี method เดียวกันที่ถูกเรียกซ้ำนับสิบครั้ง — SELECT * FROM masterWelfareType และ SELECT ... FROM mDropdown WHERE dropdownType IN (...) — ที่ไม่มี WHERE ที่ซับซ้อน สามารถใช้ entityManager.find(MasterWelfareType, {}) หรือ entityManager.find(MDropdown, { where: { dropdownType: In([...]) } }) ได้เท่ากันทุกประการ แต่ dev เดิมเลือกเขียน raw SQL:
// พบซ้ำใน payments.service.ts หลายสิบจุด
const masterDD: MasterWelfareTypeEN[] = await this.entityManager.query(`SELECT * FROM masterWelfareType`);
const dropdownWelfare: mDropdownObj[] = await this.entityManager.query(
`SELECT id, dropdownType, dropdownCode, dropdownNameTh FROM mDropdown where dropdownType in ('welfareStatus','documentType','transferType') and isDeleted = 0`,
);หลักฐาน — benefit-api/src/modules/payments/payments.service.ts (branch: prod):223, 280, 816, 855-856, 863
เหตุผลที่ไม่จำเป็นต้องเป็น raw SQL — query เหล่านี้ไม่มีเงื่อนไขซับซ้อนที่ TypeORM ทำไม่ได้ ใช้ raw SQL เพราะ dev เดิมคุ้นเคย ผลคือ type safety หาย (return type เป็น any ต้อง cast เอง) และไม่สามารถ apply migration อัตโนมัติได้
Transaction pattern
TypeORM transaction ใช้สองรูปแบบ:
1. Single connection transaction
await this.entityManager.transaction(async (transaction) => {
const payment = await transaction.save(PaymentTransactionEntity, body);
console.log('paymnent -> ', payment);
await transaction.save(PaymentTransactionHistoryEntity, { ...body, paymentTransactionId: payment.id });
this.logger.log('create| save |' + JSON.stringify(payment));
console.log('save data', JSON.stringify(payment));
});2. Nested cross-connection "transaction" (ไม่ปลอดภัยจริง)
พบที่ cancelWelfare และ updateFromUpdateWDHospital ที่ต้องการ atomicity ข้าม main และ vtrc:
await this.entityManager.transaction(async (transaction) => {
return await this.vtrcEntityManager.transaction(async (vtrcTran) => {ปัญหาสำคัญ — TypeORM ไม่สนับสนุน distributed transaction ข้าม connection โดยตรง โค้ดนี้ "ดูเหมือน" atomic แต่จริง ๆ คือสอง transaction คนละ connection ถ้า vtrcTran commit สำเร็จแล้ว transaction rollback จะเกิด data inconsistency ระหว่าง schema benefit และ vtrc โดยไม่มี compensation mechanism (ดู 06 Scorecard CORR-BENEFIT-01)
Indexing
ไม่พบ @Index() decorator ใด ๆ ในไฟล์ entity ทั้ง 28 ไฟล์ — การ search ผ่านทุก entity ไม่พบการประกาศ index ระดับ TypeORM หมายความว่า:
- Index ต้องสร้างด้วยมือที่ DB level (MSSQL
CREATE INDEX) ไม่ได้ถูก migration track - ไม่ทราบว่ามี index อะไรบ้างจริง ๆ ใน production — UNVERIFIED
- ฟิลด์ที่น่าจะต้อง index (จากการดู query pattern) ได้แก่:
paymentTransaction.docType,paymentStatus,isCancel,empCode,docUUIDwdHospitals.status,empCode,wdHospitalUuidwelfareHeader.docType,empCode,paymentStatuswfDisasterHd.approveStatus,empCode,documentNo
การทำ Like(\%${query.search}%`)(leading wildcard) ที่พบในทุก list endpoint ทำให้ index บนvarchar` ปกติใช้ไม่ได้ — MSSQL ต้องการ full-text index หรือ columnstore แต่ไม่พบการ config แบบนั้น
Schema design issues ที่พบซ้ำ
1. Denormalization รุนแรงของข้อมูลพนักงาน + ผู้รับเงิน
PaymentTransactionEntity (~90 field), WelfareHeader (~70 field), WdHospitalsEntity (~80 field), WfDisasterHD (~60 field) — ทั้งสี่เก็บข้อมูลพนักงาน (empCode, empFirstName, empLastName, orgCode, diviCode, deptCode + name คู่ทุกอัน) และข้อมูลผู้รับเงิน (withdrawFirstName, withdrawLastName, withdrawIdentityCard) ปนกันในตารางเดียว
ผลกระทบ:
- ทุกครั้งที่พนักงานย้ายหน่วยงาน/เปลี่ยนชื่อ ข้อมูลเก่าในตาราง transaction ไม่อัปเดต (โดย design — เก็บประวัติ ณ วันเบิก)
- แต่ละ list query ต้อง LIKE search หลาย field พร้อมกัน ทำให้ query ช้าและซ้ำซ้อน
- Field
empWorkingStatusในทุกตารางต้อง sync กับฝั่ง HRMI
2. History table denormalize ทั้งก้อน
PaymentTransactionHistoryEntity คือ snapshot เต็มของ PaymentTransactionEntity (~80 field) และ WelfareHistoryEntity ก็เช่นเดียวกัน (~60 field) — ทุกครั้งที่ status เปลี่ยน save ทั้งก้อน ทำให้ history table โตเร็วกว่า header table ~5-10 เท่า
3. ไม่มี enum constraint ระดับ DB
ทุก column ที่เป็น status (paymentStatus, approveStatus, status, docType, transferType) ประกาศเป็น varchar(255) ปลอด ๆ — ไม่มี CHECK constraint หรือ DB enum บังคับ ค่าที่ใช้จริงกระจายอยู่ใน const object และ service logic
4. Migration strategy ไม่ชัดเจน
ไม่พบ migration folder, ไม่พบ @Index(), synchronize ปิดใน prod — UNVERIFIED ว่าทีม maintain schema ด้วยวิธีใด (manual SQL script? Redgate? Liquibase?) — benefit-api/sql/ มีเพียง .sql แบบ ad-hoc script ไม่ใช่ migration chain
Cross-service DB access สรุป
| Schema ปลายทาง | วิธี | ตัวอย่างไฟล์:บรรทัด | ใช้ทำอะไร |
|---|---|---|---|
vtrc | raw SQL vtrcEntityManager.query() | benefit-api/src/modules/payments/payments.service.ts (branch: prod):493, 557-572, 640-720, 1137, 1293, 1408-1422 | insert/update/select ตาราง WDHospitals, WDHistories, HospitalBankAccounts, Banks, MasterApprovers |
hrmi | raw SQL hrmiEntityManager.query() | benefit-api/src/modules/payments/payments.service.ts (branch: prod):1048-1114, 1201-1267 | ดึง profile พนักงานจาก view centralizedProfileOrdering_reStructure_all_cdb_nbc, vtrcAllProfile |
hrmi | raw SQL (ใน cron) | benefit-api/src/crontab/funeral/funeral.task.service.ts (branch: prod):434-515, inheritance/inheritance.task.service.ts | ดึง HrFuneralHD, HrInheritanceHD เพื่อ sync มาเป็น WelfareHeader |
ไม่มี @Entity() ใดใน repo ผูกกับ connection vtrc หรือ hrmi เลยแม้แต่ตัวเดียว — ทุก cross-schema access เป็น raw string SQL ล้วน จึงไม่มี type safety และไม่มีการป้องกัน SQL injection ที่ TypeORM parameter binding ให้โดยปริยาย
Pattern ที่ใช้ซ้ำเมื่อเพิ่ม field ใหม่
ก่อนเพิ่ม field ใหม่ในตารางกลุ่ม payment/welfare ให้ตรวจสอบว่า field นั้นต้อง duplicate ไปยังกี่ entity เสมอ เพราะ pattern denormalize:
- เพิ่มใน
WelfareHeader→ ต้องเพิ่มในWelfareHistory(snapshot) - เพิ่มใน
PaymentTransactionEntity→ ต้องเพิ่มในPaymentTransactionHistoryEntity(snapshot) - ถ้า field นั้นใช้ตอน export → ต้องเพิ่มใน
WdExportPaymentDetailEntityหรือใน raw SQL ที่เขียนถึงvtrc.WDHospitals/WDHistories
ตัวอย่างปริมาณการ duplicate — cancelWelfare ใน payments.service.ts (สาย 618-740) ใช้ raw SQL insert ที่มี 60+ column ตรง ๆ ในคำสั่ง INSERT INTO WDHospitals (...) — ทุกครั้งที่ตารางฝั่ง vtrc เพิ่ม column ต้องแก้ raw SQL นี้ทุกจุดที่ซ้ำ (พบอย่างน้อย 4 จุดใน payments.service.ts เพียงไฟล์เดียว)