Skip to content

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 ปี

typescript
@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 goalsettingGoalStartsettingGoalEndตั้งตัวชี้วัดของปี — พนักงาน+หัวหน้าตกลง KPI
Check datacheckDataStartcheckDataEndตรวจสอบข้อมูลบุคลากร (อายุการทำงาน, วันลา)
Update resultupdateResultStartupdateResultEndบันทึกผลงานที่ทำได้จริง
EvaluationevaluationStartevaluationEndหัวหน้าประเมิน + ให้คะแนน
FeedbackfeedbackStartfeedbackEndแจ้งผลกลับให้ผู้รับการประเมิน

ฟิลด์ 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) แทนที่จะใช้สถานะ

typescript
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 แบบ

typescript
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

MethodPathServiceหน้าที่
GET/period/listgetPeriodListรายการ period ทั้งหมด (พร้อม dropdown status)
GET/period/info/:idgetPeriodByIdรายละเอียด 1 period + periodExtend + periodHistory
POST/periodcreateสร้าง period ใหม่
PUT/periodupdateแก้ไข period (ใช้ historyType update หรือ extendTime)
DELETE/period/:iddeletesoft-delete period + ref table
GET/period/list_dropdowngetDropdownPeriodListdropdown ปีงบประมาณ + status + historyType
POST/period/extendcreate_periodExtendขยายช่วง settingGoal ของ BU เฉพาะ + ส่ง email
GET/period/get_period_nowgetPeriodNowตรวจว่าวันนี้อยู่ในช่วง 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

typescript
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);
  }
}

ลำดับขั้น

  1. prepareEntity() เรียก validateData() เพื่อเช็ค duplicate year (ด้านบน)
  2. สร้าง PeriodHistoryEntity ที่มี historyType='create', status=body.status (ส่วนใหญ่คือ draft)
  3. เพิ่ม StampAudit(req, body) เพื่อแปะ createdBy, createdByName, updatedAt จาก JWT payload
  4. 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/extendpms-api/src/modules/period/period.service.ts (branch: uat):170-215

typescript
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 อย่าง

  1. หาผู้รับ — ดึง PMS admin + hrbp_department_admin + admin1-5 email ของ approver chain ของ BU นั้น ๆ
  2. เรนเดอร์ 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 แต่ละแบบ

typescript
@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

typescript
// 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

  1. repo.getCurrentPeriodWithNotifications() ดึง period ปัจจุบัน + configNotification ทั้งหมดที่ status=confirm
  2. วนรอบ notification แต่ละแบบ เลือก targetDate ตาม notiTypeCode — เช่น notiGoalStart ใช้ tbPeriod.settingGoalStart, notiEvaluationStart ใช้ tbPeriod.evaluationStart
  3. คำนวณ targetDate ลดด้วย advanceNotiDay ถ้า isAdvanceNoti=truecalculateNotificationDate() ที่บรรทัด 44-54
  4. ถ้า targetDate ตรงกับวันนี้ → เรียก sendPeriodNotificationEmail()
  5. แบ่งผู้รับเป็น batch ละ 500 คน แล้วส่งทีละ batch (batchSize = 500 ที่บรรทัด 162)

Notification type ทั้ง 7 แบบ + ฟิลด์อ้างอิงใน period

notiTypeCodeฟิลด์ period ที่อ้างTemplateSubject (ย่อ)
notiGoalStartsettingGoalStartperiod/period-goal-start.ejsแจ้งรอบการกำหนดตัวชี้วัด
notiGoalSubmitsettingGoalEndperiod/period-goal-submit.ejsใกล้ถึงวันกำหนดส่งตัวชี้วัด
notiCheckDatacheckDataStartperiod/period-check-data.ejsตรวจสอบความถูกต้องของข้อมูล
notiUpdateResultupdateResultStartperiod/period-update-result.ejsบันทึกผลงานที่ทำได้จริง
notiEvaluationStartevaluationStartperiod/period-evaluation-start.ejsประเมินผลการปฏิบัติงาน
notiEvaluationEndevaluationEndperiod/period-evaluation-end.ejsส่งการประเมิน
notiFeedbackfeedbackEndperiod/period-feedback.ejsแจ้งผลประเมินให้ผู้รับการประเมิน

