diff --git a/CLAUDE.md b/CLAUDE.md
index 07d1184..abe8e70 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,6 +31,7 @@
- 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`
@@ -39,8 +40,9 @@
- 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 bloquée sur un férié
-- Saisie d'heures (ou de jours de présence) autorisée sur un férié — nécessaire pour éviter un déficit hebdomadaire (la référence hebdo n'est pas réduite par les fériés)
+- 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)
diff --git a/doc/holiday-virtual-hours.md b/doc/holiday-virtual-hours.md
new file mode 100644
index 0000000..7c03997
--- /dev/null
+++ b/doc/holiday-virtual-hours.md
@@ -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.
diff --git a/frontend/components/AppDrawer.vue b/frontend/components/AppDrawer.vue
index 375d052..01d9776 100644
--- a/frontend/components/AppDrawer.vue
+++ b/frontend/components/AppDrawer.vue
@@ -4,13 +4,13 @@
-
-
+
+
{{ title }}
-
diff --git a/frontend/components/driver-hours/DriverHoursDayView.vue b/frontend/components/driver-hours/DriverHoursDayView.vue
index 89989e3..2606cf6 100644
--- a/frontend/components/driver-hours/DriverHoursDayView.vue
+++ b/frontend/components/driver-hours/DriverHoursDayView.vue
@@ -76,7 +76,6 @@
+
+ = {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }} (férié)
+
void
onToggleValidationBulk: (checked: boolean) => Promise | void
onToggleSiteValidationBulk: (checked: boolean) => Promise | 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
diff --git a/frontend/components/employees/ContractTab.vue b/frontend/components/employees/ContractTab.vue
index 5651292..230bf7c 100644
--- a/frontend/components/employees/ContractTab.vue
+++ b/frontend/components/employees/ContractTab.vue
@@ -108,6 +108,13 @@
La date de fin est obligatoire.
+
+
Commentaire
@@ -252,7 +259,13 @@
-
+
+
+
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 | null
}
type CreateContractForm = {
@@ -294,6 +309,7 @@ type CreateContractForm = {
startDate: string
endDate: string
isDriver: boolean
+ workDaysHours: Record | 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
diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue
index 6895bca..df560eb 100644
--- a/frontend/components/hours/HoursDayView.vue
+++ b/frontend/components/hours/HoursDayView.vue
@@ -85,7 +85,7 @@
void
onToggleValidationBulk: (checked: boolean) => Promise | void
onToggleSiteValidationBulk: (checked: boolean) => Promise | 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
diff --git a/frontend/composables/useDriverHoursPage.ts b/frontend/composables/useDriverHoursPage.ts
index 3896080..c822005 100644
--- a/frontend/composables/useDriverHoursPage.ts
+++ b/frontend/composables/useDriverHoursPage.ts
@@ -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) => {
@@ -466,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
diff --git a/frontend/composables/useEmployeeContract.ts b/frontend/composables/useEmployeeContract.ts
index d3f5d4d..098539f 100644
--- a/frontend/composables/useEmployeeContract.ts
+++ b/frontend/composables/useEmployeeContract.ts
@@ -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, reloadEmploy
startDate: '',
endDate: '',
paidLeaveSettled: false,
- comment: ''
+ comment: '',
+ workDaysHours: null as Record | null
})
const validationTouched = reactive({
@@ -44,7 +45,8 @@ export const useEmployeeContract = (employee: Ref, reloadEmploy
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
- isDriver: false
+ isDriver: false,
+ workDaysHours: null as Record | null
})
const createValidationTouched = reactive({
@@ -59,10 +61,11 @@ export const useEmployeeContract = (employee: Ref, 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, reloadEmploy
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
+ const selectedCreateContract = computed(() =>
+ 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, 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, 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, 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, reloadEmploy
}
})
+ watch(requiresCreateWorkDaysHours, (required) => {
+ if (!required) {
+ createContractForm.workDaysHours = null
+ }
+ })
+
return {
contracts,
contractHistory,
@@ -342,6 +370,8 @@ export const useEmployeeContract = (employee: Ref, reloadEmploy
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
+ requiresCreateWorkDaysHours,
+ selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,
diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts
index 5dba279..610927d 100644
--- a/frontend/composables/useHoursPage.ts
+++ b/frontend/composables/useHoursPage.ts
@@ -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) => {
@@ -583,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
diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts
index 559b7ac..6124ed8 100644
--- a/frontend/data/documentation-content.ts
+++ b/frontend/data/documentation-content.ts
@@ -56,7 +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:00–21:00), heures de nuit (00:00–06:00 et 21:00–24: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) reste autorisée — elle est même nécessaire pour ne pas être en déficit sur la semaine concernée. La création d\'une absence sur un férié reste bloquée.' },
+ { 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.' },
],
},
{
diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue
index 7734302..663f2e0 100644
--- a/frontend/pages/employees/[id].vue
+++ b/frontend/pages/employees/[id].vue
@@ -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,
diff --git a/frontend/pages/employees/index.vue b/frontend/pages/employees/index.vue
index 12745d9..2b883e0 100644
--- a/frontend/pages/employees/index.vue
+++ b/frontend/pages/employees/index.vue
@@ -205,6 +205,11 @@
Chauffeur
+
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 | 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(() =>
+ 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
}
diff --git a/frontend/services/dto/employee.ts b/frontend/services/dto/employee.ts
index 084b95d..6ad156c 100644
--- a/frontend/services/dto/employee.ts
+++ b/frontend/services/dto/employee.ts
@@ -19,6 +19,7 @@ export type ContractHistoryItem = {
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
+ workDaysHours?: Record | null
}
export type Employee = {
diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts
index 65e9fce..c51dd0f 100644
--- a/frontend/services/dto/work-hour.ts
+++ b/frontend/services/dto/work-hour.ts
@@ -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 = {
diff --git a/frontend/services/employees.ts b/frontend/services/employees.ts
index 9caa35e..ad32ca5 100644
--- a/frontend/services/employees.ts
+++ b/frontend/services/employees.ts
@@ -35,6 +35,7 @@ export const createEmployee = async (payload: {
contractStartDate?: string
contractEndDate?: string | null
isDriverInput?: boolean
+ workDaysHoursInput?: Record | null
}) => {
const api = useApi()
return api.post('/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 | 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(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',
diff --git a/frontend/utils/contract.ts b/frontend/utils/contract.ts
index 60aa7de..95b5ced 100644
--- a/frontend/utils/contract.ts
+++ b/frontend/utils/contract.ts
@@ -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 = { 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 | 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(', ')
+}
diff --git a/migrations/Version20260416100000.php b/migrations/Version20260416100000.php
new file mode 100644
index 0000000..9db8897
--- /dev/null
+++ b/migrations/Version20260416100000.php
@@ -0,0 +1,46 @@
+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');
+ }
+}
diff --git a/src/Command/DumpVerificationSnapshotCommand.php b/src/Command/DumpVerificationSnapshotCommand.php
new file mode 100644
index 0000000..e6b6167
--- /dev/null
+++ b/src/Command/DumpVerificationSnapshotCommand.php
@@ -0,0 +1,805 @@
+ '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
+ */
+ 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 $weekRanges
+ * @param array $recoveryByWeek
+ *
+ * @return list
+ */
+ 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 $weeks
+ *
+ * @return list
+ */
+ 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, '-');
+ }
+}
diff --git a/src/Dto/Employees/ContractHistoryItem.php b/src/Dto/Employees/ContractHistoryItem.php
index 7e9a622..07062eb 100644
--- a/src/Dto/Employees/ContractHistoryItem.php
+++ b/src/Dto/Employees/ContractHistoryItem.php
@@ -29,5 +29,10 @@ final class ContractHistoryItem
public array $suspensions = [],
#[Groups(['employee:read'])]
public bool $isDriver = false,
+ /**
+ * @var null|array iso-day → minutes
+ */
+ #[Groups(['employee:read'])]
+ public ?array $workDaysHours = null,
) {}
}
diff --git a/src/Dto/WorkHours/DayContextRow.php b/src/Dto/WorkHours/DayContextRow.php
index 78b493c..c72ad26 100644
--- a/src/Dto/WorkHours/DayContextRow.php
+++ b/src/Dto/WorkHours/DayContextRow.php
@@ -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,
];
}
diff --git a/src/Dto/WorkHours/WeeklyDaySummary.php b/src/Dto/WorkHours/WeeklyDaySummary.php
index e4867df..85416db 100644
--- a/src/Dto/WorkHours/WeeklyDaySummary.php
+++ b/src/Dto/WorkHours/WeeklyDaySummary.php
@@ -21,5 +21,6 @@ final class WeeklyDaySummary
public bool $hasLunch = false,
public bool $hasDinner = false,
public bool $hasOvernight = false,
+ public int $virtualHolidayMinutes = 0,
) {}
}
diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php
index c512fb0..7e08e71 100644
--- a/src/Entity/Employee.php
+++ b/src/Entity/Employee.php
@@ -92,6 +92,12 @@ class Employee
#[Groups(['employee:write'])]
private ?bool $isDriverInput = null;
+ /**
+ * @var null|array 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
+ */
+ public function getWorkDaysHoursInput(): ?array
+ {
+ return $this->workDaysHoursInput;
+ }
+
+ /**
+ * @param null|array $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
diff --git a/src/Entity/EmployeeContractPeriod.php b/src/Entity/EmployeeContractPeriod.php
index 8ffe19f..941a47c 100644
--- a/src/Entity/EmployeeContractPeriod.php
+++ b/src/Entity/EmployeeContractPeriod.php
@@ -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
+ */
+ #[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
+ */
+ public function getWorkDaysHours(): ?array
+ {
+ return $this->workDaysHours;
+ }
+
+ /**
+ * @param null|array $workDaysHours
+ */
+ public function setWorkDaysHours(?array $workDaysHours): self
+ {
+ $this->workDaysHours = $workDaysHours;
+
+ return $this;
+ }
+
/**
* @return Collection
*/
diff --git a/src/Service/Contracts/EmployeeContractChangeRequest.php b/src/Service/Contracts/EmployeeContractChangeRequest.php
index c1ea397..fadf5c0 100644
--- a/src/Service/Contracts/EmployeeContractChangeRequest.php
+++ b/src/Service/Contracts/EmployeeContractChangeRequest.php
@@ -9,6 +9,9 @@ use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest
{
+ /**
+ * @param null|array $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
diff --git a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
index 02e17fa..7a3e733 100644
--- a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
+++ b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
@@ -20,6 +20,7 @@ final class EmployeeContractChangeRequestFactory
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(),
+ workDaysHours: $employee->getWorkDaysHoursInput(),
);
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodBuilder.php b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
index 5f75bfa..16e34b6 100644
--- a/src/Service/Contracts/EmployeeContractPeriodBuilder.php
+++ b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
@@ -12,6 +12,9 @@ use DateTimeImmutable;
final class EmployeeContractPeriodBuilder
{
+ /**
+ * @param null|array $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)
;
}
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManager.php b/src/Service/Contracts/EmployeeContractPeriodManager.php
index fec9e9a..473f669 100644
--- a/src/Service/Contracts/EmployeeContractPeriodManager.php
+++ b/src/Service/Contracts/EmployeeContractPeriodManager.php
@@ -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 $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);
}
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
index 9198f6f..8103ed5 100644
--- a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
+++ b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
@@ -12,6 +12,9 @@ use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface
{
+ /**
+ * @param null|array $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 $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;
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodValidator.php b/src/Service/Contracts/EmployeeContractPeriodValidator.php
index 94a675f..25f9b19 100644
--- a/src/Service/Contracts/EmployeeContractPeriodValidator.php
+++ b/src/Service/Contracts/EmployeeContractPeriodValidator.php
@@ -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 $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
+ ));
+ }
+ }
}
diff --git a/src/Service/Contracts/EmployeeContractResolver.php b/src/Service/Contracts/EmployeeContractResolver.php
index 53b7162..8d3fbc9 100644
--- a/src/Service/Contracts/EmployeeContractResolver.php
+++ b/src/Service/Contracts/EmployeeContractResolver.php
@@ -23,6 +23,20 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
+ /**
+ * @return null|array 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 $employees
+ * @param list $days
+ *
+ * @return array>>
+ */
+ 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 $employees
* @param list $days
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
return $resolved;
}
+
+ /**
+ * @param array $raw
+ *
+ * @return array
+ */
+ 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;
+ }
}
diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php
index ecf026c..37a56aa 100644
--- a/src/Service/Rtt/RttRecoveryComputationService.php
+++ b/src/Service/Rtt/RttRecoveryComputationService.php
@@ -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);
}
}
diff --git a/src/Service/WorkHours/DailyReferenceMinutesResolver.php b/src/Service/WorkHours/DailyReferenceMinutesResolver.php
new file mode 100644
index 0000000..7afca54
--- /dev/null
+++ b/src/Service/WorkHours/DailyReferenceMinutesResolver.php
@@ -0,0 +1,47 @@
+ $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);
+ }
+}
diff --git a/src/Service/WorkHours/HolidayVirtualHoursResolver.php b/src/Service/WorkHours/HolidayVirtualHoursResolver.php
new file mode 100644
index 0000000..f8b8e1a
--- /dev/null
+++ b/src/Service/WorkHours/HolidayVirtualHoursResolver.php
@@ -0,0 +1,116 @@
+ $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 $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')]);
+ }
+}
diff --git a/src/Service/WorkHours/WorkedHoursCreditPolicy.php b/src/Service/WorkHours/WorkedHoursCreditPolicy.php
index 69ea800..0065f2c 100644
--- a/src/Service/WorkHours/WorkedHoursCreditPolicy.php
+++ b/src/Service/WorkHours/WorkedHoursCreditPolicy.php
@@ -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 $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);
}
}
diff --git a/src/State/AbsenceWriteProcessor.php b/src/State/AbsenceWriteProcessor.php
index 8b45f83..c966e80 100644
--- a/src/State/AbsenceWriteProcessor.php
+++ b/src/State/AbsenceWriteProcessor.php
@@ -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
- */
- 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;
- }
}
diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php
index 0c7c56a..5ec3f42 100644
--- a/src/State/EmployeeWriteProcessor.php
+++ b/src/State/EmployeeWriteProcessor.php
@@ -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;
diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php
index b10bf01..a5bd0d0 100644
--- a/src/State/WorkHourDayContextProvider.php
+++ b/src/State/WorkHourDayContextProvider.php
@@ -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(
diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php
index dafb34a..ad97209 100644
--- a/src/State/WorkHourWeeklySummaryProvider.php
+++ b/src/State/WorkHourWeeklySummaryProvider.php
@@ -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);
}
}
diff --git a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
index bab2324..da27286 100644
--- a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
+++ b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
@@ -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 $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;
}
diff --git a/tests/State/AbsenceWriteProcessorTest.php b/tests/State/AbsenceWriteProcessorTest.php
index d4bc357..e509d02 100644
--- a/tests/State/AbsenceWriteProcessorTest.php
+++ b/tests/State/AbsenceWriteProcessorTest.php
@@ -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;
- }
}
diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php
index 6848cd7..6227b7c 100644
--- a/tests/State/WorkHourDayContextProviderTest.php
+++ b/tests/State/WorkHourDayContextProviderTest.php
@@ -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),
+ );
+ }
}
diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php
index a3a441d..327ada0 100644
--- a/tests/State/WorkHourWeeklySummaryProviderTest.php
+++ b/tests/State/WorkHourWeeklySummaryProviderTest.php
@@ -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);