[#SIRH] Récap salaire: congés N-1 forfait non affichés et comptés en présence
L'export récap salaire comptait tous les congés 'C' d'un forfait et ne créditait aucune présence sur les jours de congé. Or un congé imputé sur le stock N-1 ne doit pas s'afficher et doit compter comme jour de présence (règle déjà appliquée dans la fiche employé via EmployeeLeaveSummaryProvider). - Nouvelle méthode publique resolvePreviousYearTakenDays() (mutualise le budget N-1 avec la fiche: phase courante + recalcul jours payés). - SalaryRecapPrintProvider charge les congés depuis le 1er janvier et consomme le budget N-1 chronologiquement (splitForfaitCongesByN1): jours couverts N-1 retirés de l'affichage congés et ajoutés à la présence; au-delà = congés N. - Non-forfait / budget N-1 = 0: comportement inchangé. Vérifié end-to-end sur données prod (SARAZI mai: +1 présence, 4 congés affichés; LIOT/ODUNCU budget 0 après paiement N-1 -> congés affichés). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,7 @@
|
||||
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
|
||||
- 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é.
|
||||
- **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é.
|
||||
- **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é)
|
||||
|
||||
@@ -267,6 +267,7 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
||||
- pris: basé sur toutes les absences (demi-journées incluses)
|
||||
- restants = acquis - pris (borné à 0)
|
||||
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
|
||||
- jours de présence et récap salaire: pour un forfait, les jours de congé imputés sur le stock N-1 (`previousYearTakenDays`) **ne réduisent pas** les jours de présence et **ne s'affichent pas** comme congés. Sur l'export Récap salaire (mensuel), le budget N-1 est consommé chronologiquement depuis le 1er janvier ; les jours couverts deviennent des jours de présence, les jours au-delà restent affichés en congés. Le budget est le même que la fiche employé (jours payés déduits du stock N-1 d'abord).
|
||||
- report annuel:
|
||||
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
|
||||
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
|
||||
|
||||
@@ -622,6 +622,7 @@ export const documentationSections: DocSection[] = [
|
||||
blocks: [
|
||||
{ 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: '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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -411,6 +411,35 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget N-1 = nombre de jours de congé pris imputés sur le stock de l'année précédente,
|
||||
* pour l'exercice de l'année donnée. Reproduit exactement la dérivation de provide()
|
||||
* (phase courante + recalcul avec les jours payés) afin que les consommateurs externes
|
||||
* (ex. récap salaire) voient le même budget que la fiche employé. 0 si non supporté.
|
||||
*/
|
||||
public function resolvePreviousYearTakenDays(Employee $employee, int $year): float
|
||||
{
|
||||
$phase = $this->resolveCurrentPhase($employee);
|
||||
if (null === $phase) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$summary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||||
if (null === $summary) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$paidLeaveDays = $this->resolvePaidLeaveDays($employee, $summary['ruleCode'], $year);
|
||||
if ($paidLeaveDays > 0.0) {
|
||||
$summary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||||
if (null === $summary) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
return (float) $summary['previousYearTakenDays'];
|
||||
}
|
||||
|
||||
private function resolveEffectivePeriodStart(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Repository\ObservationRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
@@ -42,6 +43,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private ObservationRepository $observationRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -65,6 +68,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
|
||||
$year = (int) $from->format('Y');
|
||||
$monthNumber = (int) $from->format('n');
|
||||
|
||||
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
|
||||
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
|
||||
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
@@ -83,7 +93,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$observationMap = $this->buildObservationMap($observations);
|
||||
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap, $ytdAbsenceMap, $year, $from, $to);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
@@ -264,6 +274,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
array $holidayMap,
|
||||
array $ytdAbsenceMap,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -286,6 +300,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$mileageMap[$employeeId] ?? 0.0,
|
||||
$observationMap[$employeeId] ?? '',
|
||||
$holidayMap,
|
||||
$ytdAbsenceMap[$employeeId] ?? [],
|
||||
$year,
|
||||
$monthFrom,
|
||||
$monthTo,
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -315,6 +333,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
array $holidayMap,
|
||||
array $ytdAbsences,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
@@ -415,7 +437,21 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
}
|
||||
}
|
||||
|
||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||
// Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap
|
||||
// et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement
|
||||
// sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé.
|
||||
$n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0;
|
||||
if ($isForfait && $n1Budget > 0.0) {
|
||||
$ytdConges = array_values(array_filter(
|
||||
$ytdAbsences,
|
||||
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
||||
));
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
||||
$presenceDays += $split['n1PresenceDays'];
|
||||
} else {
|
||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||
}
|
||||
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
||||
|
||||
$nightHours = round($nightMinutesTotal / 60, 2);
|
||||
@@ -574,6 +610,73 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
|
||||
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
|
||||
* dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent
|
||||
* qu'à consommer le budget N-1.
|
||||
*
|
||||
* @param list<Absence> $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois
|
||||
*
|
||||
* @return array{count: float, dates: string, n1PresenceDays: float}
|
||||
*/
|
||||
private function splitForfaitCongesByN1(
|
||||
array $ytdConges,
|
||||
float $n1Budget,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo
|
||||
): array {
|
||||
usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate());
|
||||
|
||||
$remaining = $n1Budget;
|
||||
$count = 0.0;
|
||||
$n1PresenceDays = 0.0;
|
||||
$dayKeys = [];
|
||||
|
||||
foreach ($ytdConges as $absence) {
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||||
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
if ((int) $day->format('N') >= 6) {
|
||||
continue; // week-ends ignorés
|
||||
}
|
||||
[$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d'));
|
||||
$amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||
if ($amount <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered = 0.0;
|
||||
if ($remaining > 0.0) {
|
||||
$covered = min($remaining, $amount);
|
||||
$remaining -= $covered;
|
||||
}
|
||||
$displayed = $amount - $covered;
|
||||
|
||||
// Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer.
|
||||
if ($day < $monthFrom || $day > $monthTo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$n1PresenceDays += $covered;
|
||||
if ($displayed > 0.0) {
|
||||
$count += $displayed;
|
||||
$dayKeys[] = $day->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($dayKeys);
|
||||
$dayKeys = array_unique($dayKeys);
|
||||
|
||||
return [
|
||||
'count' => $count,
|
||||
'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)),
|
||||
'n1PresenceDays' => $n1PresenceDays,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $absences
|
||||
* @param list<string> $codes
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\State\SalaryRecapPrintProvider;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Forfait N-1 split for the salary recap. The provider's collaborators are final classes
|
||||
* PHPUnit cannot double, so the pure split helper is exercised via reflection, with a real
|
||||
* AbsenceSegmentsResolver (no deps) injected into the uninitialized property.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SalaryRecapPrintProviderTest extends TestCase
|
||||
{
|
||||
public function testN1BudgetPartiallyCoversADayAndOverflowsToN(): void
|
||||
{
|
||||
// Budget N-1 = 2.5 j ; 3 congés pleins (1 j) lun/mar/mer de janvier.
|
||||
// 1.0 + 1.0 + 0.5 consommés en N-1 → reste 0.5 j affiché en congé (le mercredi).
|
||||
$conges = [
|
||||
$this->buildConge('2026-01-05'),
|
||||
$this->buildConge('2026-01-06'),
|
||||
$this->buildConge('2026-01-07'),
|
||||
];
|
||||
|
||||
$result = $this->split($conges, 2.5, '2026-01-01', '2026-01-31');
|
||||
|
||||
self::assertSame(2.5, $result['n1PresenceDays']);
|
||||
self::assertSame(0.5, $result['count']);
|
||||
self::assertSame('07/01', $result['dates']);
|
||||
}
|
||||
|
||||
public function testN1BudgetConsumedInPriorMonthLeavesCurrentMonthFullyDisplayed(): void
|
||||
{
|
||||
// Budget 1 j, consommé par le congé de janvier. Récap de février → le congé de février
|
||||
// est entièrement imputé N (affiché, 0 présence N-1 dans le mois).
|
||||
$conges = [
|
||||
$this->buildConge('2026-01-12'),
|
||||
$this->buildConge('2026-02-09'),
|
||||
];
|
||||
|
||||
$result = $this->split($conges, 1.0, '2026-02-01', '2026-02-28');
|
||||
|
||||
self::assertSame(0.0, $result['n1PresenceDays']);
|
||||
self::assertSame(1.0, $result['count']);
|
||||
self::assertSame('09/02', $result['dates']);
|
||||
}
|
||||
|
||||
public function testZeroBudgetDisplaysAllCongesInMonth(): void
|
||||
{
|
||||
$conges = [$this->buildConge('2026-03-03')];
|
||||
|
||||
$result = $this->split($conges, 0.0, '2026-03-01', '2026-03-31');
|
||||
|
||||
self::assertSame(0.0, $result['n1PresenceDays']);
|
||||
self::assertSame(1.0, $result['count']);
|
||||
self::assertSame('03/03', $result['dates']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $conges
|
||||
*
|
||||
* @return array{count: float, dates: string, n1PresenceDays: float}
|
||||
*/
|
||||
private function split(array $conges, float $budget, string $from, string $to): array
|
||||
{
|
||||
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
|
||||
new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver')
|
||||
->setValue($provider, new AbsenceSegmentsResolver());
|
||||
|
||||
return new ReflectionClass($provider::class)
|
||||
->getMethod('splitForfaitCongesByN1')
|
||||
->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to));
|
||||
}
|
||||
|
||||
private function buildConge(string $date): Absence
|
||||
{
|
||||
return new Absence()
|
||||
->setStartDate(new DateTime($date))
|
||||
->setEndDate(new DateTime($date))
|
||||
->setStartHalf(HalfDay::AM)
|
||||
->setEndHalf(HalfDay::PM)
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user