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