Compare commits

...

12 Commits

Author SHA1 Message Date
gitea-actions
be7c16778a chore: bump version to v0.1.87
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 46s
2026-04-16 13:52:31 +00:00
a8fe244b5c feat : modification de la gestion des jours fériés
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-16 15:52:19 +02:00
gitea-actions
13c71abddc chore: bump version to v0.1.86
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-14 13:55:11 +00:00
9581f9d8d9 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:55:03 +02:00
c2eaa06aff fix : écran du récap. congés ordre d'affichage + Calcule des jours ouvrés pour les FORFAIT 2026-04-14 15:54:57 +02:00
gitea-actions
187a634cc8 chore: bump version to v0.1.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-14 13:08:56 +00:00
0897154460 feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:08:45 +02:00
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
72 changed files with 2697 additions and 392 deletions

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ###
RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ###
###> nelmio/cors-bundle ###

View File

@@ -31,10 +31,19 @@
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **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`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -49,6 +58,17 @@
- 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.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`

View File

@@ -23,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/

View File

@@ -25,6 +25,7 @@ services:
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.82'
app.version: '0.1.87'

View File

@@ -23,3 +23,4 @@ DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"

View File

@@ -161,10 +161,14 @@ Documents complementaires:
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
- absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF)
@@ -326,6 +330,24 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
- `ROLE_ADMIN` : tous les employés
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)

View File

