feat : ajout de la gestion RTT

This commit is contained in:
2026-03-06 15:00:55 +01:00
parent 20a651895f
commit 4cf2608cdd
18 changed files with 1327 additions and 99 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(npx vue-tsc:*)",
"Bash(npx nuxi:*)",
"Bash(php:*)",
"Bash(docker compose:*)",
"Bash(make test:*)"
]
}
}

6
.idea/sqldialects.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
</component>
</project>

View File

@@ -2,8 +2,9 @@
Ce document centralise les règles métier actuellement implémentées dans l'application.
Document complementaire (rollover conges et checklist de lancement):
- `doc/leave-rollover.md`
Documents complementaires:
- `doc/leave-rollover.md` (rollover conges et checklist de lancement)
- `doc/rtt-rollover.md` (rollover RTT et checklist de lancement)
## 1) Utilisateurs et accès
@@ -196,6 +197,31 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu)
- exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile)
- hors périmètre phase 1: `INTERIM` (retour non supporté)
- onglet `RTT`:
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
- exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
- affichage:
- détail hebdomadaire (semaine ISO) regroupé par mois
- total mensuel des minutes de récupération
- compteur global exercice = `report N-1 + acquis N`
- attribution mensuelle des semaines:
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
- logique de calcul:
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
- compteur global:
- affiché en **jours** (1 jour = 7h = 420 minutes)
- report:
- le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent
- si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité
- sinon, le calcul dynamique sur l'exercice N-1 est effectué
- rollover automatique:
- commande: `php bin/console app:rtt:rollover`
- s'exécute le `1er juin` (même cron que le rollover congés)
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
- idempotent (ne recrée pas si la ligne existe)
## 10) Notifications

163
doc/rtt-rollover.md Normal file
View File

