Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b22270c60 | |||
| acbf1ccecb | |||
| 036399846b | |||
| 0a9b26d31e | |||
| 7dc73f37ac | |||
| dc02316d8b | |||
| 6ba70c36e9 | |||
| ef15d96d2a |
@@ -68,7 +68,7 @@
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
|
||||
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement −prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
|
||||
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement −prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. **Garde : uniquement si le salarié travaille le lundi** (`workDaysHours[lundi] > 0`, i.e. `expectedMinutes > 0`) ; un temps partiel ne travaillant jamais le lundi (ex. Nadia, Mar+Ven) **ne porte aucun déficit** (sinon `(0 − 0) − prorata` lui facturerait à tort le prorata). Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
|
||||
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
|
||||
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
|
||||
- INTERIM: no overtime bonuses, no recovery time
|
||||
@@ -111,9 +111,18 @@
|
||||
## Contingent heures supplémentaires payées
|
||||
- Suivi par **année civile** (Janv–Déc) des heures supp payées vs plafond légal (350 h
|
||||
chauffeur / 220 h autres), non-forfait uniquement.
|
||||
- **Heures payées** = `base25 + base50` (hors bonus). **Mapping** : paiements RTT stockés par
|
||||
exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ; année civile Y = exercice Y
|
||||
(mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur `OvertimePaidContingentCalculator`.
|
||||
- **Heures payées** = `base25 + base50` (hors bonus) **+ heures structurelles**. **Mapping** :
|
||||
paiements RTT stockés par exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ;
|
||||
année civile Y = exercice Y (mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur
|
||||
`OvertimePaidContingentCalculator`.
|
||||
- **Heures structurelles** : les heures contractuelles au-delà de 35h (durée légale) sont des
|
||||
heures supp payées chaque mois, hors paiements RTT (la référence d'un 39h est 39h). Ajoutées
|
||||
au contingent : `(weeklyHours − 35) × 52/12` h/mois = `(weeklyHours − 35) × 260` min (39h →
|
||||
1040 min = 17,33 h/mois). Généralisé à tout contrat non-forfait/non-intérim `weeklyHours > 35`
|
||||
(custom 40h → 21,67 h/mois) ; **proratisé** aux jours sous contrat dans le mois (itère
|
||||
`employee.contractPeriods`). Cœur partagé `StructuralOvertimeContingentCalculator`
|
||||
(`monthlyStructuralMinutes`/`totalStructuralMinutes`), branché sur l'encart fiche
|
||||
(`EmployeeOvertimeContingentProvider`) **et** l'export (`OvertimeContingentExportBuilder`).
|
||||
- **Plafond** résolu sur `isDriver` du **contrat courant**.
|
||||
- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile
|
||||
courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.115'
|
||||
app.version: '0.1.118'
|
||||
|
||||
@@ -154,6 +154,10 @@ soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebd
|
||||
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||
|
||||
Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité
|
||||
planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais
|
||||
le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé.
|
||||
|
||||
- Nature `INTERIM`:
|
||||
- pas de bonus 25%
|
||||
- pas de bonus 50%
|
||||
|
||||
@@ -5,10 +5,26 @@ Suivre, par année civile (Janv–Déc), les heures supplémentaires payées de
|
||||
non-forfait (chauffeurs inclus) face au plafond légal annuel.
|
||||
|
||||
## Règles
|
||||
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus).
|
||||
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus), **+ heures
|
||||
structurelles** (voir ci-dessous).
|
||||
- **Plafond** : 350 h pour les chauffeurs (contrat courant `isDriver`), 220 h sinon.
|
||||
- **Périmètre** : non-forfait uniquement (FORFAIT exclus, ni RTT ni heures supp payées).
|
||||
|
||||
## Heures supplémentaires structurelles
|
||||
Les heures contractuelles **au-delà de 35h** (durée légale) sont des heures supplémentaires
|
||||
payées **chaque mois**, qui ne transitent pas par les paiements RTT (la référence d'un 39h est
|
||||
39h, pas 35h) mais comptent dans le contingent légal.
|
||||
|
||||
- Montant mensuel plein = `(weeklyHours − 35) × 52/12` h = `(weeklyHours − 35) × 260` min.
|
||||
Pour un 39h : `4 × 260 = 1040` min = **17,33 h/mois**.
|
||||
- **Généralisé** à tout contrat non-forfait/non-intérim dont `weeklyHours > 35` (ex. custom
|
||||
40h → 21,67 h/mois). Contrats ≤ 35h, FORFAIT, INTERIM → 0.
|
||||
- **Proratisé** au nombre de jours réellement sous contrat dans le mois (entrée/sortie en cours
|
||||
de mois). Itère les périodes de contrat (`employee.contractPeriods`), pas de requête jour/jour.
|
||||
- Cœur partagé : `App\Service\WorkHours\StructuralOvertimeContingentCalculator`
|
||||
(`monthlyStructuralMinutes` / `totalStructuralMinutes`). Ajouté au total des paiements RTT
|
||||
côté provider (encart fiche) **et** export builder (PDF).
|
||||
|
||||
## Mapping exercice → année civile
|
||||
Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 →
|
||||
Mai N) + `month` (1–12). L'année civile d'un paiement :
|
||||
|
||||
+4
-2
@@ -32,8 +32,10 @@ Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
|
||||
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → −0h48) dans les colonnes
|
||||
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
|
||||
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Les contrats
|
||||
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement).
|
||||
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
|
||||
ne travaille pas le lundi (lundi non planifié au contrat) n'est pas concerné : aucun
|
||||
déficit. Les contrats 35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul
|
||||
normalement).
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM,
|
||||
| Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). |
|
||||
| Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. |
|
||||
| Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. |
|
||||
| Salarié CUSTOM < 35h ne travaillant pas le lundi (ex. Mar+Ven) | `expectedMinutes = workDaysHours[lundi] = 0` → pas de déficit (garde `0 === $expectedMinutes`). |
|
||||
| CUSTOM ≥ 35h (36–38h) | Hors périmètre → pas de déficit. |
|
||||
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
|
||||
| Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
|
||||
|
||||
@@ -537,7 +537,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
||||
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
||||
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là.' },
|
||||
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là. Un salarié qui ne travaille pas le lundi n\'est pas concerné : aucun déficit ne lui est imputé.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -653,6 +653,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'L\'export PDF « Contingent H.supp. » est accessible depuis la liste des employés, via le bouton Export → option « Contingent H.supp. ». Choisissez l\'année civile (par défaut l\'année courante) et éventuellement des sites ; sans sélection de site, tous les sites de votre périmètre sont inclus.' },
|
||||
{ type: 'list', content: 'PDF A4 paysage, une ligne par employé non-forfait, groupé par site\nTri : ordre d\'affichage du site, puis nom, puis prénom\nColonnes : Janv à Déc (heures payées par mois) + colonne « Total payé / payable »\nLes employés FORFAIT n\'apparaissent pas dans cet export' },
|
||||
{ type: 'note', content: 'Les heures prises en compte sont les bases payées (25 % et 50 % confondus), hors majorations. Le contingent est calculé sur l\'année civile (janvier–décembre), indépendamment de l\'exercice RTT (juin–mai) : un paiement RTT saisi pour le mois de juin est rattaché à l\'année civile précédente.' },
|
||||
{ type: 'note', content: 'Heures structurelles : les heures contractuelles au-delà de 35 h (ex. un contrat 39 h) sont des heures supplémentaires payées chaque mois, indépendamment des paiements RTT. Elles sont automatiquement ajoutées au contingent : (heures hebdo − 35) × 52 / 12 par mois, soit 17,33 h/mois pour un 39 h (proratisé aux jours réellement sous contrat). Les contrats forfait, intérim et ≤ 35 h n\'en génèrent pas.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat,
|
||||
// workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun
|
||||
// déficit n'est imputé. Sans cette garde, (0 − 0) − prorata facturerait à tort le prorata
|
||||
// à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven).
|
||||
if (0 === $expectedMinutes) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$prorata = (int) round($weeklyHours * 12);
|
||||
|
||||
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||
|
||||
@@ -17,6 +17,7 @@ final readonly class OvertimeContingentExportBuilder
|
||||
public function __construct(
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private OvertimePaidContingentCalculator $calculator,
|
||||
private StructuralOvertimeContingentCalculator $structuralCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,13 @@ final readonly class OvertimeContingentExportBuilder
|
||||
}
|
||||
|
||||
$employeePayments = $byEmployee[$employeeId] ?? [];
|
||||
$months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||
$paidMonths = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||
$structuralMonths = $this->structuralCalculator->monthlyStructuralMinutes($employee, $civilYear);
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; ++$m) {
|
||||
$months[$m] = $paidMonths[$m] + $structuralMonths[$m];
|
||||
}
|
||||
|
||||
$rows[] = new OvertimeContingentRow(
|
||||
employeeId: $employeeId,
|
||||
|
||||
@@ -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\EmployeeRttPaymentRepository;
|
||||
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -22,6 +23,7 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private OvertimePaidContingentCalculator $calculator,
|
||||
private StructuralOvertimeContingentCalculator $structuralCalculator,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
) {}
|
||||
|
||||
@@ -51,9 +53,10 @@ final readonly class EmployeeOvertimeContingentProvider implements ProviderInter
|
||||
|
||||
$output = new EmployeeOvertimeContingent();
|
||||
$output->year = $year;
|
||||
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year);
|
||||
$output->isDriver = $employee->getIsDriver();
|
||||
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year)
|
||||
+ $this->structuralCalculator->totalStructuralMinutes($employee, $year);
|
||||
$output->isDriver = $employee->getIsDriver();
|
||||
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
||||
self::assertSame(-168, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 4h NE travaillant PAS le jour de solidarité (lundi non planifié, ex. Nadia Mar+Ven) :
|
||||
* workDaysHours[lundi] absent → expected = 0. Le jour de solidarité ne la concerne pas → delta 0,
|
||||
* aucun déficit imputé. C'est la correction du bug : (0 − 0) − 48 ne doit PAS donner −48.
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustomNotScheduledThatDayIsZero(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 0, 0);
|
||||
|
||||
self::assertSame(0, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0.
|
||||
* Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai
|
||||
|
||||
@@ -4,11 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\WorkHours;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Entity\EmployeeRttPayment;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Service\WorkHours\OvertimeContingentExportBuilder;
|
||||
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionProperty;
|
||||
|
||||
@@ -41,7 +46,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase
|
||||
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
|
||||
$repo->method('findByEmployeesAndYears')->willReturn([$payment]);
|
||||
|
||||
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator());
|
||||
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
|
||||
|
||||
$rows = $builder->buildRows([$driverEmp], 2026);
|
||||
|
||||
@@ -64,7 +69,7 @@ final class OvertimeContingentExportBuilderTest extends TestCase
|
||||
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
|
||||
$repo->method('findByEmployeesAndYears')->willReturn([]);
|
||||
|
||||
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator());
|
||||
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
|
||||
$rows = $builder->buildRows([$emp], 2026);
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
@@ -72,4 +77,32 @@ final class OvertimeContingentExportBuilderTest extends TestCase
|
||||
self::assertSame(0, $rows[0]->months[6]);
|
||||
self::assertSame(220, $rows[0]->capHours); // non-driver
|
||||
}
|
||||
|
||||
public function testStructuralHoursOf39hAreAddedToPaidBase(): void
|
||||
{
|
||||
$contract = new Contract()
|
||||
->setName('CDI')
|
||||
->setTrackingMode(TrackingMode::TIME)
|
||||
->setWeeklyHours(39)
|
||||
;
|
||||
$period = new EmployeeContractPeriod()
|
||||
->setContract($contract)
|
||||
->setStartDate(new DateTimeImmutable('2020-01-01'))
|
||||
;
|
||||
$emp = new Employee();
|
||||
$emp->setLastName('Petit')->setFirstName('Marc');
|
||||
$emp->getContractPeriods()->add($period);
|
||||
$idRef = new ReflectionProperty(Employee::class, 'id');
|
||||
$idRef->setValue($emp, 11);
|
||||
|
||||
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
|
||||
$repo->method('findByEmployeesAndYears')->willReturn([]);
|
||||
|
||||
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator(), new StructuralOvertimeContingentCalculator());
|
||||
$rows = $builder->buildRows([$emp], 2026);
|
||||
|
||||
// Aucun paiement RTT, mais 12 × 1040 min de structurel (39h plein sur l'année).
|
||||
self::assertSame(1040, $rows[0]->months[1]);
|
||||
self::assertSame(12 * 1040, $rows[0]->totalMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user