From 3637109ab058dcd35c90123d9c39de0d3459f1df Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 20 Feb 2026 16:59:58 +0100 Subject: [PATCH] =?UTF-8?q?feat=20:=20ajout=20du=20syst=C3=A8me=20d'histor?= =?UTF-8?q?ique=20de=20contrat=20+=20correction=20affichage=20des=20absenc?= =?UTF-8?q?es=20sur=20la=20vue=20Jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/sqldialects.xml | 6 + frontend/components/hours/HoursDayView.vue | 5 +- migrations/Version20260220133000.php | 40 ++++++ src/Entity/Employee.php | 4 +- src/Entity/EmployeeContractPeriod.php | 102 +++++++++++++++ .../EmployeeContractPeriodRepository.php | 75 +++++++++++ .../Contracts/EmployeeContractResolver.php | 97 ++++++++++++++ .../WorkHours/WorkedHoursCreditPolicy.php | 35 ++++- src/State/EmployeeWriteProcessor.php | 121 +++++++++++++++++ src/State/WorkHourBulkUpsertProcessor.php | 5 +- src/State/WorkHourDayContextProvider.php | 2 +- src/State/WorkHourWeeklySummaryProvider.php | 123 +++++++++++++----- .../WorkHours/WorkedHoursCreditPolicyTest.php | 27 +++- .../State/WorkHourDayContextProviderTest.php | 18 ++- .../WorkHourWeeklySummaryProviderTest.php | 53 +++++++- 15 files changed, 654 insertions(+), 59 deletions(-) create mode 100644 .idea/sqldialects.xml create mode 100644 migrations/Version20260220133000.php create mode 100644 src/Entity/EmployeeContractPeriod.php create mode 100644 src/Repository/EmployeeContractPeriodRepository.php create mode 100644 src/Service/Contracts/EmployeeContractResolver.php create mode 100644 src/State/EmployeeWriteProcessor.php diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..3fadc3d --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue index 53b0e3e..8a1e856 100644 --- a/frontend/components/hours/HoursDayView.vue +++ b/frontend/components/hours/HoursDayView.vue @@ -99,10 +99,11 @@ :disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))" /> -
+

{{ getRowAbsenceLabel(employee.id) || '—' }}

