b5bd4db5f1
Auto Tag Develop / tag (push) Successful in 9s
## Résumé Nouvel export PDF **Contingent heures de nuit** dans le drawer Export de la liste employés. - PDF **A4 paysage** : lignes = employés (groupés par site, triés displayOrder/nom/prénom), colonnes = 12 mois civils, chaque mois avec 2 sous-colonnes **H.nuit** et **N.jours**. - Heures de nuit = minutes dans la fenêtre **21h→6h** via un service partagé `NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` — duplication supprimée, sans changement de comportement). - **Conducteurs inclus** via `WorkHour.nightHoursMinutes`. Statut conducteur résolu par date. - **N.jours** = nb de jours où les minutes de nuit ≥ 240 (4h). Aucun crédit absence/férié. - Périmètre via `EmployeeRepository::findScoped` (admin → tous, chef de site → ses sites), endpoint `GET /night-hours-contingent/print?year=YYYY` (`ROLE_USER`). - Sélecteur d'année (année civile). Colonne Nom calibrée, séparateurs de mois épais. ## Composants - Service `NightHoursCalculator`, builder `NightContingentExportBuilder`, DTO `NightContingentRow` - Provider `NightHoursContingentPrintProvider` + opération API `NightHoursContingentPrint` - Gabarit `templates/night-hours-contingent/print.html.twig` - Option frontend dans `frontend/pages/employees/index.vue` - Docs : `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts` ## Tests - Nouveaux tests unitaires : `NightHoursCalculatorTest` (fenêtre 21h→6h, passage minuit, bornes), `NightContingentExportBuilderTest` (agrégation mensuelle, règle ≥4h=1j, conducteur, cas sans heures) - Suite complète : **208 tests OK** - Rendu PDF validé visuellement (Twig→Dompdf) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #28 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
596 lines
24 KiB
PHP
596 lines
24 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Service\Rtt;
|
||
|
||
use App\Dto\Rtt\WeekRecoveryDetail;
|
||
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\DailyReferenceMinutesResolver;
|
||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||
use App\Service\WorkHours\NightHoursCalculator;
|
||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||
use DateTimeImmutable;
|
||
|
||
final readonly class RttRecoveryComputationService
|
||
{
|
||
private ?DateTimeImmutable $rttStartDate;
|
||
|
||
public function __construct(
|
||
private WorkHourRepository $workHourRepository,
|
||
private AbsenceRepository $absenceRepository,
|
||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||
private EmployeeContractResolver $contractResolver,
|
||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||
private SolidarityDayResolver $solidarityDayResolver,
|
||
private NightHoursCalculator $nightHoursCalculator,
|
||
string $rttStartDate = '',
|
||
) {
|
||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||
}
|
||
|
||
/**
|
||
* @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{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) {
|
||
$weeks[] = [
|
||
'weekNumber' => (int) $effectiveStart->format('W'),
|
||
'start' => $start,
|
||
'end' => $end,
|
||
];
|
||
}
|
||
$weekStart = $weekStart->modify('+7 days');
|
||
}
|
||
|
||
return $weeks;
|
||
}
|
||
|
||
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear, ?DateTimeImmutable $limitDate = null): WeekRecoveryDetail
|
||
{
|
||
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
||
$weeks = $this->buildWeeksForExercise($from, $to);
|
||
$weekRanges = array_map(
|
||
static fn (array $week): array => [
|
||
'weekNumber' => (int) $week['weekNumber'],
|
||
'start' => $week['start'],
|
||
'end' => $week['end'],
|
||
],
|
||
$weeks
|
||
);
|
||
|
||
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $limitDate);
|
||
|
||
$total = new WeekRecoveryDetail();
|
||
foreach ($byWeek as $detail) {
|
||
$total = new WeekRecoveryDetail(
|
||
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
|
||
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
|
||
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
|
||
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
|
||
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
|
||
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
|
||
);
|
||
}
|
||
|
||
return $total;
|
||
}
|
||
|
||
/**
|
||
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
|
||
*
|
||
* @return array<string, WeekRecoveryDetail>
|
||
*/
|
||
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);
|
||
$workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$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 = [];
|
||
$hasAbsenceByDate = [];
|
||
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);
|
||
if ($absentMorning || $absentAfternoon) {
|
||
$hasAbsenceByDate[$date] = true;
|
||
}
|
||
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
|
||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||
}
|
||
}
|
||
|
||
$results = [];
|
||
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||
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] = new WeekRecoveryDetail();
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
|
||
$results[$weekKey] = new WeekRecoveryDetail();
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($this->rttStartDate instanceof DateTimeImmutable && $effectiveEnd < $this->rttStartDate) {
|
||
$results[$weekKey] = new WeekRecoveryDetail();
|
||
|
||
continue;
|
||
}
|
||
|
||
$weekDays = [];
|
||
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
|
||
$weekDays[] = $cursor->format('Y-m-d');
|
||
}
|
||
|
||
$weeklyTotalMinutes = 0;
|
||
$dailyWorkedMinutes = [];
|
||
$employeeContractsByDate = [];
|
||
foreach ($weekDays as $date) {
|
||
$contractAtDate = $contractsByDate[$employeeId][$date] ?? null;
|
||
$employeeContractsByDate[$date] = $contractAtDate;
|
||
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
|
||
continue;
|
||
}
|
||
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
|
||
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
|
||
$effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
|
||
$contractAtDate,
|
||
new DateTimeImmutable($date),
|
||
$metrics->totalMinutes,
|
||
$hasAbsenceByDate[$date] ?? false,
|
||
$workDaysByDate[$employeeId][$date] ?? null,
|
||
);
|
||
$weeklyTotalMinutes += $effectiveMinutes;
|
||
$dailyWorkedMinutes[$date] = $effectiveMinutes;
|
||
}
|
||
|
||
if ([] === $weekDays) {
|
||
$results[$weekKey] = new WeekRecoveryDetail();
|
||
|
||
continue;
|
||
}
|
||
|
||
$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(
|
||
$weekAnchorContract?->getName(),
|
||
$weekAnchorContract?->getTrackingMode(),
|
||
$weekAnchorContract?->getWeeklyHours()
|
||
);
|
||
$isCustomContract = ContractType::CUSTOM === $weekContractType;
|
||
$overtimeReferenceMinutes = $isCustomContract
|
||
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
||
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||
// Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 %
|
||
// (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu
|
||
// de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %.
|
||
$overtime50StartMinutes = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract);
|
||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||
? 0
|
||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||
|
||
foreach ($solidarityDates as $solidarityDate) {
|
||
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||
continue;
|
||
}
|
||
|
||
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||
// Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
|
||
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||
$contractAtSolidarity?->getWeeklyHours(),
|
||
$solidarityIsoDay,
|
||
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||
);
|
||
|
||
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||
$contractAtSolidarity,
|
||
$solidarityExpected,
|
||
$dailyWorkedMinutes[$solidarityDate],
|
||
);
|
||
}
|
||
|
||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||
|
||
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||
$isWeekPresenceTracking,
|
||
$disableOvertimeBonuses,
|
||
$isCustomContract,
|
||
$weeklyOvertimeTotalMinutes,
|
||
$rawBase25,
|
||
$rawBase50,
|
||
$dailyWorkedMinutes,
|
||
);
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||
* des bandes d'heures sup brutes.
|
||
*
|
||
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||
* provider ne draine pas les tranches 25/50.
|
||
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||
*
|
||
* @param array<string, int> $dailyMinutes
|
||
*/
|
||
private function buildWeekRecoveryDetail(
|
||
bool $isPresence,
|
||
bool $disableBonuses,
|
||
bool $isCustom,
|
||
int $overtimeTotalMinutes,
|
||
int $rawBase25,
|
||
int $rawBase50,
|
||
array $dailyMinutes,
|
||
): WeekRecoveryDetail {
|
||
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||
|
||
$base25 = $noBands ? 0 : $rawBase25;
|
||
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||
$base50 = $noBands ? 0 : $rawBase50;
|
||
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||
|
||
if ($isPresence || $disableBonuses) {
|
||
$totalMinutes = 0;
|
||
} elseif ($isCustom) {
|
||
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||
} else {
|
||
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||
}
|
||
|
||
return new WeekRecoveryDetail(
|
||
overtimeMinutes: $overtimeTotalMinutes,
|
||
base25Minutes: $base25,
|
||
bonus25Minutes: $bonus25,
|
||
base50Minutes: $base50,
|
||
bonus50Minutes: $bonus50,
|
||
totalMinutes: $totalMinutes,
|
||
dailyMinutes: $dailyMinutes,
|
||
isFlatRecovery: $isCustom,
|
||
);
|
||
}
|
||
|
||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||
{
|
||
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
|
||
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
|
||
|
||
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
|
||
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
|
||
|
||
return new WorkMetrics(
|
||
dayMinutes: $driverDay + $driverWorkshop,
|
||
nightMinutes: $driverNight,
|
||
totalMinutes: $totalMinutes,
|
||
);
|
||
}
|
||
|
||
$ranges = [
|
||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||
];
|
||
|
||
$totalMinutes = 0;
|
||
foreach ($ranges as [$from, $to]) {
|
||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||
}
|
||
|
||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||
$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);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $days
|
||
* @param array<string, ?Contract> $contractsByDate
|
||
*/
|
||
private function computeWeeklyCustomReferenceMinutes(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();
|
||
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
|
||
}
|
||
|
||
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];
|
||
}
|
||
|
||
/**
|
||
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||
*
|
||
* @return list<string> dates au format 'Y-m-d'
|
||
*/
|
||
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||
{
|
||
$dates = [];
|
||
$firstYear = (int) $from->format('Y');
|
||
$lastYear = (int) $to->format('Y');
|
||
|
||
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||
if ($candidate >= $from && $candidate <= $to) {
|
||
$dates[] = $candidate->format('Y-m-d');
|
||
}
|
||
}
|
||
|
||
return $dates;
|
||
}
|
||
|
||
/**
|
||
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||
*
|
||
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||
*/
|
||
private function computeSolidarityDeficitAdjustment(
|
||
?Contract $contractAtSolidarity,
|
||
int $expectedMinutes,
|
||
int $workedMinutes,
|
||
): int {
|
||
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||
$type = ContractType::resolve(
|
||
$contractAtSolidarity?->getName(),
|
||
$contractAtSolidarity?->getTrackingMode(),
|
||
$weeklyHours,
|
||
);
|
||
|
||
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||
return 0;
|
||
}
|
||
|
||
$prorata = (int) round($weeklyHours * 12);
|
||
|
||
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||
}
|
||
|
||
/**
|
||
* @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();
|
||
// 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);
|
||
}
|
||
|
||
return $total;
|
||
}
|
||
|
||
/**
|
||
* Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine :
|
||
* 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté
|
||
* pour obtenir le plafond 25 %/50 %.
|
||
*/
|
||
private function resolveOvertime25BandWidthMinutes(?Contract $contract): int
|
||
{
|
||
$hours = $contract?->getWeeklyHours();
|
||
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
||
|
||
return (43 - $startHours) * 60;
|
||
}
|
||
|
||
/**
|
||
* Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %.
|
||
* La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %.
|
||
*
|
||
* @return array{int, int} [base25Minutes, base50Minutes]
|
||
*/
|
||
private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array
|
||
{
|
||
$base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes);
|
||
$base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes);
|
||
|
||
return [$base25, $base50];
|
||
}
|
||
|
||
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
|
||
{
|
||
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
|
||
}
|
||
}
|