From 387cff22933402acec425d5debcbae2b607e3815 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:37:26 +0200 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20spec=20vue=20jour=20r=C3=A9soluti?= =?UTF-8?q?on=20contrat=20=C3=A0=20la=20date=20affich=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-day-view-per-date-tracking-mode-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md diff --git a/docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md b/docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md new file mode 100644 index 0000000..a13fc0d --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md @@ -0,0 +1,88 @@ +# Vue Jour (Heures) — résolution du contrat à la date affichée + +Date : 2026-06-01 + +## Problème + +Sur l'écran **Heures — vue Jour** (`HoursDayView`), l'affichage saisie d'heures (TIME) +vs cases de présence (PRESENCE), ainsi que le libellé de contrat entre parenthèses, +sont résolus à partir de `employee.contract` — c'est-à-dire le **contrat courant** +de l'employé (résolu à aujourd'hui), pas le contrat valable à la date affichée. + +Conséquence pour un salarié passé d'un contrat 39h/35h (TIME) à un Forfait (PRESENCE) : +- toutes les dates **passées** s'affichent en cases de présence alors qu'elles + relevaient d'un contrat en heures ; +- pire, `handleSave` (`useHoursPage.ts:1073`) se base sur le même test : éditer + une date passée écrit des **flags de présence** au lieu des heures et écrase la saisie. + +La **vue Semaine** est déjà correcte : elle résout le `trackingMode` par date côté +backend via `WeeklySummaryRow.trackingMode`. Le périmètre de ce correctif est donc +la **vue Jour uniquement**. + +## Principe + +Le provider backend `WorkHourDayContextProvider::provide()` résout **déjà** le contrat +à la date affichée (`EmployeeContractResolver::resolveForEmployeeAndDate`) et expose +déjà `contractNature` par date sur chaque ligne. Il suffit : + +1. d'exposer sur la ligne du jour les champs de contrat manquants ; +2. de faire lire ces champs au frontend (au lieu de `employee.contract`). + +L'ensemble de la ligne (toggle saisie/présence + libellé 39h/Forfait + logique 4h) +devient ainsi cohérent avec le contrat valable à la date affichée. + +## Changements + +### Backend + +1. **`src/Dto/WorkHours/DayContextRow.php`** — ajouter 4 champs au constructeur et à + `toArray()` (+ mettre à jour le PHPDoc du retour de `toArray()`) : + - `trackingMode: ?string` + - `weeklyHours: ?int` + - `contractType: ?string` + - `contractName: ?string` + +2. **`src/State/WorkHourDayContextProvider.php`** — peupler ces champs depuis le + `$contract` déjà résolu (lignes 60-71) : + - `trackingMode` = `$contract?->getTrackingMode()` + - `weeklyHours` = `$contract?->getWeeklyHours()` + - `contractType` = `$contract?->getType()->value` + - `contractName` = `$contract?->getName()` + - tous `null` si pas de contrat à la date (cohérent avec `hasContractAtDate`). + +### Frontend + +3. **`frontend/services/dto/work-hour.ts`** — refléter les 4 champs sur + `WorkHourDayContextRow`. + +4. **`frontend/composables/useHoursPage.ts`** — `isPresenceTracking`, `isTimeTracking`, + `contractLabel`, `is4hContract` lisent le `dayContextByEmployeeId.get(employeeId)` + (résolu par date), avec **fallback** sur `employee.contract` si aucune ligne du jour + n'existe. Cela corrige automatiquement `handleSave` (ligne 1073), qui s'appuie sur + `isPresenceTracking`. + + Les signatures actuelles prennent un `Employee` ; on conserve la signature et on + utilise `employee.id` en interne pour récupérer la ligne du jour. + +## Hors périmètre + +- Vue Semaine (déjà par date). +- Heures Conducteurs (toujours en mode TIME, pas de toggle). +- Processor de sauvegarde backend : inchangé — le frontend enverra déjà la bonne + forme (heures vs présence) par date. + +## Tests / vérification + +- **Test backend** (`WorkHourDayContextProvider`) : pour un employé avec historique + 39h → Forfait, la ligne renvoyée porte `trackingMode=TIME`/`weeklyHours=39`/ + `contractType` non-forfait sur une date **avant** la bascule, et + `trackingMode=PRESENCE`/`contractType=FORFAIT` sur une date **après**. +- **Vérification manuelle** : naviguer une date avant et après la bascule sur la fiche + du salarié → champs d'heures puis cases de présence, libellé cohérent. + +## Documentation (règle obligatoire) + +- `doc/` : section vue Jour — résolution du contrat (mode + libellé) à la date affichée. +- `frontend/data/documentation-content.ts` : note utilisateur correspondante. +- `CLAUDE.md` : préciser que la vue Jour résout `trackingMode`/libellé **à la date + filtrée** (au même titre que `contractNature` déjà documenté). -- 2.39.5 From 71ae624c2998e1b9ab954ea805ebab7e99e32685 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:39:51 +0200 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20plan=20vue=20jour=20contrat=20?= =?UTF-8?q?=C3=A0=20la=20date=20affich=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-01-day-view-per-date-tracking-mode.md | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-day-view-per-date-tracking-mode.md diff --git a/docs/superpowers/plans/2026-06-01-day-view-per-date-tracking-mode.md b/docs/superpowers/plans/2026-06-01-day-view-per-date-tracking-mode.md new file mode 100644 index 0000000..961272e --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-day-view-per-date-tracking-mode.md @@ -0,0 +1,307 @@ +# Vue Jour — contrat résolu à la date affichée — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Faire que l'écran Heures — vue Jour affiche/sauvegarde la saisie d'heures (TIME) vs cases de présence (PRESENCE) et le libellé de contrat selon le contrat valable **à la date affichée**, et non le contrat courant de l'employé. + +**Architecture:** Le provider backend `WorkHourDayContextProvider` résout déjà le contrat à la date demandée. On expose 4 champs de contrat supplémentaires sur la ligne du jour (`DayContextRow`), on les reflète dans le DTO TS, puis le composable `useHoursPage` lit ces champs par date (fallback sur `employee.contract` si pas de ligne du jour). `handleSave` en hérite automatiquement. + +**Tech Stack:** Symfony / API Platform (PHP 8.4, PHPUnit) côté backend ; Nuxt 4 / Vue 3 / TypeScript côté frontend. + +Spec : `docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md` + +--- + +## File Structure + +- `src/Dto/WorkHours/DayContextRow.php` — DTO ligne du jour : +4 champs. +- `src/State/WorkHourDayContextProvider.php` — peuple les 4 champs depuis le contrat du jour. +- `tests/State/WorkHourDayContextProviderTest.php` — test de résolution par date. +- `frontend/services/dto/work-hour.ts` — type `WorkHourDayContextRow` : +4 champs. +- `frontend/composables/useHoursPage.ts` — helpers résolus par date. +- `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` — documentation. + +--- + +### Task 1: Backend — exposer le contrat du jour sur `DayContextRow` + +**Files:** +- Modify: `src/Dto/WorkHours/DayContextRow.php` +- Modify: `src/State/WorkHourDayContextProvider.php:60-71` +- Test: `tests/State/WorkHourDayContextProviderTest.php` + +- [ ] **Step 1: Écrire le test en échec** + +Ajouter cette méthode dans `tests/State/WorkHourDayContextProviderTest.php` (après `testBuildsRowsWithAbsenceCredits`). Elle vérifie que la ligne porte le contrat **à la date demandée** pour un employé 39h→Forfait. On remplace le resolver stub par un callback dépendant de la date. + +```php +public function testRowCarriesContractAtRequestedDate(): void +{ + $user = new User(); + + $timeContract = new Contract() + ->setName('Contrat') + ->setTrackingMode(Contract::TRACKING_TIME) + ->setWeeklyHours(39) + ; + $forfaitContract = new Contract() + ->setName('Forfait') + ->setTrackingMode(Contract::TRACKING_PRESENCE) + ->setWeeklyHours(null) + ; + $employee = new Employee() + ->setFirstName('Jean') + ->setLastName('Test') + ->setContract($forfaitContract) + ; + $this->setEntityId($employee, 1); + + // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date. + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver->method('resolveForEmployeeAndDate')->willReturnCallback( + static fn (Employee $e, \DateTimeImmutable $d): ?Contract => + $d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract + ); + $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); + + $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]); + $this->absenceRepository->method('findByDateAndEmployees')->willReturn([]); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + $this->formationRepository, + $resolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()), + $this->buildHolidayResolver(), + ); + + $row = $provider->provide(new Get())->rows[0]; + + self::assertSame('TIME', $row['trackingMode']); + self::assertSame(39, $row['weeklyHours']); + self::assertSame('39H', $row['contractType']); + self::assertSame('Contrat', $row['contractName']); +} +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `make test` +Expected: FAIL — `Undefined array key "trackingMode"` (le DTO ne porte pas encore le champ). + +- [ ] **Step 3: Ajouter les 4 champs au DTO** + +Dans `src/Dto/WorkHours/DayContextRow.php`, ajouter au constructeur après `public ?string $contractNature = null,` : + +```php + public ?string $trackingMode = null, + public ?int $weeklyHours = null, + public ?string $contractType = null, + public ?string $contractName = null, +``` + +Mettre à jour le PHPDoc de `toArray()` (ajouter les 4 clés après `contractNature:?string`) : + +```php + * contractNature:?string, + * trackingMode:?string, + * weeklyHours:?int, + * contractType:?string, + * contractName:?string +``` + +Et le corps de `toArray()`, après `'contractNature' => $this->contractNature,` : + +```php + 'trackingMode' => $this->trackingMode, + 'weeklyHours' => $this->weeklyHours, + 'contractType' => $this->contractType, + 'contractName' => $this->contractName, +``` + +- [ ] **Step 4: Peupler les champs dans le provider** + +Dans `src/State/WorkHourDayContextProvider.php`, dans l'appel `new DayContextRow(...)` (lignes 65-71), ajouter après `contractNature: $contractNature,` : + +```php + trackingMode: $contract?->getTrackingMode(), + weeklyHours: $contract?->getWeeklyHours(), + contractType: $contract?->getType()->value, + contractName: $contract?->getName(), +``` + +- [ ] **Step 5: Lancer le test, vérifier le succès** + +Run: `make test` +Expected: PASS (le nouveau test et les 150 autres ; le test legacy `EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting` peut rester rouge — pré-existant, dépendant de la date, hors périmètre). + +- [ ] **Step 6: Commit** + +```bash +git add src/Dto/WorkHours/DayContextRow.php src/State/WorkHourDayContextProvider.php tests/State/WorkHourDayContextProviderTest.php +git commit -m "[#SIRH] Vue jour: exposer le contrat du jour sur DayContextRow" +``` + +--- + +### Task 2: Frontend — lire le contrat par date dans la vue Jour + +**Files:** +- Modify: `frontend/services/dto/work-hour.ts:103-118` +- Modify: `frontend/composables/useHoursPage.ts` + +- [ ] **Step 1: Refléter les 4 champs dans le DTO TS** + +Dans `frontend/services/dto/work-hour.ts`, type `WorkHourDayContextRow`, ajouter après `contractNature?: ...` (ligne 117) : + +```typescript + trackingMode?: TrackingMode | null + weeklyHours?: number | null + contractType?: string | null + contractName?: string | null +``` + +(`TrackingMode` est déjà importé dans ce fichier — utilisé ligne 73.) + +- [ ] **Step 2: Ajouter un résolveur de contrat par date dans le composable** + +Dans `frontend/composables/useHoursPage.ts`, juste avant `const isPresenceTracking` (ligne 353), insérer : + +```typescript + // Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant. + const resolveDayContract = (employee: Employee) => { + const dayRow = dayContextByEmployeeId.value.get(employee.id) + if (dayRow?.hasContractAtDate) { + return { + trackingMode: dayRow.trackingMode ?? null, + weeklyHours: dayRow.weeklyHours ?? null, + type: dayRow.contractType ?? null, + name: dayRow.contractName ?? '' + } + } + return { + trackingMode: employee.contract?.trackingMode ?? null, + weeklyHours: employee.contract?.weeklyHours ?? null, + type: employee.contract?.type ?? null, + name: employee.contract?.name ?? '' + } + } +``` + +- [ ] **Step 3: Brancher les helpers sur le contrat du jour** + +Toujours dans `frontend/composables/useHoursPage.ts`, remplacer les définitions actuelles (lignes 353-358 et 367-377). + +Remplacer : + +```typescript + const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE + const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) + const is4hContract = (employeeId: number) => { + const employee = employees.value.find((e) => e.id === employeeId) + return employee?.contract?.weeklyHours === 4 + } +``` + +par : + +```typescript + const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).trackingMode === TRACKING_MODES.PRESENCE + const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) + const is4hContract = (employeeId: number) => { + const employee = employees.value.find((e) => e.id === employeeId) + return employee ? resolveDayContract(employee).weeklyHours === 4 : false + } +``` + +Remplacer : + +```typescript + const contractLabel = (employee: Employee) => { + const contract = employee.contract + if (!contract) return '-' + if (contract.type === CONTRACT_TYPES.INTERIM) { + return contract.name + } + if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) { + return `${contract.weeklyHours}h` + } + return contract.name + } +``` + +par : + +```typescript + const contractLabel = (employee: Employee) => { + const contract = resolveDayContract(employee) + if (!contract.type && !contract.name) return '-' + if (contract.type === CONTRACT_TYPES.INTERIM) { + return contract.name + } + if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) { + return `${contract.weeklyHours}h` + } + return contract.name + } +``` + +- [ ] **Step 4: Vérification statique de lecture** + +Relire le diff : `resolveDayContract` est défini après `dayContextByEmployeeId` (ligne 201) et `employees` — donc disponible. `isPresenceTracking` reste de signature `(employee: Employee)` ⇒ aucun appelant à modifier (`HoursDayView.vue`, `handleSave` ligne 1073 inchangés). `CONTRACT_TYPES`/`TRACKING_MODES` déjà importés (ligne 8). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/services/dto/work-hour.ts frontend/composables/useHoursPage.ts +git commit -m "[#SIRH] Vue jour: saisie/présence et libellé résolus à la date affichée" +``` + +--- + +### Task 3: Documentation + +**Files:** +- Modify: `doc/functional-rules.md` +- Modify: `frontend/data/documentation-content.ts` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Doc fonctionnelle** + +Il n'existe pas de doc dédiée « Heures » dans `doc/` ; ajouter le paragraphe suivant dans `doc/functional-rules.md` (section traitant des écrans Heures / du contrat, ou en fin de fichier sous un titre `## Vue Jour — contrat à la date affichée`) : + +> **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. + +- [ ] **Step 2: Doc in-app** + +Dans `frontend/data/documentation-content.ts`, repérer la section/article de l'écran « Heures » (`grep -n "Heures" frontend/data/documentation-content.ts`) et ajouter un bloc texte : + +> Sur la vue Jour, l'affichage (saisie d'heures ou présence) et le libellé de contrat correspondent au contrat de l'employé à la date consultée. Si un salarié a changé de type de contrat (ex. passage en forfait), les jours antérieurs restent affichés selon l'ancien contrat. + +- [ ] **Step 3: CLAUDE.md** + +Dans `CLAUDE.md`, section « Écrans Heures / Heures Conducteurs (vue jour) », compléter la puce existante sur `contractNature` par une phrase : + +> Idem pour le **mode de suivi et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Le composable lit `resolveDayContract()` (`useHoursPage.ts`), ce qui pilote aussi `handleSave` (heures vs présence par date). + +- [ ] **Step 4: Commit** + +```bash +git add doc/ frontend/data/documentation-content.ts CLAUDE.md +git commit -m "docs: vue jour contrat à la date affichée (doc + in-app + CLAUDE.md)" +``` + +--- + +## Vérification finale (manuelle, par l'utilisateur) + +- Sur la fiche d'un salarié passé 39h/35h → Forfait, écran Heures vue Jour : + - naviguer une date **avant** la bascule → champs de saisie d'heures, libellé `39h`/`35h` ; + - naviguer une date **après** la bascule → cases de présence, libellé `Forfait` ; + - éditer puis enregistrer une date avant la bascule → les heures sont conservées (pas de flags présence). -- 2.39.5 From f8e65496d7c784a7bfbb18cd34ee9f802b5db71d Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:42:04 +0200 Subject: [PATCH 03/13] [#SIRH] Vue jour: exposer le contrat du jour sur DayContextRow --- src/Dto/WorkHours/DayContextRow.php | 14 ++++- src/State/WorkHourDayContextProvider.php | 4 ++ .../State/WorkHourDayContextProviderTest.php | 54 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Dto/WorkHours/DayContextRow.php b/src/Dto/WorkHours/DayContextRow.php index 10b1bf9..b195281 100644 --- a/src/Dto/WorkHours/DayContextRow.php +++ b/src/Dto/WorkHours/DayContextRow.php @@ -21,6 +21,10 @@ final class DayContextRow public ?string $formationLabel = null, public int $virtualHolidayMinutes = 0, public ?string $contractNature = null, + public ?string $trackingMode = null, + public ?int $weeklyHours = null, + public ?string $contractType = null, + public ?string $contractName = null, ) {} public function setFormation(string $label): void @@ -79,7 +83,11 @@ final class DayContextRow * hasFormation:bool, * formationLabel:?string, * virtualHolidayMinutes:int, - * contractNature:?string + * contractNature:?string, + * trackingMode:?string, + * weeklyHours:?int, + * contractType:?string, + * contractName:?string * } */ public function toArray(): array @@ -99,6 +107,10 @@ final class DayContextRow 'formationLabel' => $this->formationLabel, 'virtualHolidayMinutes' => $this->virtualHolidayMinutes, 'contractNature' => $this->contractNature, + 'trackingMode' => $this->trackingMode, + 'weeklyHours' => $this->weeklyHours, + 'contractType' => $this->contractType, + 'contractName' => $this->contractName, ]; } diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php index 4fcf4fa..badc63a 100644 --- a/src/State/WorkHourDayContextProvider.php +++ b/src/State/WorkHourDayContextProvider.php @@ -68,6 +68,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate), virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes), contractNature: $contractNature, + trackingMode: $contract?->getTrackingMode(), + weeklyHours: $contract?->getWeeklyHours(), + contractType: $contract?->getType()->value, + contractName: $contract?->getName(), ); } diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php index 37525b6..40e72f8 100644 --- a/tests/State/WorkHourDayContextProviderTest.php +++ b/tests/State/WorkHourDayContextProviderTest.php @@ -126,6 +126,60 @@ final class WorkHourDayContextProviderTest extends TestCase self::assertSame(210, $result->rows[0]['creditedMinutes']); } + public function testRowCarriesContractAtRequestedDate(): void + { + $user = new User(); + + $timeContract = new Contract() + ->setName('Contrat') + ->setTrackingMode(Contract::TRACKING_TIME) + ->setWeeklyHours(39) + ; + $forfaitContract = new Contract() + ->setName('Forfait') + ->setTrackingMode(Contract::TRACKING_PRESENCE) + ->setWeeklyHours(null) + ; + $employee = new Employee() + ->setFirstName('Jean') + ->setLastName('Test') + ->setContract($forfaitContract) + ; + $this->setEntityId($employee, 1); + + // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date. + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver->method('resolveForEmployeeAndDate')->willReturnCallback( + static fn (Employee $e, \DateTimeImmutable $d): ?Contract => + $d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract + ); + $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); + + $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]); + $this->absenceRepository->method('findByDateAndEmployees')->willReturn([]); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + $this->formationRepository, + $resolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()), + $this->buildHolidayResolver(), + ); + + $row = $provider->provide(new Get())->rows[0]; + + self::assertSame('TIME', $row['trackingMode']); + self::assertSame(39, $row['weeklyHours']); + self::assertSame('39H', $row['contractType']); + self::assertSame('Contrat', $row['contractName']); + } + private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee { $contract = new Contract() -- 2.39.5 From 441bac9d516fd8dc66de585e92f39d1d549a33d1 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:45:23 +0200 Subject: [PATCH 04/13] =?UTF-8?q?[#SIRH]=20Vue=20jour:=20saisie/pr=C3=A9se?= =?UTF-8?q?nce=20et=20libell=C3=A9=20r=C3=A9solus=20=C3=A0=20la=20date=20a?= =?UTF-8?q?ffich=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/composables/useHoursPage.ts | 27 +++++++++++++++++++++++---- frontend/services/dto/work-hour.ts | 4 ++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts index c59f541..f571c22 100644 --- a/frontend/composables/useHoursPage.ts +++ b/frontend/composables/useHoursPage.ts @@ -350,11 +350,30 @@ export const useHoursPage = () => { updatedAt: null }) - const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE + // Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant. + const resolveDayContract = (employee: Employee) => { + const dayRow = dayContextByEmployeeId.value.get(employee.id) + if (dayRow?.hasContractAtDate) { + return { + trackingMode: dayRow.trackingMode ?? null, + weeklyHours: dayRow.weeklyHours ?? null, + type: dayRow.contractType ?? null, + name: dayRow.contractName ?? '' + } + } + return { + trackingMode: employee.contract?.trackingMode ?? null, + weeklyHours: employee.contract?.weeklyHours ?? null, + type: employee.contract?.type ?? null, + name: employee.contract?.name ?? '' + } + } + + const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).trackingMode === TRACKING_MODES.PRESENCE const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) const is4hContract = (employeeId: number) => { const employee = employees.value.find((e) => e.id === employeeId) - return employee?.contract?.weeklyHours === 4 + return employee ? resolveDayContract(employee).weeklyHours === 4 : false } const isRowLocked = (employeeId: number) => { const row = rows.value[employeeId] @@ -365,8 +384,8 @@ export const useHoursPage = () => { } const contractLabel = (employee: Employee) => { - const contract = employee.contract - if (!contract) return '-' + const contract = resolveDayContract(employee) + if (!contract.type && !contract.name) return '-' if (contract.type === CONTRACT_TYPES.INTERIM) { return contract.name } diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts index 03e6a41..2bdb72e 100644 --- a/frontend/services/dto/work-hour.ts +++ b/frontend/services/dto/work-hour.ts @@ -115,6 +115,10 @@ export type WorkHourDayContextRow = { formationLabel?: string | null virtualHolidayMinutes?: number contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null + trackingMode?: TrackingMode | null + weeklyHours?: number | null + contractType?: string | null + contractName?: string | null } export type WorkHourDayContext = { -- 2.39.5 From 1143baa169fb861aee079617903fd8c0eaf1c2db Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:49:26 +0200 Subject: [PATCH 05/13] [#SIRH] Vue jour: typer contractType (ContractType) sur la ligne du jour --- frontend/services/dto/work-hour.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts index 2bdb72e..6dd61f0 100644 --- a/frontend/services/dto/work-hour.ts +++ b/frontend/services/dto/work-hour.ts @@ -117,7 +117,7 @@ export type WorkHourDayContextRow = { contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null trackingMode?: TrackingMode | null weeklyHours?: number | null - contractType?: string | null + contractType?: ContractType | null contractName?: string | null } -- 2.39.5 From d248df69eac1fa1db659cfc1d3b6ca5538a1a937 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:51:07 +0200 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20vue=20jour=20contrat=20=C3=A0=20l?= =?UTF-8?q?a=20date=20affich=C3=A9e=20(doc=20+=20in-app=20+=20CLAUDE.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f171c3..ab2ecbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ - Contract nature (per period): CDI, CDD, INTERIM - **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` -- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). +- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). - **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 9b0d6e8..9a3da08 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -61,6 +61,7 @@ Documents complementaires: - Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom: - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) +- **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. ## 4) Absences diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 80d296b..f5cbbdd 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -29,6 +29,7 @@ export const documentationSections: DocSection[] = [ { type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' }, { type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' }, { type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' }, + { type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de présence) et le libellé de contrat correspondent au contrat de l\'employé à la date consultée. Si un salarié a changé de type de contrat (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' }, ], }, { -- 2.39.5 From 892d3b3c68319233ae0557191e7ed7e2c7bd9917 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 21:55:55 +0200 Subject: [PATCH 07/13] docs: synchroniser le docblock @var rows de WorkHourDayContext Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ApiResource/WorkHourDayContext.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ApiResource/WorkHourDayContext.php b/src/ApiResource/WorkHourDayContext.php index f035924..043b623 100644 --- a/src/ApiResource/WorkHourDayContext.php +++ b/src/ApiResource/WorkHourDayContext.php @@ -25,13 +25,23 @@ final class WorkHourDayContext /** * @var list */ public array $rows = []; -- 2.39.5 From 89e637ce9ea40e2bcbd5e9f5bb298eaf444471a7 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 22:29:08 +0200 Subject: [PATCH 08/13] [#SIRH] RTT: proratiser le plafond 25%/50% pour les embauches en milieu de semaine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le seuil de départ du +25% était proratisé aux jours contractés, mais le plafond 25%/50% restait codé en dur à 43h: pour une embauche en milieu de semaine, toutes les heures supp tombaient en 25%, jamais en 50%. Le plafond vaut désormais seuil_départ_proraté + largeur de bande +25% (4h pour un 39h, 8h pour un 35h). Semaine pleine: plafond = 43h (inchangé). Témoin Dylan (CDD 39h embauché jeudi, 22h): 4h à 25% + 3h à 50%. Écran Heures (WorkHourWeeklySummaryProvider) laissé tel quel (décision métier). Suppression de deux helpers morts (computeOvertime25/50BonusMinutes) du service. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + doc/functional-rules.md | 5 ++ frontend/data/documentation-content.ts | 1 + .../Rtt/RttRecoveryComputationService.php | 35 ++++++++++---- .../Rtt/RttRecoveryComputationServiceTest.php | 46 +++++++++++++++++++ 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab2ecbb..76b5887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ - Contracts >= 39h: +25% from 39h to 43h, +50% beyond - CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance - **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%. + - **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h − base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier). - INTERIM: no overtime bonuses, no recovery time - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 9a3da08..f902f3d 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -124,6 +124,11 @@ Documents complementaires: - contrats >= 39h: de 39h à 43h - Tranche 50%: - au-delà de 43h +- Embauche/fin de contrat en milieu de semaine (calcul RTT — `RttRecoveryComputationService`): + - les seuils sont proratisés aux jours réellement contractés de la semaine (les jours hors contrat ne comptent pas) + - le seuil de départ du 25% **et** le plafond 25%/50% sont décalés ensemble ; la bande 25% garde sa largeur réglementaire (4h pour un 39h, 8h pour un 35h) + - une semaine d'embauche peut ainsi ouvrir à la fois du 25% et du 50% (ex. CDD 39h embauché le jeudi, 22h travaillées → 4h à 25% + 3h à 50%) + - note: la synthèse de l'écran Heures (vue semaine) n'applique pas cette proratisation (calcul distinct dans `WorkHourWeeklySummaryProvider`) - Date de début RTT (`RTT_START_DATE` dans `.env`): - les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération - permet d'éviter les déficits fictifs avant la mise en service du logiciel diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index f5cbbdd..853dc04 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -513,6 +513,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' }, { type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' }, + { type: 'note', content: 'Pour un contrat débutant en milieu de semaine, le calcul RTT proratise les seuils d\'heures supplémentaires aux jours réellement contractés : le seuil de départ du +25 % et le plafond séparant le +25 % du +50 % sont décalés ensemble (la bande +25 % garde sa largeur : 4h pour un 39h, 8h pour un 35h). Une semaine d\'embauche peut donc générer à la fois des heures à 25 % et à 50 %.' }, ], }, { diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php index 0d26a7b..e8d4bc7 100644 --- a/src/Service/Rtt/RttRecoveryComputationService.php +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -236,13 +236,19 @@ final readonly class RttRecoveryComputationService ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate) : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); + // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 % + // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu + // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %. + $overtime50StartMinutes = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract); $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes; - $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes); + [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes); + + $base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25; $bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25); - $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60); + $base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50; $bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5); if ($isWeekPresenceTracking || $disableOvertimeBonuses) { @@ -452,18 +458,31 @@ final readonly class RttRecoveryComputationService return $total; } - private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int + /** + * Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine : + * 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté + * pour obtenir le plafond 25 %/50 %. + */ + private function resolveOvertime25BandWidthMinutes(?Contract $contract): int { - $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes); + $hours = $contract?->getWeeklyHours(); + $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; - return (int) round($trancheMinutes * 0.25); + return (43 - $startHours) * 60; } - private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int + /** + * Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %. + * La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %. + * + * @return array{int, int} [base25Minutes, base50Minutes] + */ + private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array { - $trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60)); + $base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes); + $base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes); - return (int) round($trancheMinutes * 0.5); + return [$base25, $base50]; } private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool diff --git a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php index 5500e56..b0c9cf5 100644 --- a/tests/Service/Rtt/RttRecoveryComputationServiceTest.php +++ b/tests/Service/Rtt/RttRecoveryComputationServiceTest.php @@ -67,6 +67,52 @@ final class RttRecoveryComputationServiceTest extends TestCase self::assertSame('2026-03-16', $anchor); } + public function testResolveOvertime25BandWidthIs4hForH39(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + $contract = new Contract()->setWeeklyHours(39); + + self::assertSame(4 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); + } + + public function testResolveOvertime25BandWidthIs8hForH35(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + $contract = new Contract()->setWeeklyHours(35); + + self::assertSame(8 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); + } + + /** + * Dylan Chaboisson, semaine 12 : embauché le jeudi sur un contrat 39h. + * Total travaillé 22h (1320 min), départ 25 % proraté aux jours contractés = 15h (900 min), + * plafond 25 %/50 % = 15h + bande 4h = 19h (1140 min). Le plafond se décale avec + * l'embauche au lieu de rester bloqué à 43h, ouvrant la tranche 50 %. + */ + public function testMidWeekHireSplitsOvertimeAcross25And50(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 1320, 900, 1140); + + self::assertSame(4 * 60, $base25); + self::assertSame(3 * 60, $base50); + } + + /** + * Régression : semaine pleine 39h (départ 39h, plafond 43h), 46h travaillées → + * 4h à 25 % (39→43) et 3h à 50 % (43→46), comportement inchangé. + */ + public function testFullWeekOvertimeSplitUnchanged(): void + { + $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); + + [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 2760, 2340, 2580); + + self::assertSame(4 * 60, $base25); + self::assertSame(3 * 60, $base50); + } + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args); -- 2.39.5 From 1486b770b1811f8235a5719201410e81e42d86ac Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 23:20:07 +0200 Subject: [PATCH 09/13] =?UTF-8?q?[#SIRH]=20R=C3=A9cap=20salaire:=20cong?= =?UTF-8?q?=C3=A9s=20N-1=20forfait=20non=20affich=C3=A9s=20et=20compt?= =?UTF-8?q?=C3=A9s=20en=20pr=C3=A9sence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'export récap salaire comptait tous les congés 'C' d'un forfait et ne créditait aucune présence sur les jours de congé. Or un congé imputé sur le stock N-1 ne doit pas s'afficher et doit compter comme jour de présence (règle déjà appliquée dans la fiche employé via EmployeeLeaveSummaryProvider). - Nouvelle méthode publique resolvePreviousYearTakenDays() (mutualise le budget N-1 avec la fiche: phase courante + recalcul jours payés). - SalaryRecapPrintProvider charge les congés depuis le 1er janvier et consomme le budget N-1 chronologiquement (splitForfaitCongesByN1): jours couverts N-1 retirés de l'affichage congés et ajoutés à la présence; au-delà = congés N. - Non-forfait / budget N-1 = 0: comportement inchangé. Vérifié end-to-end sur données prod (SARAZI mai: +1 présence, 4 congés affichés; LIOT/ODUNCU budget 0 après paiement N-1 -> congés affichés). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 1 + src/State/EmployeeLeaveSummaryProvider.php | 29 +++++ src/State/SalaryRecapPrintProvider.php | 107 ++++++++++++++++++- tests/State/SalaryRecapPrintProviderTest.php | 95 ++++++++++++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 tests/State/SalaryRecapPrintProviderTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 76b5887..5759892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. + - **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé. - **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase). ## Onglet Congés (fiche employé) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index f902f3d..a8aa99b 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -267,6 +267,7 @@ Seuls les employés dont au moins une période de contrat intersecte la période - pris: basé sur toutes les absences (demi-journées incluses) - restants = acquis - pris (borné à 0) - paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait. + - jours de présence et récap salaire: pour un forfait, les jours de congé imputés sur le stock N-1 (`previousYearTakenDays`) **ne réduisent pas** les jours de présence et **ne s'affichent pas** comme congés. Sur l'export Récap salaire (mensuel), le budget N-1 est consommé chronologiquement depuis le 1er janvier ; les jours couverts deviennent des jours de présence, les jours au-delà restent affichés en congés. Le budget est le même que la fiche employé (jours payés déduits du stock N-1 d'abord). - report annuel: - le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant - pour `CDI`/`CDD` non forfait: report séparé jours + samedis diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 853dc04..4edcdde 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -622,6 +622,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, + { type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' }, ], }, { diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php index 6195080..0a6a814 100644 --- a/src/State/EmployeeLeaveSummaryProvider.php +++ b/src/State/EmployeeLeaveSummaryProvider.php @@ -411,6 +411,35 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface return null !== $balance ? $balance->getPaidLeaveDays() : 0.0; } + /** + * Budget N-1 = nombre de jours de congé pris imputés sur le stock de l'année précédente, + * pour l'exercice de l'année donnée. Reproduit exactement la dérivation de provide() + * (phase courante + recalcul avec les jours payés) afin que les consommateurs externes + * (ex. récap salaire) voient le même budget que la fiche employé. 0 si non supporté. + */ + public function resolvePreviousYearTakenDays(Employee $employee, int $year): float + { + $phase = $this->resolveCurrentPhase($employee); + if (null === $phase) { + return 0.0; + } + + $summary = $this->computeYearSummary($employee, $year, 0.0, null, $phase); + if (null === $summary) { + return 0.0; + } + + $paidLeaveDays = $this->resolvePaidLeaveDays($employee, $summary['ruleCode'], $year); + if ($paidLeaveDays > 0.0) { + $summary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase); + if (null === $summary) { + return 0.0; + } + } + + return (float) $summary['previousYearTakenDays']; + } + private function resolveEffectivePeriodStart( Employee $employee, DateTimeImmutable $from, diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 0786e01..3a88cc9 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -19,6 +19,7 @@ use App\Repository\ObservationRepository; use App\Repository\WorkHourRepository; use App\Service\Contracts\EmployeeContractResolver; use App\Service\PublicHolidayServiceInterface; +use App\Service\WorkHours\AbsenceSegmentsResolver; use DateInterval; use DateTimeImmutable; use Dompdf\Dompdf; @@ -42,6 +43,8 @@ class SalaryRecapPrintProvider implements ProviderInterface private ObservationRepository $observationRepository, private EmployeeContractResolver $contractResolver, private PublicHolidayServiceInterface $publicHolidayService, + private EmployeeLeaveSummaryProvider $leaveSummaryProvider, + private AbsenceSegmentsResolver $absenceSegmentsResolver, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response @@ -65,6 +68,13 @@ class SalaryRecapPrintProvider implements ProviderInterface $year = (int) $from->format('Y'); $monthNumber = (int) $from->format('n'); + + // Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois : + // nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé + // imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap). + $yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year)); + $ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees); + $ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences); $rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber); $bonuses = $this->bonusRepository->findByMonth($from, $to); @@ -83,7 +93,7 @@ class SalaryRecapPrintProvider implements ProviderInterface $mileageMap = $this->buildMileageMap($mileages); $observationMap = $this->buildObservationMap($observations); - $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap); + $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap, $ytdAbsenceMap, $year, $from, $to); $options = new Options(); $options->set('isRemoteEnabled', true); @@ -264,6 +274,10 @@ class SalaryRecapPrintProvider implements ProviderInterface array $mileageMap, array $observationMap, array $holidayMap, + array $ytdAbsenceMap, + int $year, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo, ): array { $siteGroups = []; @@ -286,6 +300,10 @@ class SalaryRecapPrintProvider implements ProviderInterface $mileageMap[$employeeId] ?? 0.0, $observationMap[$employeeId] ?? '', $holidayMap, + $ytdAbsenceMap[$employeeId] ?? [], + $year, + $monthFrom, + $monthTo, ); if (!isset($siteGroups[$siteId])) { @@ -315,6 +333,10 @@ class SalaryRecapPrintProvider implements ProviderInterface float $mileageKm, string $observation, array $holidayMap, + array $ytdAbsences, + int $year, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo, ): array { $contractName = null; $presenceDays = 0.0; @@ -415,7 +437,21 @@ class SalaryRecapPrintProvider implements ProviderInterface } } - $conges = $this->countAbsencesByCode($absences, ['C']); + // Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap + // et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement + // sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé. + $n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0; + if ($isForfait && $n1Budget > 0.0) { + $ytdConges = array_values(array_filter( + $ytdAbsences, + static fn (Absence $a): bool => 'C' === $a->getType()?->getCode() + )); + $split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo); + $conges = ['count' => $split['count'], 'dates' => $split['dates']]; + $presenceDays += $split['n1PresenceDays']; + } else { + $conges = $this->countAbsencesByCode($absences, ['C']); + } $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $nightHours = round($nightMinutesTotal / 60, 2); @@ -574,6 +610,73 @@ class SalaryRecapPrintProvider implements ProviderInterface return max(0, $end - $start); } + /** + * Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement, + * non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant + * dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent + * qu'à consommer le budget N-1. + * + * @param list $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois + * + * @return array{count: float, dates: string, n1PresenceDays: float} + */ + private function splitForfaitCongesByN1( + array $ytdConges, + float $n1Budget, + DateTimeImmutable $monthFrom, + DateTimeImmutable $monthTo + ): array { + usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate()); + + $remaining = $n1Budget; + $count = 0.0; + $n1PresenceDays = 0.0; + $dayKeys = []; + + foreach ($ytdConges as $absence) { + $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0); + $end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0); + + for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) { + if ((int) $day->format('N') >= 6) { + continue; // week-ends ignorés + } + [$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d')); + $amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0); + if ($amount <= 0.0) { + continue; + } + + $covered = 0.0; + if ($remaining > 0.0) { + $covered = min($remaining, $amount); + $remaining -= $covered; + } + $displayed = $amount - $covered; + + // Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer. + if ($day < $monthFrom || $day > $monthTo) { + continue; + } + + $n1PresenceDays += $covered; + if ($displayed > 0.0) { + $count += $displayed; + $dayKeys[] = $day->format('Y-m-d'); + } + } + } + + sort($dayKeys); + $dayKeys = array_unique($dayKeys); + + return [ + 'count' => $count, + 'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)), + 'n1PresenceDays' => $n1PresenceDays, + ]; + } + /** * @param list $absences * @param list $codes diff --git a/tests/State/SalaryRecapPrintProviderTest.php b/tests/State/SalaryRecapPrintProviderTest.php new file mode 100644 index 0000000..92b15d8 --- /dev/null +++ b/tests/State/SalaryRecapPrintProviderTest.php @@ -0,0 +1,95 @@ +buildConge('2026-01-05'), + $this->buildConge('2026-01-06'), + $this->buildConge('2026-01-07'), + ]; + + $result = $this->split($conges, 2.5, '2026-01-01', '2026-01-31'); + + self::assertSame(2.5, $result['n1PresenceDays']); + self::assertSame(0.5, $result['count']); + self::assertSame('07/01', $result['dates']); + } + + public function testN1BudgetConsumedInPriorMonthLeavesCurrentMonthFullyDisplayed(): void + { + // Budget 1 j, consommé par le congé de janvier. Récap de février → le congé de février + // est entièrement imputé N (affiché, 0 présence N-1 dans le mois). + $conges = [ + $this->buildConge('2026-01-12'), + $this->buildConge('2026-02-09'), + ]; + + $result = $this->split($conges, 1.0, '2026-02-01', '2026-02-28'); + + self::assertSame(0.0, $result['n1PresenceDays']); + self::assertSame(1.0, $result['count']); + self::assertSame('09/02', $result['dates']); + } + + public function testZeroBudgetDisplaysAllCongesInMonth(): void + { + $conges = [$this->buildConge('2026-03-03')]; + + $result = $this->split($conges, 0.0, '2026-03-01', '2026-03-31'); + + self::assertSame(0.0, $result['n1PresenceDays']); + self::assertSame(1.0, $result['count']); + self::assertSame('03/03', $result['dates']); + } + + /** + * @param list $conges + * + * @return array{count: float, dates: string, n1PresenceDays: float} + */ + private function split(array $conges, float $budget, string $from, string $to): array + { + $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver') + ->setValue($provider, new AbsenceSegmentsResolver()); + + return new ReflectionClass($provider::class) + ->getMethod('splitForfaitCongesByN1') + ->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to)); + } + + private function buildConge(string $date): Absence + { + return new Absence() + ->setStartDate(new DateTime($date)) + ->setEndDate(new DateTime($date)) + ->setStartHalf(HalfDay::AM) + ->setEndHalf(HalfDay::PM) + ; + } +} -- 2.39.5 From c1ff46933a7d7d0a3b7820606d4a8c683c372d90 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 23:34:59 +0200 Subject: [PATCH 10/13] =?UTF-8?q?[#SIRH]=20R=C3=A9cap=20salaire:=20scinder?= =?UTF-8?q?=20la=20colonne=20Heures=20pay=C3=A9s=20en=2025%=20/=2050%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En-tête fusionné "Heures payés" (colspan=2) avec deux sous-colonnes 25% et 50% sous-jacentes. paid25Hours=base25Minutes, paid50Hours=base50Minutes (bases seules, total inchangé vs l'ancienne colonne unique). buildRttPaymentMap renvoie ['m25','m50'] par employé. Tableau passé à 20 colonnes (colspan ajustés). PDF généré et validé sur données prod (A4 paysage, largeurs ~228mm). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + frontend/data/documentation-content.ts | 2 +- src/State/SalaryRecapPrintProvider.php | 17 ++++++++++++----- templates/salary-recap/print.html.twig | 11 +++++++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5759892..bc9673f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. - **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé. + - **Colonne « Heures payés » scindée 25 %/50 %** : en-tête fusionné (`colspan=2`) + deux sous-colonnes `25%`/`50%` dans le template `salary-recap/print.html.twig`. Données : `paid25Hours` = `base25Minutes`, `paid50Hours` = `base50Minutes` (bases seules, **hors bonus** — total inchangé vs l'ancienne colonne unique). `buildRttPaymentMap` renvoie `['m25','m50']` par employé. Le tableau a désormais 20 colonnes (`colspan` des lignes site/vide ajusté). - **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase). ## Onglet Congés (fiche employé) diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 4edcdde..e6b09ae 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -621,7 +621,7 @@ export const documentationSections: DocSection[] = [ requiredLevel: 'admin', blocks: [ { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, - { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, + { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, { type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' }, ], }, diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 3a88cc9..80f2e34 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -174,6 +174,9 @@ class SalaryRecapPrintProvider implements ProviderInterface /** * @return array */ + /** + * @return array + */ private function buildRttPaymentMap(array $rttPayments): array { $map = []; @@ -182,7 +185,9 @@ class SalaryRecapPrintProvider implements ProviderInterface if (!$employeeId) { continue; } - $map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes(); + $map[$employeeId] ??= ['m25' => 0, 'm50' => 0]; + $map[$employeeId]['m25'] += $payment->getBase25Minutes(); + $map[$employeeId]['m50'] += $payment->getBase50Minutes(); } return $map; @@ -295,7 +300,7 @@ class SalaryRecapPrintProvider implements ProviderInterface $driverMap[$employeeId] ?? [], $workHourMap[$employeeId] ?? [], $absenceMap[$employeeId] ?? [], - $rttPaymentMap[$employeeId] ?? 0, + $rttPaymentMap[$employeeId] ?? ['m25' => 0, 'm50' => 0], $bonusMap[$employeeId] ?? 0.0, $mileageMap[$employeeId] ?? 0.0, $observationMap[$employeeId] ?? '', @@ -328,7 +333,7 @@ class SalaryRecapPrintProvider implements ProviderInterface array $driverByDate, array $workHoursByDate, array $absences, - int $rttPaidMinutes, + array $rttPaid, float $bonusAmount, float $mileageKm, string $observation, @@ -455,7 +460,8 @@ class SalaryRecapPrintProvider implements ProviderInterface $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $nightHours = round($nightMinutesTotal / 60, 2); - $paidHours = round($rttPaidMinutes / 60, 2); + $paid25Hours = round(($rttPaid['m25'] ?? 0) / 60, 2); + $paid50Hours = round(($rttPaid['m50'] ?? 0) / 60, 2); $sundayHours = round($sundayMinutesTotal / 60, 2); $holidayHours = round($holidayMinutesTotal / 60, 2); @@ -467,7 +473,8 @@ class SalaryRecapPrintProvider implements ProviderInterface 'mileageKm' => $mileageKm, 'nightHours' => $nightHours, 'nightBasketCount' => $nightBasketCount, - 'paidHours' => $paidHours, + 'paid25Hours' => $paid25Hours, + 'paid50Hours' => $paid50Hours, 'sundayHours' => $sundayHours, 'holidayHours' => $holidayHours, 'bonusAmount' => $bonusAmount, diff --git a/templates/salary-recap/print.html.twig b/templates/salary-recap/print.html.twig index 1aef0da..45ca8fe 100644 --- a/templates/salary-recap/print.html.twig +++ b/templates/salary-recap/print.html.twig @@ -117,7 +117,7 @@ Frais
Kms Heures
de
nuit Panier
de
nuit - Heures
payés + Heures
payés Heures
férié Heures
dim. Prime @@ -127,6 +127,8 @@ Observations + 25% + 50% Nbre Date Nbre @@ -141,7 +143,7 @@ {% for siteId, group in siteGroups %} {% set siteColor = group.color ?? '#B3E5FC' %} - + {{ group.name }} @@ -153,7 +155,8 @@ {{ row.mileageKm > 0 ? row.mileageKm : '' }} {{ row.nightHours > 0 ? row.nightHours : '' }} {{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }} - {{ row.paidHours > 0 ? row.paidHours : '' }} + {{ row.paid25Hours > 0 ? row.paid25Hours : '' }} + {{ row.paid50Hours > 0 ? row.paid50Hours : '' }} {{ row.holidayHours > 0 ? row.holidayHours : '' }} {{ row.sundayHours > 0 ? row.sundayHours : '' }} {{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }} @@ -169,7 +172,7 @@ {% else %} - Aucun employé. + Aucun employé. {% endfor %} {% endfor %} -- 2.39.5 From 788666681229d4c6f68a2019c1c8acf910e762bd Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 1 Jun 2026 23:47:31 +0200 Subject: [PATCH 11/13] =?UTF-8?q?[#SIRH]=20Exports=20heures=20annuelles:?= =?UTF-8?q?=20afficher=20tous=20les=20jours=20contract=C3=A9s=20+=20week-e?= =?UTF-8?q?nds=20plus=20fonc=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YearlyHoursExportBuilder: ne plus sauter les jours de semaine vides/non saisis. Tous les jours sous contrat sont affichés (jusqu'à aujourd'hui); seuls les jours hors contrat restent omis. Corrige les lignes manquantes signalées par la RH. - Templates print/print-all: gris des samedis/dimanches foncé (#f3f3f3 -> #c0c0c0). - Docs (functional-rules, in-app, CLAUDE.md). NB: l'export tous-salariés sur l'année peut dépasser memory_limit=256M (Dompdf) — limitation pré-existante (déjà le cas avant ce changement), non corrigée ici. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 2 +- src/Service/WorkHours/YearlyHoursExportBuilder.php | 7 +++---- templates/employee-yearly-hours/print-all.html.twig | 2 +- templates/employee-yearly-hours/print.html.twig | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc9673f..296561a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ - **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). +- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. - **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots diff --git a/doc/functional-rules.md b/doc/functional-rules.md index a8aa99b..c0d7159 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -62,6 +62,7 @@ Documents complementaires: - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) - **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. +- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu. ## 4) Absences diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index e6b09ae..777496d 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -640,7 +640,7 @@ export const documentationSections: DocSection[] = [ requiredLevel: 'admin', blocks: [ { type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' }, - { type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' }, + { type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nTous les jours sous contrat sont affichés, même vides ou non saisis (jusqu\'à la date du jour) ; seuls les jours hors contrat (avant embauche, après départ) sont omis\nLes samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu avec « Férié : {nom} »' }, ], }, ], diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php index 4e1a377..7b67a00 100644 --- a/src/Service/WorkHours/YearlyHoursExportBuilder.php +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -264,10 +264,9 @@ class YearlyHoursExportBuilder $isoDay = (int) new DateTimeImmutable($date)->format('N'); $isWeekend = $isoDay >= 6; - if (!$hasData && !$isWeekend && !$isHoliday) { - continue; - } - + // Tous les jours contractés sont affichés, même vides ou non saisis (lignes + // « manquantes » signalées par la RH). Seuls les jours hors contrat (avant + // embauche, après départ, suspension) sont omis. if (!$hasData && null === $contract) { continue; } diff --git a/templates/employee-yearly-hours/print-all.html.twig b/templates/employee-yearly-hours/print-all.html.twig index 94fe44e..07febbd 100644 --- a/templates/employee-yearly-hours/print-all.html.twig +++ b/templates/employee-yearly-hours/print-all.html.twig @@ -81,7 +81,7 @@ td.time { text-align: center; } td.presence { text-align: center; } td.total { text-align: center; font-weight: bold; } - tr.weekend td { background: #f3f3f3; color: #555; } + tr.weekend td { background: #c0c0c0; color: #333; } tr.weekend td.date { color: #333; } tr.holiday td { background: #e1f5fe; } diff --git a/templates/employee-yearly-hours/print.html.twig b/templates/employee-yearly-hours/print.html.twig index 6c7d10d..53db2d4 100644 --- a/templates/employee-yearly-hours/print.html.twig +++ b/templates/employee-yearly-hours/print.html.twig @@ -70,7 +70,7 @@ td.time { text-align: center; } td.presence { text-align: center; } td.total { text-align: center; font-weight: bold; } - tr.weekend td { background: #f3f3f3; color: #555; } + tr.weekend td { background: #c0c0c0; color: #333; } tr.weekend td.date { color: #333; } tr.holiday td { background: #e1f5fe; } -- 2.39.5 From 94cf8eb7a9f5dc47de5f4df7603d82bd1fbc4482 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 08:10:54 +0200 Subject: [PATCH 12/13] =?UTF-8?q?[#SIRH]=20R=C3=A9cap=20salaire:=20exclure?= =?UTF-8?q?=20les=20salari=C3=A9s=20sans=20contrat=20sur=20le=20mois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le récap listait tous les employés sans filtrer le contrat: un salarié au contrat terminé (ex. Marine, fin 26/02) apparaissait sur le récap de juin. Ajout du filtre hasContractInRange (même règle que l'impression absences) sur la période [from, to] du mois imprimé. 4 tests ajoutés. Vérifié sur données prod (Marine + 6 autres contrats terminés exclus du mois de juin, 39 salariés contractés conservés). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/functional-rules.md | 1 + frontend/data/documentation-content.ts | 1 + src/State/SalaryRecapPrintProvider.php | 24 +++++++++- tests/State/SalaryRecapPrintProviderTest.php | 50 ++++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 296561a..787675d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). - **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. -- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. +- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index c0d7159..a156869 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -63,6 +63,7 @@ Documents complementaires: - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) - **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. - **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu. +- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants. ## 4) Absences diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 777496d..60dbb49 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -622,6 +622,7 @@ export const documentationSections: DocSection[] = [ blocks: [ { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' }, + { type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' }, { type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' }, ], }, diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 80f2e34..2251563 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -62,7 +62,13 @@ class SalaryRecapPrintProvider implements ProviderInterface $from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01'); $to = $from->modify('last day of this month'); - $employees = $this->employeeRepository->findForPrintBySiteIds([]); + // N'inclure que les employés ayant un contrat couvrant tout ou partie du mois. + // Sans ce filtre, un salarié dont le contrat est terminé (ex. parti en février) + // apparaît à tort sur le récap des mois suivants. + $employees = array_values(array_filter( + $this->employeeRepository->findForPrintBySiteIds([]), + fn (Employee $employee): bool => $this->hasContractInRange($employee, $from, $to) + )); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees); $absences = $this->absenceRepository->findForPrint($from, $to, $employees); @@ -120,6 +126,22 @@ class SalaryRecapPrintProvider implements ProviderInterface ]); } + private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool + { + $fromDay = $from->format('Y-m-d'); + $toDay = $to->format('Y-m-d'); + + foreach ($employee->getContractPeriods() as $period) { + $start = $period->getStartDate()->format('Y-m-d'); + $end = $period->getEndDate()?->format('Y-m-d'); + if ($start <= $toDay && (null === $end || $end >= $fromDay)) { + return true; + } + } + + return false; + } + /** * @return list */ diff --git a/tests/State/SalaryRecapPrintProviderTest.php b/tests/State/SalaryRecapPrintProviderTest.php index 92b15d8..58d0230 100644 --- a/tests/State/SalaryRecapPrintProviderTest.php +++ b/tests/State/SalaryRecapPrintProviderTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\State; use App\Entity\Absence; +use App\Entity\Employee; +use App\Entity\EmployeeContractPeriod; use App\Enum\HalfDay; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\State\SalaryRecapPrintProvider; @@ -67,6 +69,54 @@ final class SalaryRecapPrintProviderTest extends TestCase self::assertSame('03/03', $result['dates']); } + public function testTerminatedContractExcludedFromMonth(): void + { + // Marine : contrat terminé le 26/02 → absente du récap de juin. + $employee = $this->buildEmployeeWithPeriod('2025-02-10', '2026-02-26'); + + self::assertFalse($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testOngoingContractIncluded(): void + { + $employee = $this->buildEmployeeWithPeriod('2025-01-01', null); + + self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testContractEndingOnFromDayIncluded(): void + { + $employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-06-01'); + + self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30')); + } + + public function testNoPeriodsExcluded(): void + { + self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30')); + } + + private function hasInRange(Employee $employee, string $from, string $to): bool + { + $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); + + return new ReflectionClass($provider::class) + ->getMethod('hasContractInRange') + ->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to)); + } + + private function buildEmployeeWithPeriod(string $start, ?string $end): Employee + { + $employee = new Employee(); + $period = new EmployeeContractPeriod(); + $period->setEmployee($employee); + $period->setStartDate(new DateTimeImmutable($start)); + $period->setEndDate(null !== $end ? new DateTimeImmutable($end) : null); + $employee->getContractPeriods()->add($period); + + return $employee; + } + /** * @param list $conges * -- 2.39.5 From 8ae8b2098c1c6f339592ee824037a2426cea2f36 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 08:17:02 +0200 Subject: [PATCH 13/13] [#SIRH] Panier de nuit: ne s'applique pas aux conducteurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La règle panier de nuit (nuit > jour OU nuit >= 4h) ne concerne que les non-conducteurs ; les conducteurs ont leurs propres primes (PDJ/repas/nuitée). Eddy (conducteur) avait un PN à tort (jour atelier + un peu de nuit). - WorkHourWeeklySummaryProvider: garde !isDateDriver sur le calcul du PN. - SalaryRecapPrintProvider: retrait de l'incrément PN du bloc conducteur. - Docs (functional-rules, in-app, CLAUDE.md) rectifiées (le PN était décrit à tort dans la section conducteurs). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + doc/functional-rules.md | 2 +- frontend/data/documentation-content.ts | 2 +- src/State/SalaryRecapPrintProvider.php | 5 ++--- src/State/WorkHourWeeklySummaryProvider.php | 5 ++++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 787675d..9f711e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) - Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour` +- **Panier de nuit (PN) — conducteurs exclus** : le panier de nuit (règle nuit > jour OU nuit ≥ 4h) **ne s'applique qu'aux non-conducteurs**. Un jour conducteur ne crédite jamais de PN, ni sur la vue semaine (`WorkHourWeeklySummaryProvider`, garde `!$isDateDriver`) ni sur le récap salaire (`SalaryRecapPrintProvider`, bloc `if ($isDriver)` sans incrément). Les conducteurs ont leurs propres primes (PDJ/repas/nuitée). ## Fériés - Source : API gouv via `PublicHolidayService` (cache 30j) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index a156869..149582d 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -168,7 +168,7 @@ Documents complementaires: - Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk) - Vue semaine: - jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée - - panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem. + - panier de nuit (PN): **ne s'applique pas aux conducteurs** (ils disposent de leurs propres primes repas/nuitée). Aucun PN n'est crédité sur un jour conducteur, ni sur la vue semaine conducteurs ni sur le récap salaire. La règle PN (nuit > jour OU nuit ≥ 4h) ne concerne que les non-conducteurs. - totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée - les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening) - Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période) diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 60dbb49..abcd00d 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -374,7 +374,7 @@ export const documentationSections: DocSection[] = [ requiredLevel: 'admin', blocks: [ { type: 'paragraph', content: 'La vue semaine conducteurs affiche des colonnes spécifiques.' }, - { type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : affiché quand heures nuit > heures jour OU nuit ≥ 4h\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' }, + { type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : ne s\'applique pas aux conducteurs (ils ont leurs propres primes repas/nuitée)\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' }, ], }, ], diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php index 2251563..ecd47c0 100644 --- a/src/State/SalaryRecapPrintProvider.php +++ b/src/State/SalaryRecapPrintProvider.php @@ -405,9 +405,8 @@ class SalaryRecapPrintProvider implements ProviderInterface $dayMin = $wh->getDayHoursMinutes() ?? 0; $nightMin = $wh->getNightHoursMinutes() ?? 0; $workshopMin = $wh->getWorkshopHoursMinutes() ?? 0; - if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) { - ++$nightBasketCount; - } + // Le panier de nuit ne s'applique pas aux conducteurs (primes repas/nuitée + // dédiées). Aucun panier de nuit crédité ici. if ($wh->getHasBreakfast()) { ++$driverBreakfast; diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php index 00ae3f1..5885c05 100644 --- a/src/State/WorkHourWeeklySummaryProvider.php +++ b/src/State/WorkHourWeeklySummaryProvider.php @@ -286,7 +286,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface $present = min(1.0, $morning + $afternoon + $creditedPresence); } - $hasNightBasket = ($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240; + // Le panier de nuit ne s'applique pas aux conducteurs (ils ont leurs propres + // primes repas/nuitée). Réservé aux non-conducteurs. + $hasNightBasket = !$isDateDriver + && (($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240); if ($hasNightBasket) { ++$weeklyNightBasketCount; } -- 2.39.5