From 4a2c3a8eed44fe335fcaae342d236b90015e3593 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 13 Mar 2026 10:26:33 +0100 Subject: [PATCH] =?UTF-8?q?feat=20:=20Ajout=20du=20syst=C3=A8me=20de=20RTT?= =?UTF-8?q?=20sur=20la=20page=20employ=C3=A9=20avec=20le=20repport=20annue?= =?UTF-8?q?l=20des=20heures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-03-13-rtt-tab-redesign.md | 563 ++++++++++++++++++ .../specs/2026-03-13-rtt-tab-redesign.md | 117 ++++ frontend/components/employees/RttTab.vue | 493 ++++++++++----- frontend/composables/useEmployeeDetailPage.ts | 13 +- frontend/pages/calendar.vue | 4 - frontend/pages/employees/[id].vue | 6 +- frontend/services/dto/employee-rtt-summary.ts | 19 +- frontend/services/employee-rtt-summary.ts | 12 +- migrations/Version20260313080007.php | 54 ++ migrations/Version20260313092249.php | 26 + src/ApiResource/EmployeeRttPaymentInput.php | 10 +- src/ApiResource/EmployeeRttSummary.php | 5 + src/Command/RttRolloverCommand.php | 9 +- src/Dto/Rtt/EmployeeRttWeekSummary.php | 7 +- src/Dto/Rtt/RttMonthPayment.php | 6 +- src/Dto/Rtt/WeekRecoveryDetail.php | 17 + src/Entity/EmployeeRttBalance.php | 77 ++- src/Entity/EmployeeRttPayment.php | 54 +- .../EmployeeRttPaymentRepository.php | 3 +- src/Repository/WorkHourRepository.php | 39 +- .../Leave/LeaveBalanceComputationService.php | 68 +-- .../Leave/SuspensionDaysCalculator.php | 29 + .../Rtt/RttRecoveryComputationService.php | 50 +- src/State/AbsenceWriteProcessor.php | 33 +- src/State/EmployeeLeaveSummaryProvider.php | 129 ++-- src/State/EmployeeRttPaymentProcessor.php | 19 +- src/State/EmployeeRttSummaryProvider.php | 73 ++- .../Leave/SuspensionDaysCalculatorTest.php | 34 ++ tests/State/AbsenceWriteProcessorTest.php | 17 +- 29 files changed, 1595 insertions(+), 391 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md create mode 100644 docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md create mode 100644 migrations/Version20260313080007.php create mode 100644 migrations/Version20260313092249.php create mode 100644 src/Dto/Rtt/WeekRecoveryDetail.php diff --git a/docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md b/docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md new file mode 100644 index 0000000..1f94672 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md @@ -0,0 +1,563 @@ +# Refonte onglet RTT — Plan d'implémentation + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs. + +**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes. + +**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL. + +**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md` + +--- + +## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek` + +**Files:** +- Create: `src/Dto/Rtt/WeekRecoveryDetail.php` +- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206` + +Actuellement `computeRecoveryByWeek` retourne `array` (weekKey => totalMinutes). Il faut retourner `array` avec le détail ventilé. + +- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`** + +```php +// src/Dto/Rtt/WeekRecoveryDetail.php +`** + +Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées. + +La logique de ventilation des heures de base entre palier 25% et palier 50% : +- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple : + - `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0 + - Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h + +Reprenons la logique existante (lignes 189-202) : +- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours) +- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h) +- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes +- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25 +- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5 + +Pour la ventilation : +- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup. + +En fait : +- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference` +- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé. +- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé. + +Modifier les lignes 191-202 : + +```php +$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking + ? 0 + : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes); + +$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes); +$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25); +$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60); +$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5); + +$results[$weekKey] = new WeekRecoveryDetail( + overtimeMinutes: $weeklyOvertimeTotalMinutes, + base25Minutes: $base25, + bonus25Minutes: $bonus25, + base50Minutes: $base50, + bonus50Minutes: $bonus50, + totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses) + ? 0 + : $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50, +); +``` + +Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0). + +- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé** + +Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over. + +```php +public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail +{ + [$from, $to] = $this->resolveExerciseBounds($exerciseYear); + $weeks = $this->buildWeeksForExercise($from, $to); + $weekRanges = array_map( + static fn (array $week): array => [ + 'month' => (int) $week['month'], + 'weekNumber' => (int) $week['weekNumber'], + 'start' => $week['start'], + 'end' => $week['end'], + ], + $weeks + ); + + $byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null); + + $total = new WeekRecoveryDetail(); + foreach ($byWeek as $detail) { + $total = new WeekRecoveryDetail( + overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes, + base25Minutes: $total->base25Minutes + $detail->base25Minutes, + bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes, + base50Minutes: $total->base50Minutes + $detail->base50Minutes, + bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes, + totalMinutes: $total->totalMinutes + $detail->totalMinutes, + ); + } + + return $total; +} +``` + +- [ ] **Step 4: Vérifier que le code compile** + +Run: `docker exec php-sirh-fpm php bin/console cache:clear` + +--- + +## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover + +**Files:** +- Modify: `src/Entity/EmployeeRttBalance.php` +- Modify: `src/Repository/EmployeeRttBalanceRepository.php` +- Modify: `src/Command/RttRolloverCommand.php` + +Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin. + +- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`** + +Remplacer la propriété `$openingMinutes` par : + +```php +#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])] +private int $openingBase25Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])] +private int $openingBonus25Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])] +private int $openingBase50Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])] +private int $openingBonus50Minutes = 0; +``` + +Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs. + +- [ ] **Step 2: Adapter `RttRolloverCommand`** + +`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs : + +```php +$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear); + +$balance = new EmployeeRttBalance() + ->setEmployee($employee) + ->setYear($targetYear) + ->setOpeningBase25Minutes($carry->base25Minutes) + ->setOpeningBonus25Minutes($carry->bonus25Minutes) + ->setOpeningBase50Minutes($carry->base50Minutes) + ->setOpeningBonus50Minutes($carry->bonus50Minutes) + ->setIsLocked(false) +; +``` + +- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`** + +Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` : + +```php +private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail +{ + $balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year); + if (null !== $balance) { + return new WeekRecoveryDetail( + base25Minutes: $balance->getOpeningBase25Minutes(), + bonus25Minutes: $balance->getOpeningBonus25Minutes(), + base50Minutes: $balance->getOpeningBase50Minutes(), + bonus50Minutes: $balance->getOpeningBonus50Minutes(), + totalMinutes: $balance->getTotalOpeningMinutes(), + ); + } + + return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1); +} +``` + +Adapter le provider pour utiliser le carry ventilé dans le summary : +- `carryFromPreviousYearMinutes` = carry->totalMinutes +- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend + +- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`** + +```php +public int $carryBase25Minutes = 0; +public int $carryBonus25Minutes = 0; +public int $carryBase50Minutes = 0; +public int $carryBonus50Minutes = 0; +``` + +- [ ] **Step 5: Générer et exécuter la migration** + +Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff` +Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction` + +Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration. + +--- + +## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration + +**Files:** +- Modify: `src/Entity/EmployeeRttPayment.php` +- Modify: `src/Repository/EmployeeRttPaymentRepository.php` + +- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité** + +Remplacer les propriétés `$minutes` et `$rate` par : + +```php +#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])] +private int $base25Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])] +private int $bonus25Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])] +private int $base50Minutes = 0; + +#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])] +private int $bonus50Minutes = 0; +``` + +Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`. + +- [ ] **Step 2: Adapter le repository** + +Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) : + +```php +public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment +{ + return $this->findOneBy([ + 'employee' => $employee, + 'year' => $year, + 'month' => $month, + ]); +} +``` + +- [ ] **Step 3: Générer et vérifier la migration** + +Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff` + +Vérifier que la migration : +- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes` +- Supprime `minutes` et `rate` + +Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction` + +--- + +## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary` + +**Files:** +- Modify: `src/Dto/Rtt/RttMonthPayment.php` +- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php` + +- [ ] **Step 1: Modifier `RttMonthPayment`** + +Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs : + +```php +final class RttMonthPayment +{ + public function __construct( + public int $month, + public int $paidBase25Minutes = 0, + public int $paidBonus25Minutes = 0, + public int $paidBase50Minutes = 0, + public int $paidBonus50Minutes = 0, + ) {} +} +``` + +- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`** + +Ajouter les champs de détail : + +```php +final class EmployeeRttWeekSummary +{ + public function __construct( + public int $month, + public int $weekNumber, + public string $weekStart, + public string $weekEnd, + public int $overtimeMinutes = 0, + public int $base25Minutes = 0, + public int $bonus25Minutes = 0, + public int $base50Minutes = 0, + public int $bonus50Minutes = 0, + public int $totalMinutes = 0, + ) {} +} +``` + +Supprimer l'ancien champ `recoveryMinutes`. + +--- + +## Task 4: Adapter le provider et le processor backend + +**Files:** +- Modify: `src/State/EmployeeRttSummaryProvider.php` +- Modify: `src/ApiResource/EmployeeRttSummary.php` +- Modify: `src/ApiResource/EmployeeRttPaymentInput.php` +- Modify: `src/State/EmployeeRttPaymentProcessor.php` + +- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`** + +Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` : + +```php +$summary->weeks = array_map( + static function (array $week) use ($currentByWeekStart) { + $detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail(); + + return new EmployeeRttWeekSummary( + month: (int) $week['month'], + weekNumber: (int) $week['weekNumber'], + weekStart: $week['start']->format('Y-m-d'), + weekEnd: $week['end']->format('Y-m-d'), + overtimeMinutes: $detail->overtimeMinutes, + base25Minutes: $detail->base25Minutes, + bonus25Minutes: $detail->bonus25Minutes, + base50Minutes: $detail->base50Minutes, + bonus50Minutes: $detail->bonus50Minutes, + totalMinutes: $detail->totalMinutes, + ); + }, + $weekRanges +); +``` + +Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` : + +```php +$summary->currentYearRecoveryMinutes = array_sum( + array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart) +); +``` + +Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs : + +```php +foreach ($payments as $payment) { + $m = $payment->getMonth(); + if (!isset($monthBuckets[$m])) { + $monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0]; + } + $monthBuckets[$m]['base25'] += $payment->getBase25Minutes(); + $monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes(); + $monthBuckets[$m]['base50'] += $payment->getBase50Minutes(); + $monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes(); +} + +foreach ($monthBuckets as $m => $bucket) { + $monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']); + $totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50']; +} +``` + +- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`** + +```php +final class EmployeeRttPaymentInput +{ + public int $month = 0; + public int $base25Minutes = 0; + public int $bonus25Minutes = 0; + public int $base50Minutes = 0; + public int $bonus50Minutes = 0; + public ?int $year = null; +} +``` + +- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`** + +Supprimer la validation du `rate`. Adapter le upsert : + +```php +$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month); + +if (null === $payment) { + $payment = new EmployeeRttPayment(); + $payment->setEmployee($employee); + $payment->setYear($year); + $payment->setMonth($data->month); + $this->entityManager->persist($payment); +} + +$payment->setBase25Minutes($data->base25Minutes); +$payment->setBonus25Minutes($data->bonus25Minutes); +$payment->setBase50Minutes($data->base50Minutes); +$payment->setBonus50Minutes($data->bonus50Minutes); +$payment->touch(); +$this->entityManager->flush(); +``` + +- [ ] **Step 4: Vérifier** + +Run: `docker exec php-sirh-fpm php bin/console cache:clear` + +--- + +## Task 5: Adapter le frontend — DTOs et service + +**Files:** +- Modify: `frontend/services/dto/employee-rtt-summary.ts` +- Modify: `frontend/services/employee-rtt-summary.ts` + +- [ ] **Step 1: Mettre à jour les types TS** + +```typescript +export type EmployeeRttWeekSummary = { + month: number + weekNumber: number + weekStart: string + weekEnd: string + overtimeMinutes: number + base25Minutes: number + bonus25Minutes: number + base50Minutes: number + bonus50Minutes: number + totalMinutes: number +} + +export type RttMonthPayment = { + month: number + paidBase25Minutes: number + paidBonus25Minutes: number + paidBase50Minutes: number + paidBonus50Minutes: number +} + +export type EmployeeRttSummary = { + year: number + carryFromPreviousYearMinutes: number + carryBase25Minutes: number + carryBonus25Minutes: number + carryBase50Minutes: number + carryBonus50Minutes: number + currentYearRecoveryMinutes: number + totalPaidMinutes: number + availableMinutes: number + weeks: EmployeeRttWeekSummary[] + monthPayments: RttMonthPayment[] +} +``` + +- [ ] **Step 2: Adapter le service `createRttPayment`** + +```typescript +export const createRttPayment = async ( + employeeId: number, + month: number, + base25Minutes: number, + bonus25Minutes: number, + base50Minutes: number, + bonus50Minutes: number, + year?: number +) => { + const api = useApi() + const body: Record = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes } + if (year) body.year = year + return api.patch(`/employees/${employeeId}/rtt-payments`, body) +} +``` + +--- + +## Task 6: Réécrire `RttTab.vue` + +**Files:** +- Modify: `frontend/components/employees/RttTab.vue` + +- [ ] **Step 1: Réécrire le composant complet** + +Structure du template : +1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure" +2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total +3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-") +4. 5 lignes semaines (padding si < 5) +5. Ligne Total (somme par colonne, incluant le report si présent) +6. Ligne Payé (valeurs négatives, "-" pour colonne Heure) +7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure) +8. Bouton "+ Payer les RRT" +9. Drawer de paiement avec 5 champs + +Script setup : +- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai) +- Initialiser `currentMonthIndex` au mois courant dans l'exercice +- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index +- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5 +- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments` +- Totaux par colonne : computed sommant les semaines +- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`) +- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11] + +Drawer de paiement : +- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures) +- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures +- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois + +- [ ] **Step 2: Adapter le composant parent** + +Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`. + +Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent. + +--- + +## Task 7: Test de bout en bout + +- [ ] **Step 1: Vérifier le cache et la migration** + +```bash +docker exec php-sirh-fpm php bin/console cache:clear +docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 2: Tester l'API** + +Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine. +Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs. + +- [ ] **Step 3: Tester le frontend** + +- Navigation mensuelle (flèches, mois courant par défaut) +- Tableau : vérifier les valeurs par semaine +- Paiement : créer, modifier, vérifier pré-remplissage +- "RTT À LA DATE DU JOUR" : vérifier le cumul diff --git a/docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md b/docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md new file mode 100644 index 0000000..893f756 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md @@ -0,0 +1,117 @@ +# Refonte onglet RTT employé + +## Contexte + +L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs. + +## Maquette de référence + +Fichier : `RTT.png` à la racine du projet. + +## Structure de la vue + +### En-tête + +- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite) +- Navigation limitée aux mois de l'exercice (juin N-1 à mai N) +- Mois courant affiché par défaut à l'ouverture +- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues) + +### Tableau + +7 colonnes : + +| Semaine | Heure | Base | 25% | Base | 50% | Total | +|---------|-------|------|-----|------|-----|-------| + +- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines) +- **Heure** : heures supplémentaires brutes de la semaine +- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h) +- **25%** : bonus = base 25% × 0.25 +- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h) +- **50%** : bonus = base 50% × 0.50 +- **Total** : somme de toutes les bases + tous les bonus + +### Lignes de synthèse + +- **Total** : somme des 5 semaines par colonne +- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-" +- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-" + +### Bouton + +`+ Payer les RRT` en bas, centré. Ouvre un drawer. + +## Drawer de paiement + +Champs : +1. **Mois** (select) : liste des mois de l'exercice +2. **Base 25%** (number, en heures) +3. **Heures 25%** (number, en heures) +4. **Base 50%** (number, en heures) +5. **Heures 50%** (number, en heures) + +Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification. + +Boutons : Annuler / Enregistrer. + +## Rattachement semaine → mois + +Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`). + +## Backend + +### Modification de `EmployeeRttSummary` + +Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel. + +Nouvelles données par semaine : +- `overtimeMinutes` : heures sup brutes +- `base25Minutes` : base palier 25% +- `bonus25Minutes` : bonus 25% +- `base50Minutes` : base palier 50% +- `bonus50Minutes` : bonus 50% +- `totalMinutes` : somme base + bonus + +### Modification de `EmployeeRttPayment` + +Remplacer les champs `minutes` (int) + `rate` (int 25/50) par : +- `base25Minutes` (int) +- `bonus25Minutes` (int) +- `base50Minutes` (int) +- `bonus50Minutes` (int) + +Migration Doctrine nécessaire. + +### Modification de `EmployeeRttPaymentInput` + +Adapter les champs pour correspondre aux 4 nouvelles valeurs. + +### Modification de `RttRecoveryComputationService` + +`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer. + +## Frontend + +### Stockage vs affichage + +- Backend : stockage en **minutes** (inchangé) +- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie + +### Réécriture de `RttTab.vue` + +- Supprimer la grille annuelle de 12 mois +- Navigation mensuelle avec état réactif (mois courant) +- Tableau HTML avec les 7 colonnes décrites +- 5 lignes semaines + Total + Payé + Reste +- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m") + +### Modification du DTO TypeScript + +Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs. + +## Unités de conversion + +- Affichage : heures et minutes (ex: "6h 30m", "30 m") +- Saisie paiement : en heures décimales (number input) +- Stockage : minutes entières (int) diff --git a/frontend/components/employees/RttTab.vue b/frontend/components/employees/RttTab.vue index 28137b7..a343f5e 100644 --- a/frontend/components/employees/RttTab.vue +++ b/frontend/components/employees/RttTab.vue @@ -1,80 +1,212 @@ diff --git a/frontend/composables/useEmployeeDetailPage.ts b/frontend/composables/useEmployeeDetailPage.ts index b02fb54..da03f5e 100644 --- a/frontend/composables/useEmployeeDetailPage.ts +++ b/frontend/composables/useEmployeeDetailPage.ts @@ -71,6 +71,7 @@ export const useEmployeeDetailPage = () => { const contractHistory = computed(() => employee.value?.contractHistory ?? []) const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM') + const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT) const employeeContractWorkLabel = computed(() => { const contract = employee.value?.contract if (!contract) return '-' @@ -242,7 +243,9 @@ export const useEmployeeDetailPage = () => { showLeaveTab.value ? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear) : Promise.resolve(null), - getEmployeeRttSummary(loadedEmployee.id, rttYear), + showRttTab.value + ? getEmployeeRttSummary(loadedEmployee.id, rttYear) + : Promise.resolve(null), ...holidayYears.map((y) => listPublicHolidays('metropole', y)) ]) employeeAbsences.value = absences @@ -252,6 +255,9 @@ export const useEmployeeDetailPage = () => { if (!showLeaveTab.value && activeTab.value === 'leave') { activeTab.value = 'contract' } + if (!showRttTab.value && activeTab.value === 'rtt') { + activeTab.value = 'contract' + } } finally { isLoading.value = false } @@ -374,10 +380,10 @@ export const useEmployeeDetailPage = () => { await loadEmployee() } - const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => { + const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => { if (!employee.value) return const year = rttSummary.value?.year ?? undefined - await createRttPayment(employee.value.id, month, minutes, rate, year) + await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year) await loadEmployee() } @@ -402,6 +408,7 @@ export const useEmployeeDetailPage = () => { rttSummary, publicHolidays, showLeaveTab, + showRttTab, contractHistory, employeeContractWorkLabel, contractForm, diff --git a/frontend/pages/calendar.vue b/frontend/pages/calendar.vue index 77d17f6..55a5485 100644 --- a/frontend/pages/calendar.vue +++ b/frontend/pages/calendar.vue @@ -578,10 +578,6 @@ const handleSubmit = async () => { window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.") return } - if (hasHolidayInRange(start, end)) { - window.alert("Impossible de creer une absence sur un jour ferie.") - return - } const overlaps = absences.value.filter((absence) => { if (absence.employee?.id !== Number(form.employeeId)) return false if (editingAbsence.value && absence.id === editingAbsence.value.id) return false diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue index 91dc74a..d949671 100644 --- a/frontend/pages/employees/[id].vue +++ b/frontend/pages/employees/[id].vue @@ -21,7 +21,7 @@

