From 0a9b26d31e57f39e1b819dedc074cb687f5f83f3 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 12 Jun 2026 08:57:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(overtime-contingent)=20:=20heures=20supp?= =?UTF-8?q?=20structurelles=20(>35h)=20ajout=C3=A9es=20au=20contingent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les heures contractuelles au-delà de 35h (ex. 39h → 17,33h décimales = 17h20/mois) sont payées chaque mois sans transiter par les paiements RTT (référence 39h). Elles manquaient au contingent. Ajout via StructuralOvertimeContingentCalculator : (weeklyHours-35)×260 min/mois, généralisé aux contrats non-forfait/non-intérim >35h, proratisé aux jours sous contrat. Branché sur l'encart fiche et l'export PDF. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 15 +- doc/overtime-contingent.md | 18 ++- frontend/data/documentation-content.ts | 1 + .../OvertimeContingentExportBuilder.php | 9 +- ...StructuralOvertimeContingentCalculator.php | 81 +++++++++++ .../EmployeeOvertimeContingentProvider.php | 9 +- .../OvertimeContingentExportBuilderTest.php | 37 ++++- ...cturalOvertimeContingentCalculatorTest.php | 131 ++++++++++++++++++ 8 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 src/Service/WorkHours/StructuralOvertimeContingentCalculator.php create mode 100644 tests/Service/WorkHours/StructuralOvertimeContingentCalculatorTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 56201ed..a4386ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,9 +111,18 @@ ## Contingent heures supplémentaires payées - Suivi par **année civile** (Janv–Déc) des heures supp payées vs plafond légal (350 h chauffeur / 220 h autres), non-forfait uniquement. -- **Heures payées** = `base25 + base50` (hors bonus). **Mapping** : paiements RTT stockés par - exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ; année civile Y = exercice Y - (mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur `OvertimePaidContingentCalculator`. +- **Heures payées** = `base25 + base50` (hors bonus) **+ heures structurelles**. **Mapping** : + paiements RTT stockés par exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ; + année civile Y = exercice Y (mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur + `OvertimePaidContingentCalculator`. +- **Heures structurelles** : les heures contractuelles au-delà de 35h (durée légale) sont des + heures supp payées chaque mois, hors paiements RTT (la référence d'un 39h est 39h). Ajoutées + au contingent : `(weeklyHours − 35) × 52/12` h/mois = `(weeklyHours − 35) × 260` min (39h → + 1040 min = 17,33 h/mois). Généralisé à tout contrat non-forfait/non-intérim `weeklyHours > 35` + (custom 40h → 21,67 h/mois) ; **proratisé** aux jours sous contrat dans le mois (itère + `employee.contractPeriods`). Cœur partagé `StructuralOvertimeContingentCalculator` + (`monthlyStructuralMinutes`/`totalStructuralMinutes`), branché sur l'encart fiche + (`EmployeeOvertimeContingentProvider`) **et** l'export (`OvertimeContingentExportBuilder`). - **Plafond** résolu sur `isDriver` du **contrat courant**. - **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart diff --git a/doc/overtime-contingent.md b/doc/overtime-contingent.md index 8abdbdf..941a991 100644 --- a/doc/overtime-contingent.md +++ b/doc/overtime-contingent.md @@ -5,10 +5,26 @@ Suivre, par année civile (Janv–Déc), les heures supplémentaires payées de non-forfait (chauffeurs inclus) face au plafond légal annuel. ## Règles -- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus). +- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus), **+ heures + structurelles** (voir ci-dessous). - **Plafond** : 350 h pour les chauffeurs (contrat courant `isDriver`), 220 h sinon. - **Périmètre** : non-forfait uniquement (FORFAIT exclus, ni RTT ni heures supp payées). +## Heures supplémentaires structurelles +Les heures contractuelles **au-delà de 35h** (durée légale) sont des heures supplémentaires +payées **chaque mois**, qui ne transitent pas par les paiements RTT (la référence d'un 39h est +39h, pas 35h) mais comptent dans le contingent légal. + +- Montant mensuel plein = `(weeklyHours − 35) × 52/12` h = `(weeklyHours − 35) × 260` min. + Pour un 39h : `4 × 260 = 1040` min = **17,33 h/mois**. +- **Généralisé** à tout contrat non-forfait/non-intérim dont `weeklyHours > 35` (ex. custom + 40h → 21,67 h/mois). Contrats ≤ 35h, FORFAIT, INTERIM → 0. +- **Proratisé** au nombre de jours réellement sous contrat dans le mois (entrée/sortie en cours + de mois). Itère les périodes de contrat (`employee.contractPeriods`), pas de requête jour/jour. +- Cœur partagé : `App\Service\WorkHours\StructuralOvertimeContingentCalculator` + (`monthlyStructuralMinutes` / `totalStructuralMinutes`). Ajouté au total des paiements RTT + côté provider (encart fiche) **et** export builder (PDF). + ## Mapping exercice → année civile Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 → Mai N) + `month` (1–12). L'année civile d'un paiement : diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 58c7432..2aaa5ab 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -653,6 +653,7 @@ export const documentationSections: DocSection[] = [ { type: 'paragraph', content: 'L\'export PDF « Contingent H.supp. » est accessible depuis la liste des employés, via le bouton Export → option « Contingent H.supp. ». Choisissez l\'année civile (par défaut l\'année courante) et éventuellement des sites ; sans sélection de site, tous les sites de votre périmètre sont inclus.' }, { type: 'list', content: 'PDF A4 paysage, une ligne par employé non-forfait, groupé par site\nTri : ordre d\'affichage du site, puis nom, puis prénom\nColonnes : Janv à Déc (heures payées par mois) + colonne « Total payé / payable »\nLes employés FORFAIT n\'apparaissent pas dans cet export' }, { type: 'note', content: 'Les heures prises en compte sont les bases payées (25 % et 50 % confondus), hors majorations. Le contingent est calculé sur l\'année civile (janvier–décembre), indépendamment de l\'exercice RTT (juin–mai) : un paiement RTT saisi pour le mois de juin est rattaché à l\'année civile précédente.' }, + { type: 'note', content: 'Heures structurelles : les heures contractuelles au-delà de 35 h (ex. un contrat 39 h) sont des heures supplémentaires payées chaque mois, indépendamment des paiements RTT. Elles sont automatiquement ajoutées au contingent : (heures hebdo − 35) × 52 / 12 par mois, soit 17,33 h/mois pour un 39 h (proratisé aux jours réellement sous contrat). Les contrats forfait, intérim et ≤ 35 h n\'en génèrent pas.' }, ], }, { diff --git a/src/Service/WorkHours/OvertimeContingentExportBuilder.php b/src/Service/WorkHours/OvertimeContingentExportBuilder.php index 94b76c3..e79ac02 100644 --- a/src/Service/WorkHours/OvertimeContingentExportBuilder.php +++ b/src/Service/WorkHours/OvertimeContingentExportBuilder.php @@ -17,6 +17,7 @@ final readonly class OvertimeContingentExportBuilder public function __construct( private EmployeeRttPaymentRepository $rttPaymentRepository, private OvertimePaidContingentCalculator $calculator, + private StructuralOvertimeContingentCalculator $structuralCalculator, ) {} /** @@ -49,7 +50,13 @@ final readonly class OvertimeContingentExportBuilder } $employeePayments = $byEmployee[$employeeId] ?? []; - $months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear); + $paidMonths = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear); + $structuralMonths = $this->structuralCalculator->monthlyStructuralMinutes($employee, $civilYear); + + $months = []; + for ($m = 1; $m <= 12; ++$m) { + $months[$m] = $paidMonths[$m] + $structuralMonths[$m]; + } $rows[] = new OvertimeContingentRow( employeeId: $employeeId, diff --git a/src/Service/WorkHours/StructuralOvertimeContingentCalculator.php b/src/Service/WorkHours/StructuralOvertimeContingentCalculator.php new file mode 100644 index 0000000..e25fa68 --- /dev/null +++ b/src/Service/WorkHours/StructuralOvertimeContingentCalculator.php @@ -0,0 +1,81 @@ + clé 1..12 -> minutes structurelles payées (proratisées) + */ + public function monthlyStructuralMinutes(Employee $employee, int $civilYear): array + { + $accumulated = array_fill(1, 12, 0.0); + + foreach ($employee->getContractPeriods() as $period) { + $contract = $period->getContract(); + if (null === $contract) { + continue; + } + + $type = $contract->getType(); + if (ContractType::FORFAIT === $type || ContractType::INTERIM === $type) { + continue; + } + + $weeklyHours = $contract->getWeeklyHours(); + if (null === $weeklyHours || $weeklyHours <= 35) { + continue; + } + + $fullMonthlyMinutes = ($weeklyHours - 35) * self::MINUTES_PER_WEEKLY_HOUR_PER_MONTH; + $periodStart = $period->getStartDate(); + $periodEnd = $period->getEndDate(); + + for ($month = 1; $month <= 12; ++$month) { + $monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $civilYear, $month)); + $monthEnd = $monthStart->modify('last day of this month'); + $daysInMonth = (int) $monthEnd->format('d'); + + $overlapStart = $periodStart > $monthStart ? $periodStart : $monthStart; + $overlapEnd = (null !== $periodEnd && $periodEnd < $monthEnd) ? $periodEnd : $monthEnd; + if ($overlapStart > $overlapEnd) { + continue; + } + + $overlapDays = $overlapStart->diff($overlapEnd)->days + 1; + $accumulated[$month] += $fullMonthlyMinutes * $overlapDays / $daysInMonth; + } + } + + $months = []; + for ($month = 1; $month <= 12; ++$month) { + $months[$month] = (int) round($accumulated[$month]); + } + + return $months; + } + + public function totalStructuralMinutes(Employee $employee, int $civilYear): int + { + return array_sum($this->monthlyStructuralMinutes($employee, $civilYear)); + } +} diff --git a/src/State/EmployeeOvertimeContingentProvider.php b/src/State/EmployeeOvertimeContingentProvider.php index 3af8ab6..48e97af 100644 --- a/src/State/EmployeeOvertimeContingentProvider.php +++ b/src/State/EmployeeOvertimeContingentProvider.php @@ -11,6 +11,7 @@ use App\Entity\Employee; use App\Repository\EmployeeRepository; use App\Repository\EmployeeRttPaymentRepository; use App\Service\WorkHours\OvertimePaidContingentCalculator; +use App\Service\WorkHours\StructuralOvertimeContingentCalculator; use DateTimeImmutable; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -22,6 +23,7 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter private RequestStack $requestStack, private EmployeeRttPaymentRepository $rttPaymentRepository, private OvertimePaidContingentCalculator $calculator, + private StructuralOvertimeContingentCalculator $structuralCalculator, private EmployeeRepository $employeeRepository, ) {} @@ -51,9 +53,10 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter $output = new EmployeeOvertimeContingent(); $output->year = $year; - $output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year); - $output->isDriver = $employee->getIsDriver(); - $output->capHours = $this->calculator->capHours($output->isDriver); + $output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year) + + $this->structuralCalculator->totalStructuralMinutes($employee, $year); + $output->isDriver = $employee->getIsDriver(); + $output->capHours = $this->calculator->capHours($output->isDriver); return $output; } diff --git a/tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php b/tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php index d9213b8..9cd1a51 100644 --- a/tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php +++ b/tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php @@ -4,11 +4,16 @@ declare(strict_types=1); namespace App\Tests\Service\WorkHours; +use App\Entity\Contract; use App\Entity\Employee; +use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeRttPayment; +use App\Enum\TrackingMode; use App\Repository\EmployeeRttPaymentRepository; use App\Service\WorkHours\OvertimeContingentExportBuilder; use App\Service\WorkHours\OvertimePaidContingentCalculator; +use App\Service\WorkHours\StructuralOvertimeContingentCalculator; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use ReflectionProperty; @@ -41,7 +46,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase $repo = $this->createStub(EmployeeRttPaymentRepository::class); $repo->method('findByEmployeesAndYears')->willReturn([$payment]); - $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator()); + $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator()); $rows = $builder->buildRows([$driverEmp], 2026); @@ -64,7 +69,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase $repo = $this->createStub(EmployeeRttPaymentRepository::class); $repo->method('findByEmployeesAndYears')->willReturn([]); - $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator()); + $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator()); $rows = $builder->buildRows([$emp], 2026); self::assertCount(1, $rows); @@ -72,4 +77,32 @@ final class OvertimeContingentExportBuilderTest extends TestCase self::assertSame(0, $rows[0]->months[6]); self::assertSame(220, $rows[0]->capHours); // non-driver } + + public function testStructuralHoursOf39hAreAddedToPaidBase(): void + { + $contract = new Contract() + ->setName('CDI') + ->setTrackingMode(TrackingMode::TIME) + ->setWeeklyHours(39) + ; + $period = new EmployeeContractPeriod() + ->setContract($contract) + ->setStartDate(new DateTimeImmutable('2020-01-01')) + ; + $emp = new Employee(); + $emp->setLastName('Petit')->setFirstName('Marc'); + $emp->getContractPeriods()->add($period); + $idRef = new ReflectionProperty(Employee::class, 'id'); + $idRef->setValue($emp, 11); + + $repo = $this->createStub(EmployeeRttPaymentRepository::class); + $repo->method('findByEmployeesAndYears')->willReturn([]); + + $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator()); + $rows = $builder->buildRows([$emp], 2026); + + // Aucun paiement RTT, mais 12 × 1040 min de structurel (39h plein sur l'année). + self::assertSame(1040, $rows[0]->months[1]); + self::assertSame(12 * 1040, $rows[0]->totalMinutes); + } } diff --git a/tests/Service/WorkHours/StructuralOvertimeContingentCalculatorTest.php b/tests/Service/WorkHours/StructuralOvertimeContingentCalculatorTest.php new file mode 100644 index 0000000..47907b7 --- /dev/null +++ b/tests/Service/WorkHours/StructuralOvertimeContingentCalculatorTest.php @@ -0,0 +1,131 @@ +employeeWithPeriod(39, '2020-01-01', null); + + $months = $calc->monthlyStructuralMinutes($employee, 2026); + + // (39 - 35) x 260 = 1040 minutes (17,33 h) chaque mois plein. + self::assertSame(1040, $months[1]); + self::assertSame(1040, $months[6]); + self::assertSame(1040, $months[12]); + self::assertSame(12 * 1040, $calc->totalStructuralMinutes($employee, 2026)); + } + + public function testCustomAbove35hUsesGeneralizedFormula(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + $employee = $this->employeeWithPeriod(40, '2020-01-01', null); + + // (40 - 35) x 260 = 1300 minutes par mois. + self::assertSame(1300, $calc->monthlyStructuralMinutes($employee, 2026)[1]); + } + + public function test35hAndBelowCreditNothing(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + + self::assertSame(0, $calc->totalStructuralMinutes($this->employeeWithPeriod(35, '2020-01-01', null), 2026)); + self::assertSame(0, $calc->totalStructuralMinutes($this->employeeWithPeriod(28, '2020-01-01', null), 2026)); + } + + public function testMidMonthEntryIsProratedByContractedDays(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + // Embauche le 16 janvier 2026 : 16 jours contractés sur 31. + $employee = $this->employeeWithPeriod(39, '2026-01-16', null); + + $months = $calc->monthlyStructuralMinutes($employee, 2026); + + self::assertSame((int) round(1040 * 16 / 31), $months[1]); + self::assertSame(1040, $months[2]); + } + + public function testMonthsOutsidePeriodCreditNothing(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + // Contrat clos fin mars 2026. + $employee = $this->employeeWithPeriod(39, '2020-01-01', '2026-03-31'); + + $months = $calc->monthlyStructuralMinutes($employee, 2026); + + self::assertSame(1040, $months[3]); + self::assertSame(0, $months[4]); + self::assertSame(0, $months[12]); + } + + public function testForfaitPeriodCreditsNothing(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + + $contract = new Contract() + ->setName('Forfait') + ->setTrackingMode(TrackingMode::PRESENCE) + ->setWeeklyHours(null) + ; + $period = new EmployeeContractPeriod() + ->setContract($contract) + ->setStartDate(new DateTimeImmutable('2020-01-01')) + ; + $employee = new Employee(); + $employee->getContractPeriods()->add($period); + + self::assertSame(0, $calc->totalStructuralMinutes($employee, 2026)); + } + + public function testInterimAbove35hCreditsNothing(): void + { + $calc = new StructuralOvertimeContingentCalculator(); + + $contract = new Contract() + ->setName('Interim') + ->setTrackingMode(TrackingMode::TIME) + ->setWeeklyHours(39) + ; + $period = new EmployeeContractPeriod() + ->setContract($contract) + ->setStartDate(new DateTimeImmutable('2020-01-01')) + ; + $employee = new Employee(); + $employee->getContractPeriods()->add($period); + + self::assertSame(0, $calc->totalStructuralMinutes($employee, 2026)); + } + + private function employeeWithPeriod(int $weeklyHours, string $start, ?string $end): Employee + { + $contract = new Contract() + ->setName('CDI') + ->setTrackingMode(TrackingMode::TIME) + ->setWeeklyHours($weeklyHours) + ; + $period = new EmployeeContractPeriod() + ->setContract($contract) + ->setStartDate(new DateTimeImmutable($start)) + ->setEndDate(null === $end ? null : new DateTimeImmutable($end)) + ; + $employee = new Employee(); + $employee->getContractPeriods()->add($period); + + return $employee; + } +}