@@ -0,0 +1,110 @@
# Crédit automatique des heures sur jour férié (Lun-Ven)
## Règle
Tout jour férié du **lundi au vendredi** crédite automatiquement les **heures contractuelles attendues** pour ce jour, pour tout contrat **autre que Forfait** (`trackingMode``PRESENCE`). Les heures ainsi créditées sont dites *virtuelles* : aucune ligne n'est créée dans `work_hours`, elles sont injectées à l'affichage et au calcul.
### Référence contractuelle par jour
| Contrat | Lun-Jeu | Ven | Sam-Dim |
|-----------------|---------|-------|---------|
| 35h | 7h | 7h | 0 |
| 39h | 8h | 7h | 0 |
| CUSTOM (avec planning `workDaysHours`) | minutes du jour programmé, 0 sinon | idem | 0 |
| INTERIM 35h | 7h | 7h | 0 |
| FORFAIT | — | — | — |
La référence par jour est calculée par `App\Service\WorkHours\DailyReferenceMinutesResolver`.
### Planning `workDaysHours`
Tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h) doit déclarer un planning précis sur sa `EmployeeContractPeriod` : colonne JSON `work_days_hours = {"1": 120, "4": 120}` (iso day → minutes). La somme doit égaler `weeklyHours × 60`.
- **Sur un jour du planning** : crédit férié = minutes programmées (ex. Ewa Lun → 120 min).
- **Sur un jour hors planning** : crédit férié = 0 (elle n'aurait pas travaillé).
- Même logique appliquée par `WorkedHoursCreditPolicy::resolveContractDayMinutes` pour les crédits d'absence — un 4h en absence mardi (non programmée) = 0 crédit.
Validation à l'écriture : `EmployeeContractPeriodValidator::assertWorkDaysHours`. Le frontend expose un bloc « Jours travaillés » (cases Lun-Ven + input `HH:MM`) sur les formulaires de création employé + d'ajout de contrat, visible uniquement quand le contrat le requiert.
**Limitation actuelle** : l'édition in-place d'un schedule sur une période active existante n'est **pas exposée** via l'UI. Le drawer « Modifier le contrat » affiche le schedule en lecture seule à titre informatif. Pour corriger un schedule, la démarche est : clôturer le contrat en cours + créer un nouveau contrat avec le schedule corrigé. Si un besoin d'édition directe émerge, ajouter `workDaysHours` dans `EmployeeContractChangeRequest::hasPeriodChangeRequest()` et la logique d'update dans `EmployeeContractPeriodManager`.
### Fériés exclus
Les fériés listés dans l'env `EXCLUDED_PUBLIC_HOLIDAYS` (par défaut `Lundi de Pentecôte` — journée de solidarité) **ne donnent pas** de crédit virtuel : le `PublicHolidayService` les filtre en amont, donc `HolidayVirtualHoursResolver` ne les voit pas comme fériés.
### Interaction avec saisie
Quand l'employé saisit des heures ce jour-là :
- `heures finales = max(heures saisies + crédit d'absence éventuel, heures contractuelles de référence)`
Exemples avec un contrat 39h et un férié un lundi :
| Saisie employé | Total affiché | Interprétation |
|------------------|---------------|----------------|
| Aucune | 8h | Crédit 100% virtuel |
| Matin 09:00-13:00 (4h) | 8h | Le minimum contractuel l'emporte |
| 09:00-12:00 + 13:00-19:00 (9h) | 9h | Les heures saisies l'emportent |
### Interaction avec absences
La création d'absence sur un férié Lun-Ven est **autorisée** (bouton Modifier visible). Dès qu'une absence est déclarée sur le jour (matin et/ou après-midi), le crédit virtuel férié **est désactivé** pour ce jour : c'est `absence.type.countAsWorkedHours` qui pilote le crédit d'heures, via `WorkedHoursCreditPolicy`.
- `countAsWorkedHours = true` (ex. maladie payée) : crédit calculé normalement (7h/8h selon contrat × halfUnits/2). Même quantité que la référence virtuelle si journée complète, donc résultat identique — mais la source du crédit est l'absence, pas le férié.
- `countAsWorkedHours = false` (ex. congé sans solde) : crédit = 0. Le férié ne compense pas.
Cette règle évite le double-crédit (absence + férié virtuel) et respecte le paramétrage fonctionnel du type d'absence.
## Impact technique
### Affichage
- **Écran Heures (vue jour)** : sur un férié Lun-Ven non-Forfait, la colonne Total affiche la valeur effective (référence ou saisie, selon max). Un chip "Férié : Xh comptées" apparaît sous le pill bleu du férié.
- **Écran Heures Conducteurs (vue jour)** : idem, plus un indicateur `= Xh (férié)` sous l'input "Heures jour" pour signaler que le crédit est imputé au bucket jour.
- **Vues semaine** : les totaux hebdomadaires intègrent les minutes virtuelles. Un marqueur `F + Xh` apparaît dans la cellule du jour férié.
- **Onglet RTT** : les semaines contenant un férié Lun-Ven gagnent du temps crédité, ce qui peut générer des heures sup (25% / 50%) là où l'ancienne règle produisait un déficit.
### Calcul RTT
Le service `App\Service\WorkHours\HolidayVirtualHoursResolver` est injecté dans `RttRecoveryComputationService::computeRecoveryByWeek()`. Pour chaque jour ouvré :
```
effectiveMinutes = resolveEffectiveDailyMinutes(contract, date, metrics.totalMinutes + credited)
weeklyTotalMinutes += effectiveMinutes
```
Le reste du calcul (tranches +25%, +50%, base 25% à partir de 35h/39h) demeure inchangé ; seul le total hebdo injecté a évolué.
### Calcul hebdomadaire d'affichage
`WorkHourWeeklySummaryProvider` applique la même substitution sur `weeklyDayMinutes` et `weeklyTotalMinutes`. Le DTO `WeeklyDaySummary` expose désormais un champ `virtualHolidayMinutes` utilisé par les vues semaine.
### Contexte jour
`WorkHourDayContextProvider` expose `virtualHolidayMinutes` dans `DayContextRow` pour permettre au frontend de calculer le total journalier en temps réel pendant la saisie (sans aller-retour).
### Frontend
Le composable `frontend/composables/useHolidayVirtualHours.ts` réplique la règle côté client et est consommé par `useHoursPage.ts::getRowMetrics` et `useDriverHoursPage.ts::getRowMetrics`.
## Impact historique
La règle est appliquée **à chaque lecture** depuis les `WorkHour` — donc l'exercice courant et tout exercice recalculé live bénéficient automatiquement de la nouvelle règle sans migration.
Les reports N-1 stockés dans `employee_rtt_balances.opening_*_minutes` ont été saisis manuellement par la RH (valeurs officielles) et ne sont **pas recalculés** : ces snapshots restent la source de vérité pour les soldes d'ouverture.
## Services impliqués
| Composant | Rôle |
|-----------|------|
| `DailyReferenceMinutesResolver` | Résolution "minutes contractuelles par jour" (logique partagée, anciennement dupliquée). |
| `HolidayVirtualHoursResolver` | Décide si la règle s'applique et renvoie le crédit virtuel ou la valeur effective. |
| `RttRecoveryComputationService` | Applique la substitution dans le calcul hebdo RTT. |
| `WorkHourWeeklySummaryProvider` | Applique la substitution dans les totaux hebdo UI. |
| `WorkHourDayContextProvider` | Expose `virtualHolidayMinutes` par salarié/jour. |
| `useHolidayVirtualHours.ts` (frontend) | Réplique la règle en live côté client. |
## Tests
- `tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php` couvre les scénarios par contrat + jours ouvrés/chômés.
- `make test` (PHPUnit) valide l'intégration RTT / hebdo / contexte jour.

73
doc/leave-recap-screen.md Normal file
View File

@@ -0,0 +1,73 @@
# Écran Récap. congés
## Objet
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
## Cutoff
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 14 jours)`.
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
## Colonnes
Identiques au PDF :
- Nom
- Prénom
- Contrat
- CP N-1 restant
- CP N
- Samedis acquis
- RTT
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
## Scoping
| Profil | Données visibles |
|---------------|-----------------------------------------|
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
## Flag d'accès
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
- L'affichage de l'entrée "Récap. congés" dans la sidebar
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
dans le drawer de création/édition d'un utilisateur.
## Service partagé
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
construit une ligne de récap. Il est utilisé par :
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
## Propagation du cutoff dans les calculs
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
précédant `asOfDate` (au lieu du mois précédent today).
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
qui existait déjà, en passant `cutoff` comme date de référence.

View File

@@ -4,13 +4,13 @@
<div class="absolute inset-0 bg-black/40" @click="close" />
</Transition>
<Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
<div class="flex items-center justify-between px-[20px] pt-8 pb-8">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
<div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
</div>
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)">
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
<slot />
</div>
</div>

View File

@@ -25,19 +25,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -68,19 +56,30 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
</div>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -91,6 +90,12 @@
v-model="rows[employee.id].dayHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
<p
v-if="isHoliday && getRowMetrics(employee.id).virtualHolidayMinutes > 0"
class="mt-1 text-xs font-semibold text-sky-700"
>
= {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }} (férié)
</p>
</div>
<div class="pl-2">
<TimeSelect
@@ -147,16 +152,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
@@ -184,6 +181,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
@@ -201,7 +199,7 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string

View File

@@ -108,6 +108,13 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<WorkDaysHoursInput
v-if="contractForm.workDaysHours"
:model-value="contractForm.workDaysHours"
:contract-weekly-hours="contractForm.weeklyHours ?? null"
disabled
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire
@@ -252,7 +259,13 @@
</label>
</div>
<div class="flex justify-center pt-2">
<WorkDaysHoursInput
v-if="requiresCreateWorkDaysHours"
v-model="createContractForm.workDaysHours"
:contract-weekly-hours="selectedCreateContract?.weeklyHours ?? null"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
@@ -269,6 +282,7 @@
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = {
id: number | null
@@ -286,6 +300,7 @@ type ContractForm = {
endDate: string
paidLeaveSettled: boolean
comment: string
workDaysHours: Record<number, number> | null
}
type CreateContractForm = {
@@ -294,6 +309,7 @@ type CreateContractForm = {
startDate: string
endDate: string
isDriver: boolean
workDaysHours: Record<number, number> | null
}
const props = defineProps<{
@@ -322,6 +338,8 @@ const props = defineProps<{
requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string
isCreateContractFormValid: boolean
requiresCreateWorkDaysHours: boolean
selectedCreateContract: Contract | null
onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void

View File

@@ -26,19 +26,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -78,6 +66,15 @@
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
@@ -91,8 +88,8 @@
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -181,16 +178,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
@@ -218,6 +207,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
@@ -239,7 +229,7 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean

View File

@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -368,12 +368,23 @@ export const useDriverHoursPage = () => {
const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow()
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayMinutes = toMinutes(row.dayHours) + credited
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const credited = dayRow?.creditedMinutes ?? 0
let dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
let totalMinutes = dayMinutes + nightMinutes + workshopMinutes
// Virtual holiday credit: backend already applies the contract-period
// schedule and absence-override rule; consume the value as-is.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
const delta = virtualHolidayMinutes - totalMinutes
dayMinutes += delta
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes, virtualHolidayMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
@@ -381,7 +392,6 @@ export const useDriverHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -467,7 +477,6 @@ export const useDriverHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
@@ -941,6 +950,7 @@ export const useDriverHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,

View File

@@ -5,7 +5,7 @@ import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
@@ -32,7 +32,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
comment: '',
workDaysHours: null as Record<number, number> | null
})
const validationTouched = reactive({
@@ -44,7 +45,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
isDriver: false
isDriver: false,
workDaysHours: null as Record<number, number> | null
})
const createValidationTouched = reactive({
@@ -59,10 +61,11 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
const base = item.weeklyHours !== null && item.weeklyHours !== undefined
? `${item.weeklyHours} heures`
: (item.contractName ?? '-')
const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
return scheduleSummary ? `${base} (${scheduleSummary})` : base
}
const currentActiveContractPeriod = computed(() => {
@@ -111,11 +114,27 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const selectedCreateContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
)
const requiresCreateWorkDaysHours = computed(() =>
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
)
const createScheduleTotalMinutes = computed(() => {
const raw = createContractForm.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isCreateScheduleValid = computed(() => {
if (!requiresCreateWorkDaysHours.value) return true
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && createScheduleTotalMinutes.value === expected
})
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
isCreateContractEndDateValid.value &&
isCreateScheduleValid.value
)
const baseInputClass =
@@ -159,6 +178,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
contractForm.workDaysHours = period.workDaysHours ?? null
}
const openCloseContractDrawer = () => {
@@ -186,6 +206,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
@@ -261,7 +282,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver
isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
@@ -319,6 +341,12 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
}
})
watch(requiresCreateWorkDaysHours, (required) => {
if (!required) {
createContractForm.workDaysHours = null
}
})
return {
contracts,
contractHistory,
@@ -342,6 +370,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,

View File

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -447,10 +447,21 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to)
}
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const creditedMinutes = dayRow?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
// Virtual holiday credit: the backend already applies the contract-period
// schedule (workDaysHours) and the absence-override rule, so just use the
// computed value instead of recomputing on the client.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
dayMinutes += virtualHolidayMinutes - totalMinutes
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, totalMinutes, virtualHolidayMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
@@ -458,7 +469,6 @@ export const useHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -584,7 +594,6 @@ export const useHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
@@ -1127,6 +1136,7 @@ export const useHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,

View File

@@ -56,6 +56,9 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) et la création d\'absences sont autorisées.' },
{ type: 'note', content: 'Crédit automatique sur jour férié Lun-Ven : pour tout contrat hors Forfait et s\'il n\'y a pas d\'absence déclarée, un jour férié compte au minimum les heures contractuelles attendues (35h → 7h, 39h → 8h Lun-Jeu / 7h Ven). Si vous saisissez des heures supérieures à cette référence, ce sont vos heures qui sont comptées ; sinon c\'est la référence. Les conducteurs reçoivent ce crédit dans leur bucket "Heures jour". **Si une absence est posée sur le férié**, c\'est le paramétrage du type d\'absence (compte les heures oui/non) qui pilote les heures comptées, le crédit virtuel férié ne s\'applique plus.' },
{ type: 'note', content: 'Contrats non-standards (4h, 25h, 28h, etc.) : un planning par jour travaillé doit être saisi à la création/modification du contrat (bloc « Jours travaillés » avec case à cocher + horaire par jour). Le crédit férié et le crédit d\'absence ne s\'appliquent que sur les jours programmés, avec les heures programmées. Ex. un 4h Lundi 2h + Jeudi 2h : férié le lundi → +2h, férié le mardi → 0h.' },
],
},
{
@@ -441,6 +444,17 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
],
},
{
id: 'ecran-recap-conges',
title: 'Écran Récap. congés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
],
},
],
},
{

View File

@@ -56,6 +56,9 @@
"create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
}
},
"success": {

View File

@@ -53,6 +53,17 @@
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
</NuxtLink>
<NuxtLink
v-if="hasLeaveRecapAccess"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
@@ -84,6 +95,15 @@
<p>Utilisateurs</p>
</NuxtLink>
</template>
<NuxtLink
v-if="hasLeaveRecapAccess && !isAdmin"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink
v-if="isSuperAdmin"
to="/audit-logs"
@@ -128,5 +148,6 @@ const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute()
</script>

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.checked) {
await auth.ensureSession()
}
if (!auth.user?.hasLeaveRecapAccess) {
return navigateTo('/')
}
})

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate"
@@ -174,6 +175,7 @@ const {
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave
} = useDriverHoursPage()

View File

@@ -135,6 +135,8 @@
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:requires-create-work-days-hours="requiresCreateWorkDaysHours"
:selected-create-contract="selectedCreateContract"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
@@ -274,6 +276,8 @@ const {
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,

View File

@@ -205,6 +205,11 @@
Chauffeur
</label>
</div>
<WorkDaysHoursInput
v-if="requiresSchedule"
v-model="form.workDaysHours"
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
/>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -234,6 +239,8 @@
<script setup lang="ts">
import type {Contract} from '~/services/dto/contract'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
import { requiresWorkDaysHours } from '~/utils/contract'
import type {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
@@ -292,7 +299,8 @@ const form = reactive({
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: '',
isDriver: false
isDriver: false,
workDaysHours: null as Record<number, number> | null
})
const validationTouched = reactive({
@@ -310,6 +318,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const selectedContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(form.contractId)) ?? null
)
const requiresSchedule = computed(() =>
!editingEmployee.value && requiresWorkDaysHours(selectedContract.value, form.contractNature)
)
const scheduleTotalMinutes = computed(() => {
const raw = form.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isScheduleValid = computed(() => {
if (!requiresSchedule.value) return true
const expected = (selectedContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && scheduleTotalMinutes.value === expected
})
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
@@ -327,7 +350,8 @@ const isFormValid = computed(
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
isContractEndDateValid.value &&
isScheduleValid.value))
)
const showFirstNameError = computed(
@@ -478,7 +502,8 @@ const handleSubmit = async () => {
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver
isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
})
}
@@ -490,6 +515,7 @@ const handleSubmit = async () => {
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -516,6 +542,12 @@ watch(showsContractEndDateComputed, (shows) => {
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName
@@ -534,6 +566,7 @@ const openCreate = () => {
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
isDrawerOpen.value = true
}

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
@@ -141,6 +142,7 @@ const {
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols,
saveButtonClass,
formattedSelectedDate,

View File

@@ -0,0 +1,121 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
>
<Icon name="mdi:calendar-check-outline" size="18"/>
{{ cutoffLabel }}
</span>
</div>
<div
v-if="isLoading"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Chargement...
</div>
<div
v-else-if="rows.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé à afficher.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
>
<span v-if="showSiteColumn" class="text-left">Site</span>
<span class="text-left">Nom</span>
<span class="text-left">Prénom</span>
<span class="text-left">Contrat</span>
<span class="text-right">CP N-1 restant</span>
<span class="text-right">Samedis</span>
<span class="text-right">CP N</span>
<span class="text-right">RTT</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in rows"
:key="row.employeeId"
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
>
<span v-if="showSiteColumn" class="truncate">
<span
v-if="row.siteName"
class="inline-block rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
<span v-else class="text-neutral-500">-</span>
</span>
<span class="truncate">{{ row.lastName }}</span>
<span class="truncate">{{ row.firstName }}</span>
<span class="truncate">{{ row.contractName ?? '-' }}</span>
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
<span class="text-right tabular-nums">{{ row.cpN }}</span>
<span class="text-right tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
import { fetchLeaveRecap } from '~/services/leave-recap'
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
definePageMeta({ middleware: ['leave-recap-access'] })
useHead({ title: 'Récap. congés' })
const auth = useAuthStore()
const rows = ref<LeaveRecapRow[]>([])
const isLoading = ref(false)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfOnly = computed(() => {
const roles = auth.user?.roles ?? []
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
})
const showSiteColumn = computed(() => !isSelfOnly.value)
const gridColsClass = computed(() =>
showSiteColumn.value
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
)
const cutoffLabel = computed(() => {
const ymd = rows.value[0]?.cutoffDate
if (!ymd) return ''
const parsed = parseYmd(ymd)
if (!parsed) return ''
const week = getIsoWeekNumber(parsed)
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
})
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return '-'
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
const load = async () => {
isLoading.value = true
try {
rows.value = await fetchLeaveRecap()
} finally {
isLoading.value = false
}
}
onMounted(load)
// Silence unused linter warning for isAdmin (kept for future site grouping)
void isAdmin
</script>

View File

@@ -189,6 +189,20 @@
</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.hasLeaveRecapAccess"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
@@ -233,7 +247,8 @@ const form = reactive({
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[],
isLocked: false
isLocked: false,
hasLeaveRecapAccess: false
})
const validationTouched = reactive({
@@ -345,6 +360,7 @@ const resetForm = () => {
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -373,6 +389,7 @@ const openEdit = (user: User) => {
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -427,7 +444,8 @@ const handleSubmit = async () => {
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -452,7 +470,8 @@ const handleSubmit = async () => {
plainPassword: form.password,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -19,6 +19,7 @@ export type ContractHistoryItem = {
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
workDaysHours?: Record<number, number> | null
}
export type Employee = {

View File

@@ -0,0 +1,14 @@
export type LeaveRecapRow = {
employeeId: number
lastName: string
firstName: string
siteId: number | null
siteName: string | null
siteColor: string | null
contractName: string | null
cpN1Remaining: number
cpN: string
acquiredSaturdays: string
rtt: string
cutoffDate: string
}

View File

@@ -3,4 +3,5 @@ export type UserData = {
username: string
roles: string[]
isDriver: boolean
hasLeaveRecapAccess: boolean
}

View File

@@ -5,5 +5,6 @@ export type User = {
username: string
roles: string[]
isLocked: boolean
hasLeaveRecapAccess: boolean
employee?: Employee | null
}

View File

@@ -59,6 +59,7 @@ export type WeeklyWorkHourDailySummary = {
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
virtualHolidayMinutes?: number
}
export type WeeklyWorkHourRowSummary = {
@@ -108,6 +109,7 @@ export type WorkHourDayContextRow = {
isDriverContract?: boolean
hasFormation?: boolean
formationLabel?: string | null
virtualHolidayMinutes?: number
}
export type WorkHourDayContext = {

View File

@@ -35,6 +35,7 @@ export const createEmployee = async (payload: {
contractStartDate?: string
contractEndDate?: string | null
isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
@@ -45,7 +46,8 @@ export const createEmployee = async (payload: {
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false
isDriverInput: payload.isDriverInput ?? false,
workDaysHoursInput: payload.workDaysHoursInput ?? null
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -66,6 +68,7 @@ export const updateEmployee = async (
contractComment?: string | null
displayOrder?: number
isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
}
) => {
const api = useApi()
@@ -97,6 +100,9 @@ export const updateEmployee = async (
if (payload.isDriverInput !== undefined) {
body.isDriverInput = payload.isDriverInput
}
if (payload.workDaysHoursInput !== undefined) {
body.workDaysHoursInput = payload.workDaysHoursInput
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,12 @@
import type { LeaveRecapRow } from './dto/leave-recap'
import { extractItems } from '~/utils/api'
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
const api = useApi()
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
'/leave-recap',
{},
{ toastErrorKey: 'errors.leaveRecap.load' }
)
return extractItems<LeaveRecapRow>(data)
}

View File

@@ -17,6 +17,7 @@ export const createUser = async (payload: {
roles: string[]
employeeId?: number | null
isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => {
const api = useApi()
return api.post<User>(
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
plainPassword: payload.plainPassword,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
},
{
toastSuccessKey: 'success.user.create',
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
roles: string[]
employeeId?: number | null
isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => {
const api = useApi()
const body: Record<string, unknown> = {
username: payload.username,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
}
if (payload.plainPassword) {

View File

@@ -19,3 +19,43 @@ export const requiresContractEndDate = (nature: ContractNature) => {
export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value)
}
/**
* Whether a contract + nature pair requires the per-day schedule (workDaysHours).
* Mirrors EmployeeContractPeriodValidator::assertWorkDaysHours on the backend.
*/
export const requiresWorkDaysHours = (
contract: { trackingMode?: string | null; weeklyHours?: number | null } | null | undefined,
nature: ContractNature
): boolean => {
if (!contract) return false
if (nature === 'INTERIM') return false
if (contract.trackingMode === 'PRESENCE') return false
if (contract.weeklyHours === 35 || contract.weeklyHours === 39) return false
return true
}
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
/**
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
* Returns null when the schedule is empty/unset.
*/
export const formatWorkDaysHoursSummary = (
workDaysHours: Record<number, number> | null | undefined
): string | null => {
if (!workDaysHours) return null
const entries = 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)
if (entries.length === 0) return null
return entries
.map(([iso, minutes]) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
const suffix = m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
return `${DAY_SHORT_LABELS[iso]} ${suffix}`
})
.join(', ')
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260414100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add has_leave_recap_access flag on users';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD has_leave_recap_access BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP has_leave_recap_access');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260416100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add work_days_hours JSON on employee_contract_periods (schedule for non-standard contracts) + seed Ewa and Nadia';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD work_days_hours JSON DEFAULT NULL');
// Seed the two known 4h employees currently in production.
// Ewa DALEMBA: Lundi 2h + Jeudi 2h
// Nadia GARRAUD: Mardi 2h + Vendredi 2h
// Filter on last_name + first_name (not ids) to stay safe across environments,
// and only on periods without an already-set schedule to remain idempotent.
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"1\":120,\"4\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'DALEMBA' AND e.first_name = 'Ewa' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"2\":120,\"5\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'GARRAUD' AND e.first_name = 'Nadia' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP work_days_hours');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\EmployeeLeaveRecapProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/leave-recap',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: EmployeeLeaveRecapProvider::class,
),
],
)]
final class EmployeeLeaveRecap
{
public int $employeeId = 0;
public string $lastName = '';
public string $firstName = '';
public ?int $siteId = null;
public ?string $siteName = null;
public ?string $siteColor = null;
public ?string $contractName = null;
public int $contractSortKey = 99;
public float $cpN1Remaining = 0.0;
public string $cpN = '-';
public string $acquiredSaturdays = '-';
public string $rtt = '-';
public string $cutoffDate = '';
}

View File

@@ -0,0 +1,805 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:verification:snapshot',
description: 'Dump per-employee Markdown snapshot of RTT (monthly tab view) and leave balances, to serve as a regression baseline before business-rule refactors.'
)]
final class DumpVerificationSnapshotCommand extends Command
{
private const array MONTH_LABELS = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeContractPeriodRepository $contractPeriodRepository,
private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly EmployeeRttPaymentRepository $rttPaymentRepository,
private readonly WorkHourRepository $workHourRepository,
private readonly AbsenceRepository $absenceRepository,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'employee_ids',
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
'Employee IDs to snapshot (space-separated).'
)
->addOption(
'output-dir',
null,
InputOption::VALUE_OPTIONAL,
'Output directory (relative to project root, or absolute).',
'docs/verifications'
)
->addOption(
'rtt-year',
null,
InputOption::VALUE_OPTIONAL,
'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ids = array_map('intval', $input->getArgument('employee_ids'));
$outputDirOpt = (string) $input->getOption('output-dir');
$outputDir = str_starts_with($outputDirOpt, '/')
? $outputDirOpt
: $this->projectDir.'/'.$outputDirOpt;
if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) {
$io->error('Could not create output directory: '.$outputDir);
return Command::FAILURE;
}
$today = new DateTimeImmutable('today');
$rttYearOpt = $input->getOption('rtt-year');
$rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt
? (int) $rttYearOpt
: $this->resolveCurrentRttExerciseYear($today);
foreach ($ids as $id) {
$employee = $this->employeeRepository->find($id);
if (!$employee instanceof Employee) {
$io->warning(sprintf('Employee id=%d not found — skipped.', $id));
continue;
}
$markdown = $this->buildEmployeeDoc($employee, $rttYear, $today);
$slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName());
$filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug);
file_put_contents($filename, $markdown);
$io->success(sprintf('Wrote %s', $filename));
}
return Command::SUCCESS;
}
private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$parts = [];
$parts[] = $this->buildHeader($employee, $rttYear, $today);
$parts[] = $this->buildProfileSection($employee);
$parts[] = $this->buildLeaveSection($employee, $today);
$parts[] = $this->buildRecapRowSection($employee, $today);
$parts[] = $this->buildRttSection($employee, $rttYear, $today);
return implode("\n\n", $parts)."\n";
}
private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$rttFrom = sprintf('01/06/%d', $rttYear - 1);
$rttTo = sprintf('31/05/%d', $rttYear);
return sprintf(
"# Vérification RTT & Congés — %s %s (id=%d)\n\n"
."Généré le %s. \n"
."Exercice RTT de référence : **%d** (%s → %s). \n"
."Pour les contrats Forfait, l'exercice de congés est l'année civile.",
$employee->getFirstName(),
$employee->getLastName(),
(int) $employee->getId(),
$today->format('Y-m-d'),
$rttYear,
$rttFrom,
$rttTo
);
}
private function buildProfileSection(Employee $employee): string
{
$contract = $employee->getContract();
$contractName = $contract?->getName() ?? '—';
$tracking = $contract?->getTrackingMode() ?? '—';
$weekly = $contract?->getWeeklyHours();
$weeklyLabel = null === $weekly ? '—' : ($weekly.'h');
$nature = $employee->getCurrentContractNature();
$lines = [];
$lines[] = '## 1. Profil';
$lines[] = '';
$lines[] = sprintf('- **ID** : %d', (int) $employee->getId());
$lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName());
$lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel);
$lines[] = sprintf('- **Nature** : %s', $nature);
$lines[] = '';
$lines[] = '### Périodes de contrat';
$lines[] = '';
$lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |';
$lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|';
$periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']);
foreach ($periods as $period) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s | %s | %s |',
$period->getStartDate()->format('Y-m-d'),
null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—',
$period->getContract()?->getName() ?? '—',
$period->getContractNature(),
$period->getIsDriver() ? 'oui' : 'non',
$period->isPaidLeaveSettled() ? 'oui' : 'non',
str_replace("\n", ' ', (string) ($period->getComment() ?? ''))
);
}
return implode("\n", $lines);
}
private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 2. Congés';
$lines[] = '';
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null === $yearSummary) {
$lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._';
return implode("\n", $lines);
}
// Forfait: recompute with paid leave days if any.
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
$lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']);
$lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d'));
$lines[] = '';
$lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)';
$lines[] = '';
$lines[] = '| Indicateur | Valeur |';
$lines[] = '|------------|--------|';
$lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays']));
$lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays']));
$lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays']));
$lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays']));
$lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays']));
$lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays']));
$lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays']));
if ($isForfait) {
$lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays']));
$lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays']));
$lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays']));
$lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays));
}
$lines[] = '';
$lines[] = '### 2.2 Absences de la période';
$lines[] = '';
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
if ([] === $absences) {
$lines[] = '_Aucune absence sur la période._';
} else {
$lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |';
$lines[] = '|-------|-----|------------|----------|------|-------------|';
foreach ($absences as $absence) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s (%s) | %s |',
$absence->getStartDate()->format('Y-m-d'),
$absence->getEndDate()->format('Y-m-d'),
$absence->getStartHalf()->value,
$absence->getEndHalf()->value,
$absence->getType()?->getCode() ?? '—',
$absence->getType()?->getLabel() ?? '—',
str_replace("\n", ' ', (string) ($absence->getComment() ?? ''))
);
}
}
$lines[] = '';
$lines[] = '### 2.3 Jours de présence par mois (calcul provider)';
$lines[] = '';
$presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear);
if ([] === $presenceDaysByMonth) {
$lines[] = '_Aucun jour de présence sur la période._';
} else {
$lines[] = '| Mois | Jours de présence |';
$lines[] = '|------|-------------------|';
ksort($presenceDaysByMonth);
foreach ($presenceDaysByMonth as $monthKey => $days) {
$lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days));
}
}
return implode("\n", $lines);
}
/**
* @return array<string, float>
*/
private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array
{
// The provider method is private; we re-invoke `provide()` via its public path by
// calling computeYearSummary then reading $summary->presenceDaysByMonth.
// But computeYearSummary doesn't populate that. So we call the provider publicly
// through LeaveRecapRowBuilder? No — we just call the summary API resource directly
// via a small helper below.
//
// Workaround: reuse the provider's provide() would require security; instead we
// rebuild the map from WorkHour/absences here, mirroring the provider logic.
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
// Leave this aggregated figure available only for forfait (this is where the UI
// shows it). For non-forfait we skip — the UI doesn't show presence per month.
if (!$isForfait) {
return [];
}
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
$absenceDaysByMonth = [];
foreach ($absences 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;
}
$startDate = $absence->getStartDate()->format('Y-m-d');
$endDate = $absence->getEndDate()->format('Y-m-d');
$startHalf = $absence->getStartHalf()->value;
$endHalf = $absence->getEndHalf()->value;
$dateStr = $day->format('Y-m-d');
$isStart = $dateStr === $startDate;
$isEnd = $dateStr === $endDate;
if ($startDate === $endDate) {
$am = 'AM' === $startHalf;
$pm = 'PM' === $endHalf;
} elseif ($isStart) {
$am = 'AM' === $startHalf;
$pm = true;
} elseif ($isEnd) {
$am = true;
$pm = 'PM' === $endHalf;
} else {
$am = true;
$pm = true;
}
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) {
continue;
}
$mk = $day->format('Y-m');
$absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount;
}
}
$result = [];
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthKey = $cursor->format('Y-m');
$monthStart = $cursor < $from ? $from : $cursor;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$businessDays = 0;
for ($day = $monthStart; $day <= $monthEnd; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') <= 5) {
++$businessDays;
}
}
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
if ($presence > 0.0) {
$result[$monthKey] = $presence;
}
$cursor = $cursor->modify('first day of next month');
}
return $result;
}
private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string
{
$row = $this->leaveRecapRowBuilder->build($employee);
$lines = [];
$lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)';
$lines[] = '';
$lines[] = '| CP N-1 restant | CP N | Samedis | RTT |';
$lines[] = '|----------------|------|---------|-----|';
$lines[] = sprintf(
'| %s | %s | %s | %s |',
(string) $row['cpN1Remaining'],
$row['cpN'],
$row['acquiredSaturdays'],
$row['rtt']
);
return implode("\n", $lines);
}
private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 4. RTT — Onglet par mois';
$lines[] = '';
$contract = $employee->getContract();
$trackingMode = $contract?->getTrackingMode();
if (TrackingMode::PRESENCE->value === $trackingMode) {
$lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._';
$lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._';
$lines[] = '';
$lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).';
return implode("\n", $lines);
}
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']],
$weeks
);
$currentExerciseYear = $this->resolveCurrentRttExerciseYear($today);
if ($rttYear > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
} else {
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear);
$weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo);
$weekSummaries = $this->distributeDeficits($weekSummaries, $carry);
// Aggregate payments per month.
$paymentsByMonth = [];
foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) {
$m = $payment->getMonth();
if (!isset($paymentsByMonth[$m])) {
$paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes();
$paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes();
$paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes();
$paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d'));
$lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes));
$lines[] = '';
// Iterate the 12 exercise months (June → May).
$cumulativeCarry = [
'base25' => $carry->base25Minutes,
'bonus25' => $carry->bonus25Minutes,
'base50' => $carry->base50Minutes,
'bonus50' => $carry->bonus50Minutes,
];
$monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5];
foreach ($monthsInExercise as $i => $month) {
$calYear = $month >= 6 ? $rttYear - 1 : $rttYear;
$label = self::MONTH_LABELS[$month].' '.$calYear;
$lines[] = '### '.$label;
$lines[] = '';
$lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |';
$lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|';
// Report line only on the first month (June).
if (6 === $month) {
$lines[] = sprintf(
'| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |',
$this->fmtMin($carry->base25Minutes),
$this->fmtMin($carry->bonus25Minutes),
$this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes),
$this->fmtMin($carry->base50Minutes),
$this->fmtMin($carry->bonus50Minutes),
$this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes),
$this->fmtMin($carry->totalMinutes),
);
}
$monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month));
$totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0];
foreach ($monthWeeks as $w) {
$lines[] = sprintf(
'| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |',
$w->weekNumber,
$w->weekStart,
$w->weekEnd,
$this->fmtMin($w->overtimeMinutes),
$this->fmtMin($w->base25Minutes),
$this->fmtMin($w->bonus25Minutes),
$this->fmtMin($w->base25Minutes + $w->bonus25Minutes),
$this->fmtMin($w->base50Minutes),
$this->fmtMin($w->bonus50Minutes),
$this->fmtMin($w->base50Minutes + $w->bonus50Minutes),
$this->fmtMin($w->totalMinutes),
);
$totals['over'] += $w->overtimeMinutes;
$totals['b25'] += $w->base25Minutes;
$totals['s25'] += $w->bonus25Minutes;
$totals['b50'] += $w->base50Minutes;
$totals['s50'] += $w->bonus50Minutes;
$totals['total'] += $w->totalMinutes;
}
if ([] === $monthWeeks && 6 !== $month) {
$lines[] = '| _aucune semaine_ | | | | | | | | |';
}
$lines[] = sprintf(
'| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($totals['over']),
$this->fmtMin($totals['b25']),
$this->fmtMin($totals['s25']),
$this->fmtMin($totals['b25'] + $totals['s25']),
$this->fmtMin($totals['b50']),
$this->fmtMin($totals['s50']),
$this->fmtMin($totals['b50'] + $totals['s50']),
$this->fmtMin($totals['total']),
);
$p = $paymentsByMonth[$month] ?? null;
$hasPayment = null !== $p;
if ($hasPayment) {
$lines[] = sprintf(
'| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |',
$this->fmtMin($p['base25']),
$this->fmtMin($p['bonus25']),
$this->fmtMin($p['base25'] + $p['bonus25']),
$this->fmtMin($p['base50']),
$this->fmtMin($p['bonus50']),
$this->fmtMin($p['base50'] + $p['bonus50']),
$this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']),
);
} else {
$lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |';
}
// Cumulative carry update — add month totals, subtract payments.
$cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0);
$cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0);
$cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0);
$cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0);
$cb25 = $cumulativeCarry['base25'];
$cs25 = $cumulativeCarry['bonus25'];
$cb50 = $cumulativeCarry['base50'];
$cs50 = $cumulativeCarry['bonus50'];
$cTotal = $cb25 + $cs25 + $cb50 + $cs50;
$lines[] = sprintf(
'| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($cb25),
$this->fmtMin($cs25),
$this->fmtMin($cb25 + $cs25),
$this->fmtMin($cb50),
$this->fmtMin($cs50),
$this->fmtMin($cb50 + $cs50),
$this->fmtMin($cTotal),
);
$lines[] = '';
}
// Final summary.
$currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries));
$totalPaid = 0;
foreach ($paymentsByMonth as $p) {
$totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50'];
}
$available = $carry->totalMinutes + $currentYearRecovery - $totalPaid;
$lines[] = '### Solde RTT total (fin de période calculée)';
$lines[] = '';
$lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes));
$lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery));
$lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid));
$lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available));
return implode("\n", $lines);
}
/**
* Mirrors EmployeeRttSummaryProvider::buildWeekSummaries().
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
$monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
if ((int) new DateTimeImmutable($date)->format('N') < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
}
$totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $m) {
if ($totalWorked > 0) {
$ratio = ($monthMinutes[$m] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary(
month: $m,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
/**
* Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide().
*
* @param list<EmployeeRttWeekSummary> $weeks
*
* @return list<EmployeeRttWeekSummary>
*/
private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array
{
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
continue;
}
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
return $weeks;
}
/**
* @return array{WeekRecoveryDetail, int}
*/
private function resolveCarry(Employee $employee, int $year): array
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return [
new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
),
$balance->getMonth(),
];
}
return [$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5];
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int
{
$y = (int) $today->format('Y');
$m = (int) $today->format('n');
return $m >= 6 ? $y + 1 : $y;
}
private function fmtMin(int $minutes): string
{
if (0 === $minutes) {
return '0h';
}
$sign = $minutes < 0 ? '-' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m);
}
private function fmtDays(float $value): string
{
if (abs($value - round($value)) < 0.001) {
return (string) (int) round($value);
}
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function slugify(string $value): string
{
$value = trim($value);
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (false === $ascii) {
$ascii = $value;
}
$ascii = strtolower($ascii);
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii;
return trim($ascii, '-');
}
}

View File

@@ -29,5 +29,10 @@ final class ContractHistoryItem
public array $suspensions = [],
#[Groups(['employee:read'])]
public bool $isDriver = false,
/**
* @var null|array<int, int> iso-day → minutes
*/
#[Groups(['employee:read'])]
public ?array $workDaysHours = null,
) {}
}

View File

@@ -19,6 +19,7 @@ final class DayContextRow
public bool $isDriverContract = false,
public bool $hasFormation = false,
public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
) {}
public function setFormation(string $label): void
@@ -75,7 +76,8 @@ final class DayContextRow
* creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string
* formationLabel:?string,
* virtualHolidayMinutes:int
* }
*/
public function toArray(): array
@@ -93,6 +95,7 @@ final class DayContextRow
'isDriverContract' => $this->isDriverContract,
'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
];
}

View File

@@ -21,5 +21,6 @@ final class WeeklyDaySummary
public bool $hasLunch = false,
public bool $hasDinner = false,
public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
) {}
}

View File

@@ -92,6 +92,12 @@ class Employee
#[Groups(['employee:write'])]
private ?bool $isDriverInput = null;
/**
* @var null|array<int, int> iso-day → minutes, write-only (propagated to EmployeeContractPeriod)
*/
#[Groups(['employee:write'])]
private ?array $workDaysHoursInput = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -261,6 +267,34 @@ class Employee
return $this;
}
/**
* @return null|array<int, int>
*/
public function getWorkDaysHoursInput(): ?array
{
return $this->workDaysHoursInput;
}
/**
* @param null|array<int|string, mixed> $workDaysHoursInput
*/
public function setWorkDaysHoursInput(?array $workDaysHoursInput): self
{
if (null === $workDaysHoursInput) {
$this->workDaysHoursInput = null;
return $this;
}
$normalized = [];
foreach ($workDaysHoursInput as $key => $value) {
$normalized[(int) $key] = (int) $value;
}
$this->workDaysHoursInput = $normalized;
return $this;
}
#[Groups(['employee:read'])]
public function getHasActiveContract(): bool
{
@@ -358,6 +392,7 @@ class Employee
periodId: $period->getId(),
suspensions: $suspensionData,
isDriver: $period->getIsDriver(),
workDaysHours: $period->getWorkDaysHours(),
);
},
$periods

View File

@@ -45,6 +45,16 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false;
/**
* Map ISO weekday (1=Mon..5=Fri) → minutes worked that day.
* Required for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM)
* so that férié credit and absence credit respect the actual schedule.
*
* @var null|array<int, int>
*/
#[ORM\Column(type: 'json', nullable: true)]
private ?array $workDaysHours = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
@@ -176,6 +186,24 @@ class EmployeeContractPeriod
return $this;
}
/**
* @return null|array<int, int>
*/
public function getWorkDaysHours(): ?array
{
return $this->workDaysHours;
}
/**
* @param null|array<int, int> $workDaysHours
*/
public function setWorkDaysHours(?array $workDaysHours): self
{
$this->workDaysHours = $workDaysHours;
return $this;
}
/**
* @return Collection<int, ContractSuspension>
*/

View File

@@ -90,6 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[SerializedName('isLocked')]
private bool $isLocked = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:write'])]
#[SerializedName('hasLeaveRecapAccess')]
private bool $hasLeaveRecapAccess = false;
/**
* @var Collection<int, UserSiteRole>
*/
@@ -224,6 +229,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[Groups(['user:read'])]
#[SerializedName('hasLeaveRecapAccess')]
public function hasLeaveRecapAccess(): bool
{
return $this->hasLeaveRecapAccess;
}
public function setHasLeaveRecapAccess(bool $hasLeaveRecapAccess): self
{
$this->hasLeaveRecapAccess = $hasLeaveRecapAccess;
return $this;
}
#[Groups(['user:read'])]
public function getIsDriver(): bool
{

View File

@@ -9,6 +9,9 @@ use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest
{
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function __construct(
public ?ContractNature $contractNature,
public ?DateTimeImmutable $contractStartDate,
@@ -16,6 +19,7 @@ final readonly class EmployeeContractChangeRequest
public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
public ?bool $isDriver = null,
public ?array $workDaysHours = null,
) {}
public function hasPeriodChangeRequest(): bool

View File

@@ -20,6 +20,7 @@ final class EmployeeContractChangeRequestFactory
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(),
workDaysHours: $employee->getWorkDaysHoursInput(),
);
}

View File

@@ -12,6 +12,9 @@ use DateTimeImmutable;
final class EmployeeContractPeriodBuilder
{
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function build(
Employee $employee,
Contract $contract,
@@ -19,6 +22,7 @@ final class EmployeeContractPeriodBuilder
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
@@ -27,6 +31,7 @@ final class EmployeeContractPeriodBuilder
->setEndDate($endDate)
->setContractNature($nature)
->setIsDriver($isDriver)
->setWorkDaysHours($workDaysHours)
;
}
}

View File

@@ -29,15 +29,17 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) {
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->flush();
}
@@ -75,8 +77,10 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
?array $workDaysHours = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
@@ -86,10 +90,13 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->flush();
}
/**
* @param null|array<int, int> $workDaysHours
*/
private function persistNewPeriod(
Employee $employee,
Contract $contract,
@@ -97,8 +104,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->persist($period);
}
}

View File

@@ -12,6 +12,9 @@ use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface
{
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
@@ -19,6 +22,7 @@ interface EmployeeContractPeriodManagerInterface
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
): void;
public function closeCurrentPeriod(
@@ -29,6 +33,9 @@ interface EmployeeContractPeriodManagerInterface
bool $isAlreadyEnded = false
): void;
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function createNextPeriod(
Employee $employee,
Contract $contract,
@@ -37,5 +44,6 @@ interface EmployeeContractPeriodManagerInterface
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
?array $workDaysHours = null,
): void;
}

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -60,4 +62,63 @@ final class EmployeeContractPeriodValidator
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
}
}
/**
* Validates the per-period work schedule (`workDaysHours`) against the contract.
*
* Mandatory for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM,
* non-Forfait). Forbidden on standard/forfait/interim contracts (ambiguity).
* When provided, sum of minutes MUST equal weeklyHours × 60.
*
* @param null|array<int, int> $workDaysHours
*/
public function assertWorkDaysHours(?Contract $contract, ContractNature $nature, ?array $workDaysHours): void
{
if (null === $contract) {
return;
}
$trackingMode = $contract->getTrackingMode();
$weeklyHours = $contract->getWeeklyHours();
$isStandard = 35 === $weeklyHours || 39 === $weeklyHours;
$isForfait = TrackingMode::PRESENCE->value === $trackingMode;
$isInterim = ContractNature::INTERIM === $nature;
if ($isForfait || $isInterim || $isStandard) {
if (null !== $workDaysHours && [] !== $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours must not be provided for Forfait, Interim or 35h/39h contracts.');
}
return;
}
if (null === $workDaysHours || [] === $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours is required for non-standard contracts.');
}
$totalMinutes = 0;
foreach ($workDaysHours as $isoDay => $minutes) {
if (!is_int($isoDay) && !(is_string($isoDay) && ctype_digit($isoDay))) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri) as integers.');
}
$iso = (int) $isoDay;
if ($iso < 1 || $iso > 5) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri).');
}
if (!is_int($minutes) || $minutes < 0) {
throw new UnprocessableEntityHttpException('workDaysHours values must be non-negative integer minutes.');
}
$totalMinutes += $minutes;
}
$expectedMinutes = ($weeklyHours ?? 0) * 60;
if ($totalMinutes !== $expectedMinutes) {
throw new UnprocessableEntityHttpException(sprintf(
'workDaysHours total must equal contract weekly hours: got %d min, expected %d min.',
$totalMinutes,
$expectedMinutes
));
}
}
}

View File

@@ -23,6 +23,20 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
/**
* @return null|array<int, int> workDaysHours (iso day → minutes) for the contract period active on $date
*/
public function resolveWorkDaysMinutesForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?array
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
$raw = $period?->getWorkDaysHours();
if (null === $raw) {
return null;
}
return $this->normalizeWorkDaysMinutes($raw);
}
public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
@@ -84,6 +98,57 @@ readonly class EmployeeContractResolver
return $period?->getContractNatureEnum() ?? ContractNature::CDI;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, null|array<int, int>>>
*/
public function resolveWorkDaysMinutesForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = null;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$raw = $period->getWorkDaysHours();
if (null === $raw) {
continue;
}
$normalized = $this->normalizeWorkDaysMinutes($raw);
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $normalized;
}
}
return $resolved;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
return $resolved;
}
/**
* @param array<int|string, mixed> $raw
*
* @return array<int, int>
*/
private function normalizeWorkDaysMinutes(array $raw): array
{
$result = [];
foreach ($raw as $key => $value) {
$iso = (int) $key;
if ($iso < 1 || $iso > 5) {
continue;
}
$result[$iso] = (int) $value;
}
return $result;
}
}

View File

@@ -71,7 +71,10 @@ final readonly class LeaveBalanceComputationService
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$totalBusinessDays = $this->countBusinessDays($from, $to);
// Business days for forfait must use the RAW holiday list (excluded holidays
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
// days for the 218-day legal target).
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -406,6 +409,29 @@ final readonly class LeaveBalanceComputationService
return $map;
}
/**
* @return array<string, string>
*/
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/**
* @param list<Absence> $absences
*

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveRecapRowBuilder
{
public function __construct(
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* Builds a leave recap row for one employee.
*
* - $asOfDate = null → live behavior (identical to legacy PDF export): accrual capped at
* previous month end, ALL booked absences counted (incl. future ones), RTT uses today
* - $asOfDate = non-null → frozen snapshot at that date: accrual capped at the previous
* month end before asOfDate, absences after asOfDate excluded, RTT uses asOfDate
*
* @return array{
* lastName: string,
* firstName: string,
* contractName: ?string,
* cpN1Remaining: float|string,
* cpN: string,
* acquiredSaturdays: string,
* rtt: string
* }
*/
public function build(Employee $employee, ?DateTimeImmutable $asOfDate = null): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$rttReference = $asOfDate ?? new DateTimeImmutable('today');
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, 0.0, $asOfDate);
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays, $asOfDate);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $rttReference));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $reference): int
{
$month = (int) $reference->format('n');
$year = (int) $reference->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $reference->format('N');
$limitDate = 7 === $isoDay ? $reference : $reference->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $reference->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $reference);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -17,11 +17,22 @@ use Throwable;
final readonly class PublicHolidayService implements PublicHolidayServiceInterface
{
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct(
private HttpClientInterface $client,
private string $holidayUrl,
private CacheInterface $cache,
) {}
string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/**
* @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone));
$key = "public_holidays_{$zone}_all";
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
@@ -65,6 +78,19 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
* @throws ClientExceptionInterface
*/
public function getHolidaysDayByYears(string $zone, string $years): array
{
return $this->applyExclusions($this->fetchHolidaysByYears($zone, $years));
}
public function getRawHolidaysDayByYears(string $zone, string $years): array
{
return $this->fetchHolidaysByYears($zone, $years);
}
/**
* @return array<string, string>
*/
private function fetchHolidaysByYears(string $zone, string $years): array
{
$zone = strtolower(trim($zone));
$years = trim($years);
@@ -89,4 +115,21 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
}
/**
* @param array<string, string> $holidays
*
* @return array<string, string>
*/
private function applyExclusions(array $holidays): array
{
if ([] === $this->excludedLabels) {
return $holidays;
}
return array_filter(
$holidays,
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
);
}
}

View File

@@ -9,4 +9,11 @@ interface PublicHolidayServiceInterface
public function getHolidaysDay(string $zone): array;
public function getHolidaysDayByYears(string $zone, string $years): array;
/**
* Same as getHolidaysDayByYears but WITHOUT the configured exclusions applied.
* Used for legal/contractual computations (e.g. forfait 218 days) where excluded
* holidays (journée de solidarité) must still count as non-working days.
*/
public function getRawHolidaysDayByYears(string $zone, string $years): array;
}

View File

@@ -16,6 +16,8 @@ use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
@@ -29,6 +31,8 @@ final readonly class RttRecoveryComputationService
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
@@ -126,6 +130,7 @@ final readonly class RttRecoveryComputationService
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
@@ -137,7 +142,8 @@ final readonly class RttRecoveryComputationService
$metricsByDate[$dateKey] = $this->computeMetrics($workHour);
}
$creditedByDate = [];
$creditedByDate = [];
$hasAbsenceByDate = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
@@ -148,7 +154,10 @@ final readonly class RttRecoveryComputationService
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
if ($absentMorning || $absentAfternoon) {
$hasAbsenceByDate[$date] = true;
}
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
}
}
@@ -188,14 +197,22 @@ final readonly class RttRecoveryComputationService
$dailyWorkedMinutes = [];
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
$contractAtDate = $contractsByDate[$employeeId][$date] ?? null;
$employeeContractsByDate[$date] = $contractAtDate;
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
continue;
}
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
$dailyWorkedMinutes[$date] = $metrics->totalMinutes;
$effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
$contractAtDate,
new DateTimeImmutable($date),
$metrics->totalMinutes,
$hasAbsenceByDate[$date] ?? false,
$workDaysByDate[$employeeId][$date] ?? null,
);
$weeklyTotalMinutes += $effectiveMinutes;
$dailyWorkedMinutes[$date] = $effectiveMinutes;
}
if ([] === $weekDays) {
@@ -437,16 +454,6 @@ final readonly class RttRecoveryComputationService
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
if ($isoWeekDay >= 6 || null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
final readonly class DailyReferenceMinutesResolver
{
/**
* Returns the contractual expected minutes for a given weekday.
*
* - Saturday/Sunday: always 0
* - If $workDaysMinutes is provided (per-employee schedule on `EmployeeContractPeriod`),
* it takes precedence: returns the minutes for that iso day if scheduled, 0 otherwise.
* - Else 35h: 7h every weekday
* - Else 39h: 8h Mon-Thu, 7h Fri
* - Else other positive values: weeklyHours/5 per weekday
* - Else null/<=0 weeklyHours: 0
*
* @param int $isoWeekDay 1 = Monday ... 7 = Sunday
* @param null|array<int, int> $workDaysMinutes iso-day → minutes (1=Mon, ..., 5=Fri)
*/
public function resolve(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
{
if ($isoWeekDay >= 6) {
return 0;
}
if (null !== $workDaysMinutes) {
return (int) ($workDaysMinutes[$isoWeekDay] ?? 0);
}
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Throwable;
/**
* Applies the business rule: a public holiday from Monday to Friday, for any
* non-Forfait contract, credits the contractually expected daily hours.
* If the employee has also entered hours that day, the effective total is the
* max between entered minutes and the contractual reference.
*/
final readonly class HolidayVirtualHoursResolver
{
public function __construct(
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private PublicHolidayServiceInterface $publicHolidayService,
private EmployeeContractResolver $contractResolver,
) {}
/**
* Returns the effective daily minutes to count for RTT and weekly total
* aggregation, applying the holiday credit when applicable.
*
* If an absence is declared on the day, the absence dictates the credit
* (via WorkedHoursCreditPolicy) and the holiday virtual rule is bypassed —
* $actualMinutes already includes the absence credit.
*
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
*/
public function resolveEffectiveDailyMinutes(
?Contract $contract,
DateTimeImmutable $date,
int $actualMinutes,
bool $hasAbsenceOnDate = false,
?array $workDaysMinutes = null,
): int {
$reference = $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
if (0 === $reference) {
return $actualMinutes;
}
return max($actualMinutes, $reference);
}
/**
* Returns the virtual credit (reference minutes) alone — 0 if the rule
* does not apply (weekend, non-holiday, Forfait contract, absence declared,
* or employee schedule indicates a non-working day). Used by the frontend.
*
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
*/
public function resolveVirtualCredit(
?Contract $contract,
DateTimeImmutable $date,
bool $hasAbsenceOnDate = false,
?array $workDaysMinutes = null,
): int {
if ($hasAbsenceOnDate) {
return 0;
}
$isoDay = (int) $date->format('N');
if ($isoDay >= 6) {
return 0;
}
if (null === $contract) {
return 0;
}
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
return 0;
}
if (!$this->isPublicHoliday($date)) {
return 0;
}
return $this->dailyReferenceResolver->resolve($contract->getWeeklyHours(), $isoDay, $workDaysMinutes);
}
/**
* Convenience helper: resolves the schedule internally for a single employee/date.
* Used by callers that have an Employee in hand (e.g. DayContext, LeaveRecap).
*/
public function resolveVirtualCreditForEmployee(
Employee $employee,
DateTimeImmutable $date,
bool $hasAbsenceOnDate = false,
): int {
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $date);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $date);
return $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
}
private function isPublicHoliday(DateTimeImmutable $date): bool
{
try {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', $date->format('Y'));
} catch (Throwable) {
return false;
}
return isset($holidays[$date->format('Y-m-d')]);
}
}

View File

@@ -14,6 +14,7 @@ final readonly class WorkedHoursCreditPolicy
{
public function __construct(
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
) {}
/**
@@ -38,9 +39,11 @@ final readonly class WorkedHoursCreditPolicy
return 0;
}
$weekday = (int) $workDate->format('N');
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
$weekday = (int) $workDate->format('N');
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
// Quand un planning est configuré sur la période (contrats non-standards),
// il prime : jour non programmé = 0 crédit, sinon on utilise les minutes prévues.
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday, $workDaysMinutes);
if ($dayMinutes <= 0) {
return 0;
}
@@ -74,34 +77,14 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
/**
* Single source of truth = {@see DailyReferenceMinutesResolver}. Weekend=0,
* schedule precedence, 35h/39h fixed rules, fallback = weeklyHours/5.
*
* @param null|array<int, int> $workDaysMinutes planning iso-day → minutes (priorité absolue si fourni)
*/
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
{
// Week-end non travaillé dans cette politique.
if ($isoWeekDay >= 6) {
return 0;
}
// Règle fixe: 35h => 7h/jour.
if (35 === $weeklyHours) {
return 7 * 60;
}
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
if (4 === $weeklyHours) {
return 2 * 60;
}
// Contrat non renseigné/invalide: aucun crédit.
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
// Fallback générique: répartition homogène sur 5 jours ouvrés.
return (int) round(($weeklyHours * 60) / 5);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay, $workDaysMinutes);
}
}

View File

@@ -14,7 +14,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DatePeriod;
use DateTime;
@@ -24,7 +23,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
@@ -33,7 +31,6 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
private AuditLogger $auditLogger,
) {}
@@ -167,15 +164,10 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
}
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$segments = [];
foreach ($days as $day) {
if (isset($publicHolidays[$day->format('Y-m-d')])) {
continue;
}
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast;
@@ -286,27 +278,4 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setIsValid(false)
;
}
/**
* @return array<string, string>
*/
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveRecap;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractType;
use App\Repository\EmployeeRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Util\LeaveRecapCutoff;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EmployeeRepository $employeeRepository,
private EmployeeScopeService $employeeScopeService,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
) {}
/**
* @return list<EmployeeLeaveRecap>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (!$user->hasLeaveRecapAccess()) {
throw new AccessDeniedHttpException('Leave recap access not granted.');
}
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('today'));
$cutoffYmd = $cutoff->format('Y-m-d');
$employees = $this->resolveScopedEmployees($user);
$rows = [];
foreach ($employees as $employee) {
if (!$employee->getHasActiveContract()) {
continue;
}
$row = $this->rowBuilder->build($employee, $cutoff);
$resource = new EmployeeLeaveRecap();
$resource->employeeId = (int) $employee->getId();
$resource->lastName = $row['lastName'] ?? '';
$resource->firstName = $row['firstName'] ?? '';
$site = $employee->getSite();
$resource->siteId = $site?->getId();
$resource->siteName = $site?->getName();
$resource->siteColor = $site?->getColor();
$resource->contractName = $row['contractName'] ?? null;
$resource->contractSortKey = $this->resolveContractSortKey($employee->getContract());
$resource->cpN1Remaining = is_numeric($row['cpN1Remaining']) ? (float) $row['cpN1Remaining'] : 0.0;
$resource->cpN = (string) $row['cpN'];
$resource->acquiredSaturdays = (string) $row['acquiredSaturdays'];
$resource->rtt = (string) $row['rtt'];
$resource->cutoffDate = $cutoffYmd;
$rows[] = $resource;
$this->entityManager->clear();
}
usort($rows, static function (EmployeeLeaveRecap $a, EmployeeLeaveRecap $b): int {
$siteCmp = strcmp((string) ($a->siteName ?? 'zzz'), (string) ($b->siteName ?? 'zzz'));
if (0 !== $siteCmp) {
return $siteCmp;
}
$contractCmp = $a->contractSortKey <=> $b->contractSortKey;
if (0 !== $contractCmp) {
return $contractCmp;
}
$lastCmp = strcmp($a->lastName, $b->lastName);
if (0 !== $lastCmp) {
return $lastCmp;
}
return strcmp($a->firstName, $b->firstName);
});
return $rows;
}
/**
* Sort order: FORFAIT → 39h → 35h → 25h → 4h → autres.
*/
private function resolveContractSortKey(?Contract $contract): int
{
if (null === $contract) {
return 99;
}
if (ContractType::FORFAIT === $contract->getType()) {
return 0;
}
$weeklyHours = $contract->getWeeklyHours();
return match ($weeklyHours) {
39 => 1,
35 => 2,
25 => 3,
4 => 4,
default => 99,
};
}
/**
* @return list<Employee>
*/
private function resolveScopedEmployees(User $user): array
{
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return $this->employeeRepository->findForPrintBySiteIds([]);
}
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
$employee = $user->getEmployee();
return $employee instanceof Employee ? [$employee] : [];
}
$siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if ([] === $siteIds) {
return [];
}
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
}
}

View File

@@ -140,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float
* }
*/
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0): ?array
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
{
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
if ($targetYear < $firstYear) {
@@ -196,8 +196,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$carrySaturdays = 0.0;
}
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
@@ -489,19 +490,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
string $ruleCode,
int $year,
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$today = new DateTimeImmutable('today');
$reference = $asOfDate ?? new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $today->format('Y')
: $this->resolveCurrentLeaveYear($today);
? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($reference);
if ($year < $currentYear) {
$end = $periodEnd;
} elseif ($year > $currentYear) {
$end = null;
} else {
$lastDayPreviousMonth = $today
$lastDayPreviousMonth = $reference
->modify('first day of this month')
->modify('-1 day')
->setTime(0, 0)
@@ -523,10 +525,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$end = $periodEnd;
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
$end = $asOfDate;
}
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
@@ -554,7 +561,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
{
$type = $employee->getContract()?->getType();
if (ContractType::FORFAIT === $type) {
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
// Business days for forfait must use the RAW holiday list (excluded holidays like
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
// the 218-day legal target).
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekdayHolidays = array_filter(
array_keys($publicHolidays),
@@ -648,6 +658,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $map;
}
/**
* @return array<string, string>
*/
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/**
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
*

View File

@@ -69,6 +69,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
endDate: $changeRequest->contractEndDate,
nature: $nature,
isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
);
$data->setEntryDate($startDate);
@@ -138,6 +139,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
nature: $nature,
todayPeriod: $effectivePeriod,
isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
);
return $result;

View File

@@ -6,21 +6,13 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\Service\Leave\LeaveRecapRowBuilder;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface
@@ -28,12 +20,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
public function __construct(
private Environment $twig,
private EmployeeRepository $employeeRepository,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -59,7 +47,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
];
}
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today);
$siteGroups[$siteId]['employees'][] = $this->rowBuilder->build($employee);
$this->entityManager->clear();
}
@@ -84,136 +72,4 @@ class LeaveRecapPrintProvider implements ProviderInterface
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
{
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -14,6 +14,7 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -32,6 +33,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
@@ -56,10 +58,12 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
// On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
);
}
@@ -98,6 +102,14 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
$rowsByEmployeeId[$employeeId]->setFormation('Formation');
}
// If an absence is declared on the day, the absence dictates the hours credited
// (via WorkedHoursCreditPolicy). The holiday virtual credit must not stack on top.
foreach ($rowsByEmployeeId as $row) {
if ($row->absentMorning || $row->absentAfternoon) {
$row->virtualHolidayMinutes = 0;
}
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(

View File

@@ -23,6 +23,8 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -41,6 +43,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -117,6 +121,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -276,6 +281,25 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
++$weeklyNightBasketCount;
}
// Apply the Mon-Fri public holiday credit rule: for non-Forfait contracts,
// if the total worked is below the contract-expected daily hours, top it up.
// Virtual minutes are always accounted against the "day" bucket.
// When an absence is declared on the day, the holiday credit is bypassed —
// the absence (via WorkedHoursCreditPolicy) dictates the hours.
$virtualHolidayMinutes = $this->holidayVirtualHoursResolver
->resolveVirtualCredit(
$contractAtDate,
new DateTimeImmutable($date),
$absenceByEmployeeDate[$employeeId][$date] ?? false,
$workDaysByEmployeeDate[$employeeId][$date] ?? null,
)
;
if ($virtualHolidayMinutes > $totalMinutes) {
$delta = $virtualHolidayMinutes - $totalMinutes;
$dayMinutes += $delta;
$totalMinutes = $virtualHolidayMinutes;
}
$weeklyDayMinutes += $dayMinutes;
$weeklyNightMinutes += $nightMinutes;
$weeklyWorkshopMinutes += $workshopMinutes;
@@ -299,6 +323,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasLunch: $hasLunch,
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
);
}
@@ -512,23 +537,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
// Week-end hors base de référence.
if ($isoWeekDay >= 6) {
return 0;
}
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Util;
use DateTimeImmutable;
/**
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
*
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-05 23:59:59 (end of S14).
*/
final class LeaveRecapCutoff
{
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
{
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
}
}

View File

@@ -9,6 +9,7 @@ use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTime;
use PHPUnit\Framework\TestCase;
@@ -20,7 +21,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
{
public function testComputeCreditedMinutesFor35hHalfDay(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
@@ -28,19 +29,52 @@ final class WorkedHoursCreditPolicyTest extends TestCase
self::assertSame(210, $minutes);
}
public function testComputeCreditedMinutesFor4hContractFullDay(): void
public function testComputeCreditedMinutesFor4hContractUsesWorkDaysSchedule(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
// 4h contract with schedule Mon 2h + Thu 2h
$schedule = [1 => 120, 4 => 120];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
// 2026-02-16 is a Monday: full day absence credits 2h (matches scheduled Monday)
self::assertSame(120, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
}
self::assertSame(120, $minutes);
public function testComputeCreditedMinutesFor4hContractOnUnscheduledDayReturnsZero(): void
{
// 4h contract with schedule Mon 2h + Thu 2h
$schedule = [1 => 120, 4 => 120];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(
trackMode: Contract::TRACKING_TIME,
weeklyHours: 4,
countAsWorked: true,
start: '2026-02-17',
end: '2026-02-17',
);
// 2026-02-17 is a Tuesday — not a scheduled workday → 0 credit
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-17', true, true));
}
public function testComputeCreditedMinutesHalfDayOnAsymmetricScheduleDay(): void
{
// Asymmetric schedule: Monday is a 3h day (180 min)
$schedule = [1 => 180];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 3, countAsWorked: true);
// 2026-02-16 Monday, morning only → round(180/2 * 1) = 90 min
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', true, false));
// Afternoon only → same 90 min (half of day)
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', false, true));
// Full day → 180 min
self::assertSame(180, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
}
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
// Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent.
@@ -50,15 +84,20 @@ final class WorkedHoursCreditPolicyTest extends TestCase
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
}
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
{
private function buildAbsence(
string $trackMode,
?int $weeklyHours,
bool $countAsWorked,
string $start = '2026-02-16',
string $end = '2026-02-16',
): Absence {
$contract = new Contract()
->setName('Contrat test')
->setTrackingMode($trackMode)
@@ -79,18 +118,25 @@ final class WorkedHoursCreditPolicyTest extends TestCase
return new Absence()
->setEmployee($employee)
->setType($type)
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
->setStartDate(new DateTime($start))
->setEndDate(new DateTime($end))
;
}
private function buildResolverStub(): EmployeeContractResolver
/**
* @param null|array<int, int> $schedule
*/
private function buildResolverStub(?array $schedule = null): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
$resolver
->method('resolveWorkDaysMinutesForEmployeeAndDate')
->willReturn($schedule)
;
return $resolver;
}

View File

@@ -15,7 +15,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use App\State\AbsenceWriteProcessor;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
@@ -65,7 +64,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -86,7 +85,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -108,7 +107,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
@@ -142,12 +141,4 @@ final class AbsenceWriteProcessorTest extends TestCase
return $security;
}
private function createEmptyHolidayServiceStub(): PublicHolidayServiceInterface
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn([]);
return $service;
}
}

View File

@@ -15,7 +15,10 @@ use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourDayContextProvider;
use DateTime;
@@ -60,7 +63,8 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -80,7 +84,8 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$this->expectException(UnprocessableEntityHttpException::class);
@@ -106,7 +111,8 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildHolidayResolver(),
);
$result = $provider->provide(new Get());
@@ -173,4 +179,16 @@ final class WorkHourDayContextProviderTest extends TestCase
return $resolver;
}
private function buildHolidayResolver(): HolidayVirtualHoursResolver
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn([]);
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$service,
$this->createStub(EmployeeContractResolver::class),
);
}
}

View File

@@ -16,7 +16,10 @@ use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourWeeklySummaryProvider;
use DateTime;
@@ -59,8 +62,10 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->workHourRepository,
$this->absenceRepository,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()),
$this->buildResolverStub()
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildResolverStub(),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -119,8 +124,10 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->workHourRepository,
$this->absenceRepository,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()),
$this->buildWeeklyResolverStub($employees)
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
$this->buildWeeklyResolverStub($employees),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
);
$result = $provider->provide(new Get());
@@ -171,6 +178,18 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$property->setValue($entity, $id);
}
private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn($holidayMap);
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$service,
$this->createStub(EmployeeContractResolver::class),
);
}
private function buildResolverStub(): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);