From eb628c6e94a44618d07f81725025e5b6dd87b52c Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:54:37 +0700 Subject: [PATCH 1/6] =?UTF-8?q?chore(release):=20render.yaml=20=E2=86=92?= =?UTF-8?q?=20backend=20image=20v2026-10-28=20(develop=20=E2=86=92=20main?= =?UTF-8?q?=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- render.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index 3147b8bce..d78547290 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 From 148313f6b570aa5886ea4bfc3ebc78a9d6773b3d Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:31:09 +0700 Subject: [PATCH 2/6] feat(attendance): email the month's attended-day count for 10016 and 10030 At 08:00 Asia/Bangkok on the 1st, AttendanceMonthlyReportJob mails one recipient how many days each configured employee attended in the previous month. Any punch on a work_date counts, so a lone clock-in or clock-out is a day attended. Punches resolve to an employee through AttendanceSql.RESOLVED_EMPLOYEE_JOIN, as the rest of attendance does: most fingerprint punches for these two are stored with employee_id NULL and badge_code = the employee code, so a plain employee_id join reports ~0. The recipient comes from APP_ATTENDANCE_MONTHLY_REPORT_TO (blank = off), declared sync:false in render.yaml; the address is a personal mailbox and this repo is public. Codes default to 10016,10030. Covered by AttendanceMonthlyReportJobIntegrationTest (13, real Postgres): in-only / out-only days count; in+out counts once; month bounds; unlinked punches by employee code and by card serial; zero-punch employee listed; other staff excluded; unknown code called out; no "null" for a nameless employee; one mail to the recipient with the Thai B.E. month; blank recipient and no codes send nothing; fixed-clock runs on 1 Nov and 1 Jan report October and December 2569; the real cron fires on the 1st 08:00. Co-Authored-By: Claude Opus 5.5 --- .../AttendanceMonthlyReportJob.java | 111 +++++++ .../th/co/glr/hr/config/SchedulingConfig.java | 4 +- backend/src/main/resources/application.yml | 6 + ...ndanceMonthlyReportJobIntegrationTest.java | 270 ++++++++++++++++++ render.yaml | 4 + 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java create mode 100644 backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java 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..23d0ee745 --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -0,0 +1,111 @@ +package th.co.glr.hr.attendance; + +import java.time.Clock; +import java.time.YearMonth; +import java.time.ZoneId; +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.mail.Mailer; + +/** + * Emails one recipient, at 08:00 Asia/Bangkok on the 1st of each month, how many days each + * configured employee attended in the previous calendar month. + * + *

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. A mail failure propagates so the scheduled-run event recorder logs it as an error. + */ +@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 Mailer mailer; + private final String to; + private final List employeeCodes; + private final Clock clock; + + @Autowired + public AttendanceMonthlyReportJob( + NamedParameterJdbcTemplate jdbc, + Mailer mailer, + @Value("${app.attendance.monthly-report.to:}") String to, + @Value("${app.attendance.monthly-report.employee-codes:}") List employeeCodes + ) { + this(jdbc, mailer, to, employeeCodes, Clock.system(BANGKOK)); + } + + /** Lets tests pin "today" so the 1st-of-month and New Year rollovers are deterministic. */ + AttendanceMonthlyReportJob(NamedParameterJdbcTemplate jdbc, Mailer mailer, String to, + List employeeCodes, Clock clock) { + this.jdbc = jdbc; + this.mailer = mailer; + this.to = to == null ? "" : to.trim(); + this.employeeCodes = employeeCodes.stream().map(String::trim).filter(c -> !c.isEmpty()).toList(); + this.clock = clock; + } + + @Scheduled(cron = "0 0 8 1 * *", zone = "Asia/Bangkok") + public void sendPreviousMonth() { + send(YearMonth.now(clock).minusMonths(1)); + } + + void send(YearMonth month) { + if (to.isEmpty() || employeeCodes.isEmpty()) { + log.info("Monthly attendance report is off (no recipient or no 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 label = month.getMonth().getDisplayName(TextStyle.FULL, Locale.forLanguageTag("th")) + + " " + (month.getYear() + 543); + StringBuilder body = new StringBuilder("สรุปจำนวนวันเข้างาน ").append(label).append('\n') + .append("นับทุกวันที่มีการสแกนอย่างน้อย 1 ครั้ง (สแกนเข้าหรือสแกนออกอย่างใดอย่างหนึ่งก็นับ)\n\n"); + for (String code : employeeCodes) { + body.append("- ") + .append(daysByCode.getOrDefault(code, code + ": ไม่พบรหัสพนักงานนี้ในระบบ")) + .append('\n'); + } + mailer.send(to, "สรุปจำนวนวันเข้างาน " + label, body.toString()); + } +} 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..991f1e517 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,8 +6,8 @@ /** * 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 + * 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. * diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e04df7f00..21b8c5b1d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -109,6 +109,12 @@ 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): 08:00 Asia/Bangkok on the 1st, + # covering the previous month. Blank recipient = off. The address is set on the deployment, not + # here — this repo is public and it is a personal mailbox. + monthly-report: + to: ${APP_ATTENDANCE_MONTHLY_REPORT_TO:} + 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..fd0f7f14d --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java @@ -0,0 +1,270 @@ +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.mail.Mailer; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; + +/** + * The month-end attendance email: one mail to one recipient, one line per configured 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 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 aBlankRecipientSendsNothing() { + insertPunch(alice, at(1, 8, 20)); + + new AttendanceMonthlyReportJob(jdbc, mailer, " ", List.of("10016")).send(OCTOBER); + + assertThat(mailer.sent).isEmpty(); + } + + @Test + void noEmployeeCodesSendsNothing() { + new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, 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"); + } + + @Test + void theRunOnTheFirstReportsTheMonthThatJustEnded() { + insertPunch(alice, at(20, 8, 30)); + Clock firstOfNovember = Clock.fixed( + ZonedDateTime.of(2026, 11, 1, 8, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + + new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, List.of("10016"), firstOfNovember) + .sendPreviousMonth(); + + assertThat(mailer.sent.get(0).subject()).contains("ตุลาคม 2569"); + assertThat(mailer.onlyBody()).contains("(10016): 1 วัน"); + } + + @Test + void theRunOnNewYearsDayReportsDecemberOfTheYearBefore() { + Clock firstOfJanuary = Clock.fixed( + ZonedDateTime.of(2027, 1, 1, 8, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + + new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, List.of("10016"), firstOfJanuary) + .sendPreviousMonth(); + + assertThat(mailer.sent).hasSize(1); + assertThat(mailer.sent.get(0).subject()).contains("ธันวาคม 2569"); + } + + @Test + void firesOnTheFirstOfEveryMonthAt0800Bangkok() throws Exception { + Scheduled scheduled = AttendanceMonthlyReportJob.class + .getMethod("sendPreviousMonth") + .getAnnotation(Scheduled.class); + assertThat(scheduled).as("sendPreviousMonth 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); + ZonedDateTime second = cron.next(first); + + assertThat(first).isEqualTo(ZonedDateTime.of(2026, 11, 1, 8, 0, 0, 0, BANGKOK)); + assertThat(second).isEqualTo(ZonedDateTime.of(2026, 12, 1, 8, 0, 0, 0, BANGKOK)); + } + + private AttendanceMonthlyReportJob job(List employeeCodes) { + return new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, 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, String subject, String body) {} + + 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(String to, String subject, String body) { + sent.add(new SentMail(to, subject, body)); + } + + @Override + public void sendHtml(String to, String subject, String htmlBody, String textBody, + List inlineImages) { + sent.add(new SentMail(to, subject, textBody)); + } + + @Override + public void sendWithAttachment(String to, String subject, String body, String filename, byte[] bytes) { + sent.add(new SentMail(to, subject, body)); + } + + @Override + public void sendWithAttachments(String to, String subject, String body, List attachments) { + sent.add(new SentMail(to, subject, body)); + } + } +} diff --git a/render.yaml b/render.yaml index 3147b8bce..0d5ebb4bd 100644 --- a/render.yaml +++ b/render.yaml @@ -144,3 +144,7 @@ services: # dashboard without a rebuild. - key: APP_LEAVE_SUBMISSION_INBOX sync: false + # Recipient of the month-end attendance-day count email (1st, 08: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 From 07c0e6537413c270ea4c486b48f7f7c799660da1 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:33:27 +0700 Subject: [PATCH 3/6] refactor: rewrap two lines the monthly attendance email pushed past 100 columns Co-Authored-By: Claude Opus 5.5 --- .../co/glr/hr/attendance/AttendanceMonthlyReportJob.java | 5 ++++- .../main/java/th/co/glr/hr/config/SchedulingConfig.java | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 index 23d0ee745..db6e8d1fd 100644 --- a/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -55,7 +55,10 @@ public AttendanceMonthlyReportJob( this.jdbc = jdbc; this.mailer = mailer; this.to = to == null ? "" : to.trim(); - this.employeeCodes = employeeCodes.stream().map(String::trim).filter(c -> !c.isEmpty()).toList(); + this.employeeCodes = employeeCodes.stream() + .map(String::trim) + .filter(c -> !c.isEmpty()) + .toList(); this.clock = clock; } 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 991f1e517..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, 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. + * 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 From bfff1a59098729eebec435474c1944215154e034 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:03:52 +0700 Subject: [PATCH 4/6] feat(attendance): send the attendance email at noon on the month's last day, with a CC list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month-end report now runs at 12:00 Asia/Bangkok on the last day of each month (cron "0 0 12 L * *") and covers that same month, so the last day counts the scans made before noon. The body says when the data was taken ("ข้อมูลถึงวันที่ 31 ตุลาคม 2569 เวลา 12:00 น."). It now CCs a configured list and goes out through the leave email's send path (NotificationEmailService#sendLeaveSubmission): branded HTML with names escaped, the UAT subject suffix and redirect, and CC. That path is @Async and best-effort, so a failed send is logged, not raised. The recipient and CC come from APP_ATTENDANCE_MONTHLY_REPORT_TO / _CC on the deployment. Neither address is in this public repo. Tests (AttendanceMonthlyReportJobIntegrationTest, 16, real Postgres), written first and shown red: - every configured CC address is copied; no CC configured means none - the HTML part repeats the lines, with names HTML-escaped - a blank recipient sends nothing even when a CC is configured - the noon run on 31 Oct reports October, counts that morning's scan, and carries the cutoff line - the 31 Dec run reports December 2569 - the real cron fires 31 Oct, 30 Nov, 28 Feb 2027 and 29 Feb 2028 at 12:00 Bangkok Co-Authored-By: Claude Opus 5.5 --- .../AttendanceMonthlyReportJob.java | 72 ++++++++---- backend/src/main/resources/application.yml | 7 +- ...ndanceMonthlyReportJobIntegrationTest.java | 107 +++++++++++++----- render.yaml | 7 +- 4 files changed, 138 insertions(+), 55 deletions(-) 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 index db6e8d1fd..349b80da6 100644 --- a/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -3,6 +3,8 @@ 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; @@ -16,17 +18,23 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import th.co.glr.hr.mail.Mailer; +import th.co.glr.hr.notification.NotificationEmailService; /** - * Emails one recipient, at 08:00 Asia/Bangkok on the 1st of each month, how many days each - * configured employee attended in the previous calendar month. + * 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. A mail failure propagates so the scheduled-run event recorder logs it as an error. + * 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 { @@ -34,7 +42,8 @@ public class AttendanceMonthlyReportJob { private static final ZoneId BANGKOK = ZoneId.of("Asia/Bangkok"); private final NamedParameterJdbcTemplate jdbc; - private final Mailer mailer; + private final NotificationEmailService emailService; + private final List cc; private final String to; private final List employeeCodes; private final Clock clock; @@ -42,34 +51,41 @@ public class AttendanceMonthlyReportJob { @Autowired public AttendanceMonthlyReportJob( NamedParameterJdbcTemplate jdbc, - Mailer mailer, + 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, mailer, to, employeeCodes, Clock.system(BANGKOK)); + this(jdbc, emailService, to, cc, employeeCodes, Clock.system(BANGKOK)); } - /** Lets tests pin "today" so the 1st-of-month and New Year rollovers are deterministic. */ - AttendanceMonthlyReportJob(NamedParameterJdbcTemplate jdbc, Mailer mailer, String to, - List employeeCodes, Clock clock) { + /** 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.mailer = mailer; + this.emailService = emailService; this.to = to == null ? "" : to.trim(); - this.employeeCodes = employeeCodes.stream() + this.cc = cc; + this.employeeCodes = trimmed(employeeCodes); + this.clock = clock; + } + + private static List trimmed(List values) { + return values.stream() .map(String::trim) .filter(c -> !c.isEmpty()) .toList(); - this.clock = clock; } - @Scheduled(cron = "0 0 8 1 * *", zone = "Asia/Bangkok") - public void sendPreviousMonth() { - send(YearMonth.now(clock).minusMonths(1)); + @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 no employee codes configured)"); + log.info("Monthly attendance report is off (no recipient or employee codes configured)"); return; } Map daysByCode = new HashMap<>(); @@ -100,15 +116,25 @@ WHERE e.employee_code IN (:codes) + "(" + code + "): " + rs.getInt("days") + " วัน"); }); - String label = month.getMonth().getDisplayName(TextStyle.FULL, Locale.forLanguageTag("th")) - + " " + (month.getYear() + 543); + String label = thaiMonthYear(month); + ZonedDateTime now = ZonedDateTime.now(clock).withZoneSameInstant(BANGKOK); StringBuilder body = new StringBuilder("สรุปจำนวนวันเข้างาน ").append(label).append('\n') - .append("นับทุกวันที่มีการสแกนอย่างน้อย 1 ครั้ง (สแกนเข้าหรือสแกนออกอย่างใดอย่างหนึ่งก็นับ)\n\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'); } - mailer.send(to, "สรุปจำนวนวันเข้างาน " + label, body.toString()); + emailService.sendLeaveSubmission( + to, cc, "สรุปจำนวนวันเข้างาน " + label, 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/resources/application.yml b/backend/src/main/resources/application.yml index 21b8c5b1d..0adb22ba7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -109,11 +109,12 @@ 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): 08:00 Asia/Bangkok on the 1st, - # covering the previous month. Blank recipient = off. The address is set on the deployment, not - # here — this repo is public and it is a personal mailbox. + # 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 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 index fd0f7f14d..512561d54 100644 --- a/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJobIntegrationTest.java @@ -16,11 +16,13 @@ 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 one recipient, one line per configured employee, and + * 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. */ @@ -31,6 +33,8 @@ class AttendanceMonthlyReportJobIntegrationTest extends AbstractPostgresIntegrat 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; @@ -131,17 +135,54 @@ void sendsExactlyOneMailToTheRecipientNamingTheMonthInThai() { } @Test - void aBlankRecipientSendsNothing() { + 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, mailer, " ", List.of("10016")).send(OCTOBER); + new AttendanceMonthlyReportJob(jdbc, emailService, " ", List.of("cc@example.test"), List.of("10016")) + .send(OCTOBER); assertThat(mailer.sent).isEmpty(); } @Test void noEmployeeCodesSendsNothing() { - new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, List.of(" ", "")).send(OCTOBER); + new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), List.of(" ", "")).send(OCTOBER); assertThat(mailer.sent).isEmpty(); } @@ -155,50 +196,57 @@ void anEmployeeWithNoNameOnFileIsListedByCodeWithoutPrintingNull() { 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 theRunOnTheFirstReportsTheMonthThatJustEnded() { + void theRunAtNoonOnTheLastDayReportsThatMonthIncludingThatMorning() { insertPunch(alice, at(20, 8, 30)); - Clock firstOfNovember = Clock.fixed( - ZonedDateTime.of(2026, 11, 1, 8, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + 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, mailer, RECIPIENT, List.of("10016"), firstOfNovember) - .sendPreviousMonth(); + 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): 1 วัน"); + assertThat(mailer.onlyBody()) + .contains("(10016): 2 วัน") + .contains("ข้อมูลถึงวันที่ 31 ตุลาคม 2569 เวลา 12:00 น."); } @Test - void theRunOnNewYearsDayReportsDecemberOfTheYearBefore() { - Clock firstOfJanuary = Clock.fixed( - ZonedDateTime.of(2027, 1, 1, 8, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); + void theRunOnTheLastDayOfDecemberReportsDecember() { + Clock noonOn31December = Clock.fixed( + ZonedDateTime.of(2026, 12, 31, 12, 0, 0, 0, BANGKOK).toInstant(), BANGKOK); - new AttendanceMonthlyReportJob(jdbc, mailer, RECIPIENT, List.of("10016"), firstOfJanuary) - .sendPreviousMonth(); + 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 firesOnTheFirstOfEveryMonthAt0800Bangkok() throws Exception { + void firesAtNoonBangkokOnTheLastDayOfEveryMonth() throws Exception { Scheduled scheduled = AttendanceMonthlyReportJob.class - .getMethod("sendPreviousMonth") + .getMethod("sendThisMonth") .getAnnotation(Scheduled.class); - assertThat(scheduled).as("sendPreviousMonth must be @Scheduled").isNotNull(); + 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); - ZonedDateTime second = cron.next(first); - assertThat(first).isEqualTo(ZonedDateTime.of(2026, 11, 1, 8, 0, 0, 0, BANGKOK)); - assertThat(second).isEqualTo(ZonedDateTime.of(2026, 12, 1, 8, 0, 0, 0, BANGKOK)); + 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, mailer, RECIPIENT, employeeCodes); + return new AttendanceMonthlyReportJob(jdbc, emailService, RECIPIENT, List.of(), employeeCodes); } /** @@ -237,7 +285,7 @@ private static LocalDateTime at(int dayOfOctober, int hour, int minute) { } private static final class CapturingMailer implements Mailer { - record SentMail(String to, String subject, String body) {} + record SentMail(String to, List cc, String subject, String body, String html) {} final List sent = new ArrayList<>(); @@ -246,25 +294,30 @@ String onlyBody() { 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, subject, 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, subject, textBody)); + 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, subject, body)); + 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, subject, body)); + sent.add(new SentMail(to, List.of(), subject, body, null)); } } } diff --git a/render.yaml b/render.yaml index 0d5ebb4bd..241d2cd66 100644 --- a/render.yaml +++ b/render.yaml @@ -144,7 +144,10 @@ services: # dashboard without a rebuild. - key: APP_LEAVE_SUBMISSION_INBOX sync: false - # Recipient of the month-end attendance-day count email (1st, 08:00 Bangkok). Blank = off. - # Set on the dashboard; the address is deliberately not in this public repo. + # 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 From 1093dc6a3d19e8fc059d1d0d473b98b435e5aa69 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:09:36 +0700 Subject: [PATCH 5/6] refactor: build the attendance report title once Co-Authored-By: Claude Opus 5.5 --- .../co/glr/hr/attendance/AttendanceMonthlyReportJob.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 index 349b80da6..3d2d8d14e 100644 --- a/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -116,9 +116,9 @@ WHERE e.employee_code IN (:codes) + "(" + code + "): " + rs.getInt("days") + " วัน"); }); - String label = thaiMonthYear(month); + String title = "สรุปจำนวนวันเข้างาน " + thaiMonthYear(month); ZonedDateTime now = ZonedDateTime.now(clock).withZoneSameInstant(BANGKOK); - StringBuilder body = new StringBuilder("สรุปจำนวนวันเข้างาน ").append(label).append('\n') + StringBuilder body = new StringBuilder(title).append('\n') .append("นับทุกวันที่มีการสแกนอย่างน้อย 1 ครั้ง " + "(สแกนเข้าหรือสแกนออกอย่างใดอย่างหนึ่งก็นับ)\n") .append("ข้อมูลถึงวันที่ ").append(now.getDayOfMonth()).append(' ') @@ -129,8 +129,7 @@ WHERE e.employee_code IN (:codes) .append(daysByCode.getOrDefault(code, code + ": ไม่พบรหัสพนักงานนี้ในระบบ")) .append('\n'); } - emailService.sendLeaveSubmission( - to, cc, "สรุปจำนวนวันเข้างาน " + label, body.toString(), null, List.of()); + emailService.sendLeaveSubmission(to, cc, title, body.toString(), null, List.of()); } private static String thaiMonthYear(YearMonth month) { From 1a8e289f03df7898a118050e43623f23b8d1f6d3 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:09:46 +0700 Subject: [PATCH 6/6] refactor: inline the one-caller trim helper and order the fields like the constructor Co-Authored-By: Claude Opus 5.5 --- .../glr/hr/attendance/AttendanceMonthlyReportJob.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 index 3d2d8d14e..8f86578fd 100644 --- a/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java +++ b/backend/src/main/java/th/co/glr/hr/attendance/AttendanceMonthlyReportJob.java @@ -43,8 +43,8 @@ public class AttendanceMonthlyReportJob { private final NamedParameterJdbcTemplate jdbc; private final NotificationEmailService emailService; - private final List cc; private final String to; + private final List cc; private final List employeeCodes; private final Clock clock; @@ -67,15 +67,11 @@ public AttendanceMonthlyReportJob( this.emailService = emailService; this.to = to == null ? "" : to.trim(); this.cc = cc; - this.employeeCodes = trimmed(employeeCodes); - this.clock = clock; - } - - private static List trimmed(List values) { - return values.stream() + this.employeeCodes = employeeCodes.stream() .map(String::trim) .filter(c -> !c.isEmpty()) .toList(); + this.clock = clock; } @Scheduled(cron = "0 0 12 L * *", zone = "Asia/Bangkok")