Merge commit '01b8095e9459417e2cf5bd1079c796d4f01ec549' into work/reports-ui

# Conflicts:
#	src/main/resources/templates/attendance/history.html
This commit is contained in:
sechmachine
2026-08-15 03:35:04 +07:00
46 changed files with 1270 additions and 63 deletions
@@ -0,0 +1,96 @@
# Test Evidence: Frozen leave dates and concurrent punch outcomes
- **Test type:** Integration
- **Requirement IDs:** `ATT-005`, `ATT-006`, `ATT-007`, `ATT-008`, `ATT-010`, `LEV-003`, `LEV-011`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-LEV-001`, `AC-LEV-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates`, `com.lab.labtimesheet.feature.attendance.service.AttendanceConcurrencyIntegrationTest#concurrentDuplicatePunchesReturnStableDomainOutcomes`
- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0`
## Protected behavior
Approved leave blocks check-in only on exact immutable `leave_request_days`, not
every calendar date inside the request range. Concurrent duplicate punches return
stable attendance rejection codes while preserving a single raw check-in and checkout.
## Test method
Spring Boot migrates PostgreSQL 18.4, creates an active Intern only through public
Account and SMTP services, and persists an Attendance-owned approved leave request
plus one frozen allocation through JPA. A separate non-transactional test releases
two threads simultaneously against each transactional punch endpoint.
## Hand-derived expected result
For an approved 1417 August range with only 17 August allocated, check-in on
14 August succeeds and 17 August returns `APPROVED_LEAVE`. Two simultaneous
check-ins produce one success and one `ALREADY_CHECKED_IN`; two simultaneous
checkouts produce one success and one `ALREADY_CHECKED_OUT`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test
```
**Observed result**
```text
AttendanceException: APPROVED_LEAVE at AttendanceApplicationService.checkIn for
the unallocated 2026-08-14 range date.
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
BUILD FAILURE
Process exited 1 because the query used the whole leave request range.
```
The repository-exception unit regressions separately failed because raw
`DataIntegrityViolationException` and `ObjectOptimisticLockingFailureException`
escaped the application boundary.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AttendanceConcurrencyIntegrationTest test
./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test
```
**Observed result**
```text
AttendanceConcurrencyIntegrationTest: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL reported SQLSTATE 23505 on uq_attendance_records_intern_date; the caller
received ALREADY_CHECKED_IN. The checkout race returned ALREADY_CHECKED_OUT.
AttendancePersistenceIntegrationTest focused allocation test: Tests run: 1,
Failures: 0, Errors: 0, Skipped: 0.
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
The tests do not implement the later leave workflow or account terminal-state
transitions. They prove the current read/query boundary, exact PostgreSQL 18.4
allocation semantics, and duplicate-punch conflict translation.
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ATT-002`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-010`, `CAL-001`, `CAL-006`, `CAL-007`, `CAL-009`, `AUTH-003`, `RPT-004`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-003`, `AC-CAL-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior
@@ -87,7 +87,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
@@ -0,0 +1,92 @@
# Test Evidence: Checkout eligibility and stable conflict outcomes
- **Test type:** Unit
- **Requirement IDs:** `ATT-007`, `ATT-008`, `ATT-010`, `ATT-012`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate`, `#translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection`, `#translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection`
- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0`
## Protected behavior
Checkout revalidates active internship eligibility for the attendance row's
persisted work date. A terminal Intern cannot checkout after checking in.
Database uniqueness and optimistic-lock races are translated to stable duplicate
punch rejection codes instead of leaking persistence exceptions.
## Test method
Plain JUnit and Mockito drive the production transactional application service
with a fixed Clock, attached policy, persisted row, AccountService eligibility,
and repository exceptions. Account state remains behind its public service API.
## Hand-derived expected result
False date-aware eligibility returns `INACTIVE_INTERN` before raw checkout is
saved. A check-in uniqueness race returns `ALREADY_CHECKED_IN`; a checkout
version race returns `ALREADY_CHECKED_OUT`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate test
```
**Observed result**
```text
Expected AttendanceException(INACTIVE_INTERN) but was NullPointerException after
the service continued to save checkout without calling AccountService eligibility.
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
Process exited 1.
```
The conflict regressions were also observed RED in the combined focused run:
```text
DataIntegrityViolationException: concurrent unique conflict
ObjectOptimisticLockingFailureException: optimistic locking failed
Both escaped AttendanceApplicationService instead of stable AttendanceException values.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceApplicationServiceTest test
```
**Observed result**
```text
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
The account platform has no Iteration 1 terminal-state mutation API, so the
completed/withdrawn state is represented through its public date-aware eligibility
result. The companion PostgreSQL concurrency test proves the real unique conflict.
+2 -2
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ATT-005`, `I1-UI-03`
- **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior
@@ -70,7 +70,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ARC-005`, `OPS-020`
- **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate
- **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior
@@ -90,7 +90,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
+2 -2
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175`
## Protected behavior
@@ -66,7 +66,7 @@ Process exited 0.
```text
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
@@ -4,7 +4,7 @@
- **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016`
- **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175`
## Protected behavior
@@ -71,7 +71,7 @@ Process exited 0.
```text
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
+25 -5
View File
@@ -1,29 +1,35 @@
# Test Evidence: Attendance and global-calendar web authorization
- **Test type:** Web
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `CAL-001`, `CAL-007`, `RPT-004`
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `ATT-016`, `CAL-001`, `CAL-007`, `RPT-004`, `UI-013`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest`
- **Implementation commit:** `pending (committed with this evidence)`
- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior
Authenticated Intern punch routes use the server-resolved user ID, own history
renders attached policy details, Mentor inspection routes preserve the target
scope, and calendar management rejects non-Admin access. Calendar updates carry
the submitted optimistic version.
the submitted optimistic version. History renders policy-local 24-hour times,
`dd/MM/yyyy` dates, and every simultaneous violation; `On time` appears only
when no violation applies.
## Test method
`@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route
selection, controller authorization, Thymeleaf rendering, and service-call
arguments while mocking only application-service and current-user boundaries.
The presentation regression supplies a row that is both late and early and
asserts the attached Asia/Ho_Chi_Minh timezone conversion.
## Hand-derived expected result
An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect
target 42 but receives HTTP 403 for Admin calendar management. Attached policy
grace renders as `30 min`. An event form with version 3 calls update with 3.
`2026-08-14T02:00:00.001Z` renders as local `09:00`, and a 15:00 local checkout
on that late row renders both `Late` and `Early departure`, never `On time`.
## RED
@@ -46,6 +52,20 @@ export PATH="$JAVA_HOME/bin:$PATH"
Process exited 1 because the required authenticated web endpoints did not exist.
```
The review presentation regression was separately observed RED:
```text
./mvnw -Dtest=AttendanceApplicationServiceTest,AttendanceControllerTest test
AttendanceControllerTest.historyRendersPolicyLocalDisplayValuesAndEveryViolation:
Expected a string containing "14/08/2026" but rendered "2026-08-14";
the same row rendered one nested-ternary result, "Early departure", and raw UTC instants.
Tests run: 13, Failures: 3, Errors: 1, Skipped: 0
BUILD FAILURE
Process exited 1. The eligible behavioral failures were the missing local presentation
values and simultaneous violation output; the checkout fixture error was corrected
before its own focused RED and is not claimed as behavioral evidence.
```
## GREEN
**Command**
@@ -59,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
**Observed result**
```text
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
@@ -73,7 +93,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
@@ -17,6 +17,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Server-rendered attendance routes for Intern punches and role-scoped historical inspection.
*/
@Controller
@RequestMapping("/attendance")
public class AttendanceController {
@@ -30,6 +33,15 @@ public class AttendanceController {
this.currentUsers = currentUsers;
}
/**
* Renders the authenticated Intern's inclusive attendance history, defaulting to the current month.
*
* @param principal authenticated user
* @param from optional inclusive local start date
* @param to optional inclusive local end date
* @param model Thymeleaf model
* @return attendance history view name
*/
@GetMapping
public String ownHistory(
Principal principal,
@@ -40,6 +52,16 @@ public class AttendanceController {
return history(actor, actor.userId(), from, to, model);
}
/**
* Renders a target Intern's history for an authenticated Mentor or Admin.
*
* @param principal authenticated inspecting user
* @param internId target Intern account identifier
* @param from optional inclusive local start date
* @param to optional inclusive local end date
* @param model Thymeleaf model
* @return attendance history view name
*/
@GetMapping("/interns/{internId}")
public String inspectHistory(
Principal principal,
@@ -54,6 +76,13 @@ public class AttendanceController {
return history(actor, internId, from, to, model);
}
/**
* Checks in the authenticated Intern using server time and redirects with stable feedback.
*
* @param principal authenticated Intern
* @param redirectAttributes flash-message destination
* @return redirect to own attendance history
*/
@PostMapping("/check-in")
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
@@ -66,6 +95,13 @@ public class AttendanceController {
return "redirect:/attendance";
}
/**
* Checks out the authenticated Intern using server time and redirects with stable feedback.
*
* @param principal authenticated Intern
* @param redirectAttributes flash-message destination
* @return redirect to own attendance history
*/
@PostMapping("/check-out")
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
@@ -18,6 +18,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Admin-only server-rendered routes for manual global calendar management.
*/
@Controller
@RequestMapping("/attendance/calendar")
public class CalendarController {
@@ -35,6 +38,13 @@ public class CalendarController {
this.currentUsers = currentUsers;
}
/**
* Renders the next year of locally stored calendar events for an authenticated Admin.
*
* @param principal authenticated Admin
* @param model Thymeleaf model
* @return calendar management view name
*/
@GetMapping
public String calendar(Principal principal, Model model) {
requireAdmin(currentUsers.actor(principal));
@@ -44,6 +54,16 @@ public class CalendarController {
return "attendance/calendar";
}
/**
* Creates a custom future event using the authenticated Admin identity.
*
* @param principal authenticated Admin
* @param date local event date
* @param name non-blank display name
* @param dayOff authoritative day-off choice
* @param redirectAttributes flash-message destination
* @return redirect to calendar management
*/
@PostMapping
public String create(
Principal principal,
@@ -57,6 +77,18 @@ public class CalendarController {
return "redirect:/attendance/calendar";
}
/**
* Updates a future event using the submitted optimistic version and authenticated Admin identity.
*
* @param principal authenticated Admin
* @param eventId event identifier
* @param version expected optimistic version
* @param date replacement local date
* @param name replacement display name
* @param dayOff replacement day-off choice
* @param redirectAttributes flash-message destination
* @return redirect to calendar management
*/
@PostMapping("/{eventId}")
public String update(
Principal principal,
@@ -1,14 +1,28 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Signals a rejected attendance punch or state lookup with a stable domain reason.
*/
public final class AttendanceException extends RuntimeException {
/** Stable reason preserved for controller and service consumers. */
private final AttendanceRejection rejection;
/**
* Creates an exception for the rejection that callers may safely translate to UI feedback.
*
* @param rejection stable reason for refusing the attendance operation
*/
public AttendanceException(AttendanceRejection rejection) {
super(rejection.name());
this.rejection = rejection;
}
/**
* Returns the stable rejection reason without exposing persistence failures.
*
* @return attendance rejection reason
*/
public AttendanceRejection rejection() {
return rejection;
}
@@ -1,12 +1,23 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Stable business outcomes for attendance operations, including idempotency and eligibility failures.
*/
public enum AttendanceRejection {
/** The account or internship is not active for the work date. */
INACTIVE_INTERN,
/** The attached policy does not configure the date's weekday for attendance. */
NON_WORKDAY,
/** The authoritative global calendar exempts the date. */
GLOBAL_DAY_OFF,
/** An approved leave request has a frozen allocation for the exact date. */
APPROVED_LEAVE,
/** A row already exists for the Intern and work date. */
ALREADY_CHECKED_IN,
/** No row exists for the current work date. */
NO_ATTENDANCE_RECORD,
/** The row already contains its first raw checkout. */
ALREADY_CHECKED_OUT,
/** The attached-policy inclusive checkout cutoff has passed. */
CHECKOUT_CUTOFF_PASSED
}
@@ -1,7 +1,15 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Signals a rejected global-calendar mutation, including immutable-history and optimistic conflicts.
*/
public final class CalendarException extends RuntimeException {
/**
* Creates a calendar rejection with operator-facing context.
*
* @param message explanation of the rejected mutation
*/
public CalendarException(String message) {
super(message);
}
@@ -2,8 +2,17 @@ package com.lab.labtimesheet.feature.attendance.model;
import java.util.Objects;
/**
* Attendance authorization context resolved from the authenticated account service identity.
*
* @param userId authoritative application user identifier
* @param role immutable global role used for attendance route and history scope checks
*/
public record AttendanceActor(long userId, AttendanceRole role) {
/**
* Rejects an actor without a resolved global role.
*/
public AttendanceActor {
Objects.requireNonNull(role, "role");
}
@@ -1,3 +1,11 @@
package com.lab.labtimesheet.feature.attendance.model;
/**
* Date-specific eligibility facts supplied to check-in without exposing account or calendar persistence.
* Approved leave means an approved request has a frozen allocation for the exact work date.
*
* @param activeIntern whether the account service considers the Intern active for the date
* @param globalDayOff whether the authoritative local calendar exempts the date
* @param approvedLeave whether a frozen approved leave allocation covers the date
*/
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
@@ -8,6 +8,21 @@ import java.time.ZoneId;
import java.util.Objects;
import java.util.Set;
/**
* Immutable effective-dated attendance rules interpreted in their configured business timezone.
* Grace boundaries are inclusive and the checkout cutoff must remain before the next local midnight.
*
* @param id persistent policy version identifier attached permanently to attendance rows
* @param effectiveFrom first local business date governed by this version
* @param zoneId timezone used to derive work dates and schedule instants
* @param scheduledStart expected local start time
* @param scheduledEnd expected local end time
* @param checkInGraceMinutes allowed minutes after scheduled start, from 0 through 720
* @param checkoutGraceMinutes allowed minutes after scheduled end, from 0 through 720
* @param monthlyLeaveQuota quota snapshot source for newly submitted leave allocations
* @param violationPenalty penalty applied per applicable attendance violation
* @param workdays configured ISO weekdays that normally require attendance
*/
public record AttendancePolicy(
long id,
LocalDate effectiveFrom,
@@ -23,6 +38,9 @@ public record AttendancePolicy(
private static final int MAX_GRACE_MINUTES = 720;
private static final int SECONDS_PER_DAY = 86_400;
/**
* Validates schedule and grace invariants and defensively snapshots the configured workdays.
*/
public AttendancePolicy {
Objects.requireNonNull(effectiveFrom, "effectiveFrom");
Objects.requireNonNull(zoneId, "zoneId");
@@ -41,25 +59,12 @@ public record AttendancePolicy(
}
}
public static AttendancePolicy seeded(long id) {
return new AttendancePolicy(
id,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
30,
3,
new BigDecimal("0.25"),
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
}
/**
* Reports whether the local date is a configured workday under this version.
*
* @param date local date interpreted by this policy
* @return {@code true} when the weekday is configured for attendance
*/
public boolean isWorkday(LocalDate date) {
return workdays.contains(date.getDayOfWeek());
}
@@ -8,6 +8,16 @@ import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.Objects;
/**
* One Intern's immutable raw check-in and optional raw checkout for a local work date.
* The attached policy version permanently determines schedule boundaries and violation interpretation.
*
* @param internId Intern account identifier
* @param workDate policy-local date derived when check-in was accepted
* @param policy historical policy version attached at check-in
* @param checkInAt uneditable raw server check-in instant
* @param checkOutAt uneditable raw server checkout instant, or {@code null} until accepted
*/
public record AttendanceRecord(
long internId,
LocalDate workDate,
@@ -15,12 +25,22 @@ public record AttendanceRecord(
Instant checkInAt,
Instant checkOutAt) {
/**
* Validates required historical fields while preserving a nullable raw checkout.
*/
public AttendanceRecord {
Objects.requireNonNull(workDate, "workDate");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(checkInAt, "checkInAt");
}
/**
* Returns a copy with the first raw checkout when it is at or before the attached-policy cutoff.
* Repeated or post-cutoff attempts are rejected without changing the original record.
*
* @param at authoritative server instant
* @return record containing the accepted raw checkout
*/
public AttendanceRecord checkOut(Instant at) {
Objects.requireNonNull(at, "at");
if (checkOutAt != null) {
@@ -32,6 +52,13 @@ public record AttendanceRecord(
return new AttendanceRecord(internId, workDate, policy, checkInAt, at);
}
/**
* Classifies violations using the attached policy and an authoritative observation instant.
* A missing checkout appears only after the inclusive cutoff and never implies early departure.
*
* @param observedAt instant at which missing-checkout status is evaluated
* @return independent violation flags for presentation and reporting
*/
public AttendanceViolations violations(Instant observedAt) {
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
@@ -1,7 +1,13 @@
package com.lab.labtimesheet.feature.attendance.model;
/**
* Global account roles recognized by attendance authorization rules.
*/
public enum AttendanceRole {
/** Global system administrator. */
ADMIN,
/** Global laboratory Mentor. */
MENTOR,
/** Internship participant. */
INTERN
}
@@ -1,3 +1,11 @@
package com.lab.labtimesheet.feature.attendance.model;
/**
* Independent attendance violations for a row. Late may coexist with early departure or missing checkout;
* missing checkout and early departure are mutually exclusive because the latter requires an effective checkout.
*
* @param late check-in occurred strictly after the inclusive grace boundary
* @param earlyDeparture effective checkout occurred before scheduled end
* @param missingCheckout no effective checkout existed after the inclusive checkout cutoff passed
*/
public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
@@ -1,7 +1,13 @@
package com.lab.labtimesheet.feature.attendance.model.dto;
/**
* Presentation-safe current business-date punch state exposed across feature boundaries.
*/
public enum AttendanceCurrentState {
/** No attendance row exists for the current business date. */
NOT_CHECKED_IN,
/** A row exists without raw checkout. */
CHECKED_IN,
/** A row exists with its accepted raw checkout. */
CHECKED_OUT
}
@@ -5,10 +5,102 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* Presentation and reporting DTO for one historical attendance row.
* Raw instants remain available for precise consumers while display accessors consistently render
* {@code dd/MM/yyyy} and policy-local 24-hour {@code HH:mm} values from the attached policy timezone.
*
* @param workDate immutable policy-local work date
* @param checkInAt raw server check-in instant
* @param checkOutAt raw server checkout instant, or {@code null} when absent
* @param policy historical policy version attached to the row
* @param violations all applicable violations at query time
*/
public record AttendanceHistoryItem(
LocalDate workDate,
Instant checkInAt,
Instant checkOutAt,
AttendancePolicy policy,
AttendanceViolations violations) {}
AttendanceViolations violations) {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/uuuu");
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
/**
* Formats the business date as {@code dd/MM/yyyy}.
*
* @return presentation-ready work date
*/
public String workDateDisplay() {
return DATE_FORMAT.format(workDate);
}
/**
* Formats raw check-in in the attached policy timezone as 24-hour {@code HH:mm}.
*
* @return presentation-ready local check-in time
*/
public String checkInTimeDisplay() {
return TIME_FORMAT.format(checkInAt.atZone(policy.zoneId()));
}
/**
* Formats raw checkout in the attached policy timezone or reports {@code Missing} when absent.
*
* @return presentation-ready local checkout value
*/
public String checkOutTimeDisplay() {
return checkOutAt == null ? "Missing" : TIME_FORMAT.format(checkOutAt.atZone(policy.zoneId()));
}
/**
* Formats the attached policy's local scheduled start as {@code HH:mm}.
*
* @return presentation-ready scheduled start
*/
public String scheduledStartDisplay() {
return TIME_FORMAT.format(policy.scheduledStart());
}
/**
* Formats the attached policy's local scheduled end as {@code HH:mm}.
*
* @return presentation-ready scheduled end
*/
public String scheduledEndDisplay() {
return TIME_FORMAT.format(policy.scheduledEnd());
}
/**
* Returns every applicable violation in stable presentation order, or only {@code On time}
* when no violation applies.
*
* @return immutable, non-empty presentation labels
*/
public List<String> violationLabels() {
List<String> labels = new ArrayList<>(3);
if (violations.late()) {
labels.add("Late");
}
if (violations.earlyDeparture()) {
labels.add("Early departure");
}
if (violations.missingCheckout()) {
labels.add("Missing checkout");
}
return labels.isEmpty() ? List.of("On time") : List.copyOf(labels);
}
/**
* Joins every applicable violation for table and export cells.
*
* @return comma-separated violation labels, or {@code On time}
*/
public String resultDisplay() {
return String.join(", ", violationLabels());
}
}
@@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.attendance.model.dto;
import java.time.LocalDate;
/**
* Persistence-free global calendar event returned to controllers and feature consumers.
*
* @param id stable event identifier
* @param date local business date of the event
* @param name operator-provided display name
* @param dayOff whether this event makes the date globally exempt
* @param version optimistic version required by update requests
*/
public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {}
@@ -20,6 +20,9 @@ import java.time.ZoneId;
import java.util.Set;
import java.util.stream.Collectors;
/**
* JPA mapping of an immutable-on-effective attendance policy version and its configured workdays.
*/
@Entity
@Table(name = "attendance_policy_versions")
public class AttendancePolicyEntity {
@@ -62,8 +65,16 @@ public class AttendancePolicyEntity {
@Version
private long version;
/**
* Required by JPA; application code resolves existing effective-dated versions instead of constructing them here.
*/
protected AttendancePolicyEntity() {}
/**
* Converts the persisted version to the immutable policy used for historical boundary calculations.
*
* @return domain policy including its persisted identifier and timezone
*/
public AttendancePolicy toDomain() {
Set<DayOfWeek> workdays = isoWeekdays.stream()
.map(day -> DayOfWeek.of(day.intValue()))
@@ -14,6 +14,9 @@ import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
/**
* JPA persistence model for one Intern/work-date punch row with its permanently attached policy version.
*/
@Entity
@Table(name = "attendance_records")
public class AttendanceRecordEntity {
@@ -41,8 +44,20 @@ public class AttendanceRecordEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected AttendanceRecordEntity() {}
/**
* Creates a new persistence row from server-authoritative raw punch values.
*
* @param internUserId scalar account identifier; account data remains owned by the account feature
* @param workDate attached-policy local work date
* @param policy persisted policy version fixed at check-in
* @param checkInAt raw server check-in instant
* @param checkOutAt raw server checkout instant, normally {@code null} for a new row
*/
public AttendanceRecordEntity(
long internUserId,
LocalDate workDate,
@@ -56,14 +71,29 @@ public class AttendanceRecordEntity {
this.checkOutAt = checkOutAt;
}
/**
* Rehydrates the immutable domain record without replacing the historical policy.
*
* @return attendance domain record
*/
public AttendanceRecord toDomain() {
return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt);
}
/**
* Stores the first accepted raw checkout; callers must enforce cutoff and single-write rules transactionally.
*
* @param checkOutAt accepted server checkout instant
*/
public void setCheckOutAt(Instant checkOutAt) {
this.checkOutAt = checkOutAt;
}
/**
* Returns the immutable local date used for account eligibility revalidation at checkout.
*
* @return persisted work date
*/
public LocalDate workDate() {
return workDate;
}
@@ -11,6 +11,9 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
/**
* JPA model for the locally authoritative global calendar decision.
*/
@Entity
@Table(name = "global_calendar_events")
public class GlobalCalendarEventEntity {
@@ -40,8 +43,19 @@ public class GlobalCalendarEventEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected GlobalCalendarEventEntity() {}
/**
* Creates a custom calendar event attributed to the Admin actor.
*
* @param date local business date
* @param name display name validated by the application service
* @param dayOff whether the event exempts attendance and date validation
* @param actorUserId Admin who created the local decision
*/
public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date;
this.name = name;
@@ -51,6 +65,14 @@ public class GlobalCalendarEventEntity {
this.updatedByUserId = actorUserId;
}
/**
* Applies an authorized future-event edit while preserving creator attribution.
*
* @param date replacement local date
* @param name replacement display name
* @param dayOff replacement authoritative day-off choice
* @param actorUserId Admin performing the update
*/
public void update(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date;
this.name = name;
@@ -58,14 +80,29 @@ public class GlobalCalendarEventEntity {
this.updatedByUserId = actorUserId;
}
/**
* Returns a persistence-free event including the optimistic version needed for edits.
*
* @return calendar event DTO
*/
public GlobalCalendarEvent toDomain() {
return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version);
}
/**
* Returns the date whose mutability is governed by the current policy-local business date.
*
* @return event calendar date
*/
public LocalDate calendarDate() {
return calendarDate;
}
/**
* Returns the optimistic version expected by a subsequent update.
*
* @return current version
*/
public long version() {
return version;
}
@@ -0,0 +1,54 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import java.time.LocalDate;
/**
* JPA mapping of an immutable leave-day allocation whose exact date, policy, and quota snapshot remain historical.
*/
@Entity
@Table(name = "leave_request_days")
public class LeaveRequestDayEntity {
@EmbeddedId
private LeaveRequestDayId id;
@MapsId("leaveRequestId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "leave_request_id", nullable = false)
private LeaveRequestEntity request;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "policy_version_id", nullable = false)
private AttendancePolicyEntity policy;
@Column(name = "quota_month", nullable = false)
private LocalDate quotaMonth;
@Column(name = "monthly_quota_snapshot", nullable = false)
private int monthlyQuotaSnapshot;
/**
* Required by JPA.
*/
protected LeaveRequestDayEntity() {}
LeaveRequestDayEntity(
LeaveRequestEntity request,
LocalDate leaveDate,
AttendancePolicyEntity policy,
int monthlyQuotaSnapshot) {
this.request = request;
this.id = new LeaveRequestDayId(request.id(), leaveDate);
this.policy = policy;
this.quotaMonth = leaveDate.withDayOfMonth(1);
this.monthlyQuotaSnapshot = monthlyQuotaSnapshot;
}
}
@@ -0,0 +1,50 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
/**
* Composite identifier of one frozen quota-consuming date within a leave request.
*/
@Embeddable
public class LeaveRequestDayId implements Serializable {
/** Parent request identity used by the composite primary key. */
@Column(name = "leave_request_id", nullable = false)
private long leaveRequestId;
/** Exact frozen allocation date used by the composite primary key. */
@Column(name = "leave_date", nullable = false)
private LocalDate leaveDate;
/**
* Required by JPA.
*/
protected LeaveRequestDayId() {}
/**
* Creates the identity for an already-persisted request and its exact allocated date.
*
* @param leaveRequestId persisted leave request identifier
* @param leaveDate frozen quota-consuming date
*/
public LeaveRequestDayId(long leaveRequestId, LocalDate leaveDate) {
this.leaveRequestId = leaveRequestId;
this.leaveDate = Objects.requireNonNull(leaveDate, "leaveDate");
}
@Override
public boolean equals(Object candidate) {
return candidate instanceof LeaveRequestDayId other
&& leaveRequestId == other.leaveRequestId
&& leaveDate.equals(other.leaveDate);
}
@Override
public int hashCode() {
return Objects.hash(leaveRequestId, leaveDate);
}
}
@@ -6,8 +6,13 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
/**
* Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations.
*/
@Entity
@Table(name = "leave_requests")
public class LeaveRequestEntity {
@@ -25,8 +30,56 @@ public class LeaveRequestEntity {
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private String reason;
@Column(nullable = false)
private String status;
@Column(name = "submitted_at", nullable = false)
private Instant submittedAt;
@Column(name = "first_counted_start_at", nullable = false)
private Instant firstCountedStartAt;
@Column(name = "decided_by_mentor_user_id")
private Long decidedByMentorUserId;
@Column(name = "decided_at")
private Instant decidedAt;
@Version
private long version;
/**
* Required by JPA.
*/
protected LeaveRequestEntity() {}
LeaveRequestEntity(
long internUserId,
LocalDate startDate,
LocalDate endDate,
String reason,
Instant submittedAt,
Instant firstCountedStartAt,
long decidedByMentorUserId,
Instant decidedAt) {
this.internUserId = internUserId;
this.startDate = startDate;
this.endDate = endDate;
this.reason = reason;
this.status = "APPROVED";
this.submittedAt = submittedAt;
this.firstCountedStartAt = firstCountedStartAt;
this.decidedByMentorUserId = decidedByMentorUserId;
this.decidedAt = decidedAt;
}
long id() {
if (id == null) {
throw new IllegalStateException("Leave request has not been persisted");
}
return id;
}
}
@@ -4,7 +4,15 @@ import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEnti
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Spring Data access to the effective-dated policy timeline owned by Attendance.
*/
public interface AttendancePolicyRepository extends JpaRepository<AttendancePolicyEntity, Long> {
/**
* Loads the complete timeline in effective-date order for deterministic local-date resolution.
*
* @return ascending policy versions, including the 1970 seed
*/
List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
}
@@ -1,18 +1,32 @@
package com.lab.labtimesheet.feature.attendance.repository;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayId;
import java.time.LocalDate;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface AttendanceQueryRepository extends Repository<LeaveRequestEntity, Long> {
/**
* Narrow Attendance read repository for date-specific leave eligibility facts.
*/
public interface AttendanceQueryRepository extends Repository<LeaveRequestDayEntity, LeaveRequestDayId> {
/**
* Checks whether an approved request owns a frozen allocation for the exact date.
* Request range membership alone is intentionally insufficient because non-workdays and holidays are excluded
* when leave is materialized.
*
* @param internId Intern account identifier
* @param workDate exact policy-local date being evaluated
* @return {@code true} only for an approved frozen allocation
*/
@Query("""
select count(request) > 0
from LeaveRequestEntity request
select count(day) > 0
from LeaveRequestDayEntity day
join day.request request
where request.internUserId = :internId and request.status = 'APPROVED'
and :workDate between request.startDate and request.endDate
and day.id.leaveDate = :workDate
""")
boolean hasApprovedLeave(
@Param("internId") long internId, @Param("workDate") LocalDate workDate);
@@ -6,10 +6,28 @@ import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Spring Data access to raw attendance rows and attached historical policy versions.
*/
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecordEntity, Long> {
/**
* Finds the unique row protected by the database's Intern/work-date constraint.
*
* @param internUserId Intern account identifier
* @param workDate policy-local work date
* @return row when the Intern has checked in on that date
*/
Optional<AttendanceRecordEntity> findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate);
/**
* Loads an Intern's inclusive history newest-first; each entity carries its attached policy.
*
* @param internUserId Intern account identifier
* @param from inclusive first local date
* @param to inclusive last local date
* @return matching attendance rows newest-first
*/
List<AttendanceRecordEntity> findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(
long internUserId, LocalDate from, LocalDate to);
}
@@ -5,10 +5,26 @@ import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Spring Data access to locally authoritative global calendar events.
*/
public interface GlobalCalendarEventRepository extends JpaRepository<GlobalCalendarEventEntity, Long> {
/**
* Reports whether any stored event makes the exact local date a global day off.
*
* @param date local business date
* @return {@code true} when at least one authoritative day-off decision exists
*/
boolean existsByCalendarDateAndDayOffTrue(LocalDate date);
/**
* Lists events across an inclusive local-date range in deterministic order.
*
* @param from inclusive first date
* @param to inclusive last date
* @return events ordered by date then identifier
*/
List<GlobalCalendarEventEntity> findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(
LocalDate from, LocalDate to);
}
@@ -21,9 +21,15 @@ import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Transactional Attendance application boundary for punches, current state, and authorized history reads.
* Account eligibility is obtained only through {@link AccountService}; raw rows retain their attached policy.
*/
@Service
public class AttendanceApplicationService {
@@ -52,6 +58,14 @@ public class AttendanceApplicationService {
this.attendance = attendance;
}
/**
* Records the sole server-time check-in for the effective policy-local date.
* Eligibility, workday, calendar, and exact frozen leave allocation are evaluated in the transaction;
* a concurrent unique conflict is returned as {@link AttendanceRejection#ALREADY_CHECKED_IN}.
*
* @param internId Intern account identifier
* @return persisted raw attendance record
*/
@Transactional
public AttendanceRecord checkIn(long internId) {
Instant now = clock.instant();
@@ -62,6 +76,7 @@ public class AttendanceApplicationService {
.map(AttendanceRecordEntity::toDomain);
AttendanceRecord record = attendance.checkIn(
internId, now, policy, dayContext(internId, workDate), existing);
try {
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
record.internId(),
record.workDate(),
@@ -69,8 +84,19 @@ public class AttendanceApplicationService {
record.checkInAt(),
record.checkOutAt()))
.toDomain();
} catch (DataIntegrityViolationException conflict) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN);
}
}
/**
* Records the first server-time checkout for today's open row under its attached policy cutoff.
* Account eligibility is revalidated for the persisted work date, and an optimistic race is returned as
* {@link AttendanceRejection#ALREADY_CHECKED_OUT}; rejected attempts do not replace raw checkout.
*
* @param internId Intern account identifier
* @return persisted checked-out record
*/
@Transactional
public AttendanceRecord checkOut(long internId) {
Instant now = clock.instant();
@@ -79,10 +105,23 @@ public class AttendanceApplicationService {
Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate);
AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now);
AttendanceRecordEntity persisted = entity.orElseThrow();
if (!accounts.isEligibleIntern(internId, persisted.workDate())) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
persisted.setCheckOutAt(checkedOut.checkOutAt());
try {
return recordEntities.saveAndFlush(persisted).toDomain();
} catch (ObjectOptimisticLockingFailureException conflict) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT);
}
}
/**
* Reports an eligible Intern's current policy-local business-date punch state.
*
* @param internId Intern account identifier
* @return presentation-safe current state
*/
@Transactional(readOnly = true)
public AttendanceCurrentState currentState(long internId) {
Instant now = clock.instant();
@@ -99,6 +138,16 @@ public class AttendanceApplicationService {
.orElse(AttendanceCurrentState.NOT_CHECKED_IN);
}
/**
* Returns inclusive historical rows newest-first, allowing Interns only their own history while Mentor and Admin
* actors may inspect another Intern. DTOs retain raw instants and provide attached-policy local display values.
*
* @param actor authenticated Attendance authorization context
* @param internId target Intern account identifier
* @param from inclusive first local date
* @param to inclusive last local date
* @return immutable presentation/reporting history items
*/
@Transactional(readOnly = true)
public List<AttendanceHistoryItem> history(
AttendanceActor actor, long internId, LocalDate from, LocalDate to) {
@@ -120,6 +169,11 @@ public class AttendanceApplicationService {
.toList();
}
/**
* Resolves the current business date in the effective policy timezone.
*
* @return current policy-local date from the injected server clock
*/
@Transactional(readOnly = true)
public LocalDate currentBusinessDate() {
AttendancePolicy policy = timeline().resolve(clock.instant());
@@ -9,6 +9,9 @@ import java.security.Principal;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
/**
* Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs.
*/
@Service
public class AttendanceCurrentUserService {
@@ -18,6 +21,12 @@ public class AttendanceCurrentUserService {
this.accounts = accounts;
}
/**
* Resolves the authenticated email through the Account feature and rejects missing or inactive identities.
*
* @param principal authenticated server principal
* @return Attendance actor containing only the user ID and global role needed by this feature
*/
public AttendanceActor actor(Principal principal) {
if (principal == null || principal.getName() == null) {
throw new AccessDeniedException("Authentication is required");
@@ -8,16 +8,30 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* Deterministically resolves immutable attendance policy versions for local dates or server instants.
*/
public final class AttendancePolicyTimeline {
private final List<AttendancePolicy> policies;
/**
* Snapshots and orders the supplied versions by effective date.
*
* @param policies available policy versions, normally including the 1970 seed
*/
public AttendancePolicyTimeline(Collection<AttendancePolicy> policies) {
this.policies = policies.stream()
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
.toList();
}
/**
* Resolves the latest version effective on or before a local business date.
*
* @param date local business date
* @return governing policy version
*/
public AttendancePolicy resolve(LocalDate date) {
Objects.requireNonNull(date, "date");
return policies.stream()
@@ -26,6 +40,12 @@ public final class AttendancePolicyTimeline {
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date));
}
/**
* Resolves an instant against each version's own timezone and effective date.
*
* @param instant authoritative server instant
* @return governing policy version
*/
public AttendancePolicy resolve(Instant instant) {
Objects.requireNonNull(instant, "instant");
return policies.stream()
@@ -10,9 +10,25 @@ import java.time.LocalDate;
import java.util.Optional;
import org.springframework.stereotype.Service;
/**
* Pure attendance punch rules over immutable policy, date context, and raw record values.
*/
@Service
public final class AttendanceService {
AttendanceService() {}
/**
* Creates the sole raw check-in for an eligible Intern/date using the supplied server instant.
* Equality at the grace boundary is accepted; violation classification remains attached-policy based.
*
* @param internId Intern account identifier
* @param now authoritative server instant
* @param policy effective policy at check-in
* @param context date-specific account, calendar, and frozen-leave facts
* @param existingRecord existing row for the same Intern/date, if any
* @return new raw attendance record
*/
public AttendanceRecord checkIn(
long internId,
Instant now,
@@ -27,6 +43,13 @@ public final class AttendanceService {
return new AttendanceRecord(internId, workDate, policy, now, null);
}
/**
* Applies the first raw checkout through the attached-policy inclusive cutoff.
*
* @param record open attendance row, if one exists
* @param now authoritative server instant
* @return checked-out record preserving its original check-in and attached policy
*/
public AttendanceRecord checkOut(Optional<AttendanceRecord> record, Instant now) {
return record
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD))
@@ -16,6 +16,9 @@ import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision.
*/
@Service
public class CalendarApplicationService {
@@ -32,6 +35,15 @@ public class CalendarApplicationService {
this.events = events;
}
/**
* Creates an Admin-authored custom event on a non-past policy-local date.
*
* @param actor authenticated Attendance actor; must be Admin
* @param date local event date
* @param name non-blank display name
* @param dayOff authoritative attendance/due-date exemption choice
* @return persisted event DTO including optimistic version
*/
@Transactional
public GlobalCalendarEvent createManual(
AttendanceActor actor, LocalDate date, String name, boolean dayOff) {
@@ -41,6 +53,18 @@ public class CalendarApplicationService {
.toDomain();
}
/**
* Updates a future custom event when the submitted optimistic version still matches.
* Past event dates and attempts to move an event into the past are rejected.
*
* @param actor authenticated Attendance actor; must be Admin
* @param eventId event identifier
* @param expectedVersion version rendered to the editor
* @param date replacement local event date
* @param name replacement non-blank display name
* @param dayOff replacement authoritative day-off choice
* @return updated event DTO and advanced version
*/
@Transactional
public GlobalCalendarEvent updateManual(
AttendanceActor actor,
@@ -61,6 +85,13 @@ public class CalendarApplicationService {
return events.saveAndFlush(event).toDomain();
}
/**
* Lists locally stored events over an inclusive date range.
*
* @param from inclusive first local date
* @param to inclusive last local date
* @return events ordered by date and identifier
*/
@Transactional(readOnly = true)
public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) {
if (from.isAfter(to)) {
@@ -72,6 +103,13 @@ public class CalendarApplicationService {
.toList();
}
/**
* Answers whether the exact local date has any authoritative stored day-off event.
* This is the public cross-feature calendar API; it performs no live HolidayAPI call.
*
* @param date local business date to inspect
* @return {@code true} when at least one local event is marked as a day off
*/
@Transactional(readOnly = true)
public boolean isGlobalDayOff(LocalDate date) {
return events.existsByCalendarDateAndDayOffTrue(date);
@@ -39,17 +39,12 @@
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
<td th:text="|${item.policy.scheduledStart}${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
<td th:text="${item.workDateDisplay}"></td>
<td th:text="${item.checkInTimeDisplay}"></td>
<td th:text="${item.checkOutTimeDisplay}"></td>
<td th:text="|${item.scheduledStartDisplay}${item.scheduledEndDisplay} (${item.policy.zoneId})|"></td>
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
<td>
<span th:if="${!item.violations.late and !item.violations.earlyDeparture and !item.violations.missingCheckout}">On time</span>
<span th:if="${item.violations.late}">Late</span>
<span th:if="${item.violations.earlyDeparture}" th:text="${item.violations.late ? ', Early departure' : 'Early departure'}">Early departure</span>
<span th:if="${item.violations.missingCheckout}" th:text="${item.violations.late or item.violations.earlyDeparture ? ', Missing checkout' : 'Missing checkout'}">Missing checkout</span>
</td>
<td th:text="${item.resultDisplay}"></td>
</tr>
</tbody>
</table></div>
@@ -13,9 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
@@ -69,7 +72,7 @@ class AttendanceControllerTest {
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:00:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(false, false, false))));
mockMvc.perform(get("/attendance")
@@ -82,6 +85,30 @@ class AttendanceControllerTest {
.andExpect(content().string(org.hamcrest.Matchers.containsString("30 min")));
}
@Test
void historyRendersPolicyLocalDisplayValuesAndEveryViolation() throws Exception {
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
when(currentUsers.actor(any())).thenReturn(actor);
when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem(
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:00:00.001Z"),
Instant.parse("2026-08-14T08:00:00Z"),
AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(true, true, false))));
mockMvc.perform(get("/attendance")
.with(user("intern@example.test").roles("INTERN"))
.param("from", "2026-08-01")
.param("to", "2026-08-31"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("14/08/2026")))
.andExpect(content().string(containsString("09:00")))
.andExpect(content().string(containsString("15:00")))
.andExpect(content().string(containsString("Late")))
.andExpect(content().string(containsString("Early departure")))
.andExpect(content().string(not(containsString("On time"))));
}
@Test
void mentorCanInspectInternHistory() throws Exception {
AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR);
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.attendance.model;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
public final class AttendancePolicyFixtures {
private AttendancePolicyFixtures() {}
public static AttendancePolicy seeded(long id) {
return new AttendancePolicy(
id,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
30,
3,
new BigDecimal("0.25"),
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
}
}
@@ -16,7 +16,7 @@ class AttendancePolicyTest {
@Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
@@ -0,0 +1,36 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import java.time.Instant;
import java.time.LocalDate;
public final class LeaveEntityFixtures {
private LeaveEntityFixtures() {}
public static LeaveRequestEntity approvedRequest(
long internUserId,
LocalDate startDate,
LocalDate endDate,
Instant submittedAt,
Instant firstCountedStartAt,
long decidedByMentorUserId,
Instant decidedAt) {
return new LeaveRequestEntity(
internUserId,
startDate,
endDate,
"Attendance integration fixture",
submittedAt,
firstCountedStartAt,
decidedByMentorUserId,
decidedAt);
}
public static LeaveRequestDayEntity allocatedDay(
LeaveRequestEntity request,
LocalDate leaveDate,
AttendancePolicyEntity policy,
int monthlyQuotaSnapshot) {
return new LeaveRequestDayEntity(request, leaveDate, policy, monthlyQuotaSnapshot);
}
}
@@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.attendance.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -9,6 +10,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
@@ -24,6 +26,8 @@ import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
class AttendanceApplicationServiceTest {
@@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest {
@BeforeEach
void setUp() {
AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class);
when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L));
when(policyEntity.toDomain()).thenReturn(AttendancePolicyFixtures.seeded(1L));
when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
attendance = new AttendanceApplicationService(
@@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest {
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
}
@Test
void rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate() {
AttendanceRecordEntity entity = entityFor(null);
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE))
.thenReturn(Optional.of(entity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false);
assertThatThrownBy(() -> attendance.checkOut(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
}
@Test
void translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection() {
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.empty());
when(records.saveAndFlush(any(AttendanceRecordEntity.class)))
.thenThrow(new DataIntegrityViolationException("concurrent unique conflict"));
assertThatThrownBy(() -> attendance.checkIn(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.ALREADY_CHECKED_IN));
}
@Test
void translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection() {
AttendanceRecordEntity entity = entityFor(null);
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.of(entity));
when(records.saveAndFlush(entity))
.thenThrow(new ObjectOptimisticLockingFailureException(AttendanceRecordEntity.class, 1L));
assertThatThrownBy(() -> attendance.checkOut(INTERN_ID))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(AttendanceRejection.ALREADY_CHECKED_OUT));
}
private static AttendanceRecordEntity entityFor(Instant checkOutAt) {
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
when(entity.toDomain()).thenReturn(new AttendanceRecord(
INTERN_ID,
WORK_DATE,
AttendancePolicy.seeded(1L),
AttendancePolicyFixtures.seeded(1L),
NOW,
checkOutAt));
when(entity.workDate()).thenReturn(WORK_DATE);
return entity;
}
}
@@ -0,0 +1,119 @@
package com.lab.labtimesheet.feature.attendance.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AttendanceConcurrencyIntegrationTest {
@Autowired
private AttendanceApplicationService attendance;
@Autowired
private BootstrapService bootstrap;
@Autowired
private AccountService accounts;
@Autowired
private SmtpConfigurationService smtp;
@Autowired
private AttendancePersistenceIntegrationTest.RecordingSmtpProbe mail;
@Autowired
private AttendancePersistenceIntegrationTest.MutableClock clock;
@Test
void concurrentDuplicatePunchesReturnStableDomainOutcomes() throws Exception {
long internId = createActiveIntern();
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkIn(internId))))
.containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_IN.name());
clock.set(Instant.parse("2026-08-14T09:00:00Z"));
assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkOut(internId))))
.containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_OUT.name());
}
private long createActiveIntern() {
bootstrap.bootstrap("concurrency-admin@example.test", "Admin", "correct horse battery staple");
long adminId = accounts.requireActiveAdminId("concurrency-admin@example.test");
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit",
1025,
SecurityMode.NONE,
null,
null,
"concurrency-admin@example.test",
"Lab Timesheet"));
smtp.testDraft(draftId, adminId, "concurrency-admin@example.test");
smtp.activate(draftId, adminId);
mail.clear();
var creation = accounts.create(new CreateAccountCommand(
"concurrency-intern@example.test",
"Concurrent Intern",
GlobalRole.INTERN,
"INT-CONCURRENT",
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31)), adminId);
assertThat(creation.deliverySucceeded()).isTrue();
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
accounts.activateInternship(creation.userId(), adminId);
return creation.userId();
}
private static String punchOutcome(Runnable punch) {
try {
punch.run();
return "SUCCESS";
} catch (AttendanceException rejection) {
return rejection.rejection().name();
}
}
private static List<String> runConcurrently(Callable<String> action) throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Callable<String> synchronizedAction = () -> {
ready.countDown();
start.await();
return action.call();
};
Future<String> first = executor.submit(synchronizedAction);
Future<String> second = executor.submit(synchronizedAction);
ready.await();
start.countDown();
return List.of(first.get(), second.get());
}
}
}
@@ -13,6 +13,8 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@@ -26,6 +28,7 @@ import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -40,6 +43,8 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.allocatedDay;
import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.approvedRequest;
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest
@@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest {
@Autowired
private AttendanceRecordRepository records;
@Autowired
private EntityManager entityManager;
@Autowired
private MutableClock clock;
@@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest {
assertThat(item.violations().missingCheckout()).isFalse();
}
@Test
void approvedLeaveBlocksOnlyItsFrozenAllocatedDates() {
LocalDate unallocatedDate = LocalDate.of(2026, 8, 14);
LocalDate allocatedDate = LocalDate.of(2026, 8, 17);
LeaveRequestEntity request = approvedRequest(
internId,
unallocatedDate,
allocatedDate,
Instant.parse("2026-08-13T00:00:00Z"),
Instant.parse("2026-08-14T01:30:00Z"),
adminId,
Instant.parse("2026-08-13T00:30:00Z"));
entityManager.persist(request);
entityManager.flush();
entityManager.persist(allocatedDay(
request,
allocatedDate,
entityManager.getReference(AttendancePolicyEntity.class, 1L),
3));
entityManager.flush();
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
attendance.checkIn(internId);
assertThat(records.findByInternUserIdAndWorkDate(internId, unallocatedDate)).isPresent();
clock.set(Instant.parse("2026-08-17T02:00:00Z"));
assertThatThrownBy(() -> attendance.checkIn(internId))
.isInstanceOfSatisfying(AttendanceException.class,
exception -> assertThat(exception.rejection())
.isEqualTo(com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE));
}
@Test
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
@@ -17,6 +17,7 @@ import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.math.BigDecimal;
@@ -158,7 +159,7 @@ class AttendanceServiceTest {
}
private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L);
return AttendancePolicyFixtures.seeded(1L);
}
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {