feat: add attendance policy and punch domain
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user