Skip to content

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

typescript
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 สามชั้น

ConnectionDB schemaวิธีเข้าถึงบทบาท
mainbenefitTypeORM entity + repository + query builderตาราง transactional ของระบบ benefit (ทุก withdrawal, payment, export)
vtrcvtrcraw SQL string-interpolated ผ่าน EntityManager.query()อ่าน/เขียนตาราง legacy WDHospitals, WDHistories, MasterApprovers, HospitalBankAccounts, Banks
hrmidbHRMI_Centerraw 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

typescript
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.isDeletedbenefit-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)บทบาทความสัมพันธ์
WfDisasterHDwfDisasterHdheader เอกสารเบิกภัยพิบัติ — เก็บข้อมูลผู้เบิก, ที่อยู่ทรัพย์สิน, ระยะเวลาภัยพิบัติ, มูลค่าความเสียหาย, สถานะอนุมัติ1:N → wfDisasterDocument, wfDisasterHistories, mAttachFile (cascade); N:1 → mDisasterCondition
WfDisasterDocumentwfDisasterDocumentchecklist เอกสารประกอบแต่ละรายการ (บัตรประชาชน, ทะเบียนบ้าน, รูป, หนังสือรับรอง)N:1 → wfDisasterHd
WfDisasterHistorieswfDisasterHistorieslog การเปลี่ยนสถานะ (action, status, actionBy, actionDate)N:1 → wfDisasterHd ผ่าน refId
MAttachfilemAttachFileไฟล์แนบ polymorphic — มี relatedTable + relatedId แทน FK จริงผูกกับ wfDisasterHd ผ่าน relatedTable = 'WfDisasterHd'
MDisasterConditionmDisasterConditionmaster ประเภทภัยพิบัติ (อัคคีภัย, อุทกภัย, วาตภัย, ...)referenced by wfDisasterHd.disasterTypeId
WfSubmitterAndApproverwfSubmitterAndApproverจับคู่ผู้ยื่น ↔ ผู้อนุมัติ ใช้ในใบรายงานภัยพิบัติ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)บทบาทความสัมพันธ์
WdHospitalsEntitywdHospitalsheader การเบิกค่ารักษา — มีข้อมูลพนักงาน + ผู้รับเงิน (denormalized) + โรงพยาบาล + รายการอาการป่วย1:N → WdHospitalsHistoryEntity, WdDocumentsEntity, WdHospitalsSicksEntity (cascade)
WdHospitalsHistoryEntitywdHospitalsHistorysnapshot ทุกครั้งที่เปลี่ยน status (DRAFT → PENDING → APPROVE → CANCELDOC)N:1 → wdHospitals
WdDocumentsEntitywdDocumentsmetadata ไฟล์แนบ (สลิป, ใบรับรองแพทย์, อื่น ๆ)N:1 → wdHospitals
WdHospitalsSicksEntitywdHospitalSicksรายการอาการ/โรคที่เบิกต่อ 1 withdrawalN: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)บทบาท
WelfareHeaderwelfareHeaderheader สวัสดิการรวม — มี docType (1-5), welfareOrigId (UUID อ้างอิงฝั่ง source), paymentStatus, ข้อมูลพนักงาน + ผู้รับเงิน (denormalized)
WelfareDetailwelfareDetailรายการย่อยเงินตั้งต้น (receiptAmount, welfareOrigDetId)
WelfareBankDetailwelfareBankDetailเลขบัญชีผู้รับผลประโยชน์กรณีตกทอด
WelfareAttachmentEntitywelfareAttachmentไฟล์แนบ
WelfareHistoryEntitywelfareHistorylog การเปลี่ยนสถานะ (denormalize ข้อมูล header ทั้งก้อนมาด้วย)
WelfareResulti3_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)บทบาท
PaymentTransactionEntitypaymentTransactionตารางกลาง — เก็บทั้งข้อมูลผู้เบิก (emp*) และผู้รับเงิน (withdraw*) ปนกันในตารางเดียว (~90 field)
PaymentTransactionHistoryEntitypaymentTransactionHistorysnapshot ทุกครั้งที่ status เปลี่ยน (denormalize ข้อมูล header ทั้งก้อน)
WdExportPaymentEntitywdExportPaymentTransactionรอบ export ไป FMIS/ธนาคาร (exportCode, years, status, totalExport, totalImportSuccessed)
WdExportPaymentHistoryEntitywdExportPaymentTransactionHistorysnapshot รอบ export
WdExportPaymentDetailEntitywdExportPaymentTransactionDTjunction ระหว่าง export round ↔ payment (1 export : N payment)
fmisEntityfmislog ไฟล์ที่ export ไป FMIS ผ่าน SFTP (fileName, fileStatus, runningNo)
fmisDTEntityfmisDTlog 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)บทบาท
MDropdownmDropdownlookup กลาง (dropdownType, dropdownCode, dropdownNameTh, dropdownNameEn) — ใช้แทน hardcode string หลายที่ (เช่น WDSStatus, WDSHomeType, welfareStatus, documentType, transferType)
MasterWelfareTypemasterWelfareTypemapping welfareTypeCode → ชื่อประเภทสวัสดิการ + flag isVTRC แยกว่ามาจากฝั่ง vtrc-api หรือไม่
MasterChartOfAccountmasterChartOfAccountCOA (chart of account) ต่อประเภทสวัสดิการ + emp type + budget source + isPension flag — ใช้ตอน export ไป FMIS
MasterPaidListmasterPaidListรายการที่จ่ายแล้ว สำหรับหน้า report
SetupPayorsetupPayorตั้งค่าผู้จ่ายเงิน (payor) ต่อประเภทสวัสดิการ
BatchTransactionbatchTransactionlog การรัน cron/batch job (jobName, seqNo, totalRecords, batchStatus, startDateTime, endDateTime, errors)
Sicknesssicknessmaster รายการโรค/อาการ

หลักฐาน — 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 จัดการให้ — เช่น WdHospitalsEntityWdHospitalsHistoryEntity:

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

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

typescript
// พบซ้ำใน 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, docUUID
    • wdHospitals.status, empCode, wdHospitalUuid
    • welfareHeader.docType, empCode, paymentStatus
    • wfDisasterHd.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 ปลายทางวิธีตัวอย่างไฟล์:บรรทัดใช้ทำอะไร
vtrcraw SQL vtrcEntityManager.query()benefit-api/src/modules/payments/payments.service.ts (branch: prod):493, 557-572, 640-720, 1137, 1293, 1408-1422insert/update/select ตาราง WDHospitals, WDHistories, HospitalBankAccounts, Banks, MasterApprovers
hrmiraw 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
hrmiraw 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 เพียงไฟล์เดียว)

ไป บท 4 Auth + integrations