feat(overtime-contingent) : heures supp structurelles (>35h) ajoutées au contingent
Auto Tag Develop / tag (push) Successful in 6s

Les heures contractuelles au-delà de 35h (ex. 39h → 17,33h décimales = 17h20/mois)
sont payées chaque mois sans transiter par les paiements RTT (référence 39h). Elles
manquaient au contingent. Ajout via StructuralOvertimeContingentCalculator :
(weeklyHours-35)×260 min/mois, généralisé aux contrats non-forfait/non-intérim >35h,
proratisé aux jours sous contrat. Branché sur l'encart fiche et l'export PDF.

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