{{ employee.site?.name ?? '-' }}

-
+
@@ -122,6 +123,7 @@ const { rttSummary, publicHolidays, showLeaveTab, + showRttTab, contractHistory, employeeContractWorkLabel, contractForm, diff --git a/frontend/services/dto/employee-rtt-summary.ts b/frontend/services/dto/employee-rtt-summary.ts index f4c5380..7509ada 100644 --- a/frontend/services/dto/employee-rtt-summary.ts +++ b/frontend/services/dto/employee-rtt-summary.ts @@ -3,22 +3,33 @@ export type EmployeeRttWeekSummary = { weekNumber: number weekStart: string weekEnd: string - recoveryMinutes: number + overtimeMinutes: number + base25Minutes: number + bonus25Minutes: number + base50Minutes: number + bonus50Minutes: number + totalMinutes: number } export type RttMonthPayment = { month: number - paidMinutes25: number - paidMinutes50: number + paidBase25Minutes: number + paidBonus25Minutes: number + paidBase50Minutes: number + paidBonus50Minutes: number } export type EmployeeRttSummary = { year: number + carryMonth: number carryFromPreviousYearMinutes: number + carryBase25Minutes: number + carryBonus25Minutes: number + carryBase50Minutes: number + carryBonus50Minutes: number currentYearRecoveryMinutes: number totalPaidMinutes: number availableMinutes: number weeks: EmployeeRttWeekSummary[] monthPayments: RttMonthPayment[] } - diff --git a/frontend/services/employee-rtt-summary.ts b/frontend/services/employee-rtt-summary.ts index 6ec4d12..018ad70 100644 --- a/frontend/services/employee-rtt-summary.ts +++ b/frontend/services/employee-rtt-summary.ts @@ -6,9 +6,17 @@ export const getEmployeeRttSummary = async (employeeId: number, year?: number) = return api.get(`/employees/${employeeId}/rtt-summary`, query, { toast: false }) } -export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => { +export const createRttPayment = async ( + employeeId: number, + month: number, + base25Minutes: number, + bonus25Minutes: number, + base50Minutes: number, + bonus50Minutes: number, + year?: number +) => { const api = useApi() - const body: Record = { month, minutes, rate } + const body: Record = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes } if (year) body.year = year return api.patch(`/employees/${employeeId}/rtt-payments`, body) } diff --git a/migrations/Version20260313080007.php b/migrations/Version20260313080007.php new file mode 100644 index 0000000..641eef2 --- /dev/null +++ b/migrations/Version20260313080007.php @@ -0,0 +1,54 @@ +addSql('ALTER TABLE employee_rtt_balances ADD opening_base25_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus25_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_balances ADD opening_base50_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus50_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_balances DROP opening_minutes'); + + // employee_rtt_payments: replace minutes+rate with 4 fields + $this->addSql('DROP INDEX IF EXISTS uniq_rtt_payment_employee_year_month_rate'); + $this->addSql('ALTER TABLE employee_rtt_payments ADD base25_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_payments ADD bonus25_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_payments ADD base50_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_payments ADD bonus50_minutes INT DEFAULT 0 NOT NULL'); + $this->addSql('ALTER TABLE employee_rtt_payments DROP minutes'); + $this->addSql('ALTER TABLE employee_rtt_payments DROP rate'); + } + + public function down(Schema $schema): void + { + // employee_rtt_balances: restore opening_minutes + $this->addSql('ALTER TABLE employee_rtt_balances ADD opening_minutes INT NOT NULL DEFAULT 0'); + $this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base25_minutes'); + $this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus25_minutes'); + $this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base50_minutes'); + $this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus50_minutes'); + + // employee_rtt_payments: restore minutes+rate + $this->addSql('ALTER TABLE employee_rtt_payments ADD minutes INT NOT NULL DEFAULT 0'); + $this->addSql("ALTER TABLE employee_rtt_payments ADD rate VARCHAR(10) NOT NULL DEFAULT '25'"); + $this->addSql('ALTER TABLE employee_rtt_payments DROP base25_minutes'); + $this->addSql('ALTER TABLE employee_rtt_payments DROP bonus25_minutes'); + $this->addSql('ALTER TABLE employee_rtt_payments DROP base50_minutes'); + $this->addSql('ALTER TABLE employee_rtt_payments DROP bonus50_minutes'); + $this->addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)'); + } +} diff --git a/migrations/Version20260313092249.php b/migrations/Version20260313092249.php new file mode 100644 index 0000000..f6dec4f --- /dev/null +++ b/migrations/Version20260313092249.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE employee_rtt_balances ADD month INT DEFAULT 5 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE employee_rtt_balances DROP month'); + } +} diff --git a/src/ApiResource/EmployeeRttPaymentInput.php b/src/ApiResource/EmployeeRttPaymentInput.php index 057b444..90db62f 100644 --- a/src/ApiResource/EmployeeRttPaymentInput.php +++ b/src/ApiResource/EmployeeRttPaymentInput.php @@ -22,8 +22,10 @@ use App\State\EmployeeRttPaymentProvider; )] final class EmployeeRttPaymentInput { - public int $month = 0; - public int $minutes = 0; - public string $rate = '25'; - public ?int $year = null; + public int $month = 0; + public int $base25Minutes = 0; + public int $bonus25Minutes = 0; + public int $base50Minutes = 0; + public int $bonus50Minutes = 0; + public ?int $year = null; } diff --git a/src/ApiResource/EmployeeRttSummary.php b/src/ApiResource/EmployeeRttSummary.php index 43ac184..593b56d 100644 --- a/src/ApiResource/EmployeeRttSummary.php +++ b/src/ApiResource/EmployeeRttSummary.php @@ -23,7 +23,12 @@ use App\State\EmployeeRttSummaryProvider; final class EmployeeRttSummary { public int $year = 0; + public int $carryMonth = 5; public int $carryFromPreviousYearMinutes = 0; + public int $carryBase25Minutes = 0; + public int $carryBonus25Minutes = 0; + public int $carryBase50Minutes = 0; + public int $carryBonus50Minutes = 0; public int $currentYearRecoveryMinutes = 0; public int $availableMinutes = 0; public int $totalPaidMinutes = 0; diff --git a/src/Command/RttRolloverCommand.php b/src/Command/RttRolloverCommand.php index 9efbf17..6090bc8 100644 --- a/src/Command/RttRolloverCommand.php +++ b/src/Command/RttRolloverCommand.php @@ -92,7 +92,7 @@ final class RttRolloverCommand extends Command try { $previousYear = $targetYear - 1; - $carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear); + $carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear); } catch (Throwable $e) { $this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]); ++$skipped; @@ -103,12 +103,15 @@ final class RttRolloverCommand extends Command $balance = new EmployeeRttBalance() ->setEmployee($employee) ->setYear($targetYear) - ->setOpeningMinutes($carryMinutes) + ->setOpeningBase25Minutes($carry->base25Minutes) + ->setOpeningBonus25Minutes($carry->bonus25Minutes) + ->setOpeningBase50Minutes($carry->base50Minutes) + ->setOpeningBonus50Minutes($carry->bonus50Minutes) ->setIsLocked(false) ; $this->entityManager->persist($balance); - $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]); + $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]); ++$created; } diff --git a/src/Dto/Rtt/EmployeeRttWeekSummary.php b/src/Dto/Rtt/EmployeeRttWeekSummary.php index 71af998..e409869 100644 --- a/src/Dto/Rtt/EmployeeRttWeekSummary.php +++ b/src/Dto/Rtt/EmployeeRttWeekSummary.php @@ -11,6 +11,11 @@ final class EmployeeRttWeekSummary public int $weekNumber, public string $weekStart, public string $weekEnd, - public int $recoveryMinutes, + public int $overtimeMinutes = 0, + public int $base25Minutes = 0, + public int $bonus25Minutes = 0, + public int $base50Minutes = 0, + public int $bonus50Minutes = 0, + public int $totalMinutes = 0, ) {} } diff --git a/src/Dto/Rtt/RttMonthPayment.php b/src/Dto/Rtt/RttMonthPayment.php index 6382937..dc0848d 100644 --- a/src/Dto/Rtt/RttMonthPayment.php +++ b/src/Dto/Rtt/RttMonthPayment.php @@ -8,7 +8,9 @@ final class RttMonthPayment { public function __construct( public int $month, - public int $paidMinutes25 = 0, - public int $paidMinutes50 = 0, + public int $paidBase25Minutes = 0, + public int $paidBonus25Minutes = 0, + public int $paidBase50Minutes = 0, + public int $paidBonus50Minutes = 0, ) {} } diff --git a/src/Dto/Rtt/WeekRecoveryDetail.php b/src/Dto/Rtt/WeekRecoveryDetail.php new file mode 100644 index 0000000..03765da --- /dev/null +++ b/src/Dto/Rtt/WeekRecoveryDetail.php @@ -0,0 +1,17 @@ + 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])] private int $year = 0; - #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])] - private int $openingMinutes = 0; + #[ORM\Column(type: 'integer', options: ['comment' => 'Mois de fin du report (1-12). Le report s affiche dans le mois suivant.', 'default' => 5])] + private int $month = 5; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])] + private int $openingBase25Minutes = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])] + private int $openingBonus25Minutes = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])] + private int $openingBase50Minutes = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])] + private int $openingBonus50Minutes = 0; #[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])] private bool $isLocked = false; @@ -74,18 +86,71 @@ class EmployeeRttBalance return $this; } - public function getOpeningMinutes(): int + public function getMonth(): int { - return $this->openingMinutes; + return $this->month; } - public function setOpeningMinutes(int $openingMinutes): self + public function setMonth(int $month): self { - $this->openingMinutes = $openingMinutes; + $this->month = $month; return $this; } + public function getOpeningBase25Minutes(): int + { + return $this->openingBase25Minutes; + } + + public function setOpeningBase25Minutes(int $openingBase25Minutes): self + { + $this->openingBase25Minutes = $openingBase25Minutes; + + return $this; + } + + public function getOpeningBonus25Minutes(): int + { + return $this->openingBonus25Minutes; + } + + public function setOpeningBonus25Minutes(int $openingBonus25Minutes): self + { + $this->openingBonus25Minutes = $openingBonus25Minutes; + + return $this; + } + + public function getOpeningBase50Minutes(): int + { + return $this->openingBase50Minutes; + } + + public function setOpeningBase50Minutes(int $openingBase50Minutes): self + { + $this->openingBase50Minutes = $openingBase50Minutes; + + return $this; + } + + public function getOpeningBonus50Minutes(): int + { + return $this->openingBonus50Minutes; + } + + public function setOpeningBonus50Minutes(int $openingBonus50Minutes): self + { + $this->openingBonus50Minutes = $openingBonus50Minutes; + + return $this; + } + + public function getTotalOpeningMinutes(): int + { + return $this->openingBase25Minutes + $this->openingBonus25Minutes + $this->openingBase50Minutes + $this->openingBonus50Minutes; + } + public function isLocked(): bool { return $this->isLocked; diff --git a/src/Entity/EmployeeRttPayment.php b/src/Entity/EmployeeRttPayment.php index a3a3e2f..8cc6271 100644 --- a/src/Entity/EmployeeRttPayment.php +++ b/src/Entity/EmployeeRttPayment.php @@ -28,11 +28,17 @@ class EmployeeRttPayment #[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])] private int $month = 0; - #[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])] - private int $minutes = 0; + #[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])] + private int $base25Minutes = 0; - #[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])] - private string $rate = ''; + #[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])] + private int $bonus25Minutes = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])] + private int $base50Minutes = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])] + private int $bonus50Minutes = 0; #[ORM\Column(type: 'datetime_immutable')] private DateTimeImmutable $createdAt; @@ -88,26 +94,50 @@ class EmployeeRttPayment return $this; } - public function getMinutes(): int + public function getBase25Minutes(): int { - return $this->minutes; + return $this->base25Minutes; } - public function setMinutes(int $minutes): self + public function setBase25Minutes(int $base25Minutes): self { - $this->minutes = $minutes; + $this->base25Minutes = $base25Minutes; return $this; } - public function getRate(): string + public function getBonus25Minutes(): int { - return $this->rate; + return $this->bonus25Minutes; } - public function setRate(string $rate): self + public function setBonus25Minutes(int $bonus25Minutes): self { - $this->rate = $rate; + $this->bonus25Minutes = $bonus25Minutes; + + return $this; + } + + public function getBase50Minutes(): int + { + return $this->base50Minutes; + } + + public function setBase50Minutes(int $base50Minutes): self + { + $this->base50Minutes = $base50Minutes; + + return $this; + } + + public function getBonus50Minutes(): int + { + return $this->bonus50Minutes; + } + + public function setBonus50Minutes(int $bonus50Minutes): self + { + $this->bonus50Minutes = $bonus50Minutes; return $this; } diff --git a/src/Repository/EmployeeRttPaymentRepository.php b/src/Repository/EmployeeRttPaymentRepository.php index 5a2b285..bac4e2e 100644 --- a/src/Repository/EmployeeRttPaymentRepository.php +++ b/src/Repository/EmployeeRttPaymentRepository.php @@ -19,13 +19,12 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository parent::__construct($registry, EmployeeRttPayment::class); } - public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment + public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment { return $this->findOneBy([ 'employee' => $employee, 'year' => $year, 'month' => $month, - 'rate' => $rate, ]); } diff --git a/src/Repository/WorkHourRepository.php b/src/Repository/WorkHourRepository.php index 5c054ae..9f6a101 100644 --- a/src/Repository/WorkHourRepository.php +++ b/src/Repository/WorkHourRepository.php @@ -139,29 +139,40 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo } /** - * @return array YYYY-MM => presence day count (0.5 for half-days) + * Count weekend worked days by month. + * >= 5h total = 1.0 day, < 5h = 0.5 day. + * + * @return array YYYY-MM => weekend worked day count */ - public function countPresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array + public function countWeekendWorkedDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { $sql = <<<'SQL' SELECT TO_CHAR(work_date, 'YYYY-MM') AS month, SUM( CASE - WHEN (morning_from IS NOT NULL OR is_present_morning = true) - AND (afternoon_from IS NOT NULL OR is_present_afternoon = true) - THEN 1.0 - WHEN (morning_from IS NOT NULL OR is_present_morning = true) - OR (afternoon_from IS NOT NULL OR is_present_afternoon = true) - THEN 0.5 + WHEN total_minutes >= 300 THEN 1.0 + WHEN total_minutes > 0 THEN 0.5 ELSE 0 END ) AS cnt - FROM work_hours - WHERE employee_id = :employee - AND work_date >= :from - AND work_date <= :to - AND (morning_from IS NOT NULL OR is_present_morning = true - OR afternoon_from IS NOT NULL OR is_present_afternoon = true) + FROM ( + SELECT work_date, + COALESCE( + EXTRACT(EPOCH FROM (morning_to::time - morning_from::time)) / 60, 0 + ) + + COALESCE( + EXTRACT(EPOCH FROM (afternoon_to::time - afternoon_from::time)) / 60, 0 + ) + + COALESCE( + EXTRACT(EPOCH FROM (evening_to::time - evening_from::time)) / 60, 0 + ) AS total_minutes + FROM work_hours + WHERE employee_id = :employee + AND work_date >= :from + AND work_date <= :to + AND EXTRACT(ISODOW FROM work_date) IN (6, 7) + AND (morning_from IS NOT NULL OR afternoon_from IS NOT NULL OR evening_from IS NOT NULL) + ) sub GROUP BY month SQL; diff --git a/src/Service/Leave/LeaveBalanceComputationService.php b/src/Service/Leave/LeaveBalanceComputationService.php index 55b0ad7..ee0a9cd 100644 --- a/src/Service/Leave/LeaveBalanceComputationService.php +++ b/src/Service/Leave/LeaveBalanceComputationService.php @@ -69,18 +69,9 @@ final readonly class LeaveBalanceComputationService $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year); if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { - $totalBusinessDays = $this->countBusinessDays($from, $to); - $baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS); - $suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to); - $acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays; - if ([] !== $suspensions) { - $totalMonths = $this->countFractionalMonths($from, $to); - $suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions); - if ($totalMonths > 0) { - $ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths); - $acquiredDays = $carryDays + $baseAcquiredDays * $ratio + $fractionedDays; - } - } + $totalBusinessDays = $this->countBusinessDays($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); $previousRemainingDays = max(0.0, $acquiredDays - $takenDays); @@ -89,7 +80,9 @@ final readonly class LeaveBalanceComputationService continue; } - $suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to); + $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( + $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to) + ); $generatedDays = $this->computeAccruedDays( $this->resolveAnnualDays($employee), $this->resolveDaysAccrualPerMonth($employee), @@ -425,55 +418,6 @@ final readonly class LeaveBalanceComputationService return [$takenDays, $takenSaturdays]; } - private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float - { - $from = $this->normalizeDate($from); - $to = $this->normalizeDate($to); - $months = 0.0; - $cursor = $from->modify('first day of this month')->setTime(0, 0); - - while ($cursor <= $to) { - $monthStart = $cursor > $from ? $cursor : $from; - $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); - if ($monthEnd > $to) { - $monthEnd = $to; - } - $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; - $daysInMonth = (int) $cursor->format('t'); - $months += $coveredDays / $daysInMonth; - - $cursor = $cursor->modify('first day of next month'); - } - - return $months; - } - - /** - * @param list $suspensions - */ - private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float - { - $from = $this->normalizeDate($from); - $to = $this->normalizeDate($to); - $months = 0.0; - $cursor = $from->modify('first day of this month')->setTime(0, 0); - - while ($cursor <= $to) { - $monthStart = $cursor > $from ? $cursor : $from; - $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); - if ($monthEnd > $to) { - $monthEnd = $to; - } - $daysInMonth = (int) $cursor->format('t'); - $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); - $months += $suspendedDays / $daysInMonth; - - $cursor = $cursor->modify('first day of next month'); - } - - return $months; - } - /** * @return list */ diff --git a/src/Service/Leave/SuspensionDaysCalculator.php b/src/Service/Leave/SuspensionDaysCalculator.php index ff52825..77c751f 100644 --- a/src/Service/Leave/SuspensionDaysCalculator.php +++ b/src/Service/Leave/SuspensionDaysCalculator.php @@ -38,6 +38,35 @@ final class SuspensionDaysCalculator return $total; } + /** + * Return adjusted suspensions where the first month of each suspension is excluded (grace period). + * + * @param list $suspensions + * + * @return list + */ + public function applyFirstMonthGrace(array $suspensions): array + { + $adjusted = []; + + foreach ($suspensions as $suspension) { + $gracedStart = $suspension->getStartDate()->modify('+1 month'); + $end = $suspension->getEndDate(); + + if ($end instanceof DateTimeImmutable && $gracedStart > $end) { + continue; + } + + $copy = new ContractSuspension(); + $copy->setStartDate($gracedStart); + $copy->setEndDate($end); + + $adjusted[] = $copy; + } + + return $adjusted; + } + /** * Count business days (Mon-Fri, excl. public holidays) suspended within a period. * diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 977d2ec..772cacc 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service\Rtt; +use App\Dto\Rtt\WeekRecoveryDetail; use App\Dto\WorkHours\WorkMetrics; use App\Entity\Contract; use App\Entity\Employee; @@ -70,7 +71,7 @@ final readonly class RttRecoveryComputationService return $weeks; } - public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int + public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail { [$from, $to] = $this->resolveExerciseBounds($exerciseYear); $weeks = $this->buildWeeksForExercise($from, $to); @@ -86,13 +87,25 @@ final readonly class RttRecoveryComputationService $byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null); - return array_sum($byWeek); + $total = new WeekRecoveryDetail(); + foreach ($byWeek as $detail) { + $total = new WeekRecoveryDetail( + overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes, + base25Minutes: $total->base25Minutes + $detail->base25Minutes, + bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes, + base50Minutes: $total->base50Minutes + $detail->base50Minutes, + bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes, + totalMinutes: $total->totalMinutes + $detail->totalMinutes, + ); + } + + return $total; } /** * @param list $weeks * - * @return array + * @return array */ public function computeRecoveryByWeek( Employee $employee, @@ -148,13 +161,13 @@ final readonly class RttRecoveryComputationService $effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd; if ($effectiveEnd < $effectiveStart) { - $results[$weekKey] = 0; + $results[$weekKey] = new WeekRecoveryDetail(); continue; } if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) { - $results[$weekKey] = 0; + $results[$weekKey] = new WeekRecoveryDetail(); continue; } @@ -177,7 +190,7 @@ final readonly class RttRecoveryComputationService } if ([] === $weekDays) { - $results[$weekKey] = 0; + $results[$weekKey] = new WeekRecoveryDetail(); continue; } @@ -191,15 +204,22 @@ final readonly class RttRecoveryComputationService $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes); - $weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) - ? 0 - : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes); - $weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) - ? 0 - : $this->computeOvertime50BonusMinutes($weeklyTotalMinutes); - $results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses) - ? 0 - : $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes; + + $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes); + $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25); + $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60); + $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5); + + $results[$weekKey] = new WeekRecoveryDetail( + overtimeMinutes: $weeklyOvertimeTotalMinutes, + base25Minutes: $base25, + bonus25Minutes: $bonus25, + base50Minutes: $base50, + bonus50Minutes: $bonus50, + totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses) + ? 0 + : $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50, + ); } return $results; diff --git a/src/State/AbsenceWriteProcessor.php b/src/State/AbsenceWriteProcessor.php index c741a01..4ec8624 100644 --- a/src/State/AbsenceWriteProcessor.php +++ b/src/State/AbsenceWriteProcessor.php @@ -13,6 +13,7 @@ use App\Entity\User; use App\Enum\HalfDay; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface; +use App\Service\PublicHolidayServiceInterface; use DateInterval; use DatePeriod; use DateTime; @@ -22,6 +23,7 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Throwable; final readonly class AbsenceWriteProcessor implements ProcessorInterface { @@ -30,6 +32,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface private AbsenceReadRepositoryInterface $absenceRepository, private WorkHourReadRepositoryInterface $workHourRepository, private Security $security, + private PublicHolidayServiceInterface $publicHolidayService, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -132,10 +135,15 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.'); } - $days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day')); + $days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day')); + $publicHolidays = $this->buildPublicHolidayMap($start, $end); $segments = []; foreach ($days as $day) { + if (isset($publicHolidays[$day->format('Y-m-d')])) { + continue; + } + $isFirst = $day->format('Y-m-d') === $start->format('Y-m-d'); $isLast = $day->format('Y-m-d') === $end->format('Y-m-d'); $isSame = $isFirst && $isLast; @@ -246,4 +254,27 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface ->setIsValid(false) ; } + + /** + * @return array + */ + private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $map = []; + $startYear = (int) $from->format('Y'); + $endYear = (int) $to->format('Y'); + + try { + for ($year = $startYear; $year <= $endYear; ++$year) { + $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year); + foreach ($holidays as $date => $label) { + $map[(string) $date] = (string) $label; + } + } + } catch (Throwable) { + return []; + } + + return $map; + } } diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 9cd5216..99c9332 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -103,7 +103,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $summary->remainingSaturdays = $yearSummary['remainingSaturdays']; [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); - $summary->presenceDaysByMonth = $this->workHourRepository->countPresenceDaysByMonth($employee, $periodFrom, $periodTo); + $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo); return $summary; } @@ -178,8 +178,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee); $takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee); - $suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to); - $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 + $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( + $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to) + ); + $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredDays'], $leavePolicy['accrualPerMonth'], @@ -235,16 +237,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays; } else { // Forfait: no "en cours d'acquisition" counter, all rights are in acquired. - $acquiredDays = $carryDays + $leavePolicy['acquiredDays']; - $suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to); - if ([] !== $suspensions) { - $totalMonths = $this->countFractionalMonths($from, $to); - $suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions); - if ($totalMonths > 0) { - $ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths); - $acquiredDays = $carryDays + $leavePolicy['acquiredDays'] * $ratio; - } - } + // Suspensions do not impact forfait 218 leave calculation. + $acquiredDays = $carryDays + $leavePolicy['acquiredDays']; $accruingDays = 0.0; $remainingDays = max(0.0, $acquiredDays - $takenDays); $acquiredSaturdays = 0.0; @@ -539,6 +533,66 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return $map; } + /** + * Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days. + * + * @return array YYYY-MM => presence day count + */ + private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array + { + $publicHolidays = $this->buildPublicHolidayMap($from, $to); + $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to); + $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); + + // Count absence days per month (0.5 for half-days). + $absenceDaysByMonth = []; + foreach ($absences as $absence) { + $date = DateTimeImmutable::createFromInterface($absence->getStartDate()); + $monthKey = $date->format('Y-m'); + $days = 1.0; + if ($absence->getStartHalf() === $absence->getEndHalf()) { + $days = 0.5; + } + $absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $days; + } + + // Count business days and public holidays per month. + $result = []; + $cursor = $from->modify('first day of this month')->setTime(0, 0); + while ($cursor <= $to) { + $monthKey = $cursor->format('Y-m'); + $monthStart = $cursor < $from ? $from : $cursor; + $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); + if ($monthEnd > $to) { + $monthEnd = $to; + } + + $businessDays = 0; + for ( + $day = $monthStart; + $day <= $monthEnd; + $day = $day->modify('+1 day') + ) { + $weekDay = (int) $day->format('N'); + if ($weekDay <= 5 && !isset($publicHolidays[$day->format('Y-m-d')])) { + ++$businessDays; + } + } + + $weekend = $weekendWorkedDays[$monthKey] ?? 0.0; + $absenced = $absenceDaysByMonth[$monthKey] ?? 0.0; + + $presence = max(0.0, (float) $businessDays + $weekend - $absenced); + if ($presence > 0.0) { + $result[$monthKey] = $presence; + } + + $cursor = $cursor->modify('first day of next month'); + } + + return $result; + } + /** * @return array{DateTimeImmutable, DateTimeImmutable} */ @@ -731,55 +785,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return [$takenDays, $takenSaturdays]; } - private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float - { - $from = $this->normalizeDate($from); - $to = $this->normalizeDate($to); - $months = 0.0; - $cursor = $from->modify('first day of this month')->setTime(0, 0); - - while ($cursor <= $to) { - $monthStart = $cursor > $from ? $cursor : $from; - $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); - if ($monthEnd > $to) { - $monthEnd = $to; - } - $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1; - $daysInMonth = (int) $cursor->format('t'); - $months += $coveredDays / $daysInMonth; - - $cursor = $cursor->modify('first day of next month'); - } - - return $months; - } - - /** - * @param list $suspensions - */ - private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float - { - $from = $this->normalizeDate($from); - $to = $this->normalizeDate($to); - $months = 0.0; - $cursor = $from->modify('first day of this month')->setTime(0, 0); - - while ($cursor <= $to) { - $monthStart = $cursor > $from ? $cursor : $from; - $monthEnd = $cursor->modify('last day of this month')->setTime(0, 0); - if ($monthEnd > $to) { - $monthEnd = $to; - } - $daysInMonth = (int) $cursor->format('t'); - $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); - $months += $suspendedDays / $daysInMonth; - - $cursor = $cursor->modify('first day of next month'); - } - - return $months; - } - /** * @return list */ diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php index cb5781c..ea81bdf 100644 --- a/src/State/EmployeeRttPaymentProcessor.php +++ b/src/State/EmployeeRttPaymentProcessor.php @@ -16,8 +16,6 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use function in_array; - final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface { public function __construct( @@ -42,32 +40,27 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface throw new NotFoundHttpException('Employee not found.'); } - if (!in_array($data->rate, ['25', '50'], true)) { - throw new UnprocessableEntityHttpException('rate must be "25" or "50".'); - } - if ($data->month < 1 || $data->month > 12) { throw new UnprocessableEntityHttpException('month must be between 1 and 12.'); } - if ($data->minutes < 0) { - throw new UnprocessableEntityHttpException('minutes must be >= 0.'); - } - $year = $data->year ?? $this->resolveCurrentExerciseYear(); - $payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate); + $payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month); if (null === $payment) { $payment = new EmployeeRttPayment(); $payment->setEmployee($employee); $payment->setYear($year); $payment->setMonth($data->month); - $payment->setRate($data->rate); $this->entityManager->persist($payment); } - $payment->setMinutes($data->minutes); + $payment->setBase25Minutes($data->base25Minutes); + $payment->setBonus25Minutes($data->bonus25Minutes); + $payment->setBase50Minutes($data->base50Minutes); + $payment->setBonus50Minutes($data->bonus50Minutes); + $payment->touch(); $this->entityManager->flush(); $data->year = $year; diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php index d0e6536..3deda78 100644 --- a/src/State/EmployeeRttSummaryProvider.php +++ b/src/State/EmployeeRttSummaryProvider.php @@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface; use App\ApiResource\EmployeeRttSummary; use App\Dto\Rtt\EmployeeRttWeekSummary; use App\Dto\Rtt\RttMonthPayment; +use App\Dto\Rtt\WeekRecoveryDetail; use App\Entity\Employee; use App\Entity\User; use App\Repository\EmployeeRepository; @@ -76,22 +77,36 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface $limitDate = $periodFrom->modify('-1 day'); } - $currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); - $carryMinutes = $this->resolveCarryMinutes($employee, $year); + $currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); + [$carry, $carryMonth] = $this->resolveCarry($employee, $year); $summary = new EmployeeRttSummary(); $summary->year = $year; - $summary->carryFromPreviousYearMinutes = $carryMinutes; - $summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart); + $summary->carryMonth = $carryMonth; + $summary->carryFromPreviousYearMinutes = $carry->totalMinutes; + $summary->carryBase25Minutes = $carry->base25Minutes; + $summary->carryBonus25Minutes = $carry->bonus25Minutes; + $summary->carryBase50Minutes = $carry->base50Minutes; + $summary->carryBonus50Minutes = $carry->bonus50Minutes; + $summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart)); $summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes; $summary->weeks = array_map( - static fn (array $week) => new EmployeeRttWeekSummary( - month: (int) $week['month'], - weekNumber: (int) $week['weekNumber'], - weekStart: $week['start']->format('Y-m-d'), - weekEnd: $week['end']->format('Y-m-d'), - recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0), - ), + static function (array $week) use ($currentByWeekStart) { + $detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail(); + + return new EmployeeRttWeekSummary( + month: (int) $week['month'], + weekNumber: (int) $week['weekNumber'], + weekStart: $week['start']->format('Y-m-d'), + weekEnd: $week['end']->format('Y-m-d'), + overtimeMinutes: $detail->overtimeMinutes, + base25Minutes: $detail->base25Minutes, + bonus25Minutes: $detail->bonus25Minutes, + base50Minutes: $detail->base50Minutes, + bonus50Minutes: $detail->bonus50Minutes, + totalMinutes: $detail->totalMinutes, + ); + }, $weekRanges ); @@ -101,21 +116,20 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface foreach ($payments as $payment) { $m = $payment->getMonth(); if (!isset($monthBuckets[$m])) { - $monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0]; - } - if ('25' === $payment->getRate()) { - $monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes(); - } else { - $monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes(); + $monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0]; } + $monthBuckets[$m]['base25'] += $payment->getBase25Minutes(); + $monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes(); + $monthBuckets[$m]['base50'] += $payment->getBase50Minutes(); + $monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes(); } $monthPayments = []; $totalPaidMinutes = 0; foreach ($monthBuckets as $m => $bucket) { - $monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']); - $totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50']; + $monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']); + $totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50']; } $summary->totalPaidMinutes = $totalPaidMinutes; @@ -125,14 +139,29 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface return $summary; } - private function resolveCarryMinutes(Employee $employee, int $year): int + /** + * @return array{WeekRecoveryDetail, int} [carry, month] + */ + private function resolveCarry(Employee $employee, int $year): array { $balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year); if (null !== $balance) { - return $balance->getOpeningMinutes(); + return [ + new WeekRecoveryDetail( + base25Minutes: $balance->getOpeningBase25Minutes(), + bonus25Minutes: $balance->getOpeningBonus25Minutes(), + base50Minutes: $balance->getOpeningBase50Minutes(), + bonus50Minutes: $balance->getOpeningBonus50Minutes(), + totalMinutes: $balance->getTotalOpeningMinutes(), + ), + $balance->getMonth(), + ]; } - return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1); + return [ + $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), + 5, + ]; } private function resolveYear(): int diff --git a/tests/Service/Leave/SuspensionDaysCalculatorTest.php b/tests/Service/Leave/SuspensionDaysCalculatorTest.php index f1181ed..708cfbe 100644 --- a/tests/Service/Leave/SuspensionDaysCalculatorTest.php +++ b/tests/Service/Leave/SuspensionDaysCalculatorTest.php @@ -128,6 +128,40 @@ final class SuspensionDaysCalculatorTest extends TestCase self::assertSame(5, $result); } + public function testFirstMonthGraceShiftsStartByOneMonth(): void + { + $calc = new SuspensionDaysCalculator(); + $suspension = $this->buildSuspension('2026-03-15', '2026-06-30'); + + $result = $calc->applyFirstMonthGrace([$suspension]); + + self::assertCount(1, $result); + self::assertEquals(new DateTimeImmutable('2026-04-15'), $result[0]->getStartDate()); + self::assertEquals(new DateTimeImmutable('2026-06-30'), $result[0]->getEndDate()); + } + + public function testFirstMonthGraceRemovesSuspensionShorterThanOneMonth(): void + { + $calc = new SuspensionDaysCalculator(); + $suspension = $this->buildSuspension('2026-03-10', '2026-03-25'); + + $result = $calc->applyFirstMonthGrace([$suspension]); + + self::assertCount(0, $result); + } + + public function testFirstMonthGraceOpenEndedSuspension(): void + { + $calc = new SuspensionDaysCalculator(); + $suspension = $this->buildSuspension('2026-03-01', null); + + $result = $calc->applyFirstMonthGrace([$suspension]); + + self::assertCount(1, $result); + self::assertEquals(new DateTimeImmutable('2026-04-01'), $result[0]->getStartDate()); + self::assertNull($result[0]->getEndDate()); + } + private function buildSuspension(string $start, ?string $end): ContractSuspension { $s = new ContractSuspension(); diff --git a/tests/State/AbsenceWriteProcessorTest.php b/tests/State/AbsenceWriteProcessorTest.php index 919295f..c7d6af3 100644 --- a/tests/State/AbsenceWriteProcessorTest.php +++ b/tests/State/AbsenceWriteProcessorTest.php @@ -14,6 +14,7 @@ use App\Entity\User; use App\Enum\HalfDay; use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface; +use App\Service\PublicHolidayServiceInterface; use App\State\AbsenceWriteProcessor; use DateTime; use Doctrine\ORM\EntityManagerInterface; @@ -35,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); $absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM); @@ -63,7 +64,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); @@ -84,7 +85,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); @@ -106,7 +107,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM); @@ -140,4 +141,12 @@ final class AbsenceWriteProcessorTest extends TestCase return $security; } + + private function createEmptyHolidayServiceStub(): PublicHolidayServiceInterface + { + $service = $this->createStub(PublicHolidayServiceInterface::class); + $service->method('getHolidaysDayByYears')->willReturn([]); + + return $service; + } }