From 327c10fda496afd9ba730731f03756e24edd6044 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 15:47:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(overtime-contingent)=20:=20contingent=20d'?= =?UTF-8?q?heures=20suppl=C3=A9mentaires=20pay=C3=A9es=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Résumé Suivi par **année civile** (Janv–Déc) des heures supplémentaires payées des employés non-forfait (chauffeurs inclus) face au plafond légal (**350 h** chauffeurs / **220 h** autres). - **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`. - **Export PDF** `GET /overtime-contingent/print?year=&siteIds=` (ROLE_USER, périmètre `findScoped`) : groupé par site, colonnes Janv–Déc + colonne `Total payé / payable`. Drawer liste employés (année + sites). - Heures payées = `base25 + base50` (hors majoration). Mapping exercice→civil : `mois ≥ 6 ? exercice−1 : exercice`. - Cœur partagé pur `OvertimePaidContingentCalculator`. - Ajout « Année civile » dans le titre des deux exports PDF (contingent H.supp. et heures de nuit). ## Tests - 214 tests PHPUnit verts (calculateur : mapping civil, base-only, plafond ; builder : ventilation mensuelle, ligne à zéro). ## Hors périmètre (consigné) - Bug latent `SalaryRecapPrintProvider` : rattachement des paiements RTT des mois Juin–Déc par année civile sur un stockage par exercice. À traiter séparément. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/29 Co-authored-by: tristan Co-committed-by: tristan --- CLAUDE.md | 18 + doc/overtime-contingent.md | 33 + .../2026-06-11-overtime-paid-contingent.md | 1174 +++++++++++++++++ ...6-06-11-overtime-paid-contingent-design.md | 169 +++ frontend/composables/useEmployeeDetailPage.ts | 29 + frontend/data/documentation-content.ts | 12 + frontend/pages/employees/[id].vue | 7 + frontend/pages/employees/index.vue | 31 +- .../services/employee-overtime-contingent.ts | 13 + .../EmployeeOvertimeContingent.php | 27 + src/ApiResource/OvertimeContingentPrint.php | 25 + src/Dto/WorkHours/OvertimeContingentRow.php | 19 + .../EmployeeRttPaymentRepository.php | 29 +- .../OvertimeContingentExportBuilder.php | 65 + .../OvertimePaidContingentCalculator.php | 54 + .../EmployeeOvertimeContingentProvider.php | 60 + src/State/OvertimeContingentPrintProvider.php | 169 +++ .../night-hours-contingent/print.html.twig | 2 +- templates/overtime-contingent/print.html.twig | 54 + .../OvertimeContingentExportBuilderTest.php | 75 ++ .../OvertimePaidContingentCalculatorTest.php | 88 ++ 21 files changed, 2148 insertions(+), 5 deletions(-) create mode 100644 doc/overtime-contingent.md create mode 100644 docs/superpowers/plans/2026-06-11-overtime-paid-contingent.md create mode 100644 docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md create mode 100644 frontend/services/employee-overtime-contingent.ts create mode 100644 src/ApiResource/EmployeeOvertimeContingent.php create mode 100644 src/ApiResource/OvertimeContingentPrint.php create mode 100644 src/Dto/WorkHours/OvertimeContingentRow.php create mode 100644 src/Service/WorkHours/OvertimeContingentExportBuilder.php create mode 100644 src/Service/WorkHours/OvertimePaidContingentCalculator.php create mode 100644 src/State/EmployeeOvertimeContingentProvider.php create mode 100644 src/State/OvertimeContingentPrintProvider.php create mode 100644 templates/overtime-contingent/print.html.twig create mode 100644 tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php create mode 100644 tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 9a5b5a8..56201ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,24 @@ - **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord. - Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus). +## 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`. +- **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 + volontairement indépendant de la phase sélectionnée (toujours l'année civile courante). +- **Export PDF** (`GET /overtime-contingent/print?year=&siteIds=`, `ROLE_USER`, + `findScoped`) : groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`, + colonnes Janv–Déc + `Total payé / payable`. Drawer liste employés : sélecteur année + + sites (vide = périmètre complet). Exclut les FORFAIT (contrat courant). +- ⚠️ Bug latent consigné : `SalaryRecapPrintProvider` rattache mal les paiements RTT des mois + Juin–Déc (requête par année civile sur un stockage par exercice). Hors périmètre. +- Doc : `doc/overtime-contingent.md`. + ## Vue contrat (sélecteur de phase) - Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase. - Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`. diff --git a/doc/overtime-contingent.md b/doc/overtime-contingent.md new file mode 100644 index 0000000..8abdbdf --- /dev/null +++ b/doc/overtime-contingent.md @@ -0,0 +1,33 @@ +# Contingent d'heures supplémentaires payées + +## Objectif +Suivre, par année civile (Janv–Déc), les heures supplémentaires payées de chaque employé +non-forfait (chauffeurs inclus) face au plafond légal annuel. + +## Règles +- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus). +- **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). + +## 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 : + + annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear + +Donc l'année civile **Y** agrège : exercice `Y` (mois 1–5) + exercice `Y+1` (mois 6–12). + +## Implémentation +- Cœur partagé : `App\Service\WorkHours\OvertimePaidContingentCalculator` (pur). +- Repo : `EmployeeRttPaymentRepository::findByEmployeesAndYears`. +- Fiche employé : `GET /employees/{id}/overtime-contingent?year=YYYY` → encart header + (`Total H.payés {année} : X h / plafond h`, rouge si dépassement, année civile courante). +- Export PDF : `GET /overtime-contingent/print?year=&siteIds=` (`ROLE_USER`, périmètre + `findScoped`), groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`, + colonnes Janv–Déc + colonne `Total payé / payable`. Builder + `OvertimeContingentExportBuilder`, template `overtime-contingent/print.html.twig`. + +## Hors périmètre / connu +- Bug latent récap salaire : `SalaryRecapPrintProvider` requête `findByYearAndMonth` avec + l'année civile alors que le stockage est par exercice (mauvais rattachement des paiements + des mois Juin–Déc sur le récap mensuel). À corriger séparément. diff --git a/docs/superpowers/plans/2026-06-11-overtime-paid-contingent.md b/docs/superpowers/plans/2026-06-11-overtime-paid-contingent.md new file mode 100644 index 0000000..fa17dfc --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-overtime-paid-contingent.md @@ -0,0 +1,1174 @@ +# Contingent d'heures supplémentaires payées — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre à la RH de suivre, par année civile, les heures supplémentaires payées de chaque employé non-forfait face au plafond légal (350 h chauffeur / 220 h autres) — via un encart sur la fiche employé et un export PDF groupé par site. + +**Architecture:** Un calculateur PUR (`OvertimePaidContingentCalculator`) convertit les paiements RTT (stockés par exercice) en agrégats par année civile. Il est consommé par deux surfaces : un endpoint de lecture (`GET /employees/{id}/overtime-contingent`) pour l'encart fiche employé, et un builder + provider PDF (`/overtime-contingent/print`) calqué sur l'export contingent heures de nuit existant. + +**Tech Stack:** Symfony + API Platform + Doctrine (backend), Dompdf + Twig (PDF), Nuxt 4 + Vue 3 + TS + Tailwind (frontend), PHPUnit (tests). + +**Spec:** `docs/superpowers/specs/2026-06-11-overtime-paid-contingent-design.md` + +--- + +## File Structure + +**Backend (créés) :** +- `src/Service/WorkHours/OvertimePaidContingentCalculator.php` — calcul pur (mapping civil + plafond) +- `src/Dto/WorkHours/OvertimeContingentRow.php` — DTO ligne PDF +- `src/Service/WorkHours/OvertimeContingentExportBuilder.php` — fetch groupé + délègue au calculateur +- `src/ApiResource/OvertimeContingentPrint.php` — endpoint PDF +- `src/State/OvertimeContingentPrintProvider.php` — périmètre, exclusion forfait, groupement site, rendu +- `templates/overtime-contingent/print.html.twig` — gabarit PDF A4 paysage +- `src/ApiResource/EmployeeOvertimeContingent.php` — endpoint lecture fiche employé +- `src/State/EmployeeOvertimeContingentProvider.php` — provider lecture + +**Backend (modifiés) :** +- `src/Repository/EmployeeRttPaymentRepository.php` — retirer `final` + ajouter `findByEmployeesAndYears()` + +**Tests (créés) :** +- `tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.php` +- `tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php` + +**Frontend (créés) :** +- `frontend/services/employee-overtime-contingent.ts` — appel API encart + +**Frontend (modifiés) :** +- `frontend/pages/employees/[id].vue` + `frontend/composables/useEmployeeDetailPage.ts` — encart header +- `frontend/pages/employees/index.vue` — choix d'export + drawer (année + sites) + +**Docs (créés/modifiés) :** +- `doc/overtime-contingent.md` (créé) +- `CLAUDE.md` (modifié) +- `frontend/data/documentation-content.ts` (modifié) + +**Commande de test backend (un seul test) :** +```bash +docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter +``` +**Toute la suite :** `make test` + +--- + +## Task 1 : Calculateur pur `OvertimePaidContingentCalculator` + +**Files:** +- Create: `src/Service/WorkHours/OvertimePaidContingentCalculator.php` +- Test: `tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.php` + +- [ ] **Step 1 : Écrire le test qui échoue** + +`tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.php` : + +```php +setYear($exerciseYear) + ->setMonth($month) + ->setBase25Minutes($base25) + ->setBase50Minutes($base50) + ; + } + + public function testMapsPaymentToCalendarYearAndSumsBaseOnly(): void + { + $calc = new OvertimePaidContingentCalculator(); + + // Septembre 2025 stocké en exercice 2026 (mois 9 >= 6 -> civil 2025). + // Mars 2026 stocké en exercice 2026 (mois 3 < 6 -> civil 2026). + // Septembre 2026 stocké en exercice 2027 (mois 9 >= 6 -> civil 2026). + $payments = [ + $this->payment(2026, 9, 120, 0), // civil 2025 -> exclu de 2026 + $this->payment(2026, 3, 60, 30), // civil 2026 -> mois 3 + $this->payment(2027, 9, 100, 20), // civil 2026 -> mois 9 + ]; + + $months = $calc->monthlyBaseMinutes($payments, 2026); + + self::assertSame(90, $months[3]); // 60 + 30 + self::assertSame(120, $months[9]); // 100 + 20 + self::assertSame(0, $months[1]); + self::assertSame(0, $months[9 - 1]); + self::assertSame(210, $calc->totalBaseMinutes($payments, 2026)); // bonus ignoré + } + + public function testMonth5BelongsToExerciseYearAndMonth6ToPreviousCalendarYear(): void + { + $calc = new OvertimePaidContingentCalculator(); + + $payments = [ + $this->payment(2026, 5, 50, 0), // mai -> civil 2026 + $this->payment(2026, 6, 70, 0), // juin -> civil 2025 + ]; + + self::assertSame(50, $calc->totalBaseMinutes($payments, 2026)); + self::assertSame(70, $calc->totalBaseMinutes($payments, 2025)); + } + + public function testCapHours(): void + { + $calc = new OvertimePaidContingentCalculator(); + + self::assertSame(350, $calc->capHours(true)); + self::assertSame(220, $calc->capHours(false)); + } +} +``` + +- [ ] **Step 2 : Lancer le test, vérifier l'échec** + +Run: `docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter OvertimePaidContingentCalculatorTest` +Expected: FAIL — `Class "App\Service\WorkHours\OvertimePaidContingentCalculator" not found`. + +- [ ] **Step 3 : Implémenter le calculateur** + +`src/Service/WorkHours/OvertimePaidContingentCalculator.php` : + +```php + Mai N + mois) + * en agrégats par ANNEE CIVILE (Janv-Déc). Heures payées = base25 + base50, + * hors majoration (bonus). Plafond : 350 h chauffeur, 220 h autres. + */ +final readonly class OvertimePaidContingentCalculator +{ + public const int CAP_HOURS_DRIVER = 350; + public const int CAP_HOURS_DEFAULT = 220; + + /** + * @param iterable $payments paiements d'un employé + * (typiquement exercices civilYear et civilYear+1) + * + * @return array clé 1..12 -> minutes base payées (base25+base50) + */ + public function monthlyBaseMinutes(iterable $payments, int $civilYear): array + { + $months = array_fill(1, 12, 0); + + foreach ($payments as $payment) { + $month = $payment->getMonth(); + $paymentCivilYear = $month >= 6 ? $payment->getYear() - 1 : $payment->getYear(); + if ($paymentCivilYear !== $civilYear) { + continue; + } + if ($month < 1 || $month > 12) { + continue; + } + + $months[$month] += $payment->getBase25Minutes() + $payment->getBase50Minutes(); + } + + return $months; + } + + /** + * @param iterable $payments + */ + public function totalBaseMinutes(iterable $payments, int $civilYear): int + { + return array_sum($this->monthlyBaseMinutes($payments, $civilYear)); + } + + public function capHours(bool $isDriver): int + { + return $isDriver ? self::CAP_HOURS_DRIVER : self::CAP_HOURS_DEFAULT; + } +} +``` + +- [ ] **Step 4 : Lancer le test, vérifier le succès** + +Run: `docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter OvertimePaidContingentCalculatorTest` +Expected: PASS (3 tests). + +- [ ] **Step 5 : Commit** + +```bash +git add src/Service/WorkHours/OvertimePaidContingentCalculator.php tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.php +git commit -m "feat(overtime-contingent) : calculateur pur heures supp payées par année civile" +``` + +--- + +## Task 2 : Repository — `findByEmployeesAndYears` (+ retrait `final`) + +**Files:** +- Modify: `src/Repository/EmployeeRttPaymentRepository.php` + +- [ ] **Step 1 : Retirer `final` et ajouter la méthode** + +Dans `src/Repository/EmployeeRttPaymentRepository.php`, remplacer la déclaration de classe : + +```php +final class EmployeeRttPaymentRepository extends ServiceEntityRepository +``` + +par : + +```php +class EmployeeRttPaymentRepository extends ServiceEntityRepository +``` + +(Retrait de `final` : permet de doubler le repository dans le test du builder, Task 3.) + +Puis ajouter cette méthode juste avant la dernière accolade fermante de la classe : + +```php + /** + * Paiements de plusieurs employés sur plusieurs exercices (fetch groupé, + * évite le N+1 sur l'export PDF). Jointure employé chargée. + * + * @param list $employees + * @param list $years années d'exercice + * + * @return EmployeeRttPayment[] + */ + public function findByEmployeesAndYears(array $employees, array $years): array + { + if ([] === $employees || [] === $years) { + return []; + } + + return $this->createQueryBuilder('p') + ->andWhere('p.employee IN (:employees)') + ->andWhere('p.year IN (:years)') + ->setParameter('employees', $employees) + ->setParameter('years', $years) + ->innerJoin('p.employee', 'e') + ->addSelect('e') + ->getQuery() + ->getResult() + ; + } +``` + +- [ ] **Step 2 : Vérifier que la suite passe toujours (pas de régression)** + +Run: `make test` +Expected: PASS (208 tests + ceux de Task 1). + +- [ ] **Step 3 : Commit** + +```bash +git add src/Repository/EmployeeRttPaymentRepository.php +git commit -m "feat(overtime-contingent) : findByEmployeesAndYears + repo non-final pour les tests" +``` + +--- + +## Task 3 : DTO + Builder PDF `OvertimeContingentExportBuilder` + +**Files:** +- Create: `src/Dto/WorkHours/OvertimeContingentRow.php` +- Create: `src/Service/WorkHours/OvertimeContingentExportBuilder.php` +- Test: `tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php` + +- [ ] **Step 1 : Créer le DTO** + +`src/Dto/WorkHours/OvertimeContingentRow.php` : + +```php + $months clé 1..12 -> minutes base payées + */ + public function __construct( + public readonly int $employeeId, + public readonly string $employeeName, + public readonly array $months, + public readonly int $totalMinutes, + public readonly int $capHours, + ) {} +} +``` + +- [ ] **Step 2 : Écrire le test du builder qui échoue** + +`tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php` : + +```php +setLastName('Martin')->setFirstName('Luc'); + $idRef = new ReflectionProperty(Employee::class, 'id'); + $idRef->setValue($driverEmp, 7); + + // Paiement : exercice 2027, mois 9 -> civil 2026, mois 9 ; base 100+20. + $payment = new EmployeeRttPayment() + ->setEmployee($driverEmp) + ->setYear(2027)->setMonth(9) + ->setBase25Minutes(100)->setBase50Minutes(20) + ; + + $repo = $this->createStub(EmployeeRttPaymentRepository::class); + $repo->method('findByEmployeesAndYears')->willReturn([$payment]); + + $builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator()); + + $rows = $builder->buildRows([$driverEmp], 2026); + + self::assertCount(1, $rows); + self::assertSame(7, $rows[0]->employeeId); + self::assertSame('Martin Luc', $rows[0]->employeeName); + self::assertSame(120, $rows[0]->months[9]); + self::assertSame(0, $rows[0]->months[1]); + self::assertSame(120, $rows[0]->totalMinutes); + self::assertSame(350, $rows[0]->capHours); // chauffeur + } +} +``` + +- [ ] **Step 3 : Lancer le test, vérifier l'échec** + +Run: `docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter OvertimeContingentExportBuilderTest` +Expected: FAIL — classe `OvertimeContingentExportBuilder` introuvable. + +- [ ] **Step 4 : Implémenter le builder** + +`src/Service/WorkHours/OvertimeContingentExportBuilder.php` : + +```php + $employees + * + * @return list + */ + public function buildRows(array $employees, int $civilYear): array + { + // Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12). + $payments = $this->rttPaymentRepository->findByEmployeesAndYears( + $employees, + [$civilYear, $civilYear + 1], + ); + + $byEmployee = []; + foreach ($payments as $payment) { + $employeeId = $payment->getEmployee()?->getId(); + if (null === $employeeId) { + continue; + } + $byEmployee[$employeeId][] = $payment; + } + + $rows = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if (null === $employeeId) { + continue; + } + + $employeePayments = $byEmployee[$employeeId] ?? []; + $months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear); + + $rows[] = new OvertimeContingentRow( + employeeId: $employeeId, + employeeName: trim($employee->getLastName().' '.$employee->getFirstName()), + months: $months, + totalMinutes: array_sum($months), + capHours: $this->calculator->capHours($employee->getIsDriver()), + ); + } + + return $rows; + } +} +``` + +- [ ] **Step 5 : Lancer le test, vérifier le succès** + +Run: `docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter OvertimeContingentExportBuilderTest` +Expected: PASS. + +- [ ] **Step 6 : Commit** + +```bash +git add src/Dto/WorkHours/OvertimeContingentRow.php src/Service/WorkHours/OvertimeContingentExportBuilder.php tests/Service/WorkHours/OvertimeContingentExportBuilderTest.php +git commit -m "feat(overtime-contingent) : DTO + builder export PDF (heures supp payées)" +``` + +--- + +## Task 4 : Export PDF — ApiResource + Provider + Template + +**Files:** +- Create: `src/ApiResource/OvertimeContingentPrint.php` +- Create: `src/State/OvertimeContingentPrintProvider.php` +- Create: `templates/overtime-contingent/print.html.twig` + +- [ ] **Step 1 : Créer l'ApiResource** + +`src/ApiResource/OvertimeContingentPrint.php` : + +```php +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $request = $this->requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y')); + if ($year < 2000 || $year > 2100) { + throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); + } + + $from = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $to = new DateTimeImmutable(sprintf('%d-12-31', $year)); + + // Filtre sites optionnel (vide = tout le périmètre). + $rawSiteIds = (string) $request->query->get('siteIds', ''); + $siteIds = array_values(array_filter(array_map('intval', array_filter(explode(',', $rawSiteIds), 'strlen')))); + + // Périmètre selon le profil : admin -> tous, chef de site -> ses sites. + $employees = $this->employeeRepository->findScoped($user); + + $today = new DateTimeImmutable('today'); + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (!$this->hasContractInRange($employee, $from, $to)) { + continue; + } + // Exclure les forfait (contrat courant). + $currentContract = $this->contractResolver->resolveForEmployeeAndDate($employee, $today); + if (null !== $currentContract && ContractType::FORFAIT === $currentContract->getType()) { + continue; + } + $site = $employee->getSite(); + if (null === $site) { + continue; + } + $siteId = $site->getId(); + if ([] !== $siteIds && !in_array($siteId, $siteIds, true)) { + continue; + } + $bySite[$siteId][] = $employee; + $siteMeta[$siteId] ??= [ + 'name' => $site->getName(), + 'order' => $site->getDisplayOrder(), + 'color' => $site->getColor(), + ]; + } + + uasort($siteMeta, static function (array $a, array $b): int { + return [$a['order'], $a['name']] <=> [$b['order'], $b['name']]; + }); + + $groups = []; + foreach ($siteMeta as $siteId => $meta) { + $siteEmployees = $bySite[$siteId]; + // Même tri que le calendrier : displayOrder, puis nom, puis prénom. + usort($siteEmployees, static function (Employee $a, Employee $b): int { + return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()] + <=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()]; + }); + + $rows = $this->exportBuilder->buildRows($siteEmployees, $year); + + $renderRows = []; + foreach ($rows as $row) { + $cells = []; + for ($m = 1; $m <= 12; ++$m) { + $cells[] = $row->months[$m] > 0 ? $this->formatMinutes($row->months[$m]) : '—'; + } + $renderRows[] = [ + 'employeeName' => $row->employeeName, + 'cells' => $cells, + 'totalHours' => $this->formatMinutes($row->totalMinutes), + 'capHours' => $row->capHours, + 'exceeded' => $row->totalMinutes > $row->capHours * 60, + ]; + } + + $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('overtime-contingent/print.html.twig', [ + 'groups' => $groups, + 'year' => $year, + 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'landscape'); + $dompdf->render(); + + $filename = sprintf('contingent_heures_supp_%d.pdf', $year); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } + + private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool + { + $fromDay = $from->format('Y-m-d'); + $toDay = $to->format('Y-m-d'); + + foreach ($employee->getContractPeriods() as $period) { + $start = $period->getStartDate()->format('Y-m-d'); + $end = $period->getEndDate()?->format('Y-m-d'); + if ($start <= $toDay && (null === $end || $end >= $fromDay)) { + return true; + } + } + + return false; + } + + private function formatMinutes(int $minutes): string + { + $h = intdiv($minutes, 60); + $m = $minutes % 60; + + return sprintf('%dh%02d', $h, $m); + } +} +``` + +- [ ] **Step 3 : Créer le template** + +`templates/overtime-contingent/print.html.twig` : + +```twig + + + + + + + +

