[#SIRH-36] corriger calcule rtt contrat custom (#27)
Auto Tag Develop / tag (push) Successful in 7s

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #27
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #27.
This commit is contained in:
2026-06-11 08:36:57 +00:00
committed by Autin
parent 081d92b9f4
commit f0387233e4
20 changed files with 1997 additions and 57 deletions
@@ -636,6 +636,7 @@ final class DumpVerificationSnapshotCommand extends Command
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
isFlatRecovery: $detail->isFlatRecovery,
);
continue;
@@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
isFlatRecovery: $detail->isFlatRecovery,
);
}
}
@@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
@@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
isFlatRecovery: $week->isFlatRecovery,
);
}
+1
View File
@@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public int $cumulativeBalanceMinutes = 0,
public bool $isFlatRecovery = false,
) {}
}
+1
View File
@@ -17,5 +17,6 @@ final class WeekRecoveryDetail
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public array $dailyMinutes = [],
public bool $isFlatRecovery = false,
) {}
}
+137 -21
View File
@@ -33,6 +33,7 @@ final readonly class RttRecoveryComputationService
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private SolidarityDayResolver $solidarityDayResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
@@ -162,7 +163,8 @@ final readonly class RttRecoveryComputationService
}
}
$results = [];
$results = [];
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
foreach ($weeks as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
@@ -235,7 +237,7 @@ final readonly class RttRecoveryComputationService
$overtimeReferenceMinutes = $isCustomContract
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($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 %.
@@ -244,35 +246,96 @@ final readonly class RttRecoveryComputationService
? 0
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
foreach ($solidarityDates as $solidarityDate) {
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
continue;
}
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25;
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50;
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
// Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
// c'est ce qui rend la neutralisation correcte (cf. spec).
$solidarityExpected = $this->dailyReferenceResolver->resolve(
$contractAtSolidarity?->getWeeklyHours(),
$solidarityIsoDay,
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
);
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
$totalMinutes = 0;
} elseif ($isCustomContract) {
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
} else {
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
$contractAtSolidarity,
$solidarityExpected,
$dailyWorkedMinutes[$solidarityDate],
);
}
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyWorkedMinutes,
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
$results[$weekKey] = $this->buildWeekRecoveryDetail(
$isWeekPresenceTracking,
$disableOvertimeBonuses,
$isCustomContract,
$weeklyOvertimeTotalMinutes,
$rawBase25,
$rawBase50,
$dailyWorkedMinutes,
);
}
return $results;
}
/**
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
* des bandes d'heures sup brutes.
*
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
* le total, donc une semaine travaillée sous les heures contractuelles produit un
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
* provider ne draine pas les tranches 25/50.
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
*
* @param array<string, int> $dailyMinutes
*/
private function buildWeekRecoveryDetail(
bool $isPresence,
bool $disableBonuses,
bool $isCustom,
int $overtimeTotalMinutes,
int $rawBase25,
int $rawBase50,
array $dailyMinutes,
): WeekRecoveryDetail {
$noBands = $isPresence || $disableBonuses || $isCustom;
$base25 = $noBands ? 0 : $rawBase25;
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
$base50 = $noBands ? 0 : $rawBase50;
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
if ($isPresence || $disableBonuses) {
$totalMinutes = 0;
} elseif ($isCustom) {
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
} else {
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
}
return new WeekRecoveryDetail(
overtimeMinutes: $overtimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyMinutes,
isFlatRecovery: $isCustom,
);
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
@@ -415,6 +478,59 @@ final readonly class RttRecoveryComputationService
return $weekDays[0];
}
/**
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
*
* @return list<string> dates au format 'Y-m-d'
*/
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$dates = [];
$firstYear = (int) $from->format('Y');
$lastYear = (int) $to->format('Y');
for ($year = $firstYear; $year <= $lastYear; ++$year) {
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
if ($candidate >= $from && $candidate <= $to) {
$dates[] = $candidate->format('Y-m-d');
}
}
return $dates;
}
/**
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
*
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
* semaine par ailleurs normale, le net vaut exactement prorata. Renvoie le delta à
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
*/
private function computeSolidarityDeficitAdjustment(
?Contract $contractAtSolidarity,
int $expectedMinutes,
int $workedMinutes,
): int {
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
$type = ContractType::resolve(
$contractAtSolidarity?->getName(),
$contractAtSolidarity?->getTrackingMode(),
$weeklyHours,
);
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
return 0;
}
$prorata = (int) round($weeklyHours * 12);
return ($expectedMinutes - $workedMinutes) - $prorata;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use DateTimeImmutable;
/**
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
*
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
* = dimanche de Pâques + 50 jours.
*/
final class SolidarityDayResolver
{
public function pentecostMonday(int $year): DateTimeImmutable
{
return $this->easterSunday($year)->modify('+50 days');
}
private function easterSunday(int $year): DateTimeImmutable
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
}
+57 -30
View File
@@ -142,36 +142,13 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->rttStartDate = $this->rttStartDate;
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($summary->weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
} else {
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$summary->weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
}
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%).
// Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul.
$summary->weeks = $this->applyDeficitCascade(
$summary->weeks,
$carry->base25Minutes + $carry->bonus25Minutes,
$carry->base50Minutes + $carry->bonus50Minutes,
);
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
$monthBuckets = [];
@@ -356,6 +333,54 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $weekEnd;
}
/**
* Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord,
* puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les
* montants négatifs drainés.
*
* Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de
* tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à
* partir de totalMinutes) et les colonnes 25/50 restent à 0.
*
* @param list<EmployeeRttWeekSummary> $weeks
*
* @return list<EmployeeRttWeekSummary>
*/
private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array
{
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
continue;
}
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
isFlatRecovery: $week->isFlatRecovery,
);
}
return $weeks;
}
/**
* Build week summaries, splitting weeks that span two months into two entries
* with values distributed proportionally based on daily worked minutes.
@@ -393,6 +418,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
isFlatRecovery: $detail->isFlatRecovery,
);
continue;
@@ -433,6 +459,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
isFlatRecovery: $detail->isFlatRecovery,
);
}
}