diff --git a/migrations/Version20260220133000.php b/migrations/Version20260220133000.php new file mode 100644 index 0000000..1e96ce5 --- /dev/null +++ b/migrations/Version20260220133000.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)'); + $this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)'); + $this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)'); + $this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)'); + $this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Initialise l\'historique avec le contrat actuel de chaque employé. + $this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at) + SELECT id, contract_id, DATE '1970-01-01', NULL, NOW() + FROM employees + WHERE contract_id IS NOT NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C'); + $this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD'); + $this->addSql('DROP TABLE employee_contract_periods'); + } +} diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php index 4403b73..9d35561 100644 --- a/src/Entity/Employee.php +++ b/src/Entity/Employee.php @@ -7,6 +7,7 @@ namespace App\Entity; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use App\Repository\EmployeeRepository; +use App\State\EmployeeWriteProcessor; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -15,7 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups; normalizationContext: ['groups' => ['employee:read', 'site:read']], denormalizationContext: ['groups' => ['employee:write']], paginationEnabled: false, - security: "is_granted('ROLE_ADMIN')" + security: "is_granted('ROLE_ADMIN')", + processor: EmployeeWriteProcessor::class, )] #[ORM\Entity(repositoryClass: EmployeeRepository::class)] #[ORM\Table(name: 'employees')] diff --git a/src/Entity/EmployeeContractPeriod.php b/src/Entity/EmployeeContractPeriod.php new file mode 100644 index 0000000..67720ee --- /dev/null +++ b/src/Entity/EmployeeContractPeriod.php @@ -0,0 +1,102 @@ +createdAt = new DateTimeImmutable(); + $this->startDate = new DateTimeImmutable('today'); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmployee(): ?Employee + { + return $this->employee; + } + + public function setEmployee(?Employee $employee): self + { + $this->employee = $employee; + + return $this; + } + + public function getContract(): ?Contract + { + return $this->contract; + } + + public function setContract(?Contract $contract): self + { + $this->contract = $contract; + + return $this; + } + + public function getStartDate(): DateTimeImmutable + { + return $this->startDate; + } + + public function setStartDate(DateTimeImmutable $startDate): self + { + $this->startDate = $startDate; + + return $this; + } + + public function getEndDate(): ?DateTimeImmutable + { + return $this->endDate; + } + + public function setEndDate(?DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Repository/EmployeeContractPeriodRepository.php b/src/Repository/EmployeeContractPeriodRepository.php new file mode 100644 index 0000000..72efa74 --- /dev/null +++ b/src/Repository/EmployeeContractPeriodRepository.php @@ -0,0 +1,75 @@ + + */ +final class EmployeeContractPeriodRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EmployeeContractPeriod::class); + } + + /** + * @param list $employees + * + * @return list + */ + public function findByEmployeesAndDateRange(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array + { + if ([] === $employees) { + return []; + } + + return $this->createQueryBuilder('p') + ->andWhere('p.employee IN (:employees)') + ->andWhere('p.startDate <= :to') + ->andWhere('p.endDate IS NULL OR p.endDate >= :from') + ->setParameter('employees', $employees) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('p.startDate', 'ASC') + ->getQuery() + ->getResult() + ; + } + + public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod + { + return $this->createQueryBuilder('p') + ->andWhere('p.employee = :employee') + ->andWhere('p.startDate <= :date') + ->andWhere('p.endDate IS NULL OR p.endDate >= :date') + ->setParameter('employee', $employee) + ->setParameter('date', $date) + ->orderBy('p.startDate', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int + { + return $this->createQueryBuilder('p') + ->update() + ->set('p.endDate', ':endDate') + ->andWhere('p.employee = :employee') + ->andWhere('p.endDate IS NULL') + ->setParameter('employee', $employee) + ->setParameter('endDate', $endDate) + ->getQuery() + ->execute() + ; + } +} diff --git a/src/Service/Contracts/EmployeeContractResolver.php b/src/Service/Contracts/EmployeeContractResolver.php new file mode 100644 index 0000000..99c89d5 --- /dev/null +++ b/src/Service/Contracts/EmployeeContractResolver.php @@ -0,0 +1,97 @@ +periodRepository->findOneCoveringDate($employee, $date); + $contract = $period?->getContract(); + if (null === $contract) { + throw new LogicException(sprintf( + 'Missing contract period for employee %d on %s.', + $employee->getId() ?? 0, + $date->format('Y-m-d') + )); + } + + return $contract; + } + + /** + * @param list $employees + * @param list $days + * + * @return array> + */ + public function resolveForEmployeesAndDays(array $employees, array $days): array + { + $resolved = []; + if ([] === $employees || [] === $days) { + return $resolved; + } + + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if (!$employeeId) { + continue; + } + + foreach ($days as $day) { + $resolved[$employeeId][$day] = null; + } + } + + $from = new DateTimeImmutable(min($days)); + $to = new DateTimeImmutable(max($days)); + $periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to); + foreach ($periods as $period) { + $employeeId = $period->getEmployee()?->getId(); + $contract = $period->getContract(); + if (!$employeeId || null === $contract) { + continue; + } + + $start = $period->getStartDate()->format('Y-m-d'); + $end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31'; + foreach ($days as $day) { + if ($day < $start || $day > $end) { + continue; + } + $resolved[$employeeId][$day] = $contract; + } + } + + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if (!$employeeId) { + continue; + } + + foreach ($days as $day) { + if (null === ($resolved[$employeeId][$day] ?? null)) { + throw new LogicException(sprintf( + 'Missing contract period for employee %d on %s.', + $employeeId, + $day + )); + } + } + } + + return $resolved; + } +} diff --git a/src/Service/WorkHours/WorkedHoursCreditPolicy.php b/src/Service/WorkHours/WorkedHoursCreditPolicy.php index 7688494..9454e86 100644 --- a/src/Service/WorkHours/WorkedHoursCreditPolicy.php +++ b/src/Service/WorkHours/WorkedHoursCreditPolicy.php @@ -6,11 +6,16 @@ namespace App\Service\WorkHours; use App\Entity\Absence; use App\Enum\TrackingMode; +use App\Service\Contracts\EmployeeContractResolver; use DateMalformedStringException; use DateTimeImmutable; -final class WorkedHoursCreditPolicy +final readonly class WorkedHoursCreditPolicy { + public function __construct( + private EmployeeContractResolver $contractResolver, + ) {} + /** * @throws DateMalformedStringException */ @@ -23,14 +28,19 @@ final class WorkedHoursCreditPolicy } $employee = $absence->getEmployee(); + if (null === $employee) { + return 0; + } + $workDate = new DateTimeImmutable($dateYmd); + $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); // Les contrats suivis en "présence" ne cumulent pas d'heures en minutes. - if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) { + if (TrackingMode::TIME->value !== $contract?->getTrackingMode()) { return 0; } - $weekday = (int) new DateTimeImmutable($dateYmd)->format('N'); + $weekday = (int) $workDate->format('N'); // On applique la règle de crédit dépendante du contrat (35h / 39h / fallback). - $dayMinutes = $this->resolveContractDayMinutes($employee->getContract()?->getWeeklyHours(), $weekday); + $dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday); if ($dayMinutes <= 0) { return 0; } @@ -41,15 +51,26 @@ final class WorkedHoursCreditPolicy return (int) round(($dayMinutes / 2) * $halfUnits); } - public function computeCreditedPresenceUnits(Absence $absence, bool $absentMorning, bool $absentAfternoon): float - { + /** + * @throws DateMalformedStringException + */ + public function computeCreditedPresenceUnits( + Absence $absence, + string $dateYmd, + bool $absentMorning, + bool $absentAfternoon + ): float { $type = $absence->getType(); if (!$type?->getCountAsWorkedHours()) { return 0.0; } $employee = $absence->getEmployee(); - if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) { + if (null === $employee) { + return 0.0; + } + $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, new DateTimeImmutable($dateYmd)); + if (TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) { return 0.0; } diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php new file mode 100644 index 0000000..1661176 --- /dev/null +++ b/src/State/EmployeeWriteProcessor.php @@ -0,0 +1,121 @@ +removeProcessor->process($data, $operation, $uriVariables, $context); + } + + if (!$data instanceof Employee) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $isNew = null === $data->getId(); + $previousContract = $this->resolvePreviousContract($data); + $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context); + + $currentContract = $data->getContract(); + if (!$currentContract instanceof Contract) { + return $result; + } + + $today = new DateTimeImmutable('today'); + if ($isNew) { + $this->ensureContractPeriodExists($data, $currentContract, $today); + + return $result; + } + + if ($this->isSameContract($previousContract, $currentContract)) { + return $result; + } + + $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today); + if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) { + $todayPeriod->setContract($currentContract); + $this->entityManager->flush(); + + return $result; + } + + $this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day')); + $this->createPeriod($data, $currentContract, $today); + $this->entityManager->flush(); + + return $result; + } + + private function resolvePreviousContract(Employee $employee): ?Contract + { + if (null === $employee->getId()) { + return null; + } + + $originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee); + $original = $originalData['contract'] ?? null; + + return $original instanceof Contract ? $original : null; + } + + private function isSameContract(?Contract $first, ?Contract $second): bool + { + if (null === $first || null === $second) { + return $first === $second; + } + + return $first->getId() === $second->getId(); + } + + private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void + { + $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate); + if (null !== $covered) { + return; + } + + $this->createPeriod($employee, $contract, $startDate); + $this->entityManager->flush(); + } + + private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void + { + $period = new EmployeeContractPeriod() + ->setEmployee($employee) + ->setContract($contract) + ->setStartDate($startDate) + ->setEndDate(null) + ; + + $this->entityManager->persist($period); + } +} diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php index e919ea2..a95b650 100644 --- a/src/State/WorkHourBulkUpsertProcessor.php +++ b/src/State/WorkHourBulkUpsertProcessor.php @@ -13,6 +13,7 @@ use App\Entity\WorkHour; use App\Enum\TrackingMode; use App\Repository\EmployeeRepository; use App\Repository\WorkHourRepository; +use App\Service\Contracts\EmployeeContractResolver; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; @@ -27,6 +28,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface private Security $security, private EmployeeRepository $employeeRepository, private WorkHourRepository $workHourRepository, + private EmployeeContractResolver $contractResolver, ) {} public function process( @@ -75,7 +77,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId)); } - $isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode(); + $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); + $isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode(); $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking); $existing = $existingByEmployeeId[$employeeId] ?? null; diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php index 4a03ac8..62f47c1 100644 --- a/src/State/WorkHourDayContextProvider.php +++ b/src/State/WorkHourDayContextProvider.php @@ -69,7 +69,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface // Calcule le crédit d'heures selon la politique métier (type d'absence + contrat). $creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon); - $creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon); + $creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon); $rowsByEmployeeId[$employeeId]->addAbsence( label: $absence->getType()?->getLabel(), morning: $absentMorning, diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php index dd63345..be40207 100644 --- a/src/State/WorkHourWeeklySummaryProvider.php +++ b/src/State/WorkHourWeeklySummaryProvider.php @@ -11,6 +11,7 @@ use App\Dto\WorkHours\WeeklyDaySummary; use App\Dto\WorkHours\WeeklySummaryRow; use App\Dto\WorkHours\WorkMetrics; use App\Entity\Absence; +use App\Entity\Contract; use App\Entity\Employee; use App\Entity\User; use App\Entity\WorkHour; @@ -19,6 +20,7 @@ use App\Enum\TrackingMode; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface; +use App\Service\Contracts\EmployeeContractResolver; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use DateTimeImmutable; @@ -37,6 +39,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceSegmentsResolver $absenceSegmentsResolver, private WorkedHoursCreditPolicy $workedHoursCreditPolicy, + private EmployeeContractResolver $contractResolver, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary @@ -58,7 +61,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface $summary->weekStart = $weekStart->format('Y-m-d'); $summary->weekEnd = $weekEnd->format('Y-m-d'); $summary->days = $days; - $summary->rows = $this->buildRows($employees, $workHours, $absences, $days); + $summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d')); return $summary; } @@ -108,9 +111,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface * * @return list */ - private function buildRows(array $employees, array $workHours, array $absences, array $days): array + private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array { - $metricsByEmployeeDate = []; + $contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); + $metricsByEmployeeDate = []; foreach ($workHours as $workHour) { $employeeId = $workHour->getEmployee()?->getId(); if (!$employeeId) { @@ -158,7 +162,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface $creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0) + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); $creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0) - + $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon); + + $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon); } } @@ -175,12 +179,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface $weeklyPresenceCount = 0.0; $daily = []; // Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées. - $isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode(); + $weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd] + ?? $contractsByEmployeeDate[$employeeId][$days[0]] + ?? null; + $employeeContractsByDate = []; + foreach ($days as $date) { + $employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null; + } foreach ($days as $date) { - $entry = $metricsByEmployeeDate[$employeeId][$date] ?? null; - $metrics = $entry['metrics'] ?? new WorkMetrics(); - $creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0; + $entry = $metricsByEmployeeDate[$employeeId][$date] ?? null; + $metrics = $entry['metrics'] ?? new WorkMetrics(); + $creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0; + $contractAtDate = $employeeContractsByDate[$date] ?? null; + $isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode(); // Les absences "comptées comme travaillées" alimentent le total du jour. $metrics->addCreditedMinutes($creditedMinutes); $present = null; @@ -210,18 +222,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface ); } - $contractWeeklyHours = $employee->getContract()?->getWeeklyHours(); - $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee); - $weeklyOvertimeTotalMinutes = $isPresenceTracking + $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); + $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract); + $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate); + $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate); + $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 - : $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours); - $weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses) + : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes); + $weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 - : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours); - $weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses) + : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes); + $weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $this->computeOvertime50BonusMinutes($weeklyTotalMinutes); - $weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses) + $weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes; @@ -230,9 +244,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface firstName: $employee->getFirstName(), lastName: $employee->getLastName(), siteName: $employee->getSite()?->getName(), - contractName: $employee->getContract()?->getName(), - contractType: $employee->getContract()?->getType()->value, - trackingMode: $employee->getContract()?->getTrackingMode(), + contractName: $weekAnchorContract?->getName(), + contractType: $weekAnchorContract?->getType()->value, + trackingMode: $weekAnchorContract?->getTrackingMode(), daily: $daily, weeklyDayMinutes: $weeklyDayMinutes, weeklyNightMinutes: $weeklyNightMinutes, @@ -344,25 +358,43 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface return max(0, $end - $start); } - private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int + /** + * @param array $contractsByDate + */ + private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int { - if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) { - return 0; + $total = 0; + foreach ($days as $date) { + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $contract = $contractsByDate[$date] ?? null; + $hours = $contract?->getWeeklyHours(); + $referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null; + $total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay); } - // Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp. - $referenceHours = max(35, $contractWeeklyHours); - - return max(0, $weeklyTotalMinutes - ($referenceHours * 60)); + return $total; } - private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int + /** + * @param array $contractsByDate + */ + private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int { - // Règle métier: - // - contrats <= 35h: 25% entre 35h et 43h - // - contrats >= 39h: 25% entre 39h et 43h - $startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35; - $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60)); + $total = 0; + foreach ($days as $date) { + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $contract = $contractsByDate[$date] ?? null; + $hours = $contract?->getWeeklyHours(); + $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; + $total += $this->resolveDailyReferenceMinutes($startHours, $isoDay); + } + + return $total; + } + + private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int + { + $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes); return (int) round($trancheMinutes * 0.25); } @@ -375,10 +407,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface return (int) round($trancheMinutes * 0.5); } - private function hasDisabledOvertimeBonuses(Employee $employee): bool + private function hasDisabledOvertimeBonuses(?Contract $contract): bool { - $contract = $employee->getContract(); - $type = ContractType::resolve( + $type = ContractType::resolve( $contract?->getName(), $contract?->getTrackingMode(), $contract?->getWeeklyHours() @@ -386,4 +417,26 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface return ContractType::INTERIM === $type; } + + private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int + { + // Week-end hors base de référence. + if ($isoWeekDay >= 6) { + return 0; + } + + if (null === $weeklyHours || $weeklyHours <= 0) { + return 0; + } + + if (39 === $weeklyHours) { + return 5 === $isoWeekDay ? 7 * 60 : 8 * 60; + } + + if (35 === $weeklyHours) { + return 7 * 60; + } + + return (int) round(($weeklyHours * 60) / 5); + } } diff --git a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php index 4ffd188..e79de91 100644 --- a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php +++ b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php @@ -8,6 +8,7 @@ use App\Entity\Absence; use App\Entity\AbsenceType; use App\Entity\Contract; use App\Entity\Employee; +use App\Service\Contracts\EmployeeContractResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use DateTime; use PHPUnit\Framework\TestCase; @@ -19,7 +20,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase { public function testComputeCreditedMinutesFor35hHalfDay(): void { - $policy = new WorkedHoursCreditPolicy(); + $policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true); $minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false); @@ -29,7 +30,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase public function testComputeCreditedMinutesFor4hContractFullDay(): void { - $policy = new WorkedHoursCreditPolicy(); + $policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true); $minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true); @@ -39,21 +40,21 @@ final class WorkedHoursCreditPolicyTest extends TestCase public function testComputeCreditedPresenceUnitsForPresenceContract(): void { - $policy = new WorkedHoursCreditPolicy(); + $policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true); - $units = $policy->computeCreditedPresenceUnits($absence, true, false); + $units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false); self::assertSame(0.5, $units); } public function testNoCreditWhenAbsenceTypeDoesNotCount(): void { - $policy = new WorkedHoursCreditPolicy(); + $policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false); self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true)); - self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, true, true)); + self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true)); } private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence @@ -79,6 +80,18 @@ final class WorkedHoursCreditPolicyTest extends TestCase ->setEmployee($employee) ->setType($type) ->setStartDate(new DateTime('2026-02-16')) - ->setEndDate(new DateTime('2026-02-16')); + ->setEndDate(new DateTime('2026-02-16')) + ; + } + + private function buildResolverStub(): EmployeeContractResolver + { + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver + ->method('resolveForEmployeeAndDate') + ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) + ; + + return $resolver; } } diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php index 4353a2c..cc72909 100644 --- a/tests/State/WorkHourDayContextProviderTest.php +++ b/tests/State/WorkHourDayContextProviderTest.php @@ -13,6 +13,7 @@ use App\Entity\User; use App\Enum\HalfDay; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface; +use App\Service\Contracts\EmployeeContractResolver; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\State\WorkHourDayContextProvider; @@ -53,7 +54,7 @@ final class WorkHourDayContextProviderTest extends TestCase $this->employeeRepository, $this->absenceRepository, new AbsenceSegmentsResolver(), - new WorkedHoursCreditPolicy() + new WorkedHoursCreditPolicy($this->buildResolverStub()) ); $this->expectException(AccessDeniedHttpException::class); @@ -71,7 +72,7 @@ final class WorkHourDayContextProviderTest extends TestCase $this->employeeRepository, $this->absenceRepository, new AbsenceSegmentsResolver(), - new WorkedHoursCreditPolicy() + new WorkedHoursCreditPolicy($this->buildResolverStub()) ); $this->expectException(UnprocessableEntityHttpException::class); @@ -95,7 +96,7 @@ final class WorkHourDayContextProviderTest extends TestCase $this->employeeRepository, $this->absenceRepository, new AbsenceSegmentsResolver(), - new WorkedHoursCreditPolicy() + new WorkedHoursCreditPolicy($this->buildResolverStub()) ); $result = $provider->provide(new Get()); @@ -151,4 +152,15 @@ final class WorkHourDayContextProviderTest extends TestCase $property->setAccessible(true); $property->setValue($entity, $id); } + + private function buildResolverStub(): EmployeeContractResolver + { + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver + ->method('resolveForEmployeeAndDate') + ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) + ; + + return $resolver; + } } diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php index 1087d2f..24ac5ab 100644 --- a/tests/State/WorkHourWeeklySummaryProviderTest.php +++ b/tests/State/WorkHourWeeklySummaryProviderTest.php @@ -15,6 +15,7 @@ use App\Enum\HalfDay; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface; +use App\Service\Contracts\EmployeeContractResolver; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\State\WorkHourWeeklySummaryProvider; @@ -58,7 +59,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->workHourRepository, $this->absenceRepository, new AbsenceSegmentsResolver(), - new WorkedHoursCreditPolicy() + new WorkedHoursCreditPolicy($this->buildResolverStub()), + $this->buildResolverStub() ); $this->expectException(AccessDeniedHttpException::class); @@ -117,7 +119,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $this->workHourRepository, $this->absenceRepository, new AbsenceSegmentsResolver(), - new WorkedHoursCreditPolicy() + new WorkedHoursCreditPolicy($this->buildResolverStub()), + $this->buildWeeklyResolverStub($employees) ); $result = $provider->provide(new Get()); @@ -167,4 +170,50 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase $property->setAccessible(true); $property->setValue($entity, $id); } + + private function buildResolverStub(): EmployeeContractResolver + { + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver + ->method('resolveForEmployeeAndDate') + ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) + ; + $resolver + ->method('resolveForEmployeesAndDays') + ->willReturn([]) + ; + + return $resolver; + } + + /** + * @param list $employees + */ + private function buildWeeklyResolverStub(array $employees): EmployeeContractResolver + { + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver + ->method('resolveForEmployeeAndDate') + ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) + ; + $resolver + ->method('resolveForEmployeesAndDays') + ->willReturnCallback(static function (array $scopedEmployees, array $days): array { + $map = []; + foreach ($scopedEmployees as $employee) { + $employeeId = $employee->getId(); + if (!$employeeId) { + continue; + } + foreach ($days as $day) { + $map[$employeeId][$day] = $employee->getContract(); + } + } + + return $map; + }) + ; + + return $resolver; + } }