Files
SIRH/src/Service/Rtt/RttRecoveryComputationService.php
T
tristan a41bd632cf
Auto Tag Develop / tag (push) Successful in 11s
Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
## Correctifs RH (branche fix/retour-rh)

### Vue Jour (Heures)
- Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait.

### RTT — heures supplémentaires
- Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%.

### Récap salaire (PDF mensuel)
- Forfait : congés imputés **N-1** non affichés et comptés en présence.
- Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné).
- **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé).

### Exports heures annuelles (par salarié + tous)
- **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes).
- Samedis/dimanches en **gris plus foncé**.

### Panier de nuit
- **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire).

## Tests
- 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche).

## À noter (hors scope)
- L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-02 06:26:40 +00:00

508 lines
20 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\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,
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 = [];
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;
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25;
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50;
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
$totalMinutes = 0;
} elseif ($isCustomContract) {
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
} else {
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
}
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyWorkedMinutes,
);
}
return $results;
}
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;
$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 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];
}
/**
* @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);
}
}