2 · Period & cycle — วงจรรอบการประเมินผลการปฏิบัติงาน
ภาพรวม
pms-api ทำงานบน "วงจรรอบการประเมินผลการปฏิบัติงาน" (PMS period cycle) ที่ดำเนินตลอด 1 ปีงบประมาณ ตั้งแต่การกำหนดตัวชี้วัด (setting goal) การกรอกผลปฏิบัติงานจริง การประเมินโดยหัวหน้า การยืนยันผลของหน่วยงาน ไปจนถึงการปรับเงินเดือนตามผลประเมินและส่งออกไปยังระบบ HRMI บทนี้อธิบาย flow ตั้งแต่ต้นจนจบ พร้อม code trace ระดับบรรทัด
หัวใจของวงจรคือ entity PeriodEntity ที่เก็บวันที่ของแต่ละช่วงเวลา (settingGoalStart, evaluationStart, feedbackEnd ฯลฯ) และมี field status ตัวเดียวที่บอกสถานะปัจจุบันของรอบ — ทุกอย่างใน PMS (evaluateForm, salaryAdjustment, configNotification) เช็คกับ period ก่อนทำงาน
Entity หลัก — tbPeriod
pms-api/src/entities/period.entity.ts (branch: uat):8-73 คือ entity ที่เก็บ config ของแต่ละรอบประเมิน 1 ปี
@Entity('tbPeriod')
export class PeriodEntity extends BaseEntity {
@Column({ type: 'varchar', length: 10, nullable: false })
fiscalYear: string;
@Column({ type: 'date', nullable: false })
fiscalYearStart: Date;
@Column({ type: 'date', nullable: false })
fiscalYearEnd: Date;
// ช่วงเวลาย่อย 7 ช่วง — แต่ละช่วงมี start + end
@Column({ type: 'date', nullable: true })
settingGoalStart?: Date;
@Column({ type: 'date', nullable: true })
settingGoalEnd?: Date;
@Column({ type: 'date', nullable: true })
settingGoalSubmit?: Date;
@Column({ type: 'date', nullable: true })
checkDataStart?: Date;
@Column({ type: 'date', nullable: true })
checkDataEnd?: Date;
@Column({ type: 'date', nullable: true })
updateResultStart?: Date;
@Column({ type: 'date', nullable: true })
updateResultEnd?: Date;
@Column({ type: 'date', nullable: true })
evaluationStart?: Date;
@Column({ type: 'date', nullable: true })
evaluationEnd?: Date;
@Column({ type: 'date', nullable: true })
feedbackStart?: Date;
@Column({ type: 'date', nullable: true })
feedbackEnd?: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'bit', default: true, nullable: false })
isInactive?: boolean;
@OneToMany(() => PeriodExtendEntity, (att) => att.period, { cascade: true })
periodExtend?: PeriodExtendEntity[];
@OneToMany(() => PeriodHistoryEntity, (att) => att.period, { cascade: true })
periodHistory?: PeriodHistoryEntity[];
}ช่วงเวลาย่อยทั้งหมด 5 คู่ (10 ฟิลด์) ครอบคลุมวงจร PMS ทั้งปี
| ช่วงเวลา | ฟิลด์ start | ฟิลด์ end | ความหมาย |
|---|---|---|---|
| Setting goal | settingGoalStart | settingGoalEnd | ตั้งตัวชี้วัดของปี — พนักงาน+หัวหน้าตกลง KPI |
| Check data | checkDataStart | checkDataEnd | ตรวจสอบข้อมูลบุคลากร (อายุการทำงาน, วันลา) |
| Update result | updateResultStart | updateResultEnd | บันทึกผลงานที่ทำได้จริง |
| Evaluation | evaluationStart | evaluationEnd | หัวหน้าประเมิน + ให้คะแนน |
| Feedback | feedbackStart | feedbackEnd | แจ้งผลกลับให้ผู้รับการประเมิน |
ฟิลด์ settingGoalSubmit เก็บวันส่งตัวชี้วัดแบบเฉพาะ — แยกจาก settingGoalEnd
ฟิลด์ maximumStartdate ที่บรรทัด pms-api/src/entities/period.entity.ts (branch: uat):55-56 เป็นวันสูงสุดที่ยังอนุญาตให้เริ่มสร้างแบบประเมินได้ (กรณีพนักงานเข้าใหม่กลางปี)
ฟิลด์ status เก็บค่า draft หรือ confirm (ดูการใช้ใน pms-api/src/modules/period/period.service.ts (branch: uat):71) — period ต้องเป็น confirm ถึงจะถือว่าใช้งานได้จริง
Period status (state machine แบบเรียบ)
PMS ไม่ได้ใช้ state machine ซับซ้อนระดับ FSM ที่มี transition table แยก — period status มี 2 ค่าหลักคือ draft (กำลังตั้งค่า) และ confirm (เปิดใช้งานแล้ว) ทุกช่วงเวลาถูกควบคุมโดยวันที่ (date-based) แทนที่จะใช้สถานะ
async validateData(year, id) {
const isDup = await this.repo.checkduplicationByYear(year, id);
return { status: !isDup, message: isDup ? 'Duplication Year' : '' };
}
private async prepareEntity(body, req, historyType) {
const validate = await this.validateData(body?.fiscalYear ? Number(body?.fiscalYear) : null, body?.id);
if (!validate.status && body?.status === 'confirm') {
throw new BadRequestException(validate.message);
}
// ... build PeriodHistoryEntity + PeriodExtendEntity
}จาก pms-api/src/modules/period/period.service.ts (branch: uat):60-97
ข้อจำกัดสำคัญ — ปีหนึ่งจะมี period status=confirm ได้เพียง 1 record เท่านั้น เช็คโดย pms-api/src/modules/period/period.repositories.ts (branch: uat):121-133 ที่ query แบบ
async checkduplicationByYear(year, id = 0): Promise<boolean> {
const selectQuery = this.entityManager
.createQueryBuilder(PeriodEntity, 'hd')
.where('hd.isDeleted = 0')
.andWhere('hd.fiscalYear = :year', { year })
.andWhere(`hd.status = 'confirm'`);
if (id > 0) {
selectQuery.andWhere('hd.id != :id', { id });
}
const [data, count] = await selectQuery.getManyAndCount();
return count > 0;
}ถ้า HR ตั้ง period ปีเดียวกัน 2 รอบและทั้งคู่เป็น confirm จะ throw BadRequestException('Duplication Year')
API endpoints ของ period
pms-api/src/modules/period/period.controller.ts (branch: uat):11-54
| Method | Path | Service | หน้าที่ |
|---|---|---|---|
| GET | /period/list | getPeriodList | รายการ period ทั้งหมด (พร้อม dropdown status) |
| GET | /period/info/:id | getPeriodById | รายละเอียด 1 period + periodExtend + periodHistory |
| POST | /period | create | สร้าง period ใหม่ |
| PUT | /period | update | แก้ไข period (ใช้ historyType update หรือ extendTime) |
| DELETE | /period/:id | delete | soft-delete period + ref table |
| GET | /period/list_dropdown | getDropdownPeriodList | dropdown ปีงบประมาณ + status + historyType |
| POST | /period/extend | create_periodExtend | ขยายช่วง settingGoal ของ BU เฉพาะ + ส่ง email |
| GET | /period/get_period_now | getPeriodNow | ตรวจว่าวันนี้อยู่ในช่วง setting goal ของผู้ใช้หรือไม่ |
ทุก endpoint อยู่ภายใต้ global JwtAuthGuard + rate limit 100 req/60s (ดูบทที่ 1)
Period create — flow แรกของรอบ
การสร้าง period ใหม่เริ่มที่ pms-api/src/modules/period/period.service.ts (branch: uat):99-106
async create(body: PeriodDto, req: RequestDto): Promise<PeriodEntity> {
try {
const entity = await this.prepareEntity(body, req, 'create');
return await this.entityManager.save(PeriodEntity, entity);
} catch (error) {
this.handleError(error);
}
}ลำดับขั้น
prepareEntity()เรียกvalidateData()เพื่อเช็ค duplicate year (ด้านบน)- สร้าง
PeriodHistoryEntityที่มีhistoryType='create',status=body.status(ส่วนใหญ่คือdraft) - เพิ่ม
StampAudit(req, body)เพื่อแปะcreatedBy,createdByName,updatedAtจาก JWT payload entityManager.save(PeriodEntity, entity)บันทึกทั้ง header +periodExtend+periodHistory(cascade) ใน transaction เดียว
จุดสำคัญ — status ของ period ใหม่ปกติจะเป็น draft ก่อน HR ต้องกลับมาเปลี่ยนเป็น confirm ผ่าน PUT /period หลังครบทุกฟิลด์แล้ว ในตอนเปลี่ยนเป็น confirm จะมีการเช็ค duplicate year อีกครั้งใน prepareEntity() (บรรทัด 71-73)
Period extend — ขยายช่วง setting goal ของ BU เฉพาะ
กรณี BU หนึ่งไม่ทันกำหนด setting goal สามารถขอขยายเฉพาะ BU นั้นได้ผ่าน POST /period/extend — pms-api/src/modules/period/period.service.ts (branch: uat):170-215
async create_periodExtend(body: PeriodDto, req: RequestDto): Promise<PeriodEntity> {
const dataDdl = await this.repo.getDropdownPeriodList();
const buCodeList: string[] = [];
const periodExtend = body?.periodExtend?.map<PeriodExtendEntity>((item) => {
buCodeList.push(item.buCode);
return {
...item,
PMSPeriodId: body.id,
...StampAudit(req, null),
};
});
resultDt = await this.entityManager.save(PeriodExtendEntity, periodExtend);
// save history
const history: PeriodHistoryEntity = {
historyType: 'extendTime',
PMSPeriodId: body.id,
status: 'confirm',
remark: 'extendTime',
...StampAudit(req, null),
};
await this.entityManager.save(PeriodHistoryEntity, history);
// ส่ง email แจ้ง PMS admin + approver ของ BU ที่ขยาย
await this.sendEmail(body, buCodeList);
return { ...body, periodExtend: resultDt, periodHistory: { ...resultHistory, ...} };
}ฟังก์ชัน sendEmail() ที่ pms-api/src/modules/period/period.service.ts (branch: uat):217-312 ทำ 2 อย่าง
- หาผู้รับ — ดึง PMS admin + hrbp_department_admin + admin1-5 email ของ approver chain ของ BU นั้น ๆ
- เรนเดอร์ EJS template
libs/pdfTemplate/email-template/period/period-extend.ejsส่งผ่านEmailService.sendEmail()
สังเกตว่าการ extend ถูกบันทึกเป็น PeriodExtendEntity ที่มี buCode, settingGoalStartExtend, settingGoalEndExtend — ไม่ได้แก้ไข period หลัก ทำให้แต่ละ BU สามารถมีช่วง extend ต่างกันได้ การ lookup ที่ใช้ในการตัดสินใจว่า "วันนี้อยู่ในช่วง setting goal ไหม" อยู่ที่ pms-api/src/modules/period/period.repositories.ts (branch: uat):145-203 ใน getPeriodNow() ที่ check periodExtend ของ BU ก่อน แล้วค่อย fallback ไปดู settingGoalStart/settingGoalEnd ของ period หลัก
ConfigNotification — ระบบแจ้งเตือนตามรอบ
pms-api/src/modules/configNotification/configNotification.entity.ts (branch: uat):6-77 เก็บ config ของ notification type แต่ละแบบ
@Entity('tbConfigNotification')
export class ConfigNotificationEntity extends BaseEntity {
@Column({ type: 'varchar', length: 50, nullable: false })
notiTypeCode: string; // notiGoalStart, notiGoalSubmit, notiCheckData, notiUpdateResult,
// notiEvaluationStart, notiEvaluationEnd, notiFeedback
@Column({ type: 'varchar', length: 50, nullable: false })
status: string; // draft / confirm
@Column({ type: 'bit', nullable: false })
isEmail: boolean;
@Column({ type: 'bit', nullable: false })
isVTRC: boolean;
@Column({ type: 'bit', nullable: false })
isLine: boolean;
@Column({ type: 'bit', nullable: false })
isEvaluatee: boolean;
@Column({ type: 'bit', nullable: false })
isEvaluator: boolean;
@Column({ type: 'bit', nullable: false })
isApprover: boolean;
@Column({ type: 'bit', nullable: false })
isOrgUnitAdmin: boolean;
@Column({ type: 'bit', nullable: false })
isAdvanceNoti: boolean;
@Column({ type: 'int', nullable: false })
advanceNotiDay: number;
}แต่ละ notification type ระบุว่า จะส่งผ่านช่องทางใด (email/VTRC/Line), ใครเป็นผู้รับ (evaluatee/evaluator/approver/orgUnitAdmin), และจะแจ้งล่วงหน้ากี่วัน
ConfigNotification cron — ส่ง email ทุกวัน 6 โมงเช้า
pms-api/src/modules/configNotification/configNotification.controller.ts (branch: uat):27-41
// Cron job to check and send email notifications every day at 6 AM Thailand time
@Cron('0 6 * * *', {
timeZone: 'Asia/Bangkok',
})
async handleCronNotifications() {
console.log('Starting daily notification check at 6 AM Thailand time');
try {
this.emailService.checkAndSendNotifications();
console.log('Daily notification check completed');
} catch (error) {
console.error('Error during daily notification check:', error);
}
}ลำดับการทำงานของ checkAndSendNotifications() ที่ pms-api/src/modules/configNotification/configNotificationSendEmail.service.ts (branch: uat):62-140
repo.getCurrentPeriodWithNotifications()ดึง period ปัจจุบัน + configNotification ทั้งหมดที่status=confirm- วนรอบ notification แต่ละแบบ เลือก
targetDateตามnotiTypeCode— เช่นnotiGoalStartใช้tbPeriod.settingGoalStart,notiEvaluationStartใช้tbPeriod.evaluationStart - คำนวณ
targetDateลดด้วยadvanceNotiDayถ้าisAdvanceNoti=true—calculateNotificationDate()ที่บรรทัด 44-54 - ถ้า
targetDateตรงกับวันนี้ → เรียกsendPeriodNotificationEmail() - แบ่งผู้รับเป็น batch ละ 500 คน แล้วส่งทีละ batch (
batchSize = 500ที่บรรทัด 162)
Notification type ทั้ง 7 แบบ + ฟิลด์อ้างอิงใน period
notiTypeCode | ฟิลด์ period ที่อ้าง | Template | Subject (ย่อ) |
|---|---|---|---|
notiGoalStart | settingGoalStart | period/period-goal-start.ejs | แจ้งรอบการกำหนดตัวชี้วัด |
notiGoalSubmit | settingGoalEnd | period/period-goal-submit.ejs | ใกล้ถึงวันกำหนดส่งตัวชี้วัด |
notiCheckData | checkDataStart | period/period-check-data.ejs | ตรวจสอบความถูกต้องของข้อมูล |
notiUpdateResult | updateResultStart | period/period-update-result.ejs | บันทึกผลงานที่ทำได้จริง |
notiEvaluationStart | evaluationStart | period/period-evaluation-start.ejs | ประเมินผลการปฏิบัติงาน |
notiEvaluationEnd | evaluationEnd | period/period-evaluation-end.ejs | ส่งการประเมิน |
notiFeedback | feedbackEnd | period/period-feedback.ejs | แจ้งผลประเมินให้ผู้รับการประเมิน |
เงื่อนไขที่ pms-api/src/modules/configNotification/configNotificationSendEmail.service.ts (branch: uat):133 สังเกตุว่ามี comment (targetDate && this.isSameDate(targetDate, today)) ที่ถูก comment ปิดไว้ — ในขณะนี้เงื่อนไขคือ
if (process.env.NODE_ENV === 'development' || targetDate) {
await this.sendPeriodNotificationEmail(...);
}นั่นหมายความว่า ถ้า targetDate ไม่เป็น null (เกิดจากการคำนวณได้) ระบบจะส่ง email ทุกวัน ไม่จำเป็นต้องตรงกับ today — เป็นไปได้ว่าเป็นการเปิดเผยแบบทดสอบ หรือเป็น bug ที่ยังไม่ได้แก้ (ดู UNVERIFIED เพิ่มเติมได้ใน scorecard)
CreateForm flow — สร้างแบบประเมินจากตัวชี้วัด
หลัง period เปิดช่วง setting goal พนักงาน+หัวหน้าจะสร้างแบบประเมินผ่าน createForm module โดยเริ่มจาก POST /createForm ที่เรียก createForm.service.ts:create()
EvaluateFormEntity ที่ pms-api/src/entities/evaluateForm.entity.ts (branch: uat):14-444 เป็น entity ที่เก็บ header ของแบบประเมิน มี field status ซ้อนกันหลายชั้น
status— status หลักของ form (draft,waitAgree,waitApprove,approve)createdStatus— สถานะการสร้าง (draft,confirm)createFormApprove1Status— การเห็นชอบจากผู้ประเมิน (agree, null)createFormApprove2Status— การอนุมัติจากผู้อนุมัติ (approve, null)evaluateStatus— ผลการประเมินระดับ evaluator (หลังช่วง evaluation)approveEvaluateStatus— การเห็นชอบผลประเมินจาก approverupdatePerformanceStatus— สถานะการอัปเดตผลงานในช่วง updateResultreportResultStatus— สถานะการแจ้งผลในช่วง feedbackisRequestEdit,requestEditStatus— ขอแก้ไขหลังปิดรอบ (จาก modulerequestEditForm)isNoQuota— กรณีพิเศษไม่มีโควต้าเกรด (จาก moduleconfirmEvaluation)
state machine ของการสร้าง form อยู่ใน pms-api/src/modules/createForm/createForm.service.ts (branch: uat):267-292
async setStatus(body: CreateFormDto, req: RequestDto, status: string) {
if (status === 'confirm') {
body.status = 'waitAgree';
body.createdStatus = 'confirm';
} else if (status === 'draft') {
body.createdStatus = 'draft';
} else if (status === 'agree' || status === 'approve') {
body.createdStatus = 'confirm';
body.status = 'waitApprove';
body.createFormApprove1Status = 'agree';
body.createFormApprove1ByName = req?.user?.profile?.fullName;
body.createFormApprove1By = req?.user?.profile?.empCode;
body.createFormApprove1At = new Date();
if (status === 'approve') {
body.status = 'approve';
body.createFormApprove2Status = 'approve';
body.createFormApprove2ByName = req?.user?.profile?.fullName;
body.createFormApprove2By = req?.user?.profile?.empCode;
body.createFormApprove2At = new Date();
}
}
return body;
}state ของการสร้าง form
draft → (confirm) → waitAgree → (agree) → waitApprove → (approve) → approve
↑
popup auto (agree+approve พร้อมกัน)ลำดับการ save ของ update() ที่ pms-api/src/modules/createForm/createForm.service.ts (branch: uat):294-314
const result = await this.entityManager.transaction(async (transactionalEntityManager) => {
let headerData = { ...body };
delete headerData.evaluateFormDt;
delete headerData.evaluateFormLearning;
delete headerData.evaluateFormHistory;
headerData = await this.setStatus(headerData, req, body.status);
await transactionalEntityManager.save(EvaluateFormEntity, {
...headerData,
...StampAudit(req, body),
});
if (body?.evaluateFormDt?.length > 0) {
for (const item of body.evaluateFormDt) {
const dt = { ...item };
// ...save detail rows
}
}
});ใช้ entityManager.transaction() ทำให้การ save header + detail เป็น atomic — pattern เดียวกับ module อื่น ๆ ใน PMS เกือบทั้งหมด
EvaluateForm flow — กรอกผล + ประเมิน
ช่วง evaluation (evaluationStart → evaluationEnd) พนักงาน+หัวหน้าเข้ามากรอกผลการปฏิบัติงานจริงและให้คะแนนผ่าน evaluateForm module โดยใช้ endpoint เดียวกับ createForm — แต่เปลี่ยน field ที่กรอก
pms-api/src/modules/evaluateForm/evaluateForm.controller.ts (branch: uat):25-95 มี endpoint ตามตาราง
| Method | Path | หน้าที่ |
|---|---|---|
| GET | /evaluateForm/evaluateTracking_list | ติดตามสถานะการประเมิน (track ว่าใครส่งแล้ว, ใครยังไม่ส่ง) |
| GET | /evaluateForm/evaluateTrackingSalary_list | track เดียวกันแต่มีมุมมองเงินเดือน |
| GET | /evaluateForm/exportpdf_evaluation/:id | export ผลประเมินเป็น PDF ผ่าน puppeteer |
| GET | /evaluateForm/exportpdf_evaluation_mobile/:id | เดียวกัน สำหรับมุมมองมือถือ |
| GET | /evaluateForm/export_excel | export ผลประเมินเป็น Excel |
| GET | /evaluateForm/get_dropDown_orgUnit_pms | dropdown หน่วยงานที่ผู้ใช้มีสิทธิ์ |
| GET | /evaluateForm/get_dropDown_status | dropdown สถานะ |
| PUT | /evaluateForm/emplevel_emp_code | อัปเดต employee level รายคน |
| GET | /evaluateForm/export_excel_all | export ผลประเมินทั้งหมดเป็น Excel |
สังเกตว่า evaluateForm ไม่มี endpoint สำหรับ save ข้อมูล evaluation โดยตรง — การ save ใช้ PUT /createForm ของ module createForm (ใช้ entity เดียวกัน) ตัว evaluateForm โฟกัสที่การ track และ export
PDF export flow ที่ pms-api/src/modules/evaluateForm/evaluateForm.service.ts (branch: uat):485-521
if (data) {
const ddlList = await this.dropdownRepo.getDropdownByType([
"pms_unit", "pms_priority", "employee", "agent", "approve",
]);
let pdfUrlFile = null;
if (!(data?.updatePerformanceStatus || null) && !(data?.evaluateStatus || null)) {
pdfUrlFile = await this.exportCreateFormPDF(data, ddlList);
} else {
pdfUrlFile = await this.exportEvaluateFormPDF(data, ddlList);
}
res.set({
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fileNamePDF} (${data["fiscalYear"]}).pdf"`,
});
const buf = Buffer.from(pdfUrlFile);
return new StreamableFile(buf);
}เลือก template PDF ตามสถานะของ form — ถ้ายังไม่ได้ประเมิน (ไม่มี updatePerformanceStatus และไม่มี evaluateStatus) ใช้ exportCreateFormPDF (เป็นแบบฟอร์มเปล่า) ถ้าประเมินแล้วใช้ exportEvaluateFormPDF (มีคะแนน+เกรด)
Approver chain — สายอนุมัติ 5 ชั้น
ApproverDtEntity (tbApproverDt) เก็บ email ผู้อนุมัติ 5 ชั้น — admin1Email, admin2Email, admin3Email, admin4Email, admin5Email (ดู pms-api/src/modules/period/period.service.ts (branch: uat):240-265 ที่ใช้ในการส่ง email period extend)
Module approver ให้บริการ delegate — เปลี่ยนผู้อนุมัติระหว่างทาง ผ่าน ApproverHistoryEntity ที่บันทึกการเปลี่ยนแปลงทุกครั้ง
ConfirmEvaluation flow — ยืนยันผลของหน่วยงาน
หลังช่วง evaluation และ feedback แต่ละ BU ต้องยืนยันผลรวมของหน่วยงานผ่าน module confirmEvaluation ก่อนเข้าสู่กระบวนการปรับเงินเดือน — pms-api/src/modules/confirmEvaluation/confirmEvaluation.controller.ts (branch: uat):20-82
| Method | Path | Service | หน้าที่ |
|---|---|---|---|
| GET | /confirmEvaluation/orgUnit/list | getOrgUnitConfirmList | รายการหน่วยงานที่ต้องยืนยัน |
| GET | /confirmEvaluation/orgUnit/info/:id | getOrgUnitConfirmById | รายละเอียดผลยืนยันของหน่วยงาน |
| PUT | /confirmEvaluation/orgUnit/update_no_quota | updateNoQuota | ปรับกรณีไม่มีโควต้าเกรด |
| PUT | /confirmEvaluation/orgUnit/update_reject | updateReject | ปฏิเสธผลรวมของหน่วยงาน ส่งกลับไปแก้ |
| PUT | /confirmEvaluation/orgUnit/confirm_update_status | orgUnitConfirmUpdateStatus | ยืนยันผลของหน่วยงาน |
| GET | /confirmEvaluation/orgUnit/export_excel/:id/:pageName | exportExcel | export ผลยืนยันเป็น Excel |
| GET | /confirmEvaluation/quota/list | getQuotaList | รายการโควต้าเกรดแต่ละหน่วยงาน |
| PUT | /confirmEvaluation/quota/confirm_update_status | quotaUpdateStatus | ยืนยันโควต้า |
| PUT | /confirmEvaluation/quota/update_no_quota | updateNoQuotaFromQuota | ปรับโควต้า → no quota |
การยืนยันผลหน่วยงานเก็บใน OrgUnitConfirmHistoryEntity และอัปเดตฟิลด์ isNoQuota, updateNoQuotaBy ของ EvaluateFormEntity
SalaryAdjust — เริ่มกระบวนการปรับเงินเดือน
หลัง confirmEvaluation ยืนยันครบแล้ว HR/admin เริ่มสร้างเอกสารปรับเงินเดือนผ่าน salaryAdjust module — pms-api/src/modules/salaryAdjust/salaryAdjust.controller.ts (branch: uat):11-57
| Method | Path | Service | หน้าที่ |
|---|---|---|---|
| GET | /salaryAdjust/list | getSalaryAdjustList | รายการปีงบประมาณที่มี |
| GET | /salaryAdjust/bu_list | getSalaryAdjustBuList | รายการหน่วยงานในแต่ละปี |
| GET | /salaryAdjust/info | getSalaryAdjustById | รายละเอียดการปรับของ BU |
| POST | /salaryAdjust | create | สร้างเอกสารใหม่ |
| PUT | /salaryAdjust | update | แก้ไขเอกสาร |
| PUT | /salaryAdjust/status | updateStatusBu | reject เอกสารของ BU |
ผลลัพธ์หลักคือ SalaryAdjustHdEntity + SalaryAdjustBuEntity (1 เอกสารต่อปี, มีหลาย BU) + SalaryAdjustDivEntity (1 BU มีหลาย division) + SalaryAdjustDtEntity (1 division มีหลายพนักงาน)
SalaryAdjustment flow — อนุมัติการปรับเงินเดือน
หลังจาก salaryAdjust สร้างเอกสารแล้ว module salaryAdjustment (ใหญ่กว่า — 2,367 บรรทัด) รับผิดชอบ flow การอนุมัติหลายขั้น — pms-api/src/modules/salaryAdjustment/salaryAdjustment.controller.ts (branch: uat):22-112
| Method | Path | Service | หน้าที่ |
|---|---|---|---|
| GET | /salaryAdjustment/list | getSalaryAdjustmentList | รายการปรับเงินเดือน |
| GET | /salaryAdjustment/info | getSalaryAdjustmentById | รายละเอียด BU |
| PUT | /salaryAdjustment | update | แก้ไข + auto-flow status |
| PUT | /salaryAdjustment/admin_orgunit_confirm | adminOrgUnitConfirmBeforeDirector | ผู้ดูแลหน่วยงานยืนยัน |
| GET | /salaryAdjustment/list_approve | getSalaryAdjustmentApproveList | รายการรออนุมัติ |
| PUT | /salaryAdjustment/approve_update | salaryAdjustmentApproveUpdate | reviewer/approver อนุมัติหรือ reject |
| POST | /salaryAdjustment/export_excel_budget | exportExcelBudget | export งบประมาณเป็น Excel |
| GET | /salaryAdjustment/export_excel_employee | exportExcelEmployee | export รายชื่อพนักงาน |
| POST | /salaryAdjustment/import_excel_budget | importExcelBudget | import งบประมาณกลับเข้าระบบ |
State machine ของ confirmAdjustSalaryStatus
จาก pms-api/src/modules/salaryAdjustment/salaryAdjustment.service.ts (branch: uat):528-545 (ส่วน create) และ :660-685 (ส่วน update ของ evaluator)
[สร้างใหม่]
│
├── hasEvaluator → waitEvaluator
├── hasApprover → waitApprover
└── อื่น ๆ → waitDirector
│
│ [PUT /salaryAdjustment/admin_orgunit_confirm]
▼
confirmAdjustSalary
│
│ [PUT /salaryAdjustment/approve_update]
│ → rejectToReviewer → reviewer แก้
│ → agreeAdjustSalary → waitApprover (สบค)
│ → approveAdjustSalary → flow HRMI
▼
rejectDirector / draftDirector / confirmAdjustSalaryตารางการ transition ของ confirmAdjustSalaryStatus
| From | Trigger | To | ผู้กระทำการ |
|---|---|---|---|
| (new) | create with evaluator | waitEvaluator | system |
| (new) | create with approver | waitApprover | system |
| (new) | create without evaluator/approver | waitDirector | system |
waitEvaluator | evaluator reject | rejectAdminOrgUnit | evaluator |
waitEvaluator | evaluator confirm | waitApprover หรือ waitDirector | evaluator |
waitDirector | director confirm | confirmAdjustSalary | director |
waitDirector | director reject | rejectAdminOrgUnit | director |
confirmAdjustSalary | reviewer reject | rejectToBu | reviewer (pms_salary_viewer) |
confirmAdjustSalary | reviewer confirm | agreeAdjustSalary | reviewer |
agreeAdjustSalary | approver reject | rejectToReviewer | approver (pms_salary_approver) |
agreeAdjustSalary | approver confirm | approveAdjustSalary | approver |
หลักฐานการเปลี่ยน status แต่ละขั้น — pms-api/src/modules/salaryAdjustment/salaryAdjustment.service.ts (branch: uat):811-847 (director confirm/reject/draft), :1384-1498 (reviewer/approver flow ใน salaryAdjustmentApproveUpdate())
ตัวอย่าง — salaryAdjustmentApproveUpdate() (state machine หลัก)
pms-api/src/modules/salaryAdjustment/salaryAdjustment.service.ts (branch: uat):1384-1498
async salaryAdjustmentApproveUpdate(body: UpdateSalaryAdjustBuDto, req: RequestDto): Promise<boolean> {
const result = await this.entityManager.transaction(async (transactionalEntityManager) => {
const dropdownList = await this.dropdownRepo.getDropdownByType([
'salaryAdjustBuStatus',
'salaryAdjustDtHistoryType',
'approveAdjustSalaryStatus',
]);
const roles = req?.user?.profile?.roles || [];
const isSalaryApprover = roles.includes('pms_salary_approver');
let approveAdjustSalaryStatus = 'agreeAdjustSalary';
// ถ้าเป็นครั้งแรก หรือถูก ผอ สบค reject ต้องเป็น admin ปรับเงินเดือน เท่านั้น
if (['', null, 'rejectToReviewer'].includes(currentData.approveAdjustSalaryStatus) && !body?.isAutoApprove) {
if (!isSalaryViewer) {
throw new BadRequestException('permission|ไม่มีสิทธิ์ดำเนินการ');
}
approveAdjustSalaryStatus = body.approveAdjustSalaryStatus === 'reject' ? 'rejectToBu' : 'agreeAdjustSalary';
bu.checkAdjustSalaryAt = new Date();
bu.checkAdjustSalaryBy = req?.user?.profile?.empCode;
}
// ต้องเป็น ผอ สบค เท่านั้น
if (currentData.approveAdjustSalaryStatus === 'agreeAdjustSalary' || body?.isAutoApprove === true) {
if (!isSalaryApprover) {
throw new BadRequestException('permission|ไม่มีสิทธิ์ดำเนินการ');
}
approveAdjustSalaryStatus = body.approveAdjustSalaryStatus === 'reject' ? 'rejectToReviewer' : 'approveAdjustSalary';
bu.approveAdjustSalaryAt = new Date();
bu.approveAdjustSalaryBy = req?.user?.profile?.empCode;
}
await transactionalEntityManager.save(SalaryAdjustBuEntity, {
...bu,
approveAdjustSalaryStatus,
...StampAudit(req, body),
});
// salaryAdjustBuHistory
await transactionalEntityManager.save(SalaryAdjustBuHistoryEntity, {
historyType: approveAdjustSalaryStatus === 'approveAdjustSalary' ? 'approveAdjustResult' : 'reviewAdjustResult',
historyTypeName: this.mapDropdownValue(dropdownList, /* ... */, 'salaryAdjustHistoryType'),
statusName: this.mapDropdownValue(dropdownList, bu.status, 'salaryAdjustBuStatus'),
remark: approveAdjustSalaryStatus === 'approveAdjustSalary' ? 'อนุมัติปรับเงินเดือนแล้ว' : '...',
...StampAudit(req, body),
});
});
return true;
}สังเกต pattern สำคัญ
- ใช้ transaction —
entityManager.transaction()ครอบทั้ง flow - ตรวจสิทธิ์ใน service layer — ใช้
rolesจาก JWT payload + checkpms_salary_approver/pms_salary_viewer - บันทึก history ทุกครั้ง —
SalaryAdjustBuHistoryEntityกับhistoryTypeที่ map จาก dropdown - Email notification — ถ้า status เป็น
approveAdjustSalaryจะส่ง emailPMS_SALARY_APPROVER_CONFIRM(ดูบรรทัด 1079)
SalaryAdjustToHRMI — export ไป HRMI
หลัง approveAdjustSalaryStatus = approveAdjustSalary แล้ว module salaryAdjustToHRMI รับผิดชอบ export เป็นไฟล์ Excel สำหรับทีม HR นำเข้าระบบ HRMI ด้วยมือ — ต่างจาก recruitment-api ที่ export ผ่าน HTTP + email
pms-api/src/modules/salaryAdjustment/salaryAdjustment.service.ts (branch: uat):177-196 มี exportExcelBudget() ที่ใช้ exceljs สร้างไฟล์เป็น StreamableFile
async exportExcelBudget(res: Response, payload: ExportSalaryAdjustBudgetDto): Promise<StreamableFile> {
const salaryAdjustBuId = payload?.id ?? payload?.salaryAdjustDiv?.[0]?.salaryAdjustBuId;
if (!salaryAdjustBuId) {
throw new BadRequestException('ไม่พบข้อมูลที่ต้องการ');
}
const result = await this.createBudgetExcelBuffer(salaryAdjustBuId, payload?.salaryAdjustDiv);
res.set({
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="export_excel_budget.xlsx"',
});
const buf = Buffer.from(result);
return new StreamableFile(buf);
}ไฟล์ Excel ที่ได้จะมี salaryAdjustHd + salaryAdjustBu + salaryAdjustDiv + salaryAdjustDt แบบ flat — ทีม HR นำเข้า HRMI ผ่าน import wizard
Cron jobs ที่ขับวงจร period
PMS มี cron 2 ตัวที่ active และอีก 1 ตัวที่ถูก comment ปิด
| Cron | Schedule | ที่อยู่ | หน้าที่ |
|---|---|---|---|
handleCronNotifications | 0 6 * * * Asia/Bangkok | pms-api/src/modules/configNotification/configNotification.controller.ts (branch: uat):30-32 | ส่ง email แจ้งเตือนตามรอบ period ทุกวัน 6 โมงเช้า |
handleCron | */1 * * * * | pms-api/src/modules/usagePermission/usagePermission.service.ts (branch: uat):1929-1936 | sync ข้อมูลองค์กร/พนักงานจาก dbWorkforce เข้า PMS ทุก 1 นาที |
MigrateDataController (ปิด) | // @Cron('0 6 * * *') comment | pms-api/src/modules/migrateData/migrateData.controller.ts (branch: uat):15-16 | migrate ข้อมูล workforce ครั้งใหญ่ — ปิดไว้ รัน manual ผ่าน endpoint |
UsagePermission cron — หัวใจที่ซ่อนอยู่
@Cron('*/1 * * * *')
async handleCron() {
try {
await this.getEmployeeTriggerQeue();
} catch (error) {
this.logger.error(error);
}
}pms-api/src/modules/usagePermission/usagePermission.service.ts (branch: uat):1928-1937
cron นี้ไม่เกี่ยวกับ period โดยตรง แต่สำคัญมากเพราะเป็นจุดเดียวที่ sync ข้อมูลองค์กร/ตำแหน่ง/สายงานจาก dbWorkforce เข้า PMS — ข้อมูลที่ evaluateForm/confirmEvaluation/salaryAdjustment ใช้ (org unit, position, salary range, employee level) ขึ้นอยู่กับความสดของ sync job นี้ทั้งหมด
ถ้า cron นี้หยุดทำงานเงียบ ๆ จะไม่มี alert ใด ๆ แจ้ง (ไม่พบ health-check) — เป็น single point of failure ที่ซ่อนอยู่
Cross-module impact — period → evaluation → salary
period ที่เป็น confirm จะกระตุ้น flow หลาย module ที่อ้างอิง periodId ผ่าน EvaluateFormEntity
tbPeriod (id, fiscalYear, status='confirm')
│
│ periodId ←
▼
tbEvaluateForm (periodId, fiscalYear, status, evaluateStatus, summaryGrade, isNoQuota)
│
│ aggregate per orgUnitId
▼
tbOrgUnitConfirmHistory + tbConfirmQuotaHistory
│
│ trigger เมื่อยืนยันครบ
▼
tbSalaryAdjustHd (fiscalYear, status) ← 1 ปี / 1 เอกสาร
│
│ salaryAdjustHdId
▼
tbSalaryAdjustBu (salaryAdjustHdId, buCode, budgetStaff, budgetEmployee,
status, confirmAdjustSalaryStatus, approveAdjustSalaryStatus)
│
│ salaryAdjustBuId
▼
tbSalaryAdjustDiv → tbSalaryAdjustDt (รายพนักงาน)
│
│ export เมื่อ approveAdjustSalaryStatus='approveAdjustSalary'
▼
Excel file → HRMI (manual import โดย HR)สิ่งที่ต้องระวังเมื่อแก้ไข — ถ้าแก้ฟิลด์ summaryGrade ของ EvaluateFormEntity จะกระทบ salaryAdjustDt ที่อ้างอิงเกรดนั้น ๆ ในการคำนวณ % ปรับ ถ้าแก้ period.status จะกระทบทุก module ที่เช็คว่าอยู่ในช่วงเวลาไหน (เช่น getPeriodNow())
ข้อควรระวังเมื่อทำงานกับ flow นี้
การแก้ period ที่ active อยู่แล้ว
ถ้า period status='confirm' แล้ว HR กลับมาแก้วันที่ ระบบจะ save period history ใหม่ (historyType='update') แต่ notification ที่ส่งไปแล้วก่อนหน้านี้จะไม่ส่งซ้ำ — ต้องระวังว่าผู้ใช้ที่ได้รับ notification เก่าอาจสับสน
การ extend period ที่มีผลกับการประเมิน
periodExtend มีผลเฉพาะช่วง settingGoal เท่านั้น ไม่ได้ขยายช่วงอื่น — ถ้า BU ต้องการขยายช่วง evaluation ต้องแก้ period หลักที่ระดับ period ไม่ใช่ BU
isNoQuota ที่ ConfirmEvaluation เซ็ต
เมื่อ confirmEvaluation mark EvaluateFormEntity.isNoQuota=true พนักงานคนนั้นจะถูก exclude จาก salary calculation ในรอบนั้น — ต้องระวังว่าถ้า unmark ภายหลังจะต้อง trigger คำนวณใหม่
approveAdjustSalaryStatus ที่ไม่ได้เป็น enum ที่ typed
status string ทั้งหมด (agreeAdjustSalary, rejectToReviewer, approveAdjustSalary, rejectToBu) เป็น string literal ใน code — ไม่มี TypeScript enum ที่ครอบ ทำให้ typo เป็นความเสี่ยง ถ้าจะ refactor ควรสร้าง enum ขึ้นมาก่อน
SalaryAdjust module ที่ชื่อคล้ายกัน
salaryAdjust และ salaryAdjustment เป็นคนละ module — salaryAdjust สร้างเอกสารเปล่า ๆ + preview, ส่วน salaryAdjustment ทำ flow อนุมัติจริง ก่อนแก้ต้องแน่ใจว่าอยู่ใน module ที่ถูกต้อง (ดูรายละเอียดในบทที่ 1)
ไป บท 3 Persistence →