Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions 5d2b5d1c54 chore: bump version to v0.1.120
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-12 13:06:07 +00:00
tristan c8e7f80c72 fix(conges) : un congé posé un dimanche n'est plus décompté (récap salaire)
Auto Tag Develop / tag (push) Successful in 11s
Le récap salaire comptait les congés (C) tombant un dimanche via
countAbsencesByCode, alors que l'onglet Congés, le rollover et les jours de
présence l'ignoraient déjà. Garde ajoutée (C + dimanche → ignoré) pour aligner :
poser une période à cheval sur un week-end (ex. jeu→mar) ne fait plus perdre le
dimanche. Correctif au comptage uniquement : les lignes d'absence du dimanche
restent créées et affichées sur le calendrier (volonté RH), l'existant cesse de
compter sans migration. Périmètre strict : code C (maladie/AT inchangés), samedi
inchangé (budget dédié).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:03:49 +02:00
gitea-actions c298f66993 chore: bump version to v0.1.119
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-12 12:40:10 +00:00
tristan 7187989003 feat(heures) : affichage des jours travaillés (CUSTOM) sur la vue Jour
Auto Tag Develop / tag (push) Successful in 12s
Le libellé sous le nom de l'employé affiche en suffixe les jours du planning
workDaysHours au format court (ex. BUREAU — CDI — LU,JE) pour les contrats CUSTOM.
Résolu à la date filtrée et exposé via WorkHourDayContext.workDaysHours ; formaté
front par formatWorkedDaysShort. Limité aux CUSTOM (35h/39h/forfait/intérim sans
planning → rien affiché) et à l'écran Heures (pas Heures Conducteurs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:33:46 +02:00
gitea-actions 4b22270c60 chore: bump version to v0.1.118
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m4s
2026-06-12 09:58:49 +00:00
tristan acbf1ccecb fix(rtt) : jour de solidarité sans déficit si le salarié ne travaille pas le lundi
Auto Tag Develop / tag (push) Successful in 11s
Un contrat CUSTOM < 35h qui ne travaille pas le lundi (jour de solidarité,
workDaysHours[lundi] absent → attendu = 0) ne portait à tort un déficit
forfaitaire ((0 − 0) − prorata = −prorata). Garde ajoutée : aucun déficit
quand expectedMinutes === 0. Ewa (Lun+Jeu) reste à −0h48 ; Nadia (Mar+Ven)
passe de −0h48 à 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:01:24 +02:00
19 changed files with 223 additions and 17 deletions
+4 -3
View File
@@ -33,7 +33,7 @@
- Contract nature (per period): CDI, CDD, INTERIM - 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. - **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` - 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). - **É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). **Jours travaillés (CUSTOM)** : le libellé sous le nom affiche en suffixe les jours du planning `workDaysHours` au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE`). Exposé via `WorkHourDayContext.workDaysHours` (peuplé par `EmployeeContractResolver::resolveWorkDaysMinutesForEmployeeAndDate`, à la date filtrée), formaté front par `formatWorkedDaysShort` (`utils/contract.ts`) et accédé via `getRowWorkedDaysLabel` (`useHoursPage.ts`). Affiché **uniquement écran Heures** (`HoursDayView.vue`, mobile + desktop) ; naturellement limité aux CUSTOM (seuls eux ont `workDaysHours` → null sinon, rien affiché). Pas sur Heures Conducteurs (pas de planning workDaysHours).
- **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. - **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.
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`. - **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. - **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
@@ -68,14 +68,15 @@
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond - Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond - 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). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1. - CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`. - **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. **Garde : uniquement si le salarié travaille le lundi** (`workDaysHours[lundi] > 0`, i.e. `expectedMinutes > 0`) ; un temps partiel ne travaillant jamais le lundi (ex. Nadia, Mar+Ven) **ne porte aucun déficit** (sinon `(0 0) prorata` lui facturerait à tort le prorata). Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
- **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%. - **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). - **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 - INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - 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 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é. - **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é. - **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'])`.
- **Congé posé un dimanche — jamais décompté** : un congé `C` tombant un **dimanche** n'est compté comme congé pris **nulle part** (récap congés, rollover, jours de présence l'ignoraient déjà ; le **récap salaire** est désormais aligné via une garde dans `SalaryRecapPrintProvider::countAbsencesByCode` : `'C' === code && 7 === N → continue`). Objectif RH : poser une période à cheval sur un week-end (ex. jeu→mar) sans « perdre » le dimanche. **Correctif au comptage** (pas à la création) : les lignes d'absence du dimanche **restent créées et stockées** (`AbsenceWriteProcessor::expandAbsenceRange` inchangé), donc l'existant cesse de compter sans migration, et le **calendrier + impression PDF des absences continuent d'afficher** le dimanche (volonté RH). Périmètre strict : code `C` uniquement (maladie/AT comptés normalement) ; le **samedi** garde son budget dédié (`takenSaturdays`). `splitForfaitCongesByN1` sautait déjà le week-end.
- **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é). - **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). - **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).
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.117' app.version: '0.1.120'
+7 -1
View File
@@ -61,6 +61,7 @@ Documents complementaires:
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom: - 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 - 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é) - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
- **Jours travaillés (contrats CUSTOM)** : pour un contrat CUSTOM (planning `workDaysHours` renseigné), les jours effectivement travaillés sont affichés en suffixe du libellé `Site — Nature`, au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE` pour un temps partiel travaillant lundi et jeudi). Résolu à la date filtrée. Les contrats 35h/39h/Forfait/Intérim n'ont pas de planning → aucun suffixe. Écran Heures uniquement (pas Heures Conducteurs).
- **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. - **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. - **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. - **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.
@@ -154,6 +155,10 @@ soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebd
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours), déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`. indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité
planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais
le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé.
- Nature `INTERIM`: - Nature `INTERIM`:
- pas de bonus 25% - pas de bonus 25%
- pas de bonus 50% - pas de bonus 50%
@@ -274,8 +279,9 @@ Seuls les employés dont au moins une période de contrat intersecte la période
- pas de samedi (`0`) - pas de samedi (`0`)
- pas de jours en cours d'acquisition (`0`) - pas de jours en cours d'acquisition (`0`)
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre. - fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
- **dimanche jamais décompté** : un congé `C` posé un dimanche n'est **jamais** compté comme congé pris, où que ce soit (récap congés, rollover, jours de présence, et **récap salaire**). Permet de poser une période à cheval sur un week-end (ex. jeu→mar) sans « perdre » le dimanche. Ne concerne que le code `C` (maladie/AT inchangés) ; le samedi conserve son budget dédié. **Le calendrier et son impression PDF continuent d'afficher** la ligne du dimanche (la ligne d'absence existe en base, choix RH).
- pour `CDI`/`CDD` non forfait: - pour `CDI`/`CDD` non forfait:
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées - pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées (dimanche exclu, samedi compté à part)
- samedi pris: absences `C` posées le samedi (demi-journée incluse) - samedi pris: absences `C` posées le samedi (demi-journée incluse)
- restants = acquis - pris (borné à 0) - restants = acquis - pris (borné à 0)
- pour `FORFAIT`: - pour `FORFAIT`:
+4 -2
View File
@@ -32,8 +32,10 @@ Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → 0h48) dans les colonnes forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → 0h48) dans les colonnes
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel : Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Les contrats il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement). ne travaille pas le lundi (lundi non planifié au contrat) n'est pas concerné : aucun
déficit. Les contrats 35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul
normalement).
## Sélecteur d'année ## Sélecteur d'année
@@ -127,6 +127,7 @@ Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM,
| Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). | | Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). |
| Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. | | Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. |
| Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. | | Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. |
| Salarié CUSTOM < 35h ne travaillant pas le lundi (ex. Mar+Ven) | `expectedMinutes = workDaysHours[lundi] = 0` → pas de déficit (garde `0 === $expectedMinutes`). |
| CUSTOM ≥ 35h (3638h) | Hors périmètre → pas de déficit. | | CUSTOM ≥ 35h (3638h) | Hors périmètre → pas de déficit. |
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). | | 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
| Autre déficit/surplus la même semaine | Le forfait s'y cumule. | | Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
+3 -2
View File
@@ -14,7 +14,7 @@
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-sm text-neutral-500 truncate"> <p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> {{ getRowWorkedDaysLabel(employee.id) }}</span>
</p> </p>
</div> </div>
@@ -212,7 +212,7 @@
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> {{ getRowWorkedDaysLabel(employee.id) }}</span>
</span> </span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -406,6 +406,7 @@ const props = defineProps<{
hasRowFormation: (employeeId: number) => boolean hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowWorkedDaysLabel: (employeeId: number) => string | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
+6
View File
@@ -517,6 +517,11 @@ export const useHoursPage = () => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
} }
// Jours travaillés du planning (contrats CUSTOM uniquement), ex. "LU,VE". null sinon.
const getRowWorkedDaysLabel = (employeeId: number): string | null => {
return formatWorkedDaysShort(dayContextByEmployeeId.value.get(employeeId)?.workDaysHours)
}
const getRowUpdatedAt = (employeeId: number): string => { const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt const raw = rows.value[employeeId]?.updatedAt
if (!raw) return '' if (!raw) return ''
@@ -1207,6 +1212,7 @@ export const useHoursPage = () => {
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature, getRowContractNature,
getRowWorkedDaysLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
+4 -1
View File
@@ -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: '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: '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: '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: 'Pour un contrat à temps partiel avec un planning de jours travaillés (contrat « personnalisé »), les jours travaillés sont rappelés à la suite du libellé site et nature, en abrégé : par exemple « BUREAU — CDI — LU,JE » pour un salarié travaillant le lundi et le jeudi. Les contrats 35h, 39h, forfait et intérim n\'affichent pas ce rappel.' },
{ 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.' }, { 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.' },
], ],
}, },
@@ -492,6 +493,7 @@ export const documentationSections: DocSection[] = [
blocks: [ blocks: [
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' }, { type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' }, { type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
{ type: 'note', content: 'Les dimanches ne sont jamais comptés comme congés pris. Une période de congé à cheval sur un week-end (par exemple du jeudi au mardi) ne décompte pas le dimanche. Le dimanche reste affiché sur le calendrier mais n\'entre dans aucun compteur.' },
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' }, { type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' }, { type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' }, { type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
@@ -537,7 +539,7 @@ export const documentationSections: DocSection[] = [
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' }, { type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' }, { type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' }, { type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là.' }, { type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là. Un salarié qui ne travaille pas le lundi n\'est pas concerné : aucun déficit ne lui est imputé.' },
], ],
}, },
{ {
@@ -640,6 +642,7 @@ export const documentationSections: DocSection[] = [
{ 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: '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: '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.' }, { 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.' },
{ type: 'note', content: 'Un congé posé un dimanche n\'est jamais décompté comme congé pris (colonne congés), comme partout ailleurs dans l\'application. Vous pouvez donc poser une période à cheval sur un week-end (par exemple du jeudi au mardi) sans « perdre » le dimanche. Le dimanche reste visible sur le calendrier et son impression.' },
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' }, { type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
], ],
}, },
+2
View File
@@ -87,6 +87,7 @@
:has-row-formation="hasRowFormation" :has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel" :get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature" :get-row-contract-nature="getRowContractNature"
:get-row-worked-days-label="getRowWorkedDaysLabel"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
@@ -215,6 +216,7 @@ const {
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature, getRowContractNature,
getRowWorkedDaysLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
+1
View File
@@ -119,6 +119,7 @@ export type WorkHourDayContextRow = {
weeklyHours?: number | null weeklyHours?: number | null
contractType?: ContractType | null contractType?: ContractType | null
contractName?: string | null contractName?: string | null
workDaysHours?: Record<number, number> | null
} }
export type WorkHourDayContext = { export type WorkHourDayContext = {
+20
View File
@@ -37,6 +37,26 @@ export const requiresWorkDaysHours = (
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' } const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
const DAY_TINY_LABELS: Record<number, string> = { 1: 'LU', 2: 'MA', 3: 'ME', 4: 'JE', 5: 'VE' }
/**
* Very compact worked-days summary for the day view header, e.g. "LU,VE".
* Lists the iso days actually worked (minutes > 0), uppercase 2-letter, comma-separated.
* Returns null when the schedule is empty/unset (non-CUSTOM contracts have no schedule).
*/
export const formatWorkedDaysShort = (
workDaysHours: Record<number, number> | null | undefined
): string | null => {
if (!workDaysHours) return null
const days = Object.entries(workDaysHours)
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
.sort(([a], [b]) => a - b)
.map(([iso]) => DAY_TINY_LABELS[iso])
if (days.length === 0) return null
return days.join(',')
}
/** /**
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h". * Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
* Returns null when the schedule is empty/unset. * Returns null when the schedule is empty/unset.
+2 -1
View File
@@ -41,7 +41,8 @@ final class WorkHourDayContext
* trackingMode:?string, * trackingMode:?string,
* weeklyHours:?int, * weeklyHours:?int,
* contractType:?string, * contractType:?string,
* contractName:?string * contractName:?string,
* workDaysHours:?array<int, int>
* }> * }>
*/ */
public array $rows = []; public array $rows = [];
+5 -1
View File
@@ -25,6 +25,8 @@ final class DayContextRow
public ?int $weeklyHours = null, public ?int $weeklyHours = null,
public ?string $contractType = null, public ?string $contractType = null,
public ?string $contractName = null, public ?string $contractName = null,
/** @var null|array<int, int> iso day (1=Mon..5=Fri) → minutes, planning des jours travaillés (CUSTOM uniquement) */
public ?array $workDaysHours = null,
) {} ) {}
public function setFormation(string $label): void public function setFormation(string $label): void
@@ -87,7 +89,8 @@ final class DayContextRow
* trackingMode:?string, * trackingMode:?string,
* weeklyHours:?int, * weeklyHours:?int,
* contractType:?string, * contractType:?string,
* contractName:?string * contractName:?string,
* workDaysHours:?array<int, int>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@@ -111,6 +114,7 @@ final class DayContextRow
'weeklyHours' => $this->weeklyHours, 'weeklyHours' => $this->weeklyHours,
'contractType' => $this->contractType, 'contractType' => $this->contractType,
'contractName' => $this->contractName, 'contractName' => $this->contractName,
'workDaysHours' => $this->workDaysHours,
]; ];
} }
@@ -498,6 +498,14 @@ final readonly class RttRecoveryComputationService
return 0; return 0;
} }
// Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat,
// workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun
// déficit n'est imputé. Sans cette garde, (0 0) prorata facturerait à tort le prorata
// à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven).
if (0 === $expectedMinutes) {
return 0;
}
$prorata = (int) round($weeklyHours * 12); $prorata = (int) round($weeklyHours * 12);
return ($expectedMinutes - $workedMinutes) - $prorata; return ($expectedMinutes - $workedMinutes) - $prorata;
+8
View File
@@ -694,6 +694,14 @@ class SalaryRecapPrintProvider implements ProviderInterface
continue; continue;
} }
// Un congé (C) posé un dimanche n'est pas décompté comme congé pris : un dimanche
// ne fait pas partie des congés (cf. récap congés / rollover qui l'ignorent déjà).
// Le calendrier et son impression continuent d'afficher la ligne (volonté RH).
// Hors périmètre : maladie/AT et le samedi (budget samedis dédié) sont inchangés.
if ('C' === $type->getCode() && 7 === (int) $absence->getStartDate()->format('N')) {
continue;
}
$startHalf = $absence->getStartHalf(); $startHalf = $absence->getStartHalf();
$endHalf = $absence->getEndHalf(); $endHalf = $absence->getEndHalf();
+1
View File
@@ -72,6 +72,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
weeklyHours: $contract?->getWeeklyHours(), weeklyHours: $contract?->getWeeklyHours(),
contractType: $contract?->getType()->value, contractType: $contract?->getType()->value,
contractName: $contract?->getName(), contractName: $contract?->getName(),
workDaysHours: $workDaysMinutes,
); );
} }
@@ -207,6 +207,20 @@ final class RttRecoveryComputationServiceTest extends TestCase
self::assertSame(-168, $delta); self::assertSame(-168, $delta);
} }
/**
* CUSTOM 4h NE travaillant PAS le jour de solidarité (lundi non planifié, ex. Nadia Mar+Ven) :
* workDaysHours[lundi] absent expected = 0. Le jour de solidarité ne la concerne pas delta 0,
* aucun déficit imputé. C'est la correction du bug : (0 0) 48 ne doit PAS donner 48.
*/
public function testSolidarityAdjustmentCustomNotScheduledThatDayIsZero(): void
{
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 0, 0);
self::assertSame(0, $delta);
}
/** /**
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 delta 0. * CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 delta 0.
* Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai * Le delta est nul ici par coïncidence du fallback uniforme (expected = prorata) ; avec un vrai
+69 -3
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\State; namespace App\Tests\State;
use App\Entity\Absence; use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Enum\HalfDay; use App\Enum\HalfDay;
@@ -96,13 +97,76 @@ final class SalaryRecapPrintProviderTest extends TestCase
self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30')); self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30'));
} }
public function testSundayCongeIsNotCounted(): void
{
// Congé (C) posé un dimanche (2026-06-07) : ne doit pas compter comme congé pris.
$result = $this->countByCode([$this->buildAbsenceWithCode('2026-06-07', 'C')], ['C']);
self::assertSame(0.0, $result['count']);
self::assertSame('', $result['dates']);
}
public function testSaturdayCongeStillCounted(): void
{
// Le samedi reste hors périmètre (budget samedis dédié) : congé samedi toujours compté.
$result = $this->countByCode([$this->buildAbsenceWithCode('2026-06-06', 'C')], ['C']);
self::assertSame(1.0, $result['count']);
self::assertSame('06/06', $result['dates']);
}
public function testWeekdayCongeCounted(): void
{
$result = $this->countByCode([$this->buildAbsenceWithCode('2026-06-01', 'C')], ['C']);
self::assertSame(1.0, $result['count']);
self::assertSame('01/06', $result['dates']);
}
public function testSundayMaladieStillCounted(): void
{
// L'exclusion du dimanche ne concerne que les congés (C) : maladie/AT inchangés.
$result = $this->countByCode([$this->buildAbsenceWithCode('2026-06-07', 'M')], ['M', 'AT']);
self::assertSame(1.0, $result['count']);
self::assertSame('07/06', $result['dates']);
}
/**
* @param list<Absence> $absences
* @param list<string> $codes
*
* @return array{count: float, dates: string}
*/
private function countByCode(array $absences, array $codes): array
{
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
return new ReflectionClass($provider::class)
->getMethod('countAbsencesByCode')
->invoke($provider, $absences, $codes)
;
}
private function buildAbsenceWithCode(string $date, string $code): Absence
{
return new Absence()
->setType(new AbsenceType()->setCode($code)->setLabel($code)->setColor('#000'))
->setStartDate(new DateTime($date))
->setEndDate(new DateTime($date))
->setStartHalf(HalfDay::AM)
->setEndHalf(HalfDay::PM)
;
}
private function hasInRange(Employee $employee, string $from, string $to): bool private function hasInRange(Employee $employee, string $from, string $to): bool
{ {
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
return new ReflectionClass($provider::class) return new ReflectionClass($provider::class)
->getMethod('hasContractInRange') ->getMethod('hasContractInRange')
->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to)); ->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to))
;
} }
private function buildEmployeeWithPeriod(string $start, ?string $end): Employee private function buildEmployeeWithPeriod(string $start, ?string $end): Employee
@@ -126,11 +190,13 @@ final class SalaryRecapPrintProviderTest extends TestCase
{ {
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor(); $provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver') new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver')
->setValue($provider, new AbsenceSegmentsResolver()); ->setValue($provider, new AbsenceSegmentsResolver())
;
return new ReflectionClass($provider::class) return new ReflectionClass($provider::class)
->getMethod('splitForfaitCongesByN1') ->getMethod('splitForfaitCongesByN1')
->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to)); ->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to))
;
} }
private function buildConge(string $date): Absence private function buildConge(string $date): Absence
+63 -2
View File
@@ -23,6 +23,7 @@ use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourDayContextProvider; use App\State\WorkHourDayContextProvider;
use DateTime; use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionObject; use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -150,8 +151,7 @@ final class WorkHourDayContextProviderTest extends TestCase
// Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date. // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
$resolver = $this->createStub(EmployeeContractResolver::class); $resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback( $resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
static fn (Employee $e, \DateTimeImmutable $d): ?Contract => static fn (Employee $e, DateTimeImmutable $d): ?Contract => $d < new DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
); );
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
@@ -180,6 +180,67 @@ final class WorkHourDayContextProviderTest extends TestCase
self::assertSame('Contrat', $row['contractName']); self::assertSame('Contrat', $row['contractName']);
} }
public function testRowCarriesWorkDaysHoursForCustomContract(): void
{
$user = new User();
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 4);
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver->method('resolveForEmployeeAndDate')->willReturn($employee->getContract());
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
// Contrat 4h travaillé le lundi et le vendredi (120 min chacun).
$resolver->method('resolveWorkDaysMinutesForEmployeeAndDate')->willReturn([1 => 120, 5 => 120]);
$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([1 => 120, 5 => 120], $row['workDaysHours']);
}
public function testRowHasNullWorkDaysHoursForStandardContract(): void
{
$user = new User();
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35);
// buildResolverStub ne stube pas resolveWorkDaysMinutesForEmployeeAndDate → null (35h n'a pas de planning).
$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,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$row = $provider->provide(new Get())->rows[0];
self::assertNull($row['workDaysHours']);
}
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
{ {
$contract = new Contract() $contract = new Contract()