Merge commit '01b8095e9459417e2cf5bd1079c796d4f01ec549' into work/reports-ui

# Conflicts:
#	src/main/resources/templates/attendance/history.html
This commit is contained in:
sechmachine
2026-08-15 03:35:04 +07:00
46 changed files with 1270 additions and 63 deletions
@@ -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));
@@ -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,
@@ -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;
}
@@ -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) {}
@@ -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
}
@@ -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());
}
}
@@ -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) {}
@@ -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()))
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
@@ -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);
@@ -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);
}
@@ -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);
}
@@ -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,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");
@@ -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))
@@ -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);
@@ -39,17 +39,12 @@
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></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>
<span th:if="${!item.violations.late and !item.violations.earlyDeparture and !item.violations.missingCheckout}">On time</span>
<span th:if="${item.violations.late}">Late</span>
<span th:if="${item.violations.earlyDeparture}" th:text="${item.violations.late ? ', Early departure' : 'Early departure'}">Early departure</span>
<span th:if="${item.violations.missingCheckout}" th:text="${item.violations.late or item.violations.earlyDeparture ? ', Missing checkout' : 'Missing checkout'}">Missing checkout</span>
</td>
<td th:text="${item.resultDisplay}"></td>
</tr>
</tbody>
</table></div>