diff --git a/CLAUDE.md b/CLAUDE.md index ab2ecbb..76b5887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ - 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), deficit doesn't impact balance - **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 - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 9a3da08..f902f3d 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -124,6 +124,11 @@ Documents complementaires: - contrats >= 39h: de 39h à 43h - Tranche 50%: - au-delà de 43h +- Embauche/fin de contrat en milieu de semaine (calcul RTT — `RttRecoveryComputationService`): + - les seuils sont proratisés aux jours réellement contractés de la semaine (les jours hors contrat ne comptent pas) + - le seuil de départ du 25% **et** le plafond 25%/50% sont décalés ensemble ; la bande 25% garde sa largeur réglementaire (4h pour un 39h, 8h pour un 35h) + - une semaine d'embauche peut ainsi ouvrir à la fois du 25% et du 50% (ex. CDD 39h embauché le jeudi, 22h travaillées → 4h à 25% + 3h à 50%) + - note: la synthèse de l'écran Heures (vue semaine) n'applique pas cette proratisation (calcul distinct dans `WorkHourWeeklySummaryProvider`) - Date de début RTT (`RTT_START_DATE` dans `.env`): - les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération - permet d'éviter les déficits fictifs avant la mise en service du logiciel diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index f5cbbdd..853dc04 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -513,6 +513,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' }, { type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' }, + { type: 'note', content: 'Pour un contrat débutant en milieu de semaine, le calcul RTT proratise les seuils d\'heures supplémentaires aux jours réellement contractés : le seuil de départ du +25 % et le plafond séparant le +25 % du +50 % sont décalés ensemble (la bande +25 % garde sa largeur : 4h pour un 39h, 8h pour un 35h). Une semaine d\'embauche peut donc générer à la fois des heures à 25 % et à 50 %.' }, ], }, { diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 0d26a7b..e8d4bc7 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -236,13 +236,19 @@ final readonly class RttRecoveryComputationService ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate) : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); + // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 % + // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu + // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %. + $overtime50StartMinutes = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract); $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; - $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes); + [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); + + $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25; $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25); - $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60); + $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50; $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5); if ($isWeekPresenceTracking || $disableOvertimeBonuses) { @@ -452,18 +458,31 @@ final readonly class RttRecoveryComputationService return $total; } - private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int + /** + * Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine : + * 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté + * pour obtenir le plafond 25 %/50 %. + */ + private function resolveOvertime25BandWidthMinutes(?Contract $contract): int { - $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes); + $hours = $contract?->getWeeklyHours(); + $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; - return (int) round($trancheMinutes * 0.25); + return (43 - $startHours) * 60; } - private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int + /** + * Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %. + * La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %. + * + * @return array{int, int} [base25Minutes, base50Minutes] + */ + private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array { - $trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60)); + $base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes); + $base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes); - return (int) round($trancheMinutes * 0.5); + return [$base25, $base50]; } private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index 5500e56..b0c9cf5 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -67,6 +67,52 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame('2026-03-16', $anchor); } + public function testResolveOvertime25BandWidthIs4hForH39(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + $contract = new Contract()->setWeeklyHours(39); + + self::assertSame(4 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); + } + + public function testResolveOvertime25BandWidthIs8hForH35(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + $contract = new Contract()->setWeeklyHours(35); + + self::assertSame(8 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); + } + + /** + * Dylan Chaboisson, semaine 12 : embauché le jeudi sur un contrat 39h. + * Total travaillé 22h (1320 min), départ 25 % proraté aux jours contractés = 15h (900 min), + * plafond 25 %/50 % = 15h + bande 4h = 19h (1140 min). Le plafond se décale avec + * l'embauche au lieu de rester bloqué à 43h, ouvrant la tranche 50 %. + */ + public function testMidWeekHireSplitsOvertimeAcross25And50(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 1320, 900, 1140); + + self::assertSame(4 * 60, $base25); + self::assertSame(3 * 60, $base50); + } + + /** + * Régression : semaine pleine 39h (départ 39h, plafond 43h), 46h travaillées → + * 4h à 25 % (39→43) et 3h à 50 % (43→46), comportement inchangé. + */ + public function testFullWeekOvertimeSplitUnchanged(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 2760, 2340, 2580); + + self::assertSame(4 * 60, $base25); + self::assertSame(3 * 60, $base50); + } + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);