feat: add attendance policy and punch domain

This commit is contained in:
sechmachine
2026-08-14 23:34:12 +07:00
parent 5967f7f70d
commit 71901d1670
14 changed files with 697 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
# Test Evidence: Attendance policy defaults and boundaries
- **Test type:** Unit
- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.attendance.AttendancePolicyTest`
- **Implementation commit:** `pending (committed with this evidence)`
## Protected behavior
The seeded policy applies from 1970-01-01 with the required timezone, schedule,
workdays, grace values, leave quota, and penalty. Grace outside 0..720 or a
checkout cutoff at midnight is rejected.
## Test method
Plain JUnit constructs the immutable policy and timeline directly, resolves two
dates, and exercises the validation boundary without Spring or persistence.
## Hand-derived expected result
08:30 plus 30 minutes makes the inclusive on-time boundary 09:00. 15:30 plus
30 minutes makes the inclusive checkout boundary 16:00. A 23:30 end plus 30
minutes reaches midnight and is invalid.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendancePolicyTest test
```
**Observed result**
```text
[ERROR] AttendancePolicyTest.java:[51,20] cannot find symbol
symbol: class AttendancePolicy
[INFO] BUILD FAILURE
Process exited 1. The test reached compilation and failed because the required policy domain did not exist.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendancePolicyTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest='Attendance*Test' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
This unit test does not prove the platform-owned Flyway seed, PostgreSQL policy
loading, policy-management authorization, or web rendering.
@@ -0,0 +1,83 @@
# Test Evidence: Attendance punch boundaries
- **Test type:** Unit
- **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016`
- **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005`
- **Test class/method:** `com.lab.labtimesheet.attendance.AttendanceServiceTest`
- **Implementation commit:** `pending (committed with this evidence)`
## Protected behavior
Clock-controlled server time determines the local work date and raw punches.
Check-in rejects inactive, non-workday, day-off, leave, and duplicate attempts.
Exact grace/cutoff instants succeed; later checkout never writes raw checkout;
a missed checkout is not also an early departure.
## Test method
Plain JUnit uses a fixed `Clock`, the production domain service, and a minimal
in-memory repository port. Assertions cover stored state as well as rejection
codes, including non-overwrite behavior.
## Hand-derived expected result
Asia/Ho_Chi_Minh is UTC+07 for the tested date: 09:00 local is 02:00Z,
15:30 local is 08:30Z, and 16:00 local is 09:00Z. Equality is accepted;
adding one millisecond crosses each strict-later boundary.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceServiceTest test
```
**Observed result**
```text
[ERROR] AttendanceServiceTest.java:[3,46] cannot find symbol
symbol: class AttendanceRejection
[ERROR] AttendanceServiceTest.java:[136,20] cannot find symbol
symbol: class AttendanceService
[INFO] 29 errors
[INFO] BUILD FAILURE
Process exited 1. The test reached compilation and failed because the required attendance domain did not exist.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceServiceTest test
```
**Observed result**
```text
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest='Attendance*Test' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
This unit test does not prove transaction isolation, PostgreSQL uniqueness,
platform account/intern-state queries, approved-leave persistence, Spring
Security, controller routing, or Thymeleaf rendering.
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.attendance;
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
@@ -0,0 +1,9 @@
package com.lab.labtimesheet.attendance;
import java.time.LocalDate;
@FunctionalInterface
public interface AttendanceDayContextProvider {
AttendanceDayContext get(long internId, LocalDate workDate);
}
@@ -0,0 +1,15 @@
package com.lab.labtimesheet.attendance;
public final class AttendanceException extends RuntimeException {
private final AttendanceRejection rejection;
public AttendanceException(AttendanceRejection rejection) {
super(rejection.name());
this.rejection = rejection;
}
public AttendanceRejection rejection() {
return rejection;
}
}
@@ -0,0 +1,72 @@
package com.lab.labtimesheet.attendance;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Objects;
import java.util.Set;
public record AttendancePolicy(
long id,
LocalDate effectiveFrom,
ZoneId zoneId,
LocalTime scheduledStart,
LocalTime scheduledEnd,
int checkInGraceMinutes,
int checkoutGraceMinutes,
int monthlyLeaveQuota,
BigDecimal violationPenalty,
Set<DayOfWeek> workdays) {
private static final int MAX_GRACE_MINUTES = 720;
private static final int SECONDS_PER_DAY = 86_400;
public AttendancePolicy {
Objects.requireNonNull(effectiveFrom, "effectiveFrom");
Objects.requireNonNull(zoneId, "zoneId");
Objects.requireNonNull(scheduledStart, "scheduledStart");
Objects.requireNonNull(scheduledEnd, "scheduledEnd");
Objects.requireNonNull(violationPenalty, "violationPenalty");
workdays = Set.copyOf(workdays);
requireGraceInRange(checkInGraceMinutes, "checkInGraceMinutes");
requireGraceInRange(checkoutGraceMinutes, "checkoutGraceMinutes");
if (!scheduledEnd.isAfter(scheduledStart)) {
throw new IllegalArgumentException("scheduledEnd must be after scheduledStart");
}
if (scheduledEnd.toSecondOfDay() + checkoutGraceMinutes * 60 >= SECONDS_PER_DAY) {
throw new IllegalArgumentException("checkout cutoff must be before local midnight");
}
}
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));
}
public boolean isWorkday(LocalDate date) {
return workdays.contains(date.getDayOfWeek());
}
private static void requireGraceInRange(int value, String field) {
if (value < 0 || value > MAX_GRACE_MINUTES) {
throw new IllegalArgumentException(field + " must be between 0 and 720");
}
}
}
@@ -0,0 +1,36 @@
package com.lab.labtimesheet.attendance;
import java.time.LocalDate;
import java.time.Instant;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public final class AttendancePolicyTimeline {
private final List<AttendancePolicy> policies;
public AttendancePolicyTimeline(Collection<AttendancePolicy> policies) {
this.policies = policies.stream()
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
.toList();
}
public AttendancePolicy resolve(LocalDate date) {
Objects.requireNonNull(date, "date");
return policies.stream()
.filter(policy -> !policy.effectiveFrom().isAfter(date))
.reduce((first, second) -> second)
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date));
}
public AttendancePolicy resolve(Instant instant) {
Objects.requireNonNull(instant, "instant");
return policies.stream()
.filter(policy -> !policy.effectiveFrom().isAfter(
instant.atZone(policy.zoneId()).toLocalDate()))
.reduce((first, second) -> second)
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies at " + instant));
}
}
@@ -0,0 +1,50 @@
package com.lab.labtimesheet.attendance;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.Objects;
public record AttendanceRecord(
long internId,
LocalDate workDate,
AttendancePolicy policy,
Instant checkInAt,
Instant checkOutAt) {
public AttendanceRecord {
Objects.requireNonNull(workDate, "workDate");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(checkInAt, "checkInAt");
}
public AttendanceRecord checkOut(Instant at) {
Objects.requireNonNull(at, "at");
if (checkOutAt != null) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT);
}
if (at.isAfter(checkoutCutoff())) {
throw new AttendanceException(AttendanceRejection.CHECKOUT_CUTOFF_PASSED);
}
return new AttendanceRecord(internId, workDate, policy, checkInAt, at);
}
public AttendanceViolations violations(Instant observedAt) {
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
boolean earlyDeparture = checkOutAt != null && checkOutAt.isBefore(scheduledEnd());
return new AttendanceViolations(late, earlyDeparture, missingCheckout);
}
private Instant scheduledStart() {
return ZonedDateTime.of(workDate, policy.scheduledStart(), policy.zoneId()).toInstant();
}
private Instant scheduledEnd() {
return ZonedDateTime.of(workDate, policy.scheduledEnd(), policy.zoneId()).toInstant();
}
private Instant checkoutCutoff() {
return scheduledEnd().plusSeconds(policy.checkoutGraceMinutes() * 60L);
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.attendance;
public enum AttendanceRejection {
INACTIVE_INTERN,
NON_WORKDAY,
GLOBAL_DAY_OFF,
APPROVED_LEAVE,
ALREADY_CHECKED_IN,
NO_ATTENDANCE_RECORD,
ALREADY_CHECKED_OUT,
CHECKOUT_CUTOFF_PASSED
}
@@ -0,0 +1,11 @@
package com.lab.labtimesheet.attendance;
import java.time.LocalDate;
import java.util.Optional;
public interface AttendanceRepository {
Optional<AttendanceRecord> find(long internId, LocalDate workDate);
AttendanceRecord save(AttendanceRecord record);
}
@@ -0,0 +1,62 @@
package com.lab.labtimesheet.attendance;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
public final class AttendanceService {
private final Clock clock;
private final AttendancePolicyTimeline policies;
private final AttendanceRepository records;
private final AttendanceDayContextProvider dayContexts;
public AttendanceService(
Clock clock,
AttendancePolicyTimeline policies,
AttendanceRepository records,
AttendanceDayContextProvider dayContexts) {
this.clock = Objects.requireNonNull(clock, "clock");
this.policies = Objects.requireNonNull(policies, "policies");
this.records = Objects.requireNonNull(records, "records");
this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts");
}
public AttendanceRecord checkIn(long internId) {
Instant now = clock.instant();
AttendancePolicy policy = policies.resolve(now);
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
AttendanceDayContext context = dayContexts.get(internId, workDate);
requireEligible(policy, workDate, context);
if (records.find(internId, workDate).isPresent()) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN);
}
return records.save(new AttendanceRecord(internId, workDate, policy, now, null));
}
public AttendanceRecord checkOut(long internId) {
Instant now = clock.instant();
AttendancePolicy currentPolicy = policies.resolve(now);
LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate();
AttendanceRecord record = records.find(internId, workDate)
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD));
return records.save(record.checkOut(now));
}
private static void requireEligible(
AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) {
if (!context.activeIntern()) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
if (!policy.isWorkday(workDate)) {
throw new AttendanceException(AttendanceRejection.NON_WORKDAY);
}
if (context.globalDayOff()) {
throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF);
}
if (context.approvedLeave()) {
throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE);
}
}
}
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.attendance;
public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
@@ -0,0 +1,65 @@
package com.lab.labtimesheet.attendance;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendancePolicyTest {
@Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14)));
assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId());
assertEquals(LocalTime.of(8, 30), seeded.scheduledStart());
assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd());
assertEquals(30, seeded.checkInGraceMinutes());
assertEquals(30, seeded.checkoutGraceMinutes());
assertEquals(3, seeded.monthlyLeaveQuota());
assertEquals(new BigDecimal("0.25"), seeded.violationPenalty());
assertEquals(
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY),
seeded.workdays());
}
@Test
void rejectsGraceOutsideZeroThroughSevenHundredTwenty() {
assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30)));
assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30)));
}
@Test
void rejectsCheckoutCutoffAtLocalMidnight() {
assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30)));
}
private static AttendancePolicy policy(
int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) {
return new AttendancePolicy(
2L,
LocalDate.of(2026, 9, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
scheduledEnd,
checkInGraceMinutes,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
Set.of(DayOfWeek.MONDAY));
}
}
@@ -0,0 +1,199 @@
package com.lab.labtimesheet.attendance;
import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN;
import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT;
import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE;
import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED;
import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF;
import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN;
import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendanceServiceTest {
private static final long INTERN_ID = 42L;
private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14);
@Test
void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() {
AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy());
AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy());
assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late());
assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late());
assertEquals(WORKDAY, exactBoundary.workDate());
assertEquals(1L, exactBoundary.policy().id());
}
@Test
void rejectsIneligibleAndDuplicateCheckIns() {
assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false));
assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false));
assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true));
AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY));
assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly);
InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository();
AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy());
service.checkIn(INTERN_ID);
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID));
assertEquals(ALREADY_CHECKED_IN, exception.rejection());
assertEquals(1, repository.records.size());
}
@Test
void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() {
InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy());
AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy());
AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID);
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy());
AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID));
assertEquals(ALREADY_CHECKED_OUT, repeated.rejection());
assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt());
}
@Test
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy());
AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy());
AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(repository.record().checkOutAt());
AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z"));
assertTrue(violations.missingCheckout());
assertFalse(violations.earlyDeparture());
}
@Test
void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() {
AttendancePolicy zeroGrace = policy(
0,
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace);
AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace)
.checkOut(INTERN_ID);
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace);
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace)
.checkOut(INTERN_ID));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(lateRepository.record().checkOutAt());
}
private static AttendanceRecord checkInAt(
String instant, AttendanceDayContext context, AttendancePolicy policy) {
return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID);
}
private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) {
assertCheckInRejected(rejection, context, seededPolicy());
}
private static void assertCheckInRejected(
AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) {
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy)
.checkIn(INTERN_ID));
assertEquals(rejection, exception.rejection());
}
private static AttendanceService serviceAt(
String instant,
InMemoryAttendanceRepository repository,
AttendanceDayContext context,
AttendancePolicy policy) {
return new AttendanceService(
Clock.fixed(at(instant), ZoneOffset.UTC),
new AttendancePolicyTimeline(Set.of(policy)),
repository,
(internId, date) -> context);
}
private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) {
InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository();
serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID);
return repository;
}
private static AttendanceDayContext activeDay() {
return new AttendanceDayContext(true, false, false);
}
private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L);
}
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {
return new AttendancePolicy(
2L,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
workdays);
}
private static Instant at(String instant) {
return Instant.parse(instant);
}
private static final class InMemoryAttendanceRepository implements AttendanceRepository {
private final Map<LocalDate, AttendanceRecord> records = new HashMap<>();
@Override
public Optional<AttendanceRecord> find(long internId, LocalDate workDate) {
return Optional.ofNullable(records.get(workDate));
}
@Override
public AttendanceRecord save(AttendanceRecord record) {
records.put(record.workDate(), record);
return record;
}
private AttendanceRecord record() {
return records.get(WORKDAY);
}
}
}