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
@@ -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) {}