Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
41 KiB
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 PDFsrc/Service/WorkHours/OvertimeContingentExportBuilder.php— fetch groupé + délègue au calculateursrc/ApiResource/OvertimeContingentPrint.php— endpoint PDFsrc/State/OvertimeContingentPrintProvider.php— périmètre, exclusion forfait, groupement site, rendutemplates/overtime-contingent/print.html.twig— gabarit PDF A4 paysagesrc/ApiResource/EmployeeOvertimeContingent.php— endpoint lecture fiche employésrc/State/EmployeeOvertimeContingentProvider.php— provider lecture
Backend (modifiés) :
src/Repository/EmployeeRttPaymentRepository.php— retirerfinal+ ajouterfindByEmployeesAndYears()
Tests (créés) :
tests/Service/WorkHours/OvertimePaidContingentCalculatorTest.phptests/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 headerfrontend/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
finalet 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.tsutiliseapi.get(...)/api.patch(...)). Aligner l'import (useApiou 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-dangerattendu d'après les tokensm-*; 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 :
siteOptionsexiste déjà (ligne ~343).MalioSelectCheckboxest 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 (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
(`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 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.
- 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** (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 `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 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`.
- 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 ; outputEmployeeOvertimeContingent(year/paidMinutes/capHours/isDriver) cohérent entre Task 5 et le service front Task 6. ✓