เงื่อนไขที่ pms-api/src/modules/configNotification/configNotificationSendEmail.service.ts (branch: uat):133 สังเกตุว่ามี comment (targetDate && this.isSameDate(targetDate, today)) ที่ถูก comment ปิดไว้ — ในขณะนี้เงื่อนไขคือ

typescript
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 — การเห็นชอบผลประเมินจาก approver
  • updatePerformanceStatus — สถานะการอัปเดตผลงานในช่วง updateResult
  • reportResultStatus — สถานะการแจ้งผลในช่วง feedback
  • isRequestEdit, requestEditStatus — ขอแก้ไขหลังปิดรอบ (จาก module requestEditForm)
  • isNoQuota — กรณีพิเศษไม่มีโควต้าเกรด (จาก module confirmEvaluation)

state machine ของการสร้าง form อยู่ใน pms-api/src/modules/createForm/createForm.service.ts (branch: uat):267-292

typescript
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

typescript
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 ตามตาราง

MethodPathหน้าที่
GET/evaluateForm/evaluateTracking_listติดตามสถานะการประเมิน (track ว่าใครส่งแล้ว, ใครยังไม่ส่ง)
GET/evaluateForm/evaluateTrackingSalary_listtrack เดียวกันแต่มีมุมมองเงินเดือน
GET/evaluateForm/exportpdf_evaluation/:idexport ผลประเมินเป็น PDF ผ่าน puppeteer
GET/evaluateForm/exportpdf_evaluation_mobile/:idเดียวกัน สำหรับมุมมองมือถือ
GET/evaluateForm/export_excelexport ผลประเมินเป็น Excel
GET/evaluateForm/get_dropDown_orgUnit_pmsdropdown หน่วยงานที่ผู้ใช้มีสิทธิ์
GET/evaluateForm/get_dropDown_statusdropdown สถานะ
PUT/evaluateForm/emplevel_emp_codeอัปเดต employee level รายคน
GET/evaluateForm/export_excel_allexport ผลประเมินทั้งหมดเป็น 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

typescript
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

MethodPathServiceหน้าที่
GET/confirmEvaluation/orgUnit/listgetOrgUnitConfirmListรายการหน่วยงานที่ต้องยืนยัน
GET/confirmEvaluation/orgUnit/info/:idgetOrgUnitConfirmByIdรายละเอียดผลยืนยันของหน่วยงาน
PUT/confirmEvaluation/orgUnit/update_no_quotaupdateNoQuotaปรับกรณีไม่มีโควต้าเกรด
PUT/confirmEvaluation/orgUnit/update_rejectupdateRejectปฏิเสธผลรวมของหน่วยงาน ส่งกลับไปแก้
PUT/confirmEvaluation/orgUnit/confirm_update_statusorgUnitConfirmUpdateStatusยืนยันผลของหน่วยงาน
GET/confirmEvaluation/orgUnit/export_excel/:id/:pageNameexportExcelexport ผลยืนยันเป็น Excel
GET/confirmEvaluation/quota/listgetQuotaListรายการโควต้าเกรดแต่ละหน่วยงาน
PUT/confirmEvaluation/quota/confirm_update_statusquotaUpdateStatusยืนยันโควต้า
PUT/confirmEvaluation/quota/update_no_quotaupdateNoQuotaFromQuotaปรับโควต้า → no quota

การยืนยันผลหน่วยงานเก็บใน OrgUnitConfirmHistoryEntity และอัปเดตฟิลด์ isNoQuota, updateNoQuotaBy ของ EvaluateFormEntity

SalaryAdjust — เริ่มกระบวนการปรับเงินเดือน

