[#SIRH] Récap salaire: scinder la colonne Heures payés en 25% / 50%

En-tête fusionné "Heures payés" (colspan=2) avec deux sous-colonnes 25% et
50% sous-jacentes. paid25Hours=base25Minutes, paid50Hours=base50Minutes
(bases seules, total inchangé vs l'ancienne colonne unique). buildRttPaymentMap
renvoie ['m25','m50'] par employé. Tableau passé à 20 colonnes (colspan ajustés).
PDF généré et validé sur données prod (A4 paysage, largeurs ~228mm).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:34:59 +02:00
parent 1486b770b1
commit c1ff46933a
4 changed files with 21 additions and 10 deletions
+1
View File
@@ -71,6 +71,7 @@
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. - **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
- **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé. - **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé.
- **Colonne « Heures payés » scindée 25 %/50 %** : en-tête fusionné (`colspan=2`) + deux sous-colonnes `25%`/`50%` dans le template `salary-recap/print.html.twig`. Données : `paid25Hours` = `base25Minutes`, `paid50Hours` = `base50Minutes` (bases seules, **hors bonus** — total inchangé vs l'ancienne colonne unique). `buildRttPaymentMap` renvoie `['m25','m50']` par employé. Le tableau a désormais 20 colonnes (`colspan` des lignes site/vide ajusté).
- **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase). - **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase).
## Onglet Congés (fiche employé) ## Onglet Congés (fiche employé)
+1 -1
View File
@@ -621,7 +621,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
{ type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' }, { type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' },
], ],
}, },
+12 -5
View File
@@ -174,6 +174,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
/** /**
* @return array<int, int> * @return array<int, int>
*/ */
/**
* @return array<int, array{m25: int, m50: int}>
*/
private function buildRttPaymentMap(array $rttPayments): array private function buildRttPaymentMap(array $rttPayments): array
{ {
$map = []; $map = [];
@@ -182,7 +185,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
if (!$employeeId) { if (!$employeeId) {
continue; continue;
} }
$map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes(); $map[$employeeId] ??= ['m25' => 0, 'm50' => 0];
$map[$employeeId]['m25'] += $payment->getBase25Minutes();
$map[$employeeId]['m50'] += $payment->getBase50Minutes();
} }
return $map; return $map;
@@ -295,7 +300,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$driverMap[$employeeId] ?? [], $driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [],
$absenceMap[$employeeId] ?? [], $absenceMap[$employeeId] ?? [],
$rttPaymentMap[$employeeId] ?? 0, $rttPaymentMap[$employeeId] ?? ['m25' => 0, 'm50' => 0],
$bonusMap[$employeeId] ?? 0.0, $bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0, $mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '', $observationMap[$employeeId] ?? '',
@@ -328,7 +333,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $driverByDate, array $driverByDate,
array $workHoursByDate, array $workHoursByDate,
array $absences, array $absences,
int $rttPaidMinutes, array $rttPaid,
float $bonusAmount, float $bonusAmount,
float $mileageKm, float $mileageKm,
string $observation, string $observation,
@@ -455,7 +460,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2); $nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2); $paid25Hours = round(($rttPaid['m25'] ?? 0) / 60, 2);
$paid50Hours = round(($rttPaid['m50'] ?? 0) / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2); $sundayHours = round($sundayMinutesTotal / 60, 2);
$holidayHours = round($holidayMinutesTotal / 60, 2); $holidayHours = round($holidayMinutesTotal / 60, 2);
@@ -467,7 +473,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
'mileageKm' => $mileageKm, 'mileageKm' => $mileageKm,
'nightHours' => $nightHours, 'nightHours' => $nightHours,
'nightBasketCount' => $nightBasketCount, 'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours, 'paid25Hours' => $paid25Hours,
'paid50Hours' => $paid50Hours,
'sundayHours' => $sundayHours, 'sundayHours' => $sundayHours,
'holidayHours' => $holidayHours, 'holidayHours' => $holidayHours,
'bonusAmount' => $bonusAmount, 'bonusAmount' => $bonusAmount,
+7 -4
View File
@@ -117,7 +117,7 @@
<th rowspan="2" style="width: 8mm;">Frais<br>Kms</th> <th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
<th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
<th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
<th rowspan="2" style="width: 10mm;">Heures<br>payés</th> <th colspan="2" style="width: 14mm;">Heures<br>payés</th>
<th rowspan="2" style="width: 8mm;">Heures<br>férié</th> <th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
<th rowspan="2" style="width: 8mm;">Heures<br>dim.</th> <th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
<th rowspan="2" style="width: 8mm;">Prime</th> <th rowspan="2" style="width: 8mm;">Prime</th>
@@ -127,6 +127,8 @@
<th rowspan="2" style="width: 20mm;">Observations</th> <th rowspan="2" style="width: 20mm;">Observations</th>
</tr> </tr>
<tr> <tr>
<th style="width: 7mm;">25%</th>
<th style="width: 7mm;">50%</th>
<th style="width: 8mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
<th style="width: 22mm;">Date</th> <th style="width: 22mm;">Date</th>
<th style="width: 8mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
@@ -141,7 +143,7 @@
{% for siteId, group in siteGroups %} {% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %} {% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header"> <tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="19"> <td style="background: {{ siteColor }}; text-align: left;" colspan="20">
{{ group.name }} {{ group.name }}
</td> </td>
</tr> </tr>
@@ -153,7 +155,8 @@
<td class="num">{{ row.mileageKm > 0 ? row.mileageKm : '' }}</td> <td class="num">{{ row.mileageKm > 0 ? row.mileageKm : '' }}</td>
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td> <td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td> <td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td> <td class="num">{{ row.paid25Hours > 0 ? row.paid25Hours : '' }}</td>
<td class="num">{{ row.paid50Hours > 0 ? row.paid50Hours : '' }}</td>
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td> <td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td> <td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td> <td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
@@ -169,7 +172,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="19">Aucun employé.</td> <td colspan="20">Aucun employé.</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}