Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e89a1fd7cf | |||
| 327c10fda4 |
@@ -108,6 +108,24 @@
|
|||||||
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
|
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
|
||||||
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
|
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
|
||||||
|
|
||||||
|
## Contingent heures supplémentaires payées
|
||||||
|
- Suivi par **année civile** (Janv–Déc) des heures supp payées vs plafond légal (350 h
|
||||||
|
chauffeur / 220 h autres), non-forfait uniquement.
|
||||||
|
- **Heures payées** = `base25 + base50` (hors bonus). **Mapping** : paiements RTT stockés par
|
||||||
|
exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ; année civile Y = exercice Y
|
||||||
|
(mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur `OvertimePaidContingentCalculator`.
|
||||||
|
- **Plafond** résolu sur `isDriver` du **contrat courant**.
|
||||||
|
- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile
|
||||||
|
courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart
|
||||||
|
volontairement indépendant de la phase sélectionnée (toujours l'année civile courante).
|
||||||
|
- **Export PDF** (`GET /overtime-contingent/print?year=&siteIds=`, `ROLE_USER`,
|
||||||
|
`findScoped`) : groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
|
||||||
|
colonnes Janv–Déc + `Total payé / payable`. Drawer liste employés : sélecteur année +
|
||||||
|
sites (vide = périmètre complet). Exclut les FORFAIT (contrat courant).
|
||||||
|
- ⚠️ Bug latent consigné : `SalaryRecapPrintProvider` rattache mal les paiements RTT des mois
|
||||||
|
Juin–Déc (requête par année civile sur un stockage par exercice). Hors périmètre.
|
||||||
|
- Doc : `doc/overtime-contingent.md`.
|
||||||
|
|
||||||
## Vue contrat (sélecteur de phase)
|
## Vue contrat (sélecteur de phase)
|
||||||
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
||||||
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.114'
|
app.version: '0.1.115'
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Contingent d'heures supplémentaires payées
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Suivre, par année civile (Janv–Déc), les heures supplémentaires payées de chaque employé
|
||||||
|
non-forfait (chauffeurs inclus) face au plafond légal annuel.
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus).
|
||||||
|
- **Plafond** : 350 h pour les chauffeurs (contrat courant `isDriver`), 220 h sinon.
|
||||||
|
- **Périmètre** : non-forfait uniquement (FORFAIT exclus, ni RTT ni heures supp payées).
|
||||||
|
|
||||||
|
## Mapping exercice → année civile
|
||||||
|
Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 →
|
||||||
|
Mai N) + `month` (1–12). L'année civile d'un paiement :
|
||||||
|
|
||||||
|
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
|
||||||
|
|
||||||
|
Donc l'année civile **Y** agrège : exercice `Y` (mois 1–5) + exercice `Y+1` (mois 6–12).
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- Cœur partagé : `App\Service\WorkHours\OvertimePaidContingentCalculator` (pur).
|
||||||
|
- Repo : `EmployeeRttPaymentRepository::findByEmployeesAndYears`.
|
||||||
|
- Fiche employé : `GET /employees/{id}/overtime-contingent?year=YYYY` → encart header
|
||||||
|
(`Total H.payés {année} : X h / plafond h`, rouge si dépassement, année civile courante).
|
||||||
|
- Export PDF : `GET /overtime-contingent/print?year=&siteIds=` (`ROLE_USER`, périmètre
|
||||||
|
`findScoped`), groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
|
||||||
|
colonnes Janv–Déc + colonne `Total payé / payable`. Builder
|
||||||
|
`OvertimeContingentExportBuilder`, template `overtime-contingent/print.html.twig`.
|
||||||
|
|
||||||
|
## Hors périmètre / connu
|
||||||
|
- Bug latent récap salaire : `SalaryRecapPrintProvider` requête `findByYearAndMonth` avec
|
||||||
|
l'année civile alors que le stockage est par exercice (mauvais rattachement des paiements
|
||||||
|
des mois Juin–Déc sur le récap mensuel). À corriger séparément.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
|||||||
|
# Contingent d'heures supplémentaires payées — Design
|
||||||
|
|
||||||
|
Date : 2026-06-11
|
||||||
|
Statut : validé (brainstorming)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
La RH a besoin de suivre, **par année civile (Janvier→Décembre)**, le volume d'heures
|
||||||
|
supplémentaires payées à chaque employé non-forfait (chauffeurs inclus), rapporté au
|
||||||
|
plafond réglementaire annuel (le « contingent ») :
|
||||||
|
|
||||||
|
- **350 h** pour les chauffeurs (conducteurs),
|
||||||
|
- **220 h** pour les autres non-forfait.
|
||||||
|
|
||||||
|
Deux livrables :
|
||||||
|
|
||||||
|
1. **Fiche employé** — un encart dans le header affichant `Contingent {année} : X h / plafond h`.
|
||||||
|
2. **Écran liste employés** — un export PDF supplémentaire : par employé, les heures payées
|
||||||
|
de chaque mois + une colonne finale « Total payé / Total payable », groupé par site.
|
||||||
|
|
||||||
|
## Règles métier (validées)
|
||||||
|
|
||||||
|
- **Heures payées** = `base25Minutes + base50Minutes` (en minutes), **hors majoration
|
||||||
|
(bonus)**. Cohérent avec la colonne « Heures payés » du récap salaire, déjà définie hors
|
||||||
|
bonus.
|
||||||
|
- **Période = vraie année civile (Janv–Déc).** Les paiements RTT (`EmployeeRttPayment`)
|
||||||
|
sont stockés par **exercice** (`year` = année d'exercice Juin N-1 → Mai N) + `month`
|
||||||
|
(1–12). L'année civile d'un paiement se reconstitue avec la même formule que
|
||||||
|
`RttTab.vue:392` :
|
||||||
|
|
||||||
|
```
|
||||||
|
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
|
||||||
|
```
|
||||||
|
|
||||||
|
Donc l'année civile **Y** agrège :
|
||||||
|
- exercice `Y`, mois 1–5 (Janv–Mai Y),
|
||||||
|
- exercice `Y+1`, mois 6–12 (Juin–Déc Y).
|
||||||
|
|
||||||
|
- **Plafond** : `isDriver` du **contrat courant** → 350 h, sinon → 220 h.
|
||||||
|
- **Périmètre** : non-forfait uniquement. Les FORFAIT sont exclus (pas d'heures supp
|
||||||
|
payées ; onglet RTT déjà masqué pour eux).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Cœur partagé — `App\Service\WorkHours\OvertimePaidContingentCalculator`
|
||||||
|
|
||||||
|
Source de vérité unique, consommée par l'endpoint fiche employé ET le builder PDF.
|
||||||
|
|
||||||
|
```php
|
||||||
|
final readonly class OvertimePaidContingentCalculator
|
||||||
|
{
|
||||||
|
public const int CAP_HOURS_DRIVER = 350;
|
||||||
|
public const int CAP_HOURS_DEFAULT = 220;
|
||||||
|
|
||||||
|
// Heures payées (base25+base50) ventilées par mois civil 1..12 pour l'année civile.
|
||||||
|
public function monthlyBaseMinutes(Employee $employee, int $civilYear): array; // <int,int> 1..12
|
||||||
|
|
||||||
|
// Somme des 12 mois.
|
||||||
|
public function totalBaseMinutes(Employee $employee, int $civilYear): int;
|
||||||
|
|
||||||
|
// 350 si conducteur (contrat courant isDriver), sinon 220.
|
||||||
|
public function capHours(Employee $employee): int;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Calcul de `monthlyBaseMinutes` :
|
||||||
|
1. Récupérer les paiements des exercices `civilYear` et `civilYear+1` (fetch groupé).
|
||||||
|
2. Pour chaque paiement, calculer son année civile via la formule ci-dessus ; ne garder que
|
||||||
|
ceux dont l'année civile == `civilYear`.
|
||||||
|
3. Bucketiser par `month`, sommer `base25Minutes + base50Minutes`.
|
||||||
|
|
||||||
|
Statut conducteur : résolu via le contrat courant de l'employé (cohérent avec le choix
|
||||||
|
« contrat courant » pour le plafond). Réutiliser le mécanisme existant
|
||||||
|
(`employee.currentContract` / `EmployeeContractResolver`).
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
|
||||||
|
Ajout à `EmployeeRttPaymentRepository` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Fetch groupé pour le PDF (évite N+1 sur N employés).
|
||||||
|
public function findByEmployeesAndYears(array $employees, array $years): array;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le calculator pour un seul employé peut réutiliser `findByEmployeeAndYear()` (existant) deux
|
||||||
|
fois (exercices `civilYear` et `civilYear+1`).
|
||||||
|
|
||||||
|
## Partie A — Encart fiche employé (header)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ApiResource `EmployeeOvertimeContingentOutput` + opération
|
||||||
|
`GET /employees/{id}/overtime-contingent?year=YYYY` (`ROLE_ADMIN`).
|
||||||
|
- Défaut `year` = année civile courante. Validation 2000–2100.
|
||||||
|
- Provider : retourne `{ year, paidMinutes, capHours, isDriver }`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Service + composable : fetch sur la fiche employé **uniquement pour les non-forfait**
|
||||||
|
(même condition que l'affichage de l'onglet RTT).
|
||||||
|
- Affichage : ligne texte dans le header, sous le libellé contrat
|
||||||
|
(`useEmployeeDetailPage` / header de `pages/employees/[id].vue`), au format :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contingent 2026 : 142 h / 220 h
|
||||||
|
```
|
||||||
|
|
||||||
|
Passe en **rouge** (`text-m-danger` / classe danger) si `paidMinutes > capHours*60`.
|
||||||
|
- **Année civile courante uniquement, pas de sélecteur** dans le header. L'historique se
|
||||||
|
consulte via le PDF.
|
||||||
|
|
||||||
|
## Partie B — Export PDF (écran liste employés)
|
||||||
|
|
||||||
|
Calque exact de l'export contingent heures de nuit (`night-hours-contingent`).
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ApiResource `OvertimeContingentPrint` → `GET /overtime-contingent/print?year=&siteIds=`
|
||||||
|
(`ROLE_USER`).
|
||||||
|
- Provider `OvertimeContingentPrintProvider` :
|
||||||
|
- Périmètre via `EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses
|
||||||
|
sites). `siteIds` hors périmètre ignoré.
|
||||||
|
- **Exclut les FORFAIT** (contrat courant) en plus du filtre `hasContractInRange` sur
|
||||||
|
l'année.
|
||||||
|
- Groupe par site (`displayOrder`), tri intra-site `displayOrder → nom → prénom`
|
||||||
|
(identique au calendrier / aux autres exports).
|
||||||
|
- Builder `OvertimeContingentExportBuilder::buildRows($employees, $year)` :
|
||||||
|
- utilise `OvertimePaidContingentCalculator` (fetch groupé via `findByEmployeesAndYears`),
|
||||||
|
- retourne par employé : `months[1..12]` (minutes base payées), `totalMinutes`, `capHours`.
|
||||||
|
- DTO `App\Dto\WorkHours\OvertimeContingentRow`.
|
||||||
|
|
||||||
|
### Template
|
||||||
|
- `templates/overtime-contingent/print.html.twig` — **A4 paysage**.
|
||||||
|
- Colonnes : Nom employé · Janv … Déc (heures payées du mois, format `XhYY` ou `—` si 0) ·
|
||||||
|
**Total : `total payé h / plafond h`** (ex. `142 h / 220 h`).
|
||||||
|
- Total en gras ; cellule total en rouge si dépassement.
|
||||||
|
- En-têtes de site colorées (comme night-contingent).
|
||||||
|
|
||||||
|
### Frontend (drawer existant `pages/employees/index.vue`)
|
||||||
|
- Ajouter le choix `overtime-contingent` à `exportTypeOptions`
|
||||||
|
(libellé ex. « Contingent H.supp. »).
|
||||||
|
- Bloc de formulaire dédié : sélecteur **Année** (`exportYearOptions`) + sélecteur **Sites**
|
||||||
|
multi-sélection (tags, calqué sur le drawer d'export jour ; valeurs = sites visibles).
|
||||||
|
- `isExportValid` : `exportYear > 0` (sites optionnels — vide = tous les sites du périmètre).
|
||||||
|
- `handleExportValidate` : `printPdf('/overtime-contingent/print?year=${exportYear}${siteIdsParam}')`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `OvertimePaidContingentCalculatorTest` :
|
||||||
|
- mapping année civile (paiement exercice 2027 mois 9 → compté en 2026),
|
||||||
|
- frontière mois 5/6 (mai = exercice, juin = exercice-1),
|
||||||
|
- somme `base25+base50` hors bonus,
|
||||||
|
- plafond 350 (driver) vs 220.
|
||||||
|
- `OvertimeContingentExportBuilderTest` : ventilation mensuelle + total + plafond par
|
||||||
|
employé, fetch groupé.
|
||||||
|
- Test provider : exclusion forfait, périmètre `findScoped`, tri/groupement par site.
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règle projet obligatoire)
|
||||||
|
|
||||||
|
- `doc/overtime-contingent.md` (nouveau) — règles + mapping civil/exercice.
|
||||||
|
- `CLAUDE.md` — section dédiée (cœur partagé, mapping, plafonds, périmètre).
|
||||||
|
- `frontend/data/documentation-content.ts` — section utilisateur (admin) décrivant l'encart
|
||||||
|
et l'export.
|
||||||
|
|
||||||
|
## Hors périmètre (consigné pour plus tard)
|
||||||
|
|
||||||
|
- **Bug latent du récap salaire** : `SalaryRecapPrintProvider:86` requête
|
||||||
|
`findByYearAndMonth(annéeCivile, mois)` alors que les paiements sont stockés par exercice.
|
||||||
|
Pour les mois Juin–Déc, un paiement RTT est donc probablement mal rattaché sur le récap
|
||||||
|
mensuel. À corriger dans une intervention séparée.
|
||||||
|
- Plafonds 350/220 en constantes nommées dans le calculator ; passage en config/env
|
||||||
|
envisageable ultérieurement.
|
||||||
@@ -2,12 +2,14 @@ import type { Employee } from '~/services/dto/employee'
|
|||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { getEmployee } from '~/services/employees'
|
import { getEmployee } from '~/services/employees'
|
||||||
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||||
|
import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent'
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||||
|
const overtimeContingent = ref<OvertimeContingent | null>(null)
|
||||||
|
|
||||||
const phase = useEmployeeContractPhase(employee)
|
const phase = useEmployeeContractPhase(employee)
|
||||||
|
|
||||||
@@ -28,6 +30,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
return contract.name || '-'
|
return contract.name || '-'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loadOvertimeContingent = async () => {
|
||||||
|
if (!employee.value || !showRttTab.value) {
|
||||||
|
overtimeContingent.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
overtimeContingent.value = await getEmployeeOvertimeContingent(employee.value.id)
|
||||||
|
} catch {
|
||||||
|
overtimeContingent.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadEmployee = async () => {
|
const loadEmployee = async () => {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const employeeId = Number(idParam)
|
const employeeId = Number(idParam)
|
||||||
@@ -71,6 +85,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
|
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
|
||||||
await leave.loadLeaveData()
|
await leave.loadLeaveData()
|
||||||
}
|
}
|
||||||
|
await loadOvertimeContingent()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -94,6 +109,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
if (presence === undefined || presence === null) return ''
|
if (presence === undefined || presence === null) return ''
|
||||||
return ` (${formatDays(presence)} présence)`
|
return ` (${formatDays(presence)} présence)`
|
||||||
})
|
})
|
||||||
|
const overtimeContingentLabel = computed(() => {
|
||||||
|
if (!showRttTab.value) return ''
|
||||||
|
const c = overtimeContingent.value
|
||||||
|
if (!c) return ''
|
||||||
|
const h = c.paidMinutes / 60
|
||||||
|
const hStr = Number.isInteger(h) ? String(h) : (Math.round(h * 10) / 10).toFixed(1).replace('.', ',')
|
||||||
|
return `Total H.payés ${c.year} : ${hStr} h / ${c.capHours} h`
|
||||||
|
})
|
||||||
|
const overtimeContingentExceeded = computed(() => {
|
||||||
|
const c = overtimeContingent.value
|
||||||
|
return c ? c.paidMinutes > c.capHours * 60 : false
|
||||||
|
})
|
||||||
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||||
@@ -147,6 +174,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
...phase,
|
...phase,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
|
|||||||
@@ -643,6 +643,18 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
|
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'contingent-heures-supp',
|
||||||
|
title: 'Export Contingent H.supp.',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'L\'encart « Total H.payés {année} : X h / plafond h », affiché dans l\'en-tête de la fiche d\'un employé non-forfait, indique le total d\'heures supplémentaires payées sur l\'année civile en cours face au plafond légal. Il passe en rouge si ce plafond est dépassé.' },
|
||||||
|
{ type: 'list', content: 'Plafond chauffeur (contrat courant « conducteur ») : 350 h\nPlafond autres salariés non-forfait : 220 h\nSeuls les employés non-forfait disposent de cet encart (FORFAIT exclus)' },
|
||||||
|
{ type: 'paragraph', content: 'L\'export PDF « Contingent H.supp. » est accessible depuis la liste des employés, via le bouton Export → option « Contingent H.supp. ». Choisissez l\'année civile (par défaut l\'année courante) et éventuellement des sites ; sans sélection de site, tous les sites de votre périmètre sont inclus.' },
|
||||||
|
{ type: 'list', content: 'PDF A4 paysage, une ligne par employé non-forfait, groupé par site\nTri : ordre d\'affichage du site, puis nom, puis prénom\nColonnes : Janv à Déc (heures payées par mois) + colonne « Total payé / payable »\nLes employés FORFAIT n\'apparaissent pas dans cet export' },
|
||||||
|
{ type: 'note', content: 'Les heures prises en compte sont les bases payées (25 % et 50 % confondus), hors majorations. Le contingent est calculé sur l\'année civile (janvier–décembre), indépendamment de l\'exercice RTT (juin–mai) : un paiement RTT saisi pour le mois de juin est rattaché à l\'année civile précédente.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'impression-absences',
|
id: 'impression-absences',
|
||||||
title: 'Impression absences',
|
title: 'Impression absences',
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
|
<p
|
||||||
|
v-if="overtimeContingentLabel"
|
||||||
|
class="text-[16px] font-semibold"
|
||||||
|
:class="overtimeContingentExceeded ? 'text-red-600' : ''"
|
||||||
|
>{{ overtimeContingentLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
||||||
@@ -300,6 +305,8 @@ const {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
contractForm,
|
contractForm,
|
||||||
createContractForm,
|
createContractForm,
|
||||||
isContractDrawerOpen,
|
isContractDrawerOpen,
|
||||||
|
|||||||
@@ -240,6 +240,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="exportYear"
|
||||||
|
:options="exportYearOptions"
|
||||||
|
label="Année *"
|
||||||
|
min-width=""
|
||||||
|
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="exportSiteIds"
|
||||||
|
:options="siteOptions"
|
||||||
|
label="Sites"
|
||||||
|
min-width=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Valider"
|
label="Valider"
|
||||||
@@ -274,16 +290,18 @@ const isDrawerOpen = ref(false)
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isExportDrawerOpen = ref(false)
|
const isExportDrawerOpen = ref(false)
|
||||||
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('')
|
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''>('')
|
||||||
const exportYear = ref<number>(new Date().getFullYear())
|
const exportYear = ref<number>(new Date().getFullYear())
|
||||||
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
||||||
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
const exportSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
const exportTypeOptions = [
|
const exportTypeOptions = [
|
||||||
{ label: 'Récap. congés', value: 'leave-recap' },
|
{ label: 'Récap. congés', value: 'leave-recap' },
|
||||||
{ label: 'Récap. salaire', value: 'salary-recap' },
|
{ label: 'Récap. salaire', value: 'salary-recap' },
|
||||||
{ label: 'Heures annuelles', value: 'yearly-hours' },
|
{ label: 'Heures annuelles', value: 'yearly-hours' },
|
||||||
{ label: 'Contingent H.nuit', value: 'night-contingent' }
|
{ label: 'Contingent H.nuit', value: 'night-contingent' },
|
||||||
|
{ label: 'Contingent H.supp.', value: 'overtime-contingent' }
|
||||||
]
|
]
|
||||||
const exportYearOptions = computed(() => {
|
const exportYearOptions = computed(() => {
|
||||||
const current = new Date().getFullYear()
|
const current = new Date().getFullYear()
|
||||||
@@ -315,11 +333,14 @@ const isExportValid = computed(() => {
|
|||||||
if (exportChoice.value === 'night-contingent') {
|
if (exportChoice.value === 'night-contingent') {
|
||||||
return exportYear.value > 0
|
return exportYear.value > 0
|
||||||
}
|
}
|
||||||
|
if (exportChoice.value === 'overtime-contingent') {
|
||||||
|
return exportYear.value > 0
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const onExportChoiceChange = (value: string | number | null) => {
|
const onExportChoiceChange = (value: string | number | null) => {
|
||||||
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''
|
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''
|
||||||
}
|
}
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
@@ -619,6 +640,7 @@ const openExportDrawer = () => {
|
|||||||
exportYear.value = now.getFullYear()
|
exportYear.value = now.getFullYear()
|
||||||
exportMonth.value = now.getMonth() + 1
|
exportMonth.value = now.getMonth() + 1
|
||||||
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
exportSiteIds.value = []
|
||||||
isExportDrawerOpen.value = true
|
isExportDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +656,9 @@ const handleExportValidate = async () => {
|
|||||||
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
||||||
} else if (choice === 'night-contingent') {
|
} else if (choice === 'night-contingent') {
|
||||||
await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
|
await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
|
||||||
|
} else if (choice === 'overtime-contingent') {
|
||||||
|
const siteParam = exportSiteIds.value.length > 0 ? `&siteIds=${exportSiteIds.value.join(',')}` : ''
|
||||||
|
await printPdf(`/overtime-contingent/print?year=${exportYear.value}${siteParam}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface OvertimeContingent {
|
||||||
|
year: number
|
||||||
|
paidMinutes: number
|
||||||
|
capHours: number
|
||||||
|
isDriver: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEmployeeOvertimeContingent = async (employeeId: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query: Record<string, number> = {}
|
||||||
|
if (year) query.year = year
|
||||||
|
return api.get<OvertimeContingent>(`/employees/${employeeId}/overtime-contingent`, query, { toast: false })
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\EmployeeOvertimeContingentProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/employees/{id}/overtime-contingent',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeOvertimeContingentProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeOvertimeContingent
|
||||||
|
{
|
||||||
|
public int $year = 0;
|
||||||
|
public int $paidMinutes = 0;
|
||||||
|
public int $capHours = 0;
|
||||||
|
public bool $isDriver = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\OvertimeContingentPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/overtime-contingent/print',
|
||||||
|
provider: OvertimeContingentPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'year', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: false),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class OvertimeContingentPrint {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class OvertimeContingentRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $months clé 1..12 -> minutes base payées
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $employeeId,
|
||||||
|
public readonly string $employeeName,
|
||||||
|
public readonly array $months,
|
||||||
|
public readonly int $totalMinutes,
|
||||||
|
public readonly int $capHours,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
*/
|
*/
|
||||||
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -60,4 +60,31 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paiements de plusieurs employés sur plusieurs exercices (fetch groupé,
|
||||||
|
* évite le N+1 sur l'export PDF). Jointure employé chargée.
|
||||||
|
*
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @param list<int> $years années d'exercice
|
||||||
|
*
|
||||||
|
* @return EmployeeRttPayment[]
|
||||||
|
*/
|
||||||
|
public function findByEmployeesAndYears(array $employees, array $years): array
|
||||||
|
{
|
||||||
|
if ([] === $employees || [] === $years) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee IN (:employees)')
|
||||||
|
->andWhere('p.year IN (:years)')
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
->setParameter('years', $years)
|
||||||
|
->innerJoin('p.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Dto\WorkHours\OvertimeContingentRow;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit, par employé, les heures supp payées (base, hors bonus) ventilées
|
||||||
|
* par mois civil pour l'année civile demandée, le total et le plafond légal.
|
||||||
|
*/
|
||||||
|
final readonly class OvertimeContingentExportBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private OvertimePaidContingentCalculator $calculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<OvertimeContingentRow>
|
||||||
|
*/
|
||||||
|
public function buildRows(array $employees, int $civilYear): array
|
||||||
|
{
|
||||||
|
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||||
|
$payments = $this->rttPaymentRepository->findByEmployeesAndYears(
|
||||||
|
$employees,
|
||||||
|
[$civilYear, $civilYear + 1],
|
||||||
|
);
|
||||||
|
|
||||||
|
$byEmployee = [];
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$employeeId = $payment->getEmployee()?->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byEmployee[$employeeId][] = $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeePayments = $byEmployee[$employeeId] ?? [];
|
||||||
|
$months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||||
|
|
||||||
|
$rows[] = new OvertimeContingentRow(
|
||||||
|
employeeId: $employeeId,
|
||||||
|
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||||
|
months: $months,
|
||||||
|
totalMinutes: array_sum($months),
|
||||||
|
capHours: $this->calculator->capHours($employee->getIsDriver()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les paiements RTT (stockés par exercice Juin N-1 -> Mai N + mois)
|
||||||
|
* en agrégats par ANNEE CIVILE (Janv-Déc). Heures payées = base25 + base50,
|
||||||
|
* hors majoration (bonus). Plafond : 350 h chauffeur, 220 h autres.
|
||||||
|
*/
|
||||||
|
final readonly class OvertimePaidContingentCalculator
|
||||||
|
{
|
||||||
|
public const int CAP_HOURS_DRIVER = 350;
|
||||||
|
public const int CAP_HOURS_DEFAULT = 220;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<EmployeeRttPayment> $payments paiements d'un employé
|
||||||
|
* (typiquement exercices civilYear et civilYear+1)
|
||||||
|
*
|
||||||
|
* @return array<int, int> clé 1..12 -> minutes base payées (base25+base50)
|
||||||
|
*/
|
||||||
|
public function monthlyBaseMinutes(iterable $payments, int $civilYear): array
|
||||||
|
{
|
||||||
|
$months = array_fill(1, 12, 0);
|
||||||
|
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$month = $payment->getMonth();
|
||||||
|
$paymentCivilYear = $month >= 6 ? $payment->getYear() - 1 : $payment->getYear();
|
||||||
|
if ($paymentCivilYear !== $civilYear) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months[$month] += $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<EmployeeRttPayment> $payments
|
||||||
|
*/
|
||||||
|
public function totalBaseMinutes(iterable $payments, int $civilYear): int
|
||||||
|
{
|
||||||
|
return array_sum($this->monthlyBaseMinutes($payments, $civilYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capHours(bool $isDriver): int
|
||||||
|
{
|
||||||
|
return $isDriver ? self::CAP_HOURS_DRIVER : self::CAP_HOURS_DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\EmployeeOvertimeContingent;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class EmployeeOvertimeContingentProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private OvertimePaidContingentCalculator $calculator,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeOvertimeContingent
|
||||||
|
{
|
||||||
|
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||||
|
if ($employeeId <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $this->employeeRepository->find($employeeId);
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
throw new NotFoundHttpException('Employee not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$year = (int) $request?->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
|
||||||
|
if ($year < 2000 || $year > 2100) {
|
||||||
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||||
|
$payments = array_merge(
|
||||||
|
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year),
|
||||||
|
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year + 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = new EmployeeOvertimeContingent();
|
||||||
|
$output->year = $year;
|
||||||
|
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year);
|
||||||
|
$output->isDriver = $employee->getIsDriver();
|
||||||
|
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\WorkHours\OvertimeContingentExportBuilder;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
final class OvertimeContingentPrintProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private OvertimeContingentExportBuilder $exportBuilder,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (!$request) {
|
||||||
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
|
||||||
|
if ($year < 2000 || $year > 2100) {
|
||||||
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||||
|
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
||||||
|
|
||||||
|
// Filtre sites optionnel (vide = tout le perimetre).
|
||||||
|
$rawSiteIds = (string) $request->query->get('siteIds', '');
|
||||||
|
$siteIds = array_values(array_filter(array_map('intval', array_filter(explode(',', $rawSiteIds), 'strlen'))));
|
||||||
|
|
||||||
|
// Perimetre selon le profil : admin -> tous, chef de site -> ses sites.
|
||||||
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$bySite = [];
|
||||||
|
$siteMeta = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
if (!$this->hasContractInRange($employee, $from, $to)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Exclure les forfait (contrat courant).
|
||||||
|
$currentContract = $this->contractResolver->resolveForEmployeeAndDate($employee, $today);
|
||||||
|
if (null !== $currentContract && ContractType::FORFAIT === $currentContract->getType()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$site = $employee->getSite();
|
||||||
|
if (null === $site) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$siteId = $site->getId();
|
||||||
|
if ([] !== $siteIds && !in_array($siteId, $siteIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bySite[$siteId][] = $employee;
|
||||||
|
$siteMeta[$siteId] ??= [
|
||||||
|
'name' => $site->getName(),
|
||||||
|
'order' => $site->getDisplayOrder(),
|
||||||
|
'color' => $site->getColor(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($siteMeta, static function (array $a, array $b): int {
|
||||||
|
return [$a['order'], $a['name']] <=> [$b['order'], $b['name']];
|
||||||
|
});
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
foreach ($siteMeta as $siteId => $meta) {
|
||||||
|
$siteEmployees = $bySite[$siteId];
|
||||||
|
// Meme tri que le calendrier : displayOrder, puis nom, puis prenom.
|
||||||
|
usort($siteEmployees, static function (Employee $a, Employee $b): int {
|
||||||
|
return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
|
||||||
|
<=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
|
||||||
|
});
|
||||||
|
|
||||||
|
$rows = $this->exportBuilder->buildRows($siteEmployees, $year);
|
||||||
|
|
||||||
|
$renderRows = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cells = [];
|
||||||
|
for ($m = 1; $m <= 12; ++$m) {
|
||||||
|
$cells[] = $row->months[$m] > 0 ? $this->formatMinutes($row->months[$m]) : '—';
|
||||||
|
}
|
||||||
|
$renderRows[] = [
|
||||||
|
'employeeName' => $row->employeeName,
|
||||||
|
'cells' => $cells,
|
||||||
|
'totalHours' => $this->formatMinutes($row->totalMinutes),
|
||||||
|
'capHours' => $row->capHours,
|
||||||
|
'exceeded' => $row->totalMinutes > $row->capHours * 60,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
|
||||||
|
$html = $this->twig->render('overtime-contingent/print.html.twig', [
|
||||||
|
'groups' => $groups,
|
||||||
|
'year' => $year,
|
||||||
|
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dompdf->loadHtml($html);
|
||||||
|
$dompdf->setPaper('A4', 'landscape');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
$filename = sprintf('contingent_heures_supp_%d.pdf', $year);
|
||||||
|
|
||||||
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
||||||
|
{
|
||||||
|
$fromDay = $from->format('Y-m-d');
|
||||||
|
$toDay = $to->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
$start = $period->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $period->getEndDate()?->format('Y-m-d');
|
||||||
|
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMinutes(int $minutes): string
|
||||||
|
{
|
||||||
|
$h = intdiv($minutes, 60);
|
||||||
|
$m = $minutes % 60;
|
||||||
|
|
||||||
|
return sprintf('%dh%02d', $h, $m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Contingent heures de nuit — {{ year }}</h1>
|
<h1>Contingent heures de nuit — Année civile {{ year }}</h1>
|
||||||
<div class="meta">Édité le {{ exportedAt }}</div>
|
<div class="meta">Édité le {{ exportedAt }}</div>
|
||||||
|
|
||||||
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
|
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 16px; }
|
||||||
|
body { font-family: DejaVu Sans, sans-serif; font-size: 10px; color: #000; }
|
||||||
|
h1 { font-size: 15px; margin: 0 0 2px; }
|
||||||
|
.meta { font-size: 9px; color: #555; margin-bottom: 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #999; padding: 2px 3px; text-align: center; }
|
||||||
|
th { background: #d9d9d9; }
|
||||||
|
td.name, th.name { text-align: left; width: 150px; padding-left: 4px; padding-right: 6px; }
|
||||||
|
td.data, th.data { width: 44px; font-size: 9px; }
|
||||||
|
td.total, th.total { width: 90px; font-weight: bold; white-space: nowrap; }
|
||||||
|
td.exceeded { color: #c00; }
|
||||||
|
tr.site-title td { text-align: left; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Contingent heures supplémentaires payées — Année civile {{ year }}</h1>
|
||||||
|
<div class="meta">Édité le {{ exportedAt }}</div>
|
||||||
|
|
||||||
|
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="name">Nom</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th class="data">{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th class="total">Total payé / payable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr class="site-title">
|
||||||
|
<td colspan="14" style="background: {{ group.siteColor|default('#eee') }}">{{ group.siteName }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="name">{{ row.employeeName }}</td>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td class="data">{{ cell }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="total{{ row.exceeded ? ' exceeded' : '' }}">{{ row.totalHours }} / {{ row.capHours }} h</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\Service\WorkHours\OvertimeContingentExportBuilder;
|
||||||
|
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class OvertimeContingentExportBuilderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBuildsRowsWithMonthlyTotalsAndCap(): void
|
||||||
|
{
|
||||||
|
// isDriver est résolu via le contrat courant : on le force par une
|
||||||
|
// sous-classe anonyme pour rester en test unitaire (sans BDD).
|
||||||
|
$driverEmp = new class extends Employee {
|
||||||
|
public function getIsDriver(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$driverEmp->setLastName('Martin')->setFirstName('Luc');
|
||||||
|
$idRef = new ReflectionProperty(Employee::class, 'id');
|
||||||
|
$idRef->setValue($driverEmp, 7);
|
||||||
|
|
||||||
|
// Paiement : exercice 2027, mois 9 -> civil 2026, mois 9 ; base 100+20.
|
||||||
|
$payment = new EmployeeRttPayment()
|
||||||
|
->setEmployee($driverEmp)
|
||||||
|
->setYear(2027)->setMonth(9)
|
||||||
|
->setBase25Minutes(100)->setBase50Minutes(20)
|
||||||
|
;
|
||||||
|
|
||||||
|
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
|
||||||
|
$repo->method('findByEmployeesAndYears')->willReturn([$payment]);
|
||||||
|
|
||||||
|
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator());
|
||||||
|
|
||||||
|
$rows = $builder->buildRows([$driverEmp], 2026);
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(7, $rows[0]->employeeId);
|
||||||
|
self::assertSame('Martin Luc', $rows[0]->employeeName);
|
||||||
|
self::assertSame(120, $rows[0]->months[9]);
|
||||||
|
self::assertSame(0, $rows[0]->months[1]);
|
||||||
|
self::assertSame(120, $rows[0]->totalMinutes);
|
||||||
|
self::assertSame(350, $rows[0]->capHours); // chauffeur
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmployeeWithNoPaymentsYieldsZeroRow(): void
|
||||||
|
{
|
||||||
|
$emp = new Employee();
|
||||||
|
$emp->setLastName('Durand')->setFirstName('Alice');
|
||||||
|
$idRef = new ReflectionProperty(Employee::class, 'id');
|
||||||
|
$idRef->setValue($emp, 99);
|
||||||
|
|
||||||
|
$repo = $this->createStub(EmployeeRttPaymentRepository::class);
|
||||||
|
$repo->method('findByEmployeesAndYears')->willReturn([]);
|
||||||
|
|
||||||
|
$builder = new OvertimeContingentExportBuilder($repo, new OvertimePaidContingentCalculator());
|
||||||
|
$rows = $builder->buildRows([$emp], 2026);
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(0, $rows[0]->totalMinutes);
|
||||||
|
self::assertSame(0, $rows[0]->months[6]);
|
||||||
|
self::assertSame(220, $rows[0]->capHours); // non-driver
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class OvertimePaidContingentCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testMapsPaymentToCalendarYearAndSumsBaseOnly(): void
|
||||||
|
{
|
||||||
|
$calc = new OvertimePaidContingentCalculator();
|
||||||
|
|
||||||
|
// Septembre 2025 stocké en exercice 2026 (mois 9 >= 6 -> civil 2025).
|
||||||
|
// Mars 2026 stocké en exercice 2026 (mois 3 < 6 -> civil 2026).
|
||||||
|
// Septembre 2026 stocké en exercice 2027 (mois 9 >= 6 -> civil 2026).
|
||||||
|
// March 2026 payment has a large bonus (999 min) that must be excluded.
|
||||||
|
$payments = [
|
||||||
|
$this->payment(2026, 9, 120, 0), // civil 2025 -> exclu de 2026
|
||||||
|
$this->payment(2026, 3, 60, 30, 999), // civil 2026 -> mois 3, bonus ignoré
|
||||||
|
$this->payment(2027, 9, 100, 20), // civil 2026 -> mois 9
|
||||||
|
];
|
||||||
|
|
||||||
|
$months = $calc->monthlyBaseMinutes($payments, 2026);
|
||||||
|
|
||||||
|
self::assertSame(90, $months[3]); // 60 + 30 (bonus 999 excluded)
|
||||||
|
self::assertSame(120, $months[9]); // 100 + 20
|
||||||
|
self::assertSame(0, $months[1]);
|
||||||
|
self::assertSame(0, $months[8]);
|
||||||
|
self::assertSame(210, $calc->totalBaseMinutes($payments, 2026)); // bonus ignoré
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMonth5BelongsToExerciseYearAndMonth6ToPreviousCalendarYear(): void
|
||||||
|
{
|
||||||
|
$calc = new OvertimePaidContingentCalculator();
|
||||||
|
|
||||||
|
$payments = [
|
||||||
|
$this->payment(2026, 5, 50, 0), // mai -> civil 2026
|
||||||
|
$this->payment(2026, 6, 70, 0), // juin -> civil 2025
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame(50, $calc->totalBaseMinutes($payments, 2026));
|
||||||
|
self::assertSame(70, $calc->totalBaseMinutes($payments, 2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCapHours(): void
|
||||||
|
{
|
||||||
|
$calc = new OvertimePaidContingentCalculator();
|
||||||
|
|
||||||
|
self::assertSame(350, $calc->capHours(true));
|
||||||
|
self::assertSame(220, $calc->capHours(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyPaymentsYieldsZeros(): void
|
||||||
|
{
|
||||||
|
$calc = new OvertimePaidContingentCalculator();
|
||||||
|
$months = $calc->monthlyBaseMinutes([], 2026);
|
||||||
|
|
||||||
|
self::assertSame(0, $months[1]);
|
||||||
|
self::assertSame(0, $months[12]);
|
||||||
|
self::assertSame(0, array_sum($months));
|
||||||
|
self::assertSame(0, $calc->totalBaseMinutes([], 2026));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payment(
|
||||||
|
int $exerciseYear,
|
||||||
|
int $month,
|
||||||
|
int $base25,
|
||||||
|
int $base50,
|
||||||
|
int $bonus25 = 0,
|
||||||
|
int $bonus50 = 0,
|
||||||
|
): EmployeeRttPayment {
|
||||||
|
return new EmployeeRttPayment()
|
||||||
|
->setYear($exerciseYear)
|
||||||
|
->setMonth($month)
|
||||||
|
->setBase25Minutes($base25)
|
||||||
|
->setBase50Minutes($base50)
|
||||||
|
->setBonus25Minutes($bonus25)
|
||||||
|
->setBonus50Minutes($bonus50)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user