@@ -0,0 +1,163 @@
# Rollover RTT - Regles et Mise en Production
Document de reference pour expliquer le fonctionnement metier du report RTT N-1 et preparer le lancement en production.
## 1) Objectif
Permettre le report des heures supplementaires (RTT) d'un exercice a l'autre et fiabiliser les soldes.
Principe:
- le solde d'ouverture est stocke par exercice
- au changement d'exercice, on ouvre la nouvelle periode avec un "solde d'ouverture" (report N-1)
- au go-live, les soldes d'ouverture sont importes manuellement (CSV ou insertion SQL)
## 2) Exercice metier
- exercice RTT: du `1er juin` au `31 mai`
- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
- employes eligibles: tous sauf `INTERIM` et suivi `PRESENCE`
## 3) Logique de compteurs
- `report N-1`:
- correspond au solde d'ouverture (`opening_minutes`)
- source prioritaire: table `employee_rtt_balances`
- fallback: calcul dynamique de la somme des minutes de recuperation de l'exercice precedent
- `acquis N`:
- somme des minutes de recuperation hebdomadaires de l'exercice en cours
- calcul: `HS totales + bonus 25% + bonus 50%` par semaine
- `disponible`:
- `report N-1 + acquis N`
- affichage du compteur global: en **jours** (1 jour = 7h = 420 minutes)
## 4) Attribution mensuelle des semaines
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
## 5) Table cible
Table `employee_rtt_balances` (une ligne par employe et exercice):
- `employee_id`
- `year`
- `opening_minutes`
- `is_locked`
- `created_at`, `updated_at`
Contrainte unique:
- `(employee_id, year)`
Etat implementation:
- la table est creee
- le calcul de synthese RTT lit en priorite `opening_minutes` de cette table quand une ligne existe pour `(employee, year)`
- si aucune ligne n'existe, le calcul dynamique sur l'exercice N-1 est effectue
### Definition des colonnes
- `employee_id`:
- identifiant employe (FK vers `employees`)
- une ligne de solde par employe / exercice
- `year`:
- annee d'exercice (annee de fin)
- `2026` = 01/06/2025 -> 31/05/2026
- `opening_minutes`:
- report N-1 en minutes (solde d'ouverture)
- correspond a la somme des minutes de recuperation de l'exercice precedent
- `is_locked`:
- `false` sur exercice ouvert (recalcul possible)
- `true` apres validation RH (exercice fige)
- `created_at`, `updated_at`:
- trace technique creation / mise a jour
## 6) Rollover automatique
Commande quotidienne (cron) idempotente.
- commande Symfony: `php bin/console app:rtt:rollover`
- comportement date metier:
- le `01/06`: calcule et persiste le report pour chaque employe eligible
- les autres jours: sortie sans action
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
Date d'effet:
- au `1er juin` (meme date que le rollover conges non forfait)
Traitement par employe:
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
3. calculer la somme des minutes de recuperation de l'exercice N-1
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
## 7) Donnees a fournir au go-live
La RH doit fournir les soldes RTT a reporter.
Colonnes minimales:
- `employee_id` (id interne)
- `year`
- `opening_minutes` (total en minutes)
Format recommande:
- CSV UTF-8
- separateur `;`
Exemple:
```csv
employee_id;year;opening_minutes
42;2026;1260
17;2026;840
```
Equivalent en insertion SQL directe:
```sql
INSERT INTO employee_rtt_balances (employee_id, year, opening_minutes, is_locked, created_at, updated_at)
VALUES
(42, 2026, 1260, false, NOW(), NOW()),
(17, 2026, 840, false, NOW(), NOW());
```
Conversion rapide: `1260 minutes = 21h00 = 3.00 jours` (1 jour = 420 min = 7h)
## 8) Checklist mise en prod
1. Executer la migration (`employee_rtt_balances`)
2. Importer les soldes d'ouverture N-1 (CSV ou SQL)
3. Verifier 3 cas metier:
- CDI 39h avec heures supp sur l'exercice precedent
- CDI 35h sans heures supp (report = 0)
- INTERIM (doit etre ignore, pas de ligne creee)
4. Activer le cron de rollover
5. Geler (`is_locked`) les exercices historicises valides
Exemple cron (tous les jours a 02:15, juste apres le rollover conges):
Dev
```cron
15 2 * * * cd /var/www/html && php bin/console app:rtt:rollover --no-interaction >> var/log/rtt-rollover.log 2>&1
```
Prod
```cron
15 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction >> var/log/rtt-rollover.log 2>&1
```
Explication de la ligne cron:
- `15 2 * * *`: tous les jours a 02:15
- `php bin/console app:rtt:rollover --no-interaction`: execute le rollover sans confirmation
- hors `01/06`, la commande sort en no-op (normal)
- `>> var/log/rtt-rollover.log 2>&1`: log sortie standard et erreurs
Execution manuelle forcee:
```bash
php bin/console app:rtt:rollover --force --no-interaction
```
Exemple de verification rapide:
```bash
tail -n 50 /var/www/html/var/log/rtt-rollover.log
```
## 9) Points de vigilance
- Ne jamais modifier `opening_minutes` apres validation RH sans procedure explicite
- Garder une trace de toute correction manuelle (auteur, date, motif)
- Le calcul dynamique N-1 (fallback) parcourt toutes les heures de l'exercice precedent: preferer l'import explicite pour les exercices historiques
- La commande de rollover est idempotente: si une ligne existe deja, l'employe est ignore (pas d'ecrasement)

View File

