diff --git a/README.md b/README.md index de767619..d05b1ca3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ ASA_CARE_PRODUCT_CODE= ASA_PAID_CARE_MISSION_CODES= SENSITIVE_WORKERS_CODES= MAX_LATENESS_REPORT= +ACCOUNTANTS= +LOW_REMAINING_DAYS_THRESHOLD= ``` Then, run Spring Boot as usual, diff --git a/src/main/java/school/hei/asa/endpoint/event/model/LowRemainingDaysAlertRequested.java b/src/main/java/school/hei/asa/endpoint/event/model/LowRemainingDaysAlertRequested.java new file mode 100644 index 00000000..66a2bb98 --- /dev/null +++ b/src/main/java/school/hei/asa/endpoint/event/model/LowRemainingDaysAlertRequested.java @@ -0,0 +1,31 @@ +package school.hei.asa.endpoint.event.model; + +import java.time.Duration; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +@Data +@EqualsAndHashCode(callSuper = false) +@ToString +public class LowRemainingDaysAlertRequested extends PojaEvent { + + private String workerCode; + private int remainingDays; + + @Override + public Duration maxConsumerDuration() { + return Duration.ofSeconds(45); + } + + @Override + public Duration maxConsumerBackoffBetweenRetries() { + return Duration.ofSeconds(30); + } +} diff --git a/src/main/java/school/hei/asa/endpoint/rest/controller/CalendarController.java b/src/main/java/school/hei/asa/endpoint/rest/controller/CalendarController.java index dab40ed8..e3cc5b2f 100644 --- a/src/main/java/school/hei/asa/endpoint/rest/controller/CalendarController.java +++ b/src/main/java/school/hei/asa/endpoint/rest/controller/CalendarController.java @@ -27,6 +27,7 @@ import school.hei.asa.model.Mission; import school.hei.asa.model.Worker; import school.hei.asa.service.CalendarService; +import school.hei.asa.service.LowRemainingDaysAlertService; @AllArgsConstructor @Controller @@ -35,6 +36,7 @@ public class CalendarController { private final CalendarService calendarService; private final WorkerFromAuthentication workerFromAuthentication; private final WorkerToModelAdder workerToModelAdder; + private final LowRemainingDaysAlertService lowRemainingDaysAlertService; @GetMapping("/work-and-care-calendar") public String getCalendar( @@ -43,7 +45,6 @@ public String getCalendar( @RequestParam(required = false) String workerCode, @RequestParam(required = false) Integer year) { year = year == null ? now().getYear() : year; - model.addAttribute("year", year); var workerCodeOrAuth = workerCode == null || workerCode.isBlank() @@ -66,7 +67,11 @@ public String getCalendar( missionCounts.put(month, typeCounts); }); var lateReportedDaysByMonth = calendarService.lateReportedDaysByMonth(worker, year); + var warningBannerMessage = + lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage(worker).orElse(null); + model.addAttribute("year", year); + model.addAttribute("warningBannerMessage", warningBannerMessage); model.addAttribute("workerCode", workerCodeOrAuth); model.addAttribute("currentYear", now().getYear()); model.addAttribute( diff --git a/src/main/java/school/hei/asa/endpoint/rest/controller/DailyExecutionController.java b/src/main/java/school/hei/asa/endpoint/rest/controller/DailyExecutionController.java index f4bd51c9..05ae6dea 100644 --- a/src/main/java/school/hei/asa/endpoint/rest/controller/DailyExecutionController.java +++ b/src/main/java/school/hei/asa/endpoint/rest/controller/DailyExecutionController.java @@ -10,20 +10,28 @@ import school.hei.asa.endpoint.rest.model.th.ThDailyExecutionForm; import school.hei.asa.endpoint.rest.security.WorkerFromAuthentication; import school.hei.asa.endpoint.rest.service.ThMissionService; -import school.hei.asa.repository.DailyExecutionRepository; +import school.hei.asa.service.DailyExecutionService; +import school.hei.asa.service.LowRemainingDaysAlertService; @Controller @AllArgsConstructor public class DailyExecutionController { private final ThDailyExecutionFormMapper thDailyExecutionFormMapper; - private final DailyExecutionRepository dailyExecutionRepository; + private final DailyExecutionService dailyExecutionService; private final WorkerFromAuthentication workerFromAuthentication; private final ThMissionService thMissionService; + private final LowRemainingDaysAlertService lowRemainingDaysAlertService; @GetMapping("/daily-execution") - public String getDailyExecutionForm(Model model) { + public String getDailyExecutionForm(Model model, Authentication authentication) { + var worker = workerFromAuthentication.apply(authentication).get(); var sortedMissions = thMissionService.sortedMissionsWithoutMissionExecution(); + var warningBannerMessage = + lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage(worker).orElse(null); + model.addAttribute("missions", sortedMissions); + model.addAttribute("warningBannerMessage", warningBannerMessage); + return "daily-execution"; } @@ -32,7 +40,8 @@ public String createDailyExecution(Authentication authentication, ThDailyExecuti var worker = workerFromAuthentication.apply(authentication).get(); var dailyExecution = thDailyExecutionFormMapper.toDomain(dmeForm, worker); - dailyExecutionRepository.save(dailyExecution); + dailyExecutionService.saveAndAlert(dailyExecution); + return "redirect:/work-and-care-calendar"; } } diff --git a/src/main/java/school/hei/asa/number/DaysFormatter.java b/src/main/java/school/hei/asa/number/DaysFormatter.java new file mode 100644 index 00000000..71ba155c --- /dev/null +++ b/src/main/java/school/hei/asa/number/DaysFormatter.java @@ -0,0 +1,16 @@ +package school.hei.asa.number; + +import java.math.BigDecimal; + +public final class DaysFormatter { + + private DaysFormatter() {} + + public static String format(double days) { + var normalized = BigDecimal.valueOf(days).stripTrailingZeros(); + if (normalized.scale() <= 0) { + return normalized.toBigInteger().toString(); + } + return normalized.toPlainString(); + } +} diff --git a/src/main/java/school/hei/asa/repository/ContractRepository.java b/src/main/java/school/hei/asa/repository/ContractRepository.java index af5aa4bf..0009ed1d 100644 --- a/src/main/java/school/hei/asa/repository/ContractRepository.java +++ b/src/main/java/school/hei/asa/repository/ContractRepository.java @@ -2,6 +2,7 @@ import jakarta.transaction.Transactional; import java.util.List; +import java.util.Optional; import lombok.AllArgsConstructor; import org.springframework.stereotype.Repository; import school.hei.asa.model.Worker; @@ -25,6 +26,14 @@ public List findAllByWorker(Worker worker) { workerMapper.toEntity(worker))); } + @Transactional + public Optional findActiveContractByWorker(Worker worker) { + return jContractRepository + .findFirstByWorkerAndDurationInDaysIsNotNullOrderByEntranceInstantDesc( + workerMapper.toEntity(worker)) + .map(jContract -> contractMapper.toDomain(List.of(jContract)).getFirst()); + } + public List findAll() { return contractMapper.toDomain(jContractRepository.findAll()); } diff --git a/src/main/java/school/hei/asa/repository/DailyExecutionRepository.java b/src/main/java/school/hei/asa/repository/DailyExecutionRepository.java index 67413427..0e86ee25 100644 --- a/src/main/java/school/hei/asa/repository/DailyExecutionRepository.java +++ b/src/main/java/school/hei/asa/repository/DailyExecutionRepository.java @@ -22,6 +22,7 @@ @Repository public class DailyExecutionRepository { + private final ContractRepository contractRepository; private final MissionExecutionRepository missionExecutionRepository; private final JMissionExecutionRepository jMissionExecutionRepository; private final JWorkerRepository jWorkerRepository; @@ -33,6 +34,9 @@ public class DailyExecutionRepository { @Transactional(isolation = SERIALIZABLE) public void save(DailyExecution dailyExecution) { var date = dailyExecution.date(); + if (contractRepository.findActiveContractByWorker(dailyExecution.worker()).isEmpty()) { + throw new IllegalStateException("Unable to punch in : you have no active contract."); + } if (!missionExecutionRepository.findAllBy(dailyExecution.worker(), date).isEmpty()) { throw new IllegalArgumentException("Day already has MissionExecution: " + date); } diff --git a/src/main/java/school/hei/asa/repository/jrepository/JContractRepository.java b/src/main/java/school/hei/asa/repository/jrepository/JContractRepository.java index bdcf3993..7dd4633a 100644 --- a/src/main/java/school/hei/asa/repository/jrepository/JContractRepository.java +++ b/src/main/java/school/hei/asa/repository/jrepository/JContractRepository.java @@ -1,6 +1,7 @@ package school.hei.asa.repository.jrepository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @@ -25,4 +26,7 @@ public interface JContractRepository extends JpaRepository { @Query("SELECT c FROM JContract c WHERE c.endInstant IS NULL AND c.durationInDays != 0") List findActiveContracts(); + + Optional findFirstByWorkerAndDurationInDaysIsNotNullOrderByEntranceInstantDesc( + JWorker jWorker); } diff --git a/src/main/java/school/hei/asa/service/ContractService.java b/src/main/java/school/hei/asa/service/ContractService.java index 93b83666..6915e747 100644 --- a/src/main/java/school/hei/asa/service/ContractService.java +++ b/src/main/java/school/hei/asa/service/ContractService.java @@ -1,6 +1,8 @@ package school.hei.asa.service; +import static java.time.ZoneId.systemDefault; import static java.util.Locale.FRENCH; +import static java.util.Locale.US; import static school.hei.asa.model.DailyExecution.Type.fullCare; import static school.hei.asa.model.DailyExecution.Type.fullWork; @@ -9,6 +11,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,6 +52,27 @@ public List getAllContractsByWorker(Worker worker) { return contractRepository.findAllByWorker(worker); } + public Optional findActiveContractByWorker(Worker worker) { + return contractRepository.findActiveContractByWorker(worker); + } + + public double getRemainingDaysOnActiveContractOrZero(Worker worker) { + var activeContractOpt = findActiveContractByWorker(worker); + if (activeContractOpt.isEmpty()) { + return 0d; + } + + var contract = activeContractOpt.get(); + var startDate = contract.entranceInstant().atZone(systemDefault()).toLocalDate(); + var endDate = + contract.endInstant() == null + ? LocalDate.now() + : contract.endInstant().atZone(systemDefault()).toLocalDate(); + var actualWorkedDays = getActualWorkedDaysByDateByWorker(startDate, worker.code(), endDate); + var workedDays = actualWorkedDays.equals("-") ? 0d : Double.parseDouble(actualWorkedDays); + return contract.duration().toDays() - workedDays; + } + public String getActualWorkedDaysByDateByWorker( LocalDate startDate, String workerCode, LocalDate endDate) { var dailyExecutions = @@ -80,7 +104,7 @@ private String executedDays(List executions) { }) .reduce(Double::sum) .get(); - return String.format("%.1f", result); + return String.format(US, "%.1f", result); } public List findActiveContracts() { diff --git a/src/main/java/school/hei/asa/service/DailyExecutionService.java b/src/main/java/school/hei/asa/service/DailyExecutionService.java new file mode 100644 index 00000000..ad095ee7 --- /dev/null +++ b/src/main/java/school/hei/asa/service/DailyExecutionService.java @@ -0,0 +1,18 @@ +package school.hei.asa.service; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import school.hei.asa.model.DailyExecution; +import school.hei.asa.repository.DailyExecutionRepository; + +@Service +@AllArgsConstructor +public class DailyExecutionService { + private final DailyExecutionRepository dailyExecutionRepository; + private final LowRemainingDaysAlertService lowRemainingDaysAlertService; + + public void saveAndAlert(DailyExecution dailyExecution) { + dailyExecutionRepository.save(dailyExecution); + lowRemainingDaysAlertService.sendAlertEmailIfLowRemainingDays(dailyExecution.worker()); + } +} diff --git a/src/main/java/school/hei/asa/service/LowRemainingDaysAlertService.java b/src/main/java/school/hei/asa/service/LowRemainingDaysAlertService.java new file mode 100644 index 00000000..4eaa335f --- /dev/null +++ b/src/main/java/school/hei/asa/service/LowRemainingDaysAlertService.java @@ -0,0 +1,72 @@ +package school.hei.asa.service; + +import static java.util.Locale.US; + +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import school.hei.asa.endpoint.event.EventProducer; +import school.hei.asa.endpoint.event.model.LowRemainingDaysAlertRequested; +import school.hei.asa.model.Worker; + +@Slf4j +@Service +public class LowRemainingDaysAlertService { + + private final ContractService contractService; + private final EventProducer eventProducer; + private final int lowRemainingDaysThreshold; + + public LowRemainingDaysAlertService( + ContractService contractService, + EventProducer eventProducer, + @Value("${LOW_REMAINING_DAYS_THRESHOLD}") int lowRemainingDaysThreshold) { + this.contractService = contractService; + this.eventProducer = eventProducer; + this.lowRemainingDaysThreshold = lowRemainingDaysThreshold; + } + + public Optional verifyRemainingDaysAndBuildAlertMessage(Worker worker) { + var remainingDays = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + if (remainingDays <= 0) { + return Optional.of( + "Please note : You do not have an active contract. Please contact your administrator."); + } + + if (!isBelowThreshold(remainingDays)) { + return Optional.empty(); + } + + return Optional.of( + "Please note : You have " + formatDays(remainingDays) + " day(s) left on your contract !"); + } + + public void sendAlertEmailIfLowRemainingDays(Worker worker) { + var remainingDays = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + if (!isBelowThreshold(remainingDays)) { + return; + } + + log.info("Requesting alert email to accountants for worker '{}'", worker.code()); + eventProducer.accept( + List.of( + LowRemainingDaysAlertRequested.builder() + .workerCode(worker.code()) + .remainingDays((int) remainingDays) + .build())); + } + + private boolean isBelowThreshold(double remainingDays) { + return remainingDays > 0 && remainingDays < lowRemainingDaysThreshold; + } + + private static String formatDays(double days) { + return days == Math.floor(days) + ? String.format(US, "%.0f", days) + : String.format(US, "%.1f", days); + } +} diff --git a/src/main/java/school/hei/asa/service/event/LowRemainingDaysAlertRequestedService.java b/src/main/java/school/hei/asa/service/event/LowRemainingDaysAlertRequestedService.java new file mode 100644 index 00000000..d7a07fee --- /dev/null +++ b/src/main/java/school/hei/asa/service/event/LowRemainingDaysAlertRequestedService.java @@ -0,0 +1,68 @@ +package school.hei.asa.service.event; + +import static org.reflections.Reflections.log; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import school.hei.asa.endpoint.event.model.LowRemainingDaysAlertRequested; +import school.hei.asa.mail.Email; +import school.hei.asa.mail.Mailer; +import school.hei.asa.service.mapper.InternetAddressMapper; + +@Service +public class LowRemainingDaysAlertRequestedService + implements Consumer { + + private final Mailer mailer; + private final String accountants; + private final InternetAddressMapper internetAddressMapper; + + public LowRemainingDaysAlertRequestedService( + Mailer mailer, + @Value("${ACCOUNTANTS}") String accountants, + InternetAddressMapper internetAddressMapper) { + this.mailer = mailer; + this.accountants = accountants; + this.internetAddressMapper = internetAddressMapper; + } + + @Override + public void accept(LowRemainingDaysAlertRequested event) { + var accountantAddresses = + internetAddressMapper.toInternetAddresses( + Arrays.stream(this.accountants.split(",")).toList()); + + var workerCode = event.getWorkerCode(); + var remainingDays = event.getRemainingDays(); + var subject = String.format("ASA - ALERT: low remaining days - Worker %s", workerCode); + var body = + String.format( + "Hello,\n\n" + + "Worker %s has only %d day(s) remaining on their contract.\n\n" + + "Please take the necessary action.\n\n" + + "Best regards,\n" + + "ASA", + workerCode, remainingDays); + + log.info( + "Sending low remaining days alert to accountants for worker={}, remainingDays={}", + workerCode, + remainingDays); + + try { + mailer.accept( + new Email( + accountantAddresses.get(0), + accountantAddresses.subList(1, accountantAddresses.size()), + List.of(), + subject, + body, + List.of())); + } catch (Exception e) { + throw new RuntimeException("Failed to send low remaining days alert email", e); + } + } +} diff --git a/src/main/resources/templates/calendar.html b/src/main/resources/templates/calendar.html index 7ab958b6..787c0b0a 100644 --- a/src/main/resources/templates/calendar.html +++ b/src/main/resources/templates/calendar.html @@ -11,12 +11,21 @@
+
+
+
-

-

+
+

+

+
@@ -35,113 +44,113 @@

Color description

-
-
-

- - - - - - - - - - - - - - - - - - - - - -
WeekMonTueWedThuFriSatSun
-
- -
-
- - Work Days: - -
-
- - Paid Care Days: - -
-
- - Unpaid Care Days: - -
-
- - Late Reported Days: +
+
+

+ + + + + + + + + + + + + + + + + + + + + +
WeekMonTueWedThuFriSatSun
+
+ +
+
+ + Work Days: + +
+
+ + Paid Care Days: + +
+
+ + Unpaid Care Days: + +
+
+ + Late Reported Days:
Jours pointés 3 jours plus tard ou plus
- -
+
- - +
- -
+ }); + + function updateFilters() { + const workerCode = document.getElementById("worker").value; + const year = document.getElementById("year").value; + window.location.href = `/work-and-care-calendar?workerCode=${workerCode}&year=${year}`; + } + +
\ No newline at end of file diff --git a/src/main/resources/templates/daily-execution.html b/src/main/resources/templates/daily-execution.html index 2f71f23f..a59a52d7 100644 --- a/src/main/resources/templates/daily-execution.html +++ b/src/main/resources/templates/daily-execution.html @@ -56,6 +56,13 @@
+
+
+

What did you do that day?

@@ -112,7 +119,8 @@

What did you do that day?

"dummy,dummy2"); registry.add("MAX_LATENESS_REPORT", () -> 4); registry.add("SENSITIVE_WORKERS_CODES", () -> "W-059,W-00"); + registry.add("LOW_REMAINING_DAYS_THRESHOLD", () -> 10); } } diff --git a/src/test/java/school/hei/asa/endpoint/rest/controller/DailyExecutionControllerIT.java b/src/test/java/school/hei/asa/endpoint/rest/controller/DailyExecutionControllerIT.java index c219bab4..ea8c86bc 100644 --- a/src/test/java/school/hei/asa/endpoint/rest/controller/DailyExecutionControllerIT.java +++ b/src/test/java/school/hei/asa/endpoint/rest/controller/DailyExecutionControllerIT.java @@ -59,6 +59,7 @@ void setUp() { workerRepository.save(authenticatedWorker); when(workerFromAuthentication.apply(authentication)) .thenReturn(Optional.of(authenticatedWorker)); + var product = new Product("pcode", "pname", "pdescription"); productRepository.save(product); var mission1 = new Mission("mission1-code", "title1", "description1", 10, product); @@ -69,7 +70,6 @@ void setUp() { @Test void save_then_read_with_duplicates_ok_if_sum_of_set_is_100() { - setUp(); var dmeForm = new ThDailyExecutionForm( "2024-12-03", @@ -79,7 +79,6 @@ void save_then_read_with_duplicates_ok_if_sum_of_set_is_100() { "mission2-code", "0.6", "missionComment2", - // duplicate of mission2 (missionCode2, missionPercentage2, missionComment2) "mission2-code", "0.6", "missionComment2", @@ -110,7 +109,6 @@ void save_then_read_with_duplicates_ok_if_sum_of_set_is_100() { @Test void cannot_save_if_mission_execution_already_exists() { - setUp(); var dmeForm = new ThDailyExecutionForm( "2024-12-01", @@ -145,7 +143,6 @@ void read_worker_lita_with_duplicate_missions_and_percentage_over_100_ok() { @Test void concurrently_create_daily_execution() { - setUp(); var dmeForm = new ThDailyExecutionForm( "2024-12-01", @@ -196,8 +193,7 @@ void concurrently_create_daily_execution() { @Test void can_get_daily_execution_form() { - setUp(); - var viewName = dailyExecutionController.getDailyExecutionForm(model); + var viewName = dailyExecutionController.getDailyExecutionForm(model, authentication); verify(model).addAttribute(eq("missions"), any(List.class)); diff --git a/src/test/java/school/hei/asa/service/ContractServiceIT.java b/src/test/java/school/hei/asa/service/ContractServiceIT.java new file mode 100644 index 00000000..91e1152e --- /dev/null +++ b/src/test/java/school/hei/asa/service/ContractServiceIT.java @@ -0,0 +1,53 @@ +package school.hei.asa.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import school.hei.asa.conf.FacadeIT; +import school.hei.asa.model.Worker; + +class ContractServiceIT extends FacadeIT { + @Autowired ContractService contractService; + + @Test + void active_contract_without_end_instant_is_found() { + var worker = studentWorker(); + + var actual = contractService.findActiveContractByWorker(worker); + + assertTrue(actual.isPresent()); + assertEquals("L4P-2026", actual.get().level().code()); + } + + @Test + void active_contract_without_end_instant_has_full_remaining_days() { + // contract entrance=2025-01-01, duration=80 days, no matching mission_execution in that + // range in the fixtures, so no day is worked yet + var worker = studentWorker(); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(80d, actual); + } + + @Test + void ended_contract_computes_remaining_days_within_its_own_date_range() { + // contract entrance=2024-01-01, endInstant=2024-06-01, duration=80 days, no + // mission_execution for this worker in the fixtures + var worker = newWorker(); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(80d, actual); + } + + private Worker studentWorker() { + return new Worker("W-P-2024-01", "Lita Andria", "", "", "", "", "", ""); + } + + private Worker newWorker() { + return new Worker("W-101", "John", "", "", "", "", "", ""); + } +} diff --git a/src/test/java/school/hei/asa/service/ContractServiceTest.java b/src/test/java/school/hei/asa/service/ContractServiceTest.java new file mode 100644 index 00000000..1c887495 --- /dev/null +++ b/src/test/java/school/hei/asa/service/ContractServiceTest.java @@ -0,0 +1,139 @@ +package school.hei.asa.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import school.hei.asa.CareProductCodeSupplier; +import school.hei.asa.model.DailyExecution; +import school.hei.asa.model.Mission; +import school.hei.asa.model.MissionExecution; +import school.hei.asa.model.Product; +import school.hei.asa.model.Worker; +import school.hei.asa.model.contract.Contract; +import school.hei.asa.repository.ContractRepository; +import school.hei.asa.repository.DailyExecutionRepository; +import school.hei.asa.repository.WorkerRepository; + +class ContractServiceTest { + + private ContractRepository contractRepository; + private DailyExecutionRepository dailyExecutionRepository; + private CareProductCodeSupplier careProductCodeSupplier; + private ContractService contractService; + private Worker worker; + + @BeforeEach + void setUp() { + var workerRepository = mock(WorkerRepository.class); + contractRepository = mock(ContractRepository.class); + dailyExecutionRepository = mock(DailyExecutionRepository.class); + var missionService = mock(MissionService.class); + careProductCodeSupplier = mock(CareProductCodeSupplier.class); + + contractService = + new ContractService( + workerRepository, + contractRepository, + dailyExecutionRepository, + missionService, + careProductCodeSupplier); + + worker = new Worker("W-P-2024-01", "Lita Andria", "", "", "", "", "", ""); + } + + @Test + void no_active_contract_returns_zero_remaining_days() { + when(contractRepository.findActiveContractByWorker(worker)).thenReturn(Optional.empty()); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(0d, actual); + } + + @Test + void active_contract_without_executions_returns_full_duration() { + var contract = newContract(worker, 80, Instant.parse("2025-01-01T08:00:00Z"), null); + when(contractRepository.findActiveContractByWorker(worker)).thenReturn(Optional.of(contract)); + when(dailyExecutionRepository.findByWorkerCodeAndDateBetween( + anyString(), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(80d, actual); + } + + @Test + void active_contract_with_executions_subtracts_worked_days() { + var contract = newContract(worker, 80, Instant.parse("2025-01-01T08:00:00Z"), null); + when(contractRepository.findActiveContractByWorker(worker)).thenReturn(Optional.of(contract)); + + var product = new Product("WORK-PRODUCT", "Work", "desc"); + var mission = new Mission("M1", "Mission 1", "desc", 5, product); + var execution = + new MissionExecution(mission, worker, LocalDate.of(2025, 1, 2), 1.0d, "comment", null); + var dailyExecution = new DailyExecution(worker, LocalDate.of(2025, 1, 2), List.of(execution)); + + when(dailyExecutionRepository.findByWorkerCodeAndDateBetween( + anyString(), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(dailyExecution)); + when(careProductCodeSupplier.get()).thenReturn("CARE-PRODUCT"); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(79d, actual); + } + + @Test + void ended_contract_uses_end_instant_as_upper_bound() { + var contract = + newContract( + worker, + 80, + Instant.parse("2024-01-01T08:00:00Z"), + Instant.parse("2024-06-01T08:00:00Z")); + when(contractRepository.findActiveContractByWorker(worker)).thenReturn(Optional.of(contract)); + when(dailyExecutionRepository.findByWorkerCodeAndDateBetween( + "W-P-2024-01", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 6, 1))) + .thenReturn(List.of()); + + var actual = contractService.getRemainingDaysOnActiveContractOrZero(worker); + + assertEquals(80d, actual); + } + + @Test + void find_active_contract_delegates_to_repository() { + var contract = newContract(worker, 80, Instant.parse("2025-01-01T08:00:00Z"), null); + when(contractRepository.findActiveContractByWorker(worker)).thenReturn(Optional.of(contract)); + + var actual = contractService.findActiveContractByWorker(worker); + + assertTrue(actual.isPresent()); + assertEquals(contract, actual.get()); + } + + private Contract newContract( + Worker worker, int durationInDays, Instant entranceInstant, Instant endInstant) { + return new Contract( + worker, + "job_title", + null, + entranceInstant, + endInstant, + Duration.ofDays(durationInDays), + "company", + "contract_bucket_key"); + } +} diff --git a/src/test/java/school/hei/asa/service/LowRemainingDaysAlertServiceIT.java b/src/test/java/school/hei/asa/service/LowRemainingDaysAlertServiceIT.java new file mode 100644 index 00000000..55064d99 --- /dev/null +++ b/src/test/java/school/hei/asa/service/LowRemainingDaysAlertServiceIT.java @@ -0,0 +1,79 @@ +package school.hei.asa.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import school.hei.asa.conf.FacadeIT; +import school.hei.asa.endpoint.event.EventProducer; +import school.hei.asa.model.Worker; + +class LowRemainingDaysAlertServiceIT extends FacadeIT { + + @Autowired LowRemainingDaysAlertService lowRemainingDaysAlertService; + + @MockBean EventProducer eventProducer; + + @Test + void alert_message_built_without_sending_email_when_remaining_days_below_threshold() { + var result = + lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage( + workerBelowThreshold()); + + assertTrue(result.isPresent()); + assertTrue(result.get().contains("day(s) left")); + verify(eventProducer, never()).accept(any()); + } + + @Test + void send_alert_email_when_remaining_days_below_threshold() { + lowRemainingDaysAlertService.sendAlertEmailIfLowRemainingDays(workerBelowThreshold()); + + verify(eventProducer).accept(any(List.class)); + } + + @Test + void no_alert_email_when_remaining_days_above_threshold() { + lowRemainingDaysAlertService.sendAlertEmailIfLowRemainingDays(workerAboveThreshold()); + + verify(eventProducer, never()).accept(any()); + } + + @Test + void no_alert_when_remaining_days_above_threshold() { + var result = + lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage( + workerAboveThreshold()); + + assertTrue(result.isEmpty()); + verify(eventProducer, never()).accept(any()); + } + + @Test + void alert_message_when_no_active_contract() { + var result = + lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage( + workerWithoutContract()); + + assertTrue(result.isPresent()); + assertTrue(result.get().contains("do not have an active contract")); + verify(eventProducer, never()).accept(any()); + } + + private Worker workerBelowThreshold() { + return new Worker("alert-worker-below", "Alert Worker Below", "", "", "", "", "", ""); + } + + private Worker workerAboveThreshold() { + return new Worker("alert-worker-above", "Alert Worker Above", "", "", "", "", "", ""); + } + + private Worker workerWithoutContract() { + return new Worker("alert-worker-none", "Alert Worker None", "", "", "", "", "", ""); + } +} diff --git a/src/test/resources/db/migration/V101_4__Insert_low_remaining_days_alert_fixtures.sql b/src/test/resources/db/migration/V101_4__Insert_low_remaining_days_alert_fixtures.sql new file mode 100644 index 00000000..7e8911c3 --- /dev/null +++ b/src/test/resources/db/migration/V101_4__Insert_low_remaining_days_alert_fixtures.sql @@ -0,0 +1,9 @@ +insert into worker (code, name) +values ('alert-worker-below', 'Alert Worker Below'), + ('alert-worker-above', 'Alert Worker Above'), + ('alert-worker-none', 'Alert Worker None'); + +INSERT INTO contract + (id, worker_code, level, entrance_instant, job_title, duration_in_days, contract_bucket_key) +VALUES ('alert-contract-below', 'alert-worker-below', 'L4P-2026', '2027-07-01 00:00:00.000000', 'Test Job', 5, 'test-key'), + ('alert-contract-above', 'alert-worker-above', 'L4P-2026', '2027-08-01 00:00:00.000000', 'Test Job', 15, 'test-key'); diff --git a/src/test/resources/db/migration/V101_5__Insert_pointage_it_worker_contract.sql b/src/test/resources/db/migration/V101_5__Insert_pointage_it_worker_contract.sql new file mode 100644 index 00000000..7bf1f6e9 --- /dev/null +++ b/src/test/resources/db/migration/V101_5__Insert_pointage_it_worker_contract.sql @@ -0,0 +1,16 @@ +insert into worker (code, name) +values ('worker-code', 'code'); + +INSERT INTO contract + (id, worker_code, level, entrance_instant, end_instant, job_title, duration_in_days, contract_bucket_key, company) +VALUES ( + 'it-worker-code-contract', + 'worker-code', + 'L4P-2026', + '2010-01-01 00:00:00.000000', + '2010-12-31 00:00:00.000000', + 'job-title', + 180, + 'contract-bucket-key', + 'company' +);