fix : ancrer la clôture dynamique des congés sur le solde bootstrap
computeDynamicClosingForYear (qui produit le report d'ouverture de l'exercice suivant) ignorait la table employee_leave_balances et recalculait depuis l'embauche, sans absences historiques. Pour un exercice consulté en avance, il cumulait donc une année pleine d'acquisition par exercice antérieur à la mise en service. Cas Aurore (CDI depuis 2022, bootstrap 2026 = report 32 / pris 24) : report d'ouverture 2027 affiché à 88,39 j au lieu de 31. La vue courante était juste car le provider, lui, lit déjà le bootstrap. La clôture dynamique applique désormais la même règle que EmployeeLeaveSummaryProvider::computeYearSummary : si une ligne bootstrap existe pour l'exercice, on part de opening_days/opening_saturdays et on ajoute l'offset taken_days/taken_saturdays, au lieu du report dynamique accumulé. Vérifié sur données réelles : 88,39 -> 31,00 j. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,7 @@ Etat implementation:
|
||||
- la table est creee
|
||||
- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
|
||||
- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
|
||||
- **le report dynamique (`LeaveBalanceComputationService::computeDynamicClosingForYear`, qui alimente le solde d'ouverture de l'exercice suivant) ancre lui aussi sur cette table** : pour chaque exercice de sa boucle, si une ligne bootstrap existe il part de `opening_days/opening_saturdays` (et ajoute l'offset `taken_days/taken_saturdays`) au lieu de recalculer depuis l'embauche. Sans cet ancrage, la clôture d'un exercice consulté en avance (ex. exercice suivant) cumulerait une année pleine d'acquisition par exercice antérieur à la mise en service — aucune absence historique n'étant saisie (cas Aurore : 88 jours au lieu de 31).
|
||||
- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
|
||||
|
||||
### Definition des colonnes
|
||||
|
||||
@@ -481,6 +481,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
||||
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
||||
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
|
||||
],
|
||||
},
|
||||
@@ -537,6 +538,7 @@ export const documentationSections: DocSection[] = [
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés (Juin → Mai). La plage proposée part de l\'exercice suivant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
|
||||
{ type: 'note', content: 'Sur l\'exercice suivant, le report N-1 affiché est provisoire tant que l\'exercice courant n\'est pas clôturé.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -51,9 +51,20 @@ final readonly class LeaveBalanceComputationService
|
||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
|
||||
|
||||
// Bootstrap anchor: a manually-entered opening balance (production data
|
||||
// bootstrap) is the source of truth for the carry of that year — exactly
|
||||
// like EmployeeLeaveSummaryProvider::computeYearSummary for the live view.
|
||||
// Without it, the closing would be recomputed from the contract start with no
|
||||
// historical absences, inflating the carry by one full year of accrual for
|
||||
// every exercise predating the software (cas Aurore : 88 au lieu de 31).
|
||||
$openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||||
|
||||
$carryDays = 0.0;
|
||||
$carrySaturdays = 0.0;
|
||||
if ($year > $firstYear) {
|
||||
if (null !== $openingBalance) {
|
||||
$carryDays = $openingBalance->getOpeningDays();
|
||||
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $openingBalance->getOpeningSaturdays() : 0.0;
|
||||
} elseif ($year > $firstYear) {
|
||||
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
|
||||
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
|
||||
if (!$hasSettlementOnPreviousYear) {
|
||||
@@ -63,7 +74,10 @@ final readonly class LeaveBalanceComputationService
|
||||
}
|
||||
|
||||
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
||||
if ($effectiveFrom > $from) {
|
||||
// A shifted start (new hire / settled closure) zeroes the dynamic carry, but
|
||||
// an explicit bootstrap opening balance must be preserved (it already reflects
|
||||
// the real situation at the bootstrap date).
|
||||
if ($effectiveFrom > $from && null === $openingBalance) {
|
||||
$carryDays = 0.0;
|
||||
$carrySaturdays = 0.0;
|
||||
}
|
||||
@@ -74,11 +88,14 @@ final readonly class LeaveBalanceComputationService
|
||||
// Business days for forfait must use the RAW holiday list (excluded holidays
|
||||
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
|
||||
// days for the 218-day legal target).
|
||||
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||||
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
||||
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||||
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
||||
if (null !== $openingBalance) {
|
||||
$takenDays += $openingBalance->getTakenDays();
|
||||
}
|
||||
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||
$previousRemainingSaturdays = 0.0;
|
||||
|
||||
@@ -120,6 +137,10 @@ final readonly class LeaveBalanceComputationService
|
||||
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
|
||||
if (null !== $openingBalance) {
|
||||
$takenDays += $openingBalance->getTakenDays();
|
||||
$takenSaturdays += $openingBalance->getTakenSaturdays();
|
||||
}
|
||||
|
||||
$acquiredWithFractioned = $carryDays + $fractionedDays;
|
||||
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
|
||||
|
||||
Reference in New Issue
Block a user