diff --git a/doc/functional-rules.md b/doc/functional-rules.md index f694146..f142445 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -443,7 +443,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - Accessible depuis la fiche employé (bouton imprimante à droite du nom) - Ouvre un drawer pour choisir l'année (civile, Jan-Déc) - Génère un PDF avec le détail jour par jour des heures de l'employé -- Seuls les jours avec heures saisies ou absence sont affichés +- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés +- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie) ### Colonnes selon le mode de suivi @@ -461,6 +462,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours` - Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées - PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0 +- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie. ### Nom du fichier diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 4369181..f0a7bce 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -589,7 +589,7 @@ export const documentationSections: DocSection[] = [ requiredLevel: 'admin', blocks: [ { type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' }, - { type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année' }, + { type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' }, ], }, ], diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php index b3df9d5..4e1a377 100644 --- a/src/Service/WorkHours/YearlyHoursExportBuilder.php +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -14,8 +14,10 @@ use App\Enum\TrackingMode; use App\Repository\AbsenceRepository; use App\Repository\WorkHourRepository; use App\Service\Contracts\EmployeeContractResolver; +use App\Service\PublicHolidayServiceInterface; use DateInterval; use DateTimeImmutable; +use Throwable; class YearlyHoursExportBuilder { @@ -25,6 +27,8 @@ class YearlyHoursExportBuilder private EmployeeContractResolver $contractResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver, private WorkedHoursCreditPolicy $workedHoursCreditPolicy, + private PublicHolidayServiceInterface $publicHolidayService, + private HolidayVirtualHoursResolver $holidayVirtualHoursResolver, ) {} /** @@ -56,6 +60,8 @@ class YearlyHoursExportBuilder $absences = $this->absenceRepository->findForPrint($from, $to, $employees); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); + $workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); + $holidayMap = $this->buildHolidayMap($from, $to); $workHourMap = $this->buildWorkHourMap($workHours); $absenceMap = $this->buildAbsenceMap($absences, $days); @@ -71,6 +77,8 @@ class YearlyHoursExportBuilder $driverMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [], $absenceData, + $workDaysMap[$employeeId] ?? [], + $holidayMap, ); if ([] === $segments) { @@ -205,6 +213,9 @@ class YearlyHoursExportBuilder } /** + * @param array> $workDaysMinutesByDate + * @param array $holidayMap + * * @return list}> */ private function buildSegments( @@ -213,6 +224,8 @@ class YearlyHoursExportBuilder array $driverByDate, array $workHoursByDate, array $absenceData, + array $workDaysMinutesByDate, + array $holidayMap, ): array { $segments = []; $currentMode = null; @@ -222,7 +235,8 @@ class YearlyHoursExportBuilder $firstDataDate = null; foreach ($days as $date) { $hasRow = null !== ($workHoursByDate[$date] ?? null) - || ($absenceData['hasDayAbsence'][$date] ?? false); + || ($absenceData['hasDayAbsence'][$date] ?? false) + || isset($holidayMap[$date]); if ($hasRow) { $firstDataDate = $date; @@ -241,14 +255,16 @@ class YearlyHoursExportBuilder continue; } - $contract = $contractsByDate[$date] ?? null; - $isDriver = $driverByDate[$date] ?? false; - $wh = $workHoursByDate[$date] ?? null; - $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); - $isoDay = (int) new DateTimeImmutable($date)->format('N'); - $isWeekend = $isoDay >= 6; + $contract = $contractsByDate[$date] ?? null; + $isDriver = $driverByDate[$date] ?? false; + $wh = $workHoursByDate[$date] ?? null; + $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); + $holidayLabel = $holidayMap[$date] ?? null; + $isHoliday = null !== $holidayLabel; + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $isWeekend = $isoDay >= 6; - if (!$hasData && !$isWeekend) { + if (!$hasData && !$isWeekend && !$isHoliday) { continue; } @@ -275,10 +291,18 @@ class YearlyHoursExportBuilder $creditedMinutes = $absenceData['credited'][$date] ?? 0; $absenceLabel = $absenceData['labels'][$date] ?? null; + $hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false; + $virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit( + $contract, + new DateTimeImmutable($date), + $hasAbsence, + $workDaysMinutesByDate[$date] ?? null, + ); $row = [ 'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'absenceLabel' => $absenceLabel, + 'holidayLabel' => $holidayLabel, 'isWeekend' => $isWeekend, ]; @@ -297,6 +321,9 @@ class YearlyHoursExportBuilder $nightMin = $wh?->getNightHoursMinutes() ?? 0; $workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0; $totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes; + if ($virtualMinutes > $totalMin) { + $totalMin = $virtualMinutes; + } $row['dayHours'] = $this->formatMinutes($dayMin); $row['nightHours'] = $this->formatMinutes($nightMin); @@ -305,6 +332,10 @@ class YearlyHoursExportBuilder } else { $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); $metrics->addCreditedMinutes($creditedMinutes); + $totalMin = $metrics->totalMinutes; + if ($virtualMinutes > $totalMin) { + $totalMin = $virtualMinutes; + } $row['morningFrom'] = $wh?->getMorningFrom() ?? ''; $row['morningTo'] = $wh?->getMorningTo() ?? ''; @@ -312,7 +343,7 @@ class YearlyHoursExportBuilder $row['afternoonTo'] = $wh?->getAfternoonTo() ?? ''; $row['eveningFrom'] = $wh?->getEveningFrom() ?? ''; $row['eveningTo'] = $wh?->getEveningTo() ?? ''; - $row['total'] = $this->formatMinutes($metrics->totalMinutes); + $row['total'] = $this->formatMinutes($totalMin); } $currentRows[] = $row; @@ -329,6 +360,29 @@ class YearlyHoursExportBuilder return $segments; } + /** + * @return array Y-m-d => label + */ + private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $map = []; + $startYear = (int) $from->format('Y'); + $endYear = (int) $to->format('Y'); + + try { + for ($year = $startYear; $year <= $endYear; ++$year) { + $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year); + foreach ($holidays as $date => $label) { + $map[(string) $date] = (string) $label; + } + } + } catch (Throwable) { + return []; + } + + return $map; + } + private function resolveSegmentMode(string $trackingMode, bool $isDriver): string { if ($isDriver) { diff --git a/templates/employee-yearly-hours/print-all.html.twig b/templates/employee-yearly-hours/print-all.html.twig index 41eae5c..94fe44e 100644 --- a/templates/employee-yearly-hours/print-all.html.twig +++ b/templates/employee-yearly-hours/print-all.html.twig @@ -76,11 +76,14 @@ td { font-size: 9px; } td.date { text-align: left; font-weight: bold; } td.absence { text-align: left; color: #c00; } + td.absence .holiday { color: #0277bd; font-weight: 600; } + td.absence .holiday.with-absence { display: block; } td.time { text-align: center; } td.presence { text-align: center; } td.total { text-align: center; font-weight: bold; } tr.weekend td { background: #f3f3f3; color: #555; } tr.weekend td.date { color: #333; } + tr.holiday td { background: #e1f5fe; } .signature-footer { page-break-inside: avoid; @@ -165,9 +168,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.presentMorning ? 'X' : '' }} {{ row.presentAfternoon ? 'X' : '' }} {{ row.total }} @@ -189,9 +195,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.dayHours }} {{ row.nightHours }} {{ row.workshopHours }} @@ -217,9 +226,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.morningFrom }} {{ row.morningTo }} {{ row.afternoonFrom }} diff --git a/templates/employee-yearly-hours/print.html.twig b/templates/employee-yearly-hours/print.html.twig index 4843491..6c7d10d 100644 --- a/templates/employee-yearly-hours/print.html.twig +++ b/templates/employee-yearly-hours/print.html.twig @@ -65,11 +65,14 @@ td { font-size: 9px; } td.date { text-align: left; font-weight: bold; } td.absence { text-align: left; color: #c00; } + td.absence .holiday { color: #0277bd; font-weight: 600; } + td.absence .holiday.with-absence { display: block; } td.time { text-align: center; } td.presence { text-align: center; } td.total { text-align: center; font-weight: bold; } tr.weekend td { background: #f3f3f3; color: #555; } tr.weekend td.date { color: #333; } + tr.holiday td { background: #e1f5fe; } .signature-footer { page-break-inside: avoid; @@ -151,9 +154,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.presentMorning ? 'X' : '' }} {{ row.presentAfternoon ? 'X' : '' }} {{ row.total }} @@ -175,9 +181,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.dayHours }} {{ row.nightHours }} {{ row.workshopHours }} @@ -203,9 +212,12 @@ {% for row in segment.rows %} - + {{ row.date }} - {{ row.absenceLabel ?? '' }} + + {{ row.absenceLabel ?? '' }} + {% if row.holidayLabel %}Férié : {{ row.holidayLabel }}{% endif %} + {{ row.morningFrom }} {{ row.morningTo }} {{ row.afternoonFrom }}