fix(rtt) : jour de solidarité sans déficit si le salarié ne travaille pas le lundi
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
Un contrat CUSTOM < 35h qui ne travaille pas le lundi (jour de solidarité, workDaysHours[lundi] absent → attendu = 0) ne portait à tort un déficit forfaitaire ((0 − 0) − prorata = −prorata). Garde ajoutée : aucun déficit quand expectedMinutes === 0. Ewa (Lun+Jeu) reste à −0h48 ; Nadia (Mar+Ven) passe de −0h48 à 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@
|
|||||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||||
- Contracts >= 39h: +25% from 39h 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.
|
- 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%.
|
- **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).
|
- **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
|
- INTERIM: no overtime bonuses, no recovery time
|
||||||
|
|||||||
@@ -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),
|
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||||
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
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`:
|
- Nature `INTERIM`:
|
||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
|
|||||||
+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
|
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
|
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 :
|
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
|
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
|
||||||
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement).
|
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
|
## 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). |
|
| 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. |
|
| 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é 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. |
|
| CUSTOM ≥ 35h (36–38h) | Hors périmètre → pas de déficit. |
|
||||||
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
|
| 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. |
|
| 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: '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: '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: '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é.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService
|
|||||||
return 0;
|
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);
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
return ($expectedMinutes - $workedMinutes) - $prorata;
|
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||||
|
|||||||
@@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
|||||||
self::assertSame(-168, $delta);
|
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.
|
* 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
|
* Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai
|
||||||
|
|||||||
Reference in New Issue
Block a user