fix(attendance): preserve frozen attendance boundaries
This commit is contained in:
@@ -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:** `pending`
|
||||||
|
|
||||||
|
## 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 14–17 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:** `pending`
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
```
|
```
|
||||||
|
|||||||
+36
@@ -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));
|
||||||
|
|||||||
+32
@@ -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,
|
||||||
|
|||||||
+14
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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) {}
|
||||||
|
|||||||
+6
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-1
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
@@ -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) {}
|
||||||
|
|||||||
+11
@@ -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()))
|
||||||
|
|||||||
+30
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -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);
|
||||||
|
|||||||
+18
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -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,6 +76,7 @@ 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);
|
||||||
|
try {
|
||||||
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
|
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
|
||||||
record.internId(),
|
record.internId(),
|
||||||
record.workDate(),
|
record.workDate(),
|
||||||
@@ -69,8 +84,19 @@ public class AttendanceApplicationService {
|
|||||||
record.checkInAt(),
|
record.checkInAt(),
|
||||||
record.checkOutAt()))
|
record.checkOutAt()))
|
||||||
.toDomain();
|
.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());
|
||||||
|
try {
|
||||||
return recordEntities.saveAndFlush(persisted).toDomain();
|
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
@@ -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");
|
||||||
|
|||||||
+20
@@ -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))
|
||||||
|
|||||||
+38
@@ -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);
|
||||||
|
|||||||
@@ -41,12 +41,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="item : ${items}">
|
<tr th:each="item : ${items}">
|
||||||
<td th:text="${item.workDate}"></td>
|
<td th:text="${item.workDateDisplay}"></td>
|
||||||
<td th:text="${item.checkInAt}"></td>
|
<td th:text="${item.checkInTimeDisplay}"></td>
|
||||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></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 th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
|
<td th:text="${item.resultDisplay}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+28
-1
@@ -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);
|
||||||
|
|||||||
+32
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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)));
|
||||||
|
|||||||
+36
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-2
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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);
|
||||||
|
|||||||
+2
-1
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user