fix(attendance): preserve frozen attendance boundaries
This commit is contained in:
+36
@@ -17,6 +17,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* Server-rendered attendance routes for Intern punches and role-scoped historical inspection.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/attendance")
|
||||
public class AttendanceController {
|
||||
@@ -30,6 +33,15 @@ public class AttendanceController {
|
||||
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
|
||||
public String ownHistory(
|
||||
Principal principal,
|
||||
@@ -40,6 +52,16 @@ public class AttendanceController {
|
||||
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}")
|
||||
public String inspectHistory(
|
||||
Principal principal,
|
||||
@@ -54,6 +76,13 @@ public class AttendanceController {
|
||||
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")
|
||||
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
|
||||
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
|
||||
@@ -66,6 +95,13 @@ public class AttendanceController {
|
||||
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")
|
||||
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
|
||||
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.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* Admin-only server-rendered routes for manual global calendar management.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/attendance/calendar")
|
||||
public class CalendarController {
|
||||
@@ -35,6 +38,13 @@ public class CalendarController {
|
||||
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
|
||||
public String calendar(Principal principal, Model model) {
|
||||
requireAdmin(currentUsers.actor(principal));
|
||||
@@ -44,6 +54,16 @@ public class CalendarController {
|
||||
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
|
||||
public String create(
|
||||
Principal principal,
|
||||
@@ -57,6 +77,18 @@ public class CalendarController {
|
||||
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}")
|
||||
public String update(
|
||||
Principal principal,
|
||||
|
||||
+14
@@ -1,14 +1,28 @@
|
||||
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 {
|
||||
|
||||
/** Stable reason preserved for controller and service consumers. */
|
||||
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) {
|
||||
super(rejection.name());
|
||||
this.rejection = rejection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable rejection reason without exposing persistence failures.
|
||||
*
|
||||
* @return attendance rejection reason
|
||||
*/
|
||||
public AttendanceRejection rejection() {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
+11
@@ -1,12 +1,23 @@
|
||||
package com.lab.labtimesheet.feature.attendance.exception;
|
||||
|
||||
/**
|
||||
* Stable business outcomes for attendance operations, including idempotency and eligibility failures.
|
||||
*/
|
||||
public enum AttendanceRejection {
|
||||
/** The account or internship is not active for the work date. */
|
||||
INACTIVE_INTERN,
|
||||
/** The attached policy does not configure the date's weekday for attendance. */
|
||||
NON_WORKDAY,
|
||||
/** The authoritative global calendar exempts the date. */
|
||||
GLOBAL_DAY_OFF,
|
||||
/** An approved leave request has a frozen allocation for the exact date. */
|
||||
APPROVED_LEAVE,
|
||||
/** A row already exists for the Intern and work date. */
|
||||
ALREADY_CHECKED_IN,
|
||||
/** No row exists for the current work date. */
|
||||
NO_ATTENDANCE_RECORD,
|
||||
/** The row already contains its first raw checkout. */
|
||||
ALREADY_CHECKED_OUT,
|
||||
/** The attached-policy inclusive checkout cutoff has passed. */
|
||||
CHECKOUT_CUTOFF_PASSED
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
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 {
|
||||
|
||||
/**
|
||||
* Creates a calendar rejection with operator-facing context.
|
||||
*
|
||||
* @param message explanation of the rejected mutation
|
||||
*/
|
||||
public CalendarException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,17 @@ package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
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) {
|
||||
|
||||
/**
|
||||
* Rejects an actor without a resolved global role.
|
||||
*/
|
||||
public AttendanceActor {
|
||||
Objects.requireNonNull(role, "role");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
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) {}
|
||||
|
||||
@@ -8,6 +8,21 @@ import java.time.ZoneId;
|
||||
import java.util.Objects;
|
||||
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(
|
||||
long id,
|
||||
LocalDate effectiveFrom,
|
||||
@@ -23,6 +38,9 @@ public record AttendancePolicy(
|
||||
private static final int MAX_GRACE_MINUTES = 720;
|
||||
private static final int SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/**
|
||||
* Validates schedule and grace invariants and defensively snapshots the configured workdays.
|
||||
*/
|
||||
public AttendancePolicy {
|
||||
Objects.requireNonNull(effectiveFrom, "effectiveFrom");
|
||||
Objects.requireNonNull(zoneId, "zoneId");
|
||||
@@ -41,25 +59,12 @@ public record AttendancePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the local date is a configured workday under this version.
|
||||
*
|
||||
* @param date local date interpreted by this policy
|
||||
* @return {@code true} when the weekday is configured for attendance
|
||||
*/
|
||||
public boolean isWorkday(LocalDate date) {
|
||||
return workdays.contains(date.getDayOfWeek());
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ import java.time.LocalDate;
|
||||
import java.time.ZonedDateTime;
|
||||
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(
|
||||
long internId,
|
||||
LocalDate workDate,
|
||||
@@ -15,12 +25,22 @@ public record AttendanceRecord(
|
||||
Instant checkInAt,
|
||||
Instant checkOutAt) {
|
||||
|
||||
/**
|
||||
* Validates required historical fields while preserving a nullable raw checkout.
|
||||
*/
|
||||
public AttendanceRecord {
|
||||
Objects.requireNonNull(workDate, "workDate");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
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) {
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (checkOutAt != null) {
|
||||
@@ -32,6 +52,13 @@ public record AttendanceRecord(
|
||||
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) {
|
||||
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
|
||||
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
/**
|
||||
* Global account roles recognized by attendance authorization rules.
|
||||
*/
|
||||
public enum AttendanceRole {
|
||||
/** Global system administrator. */
|
||||
ADMIN,
|
||||
/** Global laboratory Mentor. */
|
||||
MENTOR,
|
||||
/** Internship participant. */
|
||||
INTERN
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
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) {}
|
||||
|
||||
+6
@@ -1,7 +1,13 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.dto;
|
||||
|
||||
/**
|
||||
* Presentation-safe current business-date punch state exposed across feature boundaries.
|
||||
*/
|
||||
public enum AttendanceCurrentState {
|
||||
/** No attendance row exists for the current business date. */
|
||||
NOT_CHECKED_IN,
|
||||
/** A row exists without raw checkout. */
|
||||
CHECKED_IN,
|
||||
/** A row exists with its accepted raw checkout. */
|
||||
CHECKED_OUT
|
||||
}
|
||||
|
||||
+93
-1
@@ -5,10 +5,102 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
|
||||
import java.time.Instant;
|
||||
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(
|
||||
LocalDate workDate,
|
||||
Instant checkInAt,
|
||||
Instant checkOutAt,
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
|
||||
+11
@@ -20,6 +20,9 @@ import java.time.ZoneId;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JPA mapping of an immutable-on-effective attendance policy version and its configured workdays.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "attendance_policy_versions")
|
||||
public class AttendancePolicyEntity {
|
||||
@@ -62,8 +65,16 @@ public class AttendancePolicyEntity {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/**
|
||||
* Required by JPA; application code resolves existing effective-dated versions instead of constructing them here.
|
||||
*/
|
||||
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() {
|
||||
Set<DayOfWeek> workdays = isoWeekdays.stream()
|
||||
.map(day -> DayOfWeek.of(day.intValue()))
|
||||
|
||||
+30
@@ -14,6 +14,9 @@ import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* JPA persistence model for one Intern/work-date punch row with its permanently attached policy version.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "attendance_records")
|
||||
public class AttendanceRecordEntity {
|
||||
@@ -41,8 +44,20 @@ public class AttendanceRecordEntity {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/**
|
||||
* Required by JPA.
|
||||
*/
|
||||
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(
|
||||
long internUserId,
|
||||
LocalDate workDate,
|
||||
@@ -56,14 +71,29 @@ public class AttendanceRecordEntity {
|
||||
this.checkOutAt = checkOutAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrates the immutable domain record without replacing the historical policy.
|
||||
*
|
||||
* @return attendance domain record
|
||||
*/
|
||||
public AttendanceRecord toDomain() {
|
||||
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) {
|
||||
this.checkOutAt = checkOutAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the immutable local date used for account eligibility revalidation at checkout.
|
||||
*
|
||||
* @return persisted work date
|
||||
*/
|
||||
public LocalDate workDate() {
|
||||
return workDate;
|
||||
}
|
||||
|
||||
+37
@@ -11,6 +11,9 @@ import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* JPA model for the locally authoritative global calendar decision.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "global_calendar_events")
|
||||
public class GlobalCalendarEventEntity {
|
||||
@@ -40,8 +43,19 @@ public class GlobalCalendarEventEntity {
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
/**
|
||||
* Required by JPA.
|
||||
*/
|
||||
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) {
|
||||
this.calendarDate = date;
|
||||
this.name = name;
|
||||
@@ -51,6 +65,14 @@ public class GlobalCalendarEventEntity {
|
||||
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) {
|
||||
this.calendarDate = date;
|
||||
this.name = name;
|
||||
@@ -58,14 +80,29 @@ public class GlobalCalendarEventEntity {
|
||||
this.updatedByUserId = actorUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a persistence-free event including the optimistic version needed for edits.
|
||||
*
|
||||
* @return calendar event DTO
|
||||
*/
|
||||
public GlobalCalendarEvent toDomain() {
|
||||
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() {
|
||||
return calendarDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the optimistic version expected by a subsequent update.
|
||||
*
|
||||
* @return current version
|
||||
*/
|
||||
public long 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.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "leave_requests")
|
||||
public class LeaveRequestEntity {
|
||||
@@ -25,8 +30,56 @@ public class LeaveRequestEntity {
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String reason;
|
||||
|
||||
@Column(nullable = false)
|
||||
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() {}
|
||||
|
||||
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 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> {
|
||||
|
||||
/**
|
||||
* Loads the complete timeline in effective-date order for deterministic local-date resolution.
|
||||
*
|
||||
* @return ascending policy versions, including the 1970 seed
|
||||
*/
|
||||
List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
|
||||
}
|
||||
|
||||
+19
-5
@@ -1,18 +1,32 @@
|
||||
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 org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
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("""
|
||||
select count(request) > 0
|
||||
from LeaveRequestEntity request
|
||||
select count(day) > 0
|
||||
from LeaveRequestDayEntity day
|
||||
join day.request request
|
||||
where request.internUserId = :internId and request.status = 'APPROVED'
|
||||
and :workDate between request.startDate and request.endDate
|
||||
and day.id.leaveDate = :workDate
|
||||
""")
|
||||
boolean hasApprovedLeave(
|
||||
@Param("internId") long internId, @Param("workDate") LocalDate workDate);
|
||||
|
||||
+18
@@ -6,10 +6,28 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
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> {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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(
|
||||
long internUserId, LocalDate from, LocalDate to);
|
||||
}
|
||||
|
||||
+16
@@ -5,10 +5,26 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* Spring Data access to locally authoritative global calendar events.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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(
|
||||
LocalDate from, LocalDate to);
|
||||
}
|
||||
|
||||
+62
-8
@@ -21,9 +21,15 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.stereotype.Service;
|
||||
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
|
||||
public class AttendanceApplicationService {
|
||||
|
||||
@@ -52,6 +58,14 @@ public class AttendanceApplicationService {
|
||||
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
|
||||
public AttendanceRecord checkIn(long internId) {
|
||||
Instant now = clock.instant();
|
||||
@@ -62,15 +76,27 @@ public class AttendanceApplicationService {
|
||||
.map(AttendanceRecordEntity::toDomain);
|
||||
AttendanceRecord record = attendance.checkIn(
|
||||
internId, now, policy, dayContext(internId, workDate), existing);
|
||||
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
|
||||
record.internId(),
|
||||
record.workDate(),
|
||||
policyEntities.getReferenceById(record.policy().id()),
|
||||
record.checkInAt(),
|
||||
record.checkOutAt()))
|
||||
.toDomain();
|
||||
try {
|
||||
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
|
||||
record.internId(),
|
||||
record.workDate(),
|
||||
policyEntities.getReferenceById(record.policy().id()),
|
||||
record.checkInAt(),
|
||||
record.checkOutAt()))
|
||||
.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
|
||||
public AttendanceRecord checkOut(long internId) {
|
||||
Instant now = clock.instant();
|
||||
@@ -79,10 +105,23 @@ public class AttendanceApplicationService {
|
||||
Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate);
|
||||
AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now);
|
||||
AttendanceRecordEntity persisted = entity.orElseThrow();
|
||||
if (!accounts.isEligibleIntern(internId, persisted.workDate())) {
|
||||
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
|
||||
}
|
||||
persisted.setCheckOutAt(checkedOut.checkOutAt());
|
||||
return recordEntities.saveAndFlush(persisted).toDomain();
|
||||
try {
|
||||
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)
|
||||
public AttendanceCurrentState currentState(long internId) {
|
||||
Instant now = clock.instant();
|
||||
@@ -99,6 +138,16 @@ public class AttendanceApplicationService {
|
||||
.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)
|
||||
public List<AttendanceHistoryItem> history(
|
||||
AttendanceActor actor, long internId, LocalDate from, LocalDate to) {
|
||||
@@ -120,6 +169,11 @@ public class AttendanceApplicationService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current business date in the effective policy timezone.
|
||||
*
|
||||
* @return current policy-local date from the injected server clock
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public LocalDate currentBusinessDate() {
|
||||
AttendancePolicy policy = timeline().resolve(clock.instant());
|
||||
|
||||
+9
@@ -9,6 +9,9 @@ import java.security.Principal;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs.
|
||||
*/
|
||||
@Service
|
||||
public class AttendanceCurrentUserService {
|
||||
|
||||
@@ -18,6 +21,12 @@ public class AttendanceCurrentUserService {
|
||||
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) {
|
||||
if (principal == null || principal.getName() == null) {
|
||||
throw new AccessDeniedException("Authentication is required");
|
||||
|
||||
+20
@@ -8,16 +8,30 @@ import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Deterministically resolves immutable attendance policy versions for local dates or server instants.
|
||||
*/
|
||||
public final class AttendancePolicyTimeline {
|
||||
|
||||
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) {
|
||||
this.policies = policies.stream()
|
||||
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
|
||||
.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) {
|
||||
Objects.requireNonNull(date, "date");
|
||||
return policies.stream()
|
||||
@@ -26,6 +40,12 @@ public final class AttendancePolicyTimeline {
|
||||
.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) {
|
||||
Objects.requireNonNull(instant, "instant");
|
||||
return policies.stream()
|
||||
|
||||
@@ -10,9 +10,25 @@ import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Pure attendance punch rules over immutable policy, date context, and raw record values.
|
||||
*/
|
||||
@Service
|
||||
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(
|
||||
long internId,
|
||||
Instant now,
|
||||
@@ -27,6 +43,13 @@ public final class AttendanceService {
|
||||
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) {
|
||||
return 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.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision.
|
||||
*/
|
||||
@Service
|
||||
public class CalendarApplicationService {
|
||||
|
||||
@@ -32,6 +35,15 @@ public class CalendarApplicationService {
|
||||
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
|
||||
public GlobalCalendarEvent createManual(
|
||||
AttendanceActor actor, LocalDate date, String name, boolean dayOff) {
|
||||
@@ -41,6 +53,18 @@ public class CalendarApplicationService {
|
||||
.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
|
||||
public GlobalCalendarEvent updateManual(
|
||||
AttendanceActor actor,
|
||||
@@ -61,6 +85,13 @@ public class CalendarApplicationService {
|
||||
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)
|
||||
public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) {
|
||||
if (from.isAfter(to)) {
|
||||
@@ -72,6 +103,13 @@ public class CalendarApplicationService {
|
||||
.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)
|
||||
public boolean isGlobalDayOff(LocalDate date) {
|
||||
return events.existsByCalendarDateAndDayOffTrue(date);
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="item : ${items}">
|
||||
<td th:text="${item.workDate}"></td>
|
||||
<td th:text="${item.checkInAt}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
|
||||
<td th:text="|${item.policy.scheduledStart}–${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
|
||||
<td th:text="${item.workDateDisplay}"></td>
|
||||
<td th:text="${item.checkInTimeDisplay}"></td>
|
||||
<td th:text="${item.checkOutTimeDisplay}"></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.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
|
||||
<td th:text="${item.resultDisplay}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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.status;
|
||||
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.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
@@ -69,7 +72,7 @@ class AttendanceControllerTest {
|
||||
LocalDate.of(2026, 8, 14),
|
||||
Instant.parse("2026-08-14T02:00:00Z"),
|
||||
Instant.parse("2026-08-14T09:00:00Z"),
|
||||
AttendancePolicy.seeded(1L),
|
||||
AttendancePolicyFixtures.seeded(1L),
|
||||
new AttendanceViolations(false, false, false))));
|
||||
|
||||
mockMvc.perform(get("/attendance")
|
||||
@@ -82,6 +85,30 @@ class AttendanceControllerTest {
|
||||
.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
|
||||
void mentorCanInspectInternHistory() throws Exception {
|
||||
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
|
||||
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
|
||||
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
|
||||
AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L);
|
||||
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
|
||||
|
||||
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.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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.AttendanceRejection;
|
||||
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.dto.AttendanceCurrentState;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
|
||||
@@ -24,6 +26,8 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
|
||||
class AttendanceApplicationServiceTest {
|
||||
|
||||
@@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
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(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
|
||||
attendance = new AttendanceApplicationService(
|
||||
@@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest {
|
||||
.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) {
|
||||
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
|
||||
when(entity.toDomain()).thenReturn(new AttendanceRecord(
|
||||
INTERN_ID,
|
||||
WORK_DATE,
|
||||
AttendancePolicy.seeded(1L),
|
||||
AttendancePolicyFixtures.seeded(1L),
|
||||
NOW,
|
||||
checkOutAt));
|
||||
when(entity.workDate()).thenReturn(WORK_DATE);
|
||||
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.dto.AttendanceCurrentState;
|
||||
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.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
@@ -26,6 +28,7 @@ import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.testcontainers.postgresql.PostgreSQLContainer;
|
||||
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)
|
||||
@SpringBootTest
|
||||
@@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest {
|
||||
@Autowired
|
||||
private AttendanceRecordRepository records;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Autowired
|
||||
private MutableClock clock;
|
||||
|
||||
@@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest {
|
||||
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
|
||||
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
|
||||
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.model.AttendanceDayContext;
|
||||
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.AttendanceViolations;
|
||||
import java.math.BigDecimal;
|
||||
@@ -158,7 +159,7 @@ class AttendanceServiceTest {
|
||||
}
|
||||
|
||||
private static AttendancePolicy seededPolicy() {
|
||||
return AttendancePolicy.seeded(1L);
|
||||
return AttendancePolicyFixtures.seeded(1L);
|
||||
}
|
||||
|
||||
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {
|
||||
|
||||
Reference in New Issue
Block a user