Gestion du changement de type de contrat + correction du calcule des RTT sur un contrat qui commence en milieu de semaine (#19)
Auto Tag Develop / tag (push) Has been cancelled
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #19 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #19.
This commit is contained in:
@@ -42,6 +42,14 @@ final class EmployeeLeaveSummary
|
||||
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
|
||||
public float $presenceDaysToToday = 0.0;
|
||||
|
||||
/**
|
||||
* FORFAIT uniquement : jours à travailler sur l'exercice = jours ouvrés de la période − congés acquis.
|
||||
* Vaut 218 sur une année pleine (252 − 34) et le prorata sur une entrée en cours d'année
|
||||
* (ex. Grégory : 168 − 13 ≈ 155). Null pour les non-forfait. Le « restant à travailler »
|
||||
* affiché = forfaitWorkTargetDays − presenceDaysToToday.
|
||||
*/
|
||||
public ?float $forfaitWorkTargetDays = null;
|
||||
|
||||
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
|
||||
public ?string $dataStartDate = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\Contracts;
|
||||
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ContractPhase
|
||||
{
|
||||
/**
|
||||
* @param list<int> $periodIds
|
||||
*/
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public ContractType $contractType,
|
||||
public ?int $weeklyHours,
|
||||
public bool $isDriver,
|
||||
public DateTimeImmutable $startDate,
|
||||
public ?DateTimeImmutable $endDate,
|
||||
public array $periodIds,
|
||||
public bool $isCurrent,
|
||||
public ContractNature $contractNature,
|
||||
) {}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Dto\Employees\ContractHistoryItem;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\State\EmployeeWriteProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -428,6 +430,43 @@ class Employee
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* id: int,
|
||||
* contractType: string,
|
||||
* weeklyHours: ?int,
|
||||
* isDriver: bool,
|
||||
* startDate: string,
|
||||
* endDate: ?string,
|
||||
* periodIds: list<int>,
|
||||
* isCurrent: bool,
|
||||
* contractNature: string
|
||||
* }>
|
||||
*/
|
||||
#[Groups(['employee:read'])]
|
||||
public function getContractPhases(): array
|
||||
{
|
||||
// Read RTT_START_DATE directly here: the entity has no DI but must filter
|
||||
// out contract phases that ended before the application's data start.
|
||||
$rawDate = $_SERVER['RTT_START_DATE'] ?? $_ENV['RTT_START_DATE'] ?? '';
|
||||
$resolver = new EmployeeContractPhaseResolver(is_string($rawDate) ? $rawDate : '');
|
||||
|
||||
return array_map(
|
||||
static fn (ContractPhase $phase): array => [
|
||||
'id' => $phase->id,
|
||||
'contractType' => $phase->contractType->value,
|
||||
'weeklyHours' => $phase->weeklyHours,
|
||||
'isDriver' => $phase->isDriver,
|
||||
'startDate' => $phase->startDate->format('Y-m-d'),
|
||||
'endDate' => $phase->endDate?->format('Y-m-d'),
|
||||
'periodIds' => $phase->periodIds,
|
||||
'isCurrent' => $phase->isCurrent,
|
||||
'contractNature' => $phase->contractNature->value,
|
||||
],
|
||||
$resolver->resolvePhases($this),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
||||
{
|
||||
$today = new DateTimeImmutable('today');
|
||||
|
||||
@@ -87,6 +87,10 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
|
||||
->addSelect('s')
|
||||
->leftJoin('e.contract', 'c')
|
||||
->addSelect('c')
|
||||
// Eager-load des périodes pour le filtre d'intersection contrat/période (impression),
|
||||
// évite un N+1 sur getContractPeriods() lors du filtrage des employés.
|
||||
->leftJoin('e.contractPeriods', 'cp')
|
||||
->addSelect('cp')
|
||||
->orderBy('s.name', 'ASC')
|
||||
->addOrderBy('e.displayOrder', 'ASC')
|
||||
->addOrderBy('e.lastName', 'ASC')
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use DateTimeImmutable;
|
||||
use LogicException;
|
||||
|
||||
final readonly class EmployeeContractPhaseResolver
|
||||
{
|
||||
private ?DateTimeImmutable $dataStartDate;
|
||||
|
||||
public function __construct(string $dataStartDate = '')
|
||||
{
|
||||
$trimmed = trim($dataStartDate);
|
||||
if ('' === $trimmed) {
|
||||
$this->dataStartDate = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $trimmed);
|
||||
$this->dataStartDate = $parsed instanceof DateTimeImmutable ? $parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContractPhase>
|
||||
*/
|
||||
public function resolvePhases(Employee $employee): array
|
||||
{
|
||||
$periods = $employee->getContractPeriods()->toArray();
|
||||
usort(
|
||||
$periods,
|
||||
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||||
);
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
$phases = [];
|
||||
$group = [];
|
||||
$signature = null;
|
||||
|
||||
foreach ($periods as $period) {
|
||||
$currentSignature = $this->signature($period);
|
||||
if (null !== $signature && $currentSignature !== $signature) {
|
||||
$phases[] = $this->buildPhase($group, $today);
|
||||
$group = [];
|
||||
}
|
||||
$group[] = $period;
|
||||
$signature = $currentSignature;
|
||||
}
|
||||
|
||||
if ([] !== $group) {
|
||||
$phases[] = $this->buildPhase($group, $today);
|
||||
}
|
||||
|
||||
// Hide phases entirely before the application's data start date: no usable
|
||||
// work-hour or absence data exists before that date, so exposing them would
|
||||
// confuse HR (e.g. legacy contract periods predating the software launch).
|
||||
if (null !== $this->dataStartDate) {
|
||||
$dataStart = $this->dataStartDate;
|
||||
$phases = array_values(array_filter(
|
||||
$phases,
|
||||
static fn (ContractPhase $phase): bool => null === $phase->endDate || $phase->endDate >= $dataStart,
|
||||
));
|
||||
}
|
||||
|
||||
// Most recent first.
|
||||
return array_reverse($phases);
|
||||
}
|
||||
|
||||
private function signature(EmployeeContractPeriod $period): string
|
||||
{
|
||||
$contract = $period->getContract();
|
||||
$type = $contract?->getType()->value ?? '';
|
||||
$hours = $contract?->getWeeklyHours() ?? -1;
|
||||
$driver = $period->getIsDriver() ? '1' : '0';
|
||||
|
||||
return sprintf('%s|%d|%s', $type, $hours, $driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param non-empty-list<EmployeeContractPeriod> $group
|
||||
*/
|
||||
private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase
|
||||
{
|
||||
$first = $group[0];
|
||||
$last = end($group);
|
||||
|
||||
$endDate = $last->getEndDate();
|
||||
$isCurrent = null === $endDate || $endDate >= $today;
|
||||
|
||||
$contract = $first->getContract();
|
||||
|
||||
return new ContractPhase(
|
||||
id: (int) $first->getId(),
|
||||
contractType: $contract?->getType() ?? throw new LogicException('Phase requires a contract type'),
|
||||
weeklyHours: $contract?->getWeeklyHours(),
|
||||
isDriver: $first->getIsDriver(),
|
||||
startDate: $first->getStartDate(),
|
||||
endDate: $endDate,
|
||||
periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group),
|
||||
isCurrent: $isCurrent,
|
||||
contractNature: $first->getContractNatureEnum(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Exercise;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ExerciseYearResolver
|
||||
{
|
||||
/**
|
||||
* Convert a date to its leave/RTT exercise year.
|
||||
*
|
||||
* - Forfait: calendar year (Jan→Dec) — returns $date.Y.
|
||||
* - Non-forfait: leave year (Juin N-1 → Mai N) — returns $date.Y+1 if month >= 6, else $date.Y.
|
||||
*/
|
||||
public function forDate(DateTimeImmutable $date, bool $isForfait = false): int
|
||||
{
|
||||
if ($isForfait) {
|
||||
return (int) $date->format('Y');
|
||||
}
|
||||
|
||||
return (int) $date->format('n') >= 6
|
||||
? (int) $date->format('Y') + 1
|
||||
: (int) $date->format('Y');
|
||||
}
|
||||
}
|
||||
@@ -221,8 +221,9 @@ final readonly class RttRecoveryComputationService
|
||||
continue;
|
||||
}
|
||||
|
||||
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
||||
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
||||
$weekAnchorDate = $this->resolveWeekAnchorDate($weekDays, $employeeContractsByDate);
|
||||
$weekAnchorNature = $naturesByDate[$employeeId][$weekAnchorDate] ?? ContractNature::CDI;
|
||||
$weekAnchorContract = $employeeContractsByDate[$weekAnchorDate] ?? null;
|
||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
||||
$weekContractType = ContractType::resolve(
|
||||
@@ -387,6 +388,27 @@ final readonly class RttRecoveryComputationService
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date d'ancrage de la semaine pour résoudre le type/nature de contrat : premier jour
|
||||
* de la semaine couvert par un contrat. Évite qu'une semaine d'embauche en milieu de
|
||||
* semaine (premiers jours hors contrat) soit classée CUSTOM — ce qui désactiverait à
|
||||
* tort les bonus 25 %/50 % d'un contrat 35h/39h. Fallback sur le 1er jour si aucun jour
|
||||
* n'est contracté (semaine entièrement hors contrat → 0 de toute façon).
|
||||
*
|
||||
* @param list<string> $weekDays
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
private function resolveWeekAnchorDate(array $weekDays, array $contractsByDate): string
|
||||
{
|
||||
foreach ($weekDays as $date) {
|
||||
if (null !== ($contractsByDate[$date] ?? null)) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
return $weekDays[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
@@ -413,10 +435,17 @@ final readonly class RttRecoveryComputationService
|
||||
{
|
||||
$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;
|
||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$hours = $contract?->getWeeklyHours();
|
||||
// Days without an active contract (pre-hire, post-termination, contract
|
||||
// gaps) must NOT contribute to the weekly 25% overtime threshold —
|
||||
// otherwise hiring mid-week artificially inflates the threshold and
|
||||
// erases legitimate overtime.
|
||||
if (null === $hours || $hours <= 0) {
|
||||
continue;
|
||||
}
|
||||
$startHours = $hours >= 39 ? 39 : 35;
|
||||
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\Formation;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\HalfDay;
|
||||
@@ -56,12 +57,20 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
|
||||
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
|
||||
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
|
||||
if (!$fromDate instanceof DateTimeImmutable || !$toDate instanceof DateTimeImmutable) {
|
||||
return new Response('Invalid from/to date format.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
// createFromFormat('Y-m-d', ...) garde l'heure courante : on borne explicitement aux
|
||||
// extrémités de journée, sinon les comparaisons de dates (présence d'un contrat/absence
|
||||
// le jour de `from`) échouent contre les dates BDD à minuit.
|
||||
$fromDate = $fromDate->setTime(0, 0, 0);
|
||||
$toDate = $toDate->setTime(23, 59, 59);
|
||||
|
||||
$siteIds = $this->parseIds($request->query->get('sites'));
|
||||
$workContractIds = $this->parseIds($request->query->get('workContracts'));
|
||||
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
|
||||
|
||||
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
|
||||
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds, $fromDate, $toDate);
|
||||
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
|
||||
$formations = $this->formationRepository->findByDateRangeAndEmployees($fromDate, $toDate, $employees);
|
||||
|
||||
@@ -117,21 +126,45 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
|
||||
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
|
||||
|
||||
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
|
||||
return array_values(array_filter($employees, function ($employee) use ($contractNatures, $workContractIds, $from, $to): bool {
|
||||
$employeeNature = (string) $employee->getCurrentContractNature();
|
||||
$employeeContractId = $employee->getContract()?->getId();
|
||||
|
||||
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
|
||||
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
|
||||
|
||||
return $natureMatches && $contractMatches;
|
||||
// Exclut les employés dont aucune période de contrat n'intersecte la période imprimée
|
||||
// (ex. un salarié parti en avril ne doit pas apparaître sur une impression de mai).
|
||||
return $natureMatches && $contractMatches && $this->hasContractInRange($employee, $from, $to);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si au moins une période de contrat de l'employé intersecte [$from, $to].
|
||||
* Une période sans date de fin (contrat en cours) est considérée ouverte jusqu'à l'infini.
|
||||
* Comparaison sur la date seule (`Y-m-d`), insensible à l'heure des bornes — aligné avec
|
||||
* le filtre `hasContractInSelectedMonth` de la vue Calendrier (comparaison de chaînes).
|
||||
*/
|
||||
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
||||
{
|
||||
$fromDay = $from->format('Y-m-d');
|
||||
$toDay = $to->format('Y-m-d');
|
||||
|
||||
foreach ($employee->getContractPeriods() as $period) {
|
||||
$start = $period->getStartDate()->format('Y-m-d');
|
||||
$end = $period->getEndDate()?->format('Y-m-d');
|
||||
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||
{
|
||||
return $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeLeaveSummary;
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\ContractSuspension;
|
||||
use App\Entity\Employee;
|
||||
@@ -20,6 +21,8 @@ use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use App\Service\Leave\LeaveBalanceComputationService;
|
||||
use App\Service\Leave\LongMaladieService;
|
||||
use App\Service\Leave\SuspensionDaysCalculator;
|
||||
@@ -35,6 +38,7 @@ use Throwable;
|
||||
final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
{
|
||||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||||
private const int FORFAIT_STANDARD_CP_DAYS = 25;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS = 25.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS = 5.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
|
||||
@@ -60,6 +64,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private EmployeeContractPhaseResolver $phaseResolver,
|
||||
private ExerciseYearResolver $exerciseYearResolver,
|
||||
string $dataStartDate = '',
|
||||
) {
|
||||
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||
@@ -86,14 +92,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||
}
|
||||
|
||||
$year = $this->resolveYear($employee);
|
||||
$phase = $this->resolveTargetPhase($employee);
|
||||
$year = $this->resolveYear($employee, $phase);
|
||||
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
$summary->dataStartDate = $this->dataStartDate;
|
||||
|
||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
||||
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||||
if (null === $yearSummary) {
|
||||
return $summary;
|
||||
}
|
||||
@@ -104,7 +111,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
||||
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
||||
if ($paidLeaveDays > 0.0) {
|
||||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
|
||||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||||
if (null === $yearSummary) {
|
||||
return $summary;
|
||||
}
|
||||
@@ -125,26 +132,47 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||||
|
||||
// Forfait : jours à travailler sur l'exercice.
|
||||
// Année pleine → cible contractuelle 218 ; les bonus week-end/férié et les jours
|
||||
// fractionnés sont des congés EN PLUS, ils ne réduisent pas la cible. Entrée en cours
|
||||
// d'année → jours ouvrés de la période − congés acquis de l'entrée (repos proratisés +
|
||||
// CP reportés), via yearSummary['acquiredDays'] (hors fractionnés/bonus). Ex. Grégory : 168 − 13 ≈ 155.
|
||||
if (LeaveRuleCode::FORFAIT_218->value === $summary->ruleCode) {
|
||||
$businessDaysInPeriod = $this->countBusinessDays($periodFrom, $periodTo, $this->buildRawPublicHolidayMap($periodFrom, $periodTo));
|
||||
$summary->forfaitWorkTargetDays = $this->computeForfaitWorkTargetDays(
|
||||
$businessDaysInPeriod,
|
||||
$this->isForfaitEntryYear($phase, $year),
|
||||
$yearSummary['acquiredDays'],
|
||||
);
|
||||
}
|
||||
|
||||
// La présence est bornée au début de contrat de l'employé : on ne compte pas comme
|
||||
// « présents » les jours ouvrés antérieurs à l'embauche (cas d'une entrée en cours
|
||||
// d'exercice, ex. CDD). Sans effet pour un employé présent depuis avant l'exercice,
|
||||
// ni pour le forfait (déjà capé au début de phase).
|
||||
$presenceFrom = $this->resolveEarliestContractStartWithinRange($employee, $periodFrom, $periodTo) ?? $periodFrom;
|
||||
|
||||
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
||||
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
||||
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
|
||||
$employee,
|
||||
$periodFrom,
|
||||
$presenceFrom,
|
||||
$periodTo,
|
||||
$n1AbsencesBudget
|
||||
);
|
||||
|
||||
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
|
||||
// accumulated from leave year start up to today (inclusive).
|
||||
// accumulated from contract start up to today (inclusive).
|
||||
$today = new DateTimeImmutable('today');
|
||||
$cappedTo = $today < $periodTo ? $today : $periodTo;
|
||||
$summary->presenceDaysToToday = $today < $periodFrom
|
||||
$summary->presenceDaysToToday = $today < $presenceFrom
|
||||
? 0.0
|
||||
: array_sum($this->computePresenceDaysByMonth(
|
||||
$employee,
|
||||
$periodFrom,
|
||||
$presenceFrom,
|
||||
$cappedTo,
|
||||
$n1AbsencesBudget
|
||||
));
|
||||
@@ -167,9 +195,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
* previousYearRemainingDays: float
|
||||
* }
|
||||
*/
|
||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
|
||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
|
||||
{
|
||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
||||
// Track whether a phase was provided explicitly. When the caller supplies $phase,
|
||||
// we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
|
||||
// (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
|
||||
// the pre-phase-cap behavior to avoid changing observable results for terminated
|
||||
// employees (the resolved fallback phase would otherwise unduly cap `to`).
|
||||
$applyPhaseEndCap = null !== $phase;
|
||||
$phase ??= $this->resolveCurrentPhase($employee);
|
||||
if (null === $phase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
|
||||
if ($targetYear < $firstYear) {
|
||||
$targetYear = $firstYear;
|
||||
}
|
||||
@@ -179,8 +218,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$targetSummary = null;
|
||||
|
||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
|
||||
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
|
||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
|
||||
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
|
||||
if (null === $leavePolicy) {
|
||||
if ($year === $targetYear) {
|
||||
return null;
|
||||
@@ -224,8 +263,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
}
|
||||
|
||||
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
|
||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
|
||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||
);
|
||||
@@ -233,7 +272,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$longMaladiePeriods = [];
|
||||
$longMaladieReductionFactor = 1.0;
|
||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
||||
&& 4 !== $employee->getContract()?->getWeeklyHours()
|
||||
&& 4 !== $phase->weeklyHours
|
||||
&& null !== $accrualCalculationEnd
|
||||
) {
|
||||
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
||||
@@ -387,6 +426,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return $start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Début de contrat le plus ancien chevauchant [$from, $to], capé à $from.
|
||||
*
|
||||
* NB : ne tient pas compte des trous entre deux périodes de contrat à l'intérieur de
|
||||
* l'intervalle (une période qui chevauche $from fixe l'ancre à $from même s'il existe
|
||||
* un trou ensuite). Suffisant pour borner la présence au début d'emploi ; un employé
|
||||
* avec un trou de contrat intra-exercice verrait les jours du trou comptés en présence.
|
||||
*/
|
||||
private function resolveEarliestContractStartWithinRange(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
@@ -420,16 +467,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return $earliest;
|
||||
}
|
||||
|
||||
private function resolveYear(Employee $employee): int
|
||||
private function resolveYear(Employee $employee, ContractPhase $phase): int
|
||||
{
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||
|
||||
if ('' === $raw) {
|
||||
$today = new DateTimeImmutable('today');
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return (int) $today->format('Y');
|
||||
// When a phaseId is explicitly provided, default to the year derived from
|
||||
// the phase's end date (or today if the phase is still current).
|
||||
if ($phaseIdProvided) {
|
||||
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||
|
||||
return $isForfait
|
||||
? (int) $reference->format('Y')
|
||||
: $this->resolveCurrentLeaveYear($reference);
|
||||
}
|
||||
|
||||
return $this->resolveCurrentLeaveYear($today);
|
||||
$today = new DateTimeImmutable('today');
|
||||
|
||||
return $isForfait
|
||||
? (int) $today->format('Y')
|
||||
: $this->resolveCurrentLeaveYear($today);
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||
@@ -441,9 +501,144 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||
}
|
||||
|
||||
// When a phaseId is explicit, silently clamp the requested year to the
|
||||
// first/last exercise covered by the phase.
|
||||
if ($phaseIdProvided) {
|
||||
$year = $this->clampYearToPhase($year, $phase, $isForfait);
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
|
||||
{
|
||||
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
|
||||
: null;
|
||||
|
||||
if ($year < $firstYear) {
|
||||
return $firstYear;
|
||||
}
|
||||
if (null !== $lastYear && $year > $lastYear) {
|
||||
return $lastYear;
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||
{
|
||||
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||
if ([] === $phases) {
|
||||
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||
}
|
||||
|
||||
if (null === $raw || '' === (string) $raw) {
|
||||
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->isCurrent) {
|
||||
return $phase;
|
||||
}
|
||||
}
|
||||
|
||||
return $phases[0];
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||
}
|
||||
$phaseId = (int) $raw;
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->id === $phaseId) {
|
||||
return $phase;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||
}
|
||||
|
||||
private function resolveCurrentPhase(Employee $employee): ?ContractPhase
|
||||
{
|
||||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||
if ([] === $phases) {
|
||||
return null;
|
||||
}
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->isCurrent) {
|
||||
return $phase;
|
||||
}
|
||||
}
|
||||
|
||||
return $phases[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase dont la date de début est la plus proche en deçà de celle de $phase
|
||||
* (la phase qui précède immédiatement). Null si $phase est la première.
|
||||
*/
|
||||
private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
|
||||
{
|
||||
$prior = null;
|
||||
foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
|
||||
if ($candidate->startDate >= $phase->startDate) {
|
||||
continue;
|
||||
}
|
||||
if (null === $prior || $candidate->startDate > $prior->startDate) {
|
||||
$prior = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $prior;
|
||||
}
|
||||
|
||||
/**
|
||||
* CP à reporter d'une phase non-forfait vers une entrée en FORFAIT : jours ouvrés
|
||||
* NETS (acquis + en cours d'acquisition − jours ouvrés posés) + samedis BRUTS (acquis,
|
||||
* sans déduction des samedis posés). Retourne 0 si aucune phase précédente (nouvel
|
||||
* embauché → cas 2) ou si la précédente est elle-même un FORFAIT (ré-embauche / double
|
||||
* transition forfait — pas de report CP non-forfait à reprendre).
|
||||
*
|
||||
* Composition du retour (clés de computeYearSummary, branche CDI_CDD_NON_FORFAIT) :
|
||||
* remainingDays : acquis (report N-1) restant après jours ouvrés posés
|
||||
* accruingDays : généré de l'exercice restant, NET des jours posés en débordement
|
||||
* (= remainingGenerated + remainingGeneratedSaturdays)
|
||||
* remainingSaturdays : samedis acquis (report N-1) restants
|
||||
* + takenSaturdays : ré-ajout des samedis posés (règle métier ci-dessous). Invariant :
|
||||
* comme accruingDays a déjà déduit les samedis posés en débordement,
|
||||
* ce ré-ajout laisse le solde samedi BRUT (généré + acquis), pas net.
|
||||
*
|
||||
* Règle (validée comptable) : seuls les congés en JOURS OUVRÉS déjà posés réduisent
|
||||
* le report ; les SAMEDIS déjà posés ne le réduisent pas. computeYearSummary déduit
|
||||
* tous les congés posés (samedis inclus), d'où le ré-ajout de takenSaturdays.
|
||||
* Ex. Grégory : 12 acquis − 5 jours ouvrés posés = 7 (le samedi posé reste crédité).
|
||||
*
|
||||
* Les jours fractionnés (fractionedDays, ajustement manuel ajouté par provide() à
|
||||
* l'affichage) sont volontairement EXCLUS : on ne reporte que le solde CP acquis/généré
|
||||
* de la phase précédente, pas les bonus de fractionnement.
|
||||
*/
|
||||
private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
|
||||
{
|
||||
$prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
|
||||
if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$reference = $prior->endDate ?? new DateTimeImmutable('today');
|
||||
$priorYear = $this->exerciseYearResolver->forDate($reference, false);
|
||||
|
||||
$summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
|
||||
if (null === $summary) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $summary['remainingDays']
|
||||
+ $summary['accruingDays']
|
||||
+ $summary['remainingSaturdays']
|
||||
+ $summary['takenSaturdays'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ContractSuspension> $suspensions
|
||||
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||||
@@ -518,14 +713,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
int $year,
|
||||
DateTimeImmutable $periodEnd,
|
||||
Employee $employee,
|
||||
?DateTimeImmutable $asOfDate = null
|
||||
ContractPhase $phase,
|
||||
?DateTimeImmutable $asOfDate = null,
|
||||
bool $applyPhaseEndCap = true
|
||||
): ?DateTimeImmutable {
|
||||
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||||
? (int) $reference->format('Y')
|
||||
: $this->resolveCurrentLeaveYear($reference);
|
||||
|
||||
if ($year < $currentYear) {
|
||||
// When viewing a closed phase explicitly, treat its end date as the reference cutoff:
|
||||
// accrual is bounded to the phase end, never running to "today".
|
||||
// Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
|
||||
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
|
||||
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
|
||||
} elseif ($year < $currentYear) {
|
||||
$end = $periodEnd;
|
||||
} elseif ($year > $currentYear) {
|
||||
$end = null;
|
||||
@@ -538,12 +740,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||
}
|
||||
|
||||
// Cap at contract end date if the employee has left.
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||
$end = $contractEnd;
|
||||
// Cap at contract end date if the employee has left (only meaningful when
|
||||
// viewing the current phase; closed phases are already capped above).
|
||||
// Legacy callers (no explicit phase) always evaluate this branch to mimic
|
||||
// the pre-phase behavior, which relied on getCurrentContractEndDate().
|
||||
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||
$end = $contractEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +760,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private function resolveTakenCalculationEndDate(
|
||||
DateTimeImmutable $periodEnd,
|
||||
Employee $employee,
|
||||
?DateTimeImmutable $asOfDate = null
|
||||
ContractPhase $phase,
|
||||
?DateTimeImmutable $asOfDate = null,
|
||||
bool $applyPhaseEndCap = true
|
||||
): ?DateTimeImmutable {
|
||||
$end = $periodEnd;
|
||||
|
||||
@@ -561,12 +770,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$end = $asOfDate;
|
||||
}
|
||||
|
||||
// Cap at contract end date if the employee has left.
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||
$end = $contractEnd;
|
||||
// Closed phase: cap taken-absence accounting at the phase end.
|
||||
// Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
|
||||
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
|
||||
$end = $phase->endDate;
|
||||
}
|
||||
|
||||
// Legacy callers (no explicit phase) always use the live contract end date,
|
||||
// mirroring the pre-phase implementation.
|
||||
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||
$end = $contractEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,10 +802,41 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
* splitSaturdays: bool
|
||||
* }
|
||||
*/
|
||||
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||
{
|
||||
$type = $employee->getContract()?->getType();
|
||||
$type = $phase->contractType;
|
||||
if (ContractType::FORFAIT === $type) {
|
||||
$year = (int) $from->format('Y'); // période forfait = année civile
|
||||
|
||||
// Entrée en FORFAIT en cours d'année : repos proratisés + CP nets reportés de
|
||||
// la phase précédente, au lieu de max(0, businessDays − 218) qui donnerait 0.
|
||||
if ($this->isForfaitEntryYear($phase, $year)) {
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||
$yearEnd = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||
$rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);
|
||||
|
||||
$businessDaysYear = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
|
||||
$businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);
|
||||
|
||||
$repoDays = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
|
||||
$carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);
|
||||
|
||||
// NB : le bonus week-end/férié travaillé (bonusDays du chemin année pleine)
|
||||
// n'est volontairement PAS ajouté ici. L'acquis de l'année d'entrée = repos
|
||||
// proratisés + CP reportés (règle comptable). À revoir si la RH veut créditer
|
||||
// le travail week-end/férié posé pendant la période forfait partielle.
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||||
'acquiredDays' => $repoDays + $carriedCp,
|
||||
'acquiredSaturdays' => 0.0,
|
||||
'accrualPerMonth' => 0.0,
|
||||
'saturdayAccrualPerMonth' => 0.0,
|
||||
'countOnlyCp' => false,
|
||||
'splitSaturdays' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Année pleine : calcul 218 existant (INCHANGÉ).
|
||||
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
||||
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
||||
// the 218-day legal target).
|
||||
@@ -615,12 +864,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
];
|
||||
}
|
||||
|
||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
||||
// Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
|
||||
$nature = $phase->contractNature;
|
||||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$weeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||
$weeklyHours = $phase->weeklyHours;
|
||||
if (4 === $weeklyHours) {
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||
@@ -644,6 +894,55 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
|
||||
*
|
||||
* Repos année pleine = jours_ouvrés_année − 218 (cible travaillée) − 25 (CP standard).
|
||||
* Pour 2026 : 252 − 218 − 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
|
||||
*/
|
||||
private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
|
||||
{
|
||||
if ($businessDaysYear <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);
|
||||
|
||||
return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jours à travailler d'un forfait sur l'exercice consulté.
|
||||
*
|
||||
* - Année pleine : cible contractuelle 218 (bornée aux jours ouvrés de la période si
|
||||
* celle-ci en compte moins). Les bonus week-end/férié et jours fractionnés sont des
|
||||
* congés EN PLUS et ne réduisent pas la cible.
|
||||
* - Entrée en cours d'année : jours ouvrés de la période − congés acquis de l'entrée
|
||||
* (repos proratisés + CP reportés, hors fractionnés/bonus). Ex. Grégory : 168 − 13 ≈ 155.
|
||||
*/
|
||||
private function computeForfaitWorkTargetDays(int $businessDaysInPeriod, bool $isEntryYear, float $entryAcquiredDays): float
|
||||
{
|
||||
if ($isEntryYear) {
|
||||
return $businessDaysInPeriod - $entryAcquiredDays;
|
||||
}
|
||||
|
||||
return (float) min($businessDaysInPeriod, self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
|
||||
* (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
|
||||
*/
|
||||
private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
|
||||
{
|
||||
if (ContractType::FORFAIT !== $phase->contractType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $phase->startDate->format('Y') === $year
|
||||
&& '01-01' !== $phase->startDate->format('m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<string, string> $publicHolidays pre-built map (built if null)
|
||||
*/
|
||||
@@ -815,13 +1114,33 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
private function resolvePeriodBounds(Employee $employee, int $year): array
|
||||
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
|
||||
{
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return $this->resolveForfaitYearBounds($employee, $year);
|
||||
if (ContractType::FORFAIT === $phase->contractType) {
|
||||
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
|
||||
|
||||
// For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
|
||||
// is calendar-year scoped and only counts the FORFAIT portion of the year.
|
||||
if ($phase->startDate > $from) {
|
||||
$from = $phase->startDate;
|
||||
}
|
||||
} else {
|
||||
[$from, $to] = $this->resolveLeavePeriodBounds($year);
|
||||
|
||||
// For non-forfait, do NOT cap from at phase.startDate: CP accrual is
|
||||
// annual (Juin→Mai) and continuous across signature changes within the
|
||||
// same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
|
||||
// The contract-entry-date cap is handled by resolveEffectivePeriodStart().
|
||||
}
|
||||
|
||||
return $this->resolveLeavePeriodBounds($year);
|
||||
// End cap applies to both modes. Skipped when the phase was not explicitly
|
||||
// provided (legacy callers) to preserve pre-phase-cap behavior for
|
||||
// terminated employees.
|
||||
if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
|
||||
$to = $phase->endDate;
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -839,24 +1158,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
||||
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||||
{
|
||||
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||
|
||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||
$from = $contractStart;
|
||||
// When viewing the current phase, prefer the live "current contract" dates
|
||||
// for backward compat with existing tests/usage. Closed phases rely on the
|
||||
// generic cap applied in resolvePeriodBounds().
|
||||
if ($phase->isCurrent) {
|
||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||
$from = $contractStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||
$to = $contractEnd;
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||
$to = $contractEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,16 +1202,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
}
|
||||
|
||||
private function resolveFirstComputationYear(Employee $employee): int
|
||||
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
|
||||
{
|
||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
||||
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||
$fallbackYear = $isForfait
|
||||
? (int) new DateTimeImmutable('today')->format('Y')
|
||||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||||
|
||||
// Do not go before the exercice containing $phase->startDate.
|
||||
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||
|
||||
$history = $employee->getContractHistory();
|
||||
if ([] === $history) {
|
||||
return $fallbackYear;
|
||||
return max($phaseFirstYear, $fallbackYear);
|
||||
}
|
||||
|
||||
$oldestStartDate = null;
|
||||
@@ -903,22 +1230,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
|
||||
if (null === $oldestStartDate) {
|
||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||
|
||||
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||
return max($phaseFirstYear, $candidate);
|
||||
}
|
||||
|
||||
$firstYear = $isForfait
|
||||
? (int) $oldestStartDate->format('Y')
|
||||
: ((int) $oldestStartDate->format('n') >= 6
|
||||
? (int) $oldestStartDate->format('Y') + 1
|
||||
: (int) $oldestStartDate->format('Y'));
|
||||
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
|
||||
|
||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||||
return $oldestBalanceYear;
|
||||
$firstYear = $oldestBalanceYear;
|
||||
}
|
||||
|
||||
return $firstYear;
|
||||
return max($phaseFirstYear, $firstYear);
|
||||
}
|
||||
|
||||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||
|
||||
@@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use DateTimeImmutable;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
@@ -24,6 +26,9 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
private EmployeeContractPhaseResolver $phaseResolver,
|
||||
private ClockInterface $clock,
|
||||
private ExerciseYearResolver $exerciseYearResolver,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||
@@ -48,6 +53,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
|
||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||
|
||||
$this->assertYearAllowedForPayment($employee, $year);
|
||||
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||
|
||||
if (null === $payment) {
|
||||
@@ -83,10 +90,33 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
|
||||
private function resolveCurrentExerciseYear(): int
|
||||
{
|
||||
$today = new DateTimeImmutable('today');
|
||||
$year = (int) $today->format('Y');
|
||||
$month = (int) $today->format('n');
|
||||
return $this->exerciseYearResolver->forDate($this->clock->now());
|
||||
}
|
||||
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
/**
|
||||
* Allow payment when the requested exercise is either the current one
|
||||
* or the last exercise of a closed contract phase (the one containing
|
||||
* the phase end date). Reject any other exercise (past or future).
|
||||
*/
|
||||
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
||||
{
|
||||
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||
if ($year === $currentExerciseYear) {
|
||||
return;
|
||||
}
|
||||
|
||||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->isCurrent || null === $phase->endDate) {
|
||||
continue;
|
||||
}
|
||||
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeRttSummary;
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\RttMonthPayment;
|
||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||
@@ -17,6 +18,8 @@ use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -38,6 +41,8 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private RttRecoveryComputationService $rttRecoveryService,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private EmployeeContractPhaseResolver $phaseResolver,
|
||||
private ExerciseYearResolver $exerciseYearResolver,
|
||||
string $rttStartDate = '',
|
||||
) {
|
||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||
@@ -64,12 +69,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||
}
|
||||
|
||||
$year = $this->resolveYear();
|
||||
$phase = $this->resolveTargetPhase($employee);
|
||||
$year = $this->resolveYear($phase);
|
||||
$today = new DateTimeImmutable('today');
|
||||
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||
$weekRanges = array_map(
|
||||
|
||||
// Cap periodTo at the phase endDate for closed phases so the RTT table does
|
||||
// not extend past the date the phase ended.
|
||||
// Do NOT cap periodFrom at phase.startDate: keep the full exercise
|
||||
// displayed so weeks before the employee's hire (or before a past phase
|
||||
// started) appear at 0, matching the previous behavior. Weeks outside the
|
||||
// contract range contribute 0 minutes to the cumul naturally (no contract
|
||||
// ⇒ no reference, no worked hours).
|
||||
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
|
||||
$periodTo = $phase->endDate;
|
||||
}
|
||||
|
||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||
$weekRanges = array_map(
|
||||
static fn (array $week): array => [
|
||||
'weekNumber' => (int) $week['weekNumber'],
|
||||
'start' => $week['start'],
|
||||
@@ -96,6 +114,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
}
|
||||
}
|
||||
|
||||
// For a closed phase: cap the week-computation limit at the phase end date,
|
||||
// so weeks beyond the phase are not counted.
|
||||
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) {
|
||||
$limitDate = $phase->endDate;
|
||||
}
|
||||
|
||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||
|
||||
@@ -213,10 +237,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveYear(): int
|
||||
private function resolveYear(ContractPhase $phase): int
|
||||
{
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||
|
||||
if ('' === $raw) {
|
||||
// When a phaseId is explicitly provided, default to the exercise year derived from
|
||||
// the phase's end date (or today if the phase is still current).
|
||||
if ($phaseIdProvided) {
|
||||
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||
|
||||
return $this->resolveCurrentExerciseYear($reference);
|
||||
}
|
||||
|
||||
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
||||
}
|
||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||
@@ -228,9 +263,64 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||
}
|
||||
|
||||
// When a phaseId is explicit, silently clamp the requested year to the
|
||||
// first/last exercise covered by the phase.
|
||||
if ($phaseIdProvided) {
|
||||
$year = $this->clampYearToPhase($year, $phase);
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function clampYearToPhase(int $year, ContractPhase $phase): int
|
||||
{
|
||||
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate);
|
||||
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||
? $this->exerciseYearResolver->forDate($phase->endDate)
|
||||
: null;
|
||||
|
||||
if ($year < $firstYear) {
|
||||
return $firstYear;
|
||||
}
|
||||
if (null !== $lastYear && $year > $lastYear) {
|
||||
return $lastYear;
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||
{
|
||||
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||
if ([] === $phases) {
|
||||
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||
}
|
||||
|
||||
if (null === $raw || '' === (string) $raw) {
|
||||
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->isCurrent) {
|
||||
return $phase;
|
||||
}
|
||||
}
|
||||
|
||||
return $phases[0];
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||
}
|
||||
$phaseId = (int) $raw;
|
||||
foreach ($phases as $phase) {
|
||||
if ($phase->id === $phaseId) {
|
||||
return $phase;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||
}
|
||||
|
||||
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
||||
{
|
||||
$year = (int) $today->format('Y');
|
||||
|
||||
Reference in New Issue
Block a user