import { AppDataSource } from "../database/data-source"; import { Profile } from "./../entities/Profile"; import { ProfileEmployee } from "../entities/ProfileEmployee"; import { ProfileSalary } from "./../entities/ProfileSalary"; import { OrgRoot } from "../entities/OrgRoot"; import { OrgChild1 } from "../entities/OrgChild1"; import { OrgChild2 } from "../entities/OrgChild2"; import { OrgChild3 } from "../entities/OrgChild3"; import { OrgChild4 } from "../entities/OrgChild4"; import { Brackets, In, Repository } from "typeorm"; import Extension from "../interfaces/extension"; import { RequestWithUser } from "../middlewares/user"; interface LeaveFilter { page: number; pageSize: number; searchField?: "firstName" | "lastName" | "fullName" | "citizenId" | "position" | "posNo"; searchKeyword?: string; posType?: string; posLevel?: string; isProbation?: boolean; node?: number; nodeId?: string; isAll?: boolean; retireType?: string; sortBy?: string; sort: "ASC" | "DESC"; _data: DataPermission; } interface DataPermission { root: string | null; child1: string | null; child2: string | null; child3: string | null; child4: string | null; privilege: string; } interface OrganizationCondition { condition: string; params: Record; } interface NodeConfig { repository: Repository; nameField: string; condition: string; isAllTrue: string; paramKey: string; parentIdField: string; } interface NodeParams { [key: string]: string | null | undefined; } interface OrgParentName { orgRootName: string | null; orgChild1Name: string | null; orgChild2Name: string | null; orgChild3Name: string | null; orgChild4Name: string | null; } export class ProfileLeaveService { private profileEmployeeRepo: Repository; private profileRepo: Repository; private profileSalaryRepo: Repository; private orgRootRepository: Repository; private child1Repository: Repository; private child2Repository: Repository; private child3Repository: Repository; private child4Repository: Repository; private readonly nodeConfigs: NodeConfig[]; constructor() { this.profileEmployeeRepo = AppDataSource.getRepository(ProfileEmployee); this.profileRepo = AppDataSource.getRepository(Profile); this.profileSalaryRepo = AppDataSource.getRepository(ProfileSalary); this.orgRootRepository = AppDataSource.getRepository(OrgRoot); this.child1Repository = AppDataSource.getRepository(OrgChild1); this.child2Repository = AppDataSource.getRepository(OrgChild2); this.child3Repository = AppDataSource.getRepository(OrgChild3); this.child4Repository = AppDataSource.getRepository(OrgChild4); this.nodeConfigs = [ { repository: this.orgRootRepository, nameField: "orgRootName", condition: "profileSalary.orgRoot = :orgRoot", isAllTrue: "profileSalary.orgChild1 IS NULL", paramKey: "orgRoot", parentIdField: "", }, { repository: this.child1Repository, nameField: "orgChild1Name", condition: "profileSalary.orgChild1 = :orgChild1", isAllTrue: "profileSalary.orgChild2 IS NULL", paramKey: "orgChild1", parentIdField: "orgRootId", }, { repository: this.child2Repository, nameField: "orgChild2Name", condition: "profileSalary.orgChild2 = :orgChild2", isAllTrue: "profileSalary.orgChild3 IS NULL", paramKey: "orgChild2", parentIdField: "orgChild1Id", }, { repository: this.child3Repository, nameField: "orgChild3Name", condition: "profileSalary.orgChild3 = :orgChild3", isAllTrue: "profileSalary.orgChild4 IS NULL", paramKey: "orgChild3", parentIdField: "orgChild2Id", }, { repository: this.child4Repository, nameField: "orgChild4Name", condition: "profileSalary.orgChild4 = :orgChild4", isAllTrue: "", paramKey: "orgChild4", parentIdField: "orgChild3Id", }, ]; } /** สร้าง query สำหรับการค้นหาตามฟิลด์ต่างๆ */ buildSearchQuery(searchField?: string, type: string = "profile"): string { switch (searchField) { case "citizenId": return `${type}.citizenId LIKE :keyword`; case "position": return `${type}.position LIKE :keyword`; case "posNo": return ` (CONCAT(profileSalary.posNoAbb, profileSalary.posNo) LIKE :keyword) OR (CONCAT(profileSalary.posNoAbb, " ", profileSalary.posNo) LIKE :keyword) OR (profileSalary.posNo LIKE :keyword) `; default: return `CONCAT(${type}.prefix, ${type}.firstName, ' ', ${type}.lastName) LIKE :keyword`; } } async findOrgNodeParentAll(node: number, nodeId: string): Promise { const orgNames: OrgParentName = { orgRootName: null, orgChild1Name: null, orgChild2Name: null, orgChild3Name: null, orgChild4Name: null, }; if (!nodeId || node < 0 || node >= this.nodeConfigs.length) { return orgNames; } let currentNode = node; let currentNodeId = nodeId; while (currentNode >= 0) { const config = this.nodeConfigs[currentNode]; // Build select fields dynamically, excluding empty parentIdField const selectFields = [config.nameField, "id"]; if (config.parentIdField && config.parentIdField.trim() !== "") { selectFields.push(config.parentIdField); } const orgData = await config.repository.findOne({ where: { id: currentNodeId }, select: selectFields, }); if (!orgData) { break; } const orgName = orgData[config.nameField] || null; if (orgName) { orgNames[config.nameField as keyof OrgParentName] = orgName; } // Check if parentIdField exists and is not empty before accessing it if (config.parentIdField && config.parentIdField.trim() !== "") { currentNodeId = orgData[config.parentIdField]; currentNode -= 1; } else { // If no parent field (root level), break the loop break; } } return orgNames; } /** สร้างเงื่อนไขการค้นหาตาม node และ nodeId และเช็คกับ permission */ async buildNodeCondition( node: number, nodeId: string, isAll?: boolean, ): Promise { // Early return สำหรับ edge cases if (!nodeId || node < 0 || node >= this.nodeConfigs.length) { return { condition: "1=1", params: {} }; } let nodeCondition = ""; let params: NodeParams = {}; const orgLists = await this.findOrgNodeParentAll(node, nodeId); for (let index = 0; index <= node; index++) { const config = this.nodeConfigs[index]; const orgName = orgLists[config.nameField as keyof OrgParentName] || null; if (orgName) { nodeCondition += index > 0 ? ` AND ${config.condition}` : config.condition; nodeCondition += isAll === false && config.isAllTrue ? ` AND ${config.isAllTrue}` : ""; params[config.paramKey] = orgName; } } return { condition: nodeCondition, params, }; } async getOrgNameFromId(orgIds: { root: string | null; child1: string | null; child2: string | null; child3: string | null; child4: string | null; }): Promise { const [rootName, child1, child2, child3, child4] = await Promise.all([ orgIds.root ? this.orgRootRepository.findOne({ where: { id: orgIds.root }, select: ["orgRootName"] }) : Promise.resolve(null), orgIds.child1 ? this.child1Repository.findOne({ where: { id: orgIds.child1 }, select: ["orgChild1Name"] }) : Promise.resolve(null), orgIds.child2 ? this.child2Repository.findOne({ where: { id: orgIds.child2 }, select: ["orgChild2Name"] }) : Promise.resolve(null), orgIds.child3 ? this.child3Repository.findOne({ where: { id: orgIds.child3 }, select: ["orgChild3Name"] }) : Promise.resolve(null), orgIds.child4 ? this.child4Repository.findOne({ where: { id: orgIds.child4 }, select: ["orgChild4Name"] }) : Promise.resolve(null), ]); return { orgRootName: rootName?.orgRootName ?? null, orgChild1Name: child1?.orgChild1Name ?? null, orgChild2Name: child2?.orgChild2Name ?? null, orgChild3Name: child3?.orgChild3Name ?? null, orgChild4Name: child4?.orgChild4Name ?? null, }; } /** สร้างเงื่อนไขการค้นหาตาม node และ nodeId และเช็คกับ permission */ async buildPermissionCondition( _data: DataPermission, isAll?: boolean, ): Promise { // Early return สำหรับ OWNER privilege if (_data.privilege === "OWNER" || _data.privilege === "PARENT") { return { condition: "1=1", params: {} }; } // const nodeFields = ["root", "child1", "child2", "child3", "child4"] as const; let nodeCondition = ""; let params: NodeParams = {}; const orgLists = await this.getOrgNameFromId({ root: _data.root, child1: _data.child1, child2: _data.child2, child3: _data.child3, child4: _data.child4, }); // console.log("Org Hierarchy for Permission Condition:", orgLists); // check orgLists has at least one non-null value if ( !orgLists.orgRootName && !orgLists.orgChild1Name && !orgLists.orgChild2Name && !orgLists.orgChild3Name && !orgLists.orgChild4Name ) { return { condition: "1=0", params: {} }; // no access } for (let index = 0; index < this.nodeConfigs.length; index++) { const config = this.nodeConfigs[index]; const orgName = orgLists[config.nameField as keyof OrgParentName] || null; if (orgName) { nodeCondition += index > 0 ? ` AND ${config.condition}` : config.condition; nodeCondition += isAll === false && config.isAllTrue ? ` AND ${config.isAllTrue}` : ""; params[config.paramKey] = orgName; } } return { condition: nodeCondition, params, }; } /** แปลงข้อมูลลูกจ้างก่อน response */ transformEmployeeData(employee: ProfileEmployee): any { const dateEmployment = employee.profileEmployeeEmployment?.length === 0 || !employee.profileEmployeeEmployment ? null : employee.profileEmployeeEmployment.reduce((latest, current) => { return latest.date > current.date ? latest : current; }).date; // ตรวจสอบว่า profileSalary มีข้อมูลหรือไม่ const salary = employee.profileSalary && employee.profileSalary.length > 0 ? employee.profileSalary[0] : null; const posNo = salary?.posNoAbb && salary?.posNo ? `${salary.posNoAbb} ${salary.posNo}` : salary?.posNo || ""; // สร้าง organization hierarchy - ใช้ข้อมูลจาก temp fields ถ้า salary ไม่มี const org = salary ? [salary.orgChild4, salary.orgChild3, salary.orgChild2, salary.orgChild1, salary.orgRoot] .filter(Boolean) .join("\n") : [ employee.child4Temp, employee.child3Temp, employee.child2Temp, employee.child1Temp, employee.rootTemp, ] .filter(Boolean) .join("\n"); // สร้าง node information const getNodeInfo = (nodeTemp: string) => { switch (nodeTemp) { case "0": return { name: employee.rootTemp, shortName: employee.rootShortNameTemp, }; case "1": return { name: employee.child1Temp, shortName: employee.child1ShortNameTemp, }; case "2": return { name: employee.child2Temp, shortName: employee.child2ShortNameTemp, }; case "3": return { name: employee.child3Temp, shortName: employee.child3ShortNameTemp, }; case "4": return { name: employee.child4Temp, shortName: employee.child4ShortNameTemp, }; default: return { name: null, shortName: null }; } }; const nodeInfo = getNodeInfo(employee.nodeTemp || "0"); return { id: employee.id, avatar: employee.avatar, avatarName: employee.avatarName, prefix: employee.prefix, rank: employee.rank, firstName: employee.firstName, lastName: employee.lastName, citizenId: employee.citizenId, posLevel: employee.posLevel?.posLevelName || null, posType: employee.posType?.posTypeName || null, posTypeShortName: employee.posType?.posTypeShortName || null, posLevelId: employee.posLevel?.id || null, posTypeId: employee.posType?.id || null, positionId: employee.positionIdTemp, posmasterId: employee.posmasterIdTemp, position: employee.position, posNo, employeeClass: employee.employeeClass, govAge: Extension.CalculateGovAge(employee.dateAppoint, 0, 0), age: Extension.CalculateAgeStrV2(employee.birthDate, 0, 0, "GET"), dateEmployment, dateAppoint: employee.dateAppoint, dateStart: employee.dateStart, createdAt: employee.createdAt, dateRetireLaw: employee.dateRetireLaw, draftOrganizationOrganization: nodeInfo.name, draftPositionEmployee: employee.positionTemp, draftOrgEmployeeStatus: employee.statusTemp, node: employee.nodeTemp, nodeId: employee.nodeIdTemp, nodeName: nodeInfo.name, nodeShortName: nodeInfo.shortName, root: employee.rootTemp || null, rootId: employee.rootIdTemp || null, rootShortName: employee.rootShortNameTemp || null, child1: employee.child1Temp || null, child1Id: employee.child1IdTemp || null, child1ShortName: employee.child1ShortNameTemp || null, child2: employee.child2Temp || null, child2Id: employee.child2IdTemp || null, child2ShortName: employee.child2ShortNameTemp || null, child3: employee.child3Temp || null, child3Id: employee.child3IdTemp || null, child3ShortName: employee.child3ShortNameTemp || null, child4: employee.child4Temp || null, child4Id: employee.child4IdTemp || null, child4ShortName: employee.child4ShortNameTemp || null, org, }; } /** ค้นหาลูกจ้างที่พ้นจากราชการ */ async getLeaveEmployees( request: RequestWithUser, filter: LeaveFilter, ): Promise<{ data: any[]; total: number }> { const { page, pageSize, searchField, searchKeyword = "", posType, posLevel, isProbation, node, nodeId, isAll, retireType, sortBy = "profileEmployee.dateLeave", sort, _data, } = filter; const t0 = Date.now(); const searchQuery = this.buildSearchQuery(searchField, "profileEmployee"); // สร้าง base WHERE conditions แชร์ระหว่าง count/id/data query const baseWhere = (qb: any) => { qb.where( new Brackets((qb2) => { qb2.where("profileEmployee.isLeave = :isLeave", { isLeave: true }).orWhere( "profileEmployee.isRetirement = :isRetirement", { isRetirement: true }, ); }), ) .andWhere("profileEmployee.employeeClass LIKE :type", { type: "PERM" }) .andWhere( new Brackets((qb2) => { qb2.orWhere(searchKeyword && searchKeyword != "" ? searchQuery : "1=1", { keyword: `%${searchKeyword}%`, }); }), ); if (posType) { qb.andWhere("posType.posTypeName LIKE :keyword1", { keyword1: `${posType}` }); } if (posLevel) { qb.andWhere( "CONCAT(posType.posTypeShortName, ' ', posLevel.posLevelName) LIKE :keyword2", { keyword2: `${posLevel}` }, ); } if (isProbation !== undefined && isProbation !== null) { qb.andWhere("profileEmployee.isProbation = :isProbation", { isProbation }); } if (retireType) { qb.andWhere("profileEmployee.leaveType = :retireType", { retireType }); } }; // Compute permission/node conditions เพียงครั้งเดียว const conditions: { condition: string; params: Record }[] = []; if (_data.privilege !== "OWNER" && _data.privilege !== "PARENT") { conditions.push(await this.buildPermissionCondition(_data, isAll)); } if (node !== null && node !== undefined && nodeId) { conditions.push(await this.buildNodeCondition(node, nodeId, isAll)); } const applyConditions = (qb: any) => { for (const cond of conditions) { qb.andWhere(cond.condition, cond.params); } }; // console.log(`[ProfileLeaveService] getLeaveEmployees conditions took ${Date.now() - t0}ms`); // สร้าง salary EXISTS filter (ใช้ซ้ำทั้ง step1, step2) const applySalaryFilter = (qb: any) => { if (conditions.length > 0) { let existsCond = "profileSalary.positionName != :notRetire"; const existsParams: Record = { notRetire: "เกษียณอายุราชการ" }; for (const cond of conditions) { existsCond += ` AND ${cond.condition}`; Object.assign(existsParams, cond.params); } qb.andWhere( `EXISTS (SELECT 1 FROM profileSalary WHERE profileEmployeeId = profileEmployee.id AND ${existsCond} AND profileSalary.\`order\` = (SELECT MAX(ps.\`order\`) FROM profileSalary ps WHERE ps.profileEmployeeId = profileEmployee.id AND ps.positionName != :notRetire2))`, { ...existsParams, notRetire2: "เกษียณอายุราชการ" } ); } }; // Step 1: Count query const countQb = this.profileEmployeeRepo .createQueryBuilder("profileEmployee") .leftJoinAndSelect("profileEmployee.posLevel", "posLevel") .leftJoinAndSelect("profileEmployee.posType", "posType"); baseWhere(countQb); applySalaryFilter(countQb); const total = await countQb.getCount(); // console.log(`[ProfileLeaveService] getLeaveEmployees count took ${Date.now() - t0}ms, total=${total}`); // Step 2: ดึงเฉพาะ profileEmployee IDs ที่ผ่านเงื่อนไข const idQb = this.profileEmployeeRepo .createQueryBuilder("profileEmployee") .select(["profileEmployee.id"]) .leftJoin("profileEmployee.posLevel", "posLevel") .leftJoin("profileEmployee.posType", "posType"); baseWhere(idQb); applySalaryFilter(idQb); idQb.orderBy(sortBy, sort).skip((page - 1) * pageSize).take(pageSize); const rawIds = await idQb.getRawMany(); const employeeIds = rawIds.map((r) => r.profileEmployee_id); // console.log(`[ProfileLeaveService] getLeaveEmployees ids took ${Date.now() - t0}ms, ids=${employeeIds.length}`); if (employeeIds.length === 0) { return { data: [], total }; } // Step 3: Load full data โดยไม่ JOIN salary const records = await this.profileEmployeeRepo.find({ where: { id: In(employeeIds) }, relations: ["posLevel", "posType", "profileEmployeeEmployment"], order: { [sortBy.split(".")[1]]: sort } as any, }); // Step 4: Load salary เฉพาะ row ที่มี order สูงสุดต่อ profileEmployeeId (INNER JOIN + GROUP BY) const salaries = await this.profileSalaryRepo .createQueryBuilder("ps") .innerJoin( (subQuery) => subQuery .select("ps2.profileEmployeeId", "pid") .addSelect("MAX(ps2.order)", "maxOrd") .from(ProfileSalary, "ps2") .where("ps2.profileEmployeeId IN (:...employeeIds)", { employeeIds }) .andWhere("ps2.positionName != :notRetire", { notRetire: "เกษียณอายุราชการ" }) .groupBy("ps2.profileEmployeeId"), "latest", "latest.pid = ps.profileEmployeeId AND ps.order = latest.maxOrd" ) .getMany(); // สร้าง map: profileEmployeeId → salary ที่มี order สูงสุด const salaryMap = new Map(); for (const s of salaries) { salaryMap.set(s.profileEmployeeId, s); } // แปลงข้อมูลพร้อม salary const data = records.map((record) => { const salary = salaryMap.get(record.id); if (salary) { (record as any).profileSalary = [salary]; } return this.transformEmployeeData(record); }); // console.log(`[ProfileLeaveService] getLeaveEmployees total took ${Date.now() - t0}ms, total=${total}`); return { data, total }; } /** * แปลงข้อมูลลูกจ้างก่อน response */ transformOfficerData(employee: Profile) { // ตรวจสอบว่า profileSalary มีข้อมูลหรือไม่ const salary = employee.profileSalary && employee.profileSalary.length > 0 ? employee.profileSalary[0] : null; const posNo = salary?.posNoAbb && salary?.posNo ? `${salary.posNoAbb} ${salary.posNo}` : salary?.posNo || ""; const posExecutive = salary?.positionExecutive ? salary.positionExecutive : null; const root = salary?.orgRoot ? salary.orgRoot : null; // สร้าง organization hierarchy - ใช้ข้อมูลจาก temp fields ถ้า salary ไม่มี const org = salary ? [salary.orgChild4, salary.orgChild3, salary.orgChild2, salary.orgChild1, salary.orgRoot] .filter(Boolean) .join("\n") : ["", "", "", "", ""].filter(Boolean).join("\n"); const orgRootShortName = salary?.posNoAbb ? salary.posNoAbb : null; return { id: employee.id, avatar: employee.avatar, avatarName: employee.avatarName, dateAppoint: employee.dateAppoint, prefix: employee.prefix, rank: employee.rank, firstName: employee.firstName, lastName: employee.lastName, citizenId: employee.citizenId, posLevel: employee.posLevel?.posLevelName || null, posType: employee.posType?.posTypeName || null, posLevelId: employee.posLevel?.id || null, posTypeId: employee.posType?.id || null, position: employee.position, posExecutive, posNo, rootId: null, root, orgRootShortName, orgRevisionId: null, org, }; } /** * ค้นหาข้าราชการที่พ้นจากราชการ */ async getLeaveOfficer( request: RequestWithUser, filter: LeaveFilter, ): Promise<{ data: any[]; total: number }> { const { page, pageSize, searchField, searchKeyword = "", posType, posLevel, isProbation, node, nodeId, isAll, retireType, sortBy = "profile.dateLeave", sort, _data, } = filter; const t0 = Date.now(); const searchQuery = this.buildSearchQuery(searchField); // สร้าง base WHERE conditions แชร์ระหว่าง count/id/data query const baseWhere = (qb: any) => { qb.where( new Brackets((qb2) => { qb2.where("profile.isLeave = :isLeave", { isLeave: true }).orWhere( "profile.isRetirement = :isRetirement", { isRetirement: true }, ); }), ).andWhere( new Brackets((qb2) => { qb2.orWhere(searchKeyword && searchKeyword != "" ? searchQuery : "1=1", { keyword: `%${searchKeyword}%`, }); }), ); if (posType) { qb.andWhere("posType.posTypeName LIKE :keyword1", { keyword1: `${posType}` }); } if (posLevel) { qb.andWhere("posLevel.posLevelName LIKE :keyword2", { keyword2: `${posLevel}` }); } if (isProbation !== undefined && isProbation !== null) { qb.andWhere("profile.isProbation = :isProbation", { isProbation }); } if (retireType) { qb.andWhere("profile.leaveType = :retireType", { retireType }); } }; // Compute permission/node conditions เพียงครั้งเดียว const conditions: { condition: string; params: Record }[] = []; if (_data.privilege !== "OWNER" && _data.privilege !== "PARENT") { conditions.push(await this.buildPermissionCondition(_data, isAll)); } if (node !== null && node !== undefined && nodeId) { conditions.push(await this.buildNodeCondition(node, nodeId, isAll)); } const applyConditions = (qb: any) => { for (const cond of conditions) { qb.andWhere(cond.condition, cond.params); } }; // console.log(`[ProfileLeaveService] getLeaveOfficer conditions took ${Date.now() - t0}ms`); // สร้าง salary EXISTS filter (ใช้ซ้ำทั้ง step1, step2) const applySalaryFilter = (qb: any) => { if (conditions.length > 0) { let existsCond = "profileSalary.positionName != :notRetire"; const existsParams: Record = { notRetire: "เกษียณอายุราชการ" }; for (const cond of conditions) { existsCond += ` AND ${cond.condition}`; Object.assign(existsParams, cond.params); } qb.andWhere( `EXISTS (SELECT 1 FROM profileSalary WHERE profileId = profile.id AND ${existsCond} AND profileSalary.\`order\` = (SELECT MAX(ps.\`order\`) FROM profileSalary ps WHERE ps.profileId = profile.id AND ps.positionName != :notRetire2))`, { ...existsParams, notRetire2: "เกษียณอายุราชการ" } ); } }; // Step 1: Count query const countQb = this.profileRepo .createQueryBuilder("profile") .leftJoinAndSelect("profile.posLevel", "posLevel") .leftJoinAndSelect("profile.posType", "posType"); baseWhere(countQb); applySalaryFilter(countQb); const total = await countQb.getCount(); // console.log(`[ProfileLeaveService] getLeaveOfficer count took ${Date.now() - t0}ms, total=${total}`); // Step 2: ดึงเฉพาะ profile IDs ที่ผ่านเงื่อนไข const idQb = this.profileRepo .createQueryBuilder("profile") .select(["profile.id"]) .leftJoin("profile.posLevel", "posLevel") .leftJoin("profile.posType", "posType"); baseWhere(idQb); applySalaryFilter(idQb); idQb.orderBy(sortBy, sort).skip((page - 1) * pageSize).take(pageSize); const rawIds = await idQb.getRawMany(); const profileIds = rawIds.map((r) => r.profile_id); // console.log(`[ProfileLeaveService] getLeaveOfficer ids took ${Date.now() - t0}ms, ids=${profileIds.length}`); if (profileIds.length === 0) { return { data: [], total }; } // Step 3: Load full data โดยไม่ JOIN salary const records = await this.profileRepo.find({ where: { id: In(profileIds) }, relations: ["posLevel", "posType"], order: { [sortBy.split(".")[1]]: sort } as any, }); // console.log(`[ProfileLeaveService] getLeaveOfficer step3 (load profiles) took ${Date.now() - t0}ms`); // Step 4: Load salary เฉพาะ row ที่มี order สูงสุดต่อ profileId (INNER JOIN + GROUP BY) const salaries = await this.profileSalaryRepo .createQueryBuilder("ps") .innerJoin( (subQuery) => subQuery .select("ps2.profileId", "pid") .addSelect("MAX(ps2.order)", "maxOrd") .from(ProfileSalary, "ps2") .where("ps2.profileId IN (:...profileIds)", { profileIds }) .andWhere("ps2.positionName != :notRetire", { notRetire: "เกษียณอายุราชการ" }) .groupBy("ps2.profileId"), "latest", "latest.pid = ps.profileId AND ps.order = latest.maxOrd" ) .getMany(); // console.log(`[ProfileLeaveService] getLeaveOfficer step4 (load salaries) took ${Date.now() - t0}ms, salary rows=${salaries.length}`); // สร้าง map: profileId → salary ที่มี order สูงสุด const salaryMap = new Map(); for (const s of salaries) { salaryMap.set(s.profileId, s); } // แปลงข้อมูลพร้อม salary const data = records.map((record) => { const salary = salaryMap.get(record.id); if (salary) { (record as any).profileSalary = [salary]; } return this.transformOfficerData(record); }); // console.log(`[ProfileLeaveService] getLeaveOfficer total took ${Date.now() - t0}ms, total=${total}`); return { data, total }; } }