@@ -1,56 +1,65 @@
<template>
<section class="mt-8">
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Acquis année :</strong> {{ formatCount(summary?.acquiredDays) }} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong> {{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span> {{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span> {{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Acquis fractionné :</span></p>
<p>{{ formatCount(summary?.fractionedDays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
</div>
</div>
<div class="mt-8 grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">{{ month.label }}</div>
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Acquis année :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
<div v-if="!day" class="h-6" />
<div
v-else
class="flex items-center justify-center"
>
<div
class="h-6 w-6"
:class="getDayClass(day.leave)"
:style="getDayStyle(day.leave)"
:title="getDayTitle(day.leave)"
>
{{ getDayText(day) }}
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Acquis fractionné :</span></p>
<p>{{ formatCount(summary?.fractionedDays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
</div>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
</div>
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
<div v-if="!day" class="h-6"/>
<div
v-else
class="flex items-center justify-center"
>
<div
class="h-6 w-6"
:class="getDayClass(day.leave)"
:style="getDayStyle(day.leave)"
:title="getDayTitle(day.leave)"
>
{{ getDayText(day) }}
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
</section>
</section>
</template>
<script setup lang="ts">
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import { normalizeDate, toYmd } from '~/utils/date'
import type {Absence} from '~/services/dto/absence'
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
import {normalizeDate, toYmd} from '~/utils/date'
type DayLeaveState = {
am: boolean
@@ -212,12 +221,12 @@ const getDayStyle = (leave: DayLeaveState | null) => {
const color = leave.hasOtherTypes ? '#dc2626' : '#222783'
const backgroundImage = leave.am
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)`
: `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)`
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)`
: `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)`
return {
backgroundImage,
backgroundColor: 'transparent'
backgroundImage,
backgroundColor: 'transparent'
}
}

View File

@@ -1,7 +1,122 @@
<template>
<section class="mt-8">
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Bloc RTT (à implémenter)
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="flex justify-center items-center bg-primary-500 rounded-md text-white py-5 text-[20px]">
<p><span class="font-semibold uppercase">RTT à la date du jour :</span> {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10 pb-4">
<div
v-for="month in months"
:key="month.month"
class="rounded-md bg-tertiary-500 text-primary-500"
>
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
{{ month.label }}
</div>
<div class="grid grid-cols-[60%_40%] text-[18px] border border-primary-500">
<template v-for="week in month.weeks" :key="week.key">
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>Semaine {{ week.weekNumber }}</span>
</div>
<div class="py-[6px] pl-3 border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
</div>
</template>
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée</div>
<div class="py-[6px] pl-3">0h</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
const props = defineProps<{
summary: EmployeeRttSummary | null
}>()
const monthLabels = [
'Janvier',
'Fevrier',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Aout',
'Septembre',
'Octobre',
'Novembre',
'Decembre'
] as const
const months = computed(() => {
type DisplayWeek = {
key: string
weekNumber: number
recoveryMinutes: number
isEmpty?: boolean
}
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
for (const month of orderedMonths) {
byMonth.set(month, {
month,
label: monthLabels[month - 1],
weeks: [],
totalMinutes: 0
})
}
for (const week of props.summary?.weeks ?? []) {
const month = byMonth.get(week.month)
if (!month) continue
month.weeks.push({
key: week.weekStart,
weekNumber: week.weekNumber,
recoveryMinutes: week.recoveryMinutes
})
month.totalMinutes += week.recoveryMinutes
}
return orderedMonths
.map((monthNumber) => byMonth.get(monthNumber)!)
.filter(Boolean)
.map((month) => {
const minRows = 5
const missing = Math.max(0, minRows - month.weeks.length)
for (let i = 0; i < missing; i += 1) {
month.weeks.push({
key: `empty-${month.month}-${i}`,
weekNumber: 0,
recoveryMinutes: 0,
isEmpty: true
})
}
return month
})
})
const formatMinutes = (minutes: number) => {
const abs = Math.abs(minutes)
const hours = Math.floor(abs / 60)
const rest = abs % 60
const sign = minutes < 0 ? '-' : ''
return `${sign}${hours}h${rest.toString().padStart(2, '0')}`
}
const formatDays = (minutes: number) => {
const days = minutes / 420
const sign = days < 0 ? '-' : ''
return `${sign}${Math.abs(days).toFixed(2)} j`
}
</script>

View File

@@ -1,11 +1,13 @@
import type { Contract } from '~/services/dto/contract'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary'
import { getEmployeeRttSummary } from '~/services/employee-rtt-summary'
import { getEmployee, updateEmployee } from '~/services/employees'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
@@ -19,6 +21,7 @@ export const useEmployeeDetailPage = () => {
const contracts = ref<Contract[]>([])
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
@@ -188,13 +191,14 @@ export const useEmployeeDetailPage = () => {
const leaveYear = isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
const from = isForfait
? `${leaveYear}-01-01`
: `${leaveYear - 1}-06-01`
const to = isForfait
? `${leaveYear}-12-31`
: `${leaveYear}-05-31`
const [absences, summary] = await Promise.all([
const [absences, summary, rtt] = await Promise.all([
listAbsences({
from,
to,
@@ -202,10 +206,12 @@ export const useEmployeeDetailPage = () => {
}),
showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null)
: Promise.resolve(null),
getEmployeeRttSummary(loadedEmployee.id, rttYear)
])
employeeAbsences.value = absences
leaveSummary.value = summary
rttSummary.value = rtt
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
@@ -302,6 +308,7 @@ export const useEmployeeDetailPage = () => {
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
showLeaveTab,
contractHistory,
employeeContractWorkLabel,

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="h-full overflow-hidden flex flex-col">
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
@@ -10,7 +10,7 @@
Employé introuvable.
</div>
<div v-else>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
<div class="text-right">
@@ -53,42 +53,49 @@
</button>
</div>
</div>
<EmployeesContractTab
v-if="activeTab === 'contract'"
:contract-history="contractHistory"
:contract-nature-label="contractNatureLabel"
:contract-history-label="contractHistoryLabel"
:format-date="formatDate"
:is-contract-submitting="isContractSubmitting"
:can-close-current-contract="canCloseCurrentContract"
:is-create-contract-submitting="isCreateContractSubmitting"
:contracts="contracts"
:can-create-contract="canCreateContract"
:is-contract-drawer-open="isContractDrawerOpen"
:contract-form="contractForm"
:readonly-field-class="readonlyFieldClass"
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
:contract-end-date-field-class="contractEndDateFieldClass"
:show-contract-end-date-error="showContractEndDateError"
:is-contract-end-date-valid="isContractEndDateValid"
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
:create-contract-form="createContractForm"
:create-contract-nature-field-class="createContractNatureFieldClass"
:create-contract-field-class="createContractFieldClass"
:create-contract-start-date-field-class="createContractStartDateFieldClass"
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
/>
<EmployeesLeaveTab v-else-if="showLeaveTab && activeTab === 'leave'" :absences="employeeAbsences" :summary="leaveSummary" />
<EmployeesRttTab v-else />
<div class="min-h-0 flex-1">
<EmployeesContractTab
v-if="activeTab === 'contract'"
class="h-full overflow-y-auto pr-1"
:contract-history="contractHistory"
:contract-nature-label="contractNatureLabel"
:contract-history-label="contractHistoryLabel"
:format-date="formatDate"
:is-contract-submitting="isContractSubmitting"
:can-close-current-contract="canCloseCurrentContract"
:is-create-contract-submitting="isCreateContractSubmitting"
:contracts="contracts"
:can-create-contract="canCreateContract"
:is-contract-drawer-open="isContractDrawerOpen"
:contract-form="contractForm"
:readonly-field-class="readonlyFieldClass"
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
:contract-end-date-field-class="contractEndDateFieldClass"
:show-contract-end-date-error="showContractEndDateError"
:is-contract-end-date-valid="isContractEndDateValid"
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
:create-contract-form="createContractForm"
:create-contract-nature-field-class="createContractNatureFieldClass"
:create-contract-field-class="createContractFieldClass"
:create-contract-start-date-field-class="createContractStartDateFieldClass"
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
/>
<EmployeesLeaveTab
v-else-if="showLeaveTab && activeTab === 'leave'"
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
/>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" />
</div>
</div>
</div>
</template>
@@ -101,6 +108,7 @@ const {
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
showLeaveTab,
contractHistory,
employeeContractWorkLabel,

View File

@@ -0,0 +1,16 @@
export type EmployeeRttWeekSummary = {
month: number
weekNumber: number
weekStart: string
weekEnd: string
recoveryMinutes: number
}
export type EmployeeRttSummary = {
year: number
carryFromPreviousYearMinutes: number
currentYearRecoveryMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
}

View File

@@ -0,0 +1,8 @@
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
const api = useApi()
const query = year ? { year } : {}
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260306120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_rtt_balances table for RTT opening balances.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_rtt_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, year INT NOT NULL, opening_minutes INT NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_rtt_balance ON employee_rtt_balances (employee_id, year)');
$this->addSql('CREATE INDEX idx_rtt_balance_employee_year ON employee_rtt_balances (employee_id, year)');
$this->addSql('ALTER TABLE employee_rtt_balances ADD CONSTRAINT FK_rtt_balance_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_rtt_balances');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\State\EmployeeRttSummaryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/employees/{id}/rtt-summary',
security: "is_granted('ROLE_USER')",
provider: EmployeeRttSummaryProvider::class
),
],
paginationEnabled: false
)]
final class EmployeeRttSummary
{
public int $year = 0;
public int $carryFromPreviousYearMinutes = 0;
public int $currentYearRecoveryMinutes = 0;
public int $availableMinutes = 0;
/** @var list<EmployeeRttWeekSummary> */
public array $weeks = [];
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:rtt:rollover',
description: 'Create yearly RTT opening balances (idempotent).'
)]
final class RttRolloverCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'force',
null,
InputOption::VALUE_NONE,
'Run rollover regardless of business date (manual recovery mode).'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
if (!$force && '06-01' !== $today->format('m-d')) {
$io->success('No RTT rollover today: business date is not 01/06.');
return Command::SUCCESS;
}
$targetYear = $this->resolveTargetYear($today);
$created = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
if (!$employee instanceof Employee) {
continue;
}
if (!$this->isEligible($employee)) {
++$skipped;
continue;
}
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
if (null !== $existing) {
++$skipped;
continue;
}
$previousYear = $targetYear - 1;
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningMinutes($carryMinutes)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
++$created;
}
$this->entityManager->flush();
$io->success(sprintf(
'RTT rollover done: %d created, %d skipped.',
$created,
$skipped
));
return Command::SUCCESS;
}
private function resolveTargetYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $month >= 6 ? $year + 1 : $year;
}
private function isEligible(Employee $employee): bool
{
$contract = $employee->getContract();
if (null === $contract) {
return false;
}
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
return false;
}
$type = ContractType::resolve(
$contract->getName(),
$contract->getTrackingMode(),
$contract->getWeeklyHours()
);
return ContractType::INTERIM !== $type;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class EmployeeRttWeekSummary
{
public function __construct(
public int $month,
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $recoveryMinutes,
) {}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EmployeeRttBalanceRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeRttBalanceRepository::class)]
#[ORM\Table(name: 'employee_rtt_balances', options: ['comment' => 'Soldes RTT par employe et exercice (report N-1).'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_rtt_balance', columns: ['employee_id', 'year'])]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_balance_employee_year')]
class EmployeeRttBalance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])]
private int $year = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
private int $openingMinutes = 0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
private bool $isLocked = false;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): self
{
$this->year = $year;
return $this;
}
public function getOpeningMinutes(): int
{
return $this->openingMinutes;
}
public function setOpeningMinutes(int $openingMinutes): self
{
$this->openingMinutes = $openingMinutes;
return $this;
}
public function isLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): self
{
$this->updatedAt = new DateTimeImmutable();
return $this;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttBalance>
*/
final class EmployeeRttBalanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeRttBalance::class);
}
public function findOneByEmployeeAndYear(Employee $employee, int $year): ?EmployeeRttBalance
{
return $this->createQueryBuilder('b')
->andWhere('b.employee = :employee')
->andWhere('b.year = :year')
->setParameter('employee', $employee)
->setParameter('year', $year)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
final readonly class RttRecoveryComputationService
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
) {}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
public function resolveExerciseBounds(int $exerciseYear): array
{
return [
new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)),
];
}
/**
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
*/
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$dayOfWeek = (int) $from->format('N');
$weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1));
$weeks = [];
while ($weekStart <= $to) {
$start = $weekStart;
$end = $start->modify('+6 days');
$effectiveStart = $start < $from ? $from : $start;
$effectiveEnd = $end > $to ? $to : $end;
if ($effectiveEnd >= $effectiveStart) {
$saturday = $start->modify('+5 days');
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
$weeks[] = [
'month' => (int) $monthAnchor->format('n'),
'weekNumber' => (int) $effectiveStart->format('W'),
'start' => $start,
'end' => $end,
];
}
$weekStart = $weekStart->modify('+7 days');
}
return $weeks;
}
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
return array_sum($byWeek);
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, int>
*/
public function computeRecoveryByWeek(
Employee $employee,
array $weeks,
DateTimeImmutable $periodFrom,
DateTimeImmutable $periodTo,
?DateTimeImmutable $limitDate
): array {
if ([] === $weeks) {
return [];
}
$days = [];
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$days[] = $cursor->format('Y-m-d');
}
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
$absences = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]);
$metricsByDate = [];
foreach ($workHours as $workHour) {
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByDate[$dateKey] = $this->computeMetrics($workHour);
}
$creditedByDate = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$date = $cursor->format('Y-m-d');
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
}
}
$results = [];
foreach ($weeks as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
if ($effectiveEnd < $effectiveStart) {
$results[$weekKey] = 0;
continue;
}
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
$results[$weekKey] = 0;
continue;
}
$weekDays = [];
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
$weekDays[] = $cursor->format('Y-m-d');
}
$weeklyTotalMinutes = 0;
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
continue;
}
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
}
if ([] === $weekDays) {
$results[$weekKey] = 0;
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
}
return $results;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
{
$total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
}
return $total;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
{
$total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
}
return $total;
}
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
{
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
return (int) round($trancheMinutes * 0.25);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),
$contract?->getWeeklyHours()
);
return ContractType::INTERIM === $type;
}
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);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeRttSummary;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Security\EmployeeScopeService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeRttSummaryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private EmployeeScopeService $employeeScopeService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private RttRecoveryComputationService $rttRecoveryService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$employeeId = (int) ($uriVariables['id'] ?? 0);
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('id must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
throw new AccessDeniedHttpException('Employee outside your scope.');
}
$year = $this->resolveYear();
$today = new DateTimeImmutable('today');
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$limitDate = null;
if ($year > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
$carryMinutes = $this->resolveCarryMinutes($employee, $year);
$summary = new EmployeeRttSummary();
$summary->year = $year;
$summary->carryFromPreviousYearMinutes = $carryMinutes;
$summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart);
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
$summary->weeks = array_map(
static fn (array $week) => new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0),
),
$weekRanges
);
return $summary;
}
private function resolveCarryMinutes(Employee $employee, int $year): int
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return $balance->getOpeningMinutes();
}
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
}
private function resolveYear(): int
{
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
if ('' === $raw) {
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
}
if (!preg_match('/^\d{4}$/', $raw)) {
throw new UnprocessableEntityHttpException('year must use YYYY format.');
}
$year = (int) $raw;
if ($year < 2000 || $year > 2100) {
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
}
return $year;
}
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $month >= 6 ? $year + 1 : $year;
}
}