feat: add iteration one attendance workflows

This commit is contained in:
sechmachine
2026-08-15 00:43:21 +07:00
parent c01c0089ac
commit 8b48e281f7
44 changed files with 2019 additions and 169 deletions
@@ -1,9 +0,0 @@
package com.lab.labtimesheet.attendance;
import java.time.LocalDate;
@FunctionalInterface
public interface AttendanceDayContextProvider {
AttendanceDayContext get(long internId, LocalDate workDate);
}
@@ -1,11 +0,0 @@
package com.lab.labtimesheet.attendance;
import java.time.LocalDate;
import java.util.Optional;
public interface AttendanceRepository {
Optional<AttendanceRecord> find(long internId, LocalDate workDate);
AttendanceRecord save(AttendanceRecord record);
}
@@ -1,62 +0,0 @@
package com.lab.labtimesheet.attendance;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
public final class AttendanceService {
private final Clock clock;
private final AttendancePolicyTimeline policies;
private final AttendanceRepository records;
private final AttendanceDayContextProvider dayContexts;
public AttendanceService(
Clock clock,
AttendancePolicyTimeline policies,
AttendanceRepository records,
AttendanceDayContextProvider dayContexts) {
this.clock = Objects.requireNonNull(clock, "clock");
this.policies = Objects.requireNonNull(policies, "policies");
this.records = Objects.requireNonNull(records, "records");
this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts");
}
public AttendanceRecord checkIn(long internId) {
Instant now = clock.instant();
AttendancePolicy policy = policies.resolve(now);
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
AttendanceDayContext context = dayContexts.get(internId, workDate);
requireEligible(policy, workDate, context);
if (records.find(internId, workDate).isPresent()) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN);
}
return records.save(new AttendanceRecord(internId, workDate, policy, now, null));
}
public AttendanceRecord checkOut(long internId) {
Instant now = clock.instant();
AttendancePolicy currentPolicy = policies.resolve(now);
LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate();
AttendanceRecord record = records.find(internId, workDate)
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD));
return records.save(record.checkOut(now));
}
private static void requireEligible(
AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) {
if (!context.activeIntern()) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
if (!policy.isWorkday(workDate)) {
throw new AttendanceException(AttendanceRejection.NON_WORKDAY);
}
if (context.globalDayOff()) {
throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF);
}
if (context.approvedLeave()) {
throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE);
}
}
}
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.attendance.controller;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import java.security.Principal;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping("/attendance")
public class AttendanceController {
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
AttendanceController(
AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) {
this.attendance = attendance;
this.currentUsers = currentUsers;
}
@GetMapping
public String ownHistory(
Principal principal,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
Model model) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
return history(actor, actor.userId(), from, to, model);
}
@GetMapping("/interns/{internId}")
public String inspectHistory(
Principal principal,
@org.springframework.web.bind.annotation.PathVariable long internId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
Model model) {
AttendanceActor actor = currentUsers.actor(principal);
if (actor.role() == AttendanceRole.INTERN) {
throw new AccessDeniedException("Intern inspection is not allowed");
}
return history(actor, internId, from, to, model);
}
@PostMapping("/check-in")
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
try {
attendance.checkIn(actor.userId());
redirectAttributes.addFlashAttribute("message", "Checked in");
} catch (AttendanceException exception) {
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
}
return "redirect:/attendance";
}
@PostMapping("/check-out")
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
try {
attendance.checkOut(actor.userId());
redirectAttributes.addFlashAttribute("message", "Checked out");
} catch (AttendanceException exception) {
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
}
return "redirect:/attendance";
}
private String history(
AttendanceActor actor,
long internId,
LocalDate from,
LocalDate to,
Model model) {
LocalDate effectiveTo = to == null ? attendance.currentBusinessDate() : to;
LocalDate effectiveFrom = from == null ? effectiveTo.withDayOfMonth(1) : from;
model.addAttribute("items", attendance.history(actor, internId, effectiveFrom, effectiveTo));
model.addAttribute("targetInternId", internId);
model.addAttribute("from", effectiveFrom);
model.addAttribute("to", effectiveTo);
model.addAttribute("ownHistory", actor.userId() == internId);
return "attendance/history";
}
private static AttendanceActor requireIntern(AttendanceActor actor) {
if (actor.role() != AttendanceRole.INTERN) {
throw new AccessDeniedException("Only Interns may punch attendance");
}
return actor;
}
}
@@ -0,0 +1,81 @@
package com.lab.labtimesheet.feature.attendance.controller;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import java.security.Principal;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping("/attendance/calendar")
public class CalendarController {
private final CalendarApplicationService calendar;
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
CalendarController(
CalendarApplicationService calendar,
AttendanceApplicationService attendance,
AttendanceCurrentUserService currentUsers) {
this.calendar = calendar;
this.attendance = attendance;
this.currentUsers = currentUsers;
}
@GetMapping
public String calendar(Principal principal, Model model) {
requireAdmin(currentUsers.actor(principal));
LocalDate today = attendance.currentBusinessDate();
model.addAttribute("events", calendar.list(today, today.plusYears(1)));
model.addAttribute("today", today);
return "attendance/calendar";
}
@PostMapping
public String create(
Principal principal,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam String name,
@RequestParam(defaultValue = "false") boolean dayOff,
RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireAdmin(currentUsers.actor(principal));
calendar.createManual(actor, date, name, dayOff);
redirectAttributes.addFlashAttribute("message", "Calendar event created");
return "redirect:/attendance/calendar";
}
@PostMapping("/{eventId}")
public String update(
Principal principal,
@PathVariable long eventId,
@RequestParam long version,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam String name,
@RequestParam(defaultValue = "false") boolean dayOff,
RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireAdmin(currentUsers.actor(principal));
calendar.updateManual(actor, eventId, version, date, name, dayOff);
redirectAttributes.addFlashAttribute("message", "Calendar event updated");
return "redirect:/attendance/calendar";
}
private static AttendanceActor requireAdmin(AttendanceActor actor) {
if (actor.role() != AttendanceRole.ADMIN) {
throw new AccessDeniedException("Only Admin may manage the global calendar");
}
return actor;
}
}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.exception;
public final class AttendanceException extends RuntimeException {
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.exception;
public enum AttendanceRejection {
INACTIVE_INTERN,
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.attendance.exception;
public final class CalendarException extends RuntimeException {
public CalendarException(String message) {
super(message);
}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.attendance.model;
import java.util.Objects;
public record AttendanceActor(long userId, AttendanceRole role) {
public AttendanceActor {
Objects.requireNonNull(role, "role");
}
}
@@ -1,3 +1,3 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.model;
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.model;
import java.math.BigDecimal;
import java.time.DayOfWeek;
@@ -1,4 +1,7 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.model;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import java.time.Instant;
import java.time.LocalDate;
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.attendance.model;
public enum AttendanceRole {
ADMIN,
MENTOR,
INTERN
}
@@ -1,3 +1,3 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.model;
public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.attendance.model.dto;
public enum AttendanceCurrentState {
NOT_CHECKED_IN,
CHECKED_IN,
CHECKED_OUT
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.attendance.model.dto;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.time.Instant;
import java.time.LocalDate;
public record AttendanceHistoryItem(
LocalDate workDate,
Instant checkInAt,
Instant checkOutAt,
AttendancePolicy policy,
AttendanceViolations violations) {}
@@ -0,0 +1,5 @@
package com.lab.labtimesheet.feature.attendance.model.dto;
import java.time.LocalDate;
public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {}
@@ -0,0 +1,83 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import java.util.stream.Collectors;
@Entity
@Table(name = "attendance_policy_versions")
public class AttendancePolicyEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_from", nullable = false)
private LocalDate effectiveFrom;
@Column(name = "timezone_name", nullable = false)
private String timezoneName;
@Column(name = "scheduled_start", nullable = false)
private LocalTime scheduledStart;
@Column(name = "scheduled_end", nullable = false)
private LocalTime scheduledEnd;
@Column(name = "check_in_grace_minutes", nullable = false)
private int checkInGraceMinutes;
@Column(name = "checkout_grace_minutes", nullable = false)
private int checkoutGraceMinutes;
@Column(name = "monthly_leave_quota", nullable = false)
private int monthlyLeaveQuota;
@Column(name = "violation_penalty", nullable = false)
private BigDecimal violationPenalty;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "attendance_policy_workdays",
joinColumns = @JoinColumn(name = "policy_version_id"))
@Column(name = "iso_weekday", nullable = false)
private Set<Short> isoWeekdays;
@Version
private long version;
protected AttendancePolicyEntity() {}
public AttendancePolicy toDomain() {
Set<DayOfWeek> workdays = isoWeekdays.stream()
.map(day -> DayOfWeek.of(day.intValue()))
.collect(Collectors.toUnmodifiableSet());
return new AttendancePolicy(
id,
effectiveFrom,
ZoneId.of(timezoneName),
scheduledStart,
scheduledEnd,
checkInGraceMinutes,
checkoutGraceMinutes,
monthlyLeaveQuota,
violationPenalty,
workdays);
}
}
@@ -0,0 +1,70 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "attendance_records")
public class AttendanceRecordEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "intern_user_id", nullable = false)
private long internUserId;
@Column(name = "work_date", nullable = false)
private LocalDate workDate;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "policy_version_id", nullable = false)
private AttendancePolicyEntity policy;
@Column(name = "check_in_at", nullable = false)
private Instant checkInAt;
@Column(name = "check_out_at")
private Instant checkOutAt;
@Version
private long version;
protected AttendanceRecordEntity() {}
public AttendanceRecordEntity(
long internUserId,
LocalDate workDate,
AttendancePolicyEntity policy,
Instant checkInAt,
Instant checkOutAt) {
this.internUserId = internUserId;
this.workDate = workDate;
this.policy = policy;
this.checkInAt = checkInAt;
this.checkOutAt = checkOutAt;
}
public AttendanceRecord toDomain() {
return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt);
}
public void setCheckOutAt(Instant checkOutAt) {
this.checkOutAt = checkOutAt;
}
public LocalDate workDate() {
return workDate;
}
}
@@ -0,0 +1,72 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
@Entity
@Table(name = "global_calendar_events")
public class GlobalCalendarEventEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "calendar_date", nullable = false)
private LocalDate calendarDate;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String source;
@Column(name = "is_day_off", nullable = false)
private boolean dayOff;
@Column(name = "created_by_user_id", nullable = false, updatable = false)
private long createdByUserId;
@Column(name = "updated_by_user_id", nullable = false)
private long updatedByUserId;
@Version
private long version;
protected GlobalCalendarEventEntity() {}
public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date;
this.name = name;
this.source = "CUSTOM";
this.dayOff = dayOff;
this.createdByUserId = actorUserId;
this.updatedByUserId = actorUserId;
}
public void update(LocalDate date, String name, boolean dayOff, long actorUserId) {
this.calendarDate = date;
this.name = name;
this.dayOff = dayOff;
this.updatedByUserId = actorUserId;
}
public GlobalCalendarEvent toDomain() {
return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version);
}
public LocalDate calendarDate() {
return calendarDate;
}
public long version() {
return version;
}
}
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.attendance.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
@Entity
@Table(name = "leave_requests")
public class LeaveRequestEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "intern_user_id", nullable = false)
private long internUserId;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private String status;
protected LeaveRequestEntity() {}
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.attendance.repository;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AttendancePolicyRepository extends JpaRepository<AttendancePolicyEntity, Long> {
List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
}
@@ -0,0 +1,19 @@
package com.lab.labtimesheet.feature.attendance.repository;
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
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> {
@Query("""
select count(request) > 0
from LeaveRequestEntity request
where request.internUserId = :internId and request.status = 'APPROVED'
and :workDate between request.startDate and request.endDate
""")
boolean hasApprovedLeave(
@Param("internId") long internId, @Param("workDate") LocalDate workDate);
}
@@ -0,0 +1,15 @@
package com.lab.labtimesheet.feature.attendance.repository;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecordEntity, Long> {
Optional<AttendanceRecordEntity> findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate);
List<AttendanceRecordEntity> findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(
long internUserId, LocalDate from, LocalDate to);
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.attendance.repository;
import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GlobalCalendarEventRepository extends JpaRepository<GlobalCalendarEventEntity, Long> {
boolean existsByCalendarDateAndDayOffTrue(LocalDate date);
List<GlobalCalendarEventEntity> findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(
LocalDate from, LocalDate to);
}
@@ -0,0 +1,143 @@
package com.lab.labtimesheet.feature.attendance.service;
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.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
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.AttendanceRecordEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository;
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AttendanceApplicationService {
private final Clock clock;
private final AttendancePolicyRepository policyEntities;
private final AttendanceRecordRepository recordEntities;
private final AttendanceQueryRepository queries;
private final AccountService accounts;
private final CalendarApplicationService calendar;
private final AttendanceService attendance;
AttendanceApplicationService(
Clock clock,
AttendancePolicyRepository policyEntities,
AttendanceRecordRepository recordEntities,
AttendanceQueryRepository queries,
AccountService accounts,
CalendarApplicationService calendar,
AttendanceService attendance) {
this.clock = clock;
this.policyEntities = policyEntities;
this.recordEntities = recordEntities;
this.queries = queries;
this.accounts = accounts;
this.calendar = calendar;
this.attendance = attendance;
}
@Transactional
public AttendanceRecord checkIn(long internId) {
Instant now = clock.instant();
AttendancePolicy policy = timeline().resolve(now);
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
Optional<AttendanceRecord> existing = recordEntities
.findByInternUserIdAndWorkDate(internId, workDate)
.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();
}
@Transactional
public AttendanceRecord checkOut(long internId) {
Instant now = clock.instant();
AttendancePolicy currentPolicy = timeline().resolve(now);
LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate();
Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate);
AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now);
AttendanceRecordEntity persisted = entity.orElseThrow();
persisted.setCheckOutAt(checkedOut.checkOutAt());
return recordEntities.saveAndFlush(persisted).toDomain();
}
@Transactional(readOnly = true)
public AttendanceCurrentState currentState(long internId) {
Instant now = clock.instant();
AttendancePolicy policy = timeline().resolve(now);
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
if (!accounts.isEligibleIntern(internId, workDate)) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
return recordEntities.findByInternUserIdAndWorkDate(internId, workDate)
.map(AttendanceRecordEntity::toDomain)
.map(record -> record.checkOutAt() == null
? AttendanceCurrentState.CHECKED_IN
: AttendanceCurrentState.CHECKED_OUT)
.orElse(AttendanceCurrentState.NOT_CHECKED_IN);
}
@Transactional(readOnly = true)
public List<AttendanceHistoryItem> history(
AttendanceActor actor, long internId, LocalDate from, LocalDate to) {
if (actor.role() == AttendanceRole.INTERN && actor.userId() != internId) {
throw new AccessDeniedException("Interns may view only their own attendance");
}
if (from.isAfter(to)) {
throw new IllegalArgumentException("from must not be after to");
}
return recordEntities.findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(internId, from, to)
.stream()
.map(AttendanceRecordEntity::toDomain)
.map(record -> new AttendanceHistoryItem(
record.workDate(),
record.checkInAt(),
record.checkOutAt(),
record.policy(),
record.violations(clock.instant())))
.toList();
}
@Transactional(readOnly = true)
public LocalDate currentBusinessDate() {
AttendancePolicy policy = timeline().resolve(clock.instant());
return clock.instant().atZone(policy.zoneId()).toLocalDate();
}
private AttendancePolicyTimeline timeline() {
return new AttendancePolicyTimeline(policyEntities
.findAllByOrderByEffectiveFromAsc()
.stream()
.map(AttendancePolicyEntity::toDomain)
.toList());
}
private AttendanceDayContext dayContext(long internId, LocalDate workDate) {
return new AttendanceDayContext(
accounts.isEligibleIntern(internId, workDate),
calendar.isGlobalDayOff(workDate),
queries.hasApprovedLeave(internId, workDate));
}
}
@@ -0,0 +1,38 @@
package com.lab.labtimesheet.feature.attendance.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import java.security.Principal;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@Service
public class AttendanceCurrentUserService {
private final AccountService accounts;
AttendanceCurrentUserService(AccountService accounts) {
this.accounts = accounts;
}
public AttendanceActor actor(Principal principal) {
if (principal == null || principal.getName() == null) {
throw new AccessDeniedException("Authentication is required");
}
AccountIdentity identity;
try {
identity = accounts.requireIdentityByEmail(principal.getName());
} catch (IllegalArgumentException exception) {
throw new AccessDeniedException(
"No active application user matches the authenticated identity", exception);
}
if (identity.status() != AccountStatus.ACTIVE) {
throw new AccessDeniedException(
"No active application user matches the authenticated identity");
}
return new AttendanceActor(identity.id(), AttendanceRole.valueOf(identity.role().name()));
}
}
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.attendance;
package com.lab.labtimesheet.feature.attendance.service;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import java.time.LocalDate;
import java.time.Instant;
import java.util.Collection;
@@ -0,0 +1,51 @@
package com.lab.labtimesheet.feature.attendance.service;
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.AttendanceRecord;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public final class AttendanceService {
public AttendanceRecord checkIn(
long internId,
Instant now,
AttendancePolicy policy,
AttendanceDayContext context,
Optional<AttendanceRecord> existingRecord) {
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
requireEligible(policy, workDate, context);
if (existingRecord.isPresent()) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN);
}
return new AttendanceRecord(internId, workDate, policy, now, null);
}
public AttendanceRecord checkOut(Optional<AttendanceRecord> record, Instant now) {
return record
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD))
.checkOut(now);
}
private static void requireEligible(
AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) {
if (!context.activeIntern()) {
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
}
if (!policy.isWorkday(workDate)) {
throw new AttendanceException(AttendanceRejection.NON_WORKDAY);
}
if (context.globalDayOff()) {
throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF);
}
if (context.approvedLeave()) {
throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE);
}
}
}
@@ -0,0 +1,105 @@
package com.lab.labtimesheet.feature.attendance.service;
import com.lab.labtimesheet.feature.attendance.exception.CalendarException;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity;
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CalendarApplicationService {
private final Clock clock;
private final AttendancePolicyRepository policies;
private final GlobalCalendarEventRepository events;
CalendarApplicationService(
Clock clock,
AttendancePolicyRepository policies,
GlobalCalendarEventRepository events) {
this.clock = clock;
this.policies = policies;
this.events = events;
}
@Transactional
public GlobalCalendarEvent createManual(
AttendanceActor actor, LocalDate date, String name, boolean dayOff) {
requireAdmin(actor);
requireMutableDate(date);
return events.saveAndFlush(new GlobalCalendarEventEntity(date, requireName(name), dayOff, actor.userId()))
.toDomain();
}
@Transactional
public GlobalCalendarEvent updateManual(
AttendanceActor actor,
long eventId,
long expectedVersion,
LocalDate date,
String name,
boolean dayOff) {
requireAdmin(actor);
GlobalCalendarEventEntity event = events.findById(eventId)
.orElseThrow(() -> new CalendarException("Calendar event not found"));
requireMutableDate(event.calendarDate());
requireMutableDate(date);
if (event.version() != expectedVersion) {
throw new CalendarException("Calendar event was changed by another request");
}
event.update(date, requireName(name), dayOff, actor.userId());
return events.saveAndFlush(event).toDomain();
}
@Transactional(readOnly = true)
public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) {
if (from.isAfter(to)) {
throw new IllegalArgumentException("from must not be after to");
}
return events.findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(from, to)
.stream()
.map(GlobalCalendarEventEntity::toDomain)
.toList();
}
@Transactional(readOnly = true)
public boolean isGlobalDayOff(LocalDate date) {
return events.existsByCalendarDateAndDayOffTrue(date);
}
private void requireMutableDate(LocalDate date) {
AttendancePolicy policy = new AttendancePolicyTimeline(policies
.findAllByOrderByEffectiveFromAsc()
.stream()
.map(AttendancePolicyEntity::toDomain)
.toList())
.resolve(clock.instant());
LocalDate today = clock.instant().atZone(policy.zoneId()).toLocalDate();
if (date.isBefore(today)) {
throw new CalendarException("Past calendar events are immutable");
}
}
private static void requireAdmin(AttendanceActor actor) {
if (actor.role() != AttendanceRole.ADMIN) {
throw new AccessDeniedException("Only Admin may manage the global calendar");
}
}
private static String requireName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
return name.strip();
}
}
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Global calendar</title>
</head>
<body>
<main>
<h1>Global calendar</h1>
<p th:if="${message}" role="status" th:text="${message}"></p>
<form method="post" th:action="@{/attendance/calendar}">
<label for="date">Date</label>
<input id="date" name="date" type="date" required th:min="${today}">
<label for="name">Name</label>
<input id="name" name="name" type="text" maxlength="200" required>
<label><input name="dayOff" type="checkbox" value="true"> Day off</label>
<button type="submit">Add event</button>
</form>
<p th:if="${#lists.isEmpty(events)}">No upcoming calendar events.</p>
<table th:unless="${#lists.isEmpty(events)}">
<caption>Upcoming global events</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Name</th><th scope="col">Day off</th><th scope="col">Save</th></tr></thead>
<tbody>
<tr th:each="event : ${events}">
<td><input name="date" type="date" required th:min="${today}" th:value="${event.date}" th:attr="form=|event-${event.id}|" aria-label="Event date"></td>
<td><input name="name" type="text" maxlength="200" required th:value="${event.name}" th:attr="form=|event-${event.id}|" aria-label="Event name"></td>
<td><input name="dayOff" type="checkbox" value="true" th:checked="${event.dayOff}" th:attr="form=|event-${event.id}|" aria-label="Day off"></td>
<td>
<form method="post" th:id="|event-${event.id}|" th:action="@{/attendance/calendar/{id}(id=${event.id})}">
<input name="version" type="hidden" th:value="${event.version}">
<button type="submit">Save</button>
</form>
</td>
</tr>
</tbody>
</table>
</main>
</body>
</html>
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Attendance history</title>
</head>
<body>
<main>
<h1 th:text="${ownHistory} ? 'My attendance' : 'Intern attendance'">Attendance</h1>
<p th:if="${message}" role="status" th:text="${message}"></p>
<p th:if="${error}" role="alert" th:text="${error}"></p>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-in}">
<button type="submit">Check in</button>
</form>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-out}">
<button type="submit">Check out</button>
</form>
<form method="get">
<label for="from">From</label>
<input id="from" name="from" type="date" th:value="${from}">
<label for="to">To</label>
<input id="to" name="to" type="date" th:value="${to}">
<button type="submit">Filter</button>
</form>
<p th:if="${#lists.isEmpty(items)}">No attendance records in this period.</p>
<table th:unless="${#lists.isEmpty(items)}">
<caption>Attendance records and applied policy</caption>
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Check in</th>
<th scope="col">Check out</th>
<th scope="col">Applied schedule</th>
<th scope="col">Grace</th>
<th scope="col">Result</th>
</tr>
</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.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>
</tr>
</tbody>
</table>
</main>
</body>
</html>