Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
## Correctifs RH (branche fix/retour-rh) ### Vue Jour (Heures) - Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait. ### RTT — heures supplémentaires - Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%. ### Récap salaire (PDF mensuel) - Forfait : congés imputés **N-1** non affichés et comptés en présence. - Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné). - **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé). ### Exports heures annuelles (par salarié + tous) - **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes). - Samedis/dimanches en **gris plus foncé**. ### Panier de nuit - **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire). ## Tests - 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche). ## À noter (hors scope) - L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #21 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #21.
This commit is contained in:
@@ -33,12 +33,14 @@
|
||||
- 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).
|
||||
- **É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.
|
||||
- **É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. **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)
|
||||
- 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)
|
||||
@@ -65,10 +67,13 @@
|
||||
- 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.
|
||||
- **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é)
|
||||
|
||||
+10
-1
@@ -61,6 +61,9 @@ 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.
|
||||
- **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
|
||||
|
||||
@@ -123,6 +126,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
|
||||
@@ -160,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)
|
||||
@@ -261,6 +269,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
|
||||
|
||||
@@ -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).
|
||||
@@ -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é).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -373,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)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -512,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 %.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -619,7 +621,9 @@ 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: '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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -637,7 +641,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} »' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -115,6 +115,10 @@ export type WorkHourDayContextRow = {
|
||||
formationLabel?: string | null
|
||||
virtualHolidayMinutes?: number
|
||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
trackingMode?: TrackingMode | null
|
||||
weeklyHours?: number | null
|
||||
contractType?: ContractType | null
|
||||
contractName?: string | null
|
||||
}
|
||||
|
||||
export type WorkHourDayContext = {
|
||||
|
||||
@@ -25,13 +25,23 @@ final class WorkHourDayContext
|
||||
/**
|
||||
* @var list<array{
|
||||
* employeeId:int,
|
||||
* hasContractAtDate:bool,
|
||||
* absenceLabel:?string,
|
||||
* absenceColor:?string,
|
||||
* absenceHalf:?string,
|
||||
* absentMorning:bool,
|
||||
* absentAfternoon:bool,
|
||||
* creditedMinutes:int,
|
||||
* creditedPresenceUnits:float
|
||||
* creditedPresenceUnits:float,
|
||||
* isDriverContract:bool,
|
||||
* hasFormation:bool,
|
||||
* formationLabel:?string,
|
||||
* virtualHolidayMinutes:int,
|
||||
* contractNature:?string,
|
||||
* trackingMode:?string,
|
||||
* weeklyHours:?int,
|
||||
* contractType:?string,
|
||||
* contractName:?string
|
||||
* }>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -59,12 +62,25 @@ 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);
|
||||
|
||||
$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 +99,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);
|
||||
@@ -110,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<string>
|
||||
*/
|
||||
@@ -164,6 +196,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
/**
|
||||
* @return array<int, array{m25: int, m50: int}>
|
||||
*/
|
||||
private function buildRttPaymentMap(array $rttPayments): array
|
||||
{
|
||||
$map = [];
|
||||
@@ -172,7 +207,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;
|
||||
@@ -264,6 +301,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
array $holidayMap,
|
||||
array $ytdAbsenceMap,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -281,11 +322,15 @@ 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] ?? '',
|
||||
$holidayMap,
|
||||
$ytdAbsenceMap[$employeeId] ?? [],
|
||||
$year,
|
||||
$monthFrom,
|
||||
$monthTo,
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -310,11 +355,15 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $driverByDate,
|
||||
array $workHoursByDate,
|
||||
array $absences,
|
||||
int $rttPaidMinutes,
|
||||
array $rttPaid,
|
||||
float $bonusAmount,
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
array $holidayMap,
|
||||
array $ytdAbsences,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
@@ -356,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;
|
||||
@@ -415,11 +463,26 @@ 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);
|
||||
$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);
|
||||
|
||||
@@ -431,7 +494,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
'mileageKm' => $mileageKm,
|
||||
'nightHours' => $nightHours,
|
||||
'nightBasketCount' => $nightBasketCount,
|
||||
'paidHours' => $paidHours,
|
||||
'paid25Hours' => $paid25Hours,
|
||||
'paid50Hours' => $paid50Hours,
|
||||
'sundayHours' => $sundayHours,
|
||||
'holidayHours' => $holidayHours,
|
||||
'bonusAmount' => $bonusAmount,
|
||||
@@ -574,6 +638,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<Absence> $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<Absence> $absences
|
||||
* @param list<string> $codes
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
<th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
|
||||
<th colspan="2" style="width: 14mm;">Heures<br>payés</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
|
||||
<th rowspan="2" style="width: 8mm;">Prime</th>
|
||||
@@ -127,6 +127,8 @@
|
||||
<th rowspan="2" style="width: 20mm;">Observations</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 7mm;">25%</th>
|
||||
<th style="width: 7mm;">50%</th>
|
||||
<th style="width: 8mm;">Nbre</th>
|
||||
<th style="width: 22mm;">Date</th>
|
||||
<th style="width: 8mm;">Nbre</th>
|
||||
@@ -141,7 +143,7 @@
|
||||
{% for siteId, group in siteGroups %}
|
||||
{% set siteColor = group.color ?? '#B3E5FC' %}
|
||||
<tr class="site-header">
|
||||
<td style="background: {{ siteColor }}; text-align: left;" colspan="19">
|
||||
<td style="background: {{ siteColor }}; text-align: left;" colspan="20">
|
||||
{{ group.name }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -153,7 +155,8 @@
|
||||
<td class="num">{{ row.mileageKm > 0 ? row.mileageKm : '' }}</td>
|
||||
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
|
||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
|
||||
<td class="num">{{ row.paid25Hours > 0 ? row.paid25Hours : '' }}</td>
|
||||
<td class="num">{{ row.paid50Hours > 0 ? row.paid50Hours : '' }}</td>
|
||||
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
|
||||
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
||||
@@ -169,7 +172,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="19">Aucun employé.</td>
|
||||
<td colspan="20">Aucun employé.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Forfait N-1 split for the salary recap. The provider's collaborators are final classes
|
||||
* PHPUnit cannot double, so the pure split helper is exercised via reflection, with a real
|
||||
* AbsenceSegmentsResolver (no deps) injected into the uninitialized property.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SalaryRecapPrintProviderTest extends TestCase
|
||||
{
|
||||
public function testN1BudgetPartiallyCoversADayAndOverflowsToN(): void
|
||||
{
|
||||
// Budget N-1 = 2.5 j ; 3 congés pleins (1 j) lun/mar/mer de janvier.
|
||||
// 1.0 + 1.0 + 0.5 consommés en N-1 → reste 0.5 j affiché en congé (le mercredi).
|
||||
$conges = [
|
||||
$this->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']);
|
||||
}
|
||||
|
||||
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<Absence> $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)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user