Skip to content

5 · Cron jobs

pms-api ใช้ @nestjs/schedule ^6.0.0 (package.json) เปิดด้วย ScheduleModule.forRoot() ที่ pms-api/src/app.module.ts (branch: uat):104

พบ @Cron decorator 3 จุด2 จุด active และ 1 จุดถูก comment ปิด (method ยังอยู่ในไฟล์แต่ไม่ถูก schedule)

ทุก claim อ้างจาก canonical branch uat

สรุปรายการ cron

#JobCron expressionTimezoneสถานะไฟล์
1Daily period notifications'0 6 * * *' (06:00 ทุกวัน)Asia/BangkokActiveconfigNotification.controller.ts:30-41
2Workforce trigger sync'*/1 * * * *' (ทุก 1 นาที)default server TZActiveusagePermission.service.ts:1928-1936
3Daily data migrationเคยเป็น '0 6 * * *' / test '*/10 * * * * *'(comment)InactivemigrateData.controller.ts:15-29

Job 1 — Daily notification check (handleCronNotifications)

Schedule และ entry point

pms-api/src/modules/configNotification/configNotification.controller.ts (branch: uat):27-41

typescript
// @Cron('*/10 * * * * *', {   // leftover test schedule
@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);
  }
}

Code trace

06:00 Asia/Bangkok
  → ConfigNotificationController.handleCronNotifications()
    → ConfigNotificationSendEmailService.checkAndSendNotifications()
      → repo.getCurrentPeriodWithNotifications()
      → for each ConfigNotification where isEmail=true
        → switch(notiTypeCode) map period date + EJS template
        → sendPeriodNotificationEmail()
          → getNotificationRecipients()
          → EmailService.sendEmail() (batch 500)
          → insert NotifyEntity log

Notification types (7 ชนิดใน switch)

จาก configNotificationSendEmail.service.ts (branch: uat):81-129

notiTypeCodeวันที่อ้างอิงจาก periodEJS 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แจ้งผลประเมิน

เอกสารเก่าบางฉบับพูดถึง 8 ประเภท (รวม approval) — ใน switch ปัจจุบันมี 7 case + default: continue ไม่มี notiApproval ในโค้ดนี้

Advance notification — ถ้า isAdvanceNoti และ advanceNotiDay > 0 จะถอยวันที่ด้วย calculateNotificationDate() (:44-54)

Recipients และ email batch

sendPeriodNotificationEmail (:142-200)

  • เลือกผู้รับตาม flag isEvaluatee / isEvaluator / isApprover / isOrgUnitAdmin
  • แบ่ง batch ละ 500 อีเมล (batchSize = 500)
  • template data ใส่ backofficeUrl: process.env.VTRC_ESS_URL (บรรทัด 172) — ลิงก์กลับเข้า ESS
  • วันที่ในอีเมลแปลงเป็น พ.ศ. ผ่าน getThaiDateFullMonth()

Failure modes — Job 1

Failure modeพฤติกรรมจริงผลกระทบ
ไม่ await checkAndSendNotifications()fire-and-forget — catch ของ controller จับได้แค่ sync throwerror ภายใน async ไปที่ service logger แทน; log "completed" อาจพิมพ์ก่อนงานจบ
Date gate ถูก commentบรรทัด :133if (process.env.NODE_ENV === 'development' || targetDate /* isSameDate ... */)นอก development ถ้า targetDate มีค่า (truthy Date) จะส่ง ทุกประเภทที่มีวันที่ตั้งไว้ ทุกวัน ไม่ใช่เฉพาะวันที่ตรงกับวันนี้ — bug ความถูกต้องสำคัญ
SMTP ล่มEmailService retry 3 ครั้งแล้ว fail; controller console.error อย่างเดียวผู้ใช้ไม่ได้อีเมล; ไม่มี alert ไป ops
ไม่มี period / configservice log แล้ว returnเงียบ — ถูกต้องตาม logic
ไม่มี recipientslog แล้ว skip ประเภทนั้นเงียบ
Multi-instanceไม่มี distributed lockทุก replica ส่งอีเมลซ้ำเวลา 06:00 (UNVERIFIED จำนวน replica)
console.log ใน prodmain.ts no-op เฉพาะ console.log ไม่ปิด console.errorerror ยังเห็นใน stdout; success log หายใน prod

