Files
SIRH/docs/superpowers/plans/2026-06-11-overtime-paid-contingent.md
T
2026-06-11 16:55:00 +02:00

41 KiB
Raw Blame History

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) :

docker exec -t -u www-data php-sirh-fpm php -d memory_limit="512M" vendor/bin/phpunit --filter <TestName>

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

declare(strict_types=1);

namespace App\Tests\Service\WorkHours;

use App\Entity\EmployeeRttPayment;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 */
final class OvertimePaidContingentCalculatorTest extends TestCase
{
    private function payment(int $exerciseYear, int $month, int $base25, int $base50): EmployeeRttPayment
    {
        return new EmployeeRttPayment()
            ->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

declare(strict_types=1);

namespace App\Service\WorkHours;

use App\Entity\EmployeeRttPayment;

/**
 * Convertit les paiements RTT (stockés par exercice Juin N-1 -> 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<EmployeeRttPayment> $payments paiements d'un employé
     *        (typiquement exercices civilYear et civilYear+1)
     *
     * @return array<int, int> 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<EmployeeRttPayment> $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
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 :

final class EmployeeRttPaymentRepository extends ServiceEntityRepository

par :

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 :

    /**
     * Paiements de plusieurs employés sur plusieurs exercices (fetch groupé,
     * évite le N+1 sur l'export PDF). Jointure employé chargée.
     *
     * @param list<Employee> $employees
     * @param list<int>      $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
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

declare(strict_types=1);

namespace App\Dto\WorkHours;

final class OvertimeContingentRow
{
    /**
     * @param array<int, int> $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

declare(strict_types=1);

namespace App\Tests\Service\WorkHours;

use App\Entity\Employee;
use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimeContingentExportBuilder;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;

/**
 * @internal
 */
final class OvertimeContingentExportBuilderTest extends TestCase
{
    public function testBuildsRowsWithMonthlyTotalsAndCap(): void
    {
        // isDriver est résolu via le contrat courant : on le force par une
        // sous-classe anonyme pour rester en test unitaire (sans BDD).
        $driverEmp = new class extends Employee {
            public function getIsDriver(): bool
            {
                return true;
            }
        };
        $driverEmp->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

declare(strict_types=1);

namespace App\Service\WorkHours;

use App\Dto\WorkHours\OvertimeContingentRow;
use App\Entity\Employee;
use App\Repository\EmployeeRttPaymentRepository;

/**
 * Construit, par employé, les heures supp payées (base, hors bonus) ventilées
 * par mois civil pour l'année civile demandée, le total et le plafond légal.
 */
final readonly class OvertimeContingentExportBuilder
{
    public function __construct(
        private EmployeeRttPaymentRepository $rttPaymentRepository,
        private OvertimePaidContingentCalculator $calculator,
    ) {}

    /**
     * @param list<Employee> $employees
     *
     * @return list<OvertimeContingentRow>
     */
    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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\OvertimeContingentPrintProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/overtime-contingent/print',
            provider: OvertimeContingentPrintProvider::class,
            parameters: [
                new QueryParameter(key: 'year', required: true),
                new QueryParameter(key: 'siteIds', required: false),
            ],
            security: "is_granted('ROLE_USER')"
        ),
    ]
)]
final class OvertimeContingentPrint {}
  • Step 2 : Créer le Provider

src/State/OvertimeContingentPrintProvider.php :

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractType;
use App\Repository\EmployeeRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\OvertimeContingentExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;

class OvertimeContingentPrintProvider implements ProviderInterface
{
    public function __construct(
        private Environment $twig,
        private readonly RequestStack $requestStack,
        private EmployeeRepository $employeeRepository,
        private OvertimeContingentExportBuilder $exportBuilder,
        private EmployeeContractResolver $contractResolver,
        private readonly Security $security,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
    {
        $user = $this->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 :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <style>
        @page { margin: 16px; }
        body { font-family: DejaVu Sans, sans-serif; font-size: 10px; color: #000; }
        h1 { font-size: 15px; margin: 0 0 2px; }
        .meta { font-size: 9px; color: #555; margin-bottom: 8px; }
        table { width: 100%; border-collapse: collapse; }
        th, td { border: 1px solid #999; padding: 2px 3px; text-align: center; }
        th { background: #d9d9d9; }
        td.name, th.name { text-align: left; width: 150px; padding-left: 4px; padding-right: 6px; }
        td.data, th.data { width: 44px; font-size: 9px; }
        td.total, th.total { width: 90px; font-weight: bold; white-space: nowrap; }
        td.exceeded { color: #c00; }
        tr.site-title td { text-align: left; font-weight: bold; }
    </style>
</head>
<body>
    <h1>Contingent heures supplémentaires payées — {{ year }}</h1>
    <div class="meta">Édité le {{ exportedAt }}</div>

    {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}

    <table>
        <thead>
            <tr>
                <th class="name">Nom</th>
                {% for m in months %}
                    <th class="data">{{ m }}</th>
                {% endfor %}
                <th class="total">Total payé / payable</th>
            </tr>
        </thead>
        <tbody>
            {% for group in groups %}
                <tr class="site-title">
                    <td colspan="14" style="background: {{ group.siteColor|default('#eee') }}">{{ group.siteName }}</td>
                </tr>
                {% for row in group.rows %}
                    <tr>
                        <td class="name">{{ row.employeeName }}</td>
                        {% for cell in row.cells %}
                            <td class="data">{{ cell }}</td>
                        {% endfor %}
                        <td class="total{{ row.exceeded ? ' exceeded' : '' }}">{{ row.totalHours }} / {{ row.capHours }} h</td>
                    </tr>
                {% endfor %}
            {% endfor %}
        </tbody>
    </table>
</body>
</html>
  • 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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\EmployeeOvertimeContingentProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/employees/{id}/overtime-contingent',
            security: "is_granted('ROLE_ADMIN')",
            provider: EmployeeOvertimeContingentProvider::class
        ),
    ],
    paginationEnabled: false
)]
final class EmployeeOvertimeContingent
{
    public int $year        = 0;
    public int $paidMinutes  = 0;
    public int $capHours     = 0;
    public bool $isDriver    = false;
}
  • Step 2 : Créer le Provider

src/State/EmployeeOvertimeContingentProvider.php :

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeOvertimeContingent;
use App\Entity\Employee;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\WorkHours\OvertimePaidContingentCalculator;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

final readonly class EmployeeOvertimeContingentProvider implements ProviderInterface
{
    public function __construct(
        private RequestStack $requestStack,
        private EmployeeRttPaymentRepository $rttPaymentRepository,
        private OvertimePaidContingentCalculator $calculator,
        private \App\Repository\EmployeeRepository $employeeRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeOvertimeContingent
    {
        $employeeId = (int) ($uriVariables['id'] ?? 0);
        if ($employeeId <= 0) {
            throw new UnprocessableEntityHttpException('id must be a positive integer.');
        }

        $employee = $this->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
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 :

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<OvertimeContingent> => {
  const api = useApi()
  const query: Record<string, string> = {}
  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) :

import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent'

const overtimeContingent = ref<OvertimeContingent | null>(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 :

<div
    v-if="overtimeContingentLabel"
    class="text-md font-semibold"
    :class="overtimeContingentExceeded ? 'text-m-danger' : 'text-primary-500'"
>
    {{ overtimeContingentLabel }}
</div>

Et ajouter overtimeContingentLabel, overtimeContingentExceeded au destructuring de useEmployeeDetailPage() dans le <script setup>.

NB : vérifier le token Tailwind danger réel de la couche @malio/layer-ui (text-m-danger attendu d'après les tokens m-* ; sinon utiliser la classe rouge en vigueur dans le projet, ex. celle déjà utilisée pour les boutons « Supprimer »).

  • Step 4 : Vérifier le rendu (lint/typecheck léger)

Run: cd frontend && npx vue-tsc --noEmit (ou le check de type configuré du projet) Expected: pas d'erreur de type sur les fichiers modifiés.

Ne PAS lancer npm run build (interdit sauf demande explicite).

  • Step 5 : Commit
git add frontend/services/employee-overtime-contingent.ts frontend/composables/useEmployeeDetailPage.ts frontend/pages/employees/[id].vue
git commit -m "feat(overtime-contingent) : encart contingent heures supp dans le header fiche employé"

Task 7 : Frontend — Choix d'export + drawer (liste employés)

Files:

  • Modify: frontend/pages/employees/index.vue

  • Step 1 : Ajouter le type de choix + le ref sites

Dans frontend/pages/employees/index.vue, étendre le type de exportChoice (3 occurrences : ref<...> ligne ~277, le cast dans onExportChoiceChange ligne ~322) en ajoutant 'overtime-contingent' à l'union. Ajouter après exportSalaryMonth :

const exportSiteIds = ref<number[]>([])

Ajouter l'option dans exportTypeOptions :

    { label: 'Contingent H.supp.', value: 'overtime-contingent' }
  • Step 2 : Ajouter le bloc de formulaire dans le drawer

Après le bloc v-else-if="exportChoice === 'night-contingent'" (ligne ~233-241), ajouter :

                <div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
                    <MalioSelect
                        :model-value="exportYear"
                        :options="exportYearOptions"
                        label="Année *"
                        min-width=""
                        @update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
                    />
                    <MalioSelectCheckbox
                        v-model="exportSiteIds"
                        :options="siteOptions"
                        label="Sites"
                    />
                </div>

NB : siteOptions existe déjà (ligne ~343). MalioSelectCheckbox est déjà utilisé dans ce fichier (ligne ~31) — pas d'import à ajouter (auto-import couche Malio).

  • Step 3 : Étendre la validation + le déclenchement

Dans isExportValid (computed), ajouter avant le return true final :

    if (exportChoice.value === 'overtime-contingent') {
        return exportYear.value > 0
    }

(Sites optionnels : vide = tous les sites du périmètre.)

Dans handleExportValidate, ajouter une branche après celle de night-contingent :

    } else if (choice === 'overtime-contingent') {
        const siteParam = exportSiteIds.value.length > 0 ? `&siteIds=${exportSiteIds.value.join(',')}` : ''
        await printPdf(`/overtime-contingent/print?year=${exportYear.value}${siteParam}`)
    }
  • Step 4 : Réinitialiser les sites à l'ouverture du drawer

Dans la fonction qui ouvre le drawer d'export (celle qui fait isExportDrawerOpen.value = true, ligne ~622), ajouter avant l'ouverture :

    exportSiteIds.value = []
  • Step 5 : Vérifier le typecheck

Run: cd frontend && npx vue-tsc --noEmit Expected: pas d'erreur de type.

  • Step 6 : Commit
git add frontend/pages/employees/index.vue
git commit -m "feat(overtime-contingent) : export drawer (année + sites) sur la liste employés"

Task 8 : Documentation (règle projet obligatoire)

Files:

  • Create: doc/overtime-contingent.md

  • Modify: CLAUDE.md

  • Modify: frontend/data/documentation-content.ts

  • Step 1 : Créer doc/overtime-contingent.md

Contenu :

# Contingent d'heures supplémentaires payées

## Objectif
Suivre, par année civile (JanvDé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` (112). 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 15) + exercice `Y+1` (mois 612).

## Implémentation
- Cœur partagé : `App\Service\WorkHours\OvertimePaidContingentCalculator` (pur).
- Repo : `EmployeeRttPaymentRepository::findByEmployeesAndYears`.
- Fiche employé : `GET /employees/{id}/overtime-contingent?year=YYYY` → encart header
  (`Contingent {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 JanvDé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 JuinDéc sur le récap mensuel). À corriger séparément.
  • Step 2 : Mettre à jour CLAUDE.md

Ajouter une section (après la section « Paiement RTT rétroactif ») :

## Contingent heures supplémentaires payées
- Suivi par **année civile** (JanvDé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 15) + exercice Y+1 (mois 612). Cœur partagé pur `OvertimePaidContingentCalculator`.
- **Plafond** résolu sur `isDriver` du **contrat courant**.
- **Fiche employé** : encart header `Contingent {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`,
  `findScoped`) : groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
  colonnes JanvDé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
  JuinDéc (requête par année civile sur un stockage par exercice). Hors périmètre.
- Doc : `doc/overtime-contingent.md`.
  • Step 3 : Mettre à jour la doc in-app

Dans frontend/data/documentation-content.ts, ajouter un article (niveau admin) dans la section pertinente (Exports / Fiche employé). Repérer un article existant comme modèle (ex. l'article décrivant l'export contingent heures de nuit ou le récap salaire) et ajouter un bloc décrivant :

  • l'encart « Contingent » de la fiche employé (année civile courante, X h / plafond) ;
  • l'export PDF « Contingent H.supp. » (drawer : année + sites, groupé par site).

Suivre la structure DocArticle / DocBlock déjà en place (titre, paragraphes). Ne pas inventer de nouveau type de bloc.

  • Step 4 : Vérifier la non-régression globale

Run: make test Expected: PASS.

Run: cd frontend && npx vue-tsc --noEmit Expected: pas d'erreur de type.

  • Step 5 : Commit
git add doc/overtime-contingent.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs(overtime-contingent) : doc fonctionnelle, CLAUDE.md et doc in-app"

Self-Review (auteur du plan)

  • Couverture spec : cœur partagé (Task 1+2), encart fiche (Task 5+6), export PDF (Task 3+4+7), docs (Task 8). Règles métier (base seule, mapping civil, plafond contrat courant, exclusion forfait) couvertes par Task 1/3/4. Bug latent récap salaire consigné (Task 8). ✓
  • Placeholders : aucun « TODO/TBD ». Les deux NB (helper HTTP front, token Tailwind danger) sont des points de vérification d'alignement codebase, pas des trous de spec — le code complet est fourni. ✓
  • Cohérence des types : OvertimeContingentRow (months/totalMinutes/capHours) identique entre Task 1/3/4 ; calculateur (monthlyBaseMinutes/totalBaseMinutes/capHours) cohérent entre Task 1, 3, 5 ; findByEmployeesAndYears (Task 2) consommé en Task 3 ; output EmployeeOvertimeContingent (year/paidMinutes/capHours/isDriver) cohérent entre Task 5 et le service front Task 6. ✓