หลัง confirmEvaluation ยืนยันครบแล้ว HR/admin เริ่มสร้างเอกสารปรับเงินเดือนผ่าน salaryAdjust module — pms-api/src/modules/salaryAdjust/salaryAdjust.controller.ts (branch: uat):11-57

MethodPathServiceหน้าที่
GET/salaryAdjust/listgetSalaryAdjustListรายการปีงบประมาณที่มี
GET/salaryAdjust/bu_listgetSalaryAdjustBuListรายการหน่วยงานในแต่ละปี
GET/salaryAdjust/infogetSalaryAdjustByIdรายละเอียดการปรับของ BU
POST/salaryAdjustcreateสร้างเอกสารใหม่
PUT/salaryAdjustupdateแก้ไขเอกสาร
PUT/salaryAdjust/statusupdateStatusBureject เอกสารของ 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

MethodPathServiceหน้าที่
GET/salaryAdjustment/listgetSalaryAdjustmentListรายการปรับเงินเดือน
GET/salaryAdjustment/infogetSalaryAdjustmentByIdรายละเอียด BU
PUT/salaryAdjustmentupdateแก้ไข + auto-flow status
PUT/salaryAdjustment/admin_orgunit_confirmadminOrgUnitConfirmBeforeDirectorผู้ดูแลหน่วยงานยืนยัน
GET/salaryAdjustment/list_approvegetSalaryAdjustmentApproveListรายการรออนุมัติ
PUT/salaryAdjustment/approve_updatesalaryAdjustmentApproveUpdatereviewer/approver อนุมัติหรือ reject
POST/salaryAdjustment/export_excel_budgetexportExcelBudgetexport งบประมาณเป็น Excel
GET/salaryAdjustment/export_excel_employeeexportExcelEmployeeexport รายชื่อพนักงาน
POST/salaryAdjustment/import_excel_budgetimportExcelBudgetimport งบประมาณกลับเข้าระบบ

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

FromTriggerToผู้กระทำการ
(new)create with evaluatorwaitEvaluatorsystem
(new)create with approverwaitApproversystem
(new)create without evaluator/approverwaitDirectorsystem
waitEvaluatorevaluator rejectrejectAdminOrgUnitevaluator
waitEvaluatorevaluator confirmwaitApprover หรือ waitDirectorevaluator
waitDirectordirector confirmconfirmAdjustSalarydirector
waitDirectordirector rejectrejectAdminOrgUnitdirector
confirmAdjustSalaryreviewer rejectrejectToBureviewer (pms_salary_viewer)
confirmAdjustSalaryreviewer confirmagreeAdjustSalaryreviewer
agreeAdjustSalaryapprover rejectrejectToReviewerapprover (pms_salary_approver)
agreeAdjustSalaryapprover confirmapproveAdjustSalaryapprover

หลักฐานการเปลี่ยน 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

typescript
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 สำคัญ

  1. ใช้ transactionentityManager.transaction() ครอบทั้ง flow
  2. ตรวจสิทธิ์ใน service layer — ใช้ roles จาก JWT payload + check pms_salary_approver/pms_salary_viewer
  3. บันทึก history ทุกครั้งSalaryAdjustBuHistoryEntity กับ historyType ที่ map จาก dropdown
  4. Email notification — ถ้า status เป็น approveAdjustSalary จะส่ง email PMS_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

typescript
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 ปิด

CronScheduleที่อยู่หน้าที่
handleCronNotifications0 6 * * * Asia/Bangkokpms-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-1936sync ข้อมูลองค์กร/พนักงานจาก dbWorkforce เข้า PMS ทุก 1 นาที
MigrateDataController (ปิด)// @Cron('0 6 * * *') commentpms-api/src/modules/migrateData/migrateData.controller.ts (branch: uat):15-16migrate ข้อมูล workforce ครั้งใหญ่ — ปิดไว้ รัน manual ผ่าน endpoint

UsagePermission cron — หัวใจที่ซ่อนอยู่

typescript
@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