Ajout des notification + page employé (#6)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #6
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #6.
This commit is contained in:
2026-03-10 12:35:17 +00:00
committed by Autin
parent ae42c70d50
commit f493ea237b
126 changed files with 9215 additions and 935 deletions

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Enum\ContractNature;
use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest
{
public function __construct(
public ?ContractNature $contractNature,
public ?DateTimeImmutable $contractStartDate,
public ?DateTimeImmutable $contractEndDate,
public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
) {}
public function hasPeriodChangeRequest(): bool
{
return null !== $this->contractNature
|| null !== $this->contractStartDate
|| null !== $this->contractEndDate
|| null !== $this->contractPaidLeaveSettled
|| null !== $this->contractComment;
}
public function isCloseOnlyRequest(bool $contractChanged): bool
{
return !$contractChanged
&& null === $this->contractStartDate
&& null === $this->contractNature
&& null !== $this->contractEndDate;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Employee;
use App\Enum\ContractNature;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final class EmployeeContractChangeRequestFactory
{
public function fromEmployee(Employee $employee): EmployeeContractChangeRequest
{
return new EmployeeContractChangeRequest(
contractNature: $this->resolveContractNature($employee->getContractNature()),
contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'),
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
);
}
private function resolveContractNature(?string $raw): ?ContractNature
{
if (null === $raw || '' === trim($raw)) {
return null;
}
return ContractNature::tryFrom(trim($raw))
?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
}
private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
{
if (null === $raw || '' === trim($raw)) {
return null;
}
$value = trim($raw);
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
}
return $date;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
final class EmployeeContractPeriodBuilder
{
public function build(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->setEndDate($endDate)
->setContractNature($nature)
;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeContractPeriodManager implements EmployeeContractPeriodManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeContractPeriodBuilder $periodBuilder,
private EmployeeContractPeriodValidator $periodValidator,
) {}
public function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) {
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
): void {
if (null === $todayPeriod) {
throw new UnprocessableEntityHttpException('No active contract period to close.');
}
$this->periodValidator->assertCloseEndDateCanBeApplied(
$todayPeriod->getStartDate(),
$todayPeriod->getEndDate(),
$requestedEndDate,
$todayPeriod->getContractNatureEnum()
);
$todayPeriod->setEndDate($requestedEndDate);
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
$todayPeriod->setComment($comment);
$this->entityManager->flush();
}
public function createNextPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
if (null === $todayPeriod->getEndDate()) {
$todayPeriod->setEndDate($startDate->modify('-1 day'));
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
private function persistNewPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->persist($period);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface
{
public function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void;
public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
): void;
public function createNextPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
): void;
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final class EmployeeContractPeriodValidator
{
public function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $allowCdiEndDate = false
): void {
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
}
if ($nature->requiresEndDate() && null === $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
}
if (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
public function assertCloseEndDateCanBeApplied(
DateTimeImmutable $startDate,
?DateTimeImmutable $currentEndDate,
DateTimeImmutable $requestedEndDate,
ContractNature $nature
): void {
$this->assertPeriodDates($startDate, $requestedEndDate, $nature, true);
if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
}
}
public function assertNextStartDateCompatible(
DateTimeImmutable $startDate,
EmployeeContractPeriod $currentPeriod
): void {
$currentEndDate = $currentPeriod->getEndDate();
if (null === $currentEndDate) {
if ($startDate <= $currentPeriod->getStartDate()) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
}
return;
}
if ($startDate <= $currentEndDate) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
}
}
}

View File

@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveBalanceComputationService
{
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
private const float STANDARD_ANNUAL_DAYS = 25.0;
private const float STANDARD_ANNUAL_SATURDAYS = 5.0;
private const float STANDARD_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_DAYS / 12.0;
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
public function __construct(
private AbsenceRepository $absenceRepository,
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
/**
* @return array{float, float}
*/
public function computeDynamicClosingForYear(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
{
$firstYear = $this->resolveFirstComputationYear($employee, $ruleCode, $targetYear);
if ($targetYear < $firstYear) {
return [0.0, 0.0];
}
$previousRemainingDays = 0.0;
$previousRemainingSaturdays = 0.0;
for ($year = $firstYear; $year <= $targetYear; ++$year) {
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
$carryDays = 0.0;
$carrySaturdays = 0.0;
if ($year > $firstYear) {
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
if (!$hasSettlementOnPreviousYear) {
$carryDays = $previousRemainingDays;
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0;
}
}
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
if ($effectiveFrom > $from) {
$carryDays = 0.0;
$carrySaturdays = 0.0;
}
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
$previousRemainingSaturdays = 0.0;
continue;
}
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to
);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
$acquiredWithFractioned = $carryDays + $fractionedDays;
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
$remainingAcquired = $acquiredWithFractioned - $takenFromAcquired;
$remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
$remainingGenerated = $generatedDays - $remainingToImpute;
$takenFromAcquiredSaturdays = min(max(0.0, $carrySaturdays), $takenSaturdays);
$remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
$previousRemainingDays = $remainingAcquired + $remainingGenerated;
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
}
return [$previousRemainingDays, $previousRemainingSaturdays];
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array
{
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return [
new DateTimeImmutable(sprintf('%d-01-01', $year)),
new DateTimeImmutable(sprintf('%d-12-31', $year)),
];
}
return [
new DateTimeImmutable(sprintf('%d-06-01', $year - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $year)),
];
}
public function hasPaidLeaveSettledClosureBetween(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): bool {
return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to);
}
private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int
{
$history = $employee->getContractHistory();
if ([] === $history) {
return $fallbackYear;
}
$oldestStartDate = null;
foreach ($history as $item) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
if (!$start) {
continue;
}
if (null === $oldestStartDate || $start < $oldestStartDate) {
$oldestStartDate = $start;
}
}
if (null === $oldestStartDate) {
return $fallbackYear;
}
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return (int) $oldestStartDate->format('Y');
}
$startYear = (int) $oldestStartDate->format('Y');
$startMonth = (int) $oldestStartDate->format('n');
return $startMonth >= 6 ? $startYear + 1 : $startYear;
}
private function resolveEffectivePeriodStart(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): DateTimeImmutable {
$latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
$start = $from;
if (null !== $latestSettledClosure) {
$nextDay = $latestSettledClosure->modify('+1 day');
if ($nextDay > $start) {
$start = $nextDay;
}
}
$earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
if (null !== $earliestContractStart && $earliestContractStart > $start) {
$start = $earliestContractStart;
}
return $start;
}
private function resolveEarliestContractStartWithinRange(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): ?DateTimeImmutable {
$earliest = null;
foreach ($employee->getContractHistory() as $period) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
if (!$start) {
continue;
}
$end = null;
if (null !== $period->endDate && '' !== trim($period->endDate)) {
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
}
if ($start > $to) {
continue;
}
if ($end instanceof DateTimeImmutable && $end < $from) {
continue;
}
$candidate = $start < $from ? $from : $start;
if (null === $earliest || $candidate < $earliest) {
$earliest = $candidate;
}
}
return $earliest;
}
private function resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getFractionedDays() : 0.0;
}
private function resolveAnnualDays(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? self::FOUR_HOUR_ANNUAL_DAYS
: self::STANDARD_ANNUAL_DAYS;
}
private function resolveAnnualSaturdays(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? 0.0
: self::STANDARD_ANNUAL_SATURDAYS;
}
private function resolveDaysAccrualPerMonth(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? self::FOUR_HOUR_ACCRUAL_PER_MONTH
: self::STANDARD_ACCRUAL_PER_MONTH;
}
private function resolveSaturdayAccrualPerMonth(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? 0.0
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
}
private function computeAccruedDays(
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd
): float {
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
return 0.0;
}
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
+ 1;
return min($annualCap, $monthsElapsed * $accrualPerMonth);
}
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
{
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$count = 0;
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
$dayKey = $cursor->format('Y-m-d');
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
++$count;
}
}
return $count;
}
/**
* @return array<string, string>
*/
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/**
* @param list<Absence> $absences
*
* @return array{float, float}
*/
private function computeTakenAbsences(
array $absences,
DateTimeImmutable $from,
DateTimeImmutable $to,
bool $countOnlyCp,
bool $splitSaturdays
): array {
$takenDays = 0.0;
$takenSaturdays = 0.0;
foreach ($absences as $absence) {
if ($countOnlyCp) {
$typeCode = strtoupper((string) $absence->getType()?->getCode());
if ('C' !== $typeCode) {
continue;
}
}
if (null === $absence->getType()) {
continue;
}
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
$rangeStart = $start < $from ? $from : $start;
$rangeEnd = $end > $to ? $to : $end;
if ($rangeEnd < $rangeStart) {
continue;
}
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) {
continue;
}
$isSaturday = $splitSaturdays && '6' === $cursor->format('N');
if ($isSaturday) {
$takenSaturdays += $dayAmount;
} else {
$takenDays += $dayAmount;
}
}
}
return [$takenDays, $takenSaturdays];
}
/**
* @return array{bool, bool}
*/
private function resolveSegmentsForDate(Absence $absence, string $date): array
{
$startYmd = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d');
$endYmd = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d');
$startHalf = $absence->getStartHalf()->value;
$endHalf = $absence->getEndHalf()->value;
$isSingleDay = $startYmd === $endYmd;
$isStartDay = $date === $startYmd;
$isEndDay = $date === $endYmd;
if ($isSingleDay) {
return ['AM' === $startHalf, 'PM' === $endHalf];
}
if ($isStartDay) {
return ['AM' === $startHalf, true];
}
if ($isEndDay) {
return [true, 'PM' === $endHalf];
}
return [true, true];
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
final readonly class RttRecoveryComputationService
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
) {}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
public function resolveExerciseBounds(int $exerciseYear): array
{
return [
new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)),
];
}
/**
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
*/
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$dayOfWeek = (int) $from->format('N');
$weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1));
$weeks = [];
while ($weekStart <= $to) {
$start = $weekStart;
$end = $start->modify('+6 days');
$effectiveStart = $start < $from ? $from : $start;
$effectiveEnd = $end > $to ? $to : $end;
if ($effectiveEnd >= $effectiveStart) {
$saturday = $start->modify('+5 days');
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
$weeks[] = [
'month' => (int) $monthAnchor->format('n'),
'weekNumber' => (int) $effectiveStart->format('W'),
'start' => $start,
'end' => $end,
];
}
$weekStart = $weekStart->modify('+7 days');
}
return $weeks;
}
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
return array_sum($byWeek);
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, int>
*/
public function computeRecoveryByWeek(
Employee $employee,
array $weeks,
DateTimeImmutable $periodFrom,
DateTimeImmutable $periodTo,
?DateTimeImmutable $limitDate
): array {
if ([] === $weeks) {
return [];
}
$days = [];
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$days[] = $cursor->format('Y-m-d');
}
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
$absences = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]);
$metricsByDate = [];
foreach ($workHours as $workHour) {
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByDate[$dateKey] = $this->computeMetrics($workHour);
}
$creditedByDate = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$date = $cursor->format('Y-m-d');
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
}
}
$results = [];
foreach ($weeks as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
if ($effectiveEnd < $effectiveStart) {
$results[$weekKey] = 0;
continue;
}
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
$results[$weekKey] = 0;
continue;
}
$weekDays = [];
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
$weekDays[] = $cursor->format('Y-m-d');
}
$weeklyTotalMinutes = 0;
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
continue;
}
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
}
if ([] === $weekDays) {
$results[$weekKey] = 0;
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
}
return $results;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
{
$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);
}
return $total;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
{
$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);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),
$contract?->getWeeklyHours()
);
return ContractType::INTERIM === $type;
}
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
if ($isoWeekDay >= 6 || 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);
}
}

View File

@@ -69,13 +69,8 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
// Règle forfait:
// - demi-journée d'absence => 0.5 travaillé
// - journée complète d'absence => 0 travaillé
if ($absentMorning xor $absentAfternoon) {
return 0.5;
}
// Règle forfait: les absences ne créditent jamais de présence.
// Seules les checkboxes cochées par l'employé comptent.
return 0.0;
}