From 3d2fc5128ab70ceca97d01985c9e261840172a73 Mon Sep 17 00:00:00 2001 From: harid Date: Fri, 26 Jun 2026 18:09:45 +0700 Subject: [PATCH] Linear Flow discipline + organization #224 --- src/controllers/CommandController.ts | 1170 +---------------- src/services/ExecuteOfficerProfileService.ts | 17 +- src/services/ExecuteOrgCommandService.ts | 860 ++++++++++++ .../ExecuteSalaryEmployeeLeaveService.ts | 4 + .../ExecuteSalaryLeaveDisciplineService.ts | 615 +++++++++ src/services/ExecuteSalaryService.ts | 6 +- src/services/rabbitmq.ts | 67 +- 7 files changed, 1591 insertions(+), 1148 deletions(-) create mode 100644 src/services/ExecuteOrgCommandService.ts create mode 100644 src/services/ExecuteSalaryLeaveDisciplineService.ts diff --git a/src/controllers/CommandController.ts b/src/controllers/CommandController.ts index 1ea05633..7ca097fd 100644 --- a/src/controllers/CommandController.ts +++ b/src/controllers/CommandController.ts @@ -81,8 +81,6 @@ import { District } from "../entities/District"; import { Province } from "../entities/Province"; import { ProfileAssistance } from "../entities/ProfileAssistance"; import { ProfileAssistanceHistory } from "../entities/ProfileAssistanceHistory"; -import { ProfileActposition } from "../entities/ProfileActposition"; -import { ProfileActpositionHistory } from "../entities/ProfileActpositionHistory"; import { ProfileFamilyFather } from "../entities/ProfileFamilyFather"; import { ProfileFamilyFatherHistory } from "../entities/ProfileFamilyFatherHistory"; import { ProfileFamilyCouple } from "../entities/ProfileFamilyCouple"; @@ -110,9 +108,8 @@ import { ExecuteSalaryCurrentService } from "../services/ExecuteSalaryCurrentSer import { ExecuteSalaryEmployeeCurrentService } from "../services/ExecuteSalaryEmployeeCurrentService"; import { ExecuteSalaryLeaveService } from "../services/ExecuteSalaryLeaveService"; import { ExecuteSalaryEmployeeLeaveService } from "../services/ExecuteSalaryEmployeeLeaveService"; -import { promisify } from "util"; -const REDIS_HOST = process.env.REDIS_HOST; -const REDIS_PORT = process.env.REDIS_PORT; +import { ExecuteSalaryLeaveDisciplineService } from "../services/ExecuteSalaryLeaveDisciplineService"; +import { ExecuteOrgCommandService } from "../services/ExecuteOrgCommandService"; @Route("api/v1/org/command") @Tags("Command") @@ -122,7 +119,6 @@ const REDIS_PORT = process.env.REDIS_PORT; "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการได้ กรุณาลองใหม่ในภายหลัง", ) export class CommandController extends Controller { - private redis = require("redis"); private commandRepository = AppDataSource.getRepository(Command); private commandTypeRepository = AppDataSource.getRepository(CommandType); private commandSendRepository = AppDataSource.getRepository(CommandSend); @@ -157,8 +153,6 @@ export class CommandController extends Controller { private subDistrictRepo = AppDataSource.getRepository(SubDistrict); private assistanceRepository = AppDataSource.getRepository(ProfileAssistance); private assistanceHistoryRepository = AppDataSource.getRepository(ProfileAssistanceHistory); - private actpositionRepository = AppDataSource.getRepository(ProfileActposition); - private actpositionHistoryRepository = AppDataSource.getRepository(ProfileActpositionHistory); private profileFamilyCoupleRepo = AppDataSource.getRepository(ProfileFamilyCouple); private profileFamilyCoupleHistoryRepo = AppDataSource.getRepository(ProfileFamilyCoupleHistory); private profileFamilyMotherRepo = AppDataSource.getRepository(ProfileFamilyMother); @@ -4035,6 +4029,14 @@ export class CommandController extends Controller { return new HttpSuccess(); } + /** + * API สร้าง ProfileSalary + ProfileDiscipline + handle leave ของคำสั่งวินัย + * + * Thin wrapper — เรียก ExecuteSalaryLeaveDisciplineService (C-PM-19, 20, 25, 26, 27, 28, 29, 30, 31, 32) + * ทั้ง endpoint นี้และ consumer ใน rabbitmq ใช้ service ตัวเดียวกัน (Linear Flow) + * + * @summary API คำสั่งวินัย (ข้าราชการ/ลูกจ้าง) + leave + */ @Post("excexute/salary-leave-discipline") public async newSalaryAndUpdateLeaveDiscipline( @Request() req: RequestWithUser, @@ -4072,472 +4074,10 @@ export class CommandController extends Controller { }[]; }, ) { - let _posNumCodeSit: string = ""; - let _posNumCodeSitAbb: string = ""; - const _command = await this.commandRepository.findOne({ - relations: ["commandType"], - where: { id: body.data.find((x) => x.commandId)?.commandId ?? "" }, + await new ExecuteSalaryLeaveDisciplineService().executeSalaryLeaveDiscipline(body.data, { + user: { sub: req.user.sub, name: req.user.name }, + req, }); - 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 ?? ""; - } - } - await Promise.all( - body.data.map(async (item) => { - let _commandYear = item.commandYear; - if (item.commandYear) { - _commandYear = item.commandYear > 2500 ? item.commandYear : item.commandYear + 543; - } - const orgRevision = await this.orgRevisionRepo.findOne({ - where: { - orgRevisionIsCurrent: true, - orgRevisionIsDraft: false, - }, - }); - let orgRootRef = null; - let orgChild1Ref = null; - let orgChild2Ref = null; - let orgChild3Ref = null; - let orgChild4Ref = null; - let profile; - let isEmployee: boolean = false; - let retireTypeName: string = ""; - // ขรก. - if (item.profileType && item.profileType.trim().toUpperCase() == "OFFICER") { - profile = await this.profileRepository.findOne({ - relations: [ - // "profileSalary", - "posLevel", - "posType", - "current_holders", - "current_holders.orgRoot", - "current_holders.orgChild1", - "current_holders.orgChild2", - "current_holders.orgChild3", - "current_holders.orgChild4", - "current_holders.positions", - "current_holders.positions.posExecutive", - "roleKeycloaks", - ], - where: { id: item.profileId }, - // order: { - // profileSalary: { - // order: "DESC", - // }, - // }, - }); - if (!profile) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); - } - const lastSalary = await this.salaryRepo.findOne({ - where: { profileId: item.profileId }, - select: ["order"], - order: { order: "DESC" }, - }); - const nextOrder = lastSalary ? lastSalary.order + 1 : 1; - //ลบตำแหน่งที่รักษาการแทน - const code = _command?.commandType?.code; - if (code && ["C-PM-19", "C-PM-20"].includes(code)) { - removePostMasterAct(profile.id); - } - - const orgRevisionRef = - profile?.current_holders?.find((x) => x.orgRevisionId == orgRevision?.id) ?? null; - orgRootRef = orgRevisionRef?.orgRoot ?? null; - orgChild1Ref = orgRevisionRef?.orgChild1 ?? null; - orgChild2Ref = orgRevisionRef?.orgChild2 ?? null; - orgChild3Ref = orgRevisionRef?.orgChild3 ?? null; - orgChild4Ref = orgRevisionRef?.orgChild4 ?? null; - - let position = - profile.current_holders - .filter((x) => x.orgRevisionId == orgRevision?.id)[0] - ?.positions?.filter((pos) => pos.positionIsSelected === true)[0] ?? null; - // ประวัติตำแหน่ง - const data = new ProfileSalary(); - data.posNumCodeSit = _posNumCodeSit; - data.posNumCodeSitAbb = _posNumCodeSitAbb; - const meta = { - profileId: profile.id, - commandId: item.commandId, - position: profile.position, - positionName: profile.position, - positionType: profile?.posType?.posTypeName ?? null, - positionLevel: profile?.posLevel?.posLevelName ?? null, - positionExecutive: position?.posExecutive?.posExecutiveName ?? null, - amount: item.amount ? item.amount : null, - positionSalaryAmount: item.positionSalaryAmount ? item.positionSalaryAmount : null, - amountSpecial: item.amountSpecial ? item.amountSpecial : null, - mouthSalaryAmount: item.mouthSalaryAmount ? item.mouthSalaryAmount : null, - // order: - // profile.profileSalary.length >= 0 - // ? profile.profileSalary.length > 0 - // ? profile.profileSalary[0].order + 1 - // : 1 - // : null, - order: nextOrder, - orgRoot: item.orgRoot, - orgChild1: item.orgChild1, - orgChild2: item.orgChild2, - orgChild3: item.orgChild3, - orgChild4: item.orgChild4, - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - dateGovernment: item.commandDateAffect ?? new Date(), - isGovernment: item.isGovernment, - commandNo: item.commandNo, - commandYear: item.commandYear, - posNo: item.posNo, - posNoAbb: item.posNoAbb, - commandDateAffect: item.commandDateAffect, - commandDateSign: item.commandDateSign, - commandCode: item.commandCode, - commandName: item.commandName, - remark: item.remark, - }; - - Object.assign(data, meta); - const history = new ProfileSalaryHistory(); - Object.assign(history, { ...data, id: undefined }); - - await this.salaryRepo.save(data); - history.profileSalaryId = data.id; - await this.salaryHistoryRepo.save(history); - - // ประวัติวินัย - const dataDis = new ProfileDiscipline(); - - const metaDis = { - date: item.commandDateAffect, - refCommandDate: item.commandDateSign, - refCommandNo: `${item.commandNo}/${item.commandYear}`, - refCommandId: item.commandId, - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - }; - - Object.assign(dataDis, { ...item, ...metaDis }); - const historyDis = new ProfileDisciplineHistory(); - Object.assign(historyDis, { ...dataDis, id: undefined }); - - await this.disciplineRepository.save(dataDis); - historyDis.profileDisciplineId = dataDis.id; - await this.disciplineHistoryRepository.save(historyDis); - - // ทะเบียนประวัติ - if (item.isLeave != null) { - const _profile = await this.profileRepository.findOne({ - where: { id: item.profileId }, - relations: ["roleKeycloaks"], - }); - if (!_profile) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); - } - const _null: any = null; - _profile.isLeave = item.isLeave; - _profile.leaveReason = item.leaveReason ?? _null; - _profile.dateLeave = item.dateLeave ?? _null; - _profile.lastUpdateUserId = req.user.sub; - _profile.lastUpdateFullName = req.user.name; - _profile.lastUpdatedAt = new Date(); - if (item.isLeave == true) { - if (orgRevisionRef) { - await CreatePosMasterHistoryOfficer(orgRevisionRef.id, req, "DELETE"); - } - await removeProfileInOrganize(_profile.id, "OFFICER"); - } - const clearProfile = await checkCommandType(String(item.commandId)); - if (clearProfile.status) { - retireTypeName = clearProfile.retireTypeName ?? ""; - if ( - _profile.keycloak != null && - _profile.keycloak != "" && - _profile.isDelete === false - ) { - const delUserKeycloak = await deleteUser(_profile.keycloak); - if (delUserKeycloak) { - // Task #228 - // _profile.keycloak = _null; - _profile.roleKeycloaks = []; - _profile.isActive = false; - _profile.isDelete = true; - } - } - _profile.leaveCommandId = item.commandId ?? _null; - _profile.leaveCommandNo = `${item.commandNo}/${_commandYear}`; - _profile.leaveRemark = clearProfile.leaveRemark ?? _null; - _profile.leaveDate = item.commandDateAffect ?? _null; - _profile.leaveType = clearProfile.LeaveType ?? _null; - //ออกจากราชการ ไม่ต้องลบตำแหน่งในทะเบียน (issue #1516) - // _profile.position = _null; - // _profile.posTypeId = _null; - // _profile.posLevelId = _null; - } - await this.profileRepository.save(_profile); - // if (_profile.id) { - // await this.keycloakAttributeService.clearOrgDnaAttributes( - // [_profile.id], - // "PROFILE", - // ); - // } - } - } - // ลูกจ้าง - else { - isEmployee = true; - profile = await this.profileEmployeeRepository.findOne({ - relations: [ - // "profileSalary", - "posLevel", - "posType", - "current_holders", - "current_holders.orgRoot", - "current_holders.orgChild1", - "current_holders.orgChild2", - "current_holders.orgChild3", - "current_holders.orgChild4", - "roleKeycloaks", - ], - where: { id: item.profileId }, - // order: { - // profileSalary: { - // order: "DESC", - // }, - // }, - }); - if (!profile) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); - } - const lastSalary = await this.salaryRepo.findOne({ - where: { profileEmployeeId: item.profileId }, - select: ["order"], - order: { order: "DESC" }, - }); - const nextOrder = lastSalary ? lastSalary.order + 1 : 1; - const orgRevisionRef = - profile?.current_holders?.find((x) => x.orgRevisionId == orgRevision?.id) ?? null; - orgRootRef = orgRevisionRef?.orgRoot ?? null; - orgChild1Ref = orgRevisionRef?.orgChild1 ?? null; - orgChild2Ref = orgRevisionRef?.orgChild2 ?? null; - orgChild3Ref = orgRevisionRef?.orgChild3 ?? null; - orgChild4Ref = orgRevisionRef?.orgChild4 ?? null; - - // ประวัติตำแหน่ง - const data = new ProfileSalary(); - data.posNumCodeSit = _posNumCodeSit; - data.posNumCodeSitAbb = _posNumCodeSitAbb; - const meta = { - profileEmployeeId: profile.id, - commandId: item.commandId, - position: profile.position, - positionName: profile.position, - positionType: profile?.posType?.posTypeName ?? null, - positionLevel: - profile?.posType && profile?.posLevel - ? `${profile?.posType?.posTypeShortName} ${profile?.posLevel?.posLevelName}` - : null, - amount: item.amount ? item.amount : null, - positionSalaryAmount: item.positionSalaryAmount ? item.positionSalaryAmount : null, - mouthSalaryAmount: item.mouthSalaryAmount ? item.mouthSalaryAmount : null, - // order: - // profile.profileSalary.length >= 0 - // ? profile.profileSalary.length > 0 - // ? profile.profileSalary[0].order + 1 - // : 1 - // : null, - order: nextOrder, - orgRoot: item.orgRoot, - orgChild1: item.orgChild1, - orgChild2: item.orgChild2, - orgChild3: item.orgChild3, - orgChild4: item.orgChild4, - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - dateGovernment: item.commandDateAffect ?? new Date(), - isGovernment: item.isGovernment, - commandNo: item.commandNo, - commandYear: item.commandYear, - posNo: item.posNo, - posNoAbb: item.posNoAbb, - commandDateAffect: item.commandDateAffect, - commandDateSign: item.commandDateSign, - commandCode: item.commandCode, - commandName: item.commandName, - remark: item.remark, - }; - - Object.assign(data, meta); - const history = new ProfileSalaryHistory(); - Object.assign(history, { ...data, id: undefined }); - - await this.salaryRepo.save(data); - history.profileSalaryId = data.id; - await this.salaryHistoryRepo.save(history); - - // ประวัติวินัย - const dataDis = new ProfileDiscipline(); - - const metaDis = { - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - }; - - Object.assign(dataDis, { - ...item, - ...metaDis, - date: item.commandDateAffect, - refCommandDate: item.commandDateSign, - refCommandNo: item.commandNo, - profileEmployeeId: item.profileId, - profileId: undefined, - }); - const historyDis = new ProfileDisciplineHistory(); - Object.assign(historyDis, { ...dataDis, id: undefined }); - - await this.disciplineRepository.save(dataDis); - historyDis.profileDisciplineId = dataDis.id; - await this.disciplineHistoryRepository.save(historyDis); - - // ทะเบียนประวัติ - if (item.isLeave != null) { - const _profile = await this.profileEmployeeRepository.findOne({ - where: { id: item.profileId }, - relations: ["roleKeycloaks"], - }); - if (!_profile) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); - } - const _null: any = null; - _profile.isLeave = item.isLeave; - _profile.leaveReason = item.leaveReason ?? _null; - _profile.dateLeave = item.dateLeave ?? _null; - _profile.lastUpdateUserId = req.user.sub; - _profile.lastUpdateFullName = req.user.name; - _profile.lastUpdatedAt = new Date(); - if (item.isLeave == true) { - // บันทึกประวัติก่อนลบตำแหน่ง - const curRevision = await this.orgRevisionRepo.findOne({ - where: { orgRevisionIsCurrent: true, orgRevisionIsDraft: false }, - }); - if (curRevision) { - const curPosMaster = await this.employeePosMasterRepository.findOne({ - where: { - current_holderId: _profile.id, - orgRevisionId: curRevision.id, - }, - }); - if (curPosMaster) { - await CreatePosMasterHistoryEmployee(curPosMaster.id, req, "DELETE"); - } - } - await removeProfileInOrganize(_profile.id, "EMPLOYEE"); - } - const clearProfile = await checkCommandType(String(item.commandId)); - if (clearProfile.status) { - retireTypeName = clearProfile.retireTypeName ?? ""; - if ( - _profile.keycloak != null && - _profile.keycloak != "" && - _profile.isDelete === false - ) { - const delUserKeycloak = await deleteUser(_profile.keycloak); - if (delUserKeycloak) { - // Task #228 - // _profile.keycloak = _null; - _profile.roleKeycloaks = []; - _profile.isActive = false; - _profile.isDelete = true; - } - } - _profile.leaveCommandId = item.commandId ?? _null; - _profile.leaveCommandNo = `${item.commandNo}/${_commandYear}`; - _profile.leaveRemark = clearProfile.leaveRemark ?? _null; - _profile.leaveDate = item.commandDateAffect ?? _null; - _profile.leaveType = clearProfile.LeaveType ?? _null; - //ออกจากราชการ ไม่ต้องลบตำแหน่งในทะเบียน (issue #1516) - // _profile.position = _null; - // _profile.posTypeId = _null; - // _profile.posLevelId = _null; - } - await this.profileEmployeeRepository.save(_profile); - // if (_profile.id) { - // await this.keycloakAttributeService.clearOrgDnaAttributes( - // [_profile.id], - // "PROFILE_EMPLOYEE", - // ); - // } - } - } - // Task #2190 - if (_command && ["C-PM-19", "C-PM-20"].includes(_command.commandType.code)) { - let organizeName = ""; - if (orgRootRef) { - const names = [ - orgChild4Ref?.orgChild4Name, - orgChild3Ref?.orgChild3Name, - orgChild2Ref?.orgChild2Name, - orgChild1Ref?.orgChild1Name, - orgRootRef?.orgRootName, - ].filter(Boolean); - organizeName = names.join(" "); - } - let _posLevelName: string = !isEmployee - ? `${profile.posLevel?.posLevelName}` - : `${profile.posType?.posTypeName} ${profile.posLevel?.posLevelName}`; - } - }), - ); - return new HttpSuccess(); } @@ -5459,365 +4999,14 @@ export class CommandController extends Controller { }[]; }, ) { - let _reqBody: any[] = []; - const roleKeycloak = await this.roleKeycloakRepo.findOne({ - where: { name: Like("USER") }, + /** + * Thin wrapper — เรียก ExecuteOrgCommandService.executeCommand21Employee (C-PM-21) + * ทั้ง endpoint นี้และ consumer ใน rabbitmq ใช้ service ตัวเดียวกัน (Linear Flow) + */ + await new ExecuteOrgCommandService().executeCommand21Employee(body.refIds, { + user: { sub: req.user.sub, name: req.user.name }, + req, }); - let _posNumCodeSit: string = ""; - let _posNumCodeSitAbb: string = ""; - const _command = await this.commandRepository.findOne({ - where: { id: body.refIds.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 ?? ""; - } - } - await Promise.all( - body.refIds.map(async (item) => { - const profile = await this.profileEmployeeRepository.findOne({ - where: { id: item.refId }, - relations: ["roleKeycloaks"], - }); - if (!profile) { - throw new HttpError(HttpStatus.BAD_REQUEST, "ไม่พบ profile ดังกล่าว"); - } - const orgRevision = await this.orgRevisionRepository.findOne({ - where: { - orgRevisionIsCurrent: true, - orgRevisionIsDraft: false, - }, - }); - const _posMaster = await this.employeePosMasterRepository.findOne({ - where: { - orgRevisionId: orgRevision?.id, - id: profile.posmasterIdTemp, - // current_holderId: profile.id - }, - relations: { - orgRoot: true, - orgChild1: true, - orgChild2: true, - orgChild3: true, - orgChild4: true, - }, - }); - const orgRootRef = _posMaster?.orgRoot ?? null; - const orgChild1Ref = _posMaster?.orgChild1 ?? null; - const orgChild2Ref = _posMaster?.orgChild2 ?? null; - const orgChild3Ref = _posMaster?.orgChild3 ?? null; - const orgChild4Ref = _posMaster?.orgChild4 ?? null; - let orgShortName = ""; - if (_posMaster != null) { - if (_posMaster.orgChild1Id === null) { - orgShortName = _posMaster.orgRoot?.orgRootShortName; - } else if (_posMaster.orgChild2Id === null) { - orgShortName = _posMaster.orgChild1?.orgChild1ShortName; - } else if (_posMaster.orgChild3Id === null) { - orgShortName = _posMaster.orgChild2?.orgChild2ShortName; - } else if (_posMaster.orgChild4Id === null) { - orgShortName = _posMaster.orgChild3?.orgChild3ShortName; - } else { - orgShortName = _posMaster.orgChild4?.orgChild4ShortName; - } - } - const dest_item = await this.salaryRepo.findOne({ - where: { profileEmployeeId: item.refId }, - order: { order: "DESC" }, - }); - const before = null; - const data = new ProfileSalary(); - data.posNumCodeSit = _posNumCodeSit; - data.posNumCodeSitAbb = _posNumCodeSitAbb; - const meta = { - profileEmployeeId: profile.id, - amount: item.amount, - amountSpecial: item.amountSpecial, - commandId: item.commandId, - positionSalaryAmount: item.positionSalaryAmount, - mouthSalaryAmount: item.mouthSalaryAmount, - position: profile.positionTemp, - positionName: profile.positionTemp, - positionType: profile.posTypeNameTemp, - positionLevel: profile.posLevelNameTemp, - order: dest_item == null ? 1 : dest_item.order + 1, - orgRoot: orgRootRef?.orgRootName ?? null, - orgChild1: orgChild1Ref?.orgChild1Name ?? null, - orgChild2: orgChild2Ref?.orgChild2Name ?? null, - orgChild3: orgChild3Ref?.orgChild3Name ?? null, - orgChild4: orgChild4Ref?.orgChild4Name ?? null, - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - commandNo: item.commandNo, - commandYear: item.commandYear, - posNo: profile.posMasterNoTemp ?? "", - posNoAbb: orgShortName, - commandDateAffect: item.commandDateAffect, - commandDateSign: item.commandDateSign, - commandCode: item.commandCode, - commandName: item.commandName, - remark: item.remark, - }; - - Object.assign(data, meta); - const history = new ProfileSalaryHistory(); - Object.assign(history, { ...data, id: undefined }); - - await this.salaryRepo.save(data, { data: req }); - setLogDataDiff(req, { before, after: data }); - history.profileSalaryId = data.id; - await this.salaryHistoryRepo.save(history, { data: req }); - - const posMaster = await this.employeePosMasterRepository.findOne({ - where: { id: profile.posmasterIdTemp }, - relations: ["orgRoot", "orgChild1", "orgChild2", "orgChild3", "orgChild4"], - }); - if (posMaster == null) - throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลตำแหน่งนี้"); - - const posMasterOld = await this.employeePosMasterRepository.findOne({ - where: { - current_holderId: profile.id, - orgRevisionId: posMaster.orgRevisionId, - }, - }); - if (posMasterOld != null) { - posMasterOld.current_holderId = null; - posMasterOld.lastUpdatedAt = new Date(); - } - // if (posMasterOld != null) posMasterOld.next_holderId = null; - - const positionOld = await this.employeePositionRepository.findOne({ - where: { - posMasterId: posMasterOld?.id, - positionIsSelected: true, - }, - }); - if (positionOld != null) { - positionOld.positionIsSelected = false; - await this.employeePositionRepository.save(positionOld); - } - - const checkPosition = await this.employeePositionRepository.find({ - where: { - posMasterId: profile.posmasterIdTemp, - positionIsSelected: true, - }, - }); - if (checkPosition.length > 0) { - const clearPosition = checkPosition.map((positions) => ({ - ...positions, - positionIsSelected: false, - })); - await this.employeePositionRepository.save(clearPosition); - } - - posMaster.current_holderId = profile.id; - posMaster.lastUpdatedAt = new Date(); - posMaster.next_holderId = null; - if (posMasterOld != null) { - await this.employeePosMasterRepository.save(posMasterOld); - await CreatePosMasterHistoryEmployee(posMasterOld.id, req); - } - await this.employeePosMasterRepository.save(posMaster); - await CreatePosMasterHistoryEmployee(posMaster.id, req); - - const clsTempPosmaster = await this.employeeTempPosMasterRepository.find({ - where: { - current_holderId: profile.id, - orgRevisionId: posMaster.orgRevisionId, - }, - }); - - if (clsTempPosmaster.length > 0) { - const clearTempPosmaster = clsTempPosmaster.map((posMasterTemp) => ({ - ...posMasterTemp, - current_holderId: null, - next_holderId: null, - })); - await this.employeeTempPosMasterRepository.save(clearTempPosmaster); - - const checkTempPosition = await this.employeePositionRepository.find({ - where: { - posMasterTempId: In(clearTempPosmaster.map((x) => x.id)), - positionIsSelected: true, - }, - }); - if (checkTempPosition.length > 0) { - const clearTempPosition = checkTempPosition.map((positions) => ({ - ...positions, - positionIsSelected: false, - })); - await this.employeePositionRepository.save(clearTempPosition); - } - await Promise.all( - clsTempPosmaster.map( - async (posMasterTemp) => - await CreatePosMasterHistoryEmployeeTemp(posMasterTemp.id, req), - ), - ); - } - - const positionNew = await this.employeePositionRepository.findOne({ - where: { - id: profile.positionIdTemp, - posMasterId: profile.posmasterIdTemp, - }, - }); - - if (positionNew != null) { - // Create Keycloak - const checkUser = await getUserByUsername(profile.citizenId); - if (checkUser.length == 0) { - let password = profile.citizenId; - if (profile.birthDate != null) { - // const gregorianYear = profile.birthDate.getFullYear() + 543; - - // const formattedDate = - // profile.birthDate.toISOString().slice(8, 10) + - // profile.birthDate.toISOString().slice(5, 7) + - // gregorianYear; - // password = formattedDate; - const _date = new Date(profile.birthDate.toDateString()) - .getDate() - .toString() - .padStart(2, "0"); - const _month = (new Date(profile.birthDate.toDateString()).getMonth() + 1) - .toString() - .padStart(2, "0"); - const _year = new Date(profile.birthDate.toDateString()).getFullYear() + 543; - password = `${_date}${_month}${_year}`; - } - // กรอง "." ออกจาก firstName ก่อนส่งไป keycloak - const sanitizedFirstName = profile.firstName?.replace(/\./g, "") ?? ""; - const userKeycloakId = await createUser(profile.citizenId, password, { - firstName: sanitizedFirstName, - lastName: profile.lastName, - // email: profile.email, - }); - // if (typeof userKeycloakId !== "string") { - // throw new Error(userKeycloakId.errorMessage); - // } - const list = await getRoles(); - if (!Array.isArray(list)) - throw new Error("Failed. Cannot get role(s) data from the server."); - const result = await addUserRoles( - userKeycloakId, - list - .filter((v) => v.name === "USER") - .map((x) => ({ - id: x.id, - name: x.name, - })), - ); - // if (!result) throw new Error("Failed. Cannot set user's role."); - profile.keycloak = - userKeycloakId && typeof userKeycloakId == "string" ? userKeycloakId : ""; - profile.roleKeycloaks = result && roleKeycloak ? [roleKeycloak] : []; - // End Create Keycloak - } else { - const rolesData = await getRoleMappings(checkUser[0].id); - if (rolesData) { - const _roleKeycloak = await this.roleKeycloakRepo.find({ - where: { name: In(rolesData.map((x: any) => x.name)) }, - }); - profile.roleKeycloaks = - _roleKeycloak && _roleKeycloak.length > 0 ? _roleKeycloak : []; - } - profile.keycloak = checkUser[0].id; - } - positionNew.positionIsSelected = true; - profile.posLevelId = positionNew.posLevelId; - profile.posTypeId = positionNew.posTypeId; - profile.position = positionNew.positionName; - profile.employeeOc = posMaster?.orgRoot?.orgRootName ?? null; - profile.positionEmployeePositionId = positionNew.positionName; - profile.statusTemp = "DONE"; - profile.employeeClass = "PERM"; - const _null: any = null; - profile.employeeWage = item.amount == null ? _null : item.amount.toString(); - profile.dateStart = _command ? _command.commandExcecuteDate : new Date(); - profile.dateAppoint = _command ? _command.commandExcecuteDate : new Date(); - profile.amount = item.amount == null ? _null : item.amount; - profile.amountSpecial = item.amountSpecial == null ? _null : item.amountSpecial; - _reqBody.push({ - profileId: profile.id, - prefix: profile.prefix, - firstName: profile.firstName, - lastName: profile.lastName, - citizenId: profile.citizenId, - root: posMaster.orgRoot.orgRootName, - rootId: posMaster.orgRootId, - rootShortName: posMaster.orgRoot.orgRootShortName, - rootDnaId: posMaster.orgRoot?.ancestorDNA ?? _null, - child1DnaId: posMaster.orgChild1?.ancestorDNA ?? _null, - child2DnaId: posMaster.orgChild2?.ancestorDNA ?? _null, - child3DnaId: posMaster.orgChild3?.ancestorDNA ?? _null, - child4DnaId: posMaster.orgChild4?.ancestorDNA ?? _null, - }); - await this.profileEmployeeRepository.save(profile); - await this.employeePositionRepository.save(positionNew); - await CreatePosMasterHistoryEmployee(posMaster.id, req); - //ลบออกคนออกจากโครงสร้างลูกจ้างชั่วคราว - const posMasterTemp = await this.employeeTempPosMasterRepository.findOne({ - where: { - orgRevisionId: orgRevision?.id, - current_holderId: profile.id, - }, - }); - if (posMasterTemp) { - await this.employeeTempPosMasterRepository.update(posMasterTemp.id, { - current_holderId: _null, - }); - await CreatePosMasterHistoryEmployeeTemp(posMasterTemp.id, req); - } - } - }), - ); - await new CallAPI() - .PostData(req, "/placement/appointment/employee-appoint-21/report/excecute", { - profileEmps: _reqBody, - }) - .catch((error) => { - throw new Error("Failed. Cannot update status. ", error); - }); return new HttpSuccess(); } @@ -5860,153 +5049,14 @@ export class CommandController extends Controller { }[]; }, ) { - // 1. Bulk update status - await this.posMasterActRepository.update( - { id: In(body.refIds.map((x) => x.refId)) }, - { statusReport: "DONE" }, - ); - - // 2. ดึงข้อมูลครบทุก relation ที่จำเป็น - const posMasters = await this.posMasterActRepository.find({ - where: { id: In(body.refIds.map((x) => x.refId)) }, - relations: [ - "posMasterChild", - "posMasterChild.current_holder", - "posMaster", - "posMaster.current_holder", - "posMaster.positions", - "posMaster.orgRoot", - "posMaster.orgChild1", - "posMaster.orgChild2", - "posMaster.orgChild3", - "posMaster.orgChild4", - ], + /** + * Thin wrapper — เรียก ExecuteOrgCommandService.executeCommand40Officer (C-PM-40) + * ทั้ง endpoint นี้และ consumer ใน rabbitmq ใช้ service ตัวเดียวกัน (Linear Flow) + */ + await new ExecuteOrgCommandService().executeCommand40Officer(body.refIds, { + user: { sub: req.user.sub, name: req.user.name }, + req, }); - - // 3. ตรวจสอบว่ามี body.refIds[0] หรือไม่ - const firstRef = body.refIds[0]; - if (!firstRef) { - throw new HttpError(HttpStatus.BAD_REQUEST, "ไม่พบข้อมูล refIds"); - } - - const profileIdsToClearCache = new Set(); - - await Promise.all( - posMasters.map(async (item) => { - // 4. ตรวจสอบข้อมูลที่จำเป็นทั้งหมด - if (!item.posMasterChild?.current_holderId || !item.posMaster) { - console.warn(`ข้ามรายการ ${item.id}: ข้อมูลไม่ครบ`); - return; - } - - if (item.posMasterChild.current_holderId) { - profileIdsToClearCache.add(item.posMasterChild.current_holderId); - } - - // 5. สร้าง orgShortName แบบปลอดภัย - const orgShortName = - [ - item.posMaster?.orgChild4?.orgChild4ShortName, - item.posMaster?.orgChild3?.orgChild3ShortName, - item.posMaster?.orgChild2?.orgChild2ShortName, - item.posMaster?.orgChild1?.orgChild1ShortName, - item.posMaster?.orgRoot?.orgRootShortName, - ].find(Boolean) ?? ""; - - // 6. หา position ที่ถูกเลือกแบบปลอดภัย - const selectedPosition = item.posMaster?.positions; - const positionName = - selectedPosition - ?.map((pos) => pos.positionName) - .filter(Boolean) - .join(", ") ?? "-"; - - // 7. สร้าง metaAct แบบปลอดภัย - const metaAct = { - profileId: item.posMasterChild.current_holderId, - dateStart: firstRef.commandDateAffect ?? null, - dateEnd: null, - position: positionName, - status: true, - commandId: firstRef.commandId ?? null, - createdUserId: req.user?.sub ?? null, - createdFullName: req.user?.name ?? null, - lastUpdateUserId: req.user?.sub ?? null, - lastUpdateFullName: req.user?.name ?? null, - createdAt: new Date(), - lastUpdatedAt: new Date(), - commandNo: firstRef.commandNo ?? null, - refCommandNo: `${firstRef.commandNo ?? ""}/${firstRef.commandYear ? Extension.ToThaiYear(firstRef.commandYear) : ""}`, - commandYear: firstRef.commandYear ? Extension.ToThaiYear(firstRef.commandYear) : null, - posNo: - orgShortName && item.posMaster?.posMasterNo - ? `${orgShortName} ${item.posMaster.posMasterNo}` - : item.posMaster?.posMasterNo ?? "-", - posNoAbb: orgShortName, - commandDateAffect: firstRef.commandDateAffect ?? null, - commandDateSign: firstRef.commandDateSign ?? null, - commandCode: firstRef.commandCode ?? null, - commandName: firstRef.commandName ?? null, - remark: firstRef.remark ?? null, - }; - - try { - // 8. ปิดสถานะรักษาการ - const existingActPositions = await this.actpositionRepository.find({ - where: { - profileId: item.posMasterChild.current_holderId, - status: true, - isDeleted: false, - }, - }); - - if (existingActPositions.length > 0) { - const updatedActPositions = existingActPositions.map((_data) => ({ - ..._data, - status: false, - dateEnd: new Date(), - })); - - await this.actpositionRepository.save(updatedActPositions); - } - - // 9. บันทึกข้อมูลใหม่ - const dataAct = new ProfileActposition(); - Object.assign(dataAct, metaAct); - - const historyAct = new ProfileActpositionHistory(); - Object.assign(historyAct, { ...dataAct, id: undefined }); - - await this.actpositionRepository.save(dataAct); - historyAct.profileActpositionId = dataAct.id; - await this.actpositionHistoryRepository.save(historyAct); - } catch (error) { - console.error(`Error processing item ${item.id}:`, error); - throw new HttpError( - HttpStatus.INTERNAL_SERVER_ERROR, - `เกิดข้อผิดพลาดในการประมวลผลรายการ ${item.id}`, - ); - } - }), - ); - - if (profileIdsToClearCache.size > 0) { - await Promise.all( - Array.from(profileIdsToClearCache).map(async (profileId) => { - const redisClient = await this.redis.createClient({ - host: REDIS_HOST, - port: REDIS_PORT, - }); - - const delAsync = promisify(redisClient.del).bind(redisClient); - await delAsync("role_" + profileId); - await delAsync("menu_" + profileId); - - redisClient.quit(); - }), - ); - } - return new HttpSuccess(); } @@ -6157,166 +5207,14 @@ export class CommandController extends Controller { }[]; }, ) { - let _posNumCodeSit: string = ""; - let _posNumCodeSitAbb: string = ""; - const _command = await this.commandRepository.findOne({ - where: { id: body.refIds.find((x) => x.commandId)?.commandId ?? "" }, + /** + * Thin wrapper — เรียก ExecuteOrgCommandService.executeCommand38Officer (C-PM-38) + * ทั้ง endpoint นี้และ consumer ใน rabbitmq ใช้ service ตัวเดียวกัน (Linear Flow) + */ + await new ExecuteOrgCommandService().executeCommand38Officer(body.refIds, { + user: { sub: req.user.sub, name: req.user.name }, + req, }); - 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 ?? ""; - } - } - await Promise.all( - body.refIds.map(async (item) => { - const posMaster = await this.posMasterRepository.findOne({ - where: { id: item.refId }, - relations: [ - "orgRoot", - "orgChild1", - "orgChild2", - "orgChild3", - "orgChild4", - "current_holder", - "current_holder.posLevel", - "current_holder.posType", - ], - }); - if (!posMaster) { - throw new HttpError(HttpStatus.BAD_REQUEST, "ไม่พบตำแหน่งดังกล่าว"); - } - if (posMaster.next_holderId != null) { - const orgRootRef = posMaster?.orgRoot ?? null; - const orgChild1Ref = posMaster?.orgChild1 ?? null; - const orgChild2Ref = posMaster?.orgChild2 ?? null; - const orgChild3Ref = posMaster?.orgChild3 ?? null; - const orgChild4Ref = posMaster?.orgChild4 ?? null; - const shortName = - posMaster != null && posMaster.orgChild4 != null - ? `${posMaster.orgChild4.orgChild4ShortName}` - : posMaster != null && posMaster.orgChild3 != null - ? `${posMaster.orgChild3.orgChild3ShortName}` - : posMaster != null && posMaster.orgChild2 != null - ? `${posMaster.orgChild2.orgChild2ShortName}` - : posMaster != null && posMaster.orgChild1 != null - ? `${posMaster.orgChild1.orgChild1ShortName}` - : posMaster != null && posMaster?.orgRoot != null - ? `${posMaster.orgRoot.orgRootShortName}` - : null; - const profile = await this.profileRepository.findOne({ - where: { id: posMaster.next_holderId }, - }); - const position = await this.positionRepository.findOne({ - where: { - posMasterId: posMaster.id, - positionIsSelected: true, - }, - relations: ["posType", "posLevel"], - }); - const dest_item = await this.salaryRepo.findOne({ - where: { profileId: profile?.id }, - order: { order: "DESC" }, - }); - const before = null; - const data = new ProfileSalary(); - data.posNumCodeSit = _posNumCodeSit; - data.posNumCodeSitAbb = _posNumCodeSitAbb; - const meta = { - profileId: profile?.id, - date: new Date(), - amount: item.amount, - commandId: item.commandId, - positionSalaryAmount: item.positionSalaryAmount, - mouthSalaryAmount: item.mouthSalaryAmount, - position: position?.positionName ?? null, - positionType: position?.posType?.posTypeName ?? null, - positionLevel: position?.posLevel?.posLevelName ?? null, - order: dest_item == null ? 1 : dest_item.order + 1, - orgRoot: orgRootRef?.orgRootName ?? null, - orgChild1: orgChild1Ref?.orgChild1Name ?? null, - orgChild2: orgChild2Ref?.orgChild2Name ?? null, - orgChild3: orgChild3Ref?.orgChild3Name ?? null, - orgChild4: orgChild4Ref?.orgChild4Name ?? null, - createdUserId: req.user.sub, - createdFullName: req.user.name, - lastUpdateUserId: req.user.sub, - lastUpdateFullName: req.user.name, - createdAt: new Date(), - lastUpdatedAt: new Date(), - commandNo: item.commandNo, - commandYear: item.commandYear, - posNo: posMaster.posMasterNo, - posNoAbb: shortName, - commandDateAffect: item.commandDateAffect, - commandDateSign: item.commandDateSign, - commandCode: item.commandCode, - commandName: item.commandName, - remark: item.remark, - }; - Object.assign(data, meta); - const history = new ProfileSalaryHistory(); - Object.assign(history, { ...data, id: undefined }); - - await this.salaryRepo.save(data, { data: req }); - setLogDataDiff(req, { before, after: data }); - history.profileSalaryId = data.id; - await this.salaryHistoryRepo.save(history, { data: req }); - - // if (profile != null) { - // profile.position = position?.positionName ?? ""; - // profile.posTypeId = position?.posTypeId ?? ""; - // profile.posLevelId = position?.posLevelId ?? ""; - // profile.lastUpdateUserId = req.user.sub; - // profile.lastUpdateFullName = req.user.name; - // profile.lastUpdatedAt = new Date(); - // await this.profileRepository.save(profile); - // } - } - }), - ); - // const posMasters = await this.posMasterRepository.find({ - // where: { id: In(body.refIds.map((x) => x.refId)) }, - // }); - // const data = posMasters.map((_data) => ({ - // ..._data, - // current_holderId: _data.next_holderId, - // next_holderId: null, - // statusReport: "PENDING", - // })); - // await this.posMasterRepository.save(data); return new HttpSuccess(); } diff --git a/src/services/ExecuteOfficerProfileService.ts b/src/services/ExecuteOfficerProfileService.ts index c9e0bacb..f06159dc 100644 --- a/src/services/ExecuteOfficerProfileService.ts +++ b/src/services/ExecuteOfficerProfileService.ts @@ -129,14 +129,23 @@ export interface ExecutionContext { /** * Service สำหรับสร้าง/อัปเดตทะเบียนประวัติข้าราชการ (Profile) หลังออกคำสั่งบรรจุ * - * ถูกออกแบบมาเพื่อแก้ปัญหา "Circular Dependency" ระหว่าง API Org กับ API บรรจุ - * โดยให้ฝั่งบรรจุส่ง resultData กลับมา แล้วฝั่ง Org ประมวลผลสร้าง profile เอง - * ที่ต้นทาง (Linear Flow) แทนการเรียกซ้อนกันกลับไปมา + * ใช้กับ commandType: C-PM-01, 02, 14 * * - endpoint /org/command/excexute/create-officer-profile เรียกผ่าน service นี้ (thin wrapper) - * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (no HTTP loopback) + * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (Linear Flow) * * Behavior ทั้งหมด preserve จาก CommandController.CreateOfficeProfileExcecute ต้นฉบับ + * + * Batch semantics: all-or-nothing — ประมวลผลทุกคนภายใต้ transaction เดียว (sequential) + * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) + * ถ้าทุกคนสำเร็จจะ return result รายงาน success count + * + * ⚠️ หมายเหตุ Keycloak: operations (createUser/addUserRoles/removeUserRoles/updateUserAttributes) + * ทำภายใน transaction เพื่อ preserve behavior เดิม — Keycloak ไม่สามารถ rollback ได้ + * ถ้า DB rollback หลังจาก Keycloak operation สำเร็จ → Keycloak จะถูกเปลี่ยนไปแล้ว + * + * Design note: แก้ปัญหา "Circular Dependency" ระหว่าง API Org กับ API บรรจุ โดยให้ฝั่งบรรจุ + * ส่ง resultData กลับมา แล้วฝั่ง Org ประมวลผลสร้าง profile เองที่ต้นทาง แทนการเรียกซ้อนกัน */ export class ExecuteOfficerProfileService { private commandRepository = AppDataSource.getRepository(Command); diff --git a/src/services/ExecuteOrgCommandService.ts b/src/services/ExecuteOrgCommandService.ts new file mode 100644 index 00000000..62ed231f --- /dev/null +++ b/src/services/ExecuteOrgCommandService.ts @@ -0,0 +1,860 @@ +import { Double, EntityManager, In, Like } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import HttpError from "../interfaces/http-error"; +import HttpStatusCode from "../interfaces/http-status"; +import Extension from "../interfaces/extension"; +import CallAPI from "../interfaces/call-api"; +import { setLogDataDiff } from "../interfaces/utils"; +import { + CreatePosMasterHistoryEmployee, + CreatePosMasterHistoryEmployeeTemp, +} from "./PositionService"; +import { + addUserRoles, + createUser, + getRoles, + getUserByUsername, + getRoleMappings, +} from "../keycloak"; +import { Command } from "../entities/Command"; +import { Profile } from "../entities/Profile"; +import { ProfileEmployee } from "../entities/ProfileEmployee"; +import { OrgRoot } from "../entities/OrgRoot"; +import { OrgRevision } from "../entities/OrgRevision"; +import { RoleKeycloak } from "../entities/RoleKeycloak"; +import { EmployeePosMaster } from "../entities/EmployeePosMaster"; +import { EmployeeTempPosMaster } from "../entities/EmployeeTempPosMaster"; +import { EmployeePosition } from "../entities/EmployeePosition"; +import { PosMaster } from "../entities/PosMaster"; +import { Position } from "../entities/Position"; +import { ProfileSalary } from "../entities/ProfileSalary"; +import { ProfileSalaryHistory } from "../entities/ProfileSalaryHistory"; +import { PosMasterAct } from "../entities/PosMasterAct"; +import { ProfileActposition } from "../entities/ProfileActposition"; +import { ProfileActpositionHistory } from "../entities/ProfileActpositionHistory"; +import { promisify } from "util"; + +const redis = require("redis"); +const REDIS_HOST = process.env.REDIS_HOST; +const REDIS_PORT = process.env.REDIS_PORT; + +/** + * Input: refIds ที่ consumer ใน rabbitmq build ขึ้น (เดิมคือ body.refIds ของ endpoint /excecute) + * ใช้กับ C-PM-21, C-PM-38, C-PM-40 + */ +export interface CommandRefItem { + refId: string; + commandId?: string | null; + amount: Double | null; + amountSpecial?: Double | null; + positionSalaryAmount: Double | null; + mouthSalaryAmount: Double | null; + commandNo: string | null; + commandYear: number; + commandDateAffect?: Date | string | null; + commandDateSign?: Date | string | null; + commandCode?: string | null; + commandName?: string | null; + remark: string | null; +} + +/** + * Context สำหรับ audit/log (เหมือน ExecuteSalaryService) + */ +export interface OrgCommandExecutionContext { + user: { sub: string; name: string }; + req?: any; +} + +/** + * Service สำหรับคำสั่งที่เดิม "ยิงเข้าตัว" (HTTP loopback เข้า org เอง) + * + * ใช้กับ commandType: + * - C-PM-21 : command21/employee/report/excecute (ลูกจ้าง → พนักงานประจำ) + * - C-PM-38 : command38/officer/report/excecute (เงินเดือน next_holder ข้าราชการ) + * - C-PM-40 : command40/officer/report/excecute (รักษาการ) + * + * - endpoint commandXX/.../excecute ทั้ง 3 เรียกผ่าน service นี้ (thin wrapper) + * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (Linear Flow / ทำต่อ) + * แทนการ PostData(path + "/excecute") ที่เป็น HTTP loopback เข้า org ตัวเอง + * + * Behavior ทั้งหมด preserve จาก CommandController ต้นฉบับ + * + * Batch semantics: all-or-nothing — ประมวลผลทุกคนภายใต้ transaction เดียว (sequential) + * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) + * + * ⚠️ หมายเหตุ side-effect ที่อยู่นอก DB transaction: + * - Keycloak operations (createUser/addUserRoles/getRoleMappings) ใน C-PM-21 ทำภายใน transaction + * เพื่อ preserve behavior เดิม — Keycloak ไม่สามารถ rollback ได้ ถ้า DB rollback หลังจากนี้ + * Keycloak จะถูกเปลี่ยนไปแล้ว + * - .NET call (C-PM-21) ทำหลัง transaction commit แล้ว เพราะ .NET ไม่สามารถ rollback ได้ + * - Redis cache clear (C-PM-40) ทำหลัง transaction commit (เป็นการ del cache key — idempotent) + * - CreatePosMasterHistoryEmployeeTemp สร้าง nested transaction ของตัวเอง (ไม่รับ manager) + */ +export class ExecuteOrgCommandService { + private commandRepository = AppDataSource.getRepository(Command); + private profileRepository = AppDataSource.getRepository(Profile); + private profileEmployeeRepository = AppDataSource.getRepository(ProfileEmployee); + private orgRootRepository = AppDataSource.getRepository(OrgRoot); + private orgRevisionRepository = AppDataSource.getRepository(OrgRevision); + private roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak); + private posMasterRepository = AppDataSource.getRepository(PosMaster); + private posMasterActRepository = AppDataSource.getRepository(PosMasterAct); + + // ───────────────────────────────────────────────────────────── + // แก้ปัญหา _posNumCodeSit/_command resolution ที่ซ้ำกันในทุก endpoint + // (เดิมอยู่ใน controller — ย้ายมานี่ ทำครั้งเดียวก่อนเข้า transaction) + // ───────────────────────────────────────────────────────────── + private async resolvePosNumCodeSit( + commandId: string | null | undefined, + ): Promise<{ command: Command | null; posNumCodeSit: string; posNumCodeSitAbb: string }> { + let posNumCodeSit = ""; + let posNumCodeSitAbb = ""; + const command = commandId + ? await this.commandRepository.findOne({ where: { id: commandId } }) + : null; + 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 ?? ""; + } + } + return { command, posNumCodeSit, posNumCodeSitAbb }; + } + + // ═══════════════════════════════════════════════════════════════ + // C-PM-21 : command21/employee/report/excecute + // ลูกจ้างชั่วคราว → พนักงานประจำ (บรรจุ) + // ═══════════════════════════════════════════════════════════════ + /** + * @returns profileEmps ที่จะส่งต่อให้ .NET (สำหรับ consumer rabbitmq เรียก .NET เอง) + * ถ้าเรียกจาก thin-wrapper endpoint จะเรียก .NET ภายใน method นี้เอง + */ + async executeCommand21Employee( + data: CommandRefItem[], + ctx: OrgCommandExecutionContext, + options?: { callDotNet?: boolean }, + ): Promise<{ profileEmps: any[] }> { + const req = ctx.req; + const callDotNet = options?.callDotNet ?? true; + const commandId = data?.find((x) => x.commandId)?.commandId ?? ""; + console.log( + `[ExecuteOrgCommandService] executeCommand21Employee — commandId: ${commandId}, count: ${data?.length ?? 0}`, + ); + + const roleKeycloak = await this.roleKeycloakRepo.findOne({ + where: { name: Like("USER") }, + }); + const { command: _command, posNumCodeSit: _posNumCodeSit, posNumCodeSitAbb: _posNumCodeSitAbb } = + await this.resolvePosNumCodeSit(commandId); + + const profileEmps: any[] = []; + + await AppDataSource.transaction(async (manager) => { + for (const item of data ?? []) { + try { + await this.processOneCommand21( + item, + ctx, + manager, + roleKeycloak, + _command, + _posNumCodeSit, + _posNumCodeSitAbb, + profileEmps, + ); + } catch (err) { + const reason = + err instanceof HttpError + ? err.message + : err instanceof Error + ? err.message + : "unexpected error"; + console.error( + `[ExecuteOrgCommandService] Failed C-PM-21, commandId=${commandId}, refId=${item.refId}: ${reason}`, + err, + ); + throw err; // → rollback ทั้ง transaction + propagate เป็น batch failure + } + } + }); + + // .NET call ทำหลัง commit (เหมือน endpoint เดิมที่เรียกหลัง Promise.all) — .NET ไม่ rollback ได้ + if (callDotNet && profileEmps.length > 0) { + await new CallAPI() + .PostData(req, "/placement/appointment/employee-appoint-21/report/excecute", { + profileEmps, + }) + .catch((error) => { + throw new Error(`Failed. Cannot update status. ${error?.message ?? ""}`); + }); + } + + console.log( + `[ExecuteOrgCommandService] Completed C-PM-21 — ${profileEmps.length} profiles sent to .NET`, + ); + return { profileEmps }; + } + + private async processOneCommand21( + item: CommandRefItem, + ctx: OrgCommandExecutionContext, + manager: EntityManager, + roleKeycloak: RoleKeycloak | null, + _command: Command | null, + _posNumCodeSit: string, + _posNumCodeSitAbb: string, + profileEmps: any[], + ): Promise { + const req = ctx.req; + + const profileEmployeeRepository = manager.getRepository(ProfileEmployee); + const employeePosMasterRepository = manager.getRepository(EmployeePosMaster); + const employeeTempPosMasterRepository = manager.getRepository(EmployeeTempPosMaster); + const employeePositionRepository = manager.getRepository(EmployeePosition); + const salaryRepo = manager.getRepository(ProfileSalary); + const salaryHistoryRepo = manager.getRepository(ProfileSalaryHistory); + + const profile = await profileEmployeeRepository.findOne({ + where: { id: item.refId }, + relations: ["roleKeycloaks"], + }); + if (!profile) { + throw new HttpError(HttpStatusCode.BAD_REQUEST, "ไม่พบ profile ดังกล่าว"); + } + const orgRevision = await this.orgRevisionRepository.findOne({ + where: { + orgRevisionIsCurrent: true, + orgRevisionIsDraft: false, + }, + }); + const _posMaster = await employeePosMasterRepository.findOne({ + where: { + orgRevisionId: orgRevision?.id, + id: profile.posmasterIdTemp, + }, + relations: { + orgRoot: true, + orgChild1: true, + orgChild2: true, + orgChild3: true, + orgChild4: true, + }, + }); + const orgRootRef = _posMaster?.orgRoot ?? null; + const orgChild1Ref = _posMaster?.orgChild1 ?? null; + const orgChild2Ref = _posMaster?.orgChild2 ?? null; + const orgChild3Ref = _posMaster?.orgChild3 ?? null; + const orgChild4Ref = _posMaster?.orgChild4 ?? null; + let orgShortName = ""; + if (_posMaster != null) { + if (_posMaster.orgChild1Id === null) { + orgShortName = _posMaster.orgRoot?.orgRootShortName; + } else if (_posMaster.orgChild2Id === null) { + orgShortName = _posMaster.orgChild1?.orgChild1ShortName; + } else if (_posMaster.orgChild3Id === null) { + orgShortName = _posMaster.orgChild2?.orgChild2ShortName; + } else if (_posMaster.orgChild4Id === null) { + orgShortName = _posMaster.orgChild3?.orgChild3ShortName; + } else { + orgShortName = _posMaster.orgChild4?.orgChild4ShortName; + } + } + const dest_item = await salaryRepo.findOne({ + where: { profileEmployeeId: item.refId }, + order: { order: "DESC" }, + }); + const before = null; + const dataSalary = new ProfileSalary(); + dataSalary.posNumCodeSit = _posNumCodeSit; + dataSalary.posNumCodeSitAbb = _posNumCodeSitAbb; + const meta = { + profileEmployeeId: profile.id, + amount: item.amount, + amountSpecial: item.amountSpecial, + positionSalaryAmount: item.positionSalaryAmount, + mouthSalaryAmount: item.mouthSalaryAmount, + position: profile.positionTemp, + positionName: profile.positionTemp, + positionType: profile.posTypeNameTemp, + positionLevel: profile.posLevelNameTemp, + order: dest_item == null ? 1 : dest_item.order + 1, + orgRoot: orgRootRef?.orgRootName ?? null, + orgChild1: orgChild1Ref?.orgChild1Name ?? null, + orgChild2: orgChild2Ref?.orgChild2Name ?? null, + orgChild3: orgChild3Ref?.orgChild3Name ?? null, + orgChild4: orgChild4Ref?.orgChild4Name ?? null, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + commandNo: item.commandNo, + commandYear: item.commandYear, + posNo: profile.posMasterNoTemp ?? "", + posNoAbb: orgShortName, + commandDateAffect: item.commandDateAffect, + commandDateSign: item.commandDateSign, + commandCode: item.commandCode, + commandName: item.commandName, + remark: item.remark, + }; + + Object.assign(dataSalary, meta); + const history = new ProfileSalaryHistory(); + Object.assign(history, { ...dataSalary, id: undefined }); + + await salaryRepo.save(dataSalary, { data: req }); + setLogDataDiff(req, { before, after: dataSalary }); + history.profileSalaryId = dataSalary.id; + await salaryHistoryRepo.save(history, { data: req }); + + const posMaster = await employeePosMasterRepository.findOne({ + where: { id: profile.posmasterIdTemp }, + relations: ["orgRoot", "orgChild1", "orgChild2", "orgChild3", "orgChild4"], + }); + if (posMaster == null) + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลตำแหน่งนี้"); + + const posMasterOld = await employeePosMasterRepository.findOne({ + where: { + current_holderId: profile.id, + orgRevisionId: posMaster.orgRevisionId, + }, + }); + if (posMasterOld != null) { + posMasterOld.current_holderId = null; + posMasterOld.lastUpdatedAt = new Date(); + } + + const positionOld = await employeePositionRepository.findOne({ + where: { + posMasterId: posMasterOld?.id, + positionIsSelected: true, + }, + }); + if (positionOld != null) { + positionOld.positionIsSelected = false; + await employeePositionRepository.save(positionOld); + } + + const checkPosition = await employeePositionRepository.find({ + where: { + posMasterId: profile.posmasterIdTemp, + positionIsSelected: true, + }, + }); + if (checkPosition.length > 0) { + const clearPosition = checkPosition.map((positions) => ({ + ...positions, + positionIsSelected: false, + })); + await employeePositionRepository.save(clearPosition); + } + + posMaster.current_holderId = profile.id; + posMaster.lastUpdatedAt = new Date(); + posMaster.next_holderId = null; + if (posMasterOld != null) { + await employeePosMasterRepository.save(posMasterOld); + await CreatePosMasterHistoryEmployee(posMasterOld.id, req, undefined, manager); + } + await employeePosMasterRepository.save(posMaster); + await CreatePosMasterHistoryEmployee(posMaster.id, req, undefined, manager); + + const clsTempPosmaster = await employeeTempPosMasterRepository.find({ + where: { + current_holderId: profile.id, + orgRevisionId: posMaster.orgRevisionId, + }, + }); + + if (clsTempPosmaster.length > 0) { + const clearTempPosmaster = clsTempPosmaster.map((posMasterTemp) => ({ + ...posMasterTemp, + current_holderId: null, + next_holderId: null, + })); + await employeeTempPosMasterRepository.save(clearTempPosmaster); + + const checkTempPosition = await employeePositionRepository.find({ + where: { + posMasterTempId: In(clearTempPosmaster.map((x) => x.id)), + positionIsSelected: true, + }, + }); + if (checkTempPosition.length > 0) { + const clearTempPosition = checkTempPosition.map((positions) => ({ + ...positions, + positionIsSelected: false, + })); + await employeePositionRepository.save(clearTempPosition); + } + await Promise.all( + clsTempPosmaster.map( + async (posMasterTemp) => + await CreatePosMasterHistoryEmployeeTemp(posMasterTemp.id, req), + ), + ); + } + + const positionNew = await employeePositionRepository.findOne({ + where: { + id: profile.positionIdTemp, + posMasterId: profile.posmasterIdTemp, + }, + }); + + if (positionNew != null) { + // Create Keycloak + const checkUser = await getUserByUsername(profile.citizenId); + if (checkUser.length == 0) { + let password = profile.citizenId; + if (profile.birthDate != null) { + const _date = new Date(profile.birthDate.toDateString()) + .getDate() + .toString() + .padStart(2, "0"); + const _month = (new Date(profile.birthDate.toDateString()).getMonth() + 1) + .toString() + .padStart(2, "0"); + const _year = new Date(profile.birthDate.toDateString()).getFullYear() + 543; + password = `${_date}${_month}${_year}`; + } + // กรอง "." ออกจาก firstName ก่อนส่งไป keycloak + const sanitizedFirstName = profile.firstName?.replace(/\./g, "") ?? ""; + const userKeycloakId = await createUser(profile.citizenId, password, { + firstName: sanitizedFirstName, + lastName: profile.lastName, + }); + const list = await getRoles(); + if (!Array.isArray(list)) + throw new Error("Failed. Cannot get role(s) data from the server."); + const result = await addUserRoles( + userKeycloakId, + list + .filter((v) => v.name === "USER") + .map((x) => ({ + id: x.id, + name: x.name, + })), + ); + profile.keycloak = + userKeycloakId && typeof userKeycloakId == "string" ? userKeycloakId : ""; + profile.roleKeycloaks = result && roleKeycloak ? [roleKeycloak] : []; + // End Create Keycloak + } else { + const rolesData = await getRoleMappings(checkUser[0].id); + if (rolesData) { + const _roleKeycloak = await this.roleKeycloakRepo.find({ + where: { name: In(rolesData.map((x: any) => x.name)) }, + }); + profile.roleKeycloaks = + _roleKeycloak && _roleKeycloak.length > 0 ? _roleKeycloak : []; + } + profile.keycloak = checkUser[0].id; + } + positionNew.positionIsSelected = true; + profile.posLevelId = positionNew.posLevelId; + profile.posTypeId = positionNew.posTypeId; + profile.position = positionNew.positionName; + profile.employeeOc = posMaster?.orgRoot?.orgRootName ?? null; + profile.positionEmployeePositionId = positionNew.positionName; + profile.statusTemp = "DONE"; + profile.employeeClass = "PERM"; + const _null: any = null; + profile.employeeWage = item.amount == null ? _null : item.amount.toString(); + profile.dateStart = _command ? _command.commandExcecuteDate : new Date(); + profile.dateAppoint = _command ? _command.commandExcecuteDate : new Date(); + profile.amount = item.amount == null ? _null : item.amount; + profile.amountSpecial = item.amountSpecial == null ? _null : item.amountSpecial; + profileEmps.push({ + profileId: profile.id, + prefix: profile.prefix, + firstName: profile.firstName, + lastName: profile.lastName, + citizenId: profile.citizenId, + root: posMaster.orgRoot.orgRootName, + rootId: posMaster.orgRootId, + rootShortName: posMaster.orgRoot.orgRootShortName, + rootDnaId: posMaster.orgRoot?.ancestorDNA ?? _null, + child1DnaId: posMaster.orgChild1?.ancestorDNA ?? _null, + child2DnaId: posMaster.orgChild2?.ancestorDNA ?? _null, + child3DnaId: posMaster.orgChild3?.ancestorDNA ?? _null, + child4DnaId: posMaster.orgChild4?.ancestorDNA ?? _null, + }); + await profileEmployeeRepository.save(profile); + await employeePositionRepository.save(positionNew); + await CreatePosMasterHistoryEmployee(posMaster.id, req, undefined, manager); + //ลบออกคนออกจากโครงสร้างลูกจ้างชั่วคราว + const posMasterTemp = await employeeTempPosMasterRepository.findOne({ + where: { + orgRevisionId: orgRevision?.id, + current_holderId: profile.id, + }, + }); + if (posMasterTemp) { + await employeeTempPosMasterRepository.update(posMasterTemp.id, { + current_holderId: _null, + }); + await CreatePosMasterHistoryEmployeeTemp(posMasterTemp.id, req); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // C-PM-38 : command38/officer/report/excecute + // เงินเดือน next_holder ของข้าราชการ + // ═══════════════════════════════════════════════════════════════ + async executeCommand38Officer( + data: CommandRefItem[], + ctx: OrgCommandExecutionContext, + ): Promise { + const req = ctx.req; + const commandId = data?.find((x) => x.commandId)?.commandId ?? ""; + console.log( + `[ExecuteOrgCommandService] executeCommand38Officer — commandId: ${commandId}, count: ${data?.length ?? 0}`, + ); + + const { posNumCodeSit: _posNumCodeSit, posNumCodeSitAbb: _posNumCodeSitAbb } = + await this.resolvePosNumCodeSit(commandId); + + await AppDataSource.transaction(async (manager) => { + for (const item of data ?? []) { + try { + await this.processOneCommand38( + item, + ctx, + manager, + _posNumCodeSit, + _posNumCodeSitAbb, + ); + } catch (err) { + const reason = + err instanceof HttpError + ? err.message + : err instanceof Error + ? err.message + : "unexpected error"; + console.error( + `[ExecuteOrgCommandService] Failed C-PM-38, commandId=${commandId}, refId=${item.refId}: ${reason}`, + err, + ); + throw err; // → rollback ทั้ง transaction + propagate เป็น batch failure + } + } + }); + + console.log(`[ExecuteOrgCommandService] Completed C-PM-38 — ${data?.length ?? 0} items`); + } + + private async processOneCommand38( + item: CommandRefItem, + ctx: OrgCommandExecutionContext, + manager: EntityManager, + _posNumCodeSit: string, + _posNumCodeSitAbb: string, + ): Promise { + const req = ctx.req; + + const posMasterRepository = manager.getRepository(PosMaster); + const profileRepository = manager.getRepository(Profile); + const positionRepository = manager.getRepository(Position); + const salaryRepo = manager.getRepository(ProfileSalary); + const salaryHistoryRepo = manager.getRepository(ProfileSalaryHistory); + + const posMaster = await posMasterRepository.findOne({ + where: { id: item.refId }, + relations: [ + "orgRoot", + "orgChild1", + "orgChild2", + "orgChild3", + "orgChild4", + "current_holder", + "current_holder.posLevel", + "current_holder.posType", + ], + }); + if (!posMaster) { + throw new HttpError(HttpStatusCode.BAD_REQUEST, "ไม่พบตำแหน่งดังกล่าว"); + } + if (posMaster.next_holderId != null) { + const orgRootRef = posMaster?.orgRoot ?? null; + const orgChild1Ref = posMaster?.orgChild1 ?? null; + const orgChild2Ref = posMaster?.orgChild2 ?? null; + const orgChild3Ref = posMaster?.orgChild3 ?? null; + const orgChild4Ref = posMaster?.orgChild4 ?? null; + const shortName = + posMaster != null && posMaster.orgChild4 != null + ? `${posMaster.orgChild4.orgChild4ShortName}` + : posMaster != null && posMaster.orgChild3 != null + ? `${posMaster.orgChild3.orgChild3ShortName}` + : posMaster != null && posMaster.orgChild2 != null + ? `${posMaster.orgChild2.orgChild2ShortName}` + : posMaster != null && posMaster.orgChild1 != null + ? `${posMaster.orgChild1.orgChild1ShortName}` + : posMaster != null && posMaster?.orgRoot != null + ? `${posMaster.orgRoot.orgRootShortName}` + : null; + const profile = await profileRepository.findOne({ + where: { id: posMaster.next_holderId }, + }); + const position = await positionRepository.findOne({ + where: { + posMasterId: posMaster.id, + positionIsSelected: true, + }, + relations: ["posType", "posLevel"], + }); + const dest_item = await salaryRepo.findOne({ + where: { profileId: profile?.id }, + order: { order: "DESC" }, + }); + const before = null; + const dataSalary = new ProfileSalary(); + dataSalary.posNumCodeSit = _posNumCodeSit; + dataSalary.posNumCodeSitAbb = _posNumCodeSitAbb; + const meta = { + profileId: profile?.id, + date: new Date(), + amount: item.amount, + commandId: item.commandId, + positionSalaryAmount: item.positionSalaryAmount, + mouthSalaryAmount: item.mouthSalaryAmount, + position: position?.positionName ?? null, + positionType: position?.posType?.posTypeName ?? null, + positionLevel: position?.posLevel?.posLevelName ?? null, + order: dest_item == null ? 1 : dest_item.order + 1, + orgRoot: orgRootRef?.orgRootName ?? null, + orgChild1: orgChild1Ref?.orgChild1Name ?? null, + orgChild2: orgChild2Ref?.orgChild2Name ?? null, + orgChild3: orgChild3Ref?.orgChild3Name ?? null, + orgChild4: orgChild4Ref?.orgChild4Name ?? null, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + commandNo: item.commandNo, + commandYear: item.commandYear, + posNo: posMaster.posMasterNo, + posNoAbb: shortName, + commandDateAffect: item.commandDateAffect, + commandDateSign: item.commandDateSign, + commandCode: item.commandCode, + commandName: item.commandName, + remark: item.remark, + }; + Object.assign(dataSalary, meta); + const history = new ProfileSalaryHistory(); + Object.assign(history, { ...dataSalary, id: undefined }); + + await salaryRepo.save(dataSalary, { data: req }); + setLogDataDiff(req, { before, after: dataSalary }); + history.profileSalaryId = dataSalary.id; + await salaryHistoryRepo.save(history, { data: req }); + } + } + + // ═══════════════════════════════════════════════════════════════ + // C-PM-40 : command40/officer/report/excecute + // รักษาการ (ProfileActposition) + // ═══════════════════════════════════════════════════════════════ + async executeCommand40Officer( + data: CommandRefItem[], + ctx: OrgCommandExecutionContext, + ): Promise { + const commandId = data?.find((x) => x.commandId)?.commandId ?? ""; + console.log( + `[ExecuteOrgCommandService] executeCommand40Officer — commandId: ${commandId}, count: ${data?.length ?? 0}`, + ); + + // 3. ตรวจสอบว่ามี data[0] หรือไม่ + const firstRef = data[0]; + if (!firstRef) { + throw new HttpError(HttpStatusCode.BAD_REQUEST, "ไม่พบข้อมูล refIds"); + } + + const profileIdsToClearCache = new Set(); + + await AppDataSource.transaction(async (manager) => { + // 1. Bulk update status + await manager.getRepository(PosMasterAct).update( + { id: In(data.map((x) => x.refId)) }, + { statusReport: "DONE" }, + ); + + // 2. ดึงข้อมูลครบทุก relation ที่จำเป็น + const posMasters = await manager.getRepository(PosMasterAct).find({ + where: { id: In(data.map((x) => x.refId)) }, + relations: [ + "posMasterChild", + "posMasterChild.current_holder", + "posMaster", + "posMaster.current_holder", + "posMaster.positions", + "posMaster.orgRoot", + "posMaster.orgChild1", + "posMaster.orgChild2", + "posMaster.orgChild3", + "posMaster.orgChild4", + ], + }); + + for (const item of posMasters) { + try { + // 4. ตรวจสอบข้อมูลที่จำเป็นทั้งหมด + if (!item.posMasterChild?.current_holderId || !item.posMaster) { + console.warn(`ข้ามรายการ ${item.id}: ข้อมูลไม่ครบ`); + continue; + } + + if (item.posMasterChild.current_holderId) { + profileIdsToClearCache.add(item.posMasterChild.current_holderId); + } + + // 5. สร้าง orgShortName แบบปลอดภัย + const orgShortName = + [ + item.posMaster?.orgChild4?.orgChild4ShortName, + item.posMaster?.orgChild3?.orgChild3ShortName, + item.posMaster?.orgChild2?.orgChild2ShortName, + item.posMaster?.orgChild1?.orgChild1ShortName, + item.posMaster?.orgRoot?.orgRootShortName, + ].find(Boolean) ?? ""; + + // 6. หา position ที่ถูกเลือกแบบปลอดภัย + const selectedPosition = item.posMaster?.positions; + const positionName = + selectedPosition + ?.map((pos) => pos.positionName) + .filter(Boolean) + .join(", ") ?? "-"; + + // 7. สร้าง metaAct แบบปลอดภัย + const metaAct = { + profileId: item.posMasterChild.current_holderId, + dateStart: firstRef.commandDateAffect ?? null, + dateEnd: null, + position: positionName, + status: true, + commandId: firstRef.commandId ?? null, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + commandNo: firstRef.commandNo ?? null, + refCommandNo: `${firstRef.commandNo ?? ""}/${firstRef.commandYear ? Extension.ToThaiYear(firstRef.commandYear) : ""}`, + commandYear: firstRef.commandYear ? Extension.ToThaiYear(firstRef.commandYear) : null, + posNo: + orgShortName && item.posMaster?.posMasterNo + ? `${orgShortName} ${item.posMaster.posMasterNo}` + : item.posMaster?.posMasterNo ?? "-", + posNoAbb: orgShortName, + commandDateAffect: firstRef.commandDateAffect ?? null, + commandDateSign: firstRef.commandDateSign ?? null, + commandCode: firstRef.commandCode ?? null, + commandName: firstRef.commandName ?? null, + remark: firstRef.remark ?? null, + }; + + // 8. ปิดสถานะรักษาการ + const actpositionRepository = manager.getRepository(ProfileActposition); + const actpositionHistoryRepository = manager.getRepository(ProfileActpositionHistory); + + const existingActPositions = await actpositionRepository.find({ + where: { + profileId: item.posMasterChild.current_holderId, + status: true, + isDeleted: false, + }, + }); + + if (existingActPositions.length > 0) { + const updatedActPositions = existingActPositions.map((_data) => ({ + ..._data, + status: false, + dateEnd: new Date(), + })); + + await actpositionRepository.save(updatedActPositions); + } + + // 9. บันทึกข้อมูลใหม่ + const dataAct = new ProfileActposition(); + Object.assign(dataAct, metaAct); + + const historyAct = new ProfileActpositionHistory(); + Object.assign(historyAct, { ...dataAct, id: undefined }); + + await actpositionRepository.save(dataAct); + historyAct.profileActpositionId = dataAct.id; + await actpositionHistoryRepository.save(historyAct); + } catch (error) { + console.error(`Error processing item ${item.id}:`, error); + throw new HttpError( + HttpStatusCode.INTERNAL_SERVER_ERROR, + `เกิดข้อผิดพลาดในการประมวลผลรายการ ${item.id}`, + ); + } + } + }); + + // Redis cache clear ทำหลัง commit (del cache key — idempotent) + if (profileIdsToClearCache.size > 0) { + await Promise.all( + Array.from(profileIdsToClearCache).map(async (profileId) => { + const redisClient = await redis.createClient({ + host: REDIS_HOST, + port: REDIS_PORT, + }); + + const delAsync = promisify(redisClient.del).bind(redisClient); + await delAsync("role_" + profileId); + await delAsync("menu_" + profileId); + + redisClient.quit(); + }), + ); + } + + console.log(`[ExecuteOrgCommandService] Completed C-PM-40 — ${data?.length ?? 0} items`); + } +} diff --git a/src/services/ExecuteSalaryEmployeeLeaveService.ts b/src/services/ExecuteSalaryEmployeeLeaveService.ts index 46952847..95bfa948 100644 --- a/src/services/ExecuteSalaryEmployeeLeaveService.ts +++ b/src/services/ExecuteSalaryEmployeeLeaveService.ts @@ -75,6 +75,10 @@ export interface SalaryEmployeeLeaveExecutionContext { * Batch semantics: all-or-nothing — ประมวลผลทุกคนภายใต้ transaction เดียว (sequential) * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) * ถ้าทุกคนสำเร็จจะ return result รายงาน success count + * + * ⚠️ หมายเหตุ Keycloak: operation (deleteUser) ทำภายใน transaction เพื่อ preserve behavior + * เดิม — Keycloak ไม่สามารถ rollback ได้ ถ้า DB rollback หลังจาก Keycloak operation สำเร็จ + * → Keycloak จะถูกเปลี่ยนไปแล้ว */ export class ExecuteSalaryEmployeeLeaveService { private commandRepository = AppDataSource.getRepository(Command); diff --git a/src/services/ExecuteSalaryLeaveDisciplineService.ts b/src/services/ExecuteSalaryLeaveDisciplineService.ts new file mode 100644 index 00000000..99a1dffc --- /dev/null +++ b/src/services/ExecuteSalaryLeaveDisciplineService.ts @@ -0,0 +1,615 @@ +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 { ProfileEmployee } from "../entities/ProfileEmployee"; +import { ProfileSalary } from "../entities/ProfileSalary"; +import { ProfileSalaryHistory } from "../entities/ProfileSalaryHistory"; +import { ProfileDiscipline } from "../entities/ProfileDiscipline"; +import { ProfileDisciplineHistory } from "../entities/ProfileDisciplineHistory"; +import { OrgRoot } from "../entities/OrgRoot"; +import { OrgRevision } from "../entities/OrgRevision"; +import { EmployeePosMaster } from "../entities/EmployeePosMaster"; +import { Command } from "../entities/Command"; +import { + checkCommandType, + removePostMasterAct, + removeProfileInOrganize, + setLogDataDiff, +} from "../interfaces/utils"; +import { + CreatePosMasterHistoryEmployee, + CreatePosMasterHistoryOfficer, +} from "./PositionService"; +import { deleteUser } from "../keycloak"; + +/** + * Input: ข้อมูล 1 คนสำหรับ endpoint excexute/salary-leave-discipline + * (C-PM-19, 20, 25, 26, 27, 28, 29, 30, 31, 32 — คำสั่งวินัย ข้าราชการ/ลูกจ้าง) + * + * profileType "OFFICER" → ข้าราชการ, ค่าอื่น/null → ลูกจ้าง + */ +export interface SalaryLeaveDisciplineItem { + profileId: string; + profileType?: string | null; + isLeave: boolean | null; + leaveReason?: string | null; + dateLeave?: Date | string | null; + detail?: string | null; + level?: string | null; + unStigma?: string | null; + commandId?: string | null; + amount?: Double | null; + amountSpecial?: Double | null; + positionSalaryAmount?: Double | null; + mouthSalaryAmount?: Double | null; + isGovernment?: boolean | null; + commandNo: string | null; + commandYear: number | null; + commandDateAffect?: Date | string | null; + commandDateSign?: Date | string | null; + commandCode?: string | null; + commandName?: string | null; + remark: string | null; + orgRoot?: string | null; + orgChild1?: string | null; + orgChild2?: string | null; + orgChild3?: string | null; + orgChild4?: string | null; + posNo?: string | null; + posNoAbb?: string | null; +} + +/** + * Context สำหรับ audit/log + */ +export interface SalaryLeaveDisciplineExecutionContext { + user: { sub: string; name: string }; + req?: any; +} + +/** + * Service สำหรับสร้าง ProfileSalary + ProfileDiscipline + handle leave ของคำสั่งวินัย + * + * ใช้กับ commandType: C-PM-19, 20, 25, 26, 27, 28, 29, 30, 31, 32 + * + * - endpoint /org/command/excexute/salary-leave-discipline เรียกผ่าน service นี้ (thin wrapper) + * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (Linear Flow) + * + * Behavior ทั้งหมด preserve จาก CommandController.newSalaryAndUpdateLeaveDiscipline ต้นฉบับ + * รวมถึงกรณี OFFICER ที่การ save ProfileSalary + ProfileDiscipline ถูก comment out ไว้ + * (เก็บไว้เพื่อ preserve behavior เดิม — มีเพียง EMPLOYEE เท่านั้นที่ save จริง) + * + * Batch semantics: all-or-nothing — ประมวลผลทุกคนภายใต้ transaction เดียว (sequential) + * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) + * ถ้าทุกคนสำเร็จจะ return result รายงาน success count + * + * ⚠️ หมายเหตุ Keycloak: operation (deleteUser) ทำภายใน transaction เพื่อ preserve behavior + * เดิม — Keycloak ไม่สามารถ rollback ได้ ถ้า DB rollback หลังจาก Keycloak operation สำเร็จ + * → Keycloak จะถูกเปลี่ยนไปแล้ว + */ +export class ExecuteSalaryLeaveDisciplineService { + private commandRepository = AppDataSource.getRepository(Command); + private profileRepository = AppDataSource.getRepository(Profile); + private orgRootRepository = AppDataSource.getRepository(OrgRoot); + + /** + * ประมวลผลคำสั่งวินัยทั้ง batch + * + * @returns สรุปผล success/failure ต่อคน + */ + async executeSalaryLeaveDiscipline( + data: SalaryLeaveDisciplineItem[], + ctx: SalaryLeaveDisciplineExecutionContext, + ): Promise { + const commandId = data?.find((x) => x.commandId)?.commandId ?? "unknown"; + const commandCode = data?.find((x) => x.commandCode)?.commandCode ?? "unknown"; + console.log( + `[ExecuteSalaryLeaveDisciplineService] Starting executeSalaryLeaveDiscipline — commandCode: ${commandCode}, commandId: ${commandId}`, + ); + console.log(`[ExecuteSalaryLeaveDisciplineService] 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.dateLeave = toDate(it.dateLeave); + it.commandDateAffect = toDate(it.commandDateAffect); + it.commandDateSign = toDate(it.commandDateSign); + } + + let _posNumCodeSit: string = ""; + let _posNumCodeSitAbb: string = ""; + const _command = await this.commandRepository.findOne({ + relations: ["commandType"], + 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, _command, _posNumCodeSit, _posNumCodeSitAbb); + } catch (err) { + const reason = + err instanceof HttpError + ? err.message + : err instanceof Error + ? err.message + : "unexpected error"; + console.error( + `[ExecuteSalaryLeaveDisciplineService] Failed commandCode=${commandCode}, commandId=${commandId}, profileId=${item.profileId}: ${reason}`, + err, + ); + throw err; // → rollback ทั้ง transaction + propagate เป็น batch failure + } + } + }); + } + + /** + * ประมวลผล 1 คน ภายใน transaction เดียว (manager) + * ทุก save ใช้ manager.getRepository(...) เพื่อให้อยู่ใน transaction เดียวกัน + * ถ้า throw ระหว่างทาง → rollback ทั้งหมดของคนนี้ + ทั้ง batch (กัน partial commit) + */ + private async processOne( + item: SalaryLeaveDisciplineItem, + ctx: SalaryLeaveDisciplineExecutionContext, + manager: EntityManager, + _command: Command | null, + _posNumCodeSit: string, + _posNumCodeSitAbb: string, + ): Promise { + const req = ctx.req; + + const profileRepository = manager.getRepository(Profile); + const profileEmployeeRepository = manager.getRepository(ProfileEmployee); + const salaryRepo = manager.getRepository(ProfileSalary); + const salaryHistoryRepo = manager.getRepository(ProfileSalaryHistory); + const disciplineRepository = manager.getRepository(ProfileDiscipline); + const disciplineHistoryRepository = manager.getRepository(ProfileDisciplineHistory); + const orgRevisionRepo = manager.getRepository(OrgRevision); + const employeePosMasterRepository = manager.getRepository(EmployeePosMaster); + + let _commandYear = item.commandYear; + if (item.commandYear) { + _commandYear = item.commandYear > 2500 ? item.commandYear : item.commandYear + 543; + } + + const orgRevision = await orgRevisionRepo.findOne({ + where: { + orgRevisionIsCurrent: true, + orgRevisionIsDraft: false, + }, + }); + + let orgRootRef: any = null; + let orgChild1Ref: any = null; + let orgChild2Ref: any = null; + let orgChild3Ref: any = null; + let orgChild4Ref: any = null; + + const code = _command?.commandType?.code; + + // ═══════════════════════════════════════════════════════════ + // OFFICER (ข้าราชการ) + // ═══════════════════════════════════════════════════════════ + if (item.profileType && item.profileType.trim().toUpperCase() == "OFFICER") { + const profile: any = await profileRepository.findOne({ + relations: [ + "posLevel", + "posType", + "current_holders", + "current_holders.orgRoot", + "current_holders.orgChild1", + "current_holders.orgChild2", + "current_holders.orgChild3", + "current_holders.orgChild4", + "current_holders.positions", + "current_holders.positions.posExecutive", + "roleKeycloaks", + ], + where: { id: item.profileId }, + }); + if (!profile) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); + } + const lastSalary = await salaryRepo.findOne({ + where: { profileId: item.profileId }, + select: ["order"], + order: { order: "DESC" }, + }); + const nextOrder = lastSalary ? lastSalary.order + 1 : 1; + + //ลบตำแหน่งที่รักษาการแทน (await + ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน) + if (code && ["C-PM-19", "C-PM-20"].includes(code)) { + await removePostMasterAct(profile.id, manager); + } + + const orgRevisionRef = + profile?.current_holders?.find((x: any) => x.orgRevisionId == orgRevision?.id) ?? null; + orgRootRef = orgRevisionRef?.orgRoot ?? null; + orgChild1Ref = orgRevisionRef?.orgChild1 ?? null; + orgChild2Ref = orgRevisionRef?.orgChild2 ?? null; + orgChild3Ref = orgRevisionRef?.orgChild3 ?? null; + orgChild4Ref = orgRevisionRef?.orgChild4 ?? null; + + const position = + profile.current_holders + .filter((x: any) => x.orgRevisionId == orgRevision?.id)[0] + ?.positions?.filter((pos: any) => pos.positionIsSelected === true)[0] ?? null; + + // ประวัติตำแหน่ง + const data = new ProfileSalary(); + data.posNumCodeSit = _posNumCodeSit; + data.posNumCodeSitAbb = _posNumCodeSitAbb; + const meta = { + profileId: profile.id, + commandId: item.commandId, + position: profile.position, + positionName: profile.position, + positionType: profile?.posType?.posTypeName ?? null, + positionLevel: profile?.posLevel?.posLevelName ?? null, + positionExecutive: position?.posExecutive?.posExecutiveName ?? null, + amount: item.amount ? item.amount : null, + positionSalaryAmount: item.positionSalaryAmount ? item.positionSalaryAmount : null, + amountSpecial: item.amountSpecial ? item.amountSpecial : null, + mouthSalaryAmount: item.mouthSalaryAmount ? item.mouthSalaryAmount : null, + order: nextOrder, + orgRoot: item.orgRoot, + orgChild1: item.orgChild1, + orgChild2: item.orgChild2, + orgChild3: item.orgChild3, + orgChild4: item.orgChild4, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + dateGovernment: item.commandDateAffect ?? new Date(), + isGovernment: item.isGovernment, + commandNo: item.commandNo, + commandYear: item.commandYear, + posNo: item.posNo, + posNoAbb: item.posNoAbb, + commandDateAffect: item.commandDateAffect, + commandDateSign: item.commandDateSign, + commandCode: item.commandCode, + commandName: item.commandName, + remark: item.remark, + }; + Object.assign(data, meta); + const history = new ProfileSalaryHistory(); + Object.assign(history, { ...data, id: undefined }); + // ── preserve: OFFICER branch ไม่ save ProfileSalary (comment ตามต้นฉบับ) ── + // await salaryRepo.save(data, { data: req }); + // history.profileSalaryId = data.id; + // await salaryHistoryRepo.save(history, { data: req }); + + // ประวัติวินัย + const dataDis = new ProfileDiscipline(); + const metaDis = { + date: item.commandDateAffect, + refCommandDate: item.commandDateSign, + refCommandNo: `${item.commandNo}/${item.commandYear}`, + refCommandId: item.commandId, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + }; + Object.assign(dataDis, { ...item, ...metaDis }); + const historyDis = new ProfileDisciplineHistory(); + Object.assign(historyDis, { ...dataDis, id: undefined }); + // ── preserve: OFFICER branch ไม่ save ProfileDiscipline (comment ตามต้นฉบับ) ── + // await disciplineRepository.save(dataDis, { data: req }); + // historyDis.profileDisciplineId = dataDis.id; + // await disciplineHistoryRepository.save(historyDis, { data: req }); + + // ทะเบียนประวัติ + if (item.isLeave != null) { + const _profile: any = await profileRepository.findOne({ + where: { id: item.profileId }, + relations: ["roleKeycloaks"], + }); + if (!_profile) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); + } + const _null: any = null; + _profile.isLeave = item.isLeave; + _profile.leaveReason = item.leaveReason ?? _null; + _profile.dateLeave = item.dateLeave ?? _null; + _profile.lastUpdateUserId = ctx.user.sub; + _profile.lastUpdateFullName = ctx.user.name; + _profile.lastUpdatedAt = new Date(); + if (item.isLeave == true) { + if (orgRevisionRef) { + // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน + await CreatePosMasterHistoryOfficer(orgRevisionRef.id, req, "DELETE", null, manager); + } + // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน + await removeProfileInOrganize(_profile.id, "OFFICER", manager); + } + const clearProfile = await checkCommandType(String(item.commandId)); + if (clearProfile.status) { + if ( + _profile.keycloak != null && + _profile.keycloak != "" && + _profile.isDelete === false + ) { + // Keycloak ทำภายใน transaction — ไม่สามารถ rollback ได้ (ดู docstring ของ class) + const delUserKeycloak = await deleteUser(_profile.keycloak); + if (delUserKeycloak) { + // Task #228 + // _profile.keycloak = _null; + _profile.roleKeycloaks = []; + _profile.isActive = false; + _profile.isDelete = true; + } + } + _profile.leaveCommandId = item.commandId ?? _null; + _profile.leaveCommandNo = `${item.commandNo}/${_commandYear}`; + _profile.leaveRemark = clearProfile.leaveRemark ?? _null; + _profile.leaveDate = item.commandDateAffect ?? _null; + _profile.leaveType = clearProfile.LeaveType ?? _null; + //ออกจากราชการ ไม่ต้องลบตำแหน่งในทะเบียน (issue #1516) + // _profile.position = _null; + // _profile.posTypeId = _null; + // _profile.posLevelId = _null; + } + await profileRepository.save(_profile, { data: req }); + setLogDataDiff(req, { before: null, after: _profile }); + } + } + // ═══════════════════════════════════════════════════════════ + // EMPLOYEE (ลูกจ้าง) + // ═══════════════════════════════════════════════════════════ + else { + const profile: any = await profileEmployeeRepository.findOne({ + relations: [ + "posLevel", + "posType", + "current_holders", + "current_holders.orgRoot", + "current_holders.orgChild1", + "current_holders.orgChild2", + "current_holders.orgChild3", + "current_holders.orgChild4", + "roleKeycloaks", + ], + where: { id: item.profileId }, + }); + if (!profile) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); + } + const lastSalary = await salaryRepo.findOne({ + where: { profileEmployeeId: item.profileId }, + select: ["order"], + order: { order: "DESC" }, + }); + const nextOrder = lastSalary ? lastSalary.order + 1 : 1; + const orgRevisionRef = + profile?.current_holders?.find((x: any) => x.orgRevisionId == orgRevision?.id) ?? null; + orgRootRef = orgRevisionRef?.orgRoot ?? null; + orgChild1Ref = orgRevisionRef?.orgChild1 ?? null; + orgChild2Ref = orgRevisionRef?.orgChild2 ?? null; + orgChild3Ref = orgRevisionRef?.orgChild3 ?? null; + orgChild4Ref = orgRevisionRef?.orgChild4 ?? null; + + // ประวัติตำแหน่ง + const data = new ProfileSalary(); + data.posNumCodeSit = _posNumCodeSit; + data.posNumCodeSitAbb = _posNumCodeSitAbb; + const meta = { + profileEmployeeId: profile.id, + commandId: item.commandId, + position: profile.position, + positionName: profile.position, + positionType: profile?.posType?.posTypeName ?? null, + positionLevel: + profile?.posType && profile?.posLevel + ? `${profile?.posType?.posTypeShortName} ${profile?.posLevel?.posLevelName}` + : null, + amount: item.amount ? item.amount : null, + positionSalaryAmount: item.positionSalaryAmount ? item.positionSalaryAmount : null, + mouthSalaryAmount: item.mouthSalaryAmount ? item.mouthSalaryAmount : null, + order: nextOrder, + orgRoot: item.orgRoot, + orgChild1: item.orgChild1, + orgChild2: item.orgChild2, + orgChild3: item.orgChild3, + orgChild4: item.orgChild4, + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + dateGovernment: item.commandDateAffect ?? new Date(), + isGovernment: item.isGovernment, + commandNo: item.commandNo, + commandYear: item.commandYear, + posNo: item.posNo, + posNoAbb: item.posNoAbb, + commandDateAffect: item.commandDateAffect, + commandDateSign: item.commandDateSign, + commandCode: item.commandCode, + commandName: item.commandName, + remark: item.remark, + }; + Object.assign(data, meta); + const history = new ProfileSalaryHistory(); + Object.assign(history, { ...data, id: undefined }); + await salaryRepo.save(data, { data: req }); + setLogDataDiff(req, { before: null, after: data }); + history.profileSalaryId = data.id; + await salaryHistoryRepo.save(history, { data: req }); + + // ประวัติวินัย + const dataDis = new ProfileDiscipline(); + const metaDis = { + createdUserId: ctx.user.sub, + createdFullName: ctx.user.name, + lastUpdateUserId: ctx.user.sub, + lastUpdateFullName: ctx.user.name, + createdAt: new Date(), + lastUpdatedAt: new Date(), + }; + Object.assign(dataDis, { + ...item, + ...metaDis, + date: item.commandDateAffect, + refCommandDate: item.commandDateSign, + refCommandNo: item.commandNo, + profileEmployeeId: item.profileId, + profileId: undefined, + }); + const historyDis = new ProfileDisciplineHistory(); + Object.assign(historyDis, { ...dataDis, id: undefined }); + await disciplineRepository.save(dataDis, { data: req }); + setLogDataDiff(req, { before: null, after: dataDis }); + historyDis.profileDisciplineId = dataDis.id; + await disciplineHistoryRepository.save(historyDis, { data: req }); + + // ทะเบียนประวัติ + if (item.isLeave != null) { + const _profile: any = await profileEmployeeRepository.findOne({ + where: { id: item.profileId }, + relations: ["roleKeycloaks"], + }); + if (!_profile) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบข้อมูลทะเบียนประวัตินี้"); + } + const _null: any = null; + _profile.isLeave = item.isLeave; + _profile.leaveReason = item.leaveReason ?? _null; + _profile.dateLeave = item.dateLeave ?? _null; + _profile.lastUpdateUserId = ctx.user.sub; + _profile.lastUpdateFullName = ctx.user.name; + _profile.lastUpdatedAt = new Date(); + if (item.isLeave == true) { + // บันทึกประวัติก่อนลบตำแหน่ง + const curRevision = await orgRevisionRepo.findOne({ + where: { orgRevisionIsCurrent: true, orgRevisionIsDraft: false }, + }); + if (curRevision) { + const curPosMaster = await employeePosMasterRepository.findOne({ + where: { + current_holderId: _profile.id, + orgRevisionId: curRevision.id, + }, + }); + if (curPosMaster) { + // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน + await CreatePosMasterHistoryEmployee(curPosMaster.id, req, "DELETE", manager); + } + } + // ส่ง manager เข้าไปเพื่อให้อยู่ใน transaction เดียวกัน + await removeProfileInOrganize(_profile.id, "EMPLOYEE", manager); + } + const clearProfile = await checkCommandType(String(item.commandId)); + if (clearProfile.status) { + if ( + _profile.keycloak != null && + _profile.keycloak != "" && + _profile.isDelete === false + ) { + // Keycloak deleteUser ทำภายใน transaction — ถ้า DB rollback หลังจากนี้ Keycloak จะถูกลบไปแล้ว + // (Keycloak ไม่สามารถ rollback ได้) + const delUserKeycloak = await deleteUser(_profile.keycloak); + if (delUserKeycloak) { + // Task #228 + // _profile.keycloak = _null; + _profile.roleKeycloaks = []; + _profile.isActive = false; + _profile.isDelete = true; + } + } + _profile.leaveCommandId = item.commandId ?? _null; + _profile.leaveCommandNo = `${item.commandNo}/${_commandYear}`; + _profile.leaveRemark = clearProfile.leaveRemark ?? _null; + _profile.leaveDate = item.commandDateAffect ?? _null; + _profile.leaveType = clearProfile.LeaveType ?? _null; + //ออกจากราชการ ไม่ต้องลบตำแหน่งในทะเบียน (issue #1516) + // _profile.position = _null; + // _profile.posTypeId = _null; + // _profile.posLevelId = _null; + } + await profileEmployeeRepository.save(_profile, { data: req }); + setLogDataDiff(req, { before: null, after: _profile }); + } + } + + // Task #2190 (preserve: organizeName computed แต่ยังไม่ได้ใช้ในต้นฉบับ — เก็บไว้ตาม behavior เดิม) + if (_command && ["C-PM-19", "C-PM-20"].includes(_command.commandType.code)) { + let organizeName = ""; + if (orgRootRef) { + const names = [ + orgChild4Ref?.orgChild4Name, + orgChild3Ref?.orgChild3Name, + orgChild2Ref?.orgChild2Name, + orgChild1Ref?.orgChild1Name, + orgRootRef?.orgRootName, + ].filter(Boolean); + organizeName = names.join(" "); + } + } + + console.log( + `[ExecuteSalaryLeaveDisciplineService] Completed processOne — profileId: ${item.profileId}`, + ); + } +} diff --git a/src/services/ExecuteSalaryService.ts b/src/services/ExecuteSalaryService.ts index d375d9db..7bc07823 100644 --- a/src/services/ExecuteSalaryService.ts +++ b/src/services/ExecuteSalaryService.ts @@ -70,7 +70,7 @@ export interface SalaryExecutionContext { /** * Service สำหรับสร้าง ProfileSalary ของข้าราชการ + handle leave/ออกจากราชการ/ช่วยราชการ * - * ใช้กับ commandType: C-PM-13 (โอน), C-PM-15 (ช่วยราชการ), C-PM-16 (เกษียณ) + * ใช้กับ commandType: C-PM-13, 15, 16 * * - endpoint /org/command/excexute/salary เรียกผ่าน service นี้ (thin wrapper) * - consumer ใน rabbitmq handler เรียกผ่าน service นี้โดยตรง (Linear Flow) @@ -81,7 +81,9 @@ export interface SalaryExecutionContext { * ถ้าคนใด throw จะ rollback ทั้ง batch และ propagate error ออกไป (ล้มเหลวทั้งหมด) * ถ้าทุกคนสำเร็จจะ return result รายงาน success count * - * Keycloak operations (deleteUser) ทำก่อนเข้า transaction เพราะไม่สามารถ rollback ได้ + * ⚠️ หมายเหตุ Keycloak: operation (deleteUser) ทำภายใน transaction เพื่อ preserve behavior + * เดิม — Keycloak ไม่สามารถ rollback ได้ ถ้า DB rollback หลังจาก Keycloak operation สำเร็จ + * → Keycloak จะถูกเปลี่ยนไปแล้ว */ export class ExecuteSalaryService { private commandRepository = AppDataSource.getRepository(Command); diff --git a/src/services/rabbitmq.ts b/src/services/rabbitmq.ts index d84a3eed..f662e247 100644 --- a/src/services/rabbitmq.ts +++ b/src/services/rabbitmq.ts @@ -35,6 +35,8 @@ import { ExecuteSalaryCurrentService } from "./ExecuteSalaryCurrentService"; import { ExecuteSalaryEmployeeCurrentService } from "./ExecuteSalaryEmployeeCurrentService"; import { ExecuteSalaryLeaveService } from "./ExecuteSalaryLeaveService"; import { ExecuteSalaryEmployeeLeaveService } from "./ExecuteSalaryEmployeeLeaveService"; +import { ExecuteSalaryLeaveDisciplineService } from "./ExecuteSalaryLeaveDisciplineService"; +import { ExecuteOrgCommandService } from "./ExecuteOrgCommandService"; const redis = require("redis"); const REDIS_HOST = process.env.REDIS_HOST; @@ -335,6 +337,9 @@ async function handler(msg: amqp.ConsumeMessage): Promise { // - ExecuteSalaryService : C-PM-13, 15, 16 (ให้โอน/ให้ช่วยราชการ/ให้กลับเข้าราชการ) // - ExecuteSalaryLeaveService : C-PM-08, 09, 17, 18, 41, 48 (ข้าราชการ leave/กลับเข้าราชการ) // - ExecuteSalaryEmployeeLeaveService : C-PM-23, 42, 43 (ลูกจ้าง leave) + // - ExecuteSalaryLeaveDisciplineService : C-PM-19, 20, 25, 26, 27, 28, 29, 30, 31, 32 (คำสั่งวินัย) + // - ExecuteOrgCommandService : C-PM-21, 38, 40 (org-self — path ชี้กลับ org เอง + // เรียก Service ตรงๆ ไม่ผ่าน HTTP loopback เพราะ PostData(path+"/excecute") = ยิงเข้าตัว) // - คำสั่งอื่น ยังใช้ Circular Flow เดิม // ───────────────────────────────────────────────────────────── const code = command.commandType?.code; @@ -344,17 +349,46 @@ async function handler(msg: amqp.ConsumeMessage): Promise { const isSalary = ["C-PM-13", "C-PM-15", "C-PM-16"].includes(code); const isSalaryLeave = ["C-PM-08", "C-PM-09", "C-PM-17", "C-PM-18", "C-PM-41", "C-PM-48"].includes(code); const isSalaryEmployeeLeave = ["C-PM-23", "C-PM-42", "C-PM-43"].includes(code); + const isSalaryLeaveDiscipline = ["C-PM-19", "C-PM-20", "C-PM-25", "C-PM-26", "C-PM-27", "C-PM-28", + "C-PM-29", "C-PM-30", "C-PM-31", "C-PM-32", + ].includes(code); + // C-PM-21/38/40: path ชี้กลับ org เอง (ไม่ใช่ .NET) → ต้องเรียก Service ตรงๆ ไม่ผ่าน loopback + const isCommand21 = code === "C-PM-21"; + const isCommand38 = code === "C-PM-38"; + const isCommand40 = code === "C-PM-40"; + const isOrgSelfLinear = isCommand21 || isCommand38 || isCommand40; const isLinearFlow = isOfficerProfile || isSalaryCurrent || isSalaryEmployeeCurrent || isSalary || isSalaryLeave || - isSalaryEmployeeLeave; + isSalaryEmployeeLeave || + isSalaryLeaveDiscipline; - if (isLinearFlow) { + // Org-self (C-PM-21/38/40): เรียก Service ตรงๆ (Linear Flow / ทำต่อ) ไม่ผ่าน HTTP loopback + // เพราะ path ของ command เหล่านี้ชี้กลับ org เอง → PostData(path + "/excecute") = ยิงเข้าตัว + if (isOrgSelfLinear) { + console.log(`[AMQ] Linear Flow org-self (${code}) — เรียก Service ตรงๆ (no loopback)`); + const pseudoReq = { headers: { authorization: token }, user }; + const ctx = { + user: { sub: user?.sub ?? "system", name: user?.name ?? "System" }, + req: pseudoReq, + }; + const flatRefIds = chunks.flat(); + if (isCommand21) { + await new ExecuteOrgCommandService().executeCommand21Employee(flatRefIds, ctx); + } else if (isCommand38) { + await new ExecuteOrgCommandService().executeCommand38Officer(flatRefIds, ctx); + } else if (isCommand40) { + await new ExecuteOrgCommandService().executeCommand40Officer(flatRefIds, ctx); + } + console.log(`[AMQ] Processed ${flatRefIds.length} items via ExecuteOrgCommandService (${code})`); + } else if (isLinearFlow) { console.log(`[AMQ] Linear Flow (${code})`); + const isCpm32 = code === "C-PM-32"; let resultData: any[] = []; + let resultData1: any[] = []; //เฉพาะ C-PM-32 (ฝั่ง "การพิจารณาลงโทษ") for (const chunk of chunks) { const res = await new CallAPI().PostData( @@ -363,9 +397,15 @@ async function handler(msg: amqp.ConsumeMessage): Promise { { refIds: chunk }, false, ); - // response (resultData) จาก .NET - if (Array.isArray(res)) { - console.log(`[AMQ] Push result data`); + if (isCpm32 && res && !Array.isArray(res)) { + // C-PM-32: response เป็น object { data, data1 } → แยก 2 track + console.log( + `[AMQ] C-PM-32 split response — data: ${res.data?.length ?? 0}, data1: ${res.data1?.length ?? 0}`, + ); + if (Array.isArray(res.data)) resultData.push(...res.data); + if (Array.isArray(res.data1)) resultData1.push(...res.data1); + } else if (Array.isArray(res)) { + console.log(`[AMQ] Push result data (${res.length})`); resultData.push(...res); } } @@ -373,7 +413,7 @@ async function handler(msg: amqp.ConsumeMessage): Promise { console.log(`[AMQ] Received ${resultData.length} profiles from .NET (${code})`); // Route ไป service ที่ถูกต้องตาม commandType - if (resultData.length > 0) { + if (resultData.length > 0 || resultData1.length > 0) { // สร้าง pseudo-req สำหรับ setLogDataDiff/save({data: req}) const pseudoReq = { headers: { authorization: token }, @@ -402,6 +442,21 @@ async function handler(msg: amqp.ConsumeMessage): Promise { } else if (isSalaryEmployeeLeave) { await new ExecuteSalaryEmployeeLeaveService().executeSalaryEmployeeLeave(resultData, ctx); console.log(`[AMQ] Processed ${resultData.length} profiles via ExecuteSalaryEmployeeLeaveService`); + } else if (isSalaryLeaveDiscipline) { + // C-PM-32 (คำสั่งยุติเรื่อง): response เป็น object { data, data1 } + // profileId เดียวกันอาจอยู่ในทั้ง 2 track → ต้องส่งให้ org แยก 2 ครั้ง ห้าม merge + if (resultData.length > 0) { + await new ExecuteSalaryLeaveDisciplineService().executeSalaryLeaveDiscipline(resultData, ctx); + console.log( + `[AMQ] Processed ${resultData.length} profiles via ExecuteSalaryLeaveDisciplineService`, + ); + } + if (isCpm32 && resultData1.length > 0) { + await new ExecuteSalaryLeaveDisciplineService().executeSalaryLeaveDiscipline(resultData1, ctx); + console.log( + `[AMQ] Processed resultData1: ${resultData1.length} profiles via ExecuteSalaryLeaveDisciplineService`, + ); + } } } } else {