Manual trigger

POST /configNotification/check-notifications เป็น @Public() แต่ทำงานเฉพาะ NODE_ENV === 'development' (controller.ts:44-55) — บน UAT คืน { message: 'Nope !!!' }


Job 2 — Workforce trigger sync (handleCron)

Schedule และ entry point

pms-api/src/modules/usagePermission/usagePermission.service.ts (branch: uat):1928-1936

typescript
// ตรวจสอบข้อมูลที่ update มาจาก workforce ทุกๆ 1 นาที เพื่อ update ข้อมูลให้กับ pms
@Cron('*/1 * * * *')
async handleCron() {
  try {
    await this.getEmployeeTriggerQeue();
  } catch (error) {
    this.logger.error(error);
  }
}

หมายเหตุ — ไม่ระบุ timeZone จึงใช้ timezone ของ process (Dockerfile ตั้ง TZ=Asia/Bangkok ที่บรรทัด 5 — สอดคล้องถ้า deploy ตาม Dockerfile)

Code trace

ทุก 1 นาที
  → UsagePermissionService.handleCron()
    → getEmployeeTriggerQeue()                    // :1698
      → repo.getEmployeeQueue()                   // workforce: tbEmployeeTriggerQueue where isSent=0 and isFail=0
      → sort empCode ASC, createdAt DESC
      → dedupe ตาม identityCard (+ dataType สำหรับประเภทที่อนุญาตซ้ำ)
      → sort ตาม dataTypePriority (resign ก่อน … promote ท้าย)
      → for each latestEmployees:
          SendEmailPromote / Transfer / TransferAndPromote → sendEmailPromoteTransfer()
          CancelTransfer → updateCancelTransfer()
          insert → processNewPMS()
          else → processUpdatePMS()               // promote, transfer_DB, resign, …
      → mark queue rows isSent / isFail

Queue source

pms-api/src/modules/usagePermission/usagePermission.repositories.ts (branch: uat):507-516

typescript
public async getEmployeeQueue(): Promise<EmpTriggerQueueDto[]> {
  const query = this.workforceEntityManager
    .createQueryBuilder().from('tbEmployeeTriggerQueue', 'emp')
    .where('emp.isSent = 0 and emp.isFail = 0')
    .orderBy('emp.createdAt', 'ASC');
  const result = await query.getRawMany();
  return result as EmpTriggerQueueDto[];
}

อ่านจาก workforce connection โดยตรง — ไม่ผ่าน HTTP

dataType ที่รองรับ (จาก dedupe / priority)

getEmployeeTriggerQeue บรรทัด :1713-1750

dataTypeอนุญาต duplicate ในรอบเดียวPriority (เลขน้อย = ก่อน)Handler หลัก
resignใช่1processUpdatePMS
CancelTransferใช่2updateCancelTransfer
SendEmailPromoteใช่3sendEmailPromoteTransfer
SendEmailTransferใช่4sendEmailPromoteTransfer
TransferAndPromoteใช่5sendEmailPromoteTransfer
transferOrgunitใช่6processUpdatePMS
transfer_DBใช่7processUpdatePMS
promoteใช่8processUpdatePMS
insertไม่ (key = identityCard)999 (default)processNewPMS
อื่น ๆไม่999processUpdatePMS

service ไฟล์นี้ยาว 1,937 บรรทัด — logic sync กระจายอยู่หลาย method (processUpdatePMS, processNewPMS, email promote ฯลฯ) เป็นจุดที่แก้แล้วพังง่าย

Failure modes — Job 2

