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` - **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` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-003`, `AC-CAL-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior ## Protected behavior
@@ -87,7 +87,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH" export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. 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` - **Requirement IDs:** `ATT-005`, `I1-UI-03`
- **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04` - **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior ## Protected behavior
@@ -70,7 +70,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH" export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. Process exited 0.
``` ```
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ARC-005`, `OPS-020` - **Requirement IDs:** `ARC-005`, `OPS-020`
- **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate - **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate
- **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest` - **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior ## Protected behavior
@@ -90,7 +90,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH" export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. Process exited 0.
``` ```
+2 -2
View File
@@ -4,7 +4,7 @@
- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` - **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004`
- **Scenario IDs:** `AC-ATT-001` - **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175`
## Protected behavior ## Protected behavior
@@ -66,7 +66,7 @@ Process exited 0.
```text ```text
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. 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` - **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` - **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175`
## Protected behavior ## Protected behavior
@@ -71,7 +71,7 @@ Process exited 0.
```text ```text
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. Process exited 0.
``` ```
+25 -5
View File
@@ -1,29 +1,35 @@
# Test Evidence: Attendance and global-calendar web authorization # Test Evidence: Attendance and global-calendar web authorization
- **Test type:** Web - **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` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest`
- **Implementation commit:** `pending (committed with this evidence)` - **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior ## Protected behavior
Authenticated Intern punch routes use the server-resolved user ID, own history Authenticated Intern punch routes use the server-resolved user ID, own history
renders attached policy details, Mentor inspection routes preserve the target renders attached policy details, Mentor inspection routes preserve the target
scope, and calendar management rejects non-Admin access. Calendar updates carry 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 ## Test method
`@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route `@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route
selection, controller authorization, Thymeleaf rendering, and service-call selection, controller authorization, Thymeleaf rendering, and service-call
arguments while mocking only application-service and current-user boundaries. 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 ## Hand-derived expected result
An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect 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 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. 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 ## RED
@@ -46,6 +52,20 @@ export PATH="$JAVA_HOME/bin:$PATH"
Process exited 1 because the required authenticated web endpoints did not exist. 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 ## GREEN
**Command** **Command**
@@ -59,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
**Observed result** **Observed result**
```text ```text
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS BUILD SUCCESS
Process exited 0. Process exited 0.
``` ```
@@ -73,7 +93,7 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH" export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test ./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 BUILD SUCCESS
Process exited 0. 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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Server-rendered attendance routes for Intern punches and role-scoped historical inspection.
*/
@Controller @Controller
@RequestMapping("/attendance") @RequestMapping("/attendance")
public class AttendanceController { public class AttendanceController {
@@ -30,6 +33,15 @@ public class AttendanceController {
this.currentUsers = currentUsers; 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 @GetMapping
public String ownHistory( public String ownHistory(
Principal principal, Principal principal,
@@ -40,6 +52,16 @@ public class AttendanceController {
return history(actor, actor.userId(), from, to, model); 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}") @GetMapping("/interns/{internId}")
public String inspectHistory( public String inspectHistory(
Principal principal, Principal principal,
@@ -54,6 +76,13 @@ public class AttendanceController {
return history(actor, internId, from, to, model); 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") @PostMapping("/check-in")
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) { public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal)); AttendanceActor actor = requireIntern(currentUsers.actor(principal));
@@ -66,6 +95,13 @@ public class AttendanceController {
return "redirect:/attendance"; 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") @PostMapping("/check-out")
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) { public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal)); 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.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Admin-only server-rendered routes for manual global calendar management.
*/
@Controller @Controller
@RequestMapping("/attendance/calendar") @RequestMapping("/attendance/calendar")
public class CalendarController { public class CalendarController {
@@ -35,6 +38,13 @@ public class CalendarController {
this.currentUsers = currentUsers; 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 @GetMapping
public String calendar(Principal principal, Model model) { public String calendar(Principal principal, Model model) {
requireAdmin(currentUsers.actor(principal)); requireAdmin(currentUsers.actor(principal));
@@ -44,6 +54,16 @@ public class CalendarController {
return "attendance/calendar"; 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 @PostMapping
public String create( public String create(
Principal principal, Principal principal,
@@ -57,6 +77,18 @@ public class CalendarController {
return "redirect:/attendance/calendar"; 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}") @PostMapping("/{eventId}")
public String update( public String update(
Principal principal, Principal principal,
@@ -1,14 +1,28 @@
package com.lab.labtimesheet.feature.attendance.exception; 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 { public final class AttendanceException extends RuntimeException {
/** Stable reason preserved for controller and service consumers. */
private final AttendanceRejection rejection; 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) { public AttendanceException(AttendanceRejection rejection) {
super(rejection.name()); super(rejection.name());
this.rejection = rejection; this.rejection = rejection;
} }
/**
* Returns the stable rejection reason without exposing persistence failures.
*
* @return attendance rejection reason
*/
public AttendanceRejection rejection() { public AttendanceRejection rejection() {
return rejection; return rejection;
} }
@@ -1,12 +1,23 @@
package com.lab.labtimesheet.feature.attendance.exception; package com.lab.labtimesheet.feature.attendance.exception;
/**
* Stable business outcomes for attendance operations, including idempotency and eligibility failures.
*/
public enum AttendanceRejection { public enum AttendanceRejection {
/** The account or internship is not active for the work date. */
INACTIVE_INTERN, INACTIVE_INTERN,
/** The attached policy does not configure the date's weekday for attendance. */
NON_WORKDAY, NON_WORKDAY,
/** The authoritative global calendar exempts the date. */
GLOBAL_DAY_OFF, GLOBAL_DAY_OFF,
/** An approved leave request has a frozen allocation for the exact date. */
APPROVED_LEAVE, APPROVED_LEAVE,
/** A row already exists for the Intern and work date. */
ALREADY_CHECKED_IN, ALREADY_CHECKED_IN,
/** No row exists for the current work date. */
NO_ATTENDANCE_RECORD, NO_ATTENDANCE_RECORD,
/** The row already contains its first raw checkout. */
ALREADY_CHECKED_OUT, ALREADY_CHECKED_OUT,
/** The attached-policy inclusive checkout cutoff has passed. */
CHECKOUT_CUTOFF_PASSED CHECKOUT_CUTOFF_PASSED
} }
@@ -1,7 +1,15 @@
package com.lab.labtimesheet.feature.attendance.exception; 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 { 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) { public CalendarException(String message) {
super(message); super(message);
} }
@@ -2,8 +2,17 @@ package com.lab.labtimesheet.feature.attendance.model;
import java.util.Objects; 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) { public record AttendanceActor(long userId, AttendanceRole role) {
/**
* Rejects an actor without a resolved global role.
*/
public AttendanceActor { public AttendanceActor {
Objects.requireNonNull(role, "role"); Objects.requireNonNull(role, "role");
} }
@@ -1,3 +1,11 @@
package com.lab.labtimesheet.feature.attendance.model; 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) {} public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
@@ -8,6 +8,21 @@ import java.time.ZoneId;
import java.util.Objects; import java.util.Objects;
import java.util.Set; 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( public record AttendancePolicy(
long id, long id,
LocalDate effectiveFrom, LocalDate effectiveFrom,
@@ -23,6 +38,9 @@ public record AttendancePolicy(
private static final int MAX_GRACE_MINUTES = 720; private static final int MAX_GRACE_MINUTES = 720;
private static final int SECONDS_PER_DAY = 86_400; private static final int SECONDS_PER_DAY = 86_400;
/**
* Validates schedule and grace invariants and defensively snapshots the configured workdays.
*/
public AttendancePolicy { public AttendancePolicy {
Objects.requireNonNull(effectiveFrom, "effectiveFrom"); Objects.requireNonNull(effectiveFrom, "effectiveFrom");
Objects.requireNonNull(zoneId, "zoneId"); Objects.requireNonNull(zoneId, "zoneId");
@@ -41,25 +59,12 @@ public record AttendancePolicy(
} }
} }
public static AttendancePolicy seeded(long id) { /**
return new AttendancePolicy( * Reports whether the local date is a configured workday under this version.
id, *
LocalDate.of(1970, 1, 1), * @param date local date interpreted by this policy
ZoneId.of("Asia/Ho_Chi_Minh"), * @return {@code true} when the weekday is configured for attendance
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));
}
public boolean isWorkday(LocalDate date) { public boolean isWorkday(LocalDate date) {
return workdays.contains(date.getDayOfWeek()); return workdays.contains(date.getDayOfWeek());
} }
@@ -8,6 +8,16 @@ import java.time.LocalDate;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Objects; 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( public record AttendanceRecord(
long internId, long internId,
LocalDate workDate, LocalDate workDate,
@@ -15,12 +25,22 @@ public record AttendanceRecord(
Instant checkInAt, Instant checkInAt,
Instant checkOutAt) { Instant checkOutAt) {
/**
* Validates required historical fields while preserving a nullable raw checkout.
*/
public AttendanceRecord { public AttendanceRecord {
Objects.requireNonNull(workDate, "workDate"); Objects.requireNonNull(workDate, "workDate");
Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(checkInAt, "checkInAt"); 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) { public AttendanceRecord checkOut(Instant at) {
Objects.requireNonNull(at, "at"); Objects.requireNonNull(at, "at");
if (checkOutAt != null) { if (checkOutAt != null) {
@@ -32,6 +52,13 @@ public record AttendanceRecord(
return new AttendanceRecord(internId, workDate, policy, checkInAt, at); 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) { public AttendanceViolations violations(Instant observedAt) {
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L)); boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff()); boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
@@ -1,7 +1,13 @@
package com.lab.labtimesheet.feature.attendance.model; package com.lab.labtimesheet.feature.attendance.model;
/**
* Global account roles recognized by attendance authorization rules.
*/
public enum AttendanceRole { public enum AttendanceRole {
/** Global system administrator. */
ADMIN, ADMIN,
/** Global laboratory Mentor. */
MENTOR, MENTOR,
/** Internship participant. */
INTERN INTERN
} }
@@ -1,3 +1,11 @@
package com.lab.labtimesheet.feature.attendance.model; 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) {} public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
@@ -1,7 +1,13 @@
package com.lab.labtimesheet.feature.attendance.model.dto; package com.lab.labtimesheet.feature.attendance.model.dto;
/**
* Presentation-safe current business-date punch state exposed across feature boundaries.
*/
public enum AttendanceCurrentState { public enum AttendanceCurrentState {
/** No attendance row exists for the current business date. */
NOT_CHECKED_IN, NOT_CHECKED_IN,
/** A row exists without raw checkout. */
CHECKED_IN, CHECKED_IN,
/** A row exists with its accepted raw checkout. */
CHECKED_OUT CHECKED_OUT
} }
@@ -5,10 +5,102 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; 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( public record AttendanceHistoryItem(
LocalDate workDate, LocalDate workDate,
Instant checkInAt, Instant checkInAt,
Instant checkOutAt, Instant checkOutAt,
AttendancePolicy policy, 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; 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) {} 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.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* JPA mapping of an immutable-on-effective attendance policy version and its configured workdays.
*/
@Entity @Entity
@Table(name = "attendance_policy_versions") @Table(name = "attendance_policy_versions")
public class AttendancePolicyEntity { public class AttendancePolicyEntity {
@@ -62,8 +65,16 @@ public class AttendancePolicyEntity {
@Version @Version
private long version; private long version;
/**
* Required by JPA; application code resolves existing effective-dated versions instead of constructing them here.
*/
protected AttendancePolicyEntity() {} 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() { public AttendancePolicy toDomain() {
Set<DayOfWeek> workdays = isoWeekdays.stream() Set<DayOfWeek> workdays = isoWeekdays.stream()
.map(day -> DayOfWeek.of(day.intValue())) .map(day -> DayOfWeek.of(day.intValue()))
@@ -14,6 +14,9 @@ import jakarta.persistence.Version;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
/**
* JPA persistence model for one Intern/work-date punch row with its permanently attached policy version.
*/
@Entity @Entity
@Table(name = "attendance_records") @Table(name = "attendance_records")
public class AttendanceRecordEntity { public class AttendanceRecordEntity {
@@ -41,8 +44,20 @@ public class AttendanceRecordEntity {
@Version @Version
private long version; private long version;
/**
* Required by JPA.
*/
protected AttendanceRecordEntity() {} 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( public AttendanceRecordEntity(
long internUserId, long internUserId,
LocalDate workDate, LocalDate workDate,
@@ -56,14 +71,29 @@ public class AttendanceRecordEntity {
this.checkOutAt = checkOutAt; this.checkOutAt = checkOutAt;
} }
/**
* Rehydrates the immutable domain record without replacing the historical policy.
*
* @return attendance domain record
*/
public AttendanceRecord toDomain() { public AttendanceRecord toDomain() {
return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt); 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) { public void setCheckOutAt(Instant checkOutAt) {
this.checkOutAt = checkOutAt; this.checkOutAt = checkOutAt;
} }
/**
* Returns the immutable local date used for account eligibility revalidation at checkout.
*
* @return persisted work date
*/
public LocalDate workDate() { public LocalDate workDate() {
return workDate; return workDate;
} }
@@ -11,6 +11,9 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version; import jakarta.persistence.Version;
import java.time.LocalDate; import java.time.LocalDate;
/**
* JPA model for the locally authoritative global calendar decision.
*/
@Entity @Entity
@Table(name = "global_calendar_events") @Table(name = "global_calendar_events")
public class GlobalCalendarEventEntity { public class GlobalCalendarEventEntity {
@@ -40,8 +43,19 @@ public class GlobalCalendarEventEntity {
@Version @Version
private long version; private long version;
/**
* Required by JPA.
*/
protected GlobalCalendarEventEntity() {} 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) { public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date; this.calendarDate = date;
this.name = name; this.name = name;
@@ -51,6 +65,14 @@ public class GlobalCalendarEventEntity {
this.updatedByUserId = actorUserId; 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) { public void update(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date; this.calendarDate = date;
this.name = name; this.name = name;
@@ -58,14 +80,29 @@ public class GlobalCalendarEventEntity {
this.updatedByUserId = actorUserId; this.updatedByUserId = actorUserId;
} }
/**
* Returns a persistence-free event including the optimistic version needed for edits.
*
* @return calendar event DTO
*/
public GlobalCalendarEvent toDomain() { public GlobalCalendarEvent toDomain() {
return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version); 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() { public LocalDate calendarDate() {
return calendarDate; return calendarDate;
} }
/**
* Returns the optimistic version expected by a subsequent update.
*
* @return current version
*/
public long version() { public long version() {
return 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.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
/**
* Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations.
*/
@Entity @Entity
@Table(name = "leave_requests") @Table(name = "leave_requests")
public class LeaveRequestEntity { public class LeaveRequestEntity {
@@ -25,8 +30,56 @@ public class LeaveRequestEntity {
@Column(name = "end_date", nullable = false) @Column(name = "end_date", nullable = false)
private LocalDate endDate; private LocalDate endDate;
@Column(nullable = false)
private String reason;
@Column(nullable = false) @Column(nullable = false)
private String status; 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() {} 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 java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; 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> { 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(); List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
} }
@@ -1,18 +1,32 @@
package com.lab.labtimesheet.feature.attendance.repository; 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 java.time.LocalDate;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository; import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param; 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(""" @Query("""
select count(request) > 0 select count(day) > 0
from LeaveRequestEntity request from LeaveRequestDayEntity day
join day.request request
where request.internUserId = :internId and request.status = 'APPROVED' where request.internUserId = :internId and request.status = 'APPROVED'
and :workDate between request.startDate and request.endDate and day.id.leaveDate = :workDate
""") """)
boolean hasApprovedLeave( boolean hasApprovedLeave(
@Param("internId") long internId, @Param("workDate") LocalDate workDate); @Param("internId") long internId, @Param("workDate") LocalDate workDate);
@@ -6,10 +6,28 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; 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> { 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); 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( List<AttendanceRecordEntity> findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(
long internUserId, LocalDate from, LocalDate to); long internUserId, LocalDate from, LocalDate to);
} }
@@ -5,10 +5,26 @@ import java.time.LocalDate;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
/**
* Spring Data access to locally authoritative global calendar events.
*/
public interface GlobalCalendarEventRepository extends JpaRepository<GlobalCalendarEventEntity, Long> { 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); 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( List<GlobalCalendarEventEntity> findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(
LocalDate from, LocalDate to); LocalDate from, LocalDate to);
} }
@@ -21,9 +21,15 @@ import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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 @Service
public class AttendanceApplicationService { public class AttendanceApplicationService {
@@ -52,6 +58,14 @@ public class AttendanceApplicationService {
this.attendance = attendance; 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 @Transactional
public AttendanceRecord checkIn(long internId) { public AttendanceRecord checkIn(long internId) {
Instant now = clock.instant(); Instant now = clock.instant();
@@ -62,15 +76,27 @@ public class AttendanceApplicationService {
.map(AttendanceRecordEntity::toDomain); .map(AttendanceRecordEntity::toDomain);
AttendanceRecord record = attendance.checkIn( AttendanceRecord record = attendance.checkIn(
internId, now, policy, dayContext(internId, workDate), existing); internId, now, policy, dayContext(internId, workDate), existing);
return recordEntities.saveAndFlush(new AttendanceRecordEntity( try {
record.internId(), return recordEntities.saveAndFlush(new AttendanceRecordEntity(
record.workDate(), record.internId(),
policyEntities.getReferenceById(record.policy().id()), record.workDate(),
record.checkInAt(), policyEntities.getReferenceById(record.policy().id()),
record.checkOutAt())) record.checkInAt(),
.toDomain(); 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 @Transactional
public AttendanceRecord checkOut(long internId) { public AttendanceRecord checkOut(long internId) {
Instant now = clock.instant(); Instant now = clock.instant();
@@ -79,10 +105,23 @@ public class AttendanceApplicationService {
Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate); Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate);
AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now); AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now);
AttendanceRecordEntity persisted = entity.orElseThrow(); AttendanceRecordEntity persisted = entity.orElseThrow();
if (!accounts.isEligibleIntern(internId, persisted.workDate())) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
persisted.setCheckOutAt(checkedOut.checkOutAt()); persisted.setCheckOutAt(checkedOut.checkOutAt());
return recordEntities.saveAndFlush(persisted).toDomain(); 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) @Transactional(readOnly = true)
public AttendanceCurrentState currentState(long internId) { public AttendanceCurrentState currentState(long internId) {
Instant now = clock.instant(); Instant now = clock.instant();
@@ -99,6 +138,16 @@ public class AttendanceApplicationService {
.orElse(AttendanceCurrentState.NOT_CHECKED_IN); .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) @Transactional(readOnly = true)
public List<AttendanceHistoryItem> history( public List<AttendanceHistoryItem> history(
AttendanceActor actor, long internId, LocalDate from, LocalDate to) { AttendanceActor actor, long internId, LocalDate from, LocalDate to) {
@@ -120,6 +169,11 @@ public class AttendanceApplicationService {
.toList(); .toList();
} }
/**
* Resolves the current business date in the effective policy timezone.
*
* @return current policy-local date from the injected server clock
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public LocalDate currentBusinessDate() { public LocalDate currentBusinessDate() {
AttendancePolicy policy = timeline().resolve(clock.instant()); AttendancePolicy policy = timeline().resolve(clock.instant());
@@ -9,6 +9,9 @@ import java.security.Principal;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs.
*/
@Service @Service
public class AttendanceCurrentUserService { public class AttendanceCurrentUserService {
@@ -18,6 +21,12 @@ public class AttendanceCurrentUserService {
this.accounts = accounts; 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) { public AttendanceActor actor(Principal principal) {
if (principal == null || principal.getName() == null) { if (principal == null || principal.getName() == null) {
throw new AccessDeniedException("Authentication is required"); throw new AccessDeniedException("Authentication is required");
@@ -8,16 +8,30 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
/**
* Deterministically resolves immutable attendance policy versions for local dates or server instants.
*/
public final class AttendancePolicyTimeline { public final class AttendancePolicyTimeline {
private final List<AttendancePolicy> policies; 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) { public AttendancePolicyTimeline(Collection<AttendancePolicy> policies) {
this.policies = policies.stream() this.policies = policies.stream()
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom)) .sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
.toList(); .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) { public AttendancePolicy resolve(LocalDate date) {
Objects.requireNonNull(date, "date"); Objects.requireNonNull(date, "date");
return policies.stream() return policies.stream()
@@ -26,6 +40,12 @@ public final class AttendancePolicyTimeline {
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date)); .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) { public AttendancePolicy resolve(Instant instant) {
Objects.requireNonNull(instant, "instant"); Objects.requireNonNull(instant, "instant");
return policies.stream() return policies.stream()
@@ -10,9 +10,25 @@ import java.time.LocalDate;
import java.util.Optional; import java.util.Optional;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* Pure attendance punch rules over immutable policy, date context, and raw record values.
*/
@Service @Service
public final class AttendanceService { 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( public AttendanceRecord checkIn(
long internId, long internId,
Instant now, Instant now,
@@ -27,6 +43,13 @@ public final class AttendanceService {
return new AttendanceRecord(internId, workDate, policy, now, null); 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) { public AttendanceRecord checkOut(Optional<AttendanceRecord> record, Instant now) {
return record return record
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
/**
* Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision.
*/
@Service @Service
public class CalendarApplicationService { public class CalendarApplicationService {
@@ -32,6 +35,15 @@ public class CalendarApplicationService {
this.events = events; 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 @Transactional
public GlobalCalendarEvent createManual( public GlobalCalendarEvent createManual(
AttendanceActor actor, LocalDate date, String name, boolean dayOff) { AttendanceActor actor, LocalDate date, String name, boolean dayOff) {
@@ -41,6 +53,18 @@ public class CalendarApplicationService {
.toDomain(); .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 @Transactional
public GlobalCalendarEvent updateManual( public GlobalCalendarEvent updateManual(
AttendanceActor actor, AttendanceActor actor,
@@ -61,6 +85,13 @@ public class CalendarApplicationService {
return events.saveAndFlush(event).toDomain(); 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) @Transactional(readOnly = true)
public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) { public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) {
if (from.isAfter(to)) { if (from.isAfter(to)) {
@@ -72,6 +103,13 @@ public class CalendarApplicationService {
.toList(); .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) @Transactional(readOnly = true)
public boolean isGlobalDayOff(LocalDate date) { public boolean isGlobalDayOff(LocalDate date) {
return events.existsByCalendarDateAndDayOffTrue(date); return events.existsByCalendarDateAndDayOffTrue(date);
@@ -39,17 +39,12 @@
</thead> </thead>
<tbody> <tbody>
<tr th:each="item : ${items}"> <tr th:each="item : ${items}">
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td> <td th:text="${item.workDateDisplay}"></td>
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td> <td th:text="${item.checkInTimeDisplay}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></td> <td th:text="${item.checkOutTimeDisplay}"></td>
<td th:text="|${item.policy.scheduledStart}${item.policy.scheduledEnd} (${item.policy.zoneId})|"></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 th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
<td> <td th:text="${item.resultDisplay}"></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>
</tr> </tr>
</tbody> </tbody>
</table></div> </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.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 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.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; 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.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
@@ -69,7 +72,7 @@ class AttendanceControllerTest {
LocalDate.of(2026, 8, 14), LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:00:00Z"), Instant.parse("2026-08-14T02:00:00Z"),
Instant.parse("2026-08-14T09:00:00Z"), Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L), AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(false, false, false)))); new AttendanceViolations(false, false, false))));
mockMvc.perform(get("/attendance") mockMvc.perform(get("/attendance")
@@ -82,6 +85,30 @@ class AttendanceControllerTest {
.andExpect(content().string(org.hamcrest.Matchers.containsString("30 min"))); .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 @Test
void mentorCanInspectInternHistory() throws Exception { void mentorCanInspectInternHistory() throws Exception {
AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR); 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 @Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() { void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L); AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded)); AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1))); 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.mock;
import static org.mockito.Mockito.when; 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.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; 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.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
@@ -24,6 +26,8 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
class AttendanceApplicationServiceTest { class AttendanceApplicationServiceTest {
@@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class); 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(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity));
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true); when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
attendance = new AttendanceApplicationService( attendance = new AttendanceApplicationService(
@@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest {
.isEqualTo(AttendanceRejection.INACTIVE_INTERN)); .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) { private static AttendanceRecordEntity entityFor(Instant checkOutAt) {
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class); AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
when(entity.toDomain()).thenReturn(new AttendanceRecord( when(entity.toDomain()).thenReturn(new AttendanceRecord(
INTERN_ID, INTERN_ID,
WORK_DATE, WORK_DATE,
AttendancePolicy.seeded(1L), AttendancePolicyFixtures.seeded(1L),
NOW, NOW,
checkOutAt)); checkOutAt));
when(entity.workDate()).thenReturn(WORK_DATE);
return entity; 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.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; 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.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.attendance.repository.AttendanceRecordRepository;
import com.lab.labtimesheet.feature.integration.model.SecurityMode; import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@@ -26,6 +28,7 @@ import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; 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.springframework.transaction.annotation.Transactional;
import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName; 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) @Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
@SpringBootTest @SpringBootTest
@@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest {
@Autowired @Autowired
private AttendanceRecordRepository records; private AttendanceRecordRepository records;
@Autowired
private EntityManager entityManager;
@Autowired @Autowired
private MutableClock clock; private MutableClock clock;
@@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest {
assertThat(item.violations().missingCheckout()).isFalse(); 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 @Test
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() { void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); 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.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; 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.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -158,7 +159,7 @@ class AttendanceServiceTest {
} }
private static AttendancePolicy seededPolicy() { private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L); return AttendancePolicyFixtures.seeded(1L);
} }
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) { private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {