Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit #21
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user