Failure modeพฤติกรรมจริงผลกระทบ
Overlapping execution@nestjs/schedule ไม่กัน job ซ้อน; ไม่มี mutex ในโค้ดถ้า getEmployeeTriggerQeue > 1 นาที → 2 job แข่งกัน mark queue / อัปเดต PMSUser → race / duplicate email
Multi-instanceไม่มี distributed lockreplica ทุกตัวดึง queue ชุดเดียวกันพร้อมกัน
Queue query ล้มgetEmployeeQueue catch แล้ว return nullNPE/skip ตอน .slice() ถ้าไม่เช็ค null — ขึ้นกับ runtime (ควรตรวจ)
Partial failure กลาง looperror ต่อคนอาจถูกกลืนใน handler ย่อยบางคน sync สำเร็จ บางคนค้าง isSent=0
Silent top-level catchthis.logger.error(error) อย่างเดียวไม่มี alert ภายนอก
Load สูงตอน promote หมู่sort + loop แบบ sequentialรอบถัดไปเริ่มก่อนจบ → ซ้อน (ดูด้านบน)

Manual trigger ที่เกี่ยวข้อง

POST /usagePermission/send-email-promote-new (@Public() + NODE_ENV === 'development') — ทดสอบส่งอีเมล promote/transfer โดยไม่รอ queue


Job 3 — Data migration (INACTIVE)

pms-api/src/modules/migrateData/migrateData.controller.ts (branch: uat):14-29

typescript
// @Cron('*/10 * * * * *', {
// @Cron('0 6 * * *', {
//     timeZone: 'Asia/Bangkok',
// })
async handleCronMigration() {
  // await this.service.migrateAllData();
}
  • @Cron ถูก comment ทั้งคู่ — Nest จะ ไม่ register เป็น scheduled job
  • method handleCronMigration ไม่ถูกเรียกจากที่อื่นในโค้ด (dead schedule path)
  • migration ทำผ่าน manual POST /migrateData/migrate-data เท่านั้น และจำกัด NODE_ENV === 'development'
  • service จริงอยู่ที่ migrateData.service.ts (~631 บรรทัด) — migrateAllData(buCode?, empCode?)

ถ้า uncomment @Cron โดยไม่ใส่ distributed lock จะชนกับความเสี่ยงเดียวกับ Job 1 (อีเมล/migrate ซ้ำทุก instance)


เอกสารภายในที่ไม่ตรงกับโค้ด

pms-api/docs/PMS-Period-Notification-System.md (branch: uat) ระบุว่า:

ข้อในเอกสารความจริงในโค้ด
cron อยู่ที่ period.controller.tsอยู่ที่ configNotification.controller.ts
cron ถูก comment ต้อง uncomment เองJob 1 active แล้ว (@Cron('0 6 * * *'))
ขั้นตอน enable ใน README ของ docsทำให้ engineer เข้าใจผิดว่าระบบแจ้งเตือนยังไม่ทำงาน

จัดเป็น QUAL-PMS-01 ใน 06 Scorecard


สิ่งที่ไม่มีในทุก cron ของ pms-api

  1. Distributed lock / leader election (Redis, DB lock, k8s CronJob แยก)
  2. Overlap guard (@Cron + semaphore / isRunning flag)
  3. Ops alert เมื่อ fail (Slack/email ทีม) — มีแค่ logger / console
  4. Metrics (duration, success count, queue depth)
  5. Idempotency key ระดับอีเมลรายวัน (นอกจาก NotifyEntity log ภายหลังส่ง)

ตารางเปรียบเทียบกับ benefit-api

หัวข้อpms-apibenefit-api
@nestjs/schedule version^6.0.0^2.2.0
Active crons2(ดู benefit ch05)
รอบถี่สุดทุก 1 นาที
Distributed lockไม่มีไม่มี (ปัญหาเดียวกัน)
Error alertไม่มีไม่มี

แนวทาง monitor ตอนปฏิบัติการ

  1. ดูขนาด tbEmployeeTriggerQueue ที่ isSent=0 AND isFail=0 — ถ้าโตต่อเนื่อง cron sync มีปัญหา
  2. วัดเวลา getEmployeeTriggerQeue — ถ้า median > 45s เสี่ยง overlap
  3. ตรวจ tbNotify หลัง 06:00 — ถ้ามี notify ซ้ำทุกประเภททุกวัน สอดคล้องกับ date-gate bug ที่ Job 1
  4. อย่าเชื่อ PMS-Period-Notification-System.md จนกว่าจะอัปเดตให้ตรงโค้ด

ไป บท 6 Scorecard