feat : ajout des jours fériés sur l'export PDF des heures

Affiche désormais une ligne dédiée pour chaque jour férié (Lun-Ven) avec la mention "Férié : {nom}" et le total créditant les heures contractuelles, comme sur l'écran Heures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:21:59 +02:00
parent eacf52425a
commit 78f73ed2e9
5 changed files with 103 additions and 23 deletions

View File

@@ -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) - Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc) - 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é - 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 ### 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` - 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 - Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0 - 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 ### Nom du fichier

View File

@@ -589,7 +589,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ 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: '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)' },
], ],
}, },
], ],

View File

@@ -14,8 +14,10 @@ use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Throwable;
class YearlyHoursExportBuilder class YearlyHoursExportBuilder
{ {
@@ -25,6 +27,8 @@ class YearlyHoursExportBuilder
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private PublicHolidayServiceInterface $publicHolidayService,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {} ) {}
/** /**
@@ -56,6 +60,8 @@ class YearlyHoursExportBuilder
$absences = $this->absenceRepository->findForPrint($from, $to, $employees); $absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours); $workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days); $absenceMap = $this->buildAbsenceMap($absences, $days);
@@ -71,6 +77,8 @@ class YearlyHoursExportBuilder
$driverMap[$employeeId] ?? [], $driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [],
$absenceData, $absenceData,
$workDaysMap[$employeeId] ?? [],
$holidayMap,
); );
if ([] === $segments) { if ([] === $segments) {
@@ -205,6 +213,9 @@ class YearlyHoursExportBuilder
} }
/** /**
* @param array<string, ?array<int, int>> $workDaysMinutesByDate
* @param array<string, string> $holidayMap
*
* @return list<array{mode: string, contractName: ?string, rows: list<array>}> * @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/ */
private function buildSegments( private function buildSegments(
@@ -213,6 +224,8 @@ class YearlyHoursExportBuilder
array $driverByDate, array $driverByDate,
array $workHoursByDate, array $workHoursByDate,
array $absenceData, array $absenceData,
array $workDaysMinutesByDate,
array $holidayMap,
): array { ): array {
$segments = []; $segments = [];
$currentMode = null; $currentMode = null;
@@ -222,7 +235,8 @@ class YearlyHoursExportBuilder
$firstDataDate = null; $firstDataDate = null;
foreach ($days as $date) { foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null) $hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false); || ($absenceData['hasDayAbsence'][$date] ?? false)
|| isset($holidayMap[$date]);
if ($hasRow) { if ($hasRow) {
$firstDataDate = $date; $firstDataDate = $date;
@@ -241,14 +255,16 @@ class YearlyHoursExportBuilder
continue; continue;
} }
$contract = $contractsByDate[$date] ?? null; $contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false; $isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null; $wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); $hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N'); $holidayLabel = $holidayMap[$date] ?? null;
$isWeekend = $isoDay >= 6; $isHoliday = null !== $holidayLabel;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
if (!$hasData && !$isWeekend) { if (!$hasData && !$isWeekend && !$isHoliday) {
continue; continue;
} }
@@ -275,10 +291,18 @@ class YearlyHoursExportBuilder
$creditedMinutes = $absenceData['credited'][$date] ?? 0; $creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null; $absenceLabel = $absenceData['labels'][$date] ?? null;
$hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
new DateTimeImmutable($date),
$hasAbsence,
$workDaysMinutesByDate[$date] ?? null,
);
$row = [ $row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel, 'absenceLabel' => $absenceLabel,
'holidayLabel' => $holidayLabel,
'isWeekend' => $isWeekend, 'isWeekend' => $isWeekend,
]; ];
@@ -297,6 +321,9 @@ class YearlyHoursExportBuilder
$nightMin = $wh?->getNightHoursMinutes() ?? 0; $nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0; $workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes; $totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $this->formatMinutes($dayMin); $row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin); $row['nightHours'] = $this->formatMinutes($nightMin);
@@ -305,6 +332,10 @@ class YearlyHoursExportBuilder
} else { } else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes); $metrics->addCreditedMinutes($creditedMinutes);
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['morningFrom'] = $wh?->getMorningFrom() ?? ''; $row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? ''; $row['morningTo'] = $wh?->getMorningTo() ?? '';
@@ -312,7 +343,7 @@ class YearlyHoursExportBuilder
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? ''; $row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? ''; $row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? ''; $row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes); $row['total'] = $this->formatMinutes($totalMin);
} }
$currentRows[] = $row; $currentRows[] = $row;
@@ -329,6 +360,29 @@ class YearlyHoursExportBuilder
return $segments; return $segments;
} }
/**
* @return array<string, string> 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 private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{ {
if ($isDriver) { if ($isDriver) {

View File

@@ -76,11 +76,14 @@
td { font-size: 9px; } td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; } td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; } 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.time { text-align: center; }
td.presence { text-align: center; } td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; } td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; } tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; } tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer { .signature-footer {
page-break-inside: avoid; page-break-inside: avoid;
@@ -165,9 +168,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td> <td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td> <td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td> <td class="total">{{ row.total }}</td>
@@ -189,9 +195,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td> <td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td> <td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td> <td class="time">{{ row.workshopHours }}</td>
@@ -217,9 +226,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td> <td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td> <td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td> <td class="time">{{ row.afternoonFrom }}</td>

View File

@@ -65,11 +65,14 @@
td { font-size: 9px; } td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; } td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; } 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.time { text-align: center; }
td.presence { text-align: center; } td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; } td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; } tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; } tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer { .signature-footer {
page-break-inside: avoid; page-break-inside: avoid;
@@ -151,9 +154,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td> <td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td> <td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td> <td class="total">{{ row.total }}</td>
@@ -175,9 +181,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td> <td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td> <td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td> <td class="time">{{ row.workshopHours }}</td>
@@ -203,9 +212,12 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}"> <tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td> <td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td> <td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td> <td class="time">{{ row.afternoonFrom }}</td>