Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7c16778a | ||
| a8fe244b5c | |||
|
|
13c71abddc | ||
| 9581f9d8d9 | |||
| c2eaa06aff |
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.85'
|
||||
app.version: '0.1.87'
|
||||
|
||||
110
doc/holiday-virtual-hours.md
Normal file
110
doc/holiday-virtual-hours.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Crédit automatique des heures sur jour férié (Lun-Ven)
|
||||
|
||||
## Règle
|
||||
|
||||
Tout jour férié du **lundi au vendredi** crédite automatiquement les **heures contractuelles attendues** pour ce jour, pour tout contrat **autre que Forfait** (`trackingMode` ≠ `PRESENCE`). Les heures ainsi créditées sont dites *virtuelles* : aucune ligne n'est créée dans `work_hours`, elles sont injectées à l'affichage et au calcul.
|
||||
|
||||
### Référence contractuelle par jour
|
||||
|
||||
| Contrat | Lun-Jeu | Ven | Sam-Dim |
|
||||
|-----------------|---------|-------|---------|
|
||||
| 35h | 7h | 7h | 0 |
|
||||
| 39h | 8h | 7h | 0 |
|
||||
| CUSTOM (avec planning `workDaysHours`) | minutes du jour programmé, 0 sinon | idem | 0 |
|
||||
| INTERIM 35h | 7h | 7h | 0 |
|
||||
| FORFAIT | — | — | — |
|
||||
|
||||
La référence par jour est calculée par `App\Service\WorkHours\DailyReferenceMinutesResolver`.
|
||||
|
||||
### Planning `workDaysHours`
|
||||
|
||||
Tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h) doit déclarer un planning précis sur sa `EmployeeContractPeriod` : colonne JSON `work_days_hours = {"1": 120, "4": 120}` (iso day → minutes). La somme doit égaler `weeklyHours × 60`.
|
||||
|
||||
- **Sur un jour du planning** : crédit férié = minutes programmées (ex. Ewa Lun → 120 min).
|
||||
- **Sur un jour hors planning** : crédit férié = 0 (elle n'aurait pas travaillé).
|
||||
- Même logique appliquée par `WorkedHoursCreditPolicy::resolveContractDayMinutes` pour les crédits d'absence — un 4h en absence mardi (non programmée) = 0 crédit.
|
||||
|
||||
Validation à l'écriture : `EmployeeContractPeriodValidator::assertWorkDaysHours`. Le frontend expose un bloc « Jours travaillés » (cases Lun-Ven + input `HH:MM`) sur les formulaires de création employé + d'ajout de contrat, visible uniquement quand le contrat le requiert.
|
||||
|
||||
**Limitation actuelle** : l'édition in-place d'un schedule sur une période active existante n'est **pas exposée** via l'UI. Le drawer « Modifier le contrat » affiche le schedule en lecture seule à titre informatif. Pour corriger un schedule, la démarche est : clôturer le contrat en cours + créer un nouveau contrat avec le schedule corrigé. Si un besoin d'édition directe émerge, ajouter `workDaysHours` dans `EmployeeContractChangeRequest::hasPeriodChangeRequest()` et la logique d'update dans `EmployeeContractPeriodManager`.
|
||||
|
||||
### Fériés exclus
|
||||
|
||||
Les fériés listés dans l'env `EXCLUDED_PUBLIC_HOLIDAYS` (par défaut `Lundi de Pentecôte` — journée de solidarité) **ne donnent pas** de crédit virtuel : le `PublicHolidayService` les filtre en amont, donc `HolidayVirtualHoursResolver` ne les voit pas comme fériés.
|
||||
|
||||
### Interaction avec saisie
|
||||
|
||||
Quand l'employé saisit des heures ce jour-là :
|
||||
|
||||
- `heures finales = max(heures saisies + crédit d'absence éventuel, heures contractuelles de référence)`
|
||||
|
||||
Exemples avec un contrat 39h et un férié un lundi :
|
||||
|
||||
| Saisie employé | Total affiché | Interprétation |
|
||||
|------------------|---------------|----------------|
|
||||
| Aucune | 8h | Crédit 100% virtuel |
|
||||
| Matin 09:00-13:00 (4h) | 8h | Le minimum contractuel l'emporte |
|
||||
| 09:00-12:00 + 13:00-19:00 (9h) | 9h | Les heures saisies l'emportent |
|
||||
|
||||
### Interaction avec absences
|
||||
|
||||
La création d'absence sur un férié Lun-Ven est **autorisée** (bouton Modifier visible). Dès qu'une absence est déclarée sur le jour (matin et/ou après-midi), le crédit virtuel férié **est désactivé** pour ce jour : c'est `absence.type.countAsWorkedHours` qui pilote le crédit d'heures, via `WorkedHoursCreditPolicy`.
|
||||
|
||||
- `countAsWorkedHours = true` (ex. maladie payée) : crédit calculé normalement (7h/8h selon contrat × halfUnits/2). Même quantité que la référence virtuelle si journée complète, donc résultat identique — mais la source du crédit est l'absence, pas le férié.
|
||||
- `countAsWorkedHours = false` (ex. congé sans solde) : crédit = 0. Le férié ne compense pas.
|
||||
|
||||
Cette règle évite le double-crédit (absence + férié virtuel) et respecte le paramétrage fonctionnel du type d'absence.
|
||||
|
||||
## Impact technique
|
||||
|
||||
### Affichage
|
||||
|
||||
- **Écran Heures (vue jour)** : sur un férié Lun-Ven non-Forfait, la colonne Total affiche la valeur effective (référence ou saisie, selon max). Un chip "Férié : Xh comptées" apparaît sous le pill bleu du férié.
|
||||
- **Écran Heures Conducteurs (vue jour)** : idem, plus un indicateur `= Xh (férié)` sous l'input "Heures jour" pour signaler que le crédit est imputé au bucket jour.
|
||||
- **Vues semaine** : les totaux hebdomadaires intègrent les minutes virtuelles. Un marqueur `F + Xh` apparaît dans la cellule du jour férié.
|
||||
- **Onglet RTT** : les semaines contenant un férié Lun-Ven gagnent du temps crédité, ce qui peut générer des heures sup (25% / 50%) là où l'ancienne règle produisait un déficit.
|
||||
|
||||
### Calcul RTT
|
||||
|
||||
Le service `App\Service\WorkHours\HolidayVirtualHoursResolver` est injecté dans `RttRecoveryComputationService::computeRecoveryByWeek()`. Pour chaque jour ouvré :
|
||||
|
||||
```
|
||||
effectiveMinutes = resolveEffectiveDailyMinutes(contract, date, metrics.totalMinutes + credited)
|
||||
weeklyTotalMinutes += effectiveMinutes
|
||||
```
|
||||
|
||||
Le reste du calcul (tranches +25%, +50%, base 25% à partir de 35h/39h) demeure inchangé ; seul le total hebdo injecté a évolué.
|
||||
|
||||
### Calcul hebdomadaire d'affichage
|
||||
|
||||
`WorkHourWeeklySummaryProvider` applique la même substitution sur `weeklyDayMinutes` et `weeklyTotalMinutes`. Le DTO `WeeklyDaySummary` expose désormais un champ `virtualHolidayMinutes` utilisé par les vues semaine.
|
||||
|
||||
### Contexte jour
|
||||
|
||||
`WorkHourDayContextProvider` expose `virtualHolidayMinutes` dans `DayContextRow` pour permettre au frontend de calculer le total journalier en temps réel pendant la saisie (sans aller-retour).
|
||||
|
||||
### Frontend
|
||||
|
||||
Le composable `frontend/composables/useHolidayVirtualHours.ts` réplique la règle côté client et est consommé par `useHoursPage.ts::getRowMetrics` et `useDriverHoursPage.ts::getRowMetrics`.
|
||||
|
||||
## Impact historique
|
||||
|
||||
La règle est appliquée **à chaque lecture** depuis les `WorkHour` — donc l'exercice courant et tout exercice recalculé live bénéficient automatiquement de la nouvelle règle sans migration.
|
||||
|
||||
Les reports N-1 stockés dans `employee_rtt_balances.opening_*_minutes` ont été saisis manuellement par la RH (valeurs officielles) et ne sont **pas recalculés** : ces snapshots restent la source de vérité pour les soldes d'ouverture.
|
||||
|
||||
## Services impliqués
|
||||
|
||||
| Composant | Rôle |
|
||||
|-----------|------|
|
||||
| `DailyReferenceMinutesResolver` | Résolution "minutes contractuelles par jour" (logique partagée, anciennement dupliquée). |
|
||||
| `HolidayVirtualHoursResolver` | Décide si la règle s'applique et renvoie le crédit virtuel ou la valeur effective. |
|
||||
| `RttRecoveryComputationService` | Applique la substitution dans le calcul hebdo RTT. |
|
||||
| `WorkHourWeeklySummaryProvider` | Applique la substitution dans les totaux hebdo UI. |
|
||||
| `WorkHourDayContextProvider` | Expose `virtualHolidayMinutes` par salarié/jour. |
|
||||
| `useHolidayVirtualHours.ts` (frontend) | Réplique la règle en live côté client. |
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php` couvre les scénarios par contrat + jours ouvrés/chômés.
|
||||
- `make test` (PHPUnit) valide l'intégration RTT / hebdo / contexte jour.
|
||||
@@ -4,13 +4,13 @@
|
||||
<div class="absolute inset-0 bg-black/40" @click="close" />
|
||||
</Transition>
|
||||
<Transition name="drawer-panel">
|
||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
|
||||
<div class="flex items-center justify-between px-[20px] pt-8 pb-8">
|
||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
|
||||
<div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isHoliday"
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
@@ -91,6 +90,12 @@
|
||||
v-model="rows[employee.id].dayHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
<p
|
||||
v-if="isHoliday && getRowMetrics(employee.id).virtualHolidayMinutes > 0"
|
||||
class="mt-1 text-xs font-semibold text-sky-700"
|
||||
>
|
||||
= {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }} (férié)
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
@@ -194,7 +199,7 @@ const props = defineProps<{
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
|
||||
@@ -108,6 +108,13 @@
|
||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
|
||||
</div>
|
||||
|
||||
<WorkDaysHoursInput
|
||||
v-if="contractForm.workDaysHours"
|
||||
:model-value="contractForm.workDaysHours"
|
||||
:contract-weekly-hours="contractForm.weeklyHours ?? null"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
|
||||
Commentaire
|
||||
@@ -252,7 +259,13 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<WorkDaysHoursInput
|
||||
v-if="requiresCreateWorkDaysHours"
|
||||
v-model="createContractForm.workDaysHours"
|
||||
:contract-weekly-hours="selectedCreateContract?.weeklyHours ?? null"
|
||||
/>
|
||||
|
||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -269,6 +282,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Contract } from '~/services/dto/contract'
|
||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||
|
||||
type SuspensionForm = {
|
||||
id: number | null
|
||||
@@ -286,6 +300,7 @@ type ContractForm = {
|
||||
endDate: string
|
||||
paidLeaveSettled: boolean
|
||||
comment: string
|
||||
workDaysHours: Record<number, number> | null
|
||||
}
|
||||
|
||||
type CreateContractForm = {
|
||||
@@ -294,6 +309,7 @@ type CreateContractForm = {
|
||||
startDate: string
|
||||
endDate: string
|
||||
isDriver: boolean
|
||||
workDaysHours: Record<number, number> | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -322,6 +338,8 @@ const props = defineProps<{
|
||||
requiresCreateContractEndDate: boolean
|
||||
createContractEndDateFieldClass: string
|
||||
isCreateContractFormValid: boolean
|
||||
requiresCreateWorkDaysHours: boolean
|
||||
selectedCreateContract: Contract | null
|
||||
onOpenCloseContractDrawer: () => void
|
||||
onOpenCreateContractDrawer: () => void
|
||||
onUpdateContractDrawerOpen: (open: boolean) => void
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!hasRowFormation(employee.id) && !isHoliday"
|
||||
v-if="!hasRowFormation(employee.id)"
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
@@ -229,7 +229,7 @@ const props = defineProps<{
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
hasRowFormation: (employeeId: number) => boolean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ import { listContracts } from '~/services/contracts'
|
||||
import { updateEmployee } from '~/services/employees'
|
||||
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
|
||||
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
||||
|
||||
type SuspensionForm = {
|
||||
id: number | null
|
||||
@@ -32,7 +32,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
paidLeaveSettled: false,
|
||||
comment: ''
|
||||
comment: '',
|
||||
workDaysHours: null as Record<number, number> | null
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -44,7 +45,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
isDriver: false
|
||||
isDriver: false,
|
||||
workDaysHours: null as Record<number, number> | null
|
||||
})
|
||||
|
||||
const createValidationTouched = reactive({
|
||||
@@ -59,10 +61,11 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
||||
|
||||
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
||||
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
||||
return `${item.weeklyHours} heures`
|
||||
}
|
||||
return item.contractName ?? '-'
|
||||
const base = item.weeklyHours !== null && item.weeklyHours !== undefined
|
||||
? `${item.weeklyHours} heures`
|
||||
: (item.contractName ?? '-')
|
||||
const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
|
||||
return scheduleSummary ? `${base} (${scheduleSummary})` : base
|
||||
}
|
||||
|
||||
const currentActiveContractPeriod = computed(() => {
|
||||
@@ -111,11 +114,27 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
||||
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
||||
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
||||
const selectedCreateContract = computed<Contract | null>(() =>
|
||||
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
|
||||
)
|
||||
const requiresCreateWorkDaysHours = computed(() =>
|
||||
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
|
||||
)
|
||||
const createScheduleTotalMinutes = computed(() => {
|
||||
const raw = createContractForm.workDaysHours ?? {}
|
||||
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
})
|
||||
const isCreateScheduleValid = computed(() => {
|
||||
if (!requiresCreateWorkDaysHours.value) return true
|
||||
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
|
||||
return expected > 0 && createScheduleTotalMinutes.value === expected
|
||||
})
|
||||
const isCreateContractFormValid = computed(() =>
|
||||
isCreateContractValid.value &&
|
||||
isCreateContractNatureValid.value &&
|
||||
isCreateContractStartDateValid.value &&
|
||||
isCreateContractEndDateValid.value
|
||||
isCreateContractEndDateValid.value &&
|
||||
isCreateScheduleValid.value
|
||||
)
|
||||
|
||||
const baseInputClass =
|
||||
@@ -159,6 +178,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
contractForm.endDate = period.endDate ?? getTodayYmd()
|
||||
contractForm.paidLeaveSettled = false
|
||||
contractForm.comment = ''
|
||||
contractForm.workDaysHours = period.workDaysHours ?? null
|
||||
}
|
||||
|
||||
const openCloseContractDrawer = () => {
|
||||
@@ -186,6 +206,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
createContractForm.contractNature = 'CDI'
|
||||
createContractForm.endDate = ''
|
||||
createContractForm.isDriver = false
|
||||
createContractForm.workDaysHours = null
|
||||
createContractForm.startDate = editableContractPeriod.value?.endDate
|
||||
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
|
||||
: getTodayYmd()
|
||||
@@ -261,7 +282,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
contractNature: createContractForm.contractNature,
|
||||
contractStartDate: createContractForm.startDate,
|
||||
contractEndDate: createContractForm.endDate || null,
|
||||
isDriverInput: createContractForm.isDriver
|
||||
isDriverInput: createContractForm.isDriver,
|
||||
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null
|
||||
})
|
||||
isCreateContractDrawerOpen.value = false
|
||||
await reloadEmployee()
|
||||
@@ -319,6 +341,12 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
}
|
||||
})
|
||||
|
||||
watch(requiresCreateWorkDaysHours, (required) => {
|
||||
if (!required) {
|
||||
createContractForm.workDaysHours = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
contracts,
|
||||
contractHistory,
|
||||
@@ -342,6 +370,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
requiresCreateContractEndDate,
|
||||
createContractEndDateFieldClass,
|
||||
isCreateContractFormValid,
|
||||
requiresCreateWorkDaysHours,
|
||||
selectedCreateContract,
|
||||
contractNatureLabel,
|
||||
contractHistoryLabel,
|
||||
formatDate,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -205,6 +205,11 @@
|
||||
Chauffeur
|
||||
</label>
|
||||
</div>
|
||||
<WorkDaysHoursInput
|
||||
v-if="requiresSchedule"
|
||||
v-model="form.workDaysHours"
|
||||
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
|
||||
/>
|
||||
</template>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@@ -234,6 +239,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Contract} from '~/services/dto/contract'
|
||||
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||
import { requiresWorkDaysHours } from '~/utils/contract'
|
||||
import type {Employee} from '~/services/dto/employee'
|
||||
import type {Site} from '~/services/dto/site'
|
||||
import {listContracts} from '~/services/contracts'
|
||||
@@ -292,7 +299,8 @@ const form = reactive({
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
isDriver: false
|
||||
isDriver: false,
|
||||
workDaysHours: null as Record<number, number> | null
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -310,6 +318,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||
const isSiteValid = computed(() => form.siteId !== '')
|
||||
const isContractValid = computed(() => form.contractId !== '')
|
||||
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
|
||||
const selectedContract = computed<Contract | null>(() =>
|
||||
contracts.value.find((c) => c.id === Number(form.contractId)) ?? null
|
||||
)
|
||||
const requiresSchedule = computed(() =>
|
||||
!editingEmployee.value && requiresWorkDaysHours(selectedContract.value, form.contractNature)
|
||||
)
|
||||
const scheduleTotalMinutes = computed(() => {
|
||||
const raw = form.workDaysHours ?? {}
|
||||
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
})
|
||||
const isScheduleValid = computed(() => {
|
||||
if (!requiresSchedule.value) return true
|
||||
const expected = (selectedContract.value?.weeklyHours ?? 0) * 60
|
||||
return expected > 0 && scheduleTotalMinutes.value === expected
|
||||
})
|
||||
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
||||
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
|
||||
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
|
||||
@@ -327,7 +350,8 @@ const isFormValid = computed(
|
||||
: (isContractValid.value &&
|
||||
isContractNatureValid.value &&
|
||||
isContractStartDateValid.value &&
|
||||
isContractEndDateValid.value))
|
||||
isContractEndDateValid.value &&
|
||||
isScheduleValid.value))
|
||||
)
|
||||
|
||||
const showFirstNameError = computed(
|
||||
@@ -478,7 +502,8 @@ const handleSubmit = async () => {
|
||||
contractNature: form.contractNature,
|
||||
contractStartDate: form.contractStartDate,
|
||||
contractEndDate: form.contractEndDate || null,
|
||||
isDriverInput: form.isDriver
|
||||
isDriverInput: form.isDriver,
|
||||
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,6 +515,7 @@ const handleSubmit = async () => {
|
||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||
form.contractEndDate = ''
|
||||
form.isDriver = false
|
||||
form.workDaysHours = null
|
||||
editingEmployee.value = null
|
||||
isDrawerOpen.value = false
|
||||
await loadEmployees()
|
||||
@@ -516,6 +542,12 @@ watch(showsContractEndDateComputed, (shows) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(requiresSchedule, (required) => {
|
||||
if (!required) {
|
||||
form.workDaysHours = null
|
||||
}
|
||||
})
|
||||
|
||||
const openEdit = (employee: Employee) => {
|
||||
editingEmployee.value = employee
|
||||
form.firstName = employee.firstName
|
||||
@@ -534,6 +566,7 @@ const openCreate = () => {
|
||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||
form.contractEndDate = ''
|
||||
form.isDriver = false
|
||||
form.workDaysHours = null
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
<span class="text-left">Prénom</span>
|
||||
<span class="text-left">Contrat</span>
|
||||
<span class="text-right">CP N-1 restant</span>
|
||||
<span class="text-right">CP N</span>
|
||||
<span class="text-right">Samedis</span>
|
||||
<span class="text-right">CP N</span>
|
||||
<span class="text-right">RTT</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
@@ -58,8 +58,8 @@
|
||||
<span class="truncate">{{ row.firstName }}</span>
|
||||
<span class="truncate">{{ row.contractName ?? '-' }}</span>
|
||||
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.cpN }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.cpN }}</span>
|
||||
<span class="text-right tabular-nums">{{ row.rtt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ContractHistoryItem = {
|
||||
periodId?: number | null
|
||||
suspensions?: ContractSuspension[]
|
||||
isDriver?: boolean
|
||||
workDaysHours?: Record<number, number> | null
|
||||
}
|
||||
|
||||
export type Employee = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const createEmployee = async (payload: {
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string | null
|
||||
isDriverInput?: boolean
|
||||
workDaysHoursInput?: Record<number, number> | null
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<Employee>('/employees', {
|
||||
@@ -45,7 +46,8 @@ export const createEmployee = async (payload: {
|
||||
contractNature: payload.contractNature,
|
||||
contractStartDate: payload.contractStartDate,
|
||||
contractEndDate: payload.contractEndDate ?? null,
|
||||
isDriverInput: payload.isDriverInput ?? false
|
||||
isDriverInput: payload.isDriverInput ?? false,
|
||||
workDaysHoursInput: payload.workDaysHoursInput ?? null
|
||||
}, {
|
||||
toastSuccessKey: 'success.employee.create',
|
||||
toastErrorKey: 'errors.employee.create'
|
||||
@@ -66,6 +68,7 @@ export const updateEmployee = async (
|
||||
contractComment?: string | null
|
||||
displayOrder?: number
|
||||
isDriverInput?: boolean
|
||||
workDaysHoursInput?: Record<number, number> | null
|
||||
}
|
||||
) => {
|
||||
const api = useApi()
|
||||
@@ -97,6 +100,9 @@ export const updateEmployee = async (
|
||||
if (payload.isDriverInput !== undefined) {
|
||||
body.isDriverInput = payload.isDriverInput
|
||||
}
|
||||
if (payload.workDaysHoursInput !== undefined) {
|
||||
body.workDaysHoursInput = payload.workDaysHoursInput
|
||||
}
|
||||
|
||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||
toastSuccessKey: 'success.employee.update',
|
||||
|
||||
@@ -19,3 +19,43 @@ export const requiresContractEndDate = (nature: ContractNature) => {
|
||||
export const isContractNature = (value: string): value is ContractNature => {
|
||||
return (CONTRACT_NATURES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a contract + nature pair requires the per-day schedule (workDaysHours).
|
||||
* Mirrors EmployeeContractPeriodValidator::assertWorkDaysHours on the backend.
|
||||
*/
|
||||
export const requiresWorkDaysHours = (
|
||||
contract: { trackingMode?: string | null; weeklyHours?: number | null } | null | undefined,
|
||||
nature: ContractNature
|
||||
): boolean => {
|
||||
if (!contract) return false
|
||||
if (nature === 'INTERIM') return false
|
||||
if (contract.trackingMode === 'PRESENCE') return false
|
||||
if (contract.weeklyHours === 35 || contract.weeklyHours === 39) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
|
||||
|
||||
/**
|
||||
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
|
||||
* Returns null when the schedule is empty/unset.
|
||||
*/
|
||||
export const formatWorkDaysHoursSummary = (
|
||||
workDaysHours: Record<number, number> | null | undefined
|
||||
): string | null => {
|
||||
if (!workDaysHours) return null
|
||||
const entries = Object.entries(workDaysHours)
|
||||
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
|
||||
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
|
||||
.sort(([a], [b]) => a - b)
|
||||
if (entries.length === 0) return null
|
||||
return entries
|
||||
.map(([iso, minutes]) => {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
const suffix = m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
|
||||
return `${DAY_SHORT_LABELS[iso]} ${suffix}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
46
migrations/Version20260416100000.php
Normal file
46
migrations/Version20260416100000.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260416100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add work_days_hours JSON on employee_contract_periods (schedule for non-standard contracts) + seed Ewa and Nadia';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE employee_contract_periods ADD work_days_hours JSON DEFAULT NULL');
|
||||
|
||||
// Seed the two known 4h employees currently in production.
|
||||
// Ewa DALEMBA: Lundi 2h + Jeudi 2h
|
||||
// Nadia GARRAUD: Mardi 2h + Vendredi 2h
|
||||
// Filter on last_name + first_name (not ids) to stay safe across environments,
|
||||
// and only on periods without an already-set schedule to remain idempotent.
|
||||
$this->addSql(
|
||||
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"1\":120,\"4\":120}' "
|
||||
.'FROM employees e '
|
||||
.'WHERE ecp.employee_id = e.id '
|
||||
."AND e.last_name = 'DALEMBA' AND e.first_name = 'Ewa' "
|
||||
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
|
||||
);
|
||||
$this->addSql(
|
||||
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"2\":120,\"5\":120}' "
|
||||
.'FROM employees e '
|
||||
.'WHERE ecp.employee_id = e.id '
|
||||
."AND e.last_name = 'GARRAUD' AND e.first_name = 'Nadia' "
|
||||
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE employee_contract_periods DROP work_days_hours');
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ final class EmployeeLeaveRecap
|
||||
public ?string $siteName = null;
|
||||
public ?string $siteColor = null;
|
||||
public ?string $contractName = null;
|
||||
public int $contractSortKey = 99;
|
||||
public float $cpN1Remaining = 0.0;
|
||||
public string $cpN = '-';
|
||||
public string $acquiredSaturdays = '-';
|
||||
|
||||
805
src/Command/DumpVerificationSnapshotCommand.php
Normal file
805
src/Command/DumpVerificationSnapshotCommand.php
Normal file
@@ -0,0 +1,805 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Leave\LeaveRecapRowBuilder;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use App\State\EmployeeLeaveSummaryProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:verification:snapshot',
|
||||
description: 'Dump per-employee Markdown snapshot of RTT (monthly tab view) and leave balances, to serve as a regression baseline before business-rule refactors.'
|
||||
)]
|
||||
final class DumpVerificationSnapshotCommand extends Command
|
||||
{
|
||||
private const array MONTH_LABELS = [
|
||||
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
|
||||
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
|
||||
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EmployeeRepository $employeeRepository,
|
||||
private readonly EmployeeContractPeriodRepository $contractPeriodRepository,
|
||||
private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||
private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder,
|
||||
private readonly RttRecoveryComputationService $rttRecoveryService,
|
||||
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private readonly EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private readonly WorkHourRepository $workHourRepository,
|
||||
private readonly AbsenceRepository $absenceRepository,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument(
|
||||
'employee_ids',
|
||||
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
|
||||
'Employee IDs to snapshot (space-separated).'
|
||||
)
|
||||
->addOption(
|
||||
'output-dir',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Output directory (relative to project root, or absolute).',
|
||||
'docs/verifications'
|
||||
)
|
||||
->addOption(
|
||||
'rtt-year',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$ids = array_map('intval', $input->getArgument('employee_ids'));
|
||||
|
||||
$outputDirOpt = (string) $input->getOption('output-dir');
|
||||
$outputDir = str_starts_with($outputDirOpt, '/')
|
||||
? $outputDirOpt
|
||||
: $this->projectDir.'/'.$outputDirOpt;
|
||||
|
||||
if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) {
|
||||
$io->error('Could not create output directory: '.$outputDir);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
$rttYearOpt = $input->getOption('rtt-year');
|
||||
$rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt
|
||||
? (int) $rttYearOpt
|
||||
: $this->resolveCurrentRttExerciseYear($today);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$employee = $this->employeeRepository->find($id);
|
||||
if (!$employee instanceof Employee) {
|
||||
$io->warning(sprintf('Employee id=%d not found — skipped.', $id));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$markdown = $this->buildEmployeeDoc($employee, $rttYear, $today);
|
||||
$slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName());
|
||||
$filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug);
|
||||
file_put_contents($filename, $markdown);
|
||||
$io->success(sprintf('Wrote %s', $filename));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
||||
{
|
||||
$parts = [];
|
||||
$parts[] = $this->buildHeader($employee, $rttYear, $today);
|
||||
$parts[] = $this->buildProfileSection($employee);
|
||||
$parts[] = $this->buildLeaveSection($employee, $today);
|
||||
$parts[] = $this->buildRecapRowSection($employee, $today);
|
||||
$parts[] = $this->buildRttSection($employee, $rttYear, $today);
|
||||
|
||||
return implode("\n\n", $parts)."\n";
|
||||
}
|
||||
|
||||
private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
||||
{
|
||||
$rttFrom = sprintf('01/06/%d', $rttYear - 1);
|
||||
$rttTo = sprintf('31/05/%d', $rttYear);
|
||||
|
||||
return sprintf(
|
||||
"# Vérification RTT & Congés — %s %s (id=%d)\n\n"
|
||||
."Généré le %s. \n"
|
||||
."Exercice RTT de référence : **%d** (%s → %s). \n"
|
||||
."Pour les contrats Forfait, l'exercice de congés est l'année civile.",
|
||||
$employee->getFirstName(),
|
||||
$employee->getLastName(),
|
||||
(int) $employee->getId(),
|
||||
$today->format('Y-m-d'),
|
||||
$rttYear,
|
||||
$rttFrom,
|
||||
$rttTo
|
||||
);
|
||||
}
|
||||
|
||||
private function buildProfileSection(Employee $employee): string
|
||||
{
|
||||
$contract = $employee->getContract();
|
||||
$contractName = $contract?->getName() ?? '—';
|
||||
$tracking = $contract?->getTrackingMode() ?? '—';
|
||||
$weekly = $contract?->getWeeklyHours();
|
||||
$weeklyLabel = null === $weekly ? '—' : ($weekly.'h');
|
||||
$nature = $employee->getCurrentContractNature();
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '## 1. Profil';
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf('- **ID** : %d', (int) $employee->getId());
|
||||
$lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName());
|
||||
$lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel);
|
||||
$lines[] = sprintf('- **Nature** : %s', $nature);
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '### Périodes de contrat';
|
||||
$lines[] = '';
|
||||
$lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |';
|
||||
$lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|';
|
||||
|
||||
$periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']);
|
||||
foreach ($periods as $period) {
|
||||
$lines[] = sprintf(
|
||||
'| %s | %s | %s | %s | %s | %s | %s |',
|
||||
$period->getStartDate()->format('Y-m-d'),
|
||||
null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—',
|
||||
$period->getContract()?->getName() ?? '—',
|
||||
$period->getContractNature(),
|
||||
$period->getIsDriver() ? 'oui' : 'non',
|
||||
$period->isPaidLeaveSettled() ? 'oui' : 'non',
|
||||
str_replace("\n", ' ', (string) ($period->getComment() ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '## 2. Congés';
|
||||
$lines[] = '';
|
||||
|
||||
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
|
||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
||||
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
|
||||
|
||||
if (null === $yearSummary) {
|
||||
$lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
// Forfait: recompute with paid leave days if any.
|
||||
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
|
||||
if ($paidLeaveDays > 0.0) {
|
||||
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
|
||||
if (null !== $recomputed) {
|
||||
$yearSummary = $recomputed;
|
||||
}
|
||||
}
|
||||
|
||||
[$from, $to] = $isForfait
|
||||
? [
|
||||
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
|
||||
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
|
||||
]
|
||||
: [
|
||||
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
|
||||
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
|
||||
];
|
||||
|
||||
$lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']);
|
||||
$lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d'));
|
||||
$lines[] = '';
|
||||
$lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)';
|
||||
$lines[] = '';
|
||||
$lines[] = '| Indicateur | Valeur |';
|
||||
$lines[] = '|------------|--------|';
|
||||
$lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays']));
|
||||
$lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays']));
|
||||
$lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays']));
|
||||
$lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays']));
|
||||
$lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays']));
|
||||
$lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays']));
|
||||
$lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays']));
|
||||
if ($isForfait) {
|
||||
$lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays']));
|
||||
$lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays']));
|
||||
$lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays']));
|
||||
$lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays));
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '### 2.2 Absences de la période';
|
||||
$lines[] = '';
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||
if ([] === $absences) {
|
||||
$lines[] = '_Aucune absence sur la période._';
|
||||
} else {
|
||||
$lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |';
|
||||
$lines[] = '|-------|-----|------------|----------|------|-------------|';
|
||||
foreach ($absences as $absence) {
|
||||
$lines[] = sprintf(
|
||||
'| %s | %s | %s | %s | %s (%s) | %s |',
|
||||
$absence->getStartDate()->format('Y-m-d'),
|
||||
$absence->getEndDate()->format('Y-m-d'),
|
||||
$absence->getStartHalf()->value,
|
||||
$absence->getEndHalf()->value,
|
||||
$absence->getType()?->getCode() ?? '—',
|
||||
$absence->getType()?->getLabel() ?? '—',
|
||||
str_replace("\n", ' ', (string) ($absence->getComment() ?? ''))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '### 2.3 Jours de présence par mois (calcul provider)';
|
||||
$lines[] = '';
|
||||
$presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear);
|
||||
if ([] === $presenceDaysByMonth) {
|
||||
$lines[] = '_Aucun jour de présence sur la période._';
|
||||
} else {
|
||||
$lines[] = '| Mois | Jours de présence |';
|
||||
$lines[] = '|------|-------------------|';
|
||||
ksort($presenceDaysByMonth);
|
||||
foreach ($presenceDaysByMonth as $monthKey => $days) {
|
||||
$lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days));
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array
|
||||
{
|
||||
// The provider method is private; we re-invoke `provide()` via its public path by
|
||||
// calling computeYearSummary then reading $summary->presenceDaysByMonth.
|
||||
// But computeYearSummary doesn't populate that. So we call the provider publicly
|
||||
// through LeaveRecapRowBuilder? No — we just call the summary API resource directly
|
||||
// via a small helper below.
|
||||
//
|
||||
// Workaround: reuse the provider's provide() would require security; instead we
|
||||
// rebuild the map from WorkHour/absences here, mirroring the provider logic.
|
||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
||||
[$from, $to] = $isForfait
|
||||
? [
|
||||
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
|
||||
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
|
||||
]
|
||||
: [
|
||||
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
|
||||
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
|
||||
];
|
||||
|
||||
// Leave this aggregated figure available only for forfait (this is where the UI
|
||||
// shows it). For non-forfait we skip — the UI doesn't show presence per month.
|
||||
if (!$isForfait) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||
|
||||
$absenceDaysByMonth = [];
|
||||
foreach ($absences as $absence) {
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
if ((int) $day->format('N') >= 6) {
|
||||
continue;
|
||||
}
|
||||
$startDate = $absence->getStartDate()->format('Y-m-d');
|
||||
$endDate = $absence->getEndDate()->format('Y-m-d');
|
||||
$startHalf = $absence->getStartHalf()->value;
|
||||
$endHalf = $absence->getEndHalf()->value;
|
||||
$dateStr = $day->format('Y-m-d');
|
||||
$isStart = $dateStr === $startDate;
|
||||
$isEnd = $dateStr === $endDate;
|
||||
if ($startDate === $endDate) {
|
||||
$am = 'AM' === $startHalf;
|
||||
$pm = 'PM' === $endHalf;
|
||||
} elseif ($isStart) {
|
||||
$am = 'AM' === $startHalf;
|
||||
$pm = true;
|
||||
} elseif ($isEnd) {
|
||||
$am = true;
|
||||
$pm = 'PM' === $endHalf;
|
||||
} else {
|
||||
$am = true;
|
||||
$pm = true;
|
||||
}
|
||||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||
if ($dayAmount <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
$mk = $day->format('Y-m');
|
||||
$absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
while ($cursor <= $to) {
|
||||
$monthKey = $cursor->format('Y-m');
|
||||
$monthStart = $cursor < $from ? $from : $cursor;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$businessDays = 0;
|
||||
for ($day = $monthStart; $day <= $monthEnd; $day = $day->modify('+1 day')) {
|
||||
if ((int) $day->format('N') <= 5) {
|
||||
++$businessDays;
|
||||
}
|
||||
}
|
||||
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
|
||||
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
|
||||
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
|
||||
if ($presence > 0.0) {
|
||||
$result[$monthKey] = $presence;
|
||||
}
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string
|
||||
{
|
||||
$row = $this->leaveRecapRowBuilder->build($employee);
|
||||
$lines = [];
|
||||
|
||||
$lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)';
|
||||
$lines[] = '';
|
||||
$lines[] = '| CP N-1 restant | CP N | Samedis | RTT |';
|
||||
$lines[] = '|----------------|------|---------|-----|';
|
||||
$lines[] = sprintf(
|
||||
'| %s | %s | %s | %s |',
|
||||
(string) $row['cpN1Remaining'],
|
||||
$row['cpN'],
|
||||
$row['acquiredSaturdays'],
|
||||
$row['rtt']
|
||||
);
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '## 4. RTT — Onglet par mois';
|
||||
$lines[] = '';
|
||||
|
||||
$contract = $employee->getContract();
|
||||
$trackingMode = $contract?->getTrackingMode();
|
||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||
$lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._';
|
||||
$lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._';
|
||||
$lines[] = '';
|
||||
$lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear);
|
||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||
$weekRanges = array_map(
|
||||
static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']],
|
||||
$weeks
|
||||
);
|
||||
|
||||
$currentExerciseYear = $this->resolveCurrentRttExerciseYear($today);
|
||||
if ($rttYear > $currentExerciseYear) {
|
||||
$limitDate = $periodFrom->modify('-1 day');
|
||||
} else {
|
||||
$isoDay = (int) $today->format('N');
|
||||
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
|
||||
if (7 !== $isoDay) {
|
||||
$currentWeekStart = $today->modify('monday this week');
|
||||
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
||||
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
|
||||
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
||||
$limitDate = $currentWeekEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear);
|
||||
$weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo);
|
||||
$weekSummaries = $this->distributeDeficits($weekSummaries, $carry);
|
||||
|
||||
// Aggregate payments per month.
|
||||
$paymentsByMonth = [];
|
||||
foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) {
|
||||
$m = $payment->getMonth();
|
||||
if (!isset($paymentsByMonth[$m])) {
|
||||
$paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
||||
}
|
||||
$paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes();
|
||||
$paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes();
|
||||
$paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes();
|
||||
$paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||
}
|
||||
|
||||
$lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d'));
|
||||
$lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes));
|
||||
$lines[] = '';
|
||||
|
||||
// Iterate the 12 exercise months (June → May).
|
||||
$cumulativeCarry = [
|
||||
'base25' => $carry->base25Minutes,
|
||||
'bonus25' => $carry->bonus25Minutes,
|
||||
'base50' => $carry->base50Minutes,
|
||||
'bonus50' => $carry->bonus50Minutes,
|
||||
];
|
||||
|
||||
$monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5];
|
||||
foreach ($monthsInExercise as $i => $month) {
|
||||
$calYear = $month >= 6 ? $rttYear - 1 : $rttYear;
|
||||
$label = self::MONTH_LABELS[$month].' '.$calYear;
|
||||
|
||||
$lines[] = '### '.$label;
|
||||
$lines[] = '';
|
||||
$lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |';
|
||||
$lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|';
|
||||
|
||||
// Report line only on the first month (June).
|
||||
if (6 === $month) {
|
||||
$lines[] = sprintf(
|
||||
'| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |',
|
||||
$this->fmtMin($carry->base25Minutes),
|
||||
$this->fmtMin($carry->bonus25Minutes),
|
||||
$this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes),
|
||||
$this->fmtMin($carry->base50Minutes),
|
||||
$this->fmtMin($carry->bonus50Minutes),
|
||||
$this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes),
|
||||
$this->fmtMin($carry->totalMinutes),
|
||||
);
|
||||
}
|
||||
|
||||
$monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month));
|
||||
$totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0];
|
||||
|
||||
foreach ($monthWeeks as $w) {
|
||||
$lines[] = sprintf(
|
||||
'| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |',
|
||||
$w->weekNumber,
|
||||
$w->weekStart,
|
||||
$w->weekEnd,
|
||||
$this->fmtMin($w->overtimeMinutes),
|
||||
$this->fmtMin($w->base25Minutes),
|
||||
$this->fmtMin($w->bonus25Minutes),
|
||||
$this->fmtMin($w->base25Minutes + $w->bonus25Minutes),
|
||||
$this->fmtMin($w->base50Minutes),
|
||||
$this->fmtMin($w->bonus50Minutes),
|
||||
$this->fmtMin($w->base50Minutes + $w->bonus50Minutes),
|
||||
$this->fmtMin($w->totalMinutes),
|
||||
);
|
||||
$totals['over'] += $w->overtimeMinutes;
|
||||
$totals['b25'] += $w->base25Minutes;
|
||||
$totals['s25'] += $w->bonus25Minutes;
|
||||
$totals['b50'] += $w->base50Minutes;
|
||||
$totals['s50'] += $w->bonus50Minutes;
|
||||
$totals['total'] += $w->totalMinutes;
|
||||
}
|
||||
|
||||
if ([] === $monthWeeks && 6 !== $month) {
|
||||
$lines[] = '| _aucune semaine_ | | | | | | | | |';
|
||||
}
|
||||
|
||||
$lines[] = sprintf(
|
||||
'| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |',
|
||||
$this->fmtMin($totals['over']),
|
||||
$this->fmtMin($totals['b25']),
|
||||
$this->fmtMin($totals['s25']),
|
||||
$this->fmtMin($totals['b25'] + $totals['s25']),
|
||||
$this->fmtMin($totals['b50']),
|
||||
$this->fmtMin($totals['s50']),
|
||||
$this->fmtMin($totals['b50'] + $totals['s50']),
|
||||
$this->fmtMin($totals['total']),
|
||||
);
|
||||
|
||||
$p = $paymentsByMonth[$month] ?? null;
|
||||
$hasPayment = null !== $p;
|
||||
if ($hasPayment) {
|
||||
$lines[] = sprintf(
|
||||
'| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |',
|
||||
$this->fmtMin($p['base25']),
|
||||
$this->fmtMin($p['bonus25']),
|
||||
$this->fmtMin($p['base25'] + $p['bonus25']),
|
||||
$this->fmtMin($p['base50']),
|
||||
$this->fmtMin($p['bonus50']),
|
||||
$this->fmtMin($p['base50'] + $p['bonus50']),
|
||||
$this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']),
|
||||
);
|
||||
} else {
|
||||
$lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |';
|
||||
}
|
||||
|
||||
// Cumulative carry update — add month totals, subtract payments.
|
||||
$cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0);
|
||||
$cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0);
|
||||
$cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0);
|
||||
$cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0);
|
||||
|
||||
$cb25 = $cumulativeCarry['base25'];
|
||||
$cs25 = $cumulativeCarry['bonus25'];
|
||||
$cb50 = $cumulativeCarry['base50'];
|
||||
$cs50 = $cumulativeCarry['bonus50'];
|
||||
$cTotal = $cb25 + $cs25 + $cb50 + $cs50;
|
||||
$lines[] = sprintf(
|
||||
'| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |',
|
||||
$this->fmtMin($cb25),
|
||||
$this->fmtMin($cs25),
|
||||
$this->fmtMin($cb25 + $cs25),
|
||||
$this->fmtMin($cb50),
|
||||
$this->fmtMin($cs50),
|
||||
$this->fmtMin($cb50 + $cs50),
|
||||
$this->fmtMin($cTotal),
|
||||
);
|
||||
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// Final summary.
|
||||
$currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries));
|
||||
$totalPaid = 0;
|
||||
foreach ($paymentsByMonth as $p) {
|
||||
$totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50'];
|
||||
}
|
||||
$available = $carry->totalMinutes + $currentYearRecovery - $totalPaid;
|
||||
|
||||
$lines[] = '### Solde RTT total (fin de période calculée)';
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes));
|
||||
$lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery));
|
||||
$lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid));
|
||||
$lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available));
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors EmployeeRttSummaryProvider::buildWeekSummaries().
|
||||
*
|
||||
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
|
||||
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
|
||||
*
|
||||
* @return list<EmployeeRttWeekSummary>
|
||||
*/
|
||||
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($weekRanges as $week) {
|
||||
$weekStart = $week['start'];
|
||||
$weekEnd = $week['end'];
|
||||
$weekKey = $weekStart->format('Y-m-d');
|
||||
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
|
||||
|
||||
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
|
||||
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
|
||||
$startMonth = (int) $effectiveStart->format('n');
|
||||
$endMonth = (int) $effectiveEnd->format('n');
|
||||
|
||||
if ($startMonth === $endMonth) {
|
||||
$result[] = new EmployeeRttWeekSummary(
|
||||
month: $startMonth,
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $weekStart->format('Y-m-d'),
|
||||
weekEnd: $weekEnd->format('Y-m-d'),
|
||||
overtimeMinutes: $detail->overtimeMinutes,
|
||||
base25Minutes: $detail->base25Minutes,
|
||||
bonus25Minutes: $detail->bonus25Minutes,
|
||||
base50Minutes: $detail->base50Minutes,
|
||||
bonus50Minutes: $detail->bonus50Minutes,
|
||||
totalMinutes: $detail->totalMinutes,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$monthMinutes = [];
|
||||
$monthWeekdays = [];
|
||||
foreach ($detail->dailyMinutes as $date => $mins) {
|
||||
$m = (int) new DateTimeImmutable($date)->format('n');
|
||||
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
|
||||
if ((int) new DateTimeImmutable($date)->format('N') < 6) {
|
||||
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$totalWorked = array_sum($monthMinutes);
|
||||
$totalWeekdays = array_sum($monthWeekdays);
|
||||
|
||||
foreach ([$startMonth, $endMonth] as $m) {
|
||||
if ($totalWorked > 0) {
|
||||
$ratio = ($monthMinutes[$m] ?? 0) / $totalWorked;
|
||||
} elseif ($totalWeekdays > 0) {
|
||||
$ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays;
|
||||
} else {
|
||||
$ratio = 0.0;
|
||||
}
|
||||
$result[] = new EmployeeRttWeekSummary(
|
||||
month: $m,
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $weekStart->format('Y-m-d'),
|
||||
weekEnd: $weekEnd->format('Y-m-d'),
|
||||
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
|
||||
base25Minutes: (int) round($detail->base25Minutes * $ratio),
|
||||
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
|
||||
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
||||
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
||||
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide().
|
||||
*
|
||||
* @param list<EmployeeRttWeekSummary> $weeks
|
||||
*
|
||||
* @return list<EmployeeRttWeekSummary>
|
||||
*/
|
||||
private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array
|
||||
{
|
||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
||||
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
||||
|
||||
foreach ($weeks as $i => $week) {
|
||||
if ($week->totalMinutes >= 0) {
|
||||
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||
|
||||
continue;
|
||||
}
|
||||
$deficit = -$week->totalMinutes;
|
||||
$from50 = min($deficit, max(0, $cumulative50));
|
||||
$from25 = $deficit - $from50;
|
||||
$cumulative50 -= $from50;
|
||||
$cumulative25 -= $from25;
|
||||
$weeks[$i] = new EmployeeRttWeekSummary(
|
||||
month: $week->month,
|
||||
weekNumber: $week->weekNumber,
|
||||
weekStart: $week->weekStart,
|
||||
weekEnd: $week->weekEnd,
|
||||
overtimeMinutes: $week->overtimeMinutes,
|
||||
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
||||
bonus25Minutes: 0,
|
||||
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||
bonus50Minutes: 0,
|
||||
totalMinutes: $week->totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
return $weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{WeekRecoveryDetail, int}
|
||||
*/
|
||||
private function resolveCarry(Employee $employee, int $year): array
|
||||
{
|
||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||
if (null !== $balance) {
|
||||
return [
|
||||
new WeekRecoveryDetail(
|
||||
base25Minutes: $balance->getOpeningBase25Minutes(),
|
||||
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
|
||||
base50Minutes: $balance->getOpeningBase50Minutes(),
|
||||
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
|
||||
totalMinutes: $balance->getTotalOpeningMinutes(),
|
||||
),
|
||||
$balance->getMonth(),
|
||||
];
|
||||
}
|
||||
|
||||
return [$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5];
|
||||
}
|
||||
|
||||
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
||||
{
|
||||
foreach ($employee->getContractPeriods() as $period) {
|
||||
if ($period->getStartDate() > $today) {
|
||||
continue;
|
||||
}
|
||||
$endDate = $period->getEndDate();
|
||||
if (null === $endDate) {
|
||||
continue;
|
||||
}
|
||||
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
||||
return $endDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $weekEnd;
|
||||
}
|
||||
|
||||
private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int
|
||||
{
|
||||
$y = (int) $today->format('Y');
|
||||
$m = (int) $today->format('n');
|
||||
|
||||
return $m >= 6 ? $y + 1 : $y;
|
||||
}
|
||||
|
||||
private function fmtMin(int $minutes): string
|
||||
{
|
||||
if (0 === $minutes) {
|
||||
return '0h';
|
||||
}
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$abs = abs($minutes);
|
||||
$h = intdiv($abs, 60);
|
||||
$m = $abs % 60;
|
||||
|
||||
return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m);
|
||||
}
|
||||
|
||||
private function fmtDays(float $value): string
|
||||
{
|
||||
if (abs($value - round($value)) < 0.001) {
|
||||
return (string) (int) round($value);
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||
if (false === $ascii) {
|
||||
$ascii = $value;
|
||||
}
|
||||
$ascii = strtolower($ascii);
|
||||
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii;
|
||||
|
||||
return trim($ascii, '-');
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,10 @@ final class ContractHistoryItem
|
||||
public array $suspensions = [],
|
||||
#[Groups(['employee:read'])]
|
||||
public bool $isDriver = false,
|
||||
/**
|
||||
* @var null|array<int, int> iso-day → minutes
|
||||
*/
|
||||
#[Groups(['employee:read'])]
|
||||
public ?array $workDaysHours = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ final class WeeklyDaySummary
|
||||
public bool $hasLunch = false,
|
||||
public bool $hasDinner = false,
|
||||
public bool $hasOvernight = false,
|
||||
public int $virtualHolidayMinutes = 0,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ class Employee
|
||||
#[Groups(['employee:write'])]
|
||||
private ?bool $isDriverInput = null;
|
||||
|
||||
/**
|
||||
* @var null|array<int, int> iso-day → minutes, write-only (propagated to EmployeeContractPeriod)
|
||||
*/
|
||||
#[Groups(['employee:write'])]
|
||||
private ?array $workDaysHoursInput = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
@@ -261,6 +267,34 @@ class Employee
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array<int, int>
|
||||
*/
|
||||
public function getWorkDaysHoursInput(): ?array
|
||||
{
|
||||
return $this->workDaysHoursInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<int|string, mixed> $workDaysHoursInput
|
||||
*/
|
||||
public function setWorkDaysHoursInput(?array $workDaysHoursInput): self
|
||||
{
|
||||
if (null === $workDaysHoursInput) {
|
||||
$this->workDaysHoursInput = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($workDaysHoursInput as $key => $value) {
|
||||
$normalized[(int) $key] = (int) $value;
|
||||
}
|
||||
$this->workDaysHoursInput = $normalized;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['employee:read'])]
|
||||
public function getHasActiveContract(): bool
|
||||
{
|
||||
@@ -358,6 +392,7 @@ class Employee
|
||||
periodId: $period->getId(),
|
||||
suspensions: $suspensionData,
|
||||
isDriver: $period->getIsDriver(),
|
||||
workDaysHours: $period->getWorkDaysHours(),
|
||||
);
|
||||
},
|
||||
$periods
|
||||
|
||||
@@ -45,6 +45,16 @@ class EmployeeContractPeriod
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $paidLeaveSettled = false;
|
||||
|
||||
/**
|
||||
* Map ISO weekday (1=Mon..5=Fri) → minutes worked that day.
|
||||
* Required for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM)
|
||||
* so that férié credit and absence credit respect the actual schedule.
|
||||
*
|
||||
* @var null|array<int, int>
|
||||
*/
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $workDaysHours = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $comment = null;
|
||||
|
||||
@@ -176,6 +186,24 @@ class EmployeeContractPeriod
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array<int, int>
|
||||
*/
|
||||
public function getWorkDaysHours(): ?array
|
||||
{
|
||||
return $this->workDaysHours;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours
|
||||
*/
|
||||
public function setWorkDaysHours(?array $workDaysHours): self
|
||||
{
|
||||
$this->workDaysHours = $workDaysHours;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ContractSuspension>
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,9 @@ use DateTimeImmutable;
|
||||
|
||||
final readonly class EmployeeContractChangeRequest
|
||||
{
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours iso-day → minutes
|
||||
*/
|
||||
public function __construct(
|
||||
public ?ContractNature $contractNature,
|
||||
public ?DateTimeImmutable $contractStartDate,
|
||||
@@ -16,6 +19,7 @@ final readonly class EmployeeContractChangeRequest
|
||||
public ?bool $contractPaidLeaveSettled,
|
||||
public ?string $contractComment,
|
||||
public ?bool $isDriver = null,
|
||||
public ?array $workDaysHours = null,
|
||||
) {}
|
||||
|
||||
public function hasPeriodChangeRequest(): bool
|
||||
|
||||
@@ -20,6 +20,7 @@ final class EmployeeContractChangeRequestFactory
|
||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||
contractComment: $employee->getContractComment(),
|
||||
isDriver: $employee->getIsDriverInput(),
|
||||
workDaysHours: $employee->getWorkDaysHoursInput(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ use DateTimeImmutable;
|
||||
|
||||
final class EmployeeContractPeriodBuilder
|
||||
{
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours iso-day → minutes
|
||||
*/
|
||||
public function build(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
@@ -19,6 +22,7 @@ final class EmployeeContractPeriodBuilder
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): EmployeeContractPeriod {
|
||||
return new EmployeeContractPeriod()
|
||||
->setEmployee($employee)
|
||||
@@ -27,6 +31,7 @@ final class EmployeeContractPeriodBuilder
|
||||
->setEndDate($endDate)
|
||||
->setContractNature($nature)
|
||||
->setIsDriver($isDriver)
|
||||
->setWorkDaysHours($workDaysHours)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,17 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): void {
|
||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||
|
||||
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
||||
if (null !== $covered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
@@ -75,8 +77,10 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
||||
ContractNature $nature,
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): void {
|
||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||
|
||||
if (null !== $todayPeriod) {
|
||||
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
|
||||
@@ -86,10 +90,13 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
||||
}
|
||||
}
|
||||
|
||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours
|
||||
*/
|
||||
private function persistNewPeriod(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
@@ -97,8 +104,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): void {
|
||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
||||
$this->entityManager->persist($period);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ use DateTimeImmutable;
|
||||
|
||||
interface EmployeeContractPeriodManagerInterface
|
||||
{
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours iso-day → minutes
|
||||
*/
|
||||
public function ensureContractPeriodExists(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
@@ -19,6 +22,7 @@ interface EmployeeContractPeriodManagerInterface
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): void;
|
||||
|
||||
public function closeCurrentPeriod(
|
||||
@@ -29,6 +33,9 @@ interface EmployeeContractPeriodManagerInterface
|
||||
bool $isAlreadyEnded = false
|
||||
): void;
|
||||
|
||||
/**
|
||||
* @param null|array<int, int> $workDaysHours iso-day → minutes
|
||||
*/
|
||||
public function createNextPeriod(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
@@ -37,5 +44,6 @@ interface EmployeeContractPeriodManagerInterface
|
||||
ContractNature $nature,
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
bool $isDriver = false,
|
||||
?array $workDaysHours = null,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\TrackingMode;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
@@ -60,4 +62,63 @@ final class EmployeeContractPeriodValidator
|
||||
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the per-period work schedule (`workDaysHours`) against the contract.
|
||||
*
|
||||
* Mandatory for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM,
|
||||
* non-Forfait). Forbidden on standard/forfait/interim contracts (ambiguity).
|
||||
* When provided, sum of minutes MUST equal weeklyHours × 60.
|
||||
*
|
||||
* @param null|array<int, int> $workDaysHours
|
||||
*/
|
||||
public function assertWorkDaysHours(?Contract $contract, ContractNature $nature, ?array $workDaysHours): void
|
||||
{
|
||||
if (null === $contract) {
|
||||
return;
|
||||
}
|
||||
|
||||
$trackingMode = $contract->getTrackingMode();
|
||||
$weeklyHours = $contract->getWeeklyHours();
|
||||
$isStandard = 35 === $weeklyHours || 39 === $weeklyHours;
|
||||
$isForfait = TrackingMode::PRESENCE->value === $trackingMode;
|
||||
$isInterim = ContractNature::INTERIM === $nature;
|
||||
|
||||
if ($isForfait || $isInterim || $isStandard) {
|
||||
if (null !== $workDaysHours && [] !== $workDaysHours) {
|
||||
throw new UnprocessableEntityHttpException('workDaysHours must not be provided for Forfait, Interim or 35h/39h contracts.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $workDaysHours || [] === $workDaysHours) {
|
||||
throw new UnprocessableEntityHttpException('workDaysHours is required for non-standard contracts.');
|
||||
}
|
||||
|
||||
$totalMinutes = 0;
|
||||
foreach ($workDaysHours as $isoDay => $minutes) {
|
||||
if (!is_int($isoDay) && !(is_string($isoDay) && ctype_digit($isoDay))) {
|
||||
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri) as integers.');
|
||||
}
|
||||
$iso = (int) $isoDay;
|
||||
if ($iso < 1 || $iso > 5) {
|
||||
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri).');
|
||||
}
|
||||
|
||||
if (!is_int($minutes) || $minutes < 0) {
|
||||
throw new UnprocessableEntityHttpException('workDaysHours values must be non-negative integer minutes.');
|
||||
}
|
||||
$totalMinutes += $minutes;
|
||||
}
|
||||
|
||||
$expectedMinutes = ($weeklyHours ?? 0) * 60;
|
||||
if ($totalMinutes !== $expectedMinutes) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'workDaysHours total must equal contract weekly hours: got %d min, expected %d min.',
|
||||
$totalMinutes,
|
||||
$expectedMinutes
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,20 @@ readonly class EmployeeContractResolver
|
||||
return $period?->getContract();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array<int, int> workDaysHours (iso day → minutes) for the contract period active on $date
|
||||
*/
|
||||
public function resolveWorkDaysMinutesForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?array
|
||||
{
|
||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||
$raw = $period?->getWorkDaysHours();
|
||||
if (null === $raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeWorkDaysMinutes($raw);
|
||||
}
|
||||
|
||||
public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
|
||||
{
|
||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||
@@ -84,6 +98,57 @@ readonly class EmployeeContractResolver
|
||||
return $period?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
* @param list<string> $days
|
||||
*
|
||||
* @return array<int, array<string, null|array<int, int>>>
|
||||
*/
|
||||
public function resolveWorkDaysMinutesForEmployeesAndDays(array $employees, array $days): array
|
||||
{
|
||||
$resolved = [];
|
||||
if ([] === $employees || [] === $days) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
foreach ($days as $day) {
|
||||
$resolved[$employeeId][$day] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$from = new DateTimeImmutable(min($days));
|
||||
$to = new DateTimeImmutable(max($days));
|
||||
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
|
||||
foreach ($periods as $period) {
|
||||
$employeeId = $period->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = $period->getWorkDaysHours();
|
||||
if (null === $raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeWorkDaysMinutes($raw);
|
||||
$start = $period->getStartDate()->format('Y-m-d');
|
||||
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
|
||||
foreach ($days as $day) {
|
||||
if ($day < $start || $day > $end) {
|
||||
continue;
|
||||
}
|
||||
$resolved[$employeeId][$day] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
* @param list<string> $days
|
||||
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $raw
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeWorkDaysMinutes(array $raw): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($raw as $key => $value) {
|
||||
$iso = (int) $key;
|
||||
if ($iso < 1 || $iso > 5) {
|
||||
continue;
|
||||
}
|
||||
$result[$iso] = (int) $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ final readonly class LeaveBalanceComputationService
|
||||
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
||||
|
||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||
$totalBusinessDays = $this->countBusinessDays($from, $to);
|
||||
// Business days for forfait must use the RAW holiday list (excluded holidays
|
||||
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
|
||||
// days for the 218-day legal target).
|
||||
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||||
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||
@@ -406,6 +409,29 @@ final readonly class LeaveBalanceComputationService
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $absences
|
||||
*
|
||||
|
||||
@@ -78,12 +78,25 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function getHolidaysDayByYears(string $zone, string $years): array
|
||||
{
|
||||
return $this->applyExclusions($this->fetchHolidaysByYears($zone, $years));
|
||||
}
|
||||
|
||||
public function getRawHolidaysDayByYears(string $zone, string $years): array
|
||||
{
|
||||
return $this->fetchHolidaysByYears($zone, $years);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fetchHolidaysByYears(string $zone, string $years): array
|
||||
{
|
||||
$zone = strtolower(trim($zone));
|
||||
$years = trim($years);
|
||||
$key = "public_holidays_{$zone}_{$years}";
|
||||
|
||||
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
|
||||
return $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
|
||||
$item->expiresAfter(30 * 86400);
|
||||
$url = $this->holidayUrl."{$zone}/{$years}.json";
|
||||
|
||||
@@ -101,8 +114,6 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
||||
|
||||
return json_decode($response->getContent(), true);
|
||||
});
|
||||
|
||||
return $this->applyExclusions($holidays);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,4 +9,11 @@ interface PublicHolidayServiceInterface
|
||||
public function getHolidaysDay(string $zone): array;
|
||||
|
||||
public function getHolidaysDayByYears(string $zone, string $years): array;
|
||||
|
||||
/**
|
||||
* Same as getHolidaysDayByYears but WITHOUT the configured exclusions applied.
|
||||
* Used for legal/contractual computations (e.g. forfait 218 days) where excluded
|
||||
* holidays (journée de solidarité) must still count as non-working days.
|
||||
*/
|
||||
public function getRawHolidaysDayByYears(string $zone, string $years): array;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Service/WorkHours/DailyReferenceMinutesResolver.php
Normal file
47
src/Service/WorkHours/DailyReferenceMinutesResolver.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
final readonly class DailyReferenceMinutesResolver
|
||||
{
|
||||
/**
|
||||
* Returns the contractual expected minutes for a given weekday.
|
||||
*
|
||||
* - Saturday/Sunday: always 0
|
||||
* - If $workDaysMinutes is provided (per-employee schedule on `EmployeeContractPeriod`),
|
||||
* it takes precedence: returns the minutes for that iso day if scheduled, 0 otherwise.
|
||||
* - Else 35h: 7h every weekday
|
||||
* - Else 39h: 8h Mon-Thu, 7h Fri
|
||||
* - Else other positive values: weeklyHours/5 per weekday
|
||||
* - Else null/<=0 weeklyHours: 0
|
||||
*
|
||||
* @param int $isoWeekDay 1 = Monday ... 7 = Sunday
|
||||
* @param null|array<int, int> $workDaysMinutes iso-day → minutes (1=Mon, ..., 5=Fri)
|
||||
*/
|
||||
public function resolve(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
|
||||
{
|
||||
if ($isoWeekDay >= 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (null !== $workDaysMinutes) {
|
||||
return (int) ($workDaysMinutes[$isoWeekDay] ?? 0);
|
||||
}
|
||||
|
||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (39 === $weeklyHours) {
|
||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||
}
|
||||
|
||||
if (35 === $weeklyHours) {
|
||||
return 7 * 60;
|
||||
}
|
||||
|
||||
return (int) round(($weeklyHours * 60) / 5);
|
||||
}
|
||||
}
|
||||
116
src/Service/WorkHours/HolidayVirtualHoursResolver.php
Normal file
116
src/Service/WorkHours/HolidayVirtualHoursResolver.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Applies the business rule: a public holiday from Monday to Friday, for any
|
||||
* non-Forfait contract, credits the contractually expected daily hours.
|
||||
* If the employee has also entered hours that day, the effective total is the
|
||||
* max between entered minutes and the contractual reference.
|
||||
*/
|
||||
final readonly class HolidayVirtualHoursResolver
|
||||
{
|
||||
public function __construct(
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the effective daily minutes to count for RTT and weekly total
|
||||
* aggregation, applying the holiday credit when applicable.
|
||||
*
|
||||
* If an absence is declared on the day, the absence dictates the credit
|
||||
* (via WorkedHoursCreditPolicy) and the holiday virtual rule is bypassed —
|
||||
* $actualMinutes already includes the absence credit.
|
||||
*
|
||||
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
|
||||
*/
|
||||
public function resolveEffectiveDailyMinutes(
|
||||
?Contract $contract,
|
||||
DateTimeImmutable $date,
|
||||
int $actualMinutes,
|
||||
bool $hasAbsenceOnDate = false,
|
||||
?array $workDaysMinutes = null,
|
||||
): int {
|
||||
$reference = $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
|
||||
if (0 === $reference) {
|
||||
return $actualMinutes;
|
||||
}
|
||||
|
||||
return max($actualMinutes, $reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the virtual credit (reference minutes) alone — 0 if the rule
|
||||
* does not apply (weekend, non-holiday, Forfait contract, absence declared,
|
||||
* or employee schedule indicates a non-working day). Used by the frontend.
|
||||
*
|
||||
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
|
||||
*/
|
||||
public function resolveVirtualCredit(
|
||||
?Contract $contract,
|
||||
DateTimeImmutable $date,
|
||||
bool $hasAbsenceOnDate = false,
|
||||
?array $workDaysMinutes = null,
|
||||
): int {
|
||||
if ($hasAbsenceOnDate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$isoDay = (int) $date->format('N');
|
||||
if ($isoDay >= 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (null === $contract) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$this->isPublicHoliday($date)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->dailyReferenceResolver->resolve($contract->getWeeklyHours(), $isoDay, $workDaysMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper: resolves the schedule internally for a single employee/date.
|
||||
* Used by callers that have an Employee in hand (e.g. DayContext, LeaveRecap).
|
||||
*/
|
||||
public function resolveVirtualCreditForEmployee(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $date,
|
||||
bool $hasAbsenceOnDate = false,
|
||||
): int {
|
||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $date);
|
||||
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $date);
|
||||
|
||||
return $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
|
||||
}
|
||||
|
||||
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||
{
|
||||
try {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final readonly class WorkedHoursCreditPolicy
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -38,9 +39,11 @@ final readonly class WorkedHoursCreditPolicy
|
||||
return 0;
|
||||
}
|
||||
|
||||
$weekday = (int) $workDate->format('N');
|
||||
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
||||
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
|
||||
$weekday = (int) $workDate->format('N');
|
||||
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
|
||||
// Quand un planning est configuré sur la période (contrats non-standards),
|
||||
// il prime : jour non programmé = 0 crédit, sinon on utilise les minutes prévues.
|
||||
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday, $workDaysMinutes);
|
||||
if ($dayMinutes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -74,34 +77,14 @@ final readonly class WorkedHoursCreditPolicy
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||
/**
|
||||
* Single source of truth = {@see DailyReferenceMinutesResolver}. Weekend=0,
|
||||
* schedule precedence, 35h/39h fixed rules, fallback = weeklyHours/5.
|
||||
*
|
||||
* @param null|array<int, int> $workDaysMinutes planning iso-day → minutes (priorité absolue si fourni)
|
||||
*/
|
||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
|
||||
{
|
||||
// Week-end non travaillé dans cette politique.
|
||||
if ($isoWeekDay >= 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Règle fixe: 35h => 7h/jour.
|
||||
if (35 === $weeklyHours) {
|
||||
return 7 * 60;
|
||||
}
|
||||
|
||||
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
|
||||
if (39 === $weeklyHours) {
|
||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||
}
|
||||
|
||||
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
|
||||
if (4 === $weeklyHours) {
|
||||
return 2 * 60;
|
||||
}
|
||||
|
||||
// Contrat non renseigné/invalide: aucun crédit.
|
||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fallback générique: répartition homogène sur 5 jours ouvrés.
|
||||
return (int) round(($weeklyHours * 60) / 5);
|
||||
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay, $workDaysMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTime;
|
||||
@@ -24,7 +23,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
{
|
||||
@@ -33,7 +31,6 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private Security $security,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
@@ -167,15 +164,10 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
|
||||
}
|
||||
|
||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
|
||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
|
||||
$segments = [];
|
||||
foreach ($days as $day) {
|
||||
if (isset($publicHolidays[$day->format('Y-m-d')])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
||||
$isSame = $isFirst && $isLast;
|
||||
@@ -286,27 +278,4 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setIsValid(false)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeLeaveRecap;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ContractType;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Leave\LeaveRecapRowBuilder;
|
||||
@@ -63,6 +65,7 @@ final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
|
||||
$resource->siteName = $site?->getName();
|
||||
$resource->siteColor = $site?->getColor();
|
||||
$resource->contractName = $row['contractName'] ?? null;
|
||||
$resource->contractSortKey = $this->resolveContractSortKey($employee->getContract());
|
||||
$resource->cpN1Remaining = is_numeric($row['cpN1Remaining']) ? (float) $row['cpN1Remaining'] : 0.0;
|
||||
$resource->cpN = (string) $row['cpN'];
|
||||
$resource->acquiredSaturdays = (string) $row['acquiredSaturdays'];
|
||||
@@ -78,6 +81,10 @@ final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
|
||||
if (0 !== $siteCmp) {
|
||||
return $siteCmp;
|
||||
}
|
||||
$contractCmp = $a->contractSortKey <=> $b->contractSortKey;
|
||||
if (0 !== $contractCmp) {
|
||||
return $contractCmp;
|
||||
}
|
||||
$lastCmp = strcmp($a->lastName, $b->lastName);
|
||||
if (0 !== $lastCmp) {
|
||||
return $lastCmp;
|
||||
@@ -89,6 +96,30 @@ final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort order: FORFAIT → 39h → 35h → 25h → 4h → autres.
|
||||
*/
|
||||
private function resolveContractSortKey(?Contract $contract): int
|
||||
{
|
||||
if (null === $contract) {
|
||||
return 99;
|
||||
}
|
||||
|
||||
if (ContractType::FORFAIT === $contract->getType()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$weeklyHours = $contract->getWeeklyHours();
|
||||
|
||||
return match ($weeklyHours) {
|
||||
39 => 1,
|
||||
35 => 2,
|
||||
25 => 3,
|
||||
4 => 4,
|
||||
default => 99,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Employee>
|
||||
*/
|
||||
|
||||
@@ -561,7 +561,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
{
|
||||
$type = $employee->getContract()?->getType();
|
||||
if (ContractType::FORFAIT === $type) {
|
||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
|
||||
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
||||
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
||||
// the 218-day legal target).
|
||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
|
||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||
$weekdayHolidays = array_filter(
|
||||
array_keys($publicHolidays),
|
||||
@@ -655,6 +658,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\AbsenceType;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -20,7 +21,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
||||
{
|
||||
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
||||
{
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
||||
|
||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
||||
@@ -28,19 +29,52 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
||||
self::assertSame(210, $minutes);
|
||||
}
|
||||
|
||||
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
||||
public function testComputeCreditedMinutesFor4hContractUsesWorkDaysSchedule(): void
|
||||
{
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
||||
// 4h contract with schedule Mon 2h + Thu 2h
|
||||
$schedule = [1 => 120, 4 => 120];
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
||||
|
||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
||||
// 2026-02-16 is a Monday: full day absence credits 2h (matches scheduled Monday)
|
||||
self::assertSame(120, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
||||
}
|
||||
|
||||
self::assertSame(120, $minutes);
|
||||
public function testComputeCreditedMinutesFor4hContractOnUnscheduledDayReturnsZero(): void
|
||||
{
|
||||
// 4h contract with schedule Mon 2h + Thu 2h
|
||||
$schedule = [1 => 120, 4 => 120];
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(
|
||||
trackMode: Contract::TRACKING_TIME,
|
||||
weeklyHours: 4,
|
||||
countAsWorked: true,
|
||||
start: '2026-02-17',
|
||||
end: '2026-02-17',
|
||||
);
|
||||
|
||||
// 2026-02-17 is a Tuesday — not a scheduled workday → 0 credit
|
||||
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-17', true, true));
|
||||
}
|
||||
|
||||
public function testComputeCreditedMinutesHalfDayOnAsymmetricScheduleDay(): void
|
||||
{
|
||||
// Asymmetric schedule: Monday is a 3h day (180 min)
|
||||
$schedule = [1 => 180];
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 3, countAsWorked: true);
|
||||
|
||||
// 2026-02-16 Monday, morning only → round(180/2 * 1) = 90 min
|
||||
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', true, false));
|
||||
// Afternoon only → same 90 min (half of day)
|
||||
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', false, true));
|
||||
// Full day → 180 min
|
||||
self::assertSame(180, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
||||
}
|
||||
|
||||
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
||||
{
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
||||
|
||||
// Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent.
|
||||
@@ -50,15 +84,20 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
||||
|
||||
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
||||
{
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
|
||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
||||
|
||||
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
||||
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
|
||||
}
|
||||
|
||||
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
||||
{
|
||||
private function buildAbsence(
|
||||
string $trackMode,
|
||||
?int $weeklyHours,
|
||||
bool $countAsWorked,
|
||||
string $start = '2026-02-16',
|
||||
string $end = '2026-02-16',
|
||||
): Absence {
|
||||
$contract = new Contract()
|
||||
->setName('Contrat test')
|
||||
->setTrackingMode($trackMode)
|
||||
@@ -79,18 +118,25 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
||||
return new Absence()
|
||||
->setEmployee($employee)
|
||||
->setType($type)
|
||||
->setStartDate(new DateTime('2026-02-16'))
|
||||
->setEndDate(new DateTime('2026-02-16'))
|
||||
->setStartDate(new DateTime($start))
|
||||
->setEndDate(new DateTime($end))
|
||||
;
|
||||
}
|
||||
|
||||
private function buildResolverStub(): EmployeeContractResolver
|
||||
/**
|
||||
* @param null|array<int, int> $schedule
|
||||
*/
|
||||
private function buildResolverStub(?array $schedule = null): EmployeeContractResolver
|
||||
{
|
||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$resolver
|
||||
->method('resolveForEmployeeAndDate')
|
||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||
;
|
||||
$resolver
|
||||
->method('resolveWorkDaysMinutesForEmployeeAndDate')
|
||||
->willReturn($schedule)
|
||||
;
|
||||
|
||||
return $resolver;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user