import { Double, EntityManager } from "typeorm"; import { AppDataSource } from "../database/data-source"; import HttpError from "../interfaces/http-error"; import HttpStatusCode from "../interfaces/http-status"; import { Profile } from "../entities/Profile"; import { ProfileSalary } from "../entities/ProfileSalary"; import { ProfileSalaryHistory } from "../entities/ProfileSalaryHistory"; import { OrgRoot } from "../entities/OrgRoot"; import { PosMaster } from "../entities/PosMaster"; import { Position } from "../entities/Position"; import { Command } from "../entities/Command"; import { getOrgFullName, getPosMasterNo } from "../utils/org-formatting"; import { logPositionIsSelectedChange, setLogDataDiff } from "../interfaces/utils"; import { CreatePosMasterHistoryOfficer } from "./PositionService"; /** * Input: ข้อมูล 1 คนสำหรับ endpoint excexute/salary-current * (C-PM-03, 04, 05, 06, 07, 39, 47 — เปลี่ยนตำแหน่งปัจจุบันของข้าราชการ + salary ใหม่) */ export interface SalaryCurrentItem { profileId: string; amount?: Double | null; amountSpecial?: Double | null; positionSalaryAmount?: Double | null; mouthSalaryAmount?: Double | null; positionExecutive: string | null; positionExecutiveField?: string | null; positionArea?: string | null; positionType: string | null; positionLevel: string | null; positionTypeId?: string | null; positionLevelId?: string | null; posmasterId: string; positionId: string; posExecutiveId?: string | null; positionField?: string | null; commandId?: string | null; orgRoot?: string | null; orgChild1?: string | null; orgChild2?: string | null; orgChild3?: string | null; orgChild4?: string | null; commandNo: string | null; commandYear: number | null; posNo: string | null; posNoAbb: string | null; commandDateAffect?: Date | string | null; commandDateSign?: Date | string | null; positionName: string | null; commandCode?: string | null; commandName?: string | null; remark: string | null; } /** * Context สำหรับ audit/log */ export interface SalaryCurrentExecutionContext { user: { sub: string; name: string }; req?: any; } /** * Service สำหรับสร้าง ProfileSalary ของข้าราชการ + อัปเดตตำแหน่งปัจจุบัน (เปลี่ยนตำแหน่ง) * * ใช้กับ commandType: C-PM-03, 04, 05, 06, 07, 39, 47 * * - endpoint /org/command/excexute/salary-current เรียกผ่าน service นี้ (thin wrapper) * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (Linear Flow) * * Behavior ทั้งหมด preserve จาก CommandController.newSalaryAndUpdateCurrent ต้นฉบับ * * Batch semantics: all-or-nothing — ประมวลผลทุกคนภายใต้ transaction เดียว (sequential) * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) * ถ้าทุกคนสำเร็จจะ return result รายงาน success count */ export class ExecuteSalaryCurrentService { private commandRepository = AppDataSource.getRepository(Command); private profileRepository = AppDataSource.getRepository(Profile); private orgRootRepository = AppDataSource.getRepository(OrgRoot); /** * ประมวลผลสร้าง ProfileSalary + อัปเดตตำแหน่งปัจจุบันของข้าราชการทั้ง batch * * @returns สรุปผล success/failure ต่อคน */ async executeSalaryCurrent( data: SalaryCurrentItem[], ctx: SalaryCurrentExecutionContext, ): Promise { const commandId = data?.find((x) => x.commandId)?.commandId ?? "unknown"; const commandCode = data?.find((x) => x.commandCode)?.commandCode ?? "unknown"; console.log( `[ExecuteSalaryCurrentService] Starting executeSalaryCurrent — commandCode: ${commandCode}, commandId: ${commandId}`, ); console.log(`[ExecuteSalaryCurrentService] Request body count: ${data?.length ?? 0}`); // ───────────────────────────────────────────────────────────── // Normalize date fields (ผ่าน handler จะได้ string → ต้องแปลงเป็น Date) // ───────────────────────────────────────────────────────────── const toDate = (v: any): Date | null => { if (v == null || v === "") return null; if (v instanceof Date) return isNaN(v.getTime()) ? null : v; const d = new Date(v); return isNaN(d.getTime()) ? null : d; }; for (const item of data ?? []) { const it = item as any; it.commandDateAffect = toDate(it.commandDateAffect); it.commandDateSign = toDate(it.commandDateSign); } let _posNumCodeSit: string = ""; let _posNumCodeSitAbb: string = ""; const _command = await this.commandRepository.findOne({ where: { id: data.find((x) => x.commandId)?.commandId ?? "" }, }); if (_command) { if (_command?.isBangkok?.toLocaleUpperCase() == "OFFICE") { const orgRootDeputy = await this.orgRootRepository.findOne({ where: { isDeputy: true, orgRevision: { orgRevisionIsCurrent: true, orgRevisionIsDraft: false, }, }, relations: ["orgRevision"], }); _posNumCodeSit = orgRootDeputy ? orgRootDeputy?.orgRootName : "สำนักปลัดกรุงเทพมหานคร"; _posNumCodeSitAbb = orgRootDeputy ? orgRootDeputy?.orgRootShortName : "สนป."; } else if (_command?.isBangkok?.toLocaleUpperCase() == "BANGKOK") { _posNumCodeSit = "กรุงเทพมหานคร"; _posNumCodeSitAbb = "กทม."; } else { let _profileAdmin = await this.profileRepository.findOne({ where: { keycloak: _command?.createdUserId.toString(), current_holders: { orgRevision: { orgRevisionIsCurrent: true, orgRevisionIsDraft: false, }, }, }, relations: ["current_holders", "current_holders.orgRevision", "current_holders.orgRoot"], }); _posNumCodeSit = _profileAdmin?.current_holders.find((x) => x.orgRoot.orgRootName)?.orgRoot.orgRootName ?? ""; _posNumCodeSitAbb = _profileAdmin?.current_holders.find((x) => x.orgRoot.orgRootShortName)?.orgRoot .orgRootShortName ?? ""; } } // ───────────────────────────────────────────────────────────── // Single transaction ครอบทั้ง batch (all-or-nothing) // ทุกคนใช้ manager ตัวเดียวกัน — คนใด throw จะ rollback ทั้ง batch // และ propagate error ออกไป (ล้มเหลวทั้งหมด) โดย log error ของคนที่ทำให้ fail ก่อน rethrow // ───────────────────────────────────────────────────────────── await AppDataSource.transaction(async (manager) => { for (const item of data ?? []) { try { await this.processOne(item, ctx, manager, _posNumCodeSit, _posNumCodeSitAbb); } catch (err) { const reason = err instanceof HttpError ? err.message : err instanceof Error ? err.message : "unexpected error"; console.error( `[ExecuteSalaryCurrentService] Failed — commandCode: ${commandCode}, commandId: ${commandId}, profileId: ${item.profileId}, reason: ${reason}`, err, ); throw err; // → rollback ทั้ง transaction + propagate เป็น batch failure } } }); } /** * ประมวลผล 1 คน ภายใน transaction เดียว (manager) * ทุก save ใช้ manager.getRepository(...) เพื่อให้อยู่ใน transaction เดียวกัน * ถ้า throw ระหว่างทาง → rollback ทั้งหมดของคนนี้ (กัน partial commit) */ private async processOne( item: SalaryCurrentItem, ctx: SalaryCurrentExecutionContext, manager: EntityManager, _posNumCodeSit: string, _posNumCodeSitAbb: string, ): Promise { const req = ctx.req; const profileRepository = manager.getRepository(Profile); const salaryRepo = manager.getRepository(ProfileSalary); const salaryHistoryRepo = manager.getRepository(ProfileSalaryHistory); const posMasterRepository = manager.getRepository(PosMaster); const positionRepository = manager.getRepository(Position); const profile: any = await profileRepository.findOneBy({ id: item.profileId }); if (!profile) { throw new HttpError(HttpStatusCode.NOT_FOUND, `ไม่พบข้อมูลทะเบียนประวัตินี้ profileId: ${item.profileId}`); } let _null: any = null; const dest_item = await salaryRepo.findOne({ where: { profileId: item.profileId }, order: { order: "DESC" }, }); const before = null; const dataSalary = new ProfileSalary(); const meta = { order: dest_item == null ? 1 : dest_item.order + 1, createdUserId: ctx.user.sub, createdFullName: ctx.user.name, lastUpdateUserId: ctx.user.sub, lastUpdateFullName: ctx.user.name, createdAt: new Date(), lastUpdatedAt: new Date(), }; dataSalary.posNumCodeSit = _posNumCodeSit; dataSalary.posNumCodeSitAbb = _posNumCodeSitAbb; Object.assign(dataSalary, { ...item, ...meta }); const history = new ProfileSalaryHistory(); Object.assign(history, { ...dataSalary, id: undefined }); await salaryRepo.save(dataSalary, { data: req }); setLogDataDiff(req, { before, after: dataSalary }); history.commandId = item.commandId ?? _null; history.profileSalaryId = dataSalary.id; await salaryHistoryRepo.save(history, { data: req }); // STEP 1: หา posMaster ที่จะใช้งานตาม id ที่ส่งมา console.log( `[ExecuteSalaryCurrentService] STEP 1: Finding posMaster — posmasterId: ${item.posmasterId}, profileId: ${item.profileId}`, ); let posMaster = await posMasterRepository.findOne({ where: { id: item.posmasterId }, relations: { orgRevision: true, orgRoot: true, orgChild1: true, orgChild2: true, orgChild3: true, orgChild4: true, }, }); console.log( `[ExecuteSalaryCurrentService] STEP 1: posMaster found: ${!!posMaster}, ancestorDNA: ${posMaster?.ancestorDNA ?? "null"}, orgRevisionId: ${posMaster?.orgRevisionId ?? "null"}`, ); // เช็คว่า posMaster ที่หามาอยู่ในโครงสร้างปัจจุบันหรือไม่ const isCurrent = posMaster?.orgRevision?.orgRevisionIsCurrent === true && posMaster?.orgRevision?.orgRevisionIsDraft === false; console.log(`[ExecuteSalaryCurrentService] STEP 1: isCurrent: ${isCurrent}`); // ถ้าไม่อยู่ในโครงสร้างปัจจุบัน ให้หาตัวใหม่จาก ancestorDNA if (!isCurrent && posMaster?.ancestorDNA) { console.log( `[ExecuteSalaryCurrentService] STEP 1: Not current — re-resolving via ancestorDNA: ${posMaster.ancestorDNA}`, ); posMaster = await posMasterRepository.findOne({ where: { ancestorDNA: posMaster.ancestorDNA, orgRevision: { orgRevisionIsCurrent: true, orgRevisionIsDraft: false, }, }, relations: { orgRevision: true, orgRoot: true, orgChild1: true, orgChild2: true, orgChild3: true, orgChild4: true, }, }); console.log( `[ExecuteSalaryCurrentService] STEP 1: ancestorDNA re-resolve — found: ${!!posMaster}`, ); } if (posMaster == null) { console.error( `[ExecuteSalaryCurrentService] STEP 1: PosMaster not found — posmasterId: ${item.posmasterId}`, ); throw new HttpError(HttpStatusCode.NOT_FOUND, `ไม่พบข้อมูลตำแหน่งนี้ posMasterId: ${item.posmasterId}`); } const posMasterOld = await posMasterRepository.findOne({ where: { current_holderId: item.profileId, orgRevisionId: posMaster.orgRevisionId, }, }); if (posMasterOld != null) { posMasterOld.current_holderId = null; posMasterOld.lastUpdatedAt = new Date(); } const positionOld = await positionRepository.findOne({ where: { posMasterId: posMasterOld?.id, positionIsSelected: true, }, }); if (positionOld != null) { logPositionIsSelectedChange(positionOld.id, positionOld.positionIsSelected, false, { posMasterId: posMasterOld?.id, userId: ctx.user.sub, endpoint: "updateMaster", action: "command_change_reset_old_position", }); positionOld.positionIsSelected = false; await positionRepository.save(positionOld); } const checkPosition = await positionRepository.find({ where: { posMasterId: posMaster!.id, // ใช้ posMaster ตัวใหม่ (ที่อาจจะเปลี่ยนจาก ancestorDNA) positionIsSelected: true, }, }); if (checkPosition.length > 0) { console.log( `[positionIsSelected-DEBUG] Command change: clearing ${checkPosition.length} positions (posMasterId: ${posMaster!.id}, userId: ${ctx.user.sub}, endpoint: updateMaster)`, ); const clearPosition = checkPosition.map((positions) => { logPositionIsSelectedChange(positions.id, positions.positionIsSelected, false, { posMasterId: posMaster!.id, userId: ctx.user.sub, endpoint: "updateMaster", action: "command_change_clear_positions", }); return { ...positions, positionIsSelected: false, }; }); await positionRepository.save(clearPosition); } posMaster.current_holderId = item.profileId; posMaster.lastUpdatedAt = new Date(); // posMaster.conditionReason = _null; // posMaster.isCondition = false; if (posMasterOld != null) { await posMasterRepository.save(posMasterOld); // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน console.log( `[ExecuteSalaryCurrentService] Creating PosMasterHistory — posMasterId: ${posMasterOld.id}, profileId: ${item.profileId} (old)`, ); await CreatePosMasterHistoryOfficer(posMasterOld.id, req, null, null, manager); } await posMasterRepository.save(posMaster); // STEP 2: กำหนด position ใหม่ // Match position ตามลำดับ priority: // Condition 1: match จาก positionId // Condition 2: match 7 ฟิลด์ (positionName, posTypeId, posLevelId, positionField, positionArea, positionExecutiveField, posExecutiveId) // Condition 3: match 3 ฟิลด์ (positionName, posTypeId, posLevelId) // Fallback: เลือก position แรกใน posMaster let positionNew: Position | null = null; // Resolve ID: ใช้ positionTypeId/positionLevelId ก่อน ถ้าไม่มี fallback เป็น positionType/positionLevel const posTypeId = item.positionTypeId || item.positionType; const posLevelId = item.positionLevelId || item.positionLevel; console.log( `[ExecuteSalaryCurrentService] STEP 2: Resolving position — posMasterId: ${posMaster.id}, positionId: ${item.positionId ?? "null"}, positionName: ${item.positionName ?? "null"}, posTypeId: ${posTypeId ?? "null"}, posLevelId: ${posLevelId ?? "null"}`, ); // ═══════════════════════════════════════════════════════════ // CONDITION 1: เช็คจาก positionId ตรง // ═══════════════════════════════════════════════════════════ if (item.positionId) { const positionById = await positionRepository.findOne({ where: { id: item.positionId, posMasterId: posMaster.id, // ต้องอยู่ใน posMaster ที่ถูกต้อง }, relations: ["posExecutive"], }); console.log( `[ExecuteSalaryCurrentService] STEP 2 / Condition 1: match: ${!!positionById}`, ); if (positionById) { positionNew = positionById; } } // ═══════════════════════════════════════════════════════════ // CONDITION 2: Match 7 ฟิลด์ (ถ้า Condition 1 ไม่ match) // ═══════════════════════════════════════════════════════════ if (!positionNew && item.positionName && posTypeId && posLevelId) { // สร้าง where clause แบบ dynamic - ใส่เฉพาะฟิลด์ที่มีค่า const whereCondition: any = { posMasterId: posMaster.id, positionName: item.positionName, posTypeId: posTypeId, posLevelId: posLevelId, }; // เพิ่มเฉพาะฟิลด์ที่มีค่า (ไม่ใช่ null, undefined, หรือ string ว่าง) if (item.positionField) { whereCondition.positionField = item.positionField; } if (item.posExecutiveId) { whereCondition.posExecutiveId = item.posExecutiveId; } if (item.positionExecutiveField) { whereCondition.positionExecutiveField = item.positionExecutiveField; } if (item.positionArea) { whereCondition.positionArea = item.positionArea; } const positionBy7Fields = await positionRepository.findOne({ where: whereCondition, relations: ["posExecutive"], order: { orderNo: "ASC" }, }); console.log( `[ExecuteSalaryCurrentService] STEP 2 / Condition 2: match: ${!!positionBy7Fields}`, whereCondition, ); if (positionBy7Fields) { positionNew = positionBy7Fields; } } // ═══════════════════════════════════════════════════════════ // CONDITION 3: Match 3 ฟิลด์ (ถ้า Condition 2 ไม่ match) // ═══════════════════════════════════════════════════════════ if (!positionNew && item.positionName && posTypeId && posLevelId) { const positionBy3Fields = await positionRepository.findOne({ where: { posMasterId: posMaster.id, positionName: item.positionName, posTypeId: posTypeId, posLevelId: posLevelId, }, relations: ["posExecutive"], order: { orderNo: "ASC" }, }); console.log( `[ExecuteSalaryCurrentService] STEP 2 / Condition 3: match: ${!!positionBy3Fields}`, ); if (positionBy3Fields) { positionNew = positionBy3Fields; } } console.log( `[ExecuteSalaryCurrentService] STEP 2: Resolved positionNew: ${positionNew ? positionNew.id : "null (no match — profile position not updated)"}`, ); // ถ้าไม่ใช่ตำแหน่งนั่งทับ (isSit = false) ถึงจะอัพเดทตำแหน่งในทะเบียนประวัติ if (positionNew != null) { positionNew.positionIsSelected = true; // อัพเดท org และ posMasterNo ตลอดไม่ต้องดัก isSit profile.posMasterNo = getPosMasterNo(posMaster); profile.org = getOrgFullName(posMaster); if (!posMaster.isSit) { profile.posLevelId = positionNew.posLevelId; profile.posTypeId = positionNew.posTypeId; profile.position = positionNew.positionName; profile.positionField = positionNew.positionField ?? null; profile.posExecutive = positionNew.posExecutive?.posExecutiveName ?? null; profile.positionArea = positionNew.positionArea ?? null; profile.positionExecutiveField = positionNew.positionExecutiveField ?? null; } profile.amount = item.amount ?? null; profile.amountSpecial = item.amountSpecial ?? null; await profileRepository.save(profile); await positionRepository.save(positionNew); } // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน console.log( `[ExecuteSalaryCurrentService] Creating PosMasterHistory — posMasterId: ${posMaster.id}, profileId: ${item.profileId}`, ); await CreatePosMasterHistoryOfficer(posMaster.id, req, null, null, manager); console.log( `[ExecuteSalaryCurrentService] Completed processOne — profileId: ${item.profileId}, posMasterId: ${posMaster.id}`, ); } }