diff --git a/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java new file mode 100644 index 000000000..8f86578fd --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -0,0 +1,135 @@ +package th.co.glr.hr.attendance; + +import java.time.Clock; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import th.co.glr.hr.notification.NotificationEmailService; + +/** + * Emails the configured recipient (and CC list), at 12:00 noon Asia/Bangkok on the last day of each + * month, how many days each configured employee attended in that month. The run happens at noon, so + * the last day counts only scans made before then. + * + *

A day counts when the employee has at least one {@code hr.attendance_punch} row with that + * {@code work_date} — a lone clock-in or a lone clock-out is a day attended. Punches resolve to an + * employee the same way the rest of attendance does ({@link AttendanceSql}: stored employee_id, + * else badge = card number or employee code). A blank recipient (or no employee codes) turns the + * report off. + * + *

Sent through {@link NotificationEmailService#sendLeaveSubmission}, the leave-email path + * (branded HTML, CC list, subject suffix), so its log lines say "leave submission email". That + * method is {@code @Async} and best-effort: a send failure is logged there and not rethrown, so the + * scheduled run itself never fails on a mail outage. + */ +@Component +public class AttendanceMonthlyReportJob { + private static final Logger log = LoggerFactory.getLogger(AttendanceMonthlyReportJob.class); + private static final ZoneId BANGKOK = ZoneId.of("Asia/Bangkok"); + + private final NamedParameterJdbcTemplate jdbc; + private final NotificationEmailService emailService; + private final String to; + private final List cc; + private final List employeeCodes; + private final Clock clock; + + @Autowired + public AttendanceMonthlyReportJob( + NamedParameterJdbcTemplate jdbc, + NotificationEmailService emailService, + @Value("${app.attendance.monthly-report.to:}") String to, + @Value("${app.attendance.monthly-report.cc:}") List cc, + @Value("${app.attendance.monthly-report.employee-codes:}") List employeeCodes + ) { + this(jdbc, emailService, to, cc, employeeCodes, Clock.system(BANGKOK)); + } + + /** Lets tests pin "now" so month-end and New Year's Eve runs are deterministic. */ + AttendanceMonthlyReportJob(NamedParameterJdbcTemplate jdbc, + NotificationEmailService emailService, String to, + List cc, List employeeCodes, Clock clock) { + this.jdbc = jdbc; + this.emailService = emailService; + this.to = to == null ? "" : to.trim(); + this.cc = cc; + this.employeeCodes = employeeCodes.stream() + .map(String::trim) + .filter(c -> !c.isEmpty()) + .toList(); + this.clock = clock; + } + + @Scheduled(cron = "0 0 12 L * *", zone = "Asia/Bangkok") + public void sendThisMonth() { + send(YearMonth.now(clock)); + } + + void send(YearMonth month) { + if (to.isEmpty() || employeeCodes.isEmpty()) { + log.info("Monthly attendance report is off (no recipient or employee codes configured)"); + return; + } + Map daysByCode = new HashMap<>(); + jdbc.query(""" + WITH days AS ( + SELECT %s AS employee_id, COUNT(DISTINCT p.work_date) AS days + FROM hr.attendance_punch p + %s + WHERE p.work_date BETWEEN :from AND :to + GROUP BY %s + ) + SELECT e.employee_code, + concat_ws(' ', e.first_name_th, e.last_name_th) AS full_name, + COALESCE(d.days, 0) AS days + FROM hr.employee e + LEFT JOIN days d ON d.employee_id = e.employee_id + WHERE e.employee_code IN (:codes) + """.formatted(AttendanceSql.RESOLVED_EMPLOYEE_ID, AttendanceSql.RESOLVED_EMPLOYEE_JOIN, + AttendanceSql.RESOLVED_EMPLOYEE_ID), + new MapSqlParameterSource() + .addValue("from", month.atDay(1)) + .addValue("to", month.atEndOfMonth()) + .addValue("codes", employeeCodes), + rs -> { + String code = rs.getString("employee_code"); + String name = rs.getString("full_name").trim(); + daysByCode.put(code, (name.isEmpty() ? "" : name + " ") + + "(" + code + "): " + rs.getInt("days") + " วัน"); + }); + + String title = "สรุปจำนวนวันเข้างาน " + thaiMonthYear(month); + ZonedDateTime now = ZonedDateTime.now(clock).withZoneSameInstant(BANGKOK); + StringBuilder body = new StringBuilder(title).append('\n') + .append("นับทุกวันที่มีการสแกนอย่างน้อย 1 ครั้ง " + + "(สแกนเข้าหรือสแกนออกอย่างใดอย่างหนึ่งก็นับ)\n") + .append("ข้อมูลถึงวันที่ ").append(now.getDayOfMonth()).append(' ') + .append(thaiMonthYear(YearMonth.from(now))).append(" เวลา ") + .append(now.format(DateTimeFormatter.ofPattern("HH:mm"))).append(" น.\n\n"); + for (String code : employeeCodes) { + body.append("- ") + .append(daysByCode.getOrDefault(code, code + ": ไม่พบรหัสพนักงานนี้ในระบบ")) + .append('\n'); + } + emailService.sendLeaveSubmission(to, cc, title, body.toString(), null, List.of()); + } + + private static String thaiMonthYear(YearMonth month) { + return month.getMonth().getDisplayName(TextStyle.FULL, Locale.forLanguageTag("th")) + + " " + (month.getYear() + 543); + } +} diff --git a/backend/src/main/java/th/co/glr/hr/config/SchedulingConfig.java b/backend/src/main/java/th/co/glr/hr/config/SchedulingConfig.java index f9f1eaec9..0564fd289 100644 --- a/backend/src/main/java/th/co/glr/hr/config/SchedulingConfig.java +++ b/backend/src/main/java/th/co/glr/hr/config/SchedulingConfig.java @@ -6,10 +6,10 @@ /** * Enables Spring's {@code @Scheduled} background workers (quotation expiry sweep, attendance - * daily recalc, BOT FX fetch, BOT holiday fetch) — but only outside the {@code test} - * profile. The factory-quote email outbox worker this list used to include ({@code - * FactoryQuoteEmailDispatchWorker}) is deleted — factory RFQ email is manual-only now, so there - * is nothing left on that surface for a scheduler to drive. + * daily recalc, BOT FX fetch, BOT holiday fetch, monthly attendance report email) — but only + * outside the {@code test} profile. The factory-quote email outbox worker this list used to + * include ({@code FactoryQuoteEmailDispatchWorker}) is deleted — factory RFQ email is manual-only + * now, so there is nothing left on that surface for a scheduler to drive. * *

Integration tests that boot a full {@code @SpringBootTest} context point it at the same shared * Testcontainers Postgres the non-Spring {@code AbstractPostgresIntegrationTest} tests use. Spring diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e04df7f00..0adb22ba7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -109,6 +109,13 @@ app: recalc-enabled: ${APP_ATTENDANCE_RECALC_ENABLED:true} recalc-lookback-days: ${APP_ATTENDANCE_RECALC_LOOKBACK_DAYS:7} backfill-on-startup: ${APP_ATTENDANCE_BACKFILL_ON_STARTUP:false} + # Month-end attendance-day count email (AttendanceMonthlyReportJob): 12:00 Asia/Bangkok on the last + # day of the month, covering that month. Blank recipient = off. cc: comma-separated, blank = no CC. + # Both are set on the deployment only — this repo is public. + monthly-report: + to: ${APP_ATTENDANCE_MONTHLY_REPORT_TO:} + cc: ${APP_ATTENDANCE_MONTHLY_REPORT_CC:} + employee-codes: ${APP_ATTENDANCE_MONTHLY_REPORT_EMPLOYEE_CODES:10016,10030} auth: # OFF by default: never seed the low-entropy employee_code as a temp login password (guessable # before first login). Opt in for local dev only; secure onboarding uses the HR reset endpoint. diff --git a/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java new file mode 100644 index 000000000..512561d54 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java @@ -0,0 +1,323 @@ +package th.co.glr.hr.attendance; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import th.co.glr.hr.brand.BrandAssets; +import th.co.glr.hr.mail.Mailer; +import th.co.glr.hr.notification.NotificationEmailService; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; + +/** + * The month-end attendance email: one mail to the recipient (plus CC), one line per employee, and + * the count is the number of distinct days with at least one punch — a lone clock-in or a lone + * clock-out is a day attended. + */ +class AttendanceMonthlyReportJobIntegrationTest extends AbstractPostgresIntegrationTest { + + private static final ZoneId BANGKOK = ZoneId.of("Asia/Bangkok"); + private static final YearMonth OCTOBER = YearMonth.of(2026, 10); + private static final String RECIPIENT = "report@example.test"; + + private final CapturingMailer mailer = new CapturingMailer(); + private final NotificationEmailService emailService = + new NotificationEmailService(mailer, new BrandAssets(), "", "", ""); + + private long alice; + private long bob; + private long someoneElse; + + @BeforeEach + void seedEmployees() { + alice = insertEmployee("10016", "ทดสอบ", "หนึ่ง"); + bob = insertEmployee("10030", "ทดสอบ", "สอง"); + someoneElse = insertEmployee("10099", "คนอื่น", "ไม่เกี่ยว"); + } + + @Test + void aDayWithOnlyAClockInOrOnlyAClockOutCountsAsAttended() { + insertPunch(alice, at(1, 8, 20)); + insertPunch(alice, at(1, 17, 40)); // in + out on the 1st: one day, not two + insertPunch(alice, at(2, 8, 25)); // clock-in only + insertPunch(alice, at(3, 17, 35)); // clock-out only + + job(List.of("10016")).send(OCTOBER); + + assertThat(mailer.onlyBody()).contains("ทดสอบ หนึ่ง (10016): 3 วัน"); + } + + /** + * The prod shape for these two people: fingerprint/PIN punches arrive with the employee CODE as + * badge_code and are stored with employee_id NULL (bulk ingest matches badge_card_no only), and + * only the odd card tap is linked. The rest of attendance resolves both via + * AttendanceSql.RESOLVED_EMPLOYEE_JOIN; this report must count the same days. + */ + @Test + void punchesStoredWithoutAnEmployeeIdStillCountWhenTheBadgeResolvesToThem() { + insertUnlinkedPunch("10016", at(1, 8, 20)); // fingerprint, by employee code + insertPunch(alice, at(2, 8, 20)); // linked card tap ... + insertUnlinkedPunch("10016", at(2, 17, 40)); // ... plus fingerprint, same day + insertUnlinkedPunch("CARD10016", at(3, 8, 20)); // unlinked tap, by card serial + insertUnlinkedPunch("10099", at(4, 8, 20)); // someone else's code + + job(List.of("10016")).send(OCTOBER); + + assertThat(mailer.onlyBody()).contains("ทดสอบ หนึ่ง (10016): 3 วัน"); + } + + @Test + void punchesOnTheDaysEitherSideOfTheMonthAreNotCounted() { + insertPunch(alice, LocalDate.of(2026, 9, 30).atTime(23, 50)); + insertPunch(alice, at(31, 9, 0)); + insertPunch(alice, LocalDate.of(2026, 11, 1).atTime(0, 10)); + + job(List.of("10016")).send(OCTOBER); + + assertThat(mailer.onlyBody()).contains("ทดสอบ หนึ่ง (10016): 1 วัน"); + } + + @Test + void anEmployeeWithNoPunchesIsListedWithZeroRatherThanLeftOut() { + insertPunch(alice, at(5, 8, 30)); + + job(List.of("10016", "10030")).send(OCTOBER); + + assertThat(mailer.onlyBody()) + .contains("ทดสอบ หนึ่ง (10016): 1 วัน") + .contains("ทดสอบ สอง (10030): 0 วัน"); + } + + @Test + void anotherEmployeesPunchesNeitherAppearNorInflateTheCount() { + insertPunch(alice, at(6, 8, 30)); + insertPunch(someoneElse, at(6, 8, 31)); + insertPunch(someoneElse, at(7, 8, 30)); + insertPunch(someoneElse, at(8, 8, 30)); + + job(List.of("10016", "10030")).send(OCTOBER); + + assertThat(mailer.onlyBody()) + .contains("ทดสอบ หนึ่ง (10016): 1 วัน") + .contains("ทดสอบ สอง (10030): 0 วัน") + .doesNotContain("10099") + .doesNotContain("คนอื่น"); + } + + @Test + void aConfiguredCodeWithNoEmployeeIsCalledOutNotSilentlyDropped() { + job(List.of("10016", "77777")).send(OCTOBER); + + assertThat(mailer.onlyBody()) + .contains("ทดสอบ หนึ่ง (10016): 0 วัน") + .contains("77777: ไม่พบรหัสพนักงานนี้ในระบบ"); + } + + @Test + void sendsExactlyOneMailToTheRecipientNamingTheMonthInThai() { + job(List.of("10016", "10030")).send(OCTOBER); + + assertThat(mailer.sent).hasSize(1); + assertThat(mailer.sent.get(0).to()).isEqualTo(RECIPIENT); + assertThat(mailer.sent.get(0).subject()).contains("ตุลาคม 2569"); + } + + @Test + void copiesEveryConfiguredCcAddress() { + new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, + List.of(" cc@example.test ", "", "second@example.test"), List.of("10016")).send(OCTOBER); + + assertThat(mailer.sent).hasSize(1); + assertThat(mailer.sent.get(0).to()).isEqualTo(RECIPIENT); + assertThat(mailer.sent.get(0).cc()).containsExactly("cc@example.test", "second@example.test"); + } + + @Test + void withNoCcConfiguredTheMailHasNoCc() { + job(List.of("10016")).send(OCTOBER); + + assertThat(mailer.sent).hasSize(1); + assertThat(mailer.sent.get(0).cc()).isEmpty(); + } + + /** The CC path is the rich send, which needs an HTML part; names come from the DB, so escape them. */ + @Test + void theHtmlPartRepeatsTheLinesWithNamesEscaped() { + jdbc.update(""" + INSERT INTO hr.employee (employee_code, first_name_th, last_name_th, is_active) + VALUES ('10051', 'ชื่อ', 'สกุล', TRUE) + """, Map.of()); + insertPunch(alice, at(1, 8, 20)); + + job(List.of("10016", "10051")).send(OCTOBER); + + assertThat(mailer.sent.get(0).html()) + .contains("ทดสอบ หนึ่ง (10016): 1 วัน") + .contains("<b>สกุล</b>") + .doesNotContain("สกุล"); + } + + /** With a CC configured, so the leave path's own "no recipient at all" skip cannot mask this one. */ + @Test + void aBlankRecipientSendsNothingEvenWithACcConfigured() { + insertPunch(alice, at(1, 8, 20)); + + new AttendanceMonthlyReportJob(jdbc, emailService, " ", List.of("cc@example.test"), List.of("10016")) + .send(OCTOBER); + + assertThat(mailer.sent).isEmpty(); + } + + @Test + void noEmployeeCodesSendsNothing() { + new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), List.of(" ", "")).send(OCTOBER); + + assertThat(mailer.sent).isEmpty(); + } + + @Test + void anEmployeeWithNoNameOnFileIsListedByCodeWithoutPrintingNull() { + jdbc.update("INSERT INTO hr.employee (employee_code, is_active) VALUES ('10050', TRUE)", Map.of()); + + job(List.of("10050")).send(OCTOBER); + + assertThat(mailer.onlyBody()).contains("(10050): 0 วัน").doesNotContain("null"); + } + + /** The run at noon on the last day reports that same month, including that morning's scans. */ + @Test + void theRunAtNoonOnTheLastDayReportsThatMonthIncludingThatMorning() { + insertPunch(alice, at(20, 8, 30)); + insertPunch(alice, at(31, 8, 15)); + Clock noonOn31October = Clock.fixed( + ZonedDateTime.of(2026, 10, 31, 12, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + + new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), List.of("10016"), noonOn31October) + .sendThisMonth(); + + assertThat(mailer.sent.get(0).subject()).contains("ตุลาคม 2569"); + assertThat(mailer.onlyBody()) + .contains("(10016): 2 วัน") + .contains("ข้อมูลถึงวันที่ 31 ตุลาคม 2569 เวลา 12:00 น."); + } + + @Test + void theRunOnTheLastDayOfDecemberReportsDecember() { + Clock noonOn31December = Clock.fixed( + ZonedDateTime.of(2026, 12, 31, 12, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + + new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), List.of("10016"), noonOn31December) + .sendThisMonth(); + + assertThat(mailer.sent).hasSize(1); + assertThat(mailer.sent.get(0).subject()).contains("ธันวาคม 2569"); + } + + @Test + void firesAtNoonBangkokOnTheLastDayOfEveryMonth() throws Exception { + Scheduled scheduled = AttendanceMonthlyReportJob.class + .getMethod("sendThisMonth") + .getAnnotation(Scheduled.class); + assertThat(scheduled).as("sendThisMonth must be @Scheduled").isNotNull(); + assertThat(scheduled.zone()).isEqualTo("Asia/Bangkok"); + + CronExpression cron = CronExpression.parse(scheduled.cron()); + ZonedDateTime midOctober = ZonedDateTime.of(2026, 10, 15, 12, 0, 0, 0, BANGKOK); + ZonedDateTime first = cron.next(midOctober); + + assertThat(first).isEqualTo(ZonedDateTime.of(2026, 10, 31, 12, 0, 0, 0, BANGKOK)); + assertThat(cron.next(first)).isEqualTo(ZonedDateTime.of(2026, 11, 30, 12, 0, 0, 0, BANGKOK)); + assertThat(cron.next(ZonedDateTime.of(2027, 2, 1, 0, 0, 0, 0, BANGKOK))) + .isEqualTo(ZonedDateTime.of(2027, 2, 28, 12, 0, 0, 0, BANGKOK)); + assertThat(cron.next(ZonedDateTime.of(2028, 2, 1, 0, 0, 0, 0, BANGKOK))) + .isEqualTo(ZonedDateTime.of(2028, 2, 29, 12, 0, 0, 0, BANGKOK)); + } + + private AttendanceMonthlyReportJob job(List employeeCodes) { + return new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), employeeCodes); + } + + /** + * Mirrors the prod rows: created by the attendance import, so no division, hire date or email, + * and badge_card_no holds the card serial, not the employee code. + */ + private long insertEmployee(String code, String firstName, String lastName) { + return jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, badge_card_no, first_name_th, last_name_th, is_active) + VALUES (:code, 'CARD' || :code, :firstName, :lastName, TRUE) + RETURNING employee_id + """, Map.of("code", code, "firstName", firstName, "lastName", lastName), Long.class); + } + + private void insertUnlinkedPunch(String badgeCode, LocalDateTime local) { + jdbc.update(""" + INSERT INTO hr.attendance_punch (site_code, badge_code, punch_time, work_date) + VALUES ('SHOWROOM', :badge, :at, :workDate) + """, + Map.of("badge", badgeCode, "at", local.atZone(BANGKOK).toOffsetDateTime(), + "workDate", local.toLocalDate())); + } + + private void insertPunch(long employeeId, LocalDateTime local) { + OffsetDateTime at = local.atZone(BANGKOK).toOffsetDateTime(); + jdbc.update(""" + INSERT INTO hr.attendance_punch (site_code, badge_code, punch_time, work_date, employee_id) + VALUES ('SHOWROOM', :badge, :at, :workDate, :employeeId) + """, + Map.of("badge", "B" + employeeId, "at", at, "workDate", local.toLocalDate(), + "employeeId", employeeId)); + } + + private static LocalDateTime at(int dayOfOctober, int hour, int minute) { + return OCTOBER.atDay(dayOfOctober).atTime(hour, minute); + } + + private static final class CapturingMailer implements Mailer { + record SentMail(String to, List cc, String subject, String body, String html) {} + + final List sent = new ArrayList<>(); + + String onlyBody() { + assertThat(sent).as("exactly one report mail").hasSize(1); + return sent.get(0).body(); + } + + @Override + public void send(OutgoingEmail email) { + sent.add(new SentMail(email.to(), email.cc(), email.subject(), email.textBody(), email.htmlBody())); + } + + @Override + public void send(String to, String subject, String body) { + sent.add(new SentMail(to, List.of(), subject, body, null)); + } + + @Override + public void sendHtml(String to, String subject, String htmlBody, String textBody, + List inlineImages) { + sent.add(new SentMail(to, List.of(), subject, textBody, htmlBody)); + } + + @Override + public void sendWithAttachment(String to, String subject, String body, String filename, byte[] bytes) { + sent.add(new SentMail(to, List.of(), subject, body, null)); + } + + @Override + public void sendWithAttachments(String to, String subject, String body, List attachments) { + sent.add(new SentMail(to, List.of(), subject, body, null)); + } + } +} diff --git a/render.yaml b/render.yaml index 3147b8bce..91d70e5e8 100644 --- a/render.yaml +++ b/render.yaml @@ -40,7 +40,7 @@ services: name: gl-r-erp runtime: image image: - url: ghcr.io/waritctd/glr-hr-backend:v2026-10-22 + url: ghcr.io/waritctd/glr-hr-backend:v2026-10-28 creds: fromRegistryCreds: name: ghcr-glr @@ -144,3 +144,10 @@ services: # dashboard without a rebuild. - key: APP_LEAVE_SUBMISSION_INBOX sync: false + # Recipient of the month-end attendance-day count email (last day of month, 12:00 Bangkok). + # Blank = off. Set on the dashboard; the address is deliberately not in this public repo. + - key: APP_ATTENDANCE_MONTHLY_REPORT_TO + sync: false + # CC list for the same email, comma-separated. Blank = no CC. Set on the dashboard. + - key: APP_ATTENDANCE_MONTHLY_REPORT_CC + sync: false