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