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) {}
|
||||
Reference in New Issue
Block a user