Contingent heures supplémentaires payées — {{ year }}

+
Édité le {{ exportedAt }}
+ + {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %} + + + + + + {% for m in months %} + + {% endfor %} + + + + + {% for group in groups %} + + + + {% for row in group.rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + {% endfor %} + +
Nom{{ m }}Total payé / payable
{{ group.siteName }}
{{ row.employeeName }}{{ cell }}{{ row.totalHours }} / {{ row.capHours }} h
+ + +``` + +- [ ] **Step 4 : Vérifier le routage API + non-régression** + +Run: `docker exec -t -u www-data php-sirh-fpm php bin/console debug:router | grep overtime-contingent` +Expected: la route `/overtime-contingent/print` apparaît. + +Run: `make test` +Expected: PASS (aucune régression). + +- [ ] **Step 5 : Commit** + +```bash +git add src/ApiResource/OvertimeContingentPrint.php src/State/OvertimeContingentPrintProvider.php templates/overtime-contingent/print.html.twig +git commit -m "feat(overtime-contingent) : export PDF groupé par site (heures supp payées)" +``` + +--- + +## Task 5 : Endpoint lecture fiche employé + +**Files:** +- Create: `src/ApiResource/EmployeeOvertimeContingent.php` +- Create: `src/State/EmployeeOvertimeContingentProvider.php` + +- [ ] **Step 1 : Créer l'ApiResource (output)** + +`src/ApiResource/EmployeeOvertimeContingent.php` : + +```php +employeeRepository->find($employeeId); + if (!$employee instanceof Employee) { + throw new NotFoundHttpException('Employee not found.'); + } + + $request = $this->requestStack->getCurrentRequest(); + $year = (int) ($request?->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'))); + if ($year < 2000 || $year > 2100) { + throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); + } + + // Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12). + $payments = array_merge( + $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year), + $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year + 1), + ); + + $output = new EmployeeOvertimeContingent(); + $output->year = $year; + $output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year); + $output->isDriver = $employee->getIsDriver(); + $output->capHours = $this->calculator->capHours($output->isDriver); + + return $output; + } +} +``` + +- [ ] **Step 3 : Vérifier le routage + non-régression** + +Run: `docker exec -t -u www-data php-sirh-fpm php bin/console debug:router | grep overtime-contingent` +Expected: les deux routes apparaissent (`/overtime-contingent/print` et `/employees/{id}/overtime-contingent`). + +Run: `make test` +Expected: PASS. + +- [ ] **Step 4 : Commit** + +```bash +git add src/ApiResource/EmployeeOvertimeContingent.php src/State/EmployeeOvertimeContingentProvider.php +git commit -m "feat(overtime-contingent) : endpoint lecture contingent fiche employé" +``` + +--- + +## Task 6 : Frontend — Encart header fiche employé + +**Files:** +- Create: `frontend/services/employee-overtime-contingent.ts` +- Modify: `frontend/composables/useEmployeeDetailPage.ts` +- Modify: `frontend/pages/employees/[id].vue` + +- [ ] **Step 1 : Créer le service API** + +`frontend/services/employee-overtime-contingent.ts` : + +```ts +import { useApi } from '~/composables/useApi' + +export interface OvertimeContingent { + year: number + paidMinutes: number + capHours: number + isDriver: boolean +} + +export const getEmployeeOvertimeContingent = async ( + employeeId: number, + year?: number, +): Promise => { + const api = useApi() + const query: Record = {} + if (year) query.year = String(year) + return api.get(`/employees/${employeeId}/overtime-contingent`, { query }) +} +``` + +> NB : vérifier le helper HTTP réellement utilisé dans `frontend/services/` (ex. `employee-rtt-summary.ts` utilise `api.get(...)` / `api.patch(...)`). Aligner l'import (`useApi` ou import direct du client) sur ce fichier voisin. + +- [ ] **Step 2 : Charger le contingent dans le composable** + +Dans `frontend/composables/useEmployeeDetailPage.ts`, ajouter (près des autres `ref` / computed) : + +```ts +import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent' + +const overtimeContingent = ref(null) + +const loadOvertimeContingent = async () => { + if (!employee.value || showRttTab.value !== true) return // non-forfait uniquement + overtimeContingent.value = await getEmployeeOvertimeContingent(employee.value.id) +} + +// Libellé affiché dans le header (non-forfait uniquement). +const overtimeContingentLabel = computed(() => { + const c = overtimeContingent.value + if (!c) return null + const paidH = Math.round((c.paidMinutes / 60) * 10) / 10 + return `Contingent ${c.year} : ${paidH} h / ${c.capHours} h` +}) + +const overtimeContingentExceeded = computed(() => { + const c = overtimeContingent.value + return c ? c.paidMinutes > c.capHours * 60 : false +}) +``` + +Appeler `loadOvertimeContingent()` là où les autres données employé sont chargées (à côté du chargement eager du récap congés / au montage), puis exposer dans le `return` du composable : `overtimeContingentLabel`, `overtimeContingentExceeded`. + +- [ ] **Step 3 : Afficher l'encart dans le header** + +Dans `frontend/pages/employees/[id].vue`, repérer la ligne du header qui rend `nonForfaitPresenceLabel` (libellé `{weeklyHours} heures ({présence})`). Juste après, ajouter : + +```vue +
+ {{ overtimeContingentLabel }} +
+``` + +Et ajouter `overtimeContingentLabel`, `overtimeContingentExceeded` au destructuring de `useEmployeeDetailPage()` dans le `