diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..502e17c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npx vue-tsc:*)",
+ "Bash(npx nuxi:*)",
+ "Bash(php:*)",
+ "Bash(docker compose:*)",
+ "Bash(make test:*)",
+ "Bash(grep:*)"
+ ]
+ }
+}
diff --git a/.idea/SIRH.iml b/.idea/SIRH.iml
index c7476c0..47ac152 100644
--- a/.idea/SIRH.iml
+++ b/.idea/SIRH.iml
@@ -152,6 +152,8 @@
+
+
diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml
index 651867c..c621ddf 100644
--- a/.idea/db-forest-config.xml
+++ b/.idea/db-forest-config.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/php.xml b/.idea/php.xml
index 6df8250..c4734e1 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -153,6 +153,8 @@
+
+
diff --git a/AGENTS.md b/AGENTS.md
index 02f1a16..b91a212 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,13 @@ Arborescence clé:
- `tests/`: TU backend (PHPUnit)
- `frontend/`: app Nuxt (pages, composants, composables, services)
- `migrations/`: migrations Doctrine
+- `doc/`: documentation fonctionnelle et règles métier de référence
+
+## 1.1) Référentiel Fonctionnel (obligatoire)
+
+- Référence principale des règles métier: `doc/functional-rules.md`
+- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
+- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
## 2) Commandes utiles
diff --git a/composer.json b/composer.json
index bf8f442..c7ed2ef 100644
--- a/composer.json
+++ b/composer.json
@@ -87,6 +87,7 @@
}
},
"require-dev": {
+ "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.93",
"phpunit/phpunit": "^12.5"
}
diff --git a/composer.lock b/composer.lock
index 58cd62f..cb7c9e7 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "71d28cc0a29fa3f385b067186aa43678",
+ "content-hash": "b540b6cb25ef55c5eebccb57c76da584",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -8504,6 +8504,175 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
+ {
+ "name": "doctrine/data-fixtures",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/data-fixtures.git",
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
+ "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/persistence": "^3.1 || ^4.0",
+ "php": "^8.1",
+ "psr/log": "^1.1 || ^2 || ^3"
+ },
+ "conflict": {
+ "doctrine/dbal": "<3.5 || >=5",
+ "doctrine/orm": "<2.14 || >=4",
+ "doctrine/phpcr-odm": "<1.3.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^14",
+ "doctrine/dbal": "^3.5 || ^4",
+ "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
+ "doctrine/orm": "^2.14 || ^3",
+ "ext-sqlite3": "*",
+ "fig/log-test": "^1",
+ "phpstan/phpstan": "2.1.31",
+ "phpunit/phpunit": "10.5.45 || 12.4.0",
+ "symfony/cache": "^6.4 || ^7",
+ "symfony/var-exporter": "^6.4 || ^7"
+ },
+ "suggest": {
+ "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
+ "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
+ "doctrine/orm": "For loading ORM fixtures",
+ "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Common\\DataFixtures\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jonathan Wage",
+ "email": "jonwage@gmail.com"
+ }
+ ],
+ "description": "Data Fixtures for all Doctrine Object Managers",
+ "homepage": "https://www.doctrine-project.org",
+ "keywords": [
+ "database"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/data-fixtures/issues",
+ "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-10-17T20:06:20+00:00"
+ },
+ {
+ "name": "doctrine/doctrine-fixtures-bundle",
+ "version": "4.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
+ "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
+ "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/data-fixtures": "^2.2",
+ "doctrine/doctrine-bundle": "^2.2 || ^3.0",
+ "doctrine/orm": "^2.14.0 || ^3.0",
+ "doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
+ "php": "^8.1",
+ "psr/log": "^2 || ^3",
+ "symfony/config": "^6.4 || ^7.0 || ^8.0",
+ "symfony/console": "^6.4 || ^7.0 || ^8.0",
+ "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
+ "symfony/deprecation-contracts": "^2.1 || ^3",
+ "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
+ "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/dbal": "< 3"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "14.0.0",
+ "phpstan/phpstan": "2.1.11",
+ "phpunit/phpunit": "^10.5.38 || 11.4.14"
+ },
+ "type": "symfony-bundle",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Bundle\\FixturesBundle\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Doctrine Project",
+ "homepage": "https://www.doctrine-project.org"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony DoctrineFixturesBundle",
+ "homepage": "https://www.doctrine-project.org",
+ "keywords": [
+ "Fixture",
+ "persistence"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
+ "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-03T16:05:42+00:00"
+ },
{
"name": "evenement/evenement",
"version": "v3.0.2",
diff --git a/config/bundles.php b/config/bundles.php
index 746539c..1c5d047 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
+use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
+ DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
];
diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml
index 06b67a2..cf2b085 100644
--- a/config/packages/monolog.yaml
+++ b/config/packages/monolog.yaml
@@ -1,14 +1,19 @@
monolog:
- channels: [deprecation]
+ channels: [deprecation, cron]
when@dev:
monolog:
handlers:
+ cron:
+ type: stream
+ path: "%kernel.logs_dir%/cron.log"
+ level: info
+ channels: [cron]
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
- channels: ["!event"]
+ channels: ["!event", "!cron"]
console:
type: console
process_psr_3_messages: false
@@ -17,11 +22,16 @@ when@dev:
when@prod:
monolog:
handlers:
+ cron:
+ type: stream
+ path: "%kernel.logs_dir%/cron.log"
+ level: info
+ channels: [cron]
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
- channels: ["!deprecation"]
+ channels: ["!deprecation", "!cron"]
deprecation:
type: stream
channels: [deprecation]
diff --git a/config/services.yaml b/config/services.yaml
index 9a7408a..f1d8a11 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -23,8 +23,10 @@ services:
resource: '../src/'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
+ App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
+ App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
diff --git a/doc/functional-rules.md b/doc/functional-rules.md
new file mode 100644
index 0000000..ce18874
--- /dev/null
+++ b/doc/functional-rules.md
@@ -0,0 +1,255 @@
+# Règles Fonctionnelles SIRH
+
+Ce document centralise les règles métier actuellement implémentées dans l'application.
+
+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
+
+- `ROLE_ADMIN`
+ - accès complet aux écrans d'administration
+ - vue semaine des heures
+ - validation RH des lignes d'heures
+- `ROLE_SELF`
+ - accès limité à son périmètre personnel
+- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
+ - accès au périmètre des sites autorisés
+ - validation site des lignes d'heures
+
+## 2) Contrats
+
+- Le profil de temps de travail est porté par `Contract`:
+ - `trackingMode`: `TIME` ou `PRESENCE`
+ - `weeklyHours` (ex: 35, 39, 4, etc.)
+- La nature RH est portée par période employé:
+ - `CDI`, `CDD`, `INTERIM`
+- Historique des contrats employé:
+ - table `employee_contract_periods`
+ - un employé peut avoir plusieurs périodes
+
+### Règles de période
+
+- `CDI`:
+ - à la création d'une période: `endDate` doit être vide
+ - en clôture d'un contrat en cours: `endDate` peut être renseignée
+- `CDD` / `INTERIM`:
+ - `endDate` obligatoire
+- `endDate` ne peut pas être antérieure à `startDate`
+
+## 3) Heures (vue jour)
+
+- Saisie par salarié et par date:
+ - matin / après-midi / soir
+ - pour `PRESENCE`: demi-journées matin/après-midi
+- Sélecteur de temps:
+ - créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
+ - saisie libre possible mais valeur vidée au blur si hors options
+- Calculs affichés:
+ - `Jour`, `Nuit`, `Total`
+- Heures de nuit:
+ - fenêtres `00:00-06:00` et `21:00-24:00`
+- Date de modification (`updatedAt`):
+ - mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
+ - non mise à jour lors de modifications admin ou chef de site
+ - affichée sous le nom de l'employé (visible admin uniquement)
+
+## 4) Absences
+
+- Les absences sont stockées par jour (découpage lors de l'écriture)
+- Une absence peut être:
+ - journée complète
+ - demi-journée `AM` ou `PM`
+- Colonne absence (vue jour):
+ - affiche le libellé
+ - fond coloré selon le type d'absence
+- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
+ - demi-journée: dégradé diagonal
+ - journée complète: fond plein
+
+### Effet absence sur les heures
+
+- Absence `AM`:
+ - efface les heures du matin
+- Absence `PM`:
+ - efface les heures d'après-midi et du soir
+- Absence journée:
+ - efface toutes les plages horaires
+
+### Absences "comptées comme travaillées"
+
+- Si `countAsWorkedHours = true`:
+ - `TIME`: crédit de minutes selon contrat actif du jour
+ - `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)
+
+## 5) Validations des lignes d'heures
+
+- Validation RH (`isValid`)
+ - action admin
+- Validation site (`isSiteValid`)
+ - action chef de site
+
+### Verrouillage
+
+- Ligne validée RH:
+ - verrouillée pour modifications heures/absences
+- Ligne validée site:
+ - verrouillée pour non-admin
+ - admin peut corriger
+- Toute vraie modification d'une ligne:
+ - remet `isSiteValid = false`
+ - remet `isValid = false`
+- Si aucun changement réel à l'enregistrement:
+ - les validations existantes ne sont pas altérées
+
+## 6) Heures supplémentaires (vue semaine)
+
+- Base de calcul:
+ - dépend du contrat actif par jour
+- Tranche 25%:
+ - contrats <= 35h: de 35h à 43h
+ - contrats >= 39h: de 39h à 43h
+- Tranche 50%:
+ - au-delà de 43h
+- Nature `INTERIM`:
+ - pas de bonus 25%
+ - pas de bonus 50%
+ - pas de total récup
+
+## 7) Fériés
+
+- Les jours fériés sont identifiés et affichés
+- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
+- Règle courante:
+ - absences bloquées sur jour férié
+ - saisie d'heures autorisée
+
+## 8) Impression absences (PDF)
+
+Filtres disponibles:
+- période `from` / `to`
+- sites
+- nature de contrat (`CDI`, `CDD`, `INTERIM`)
+- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
+
+Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
+
+## 9) Employés
+
+- Création employé:
+ - prénom, nom, site
+ - type de contrat (nature RH)
+ - temps de travail
+ - dates début/fin (selon règles nature)
+- Modification employé:
+ - uniquement prénom, nom, site
+ - pas de modification de contrat depuis ce drawer
+- Détail employé:
+ - onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
+ - chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
+ - action `Clôturer`:
+ - bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
+ - ouvre un drawer en lecture seule (type/temps de travail/date de début)
+ - champs saisissables:
+ - `contractEndDate` (prérempli à aujourd'hui)
+ - `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
+ - backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
+ - action `Ajouter`:
+ - conserve le flux d'ajout d'un nouveau contrat via drawer dédié
+ - disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
+ - onglet `Congé`:
+ - endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY`
+ - phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`):
+ - exercice CP:
+ - `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
+ - `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile)
+ - contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`):
+ - acquis annuel CP: `25`
+ - acquis annuel samedi: `5`
+ - en cours d'acquisition jours: `25/12 = 2,08` jours/mois
+ - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
+ - samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
+ - contrat `4h`:
+ - acquis annuel CP: `10`
+ - acquis annuel samedi: `0`
+ - en cours d'acquisition: `0.83` jour/mois
+ - contrat `FORFAIT`:
+ - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
+ - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
+ - reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
+ - pas de samedi (`0`)
+ - pas de jours en cours d'acquisition (`0`)
+ - fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
+ - pour `CDI`/`CDD` non forfait:
+ - pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées
+ - samedi pris: absences `C` posées le samedi (demi-journée incluse)
+ - restants = acquis - pris (borné à 0)
+ - pour `FORFAIT`:
+ - pris: basé sur toutes les absences (demi-journées incluses)
+ - restants = acquis - pris (borné à 0)
+ - report annuel:
+ - le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
+ - pour `CDI`/`CDD` non forfait: report séparé jours + samedis
+ - pour `FORFAIT`: report uniquement sur les jours
+ - si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report
+ - si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0`
+ - si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice)
+ - lecture des compteurs:
+ - `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
+ - `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
+ - règle de consommation:
+ - les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
+ - la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
+ - `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
+ - date d'arret de calcul:
+ - 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)
+ - paiement RTT:
+ - saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
+ - stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
+ - `rate`: taux de majoration, valeurs `25` ou `50`
+ - les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
+ - affichage: 2 lignes par mois dans le tableau (25% et 50%)
+ - affichage:
+ - le compteur global RTT est affiché en **heures** (format `Xh00`)
+
+## 10) Notifications
+
+- Icône cloche en topbar:
+ - badge = nombre de notifications non lues
+ - ouverture panneau = liste des non lues
+ - fermeture panneau = marquage "lu" en masse
+
+### Règle métier de déclenchement
+
+- Les notifications de validation site ne sont pas envoyées ligne par ligne.
+- Une notification est créée uniquement quand un chef de site termine la validation complète:
+ - condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
+ - destinataires: utilisateurs `ROLE_ADMIN`
diff --git a/doc/leave-rollover.md b/doc/leave-rollover.md
new file mode 100644
index 0000000..3b26087
--- /dev/null
+++ b/doc/leave-rollover.md
@@ -0,0 +1,226 @@
+# Rollover Conges - Regles et Mise en Production
+
+Document de reference pour expliquer le fonctionnement metier du report N-1 et preparer le lancement en production.
+
+## 1) Objectif
+
+Eviter les recalculs "depuis le debut du contrat" et fiabiliser les soldes.
+
+Principe:
+- le solde est stocké par exercice
+- au changement d'exercice, on ouvre la nouvelle période avec un "solde d'ouverture" (report N-1)
+- un indicateur de cloture (`contractPaidLeaveSettled`) permet de couper la continuité entre 2 contrats
+
+## 2) Exercices metier
+
+- `CDI` / `CDD` non forfait:
+ - exercice: `1er juin` au `31 mai`
+ - `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
+- `FORFAIT`:
+ - exercice: `1er janvier` au `31 decembre`
+ - `year` = annee civile
+- `INTERIM`:
+ - hors perimetre conges
+
+## 3) Logique de compteurs
+
+- `acquis`:
+ - correspond au report N-1 (solde d'ouverture)
+- `en cours d'acquisition`:
+ - correspond aux droits generes sur l'exercice en cours
+- `pris`:
+ - non forfait: absences type `C` (conge)
+ - forfait: toutes absences
+- `restant`:
+ - `acquis + en_cours - pris` (borne a 0 dans l'affichage)
+
+## 4) Effet du "solde de tout compte"
+
+Le champ de cloture `contractPaidLeaveSettled` est saisi lors de la fermeture d'une periode contrat.
+
+- `false`:
+ - continuite des droits entre contrats
+- `true`:
+ - pas de reprise des droits precedents
+ - reset de continuite au lendemain de la date de cloture
+
+## 5) Table cible
+
+Table `employee_leave_balances` (une ligne par employe et exercice):
+- `employee_id`
+- `rule_code` (`CDI_CDD_NON_FORFAIT` ou `FORFAIT_218`)
+- `year`
+- `opening_days`
+- `opening_saturdays`
+- `accrued_days`
+- `accrued_saturdays` (optionnel selon implementation)
+- `taken_days`
+- `taken_saturdays`
+- `closing_days`
+- `closing_saturdays`
+- `is_locked`
+- `created_at`, `updated_at`
+
+Contrainte unique recommandee:
+- `(employee_id, rule_code, year)`
+
+Etat implementation:
+- la table est creee
+- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
+- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
+- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
+
+### Definition des colonnes
+
+- `employee_id`:
+ - identifiant employe (FK vers `employees`)
+ - une ligne de solde par employe / regle / exercice
+- `rule_code`:
+ - code de regle appliquee (`CDI_CDD_NON_FORFAIT`, `FORFAIT_218`)
+ - permet de savoir quelles regles de calcul sont utilisees
+- `year`:
+ - annee d'exercice
+ - non forfait: annee de fin d'exercice (`2026` = 01/06/2025 -> 31/05/2026)
+ - forfait: annee civile (`2026` = 01/01/2026 -> 31/12/2026)
+- `opening_days`:
+ - report N-1 en jours (solde d'ouverture)
+- `opening_saturdays`:
+ - report N-1 "samedis" (0 pour forfait)
+- `accrued_days`:
+ - droits generes sur l'exercice courant (N)
+- `accrued_saturdays`:
+ - droits samedis generes sur N (0 pour forfait)
+- `taken_days`:
+ - jours poses sur l'exercice
+- `taken_saturdays`:
+ - samedis poses sur l'exercice (0 pour forfait)
+- `closing_days`:
+ - solde de cloture jours (`opening_days + accrued_days - taken_days`)
+- `closing_saturdays`:
+ - solde de cloture samedis (`opening_saturdays + accrued_saturdays - taken_saturdays`)
+- `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:leave:rollover`
+- comportement date metier:
+ - le `01/01`: traite uniquement `FORFAIT_218`
+ - le `01/06`: traite uniquement `CDI_CDD_NON_FORFAIT`
+ - les autres jours: sortie sans action
+- option manuelle: `--force` pour executer hors date metier (reprise/correction)
+
+Date d'effet:
+- forfait: au `1er janvier`
+- non forfait: au `1er juin`
+
+Traitement par employe:
+1. lire l'exercice precedent
+2. determiner le report:
+ - si cloture `paidLeaveSettled=true` sur la periode precedente => report `0`
+ - sinon report = `closing` exercice precedent
+3. creer la ligne du nouvel exercice avec ce report en `opening_*`
+4. initialiser `accrued/taken/closing` pour le nouvel exercice
+
+## 7) Donnees a fournir au go-live
+
+La RH doit fournir un import d'ouverture:
+
+Colonnes minimales:
+- `employee_identifier` (id interne ou matricule)
+- `rule_code`
+- `year`
+- `opening_days`
+- `opening_saturdays` (0 pour forfait)
+- `source_date` (date de reference du relevé RH)
+- `comment` (optionnel)
+
+Format recommande:
+- CSV UTF-8
+- separateur `;`
+- decimales en point (`7.5`)
+
+Exemple:
+```csv
+employee_id;rule_code;year;opening_days;opening_saturdays;source_date;comment
+42;CDI_CDD_NON_FORFAIT;2026;12.5;2;2026-05-31;Reprise fichier RH
+17;FORFAIT_218;2026;8;0;2025-12-31;Reprise fichier RH
+```
+
+## 8) Checklist mise en prod
+
+1. Valider le mapping employe RH -> employe applicatif
+2. Importer les soldes d'ouverture N-1
+3. Verifier 5 cas metier:
+ - CDI simple sans changement de contrat
+ - CDD -> CDI avec `paidLeaveSettled=false`
+ - CDD -> CDI avec `paidLeaveSettled=true`
+ - Forfait sur annee complete
+ - Forfait avec debut en cours d'annee
+4. Activer le cron de rollover
+5. Geler (`is_locked`) les exercices historicises valides
+
+Exemple cron (tous les jours a 02:10):
+Dev
+```cron
+10 2 * * * cd /var/www/html && php bin/console app:leave:rollover --no-interaction 2>&1
+```
+Prod
+```cron
+10 2 * * * cd /var/www/sirh && php bin/console app:leave:rollover --no-interaction 2>&1
+```
+Explication de la ligne cron:
+- `10 2 * * *`: planification
+ - `10` = minute
+ - `2` = heure
+ - `*` = tous les jours du mois
+ - `*` = tous les mois
+ - `*` = tous les jours de la semaine
+- `cd /var/www/html`: se place dans le dossier de l application Symfony
+- `php bin/console app:leave:rollover --no-interaction`: execute le rollover sans demander de confirmation
+ - hors `01/01` et `01/06`, la commande sort en no-op (normal)
+- `>> var/log/leave-rollover.log`: ajoute la sortie standard dans le fichier de log (sans ecraser l historique)
+- `2>&1`: redirige aussi les erreurs dans le meme fichier de log
+
+Execution manuelle forcee:
+```bash
+php bin/console app:leave:rollover --force --no-interaction
+```
+
+Exemple de verification rapide:
+```bash
+tail -n 50 /var/www/html/var/log/leave-rollover.log
+```
+
+## 9) Points de vigilance
+
+- Ne jamais recalculer les soldes historiques apres validation RH sans procedure explicite
+- Garder une trace de toute correction manuelle (auteur, date, motif)
+- Aligner strictement les regles UI et API sur les memes compteurs (pas de formule differente front/back)
+
+## 10) Regle de consommation des droits
+
+Regle metier:
+- un employe peut poser des conges en cours d'acquisition
+- la consommation se fait par ordre:
+ 1. `acquis` (report N-1)
+ 2. `en cours d'acquisition` (droits N)
+
+Effet attendu:
+- si `acquis = 0` et `en cours = 7.5`, puis prise de `7`, alors:
+ - `acquis` reste `0`
+ - `en cours` devient `0.5`
+- si `acquis = 0` et `en cours = 2.5`, puis prise de `3`, alors:
+ - `acquis` reste `0`
+ - `en cours` devient `-0.5` (dette)
+ - le mois suivant, une acquisition de `2.5` ramené `en cours` a `2.0`
+
+Formule de lecture recommandée:
+- `restant_acquis = max(0, acquis - pris)`
+- `reste_a_imputer_sur_en_cours = max(0, pris - acquis)`
+- `restant_en_cours = en_cours - reste_a_imputer_sur_en_cours` (valeur negative autorisee)
diff --git a/doc/rtt-rollover.md b/doc/rtt-rollover.md
new file mode 100644
index 0000000..3c3792f
--- /dev/null
+++ b/doc/rtt-rollover.md
@@ -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 2>&1
+```
+Prod
+```cron
+10 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction 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)
diff --git a/frontend/components/AppTopNav.vue b/frontend/components/AppTopNav.vue
new file mode 100644
index 0000000..115d5f3
--- /dev/null
+++ b/frontend/components/AppTopNav.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+ {{ unreadCount }}
+
+
+
+
+
+ Notifications
+
+
+
+ Non lues
+
+
+ Historique
+
+
+
+ Chargement...
+
+
+ Aucune notification.
+
+
+
+
+
+
{{ notification.actorName }} {{ notification.message }}
+
{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}
+
+
+
+
+
+
+
+
+
+ {{ user?.username }}
+
+
+
+ Mon profil
+
+
+ Déconnexion
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/CalendarGrid.vue b/frontend/components/CalendarGrid.vue
index cef459e..fbd734f 100644
--- a/frontend/components/CalendarGrid.vue
+++ b/frontend/components/CalendarGrid.vue
@@ -11,12 +11,12 @@
v-for="day in daysInMonth"
:key="day.date"
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
- :class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
+ :class="isHoveredColumn(day.date) || day.date === today ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
>
{{ day.label }}
{{ day.weekday }}
@@ -91,6 +91,10 @@
diff --git a/frontend/components/SiteFilterSelector.vue b/frontend/components/SiteFilterSelector.vue
index 405e40b..4f5922a 100644
--- a/frontend/components/SiteFilterSelector.vue
+++ b/frontend/components/SiteFilterSelector.vue
@@ -1,25 +1,69 @@
-
-
-
-
{{ site.name }}
-
+
+
+ Sites
+
+ {{ selectedCount }}/{{ sites.length }}
+
+
+
+
+
diff --git a/frontend/components/employees/ContractTab.vue b/frontend/components/employees/ContractTab.vue
new file mode 100644
index 0000000..041c021
--- /dev/null
+++ b/frontend/components/employees/ContractTab.vue
@@ -0,0 +1,248 @@
+
+
+
+
+
Contrat
+
Heures
+
Date de début
+
Date de fin
+
+
+ Aucun historique de contrat.
+
+
+
+
{{ contractNatureLabel(item.contractNature) }}
+
{{ contractHistoryLabel(item) }}
+
{{ formatDate(item.startDate) }}
+
{{ formatDate(item.endDate) }}
+
+
+
+
+
+
+ Clôturer
+
+
+ + Ajouter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/employees/LeaveTab.vue b/frontend/components/employees/LeaveTab.vue
new file mode 100644
index 0000000..e85108e
--- /dev/null
+++ b/frontend/components/employees/LeaveTab.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
Année acquis : {{
+ formatCount(summary?.acquiredDays)
+ }} Jours
+
Reste à prendre :
+ {{ formatCount(summary?.remainingDays) }} Jours
+
+
+
Samedi acquis :
+ {{ formatCount(summary?.acquiredSaturdays) }} Jours
+
Reste à prendre :
+ {{ formatCount(summary?.remainingSaturdays) }} Jours
+
+
+
Fractionné acquis : {{ formatCount(summary?.fractionedDays) }} Jours
+
+ {{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}
+
+
+
En cours d'acquisition :
+
{{ formatCount(summary?.accruingDays) }} Jours
+
+
+
+
+
+
+ {{ month.label }}
+
+
+
+
+
+
+
+ {{ getDayText(day) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/employees/RttTab.vue b/frontend/components/employees/RttTab.vue
new file mode 100644
index 0000000..28137b7
--- /dev/null
+++ b/frontend/components/employees/RttTab.vue
@@ -0,0 +1,220 @@
+
+
+
+
RTT à la date du jour : {{ formatMinutes(summary?.availableMinutes ?? 0) }}
+
+ + Payer les RTT
+
+
+
+
+
+
+ {{ month.label }}
+
+
+
+
+
+ Semaine {{ week.weekNumber }}
+
+
+
+ {{ formatMinutes(week.recoveryMinutes) }}
+
+
+
Total
+
{{ formatMinutes(month.totalMinutes) }}
+
Heure payée 25%
+
+
{{ formatMinutes(getMonthPaid25(month.month)) }}
+
+
+
+
+
Heure payée 50%
+
+
{{ formatMinutes(getMonthPaid50(month.month)) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue
index 384e49f..bc0e6bf 100644
--- a/frontend/components/hours/HoursDayView.vue
+++ b/frontend/components/hours/HoursDayView.vue
@@ -1,253 +1,267 @@
-
-
-
-
Nom
-
Absence
-
Début matin
-
Fin matin
-
Début après-midi
-
Fin après-midi
-
Début soir
-
Fin soir
-
Jour
-
Nuit
-
Total
-
+
+
-
-
-
- {{ employee.firstName }} {{ employee.lastName }}
- ({{ contractLabel(employee) }})
-
-
- {{ employee.site?.name ?? 'Sans site' }}
-
+
+
+ {{ employee.firstName }} {{ employee.lastName }}
+ ({{ contractLabel(employee) }})
+
+
+ {{ employee.site?.name ?? 'Sans site' }}
+
-
+
+
+ Modifié le {{ getRowUpdatedAt(employee.id) }}
+
+
+
+
+ {{ getRowAbsenceLabel(employee.id) || '—' }}
+
+
+ Modifier
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{
+ formatMinutes(getRowMetrics(employee.id).dayMinutes)
+ }}
+
+
+
+
{{
+ formatMinutes(getRowMetrics(employee.id).nightMinutes)
+ }}
+
+
+
+
{{
+ formatMinutes(getRowMetrics(employee.id).totalMinutes)
+ }}
+
+
{{ getPresenceDayValue(employee.id) }}
+
+
+
+
+
+
+ Validé
+ -
+
+
+ Validé
+ -
+
+
-
-
- {{ getRowAbsenceLabel(employee.id) || '—' }}
-
-
- Modifier
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}
-
-
-
{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}
-
-
-
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
-
{{ getPresenceDayValue(employee.id) }}
-
-
-
-
-
-
- Validé
- -
-
-
- Validé
- -
-
-
-
diff --git a/frontend/components/hours/HoursToolbar.vue b/frontend/components/hours/HoursToolbar.vue
index f833eb6..9b0fd29 100644
--- a/frontend/components/hours/HoursToolbar.vue
+++ b/frontend/components/hours/HoursToolbar.vue
@@ -1,6 +1,11 @@
-
+
-
-
-
-
{
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
- if (normalized === null) {
- inputValue.value = props.modelValue
+ if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
+ emit('update:modelValue', '')
+ inputValue.value = ''
closeMenu()
return
}
diff --git a/frontend/composables/useEmployeeDetailPage.ts b/frontend/composables/useEmployeeDetailPage.ts
new file mode 100644
index 0000000..83fbe2f
--- /dev/null
+++ b/frontend/composables/useEmployeeDetailPage.ts
@@ -0,0 +1,371 @@
+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, updateFractionedDays } from '~/services/employee-leave-summary'
+import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
+import { getEmployee, updateEmployee } from '~/services/employees'
+import { listPublicHolidays } from '~/services/public-holidays'
+import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
+import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
+
+export const useEmployeeDetailPage = () => {
+ const route = useRoute()
+ const toast = useToast()
+ const employee = ref
(null)
+ const isLoading = ref(false)
+ const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
+ const contracts = ref([])
+ const employeeAbsences = ref([])
+ const leaveSummary = ref(null)
+ const rttSummary = ref(null)
+ const publicHolidays = ref>({})
+ const isContractDrawerOpen = ref(false)
+ const isContractSubmitting = ref(false)
+ const isCreateContractDrawerOpen = ref(false)
+ const isCreateContractSubmitting = ref(false)
+
+ const contractForm = reactive({
+ contractId: '' as number | '',
+ contractName: '',
+ weeklyHours: null as number | null,
+ contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
+ startDate: '',
+ endDate: '',
+ paidLeaveSettled: false,
+ comment: ''
+ })
+
+ const validationTouched = reactive({
+ endDate: false
+ })
+
+ const createContractForm = reactive({
+ contractId: '' as number | '',
+ contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
+ startDate: '',
+ endDate: ''
+ })
+
+ const createValidationTouched = reactive({
+ contractId: false,
+ contractNature: false,
+ startDate: false,
+ endDate: false
+ })
+
+ const contractHistory = computed(() => employee.value?.contractHistory ?? [])
+ const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
+ const employeeContractWorkLabel = computed(() => {
+ const contract = employee.value?.contract
+ if (!contract) return '-'
+ if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
+ if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
+ return contract.name || '-'
+ })
+
+ 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 currentActiveContractPeriod = computed(() => {
+ const today = getTodayYmd()
+ const history = employee.value?.contractHistory ?? []
+ return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
+ })
+
+ const canCloseCurrentContract = computed(() => {
+ const active = currentActiveContractPeriod.value
+ if (!active) return false
+ if (!active.endDate) return true
+ return active.endDate > getTodayYmd()
+ })
+
+ const canCreateContract = computed(() => {
+ const active = currentActiveContractPeriod.value
+ if (!active) return true
+ return !!active.endDate
+ })
+
+ const isContractEndDateValid = computed(() => contractForm.endDate !== '')
+ const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
+
+ const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
+ const isCreateContractValid = computed(() => createContractForm.contractId !== '')
+ const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
+ const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
+ const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
+ const isCreateContractFormValid = computed(() =>
+ isCreateContractValid.value &&
+ isCreateContractNatureValid.value &&
+ isCreateContractStartDateValid.value &&
+ isCreateContractEndDateValid.value
+ )
+
+ const baseInputClass =
+ 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
+ const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
+ const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
+ const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
+ const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
+ const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
+ const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
+ const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
+ const closeContractWorkedHoursLabel = computed(() => {
+ if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
+ return contractForm.contractName || '-'
+ })
+
+ const resetContractValidation = () => {
+ validationTouched.endDate = false
+ }
+
+ const hydrateContractFormFromCurrent = () => {
+ const current = employee.value
+ const active = currentActiveContractPeriod.value
+ if (!current || !active) return
+
+ contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
+ contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
+ contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
+ contractForm.contractNature = active.contractNature
+ contractForm.startDate = active.startDate
+ contractForm.endDate = getTodayYmd()
+ contractForm.paidLeaveSettled = false
+ contractForm.comment = ''
+ }
+
+ const openCloseContractDrawer = () => {
+ if (!employee.value || !canCloseCurrentContract.value) return
+ hydrateContractFormFromCurrent()
+ resetContractValidation()
+ isContractDrawerOpen.value = true
+ }
+
+ const setContractDrawerOpen = (open: boolean) => {
+ isContractDrawerOpen.value = open
+ }
+
+ const resetCreateValidation = () => {
+ createValidationTouched.contractId = false
+ createValidationTouched.contractNature = false
+ createValidationTouched.startDate = false
+ createValidationTouched.endDate = false
+ }
+
+ const openCreateContractDrawer = () => {
+ if (!employee.value || !canCreateContract.value) return
+ createContractForm.contractId = ''
+ createContractForm.contractNature = 'CDI'
+ createContractForm.endDate = ''
+ createContractForm.startDate = currentActiveContractPeriod.value?.endDate
+ ? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
+ : getTodayYmd()
+ resetCreateValidation()
+ isCreateContractDrawerOpen.value = true
+ }
+
+ const setCreateContractDrawerOpen = (open: boolean) => {
+ isCreateContractDrawerOpen.value = open
+ }
+
+ const loadEmployee = async () => {
+ const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
+ const employeeId = Number(idParam)
+ if (!Number.isInteger(employeeId) || employeeId <= 0) {
+ return
+ }
+
+ isLoading.value = true
+ try {
+ const loadedEmployee = await getEmployee(employeeId)
+ employee.value = loadedEmployee
+
+ const now = new Date()
+ const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
+ 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 holidayYears = isForfait
+ ? [leaveYear]
+ : [leaveYear - 1, leaveYear]
+ const [absences, summary, rtt, ...holidayResults] = await Promise.all([
+ listAbsences({
+ from,
+ to,
+ employeeId: loadedEmployee.id
+ }),
+ showLeaveTab.value
+ ? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
+ : Promise.resolve(null),
+ getEmployeeRttSummary(loadedEmployee.id, rttYear),
+ ...holidayYears.map((y) => listPublicHolidays('metropole', y))
+ ])
+ employeeAbsences.value = absences
+ leaveSummary.value = summary
+ rttSummary.value = rtt
+ publicHolidays.value = Object.assign({}, ...holidayResults)
+ if (!showLeaveTab.value && activeTab.value === 'leave') {
+ activeTab.value = 'contract'
+ }
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const submitContractUpdate = async () => {
+ if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
+
+ validationTouched.endDate = true
+ if (!isContractEndDateValid.value) return
+
+ if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
+ toast.error({
+ title: 'Erreur',
+ message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
+ })
+ return
+ }
+
+ isContractSubmitting.value = true
+ try {
+ await updateEmployee(employee.value.id, {
+ firstName: employee.value.firstName,
+ lastName: employee.value.lastName,
+ siteId: employee.value.site?.id ?? null,
+ contractId: Number(contractForm.contractId),
+ contractEndDate: contractForm.endDate || null,
+ contractPaidLeaveSettled: contractForm.paidLeaveSettled,
+ contractComment: contractForm.comment || null
+ })
+
+ isContractDrawerOpen.value = false
+ await loadEmployee()
+ } finally {
+ isContractSubmitting.value = false
+ }
+ }
+
+ const submitCreateContract = async () => {
+ if (!employee.value || isCreateContractSubmitting.value) return
+
+ createValidationTouched.contractId = true
+ createValidationTouched.contractNature = true
+ createValidationTouched.startDate = true
+ createValidationTouched.endDate = true
+ if (!isCreateContractFormValid.value) return
+
+ if (currentActiveContractPeriod.value?.endDate) {
+ const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
+ if (createContractForm.startDate < minStartDate) {
+ toast.error({
+ title: 'Erreur',
+ message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
+ })
+ return
+ }
+ }
+
+ isCreateContractSubmitting.value = true
+ try {
+ await updateEmployee(employee.value.id, {
+ firstName: employee.value.firstName,
+ lastName: employee.value.lastName,
+ siteId: employee.value.site?.id ?? null,
+ contractId: Number(createContractForm.contractId),
+ contractNature: createContractForm.contractNature,
+ contractStartDate: createContractForm.startDate,
+ contractEndDate: createContractForm.endDate || null
+ })
+ isCreateContractDrawerOpen.value = false
+ await loadEmployee()
+ } finally {
+ isCreateContractSubmitting.value = false
+ }
+ }
+
+ const submitFractionedDays = async (days: number) => {
+ if (!employee.value) return
+ const year = leaveSummary.value?.year ?? undefined
+ await updateFractionedDays(employee.value.id, days, year)
+ await loadEmployee()
+ }
+
+ const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
+ if (!employee.value) return
+ const year = rttSummary.value?.year ?? undefined
+ await createRttPayment(employee.value.id, month, minutes, rate, year)
+ await loadEmployee()
+ }
+
+ watch(requiresCreateContractEndDate, (required) => {
+ if (!required) {
+ createContractForm.endDate = ''
+ }
+ })
+
+ onMounted(async () => {
+ contracts.value = await listContracts()
+ await loadEmployee()
+ })
+
+ return {
+ employee,
+ isLoading,
+ activeTab,
+ contracts,
+ employeeAbsences,
+ leaveSummary,
+ rttSummary,
+ publicHolidays,
+ showLeaveTab,
+ contractHistory,
+ employeeContractWorkLabel,
+ contractForm,
+ createContractForm,
+ isContractDrawerOpen,
+ isContractSubmitting,
+ isCreateContractDrawerOpen,
+ isCreateContractSubmitting,
+ canCloseCurrentContract,
+ canCreateContract,
+ readonlyFieldClass,
+ closeContractWorkedHoursLabel,
+ contractEndDateFieldClass,
+ showContractEndDateError,
+ isContractEndDateValid,
+ createContractNatureFieldClass,
+ createContractFieldClass,
+ createContractStartDateFieldClass,
+ requiresCreateContractEndDate,
+ createContractEndDateFieldClass,
+ isCreateContractFormValid,
+ contractNatureLabel,
+ contractHistoryLabel,
+ formatDate,
+ openCloseContractDrawer,
+ openCreateContractDrawer,
+ setContractDrawerOpen,
+ setCreateContractDrawerOpen,
+ submitContractUpdate,
+ submitCreateContract,
+ submitFractionedDays,
+ submitRttPayment
+ }
+}
diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts
index 864291a..6ec531b 100644
--- a/frontend/composables/useHoursPage.ts
+++ b/frontend/composables/useHoursPage.ts
@@ -341,7 +341,8 @@ export const useHoursPage = () => {
isPresentMorning: false,
isPresentAfternoon: false,
isSiteValid: false,
- isValid: false
+ isValid: false,
+ updatedAt: null
})
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
@@ -463,6 +464,14 @@ export const useHoursPage = () => {
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
+ const getRowUpdatedAt = (employeeId: number): string => {
+ const raw = rows.value[employeeId]?.updatedAt
+ if (!raw) return ''
+ const date = new Date(raw)
+ if (Number.isNaN(date.getTime())) return ''
+ return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
+ }
+
const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
@@ -521,7 +530,8 @@ export const useHoursPage = () => {
isPresentMorning: workHour?.isPresentMorning ?? false,
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
- isValid: workHour?.isValid ?? false
+ isValid: workHour?.isValid ?? false,
+ updatedAt: workHour?.updatedAt ?? null
}
}
@@ -1128,6 +1138,7 @@ export const useHoursPage = () => {
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
+ canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
@@ -1140,6 +1151,7 @@ export const useHoursPage = () => {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
+ getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue
index ead9d69..066aa0f 100644
--- a/frontend/layouts/default.vue
+++ b/frontend/layouts/default.vue
@@ -2,59 +2,71 @@
-
+
Tableau de bord
Calendrier
Heures
Employés
Sites
Types d'absence
Utilisateurs
@@ -62,20 +74,16 @@
-
- Déconnexion
-
v{{ version }}
-
-
-
+
@@ -84,9 +92,5 @@
const auth = useAuthStore()
const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
-
-const handleLogout = async () => {
- await auth.logout()
- await navigateTo('/login')
-}
+const route = useRoute()
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 4dd0d11..374000f 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -22,7 +22,7 @@ export default defineNuxtConfig({
devServer: {port: 3001},
toast: {
settings: {
- timeout: 10000,
+ timeout: 2000,
closeOnClick: true,
progressBar: false
}
diff --git a/frontend/pages/employees.vue b/frontend/pages/employees.vue
deleted file mode 100644
index aa08583..0000000
--- a/frontend/pages/employees.vue
+++ /dev/null
@@ -1,526 +0,0 @@
-
-
-
-
-
Employés
-
-
-
-
-
-
- Ajouter un employé
-
-
-
-
-
-
-
-
-
- Aucun employé pour le moment.
-
-
-
-
-
-
- Prénom
- Nom
- Site
- Nature
- Contrat
- Actions
-
-
- Chargement...
-
-
-
-
{{ employee.firstName }}
-
{{ employee.lastName }}
-
- {{ employee.site?.name ?? '-' }}
-
-
{{ contractNatureLabel(employee.currentContractNature) }}
-
{{ employee.contract?.name ?? '-' }}
-
-
- Modifier
-
-
- Supprimer
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue
new file mode 100644
index 0000000..a05f84a
--- /dev/null
+++ b/frontend/pages/employees/[id].vue
@@ -0,0 +1,155 @@
+
+
+
+
+ Chargement...
+
+
+
+ Employé introuvable.
+
+
+
+
+
{{ employee.firstName }} {{ employee.lastName }}
+
+
{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}
+
{{ employee.site?.name ?? '-' }}
+
+
+
+
+
+
+ Suivi contrat
+
+
+
+ Congé
+
+
+
+ RTT
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/employees/index.vue b/frontend/pages/employees/index.vue
new file mode 100644
index 0000000..76487a5
--- /dev/null
+++ b/frontend/pages/employees/index.vue
@@ -0,0 +1,496 @@
+
+
+
+
+
Employés
+
+ + Ajouter un employé
+
+
+
+
+
+
+ Aucun employé pour le moment.
+
+
+
+
+
+
{{ employee.initials}}
+
+
{{ employee.firstName }} {{ employee.lastName }}
+
Nom du poste occupé
+
Site ({{ employee.site?.name ?? '-' }})
+
+
+
+
+
+
{{ employee.lastName }} {{ employee.firstName }}
+
Type: {{ contractNatureLabel(employee.currentContractNature) }}
+
Temps de travail: {{ employee.contract?.name ?? '-' }}
+
Site: {{ employee.site?.name ?? '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue
index 9245760..6361293 100644
--- a/frontend/pages/hours.vue
+++ b/frontend/pages/hours.vue
@@ -54,6 +54,7 @@
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
+ :can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
@@ -66,6 +67,7 @@
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
+ :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
@@ -161,6 +163,7 @@ const {
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
+ canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
@@ -173,6 +176,7 @@ const {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
+ getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
diff --git a/frontend/public/malio.png b/frontend/public/malio.png
index ab1ea2f..f66b075 100644
Binary files a/frontend/public/malio.png and b/frontend/public/malio.png differ
diff --git a/frontend/services/absences.ts b/frontend/services/absences.ts
index 747b086..bacdabc 100644
--- a/frontend/services/absences.ts
+++ b/frontend/services/absences.ts
@@ -6,6 +6,7 @@ type ListAbsencesFilters = {
from?: string
to?: string
siteIds?: number[]
+ employeeId?: number
}
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
@@ -20,6 +21,9 @@ export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
if (filters.siteIds && filters.siteIds.length > 0) {
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
}
+ if (filters.employeeId) {
+ query.employee = `/api/employees/${filters.employeeId}`
+ }
const data = await api.get
(
'/absences',
query,
diff --git a/frontend/services/dto/employee-leave-summary.ts b/frontend/services/dto/employee-leave-summary.ts
new file mode 100644
index 0000000..723373f
--- /dev/null
+++ b/frontend/services/dto/employee-leave-summary.ts
@@ -0,0 +1,14 @@
+export type EmployeeLeaveSummary = {
+ year: number
+ isSupported: boolean
+ ruleCode: string
+ acquiredDays: number
+ remainingDays: number
+ takenDays: number
+ acquiredSaturdays: number
+ remainingSaturdays: number
+ takenSaturdays: number
+ fractionedDays: number
+ accruingDays: number
+}
+
diff --git a/frontend/services/dto/employee-rtt-summary.ts b/frontend/services/dto/employee-rtt-summary.ts
new file mode 100644
index 0000000..f4c5380
--- /dev/null
+++ b/frontend/services/dto/employee-rtt-summary.ts
@@ -0,0 +1,24 @@
+export type EmployeeRttWeekSummary = {
+ month: number
+ weekNumber: number
+ weekStart: string
+ weekEnd: string
+ recoveryMinutes: number
+}
+
+export type RttMonthPayment = {
+ month: number
+ paidMinutes25: number
+ paidMinutes50: number
+}
+
+export type EmployeeRttSummary = {
+ year: number
+ carryFromPreviousYearMinutes: number
+ currentYearRecoveryMinutes: number
+ totalPaidMinutes: number
+ availableMinutes: number
+ weeks: EmployeeRttWeekSummary[]
+ monthPayments: RttMonthPayment[]
+}
+
diff --git a/frontend/services/dto/employee.ts b/frontend/services/dto/employee.ts
index 8f8c5a8..ceaac93 100644
--- a/frontend/services/dto/employee.ts
+++ b/frontend/services/dto/employee.ts
@@ -1,6 +1,16 @@
import type { Site } from './site'
import type { Contract } from './contract'
+export type ContractHistoryItem = {
+ contractId?: number | null
+ contractName?: string | null
+ weeklyHours?: number | null
+ contractNature: 'CDI' | 'CDD' | 'INTERIM'
+ startDate: string
+ endDate?: string | null
+ comment?: string | null
+}
+
export type Employee = {
id: number
firstName: string
@@ -10,5 +20,6 @@ export type Employee = {
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
+ contractHistory?: ContractHistoryItem[]
displayOrder?: number
}
diff --git a/frontend/services/dto/notification.ts b/frontend/services/dto/notification.ts
new file mode 100644
index 0000000..94192ec
--- /dev/null
+++ b/frontend/services/dto/notification.ts
@@ -0,0 +1,9 @@
+export type NotificationItem = {
+ id: number
+ actorName: string
+ message: string
+ category: string
+ target: string
+ isRead: boolean
+ createdAt: string
+}
diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts
index 7f4dbac..953eada 100644
--- a/frontend/services/dto/work-hour.ts
+++ b/frontend/services/dto/work-hour.ts
@@ -15,6 +15,7 @@ export type WorkHour = {
isPresentAfternoon?: boolean
isSiteValid?: boolean
isValid?: boolean
+ updatedAt?: string | null
}
export type WorkHourEntryPayload = {
diff --git a/frontend/services/employee-leave-summary.ts b/frontend/services/employee-leave-summary.ts
new file mode 100644
index 0000000..b419abc
--- /dev/null
+++ b/frontend/services/employee-leave-summary.ts
@@ -0,0 +1,18 @@
+import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
+
+export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
+ const api = useApi()
+ const query: Record = {}
+ if (year) query.year = String(year)
+
+ return api.get(`/employees/${employeeId}/leave-summary`, query, { toast: false })
+}
+
+export const updateFractionedDays = async (employeeId: number, fractionedDays: number, year?: number) => {
+ const api = useApi()
+ const body: Record = { fractionedDays }
+ if (year) body.year = year
+
+ return api.patch(`/employees/${employeeId}/fractioned-days`, body)
+}
+
diff --git a/frontend/services/employee-rtt-summary.ts b/frontend/services/employee-rtt-summary.ts
new file mode 100644
index 0000000..6ec4d12
--- /dev/null
+++ b/frontend/services/employee-rtt-summary.ts
@@ -0,0 +1,15 @@
+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(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
+}
+
+export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => {
+ const api = useApi()
+ const body: Record = { month, minutes, rate }
+ if (year) body.year = year
+ return api.patch(`/employees/${employeeId}/rtt-payments`, body)
+}
+
diff --git a/frontend/services/employees.ts b/frontend/services/employees.ts
index a2977c9..19dd6e6 100644
--- a/frontend/services/employees.ts
+++ b/frontend/services/employees.ts
@@ -21,6 +21,11 @@ export const listScopedEmployees = async () => {
return extractItems(data)
}
+export const getEmployee = async (id: number) => {
+ const api = useApi()
+ return api.get(`/employees/${id}`, {}, { toast: false })
+}
+
export const createEmployee = async (payload: {
firstName: string
lastName: string
@@ -51,24 +56,43 @@ export const updateEmployee = async (
firstName: string
lastName: string
siteId?: number | null
- contractId: number
+ contractId?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
+ contractPaidLeaveSettled?: boolean
+ contractComment?: string | null
displayOrder?: number
}
) => {
const api = useApi()
- return api.patch(`/employees/${id}`, {
+ const body: Record = {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
- contract: `/api/contracts/${payload.contractId}`,
- contractNature: payload.contractNature,
- contractStartDate: payload.contractStartDate,
- contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder
- }, {
+ }
+
+ if (payload.contractId !== undefined) {
+ body.contract = `/api/contracts/${payload.contractId}`
+ }
+ if (payload.contractNature !== undefined) {
+ body.contractNature = payload.contractNature
+ }
+ if (payload.contractStartDate !== undefined) {
+ body.contractStartDate = payload.contractStartDate
+ }
+ if (payload.contractEndDate !== undefined) {
+ body.contractEndDate = payload.contractEndDate ?? null
+ }
+ if (payload.contractPaidLeaveSettled !== undefined) {
+ body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
+ }
+ if (payload.contractComment !== undefined) {
+ body.contractComment = payload.contractComment ?? null
+ }
+
+ return api.patch(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',
toastErrorKey: 'errors.employee.update'
})
diff --git a/frontend/services/notifications.ts b/frontend/services/notifications.ts
new file mode 100644
index 0000000..a0d921e
--- /dev/null
+++ b/frontend/services/notifications.ts
@@ -0,0 +1,40 @@
+import type { NotificationItem } from './dto/notification'
+import { extractItems } from '~/utils/api'
+
+export const listUnreadNotifications = async () => {
+ const api = useApi()
+ const data = await api.get(
+ '/notifications/unread',
+ {},
+ { toast: false }
+ )
+
+ return extractItems(data)
+}
+
+export const listTodayNotifications = async () => {
+ const api = useApi()
+ const data = await api.get(
+ '/notifications/today',
+ {},
+ { toast: false }
+ )
+
+ return extractItems(data)
+}
+
+export const listHistoryNotifications = async () => {
+ const api = useApi()
+ const data = await api.get(
+ '/notifications/history',
+ {},
+ { toast: false }
+ )
+
+ return extractItems(data)
+}
+
+export const markAllNotificationsRead = async () => {
+ const api = useApi()
+ return api.post('/notifications/mark-all-read', {}, { toast: false })
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index 3e176c4..13ab8ad 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -4,7 +4,7 @@ export default >{
theme: {
extend: {
fontFamily: {
- sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
+ sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif']
},
colors: {
primary: {
@@ -15,6 +15,9 @@ export default >{
},
tertiary: {
500: '#F3F4F8'
+ },
+ blue: {
+ 500: '#056CF2'
}
}
}
diff --git a/frontend/utils/contract.ts b/frontend/utils/contract.ts
new file mode 100644
index 0000000..6b8a3a2
--- /dev/null
+++ b/frontend/utils/contract.ts
@@ -0,0 +1,17 @@
+export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
+
+export type ContractNature = (typeof CONTRACT_NATURES)[number]
+
+export const contractNatureLabel = (value?: ContractNature) => {
+ if (value === 'CDD') return 'CDD'
+ if (value === 'INTERIM') return 'Intérim'
+ return 'CDI'
+}
+
+export const requiresContractEndDate = (nature: ContractNature) => {
+ return nature === 'CDD' || nature === 'INTERIM'
+}
+
+export const isContractNature = (value: string): value is ContractNature => {
+ return (CONTRACT_NATURES as readonly string[]).includes(value)
+}
diff --git a/frontend/utils/date.ts b/frontend/utils/date.ts
index 2acd56f..d165856 100644
--- a/frontend/utils/date.ts
+++ b/frontend/utils/date.ts
@@ -6,6 +6,17 @@ export const toYmd = (year: number, month: number, day: number) => {
export const normalizeDate = (value: string) => value.slice(0, 10)
+export const formatYmdToFr = (value: string) => {
+ const [year, month, day] = value.split('-')
+ if (!year || !month || !day) return value
+ return `${day}/${month}/${year}`
+}
+
+export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
+ if (!value) return fallback
+ return formatYmdToFr(value)
+}
+
export const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
diff --git a/makefile b/makefile
index 92c2dc5..2384ffb 100644
--- a/makefile
+++ b/makefile
@@ -77,7 +77,7 @@ migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
fixtures:
- $(SYMFONY_CONSOLE) doctrine:fixtures:load
+ $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
# Attention, supprime votre bdd local
db-reset:
diff --git a/migrations/Version20260302110000.php b/migrations/Version20260302110000.php
new file mode 100644
index 0000000..9ab5824
--- /dev/null
+++ b/migrations/Version20260302110000.php
@@ -0,0 +1,30 @@
+addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)');
+ $this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)');
+ $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78');
+ $this->addSql('DROP TABLE notifications');
+ }
+}
diff --git a/migrations/Version20260304140000.php b/migrations/Version20260304140000.php
new file mode 100644
index 0000000..39d1e6a
--- /dev/null
+++ b/migrations/Version20260304140000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE employee_contract_periods ADD paid_leave_settled BOOLEAN DEFAULT FALSE NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE employee_contract_periods DROP paid_leave_settled');
+ }
+}
diff --git a/migrations/Version20260304153000.php b/migrations/Version20260304153000.php
new file mode 100644
index 0000000..69a5995
--- /dev/null
+++ b/migrations/Version20260304153000.php
@@ -0,0 +1,29 @@
+addSql('CREATE TABLE employee_leave_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, rule_code VARCHAR(64) NOT NULL, year INT NOT NULL, opening_days DOUBLE PRECISION NOT NULL, opening_saturdays DOUBLE PRECISION NOT NULL, accrued_days DOUBLE PRECISION NOT NULL, accrued_saturdays DOUBLE PRECISION NOT NULL, taken_days DOUBLE PRECISION NOT NULL, taken_saturdays DOUBLE PRECISION NOT NULL, closing_days DOUBLE PRECISION NOT NULL, closing_saturdays DOUBLE PRECISION 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_leave_balance ON employee_leave_balances (employee_id, rule_code, year)');
+ $this->addSql('CREATE INDEX idx_leave_balance_employee_year ON employee_leave_balances (employee_id, year)');
+ $this->addSql('ALTER TABLE employee_leave_balances ADD CONSTRAINT FK_3834A8A18C03F15C 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_leave_balances');
+ }
+}
diff --git a/migrations/Version20260304170000.php b/migrations/Version20260304170000.php
new file mode 100644
index 0000000..4bc5c83
--- /dev/null
+++ b/migrations/Version20260304170000.php
@@ -0,0 +1,48 @@
+addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('COMMENT ON TABLE employee_leave_balances IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.rule_code IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.year IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_days IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_days IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_days IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_days IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS NULL');
+ $this->addSql('COMMENT ON COLUMN employee_leave_balances.is_locked IS NULL');
+ }
+}
diff --git a/migrations/Version20260304173000.php b/migrations/Version20260304173000.php
new file mode 100644
index 0000000..dd1266c
--- /dev/null
+++ b/migrations/Version20260304173000.php
@@ -0,0 +1,250 @@
+addSql("COMMENT ON TABLE absence_types IS 'Catalogue des types d absence (CP, RTT, maladie, etc.).'");
+ $this->addSql("COMMENT ON COLUMN absence_types.id IS 'Identifiant technique du type d absence.'");
+ $this->addSql("COMMENT ON COLUMN absence_types.code IS 'Code metier court du type d absence.'");
+ $this->addSql("COMMENT ON COLUMN absence_types.label IS 'Libelle metier du type d absence.'");
+ $this->addSql("COMMENT ON COLUMN absence_types.color IS 'Couleur d affichage du type d absence.'");
+ $this->addSql("COMMENT ON COLUMN absence_types.count_as_worked_hours IS 'Indique si l absence est comptee comme temps travaille.'");
+
+ // absences
+ $this->addSql("COMMENT ON TABLE absences IS 'Absences employees enregistrees par jour et demi journee.'");
+ $this->addSql("COMMENT ON COLUMN absences.id IS 'Identifiant technique de l absence.'");
+ $this->addSql("COMMENT ON COLUMN absences.employee_id IS 'Employe concerne par l absence.'");
+ $this->addSql("COMMENT ON COLUMN absences.type_id IS 'Type d absence applique.'");
+ $this->addSql("COMMENT ON COLUMN absences.comment IS 'Commentaire libre de l absence.'");
+ $this->addSql("COMMENT ON COLUMN absences.start_date IS 'Date de debut de l absence.'");
+ $this->addSql("COMMENT ON COLUMN absences.end_date IS 'Date de fin de l absence.'");
+ $this->addSql("COMMENT ON COLUMN absences.start_half IS 'Demi journee de debut (AM ou PM).'");
+ $this->addSql("COMMENT ON COLUMN absences.end_half IS 'Demi journee de fin (AM ou PM).'");
+
+ // contracts
+ $this->addSql("COMMENT ON TABLE contracts IS 'Referentiel des contrats de travail utilisables.'");
+ $this->addSql("COMMENT ON COLUMN contracts.id IS 'Identifiant technique du contrat.'");
+ $this->addSql("COMMENT ON COLUMN contracts.name IS 'Nom metier du contrat (35h, 39h, Forfait, etc.).'");
+ $this->addSql("COMMENT ON COLUMN contracts.tracking_mode IS 'Mode de suivi du contrat (TIME ou PRESENCE).'");
+ $this->addSql("COMMENT ON COLUMN contracts.weekly_hours IS 'Volume horaire hebdomadaire contractuel, si applicable.'");
+ $this->addSql("COMMENT ON COLUMN contracts.is_active IS 'Indique si le contrat est actif dans le referentiel.'");
+
+ // doctrine_migration_versions
+ $this->addSql("COMMENT ON TABLE doctrine_migration_versions IS 'Historique technique des migrations Doctrine appliquees.'");
+ $this->addSql("COMMENT ON COLUMN doctrine_migration_versions.version IS 'Version unique de migration appliquee.'");
+ $this->addSql("COMMENT ON COLUMN doctrine_migration_versions.executed_at IS 'Date et heure d execution de la migration.'");
+ $this->addSql("COMMENT ON COLUMN doctrine_migration_versions.execution_time IS 'Duree d execution de la migration en millisecondes.'");
+
+ // employee_contract_periods
+ $this->addSql("COMMENT ON TABLE employee_contract_periods IS 'Historique des periodes de contrat par employe.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.id IS 'Identifiant technique de la periode contrat.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.employee_id IS 'Employe concerne par la periode contrat.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_id IS 'Contrat applique sur la periode.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.start_date IS 'Date de debut de la periode contrat.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.end_date IS 'Date de fin de la periode contrat, null si en cours.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.created_at IS 'Date de creation technique de la periode contrat.'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_nature IS 'Nature du contrat (CDI, CDD, INTERIM).'");
+ $this->addSql("COMMENT ON COLUMN employee_contract_periods.paid_leave_settled IS 'Indique si les conges ont ete soldes a la cloture de cette periode.'");
+
+ // employee_leave_balances
+ $this->addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.id IS 'Identifiant technique du solde annuel.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.employee_id IS 'Employe concerne par le solde annuel.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.created_at IS 'Date de creation technique du solde.'");
+ $this->addSql("COMMENT ON COLUMN employee_leave_balances.updated_at IS 'Date de derniere mise a jour du solde.'");
+
+ // employees
+ $this->addSql("COMMENT ON TABLE employees IS 'Referentiel des employes.'");
+ $this->addSql("COMMENT ON COLUMN employees.id IS 'Identifiant technique de l employe.'");
+ $this->addSql("COMMENT ON COLUMN employees.first_name IS 'Prenom de l employe.'");
+ $this->addSql("COMMENT ON COLUMN employees.last_name IS 'Nom de l employe.'");
+ $this->addSql("COMMENT ON COLUMN employees.created_at IS 'Date de creation technique de l employe.'");
+ $this->addSql("COMMENT ON COLUMN employees.site_id IS 'Site principal de rattachement de l employe.'");
+ $this->addSql("COMMENT ON COLUMN employees.display_order IS 'Ordre d affichage de l employe dans les listes UI.'");
+ $this->addSql("COMMENT ON COLUMN employees.contract_id IS 'Contrat courant reference sur la fiche employe.'");
+
+ // notifications
+ $this->addSql("COMMENT ON TABLE notifications IS 'Notifications applicatives envoyees aux utilisateurs.'");
+ $this->addSql("COMMENT ON COLUMN notifications.id IS 'Identifiant technique de la notification.'");
+ $this->addSql("COMMENT ON COLUMN notifications.recipient_id IS 'Utilisateur destinataire de la notification.'");
+ $this->addSql("COMMENT ON COLUMN notifications.title IS 'Titre court de la notification.'");
+ $this->addSql("COMMENT ON COLUMN notifications.message IS 'Message detaille de la notification.'");
+ $this->addSql("COMMENT ON COLUMN notifications.is_read IS 'Indique si la notification a ete lue.'");
+ $this->addSql("COMMENT ON COLUMN notifications.created_at IS 'Date de creation de la notification.'");
+
+ // sites
+ $this->addSql("COMMENT ON TABLE sites IS 'Referentiel des sites de l entreprise.'");
+ $this->addSql("COMMENT ON COLUMN sites.id IS 'Identifiant technique du site.'");
+ $this->addSql("COMMENT ON COLUMN sites.name IS 'Nom du site.'");
+ $this->addSql("COMMENT ON COLUMN sites.color IS 'Couleur associee au site pour affichage UI.'");
+ $this->addSql("COMMENT ON COLUMN sites.display_order IS 'Ordre d affichage du site dans les listes UI.'");
+
+ // user_site_roles
+ $this->addSql("COMMENT ON TABLE user_site_roles IS 'Roles attribues aux utilisateurs sur des sites donnes.'");
+ $this->addSql("COMMENT ON COLUMN user_site_roles.id IS 'Identifiant technique de l attribution de role site.'");
+ $this->addSql("COMMENT ON COLUMN user_site_roles.user_id IS 'Utilisateur concerne par l attribution.'");
+ $this->addSql("COMMENT ON COLUMN user_site_roles.site_id IS 'Site concerne par l attribution.'");
+ $this->addSql("COMMENT ON COLUMN user_site_roles.role IS 'Role attribue sur le site (ex: ROLE_SITE_MANAGER).'");
+
+ // users
+ $this->addSql("COMMENT ON TABLE users IS 'Comptes utilisateurs de l application.'");
+ $this->addSql("COMMENT ON COLUMN users.id IS 'Identifiant technique du compte utilisateur.'");
+ $this->addSql("COMMENT ON COLUMN users.username IS 'Identifiant de connexion de l utilisateur.'");
+ $this->addSql("COMMENT ON COLUMN users.roles IS 'Liste des roles globaux de securite du compte.'");
+ $this->addSql("COMMENT ON COLUMN users.password IS 'Hash du mot de passe utilisateur.'");
+ $this->addSql("COMMENT ON COLUMN users.employee_id IS 'Lien optionnel vers la fiche employe associee.'");
+
+ // work_hours
+ $this->addSql("COMMENT ON TABLE work_hours IS 'Saisie des heures de travail et presences par employe et par jour.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.id IS 'Identifiant technique de la ligne de saisie.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.employee_id IS 'Employe concerne par la saisie horaire.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.work_date IS 'Date de travail de la ligne de saisie.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.morning_from IS 'Heure de debut du matin.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.morning_to IS 'Heure de fin du matin.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.afternoon_from IS 'Heure de debut de l apres midi.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.afternoon_to IS 'Heure de fin de l apres midi.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.evening_from IS 'Heure de debut du soir.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.evening_to IS 'Heure de fin du soir.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.is_valid IS 'Validation RH de la ligne horaire.'");
+ $this->addSql("COMMENT ON COLUMN work_hours.is_present_morning IS 'Presence declarative du matin (mode PRESENCE).'");
+ $this->addSql("COMMENT ON COLUMN work_hours.is_present_afternoon IS 'Presence declarative de l apres midi (mode PRESENCE).'");
+ $this->addSql("COMMENT ON COLUMN work_hours.is_site_valid IS 'Validation site (chef de site) de la ligne horaire.'");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $tables = [
+ 'absence_types',
+ 'absences',
+ 'contracts',
+ 'doctrine_migration_versions',
+ 'employee_contract_periods',
+ 'employee_leave_balances',
+ 'employees',
+ 'notifications',
+ 'sites',
+ 'user_site_roles',
+ 'users',
+ 'work_hours',
+ ];
+
+ foreach ($tables as $table) {
+ $this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $table));
+ }
+
+ $columns = [
+ ['absence_types', 'id'],
+ ['absence_types', 'code'],
+ ['absence_types', 'label'],
+ ['absence_types', 'color'],
+ ['absence_types', 'count_as_worked_hours'],
+ ['absences', 'id'],
+ ['absences', 'employee_id'],
+ ['absences', 'type_id'],
+ ['absences', 'comment'],
+ ['absences', 'start_date'],
+ ['absences', 'end_date'],
+ ['absences', 'start_half'],
+ ['absences', 'end_half'],
+ ['contracts', 'id'],
+ ['contracts', 'name'],
+ ['contracts', 'tracking_mode'],
+ ['contracts', 'weekly_hours'],
+ ['contracts', 'is_active'],
+ ['doctrine_migration_versions', 'version'],
+ ['doctrine_migration_versions', 'executed_at'],
+ ['doctrine_migration_versions', 'execution_time'],
+ ['employee_contract_periods', 'id'],
+ ['employee_contract_periods', 'employee_id'],
+ ['employee_contract_periods', 'contract_id'],
+ ['employee_contract_periods', 'start_date'],
+ ['employee_contract_periods', 'end_date'],
+ ['employee_contract_periods', 'created_at'],
+ ['employee_contract_periods', 'contract_nature'],
+ ['employee_contract_periods', 'paid_leave_settled'],
+ ['employee_leave_balances', 'id'],
+ ['employee_leave_balances', 'employee_id'],
+ ['employee_leave_balances', 'rule_code'],
+ ['employee_leave_balances', 'year'],
+ ['employee_leave_balances', 'opening_days'],
+ ['employee_leave_balances', 'opening_saturdays'],
+ ['employee_leave_balances', 'accrued_days'],
+ ['employee_leave_balances', 'accrued_saturdays'],
+ ['employee_leave_balances', 'taken_days'],
+ ['employee_leave_balances', 'taken_saturdays'],
+ ['employee_leave_balances', 'closing_days'],
+ ['employee_leave_balances', 'closing_saturdays'],
+ ['employee_leave_balances', 'is_locked'],
+ ['employee_leave_balances', 'created_at'],
+ ['employee_leave_balances', 'updated_at'],
+ ['employees', 'id'],
+ ['employees', 'first_name'],
+ ['employees', 'last_name'],
+ ['employees', 'created_at'],
+ ['employees', 'site_id'],
+ ['employees', 'display_order'],
+ ['employees', 'contract_id'],
+ ['notifications', 'id'],
+ ['notifications', 'recipient_id'],
+ ['notifications', 'title'],
+ ['notifications', 'message'],
+ ['notifications', 'is_read'],
+ ['notifications', 'created_at'],
+ ['sites', 'id'],
+ ['sites', 'name'],
+ ['sites', 'color'],
+ ['sites', 'display_order'],
+ ['user_site_roles', 'id'],
+ ['user_site_roles', 'user_id'],
+ ['user_site_roles', 'site_id'],
+ ['user_site_roles', 'role'],
+ ['users', 'id'],
+ ['users', 'username'],
+ ['users', 'roles'],
+ ['users', 'password'],
+ ['users', 'employee_id'],
+ ['work_hours', 'id'],
+ ['work_hours', 'employee_id'],
+ ['work_hours', 'work_date'],
+ ['work_hours', 'morning_from'],
+ ['work_hours', 'morning_to'],
+ ['work_hours', 'afternoon_from'],
+ ['work_hours', 'afternoon_to'],
+ ['work_hours', 'evening_from'],
+ ['work_hours', 'evening_to'],
+ ['work_hours', 'is_valid'],
+ ['work_hours', 'is_present_morning'],
+ ['work_hours', 'is_present_afternoon'],
+ ['work_hours', 'is_site_valid'],
+ ];
+
+ foreach ($columns as [$table, $column]) {
+ $this->addSql(sprintf('COMMENT ON COLUMN %s.%s IS NULL', $table, $column));
+ }
+ }
+}
diff --git a/migrations/Version20260306120000.php b/migrations/Version20260306120000.php
new file mode 100644
index 0000000..f76319e
--- /dev/null
+++ b/migrations/Version20260306120000.php
@@ -0,0 +1,29 @@
+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');
+ }
+}
diff --git a/migrations/Version20260306140000.php b/migrations/Version20260306140000.php
new file mode 100644
index 0000000..7ccd060
--- /dev/null
+++ b/migrations/Version20260306140000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE work_hours ADD COLUMN updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN updated_at');
+ }
+}
diff --git a/migrations/Version20260306160000.php b/migrations/Version20260306160000.php
new file mode 100644
index 0000000..cd981bd
--- /dev/null
+++ b/migrations/Version20260306160000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE employee_contract_periods ADD COLUMN comment TEXT DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN comment');
+ }
+}
diff --git a/migrations/Version20260309120000.php b/migrations/Version20260309120000.php
new file mode 100644
index 0000000..1c36d8c
--- /dev/null
+++ b/migrations/Version20260309120000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE employee_leave_balances ADD fractioned_days DOUBLE PRECISION NOT NULL DEFAULT 0');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE employee_leave_balances DROP COLUMN fractioned_days');
+ }
+}
diff --git a/migrations/Version20260309140000.php b/migrations/Version20260309140000.php
new file mode 100644
index 0000000..2e0c888
--- /dev/null
+++ b/migrations/Version20260309140000.php
@@ -0,0 +1,41 @@
+addSql(<<<'SQL'
+ CREATE TABLE employee_rtt_payments (
+ id SERIAL PRIMARY KEY,
+ employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
+ year INT NOT NULL,
+ month INT NOT NULL,
+ minutes INT NOT NULL,
+ rate VARCHAR(10) NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
+ )
+ SQL);
+ $this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
+ $this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
+ $this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
+ $this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE employee_rtt_payments');
+ }
+}
diff --git a/migrations/Version20260309160000.php b/migrations/Version20260309160000.php
new file mode 100644
index 0000000..61855a0
--- /dev/null
+++ b/migrations/Version20260309160000.php
@@ -0,0 +1,26 @@
+addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP INDEX uniq_rtt_payment_employee_year_month_rate');
+ }
+}
diff --git a/migrations/Version20260309170000.php b/migrations/Version20260309170000.php
new file mode 100644
index 0000000..8926021
--- /dev/null
+++ b/migrations/Version20260309170000.php
@@ -0,0 +1,26 @@
+addSql("ALTER TABLE notifications ADD COLUMN category VARCHAR(60) NOT NULL DEFAULT ''");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE notifications DROP COLUMN category');
+ }
+}
diff --git a/migrations/Version20260309180000.php b/migrations/Version20260309180000.php
new file mode 100644
index 0000000..6b1e67a
--- /dev/null
+++ b/migrations/Version20260309180000.php
@@ -0,0 +1,28 @@
+addSql('ALTER TABLE notifications ADD COLUMN actor_id INT DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL');
+ $this->addSql('ALTER TABLE notifications DROP COLUMN title');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql("ALTER TABLE notifications ADD COLUMN title VARCHAR(120) NOT NULL DEFAULT ''");
+ $this->addSql('ALTER TABLE notifications DROP COLUMN actor_id');
+ }
+}
diff --git a/migrations/Version20260309190000.php b/migrations/Version20260309190000.php
new file mode 100644
index 0000000..5632ba7
--- /dev/null
+++ b/migrations/Version20260309190000.php
@@ -0,0 +1,26 @@
+addSql("ALTER TABLE notifications ADD COLUMN target VARCHAR(255) NOT NULL DEFAULT ''");
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE notifications DROP COLUMN target');
+ }
+}
diff --git a/src/ApiResource/EmployeeFractionedDaysInput.php b/src/ApiResource/EmployeeFractionedDaysInput.php
new file mode 100644
index 0000000..b2ddb70
--- /dev/null
+++ b/src/ApiResource/EmployeeFractionedDaysInput.php
@@ -0,0 +1,27 @@
+ */
+ public array $monthPayments = [];
+
+ /** @var list */
+ public array $weeks = [];
+}
diff --git a/src/Command/LeaveRolloverCommand.php b/src/Command/LeaveRolloverCommand.php
new file mode 100644
index 0000000..59f5ef7
--- /dev/null
+++ b/src/Command/LeaveRolloverCommand.php
@@ -0,0 +1,213 @@
+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');
+
+ $this->logger->info('app:leave:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
+
+ if (!$force && !$this->isBusinessRolloverDate($today)) {
+ $message = 'No rollover today: business date is neither 01/01 nor 01/06.';
+ $this->logger->info($message, ['date' => $today->format('Y-m-d')]);
+ $io->success($message);
+
+ return Command::SUCCESS;
+ }
+
+ $created = 0;
+ $skipped = 0;
+
+ foreach ($this->employeeRepository->findAll() as $employee) {
+ if (!$employee instanceof Employee) {
+ continue;
+ }
+
+ $ruleCode = $this->resolveRuleCode($employee);
+ if (null === $ruleCode) {
+ $this->logger->info('Employee skipped: no eligible rule.', ['employeeId' => $employee->getId()]);
+ ++$skipped;
+
+ continue;
+ }
+ if (!$force && !$this->shouldProcessRuleToday($ruleCode, $today)) {
+ ++$skipped;
+
+ continue;
+ }
+
+ $targetYear = $this->resolveTargetYear($ruleCode, $today);
+ $existing = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $targetYear);
+ if (null !== $existing) {
+ $this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value]);
+ ++$skipped;
+
+ continue;
+ }
+
+ try {
+ [$carryDays, $carrySaturdays] = $this->resolveCarry($employee, $ruleCode, $targetYear);
+ } catch (Throwable $e) {
+ $this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
+ ++$skipped;
+
+ continue;
+ }
+
+ $balance = new EmployeeLeaveBalance()
+ ->setEmployee($employee)
+ ->setRuleCode($ruleCode)
+ ->setYear($targetYear)
+ ->setOpeningDays($carryDays)
+ ->setOpeningSaturdays($carrySaturdays)
+ ->setAccruedDays(0.0)
+ ->setAccruedSaturdays(0.0)
+ ->setTakenDays(0.0)
+ ->setTakenSaturdays(0.0)
+ ->setClosingDays($carryDays)
+ ->setClosingSaturdays($carrySaturdays)
+ ->setIsLocked(false)
+ ;
+
+ $this->entityManager->persist($balance);
+ $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value, 'carryDays' => $carryDays, 'carrySaturdays' => $carrySaturdays]);
+ ++$created;
+ }
+
+ try {
+ $this->entityManager->flush();
+ } catch (Throwable $e) {
+ $this->logger->error('Error flushing leave balances.', ['error' => $e->getMessage()]);
+ $io->error('Leave rollover failed: '.$e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ $message = sprintf('Leave rollover done: %d created, %d skipped.', $created, $skipped);
+ $this->logger->info($message);
+ $io->success($message);
+
+ return Command::SUCCESS;
+ }
+
+ private function resolveRuleCode(Employee $employee): ?LeaveRuleCode
+ {
+ $type = $employee->getContract()?->getType();
+ if (null === $type || ContractType::INTERIM === $type) {
+ return null;
+ }
+
+ if (ContractType::FORFAIT === $type) {
+ return LeaveRuleCode::FORFAIT_218;
+ }
+
+ return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
+ }
+
+ private function resolveTargetYear(LeaveRuleCode $ruleCode, DateTimeImmutable $today): int
+ {
+ if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
+ return (int) $today->format('Y');
+ }
+
+ $year = (int) $today->format('Y');
+ $month = (int) $today->format('n');
+
+ return $month >= 6 ? $year + 1 : $year;
+ }
+
+ private function isBusinessRolloverDate(DateTimeImmutable $today): bool
+ {
+ return in_array($today->format('m-d'), ['01-01', '06-01'], true);
+ }
+
+ private function shouldProcessRuleToday(LeaveRuleCode $ruleCode, DateTimeImmutable $today): bool
+ {
+ $day = $today->format('m-d');
+ if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
+ return '01-01' === $day;
+ }
+
+ return '06-01' === $day;
+ }
+
+ /**
+ * @return array{float, float}
+ */
+ private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
+ {
+ $previousYear = $targetYear - 1;
+ $previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
+ if (null !== $previous) {
+ $carryDays = $previous->getClosingDays() + $previous->getFractionedDays();
+ $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode
+ ? $previous->getClosingSaturdays()
+ : 0.0;
+ } else {
+ [$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
+ ->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
+ ;
+ }
+
+ [$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear);
+ $hasSettlement = $this->leaveBalanceComputationService
+ ->hasPaidLeaveSettledClosureBetween($employee, $from, $to)
+ ;
+ if ($hasSettlement) {
+ return [0.0, 0.0];
+ }
+
+ return [$carryDays, $carrySaturdays];
+ }
+}
diff --git a/src/Command/RttRolloverCommand.php b/src/Command/RttRolloverCommand.php
new file mode 100644
index 0000000..9efbf17
--- /dev/null
+++ b/src/Command/RttRolloverCommand.php
@@ -0,0 +1,158 @@
+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');
+
+ $this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
+
+ if (!$force && '06-01' !== $today->format('m-d')) {
+ $message = 'No RTT rollover today: business date is not 01/06.';
+ $this->logger->info($message, ['date' => $today->format('Y-m-d')]);
+ $io->success($message);
+
+ 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)) {
+ $this->logger->info('Employee skipped: not eligible.', ['employeeId' => $employee->getId()]);
+ ++$skipped;
+
+ continue;
+ }
+
+ $existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
+ if (null !== $existing) {
+ $this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
+ ++$skipped;
+
+ continue;
+ }
+
+ try {
+ $previousYear = $targetYear - 1;
+ $carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
+ } catch (Throwable $e) {
+ $this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
+ ++$skipped;
+
+ continue;
+ }
+
+ $balance = new EmployeeRttBalance()
+ ->setEmployee($employee)
+ ->setYear($targetYear)
+ ->setOpeningMinutes($carryMinutes)
+ ->setIsLocked(false)
+ ;
+
+ $this->entityManager->persist($balance);
+ $this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]);
+ ++$created;
+ }
+
+ try {
+ $this->entityManager->flush();
+ } catch (Throwable $e) {
+ $this->logger->error('Error flushing RTT balances.', ['error' => $e->getMessage()]);
+ $io->error('RTT rollover failed: '.$e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ $message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped);
+ $this->logger->info($message);
+ $io->success($message);
+
+ 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;
+ }
+}
diff --git a/src/DataFixtures/AbsenceFixtures.php b/src/DataFixtures/AbsenceFixtures.php
new file mode 100644
index 0000000..0bac63d
--- /dev/null
+++ b/src/DataFixtures/AbsenceFixtures.php
@@ -0,0 +1,97 @@
+createAbsence(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
+ $this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
+ '2026-03-03',
+ HalfDay::AM,
+ '2026-03-03',
+ HalfDay::PM,
+ 'CP standard non forfait'
+ );
+
+ $this->createAbsence(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
+ $this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
+ '2026-03-04',
+ HalfDay::AM,
+ '2026-03-04',
+ HalfDay::PM,
+ 'CP employe 4h'
+ );
+
+ $this->createAbsence(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
+ $this->getReference(FixtureReferences::ABSENCE_TYPE_AUTRE, AbsenceType::class),
+ '2026-03-05',
+ HalfDay::AM,
+ '2026-03-05',
+ HalfDay::AM,
+ 'Absence forfait demi-journee'
+ );
+
+ $this->createAbsence(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
+ $this->getReference(FixtureReferences::ABSENCE_TYPE_ABSENT, AbsenceType::class),
+ '2026-03-06',
+ HalfDay::AM,
+ '2026-03-06',
+ HalfDay::PM,
+ 'Absence interim'
+ );
+
+ $manager->flush();
+ }
+
+ public function getDependencies(): array
+ {
+ return [
+ EmployeeFixtures::class,
+ AbsenceTypeFixtures::class,
+ ];
+ }
+
+ private function createAbsence(
+ ObjectManager $manager,
+ Employee $employee,
+ AbsenceType $type,
+ string $startDate,
+ HalfDay $startHalf,
+ string $endDate,
+ HalfDay $endHalf,
+ string $comment
+ ): void {
+ $absence = new Absence()
+ ->setEmployee($employee)
+ ->setType($type)
+ ->setStartDate(new DateTime($startDate))
+ ->setStartHalf($startHalf)
+ ->setEndDate(new DateTime($endDate))
+ ->setEndHalf($endHalf)
+ ->setComment($comment)
+ ;
+
+ $manager->persist($absence);
+ }
+}
diff --git a/src/DataFixtures/AbsenceTypeFixtures.php b/src/DataFixtures/AbsenceTypeFixtures.php
new file mode 100644
index 0000000..6c2d198
--- /dev/null
+++ b/src/DataFixtures/AbsenceTypeFixtures.php
@@ -0,0 +1,39 @@
+setCode($code)
+ ->setLabel($label)
+ ->setColor($color)
+ ->setCountAsWorkedHours($countAsWorkedHours)
+ ;
+ $manager->persist($absenceType);
+ $this->addReference($reference, $absenceType);
+ }
+
+ $manager->flush();
+ }
+}
diff --git a/src/DataFixtures/ContractFixtures.php b/src/DataFixtures/ContractFixtures.php
new file mode 100644
index 0000000..1212eef
--- /dev/null
+++ b/src/DataFixtures/ContractFixtures.php
@@ -0,0 +1,55 @@
+setName('35h')
+ ->setTrackingMode(TrackingMode::TIME)
+ ->setWeeklyHours(35)
+ ->setIsActive(true)
+ ;
+
+ $contract4h = new Contract()
+ ->setName('4h')
+ ->setTrackingMode(TrackingMode::TIME)
+ ->setWeeklyHours(4)
+ ->setIsActive(true)
+ ;
+
+ $forfait = new Contract()
+ ->setName('Forfait')
+ ->setTrackingMode(TrackingMode::PRESENCE)
+ ->setWeeklyHours(null)
+ ->setIsActive(true)
+ ;
+
+ $interim = new Contract()
+ ->setName('Interim')
+ ->setTrackingMode(TrackingMode::TIME)
+ ->setWeeklyHours(35)
+ ->setIsActive(true)
+ ;
+
+ $manager->persist($contract35);
+ $manager->persist($contract4h);
+ $manager->persist($forfait);
+ $manager->persist($interim);
+ $manager->flush();
+
+ $this->addReference(FixtureReferences::CONTRACT_35, $contract35);
+ $this->addReference(FixtureReferences::CONTRACT_4H, $contract4h);
+ $this->addReference(FixtureReferences::CONTRACT_FORFAIT, $forfait);
+ $this->addReference(FixtureReferences::CONTRACT_INTERIM, $interim);
+ }
+}
diff --git a/src/DataFixtures/EmployeeContractPeriodFixtures.php b/src/DataFixtures/EmployeeContractPeriodFixtures.php
new file mode 100644
index 0000000..29a7340
--- /dev/null
+++ b/src/DataFixtures/EmployeeContractPeriodFixtures.php
@@ -0,0 +1,91 @@
+createPeriod(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
+ $this->getReference(FixtureReferences::CONTRACT_35, Contract::class),
+ '2025-01-01',
+ null,
+ ContractNature::CDI,
+ false
+ );
+
+ $this->createPeriod(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
+ $this->getReference(FixtureReferences::CONTRACT_4H, Contract::class),
+ '2026-01-01',
+ '2026-12-31',
+ ContractNature::CDD,
+ false
+ );
+
+ $this->createPeriod(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
+ $this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class),
+ '2024-01-01',
+ null,
+ ContractNature::CDI,
+ false
+ );
+
+ $this->createPeriod(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
+ $this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class),
+ '2026-02-01',
+ '2026-06-30',
+ ContractNature::INTERIM,
+ false
+ );
+
+ $manager->flush();
+ }
+
+ public function getDependencies(): array
+ {
+ return [
+ EmployeeFixtures::class,
+ ContractFixtures::class,
+ ];
+ }
+
+ private function createPeriod(
+ ObjectManager $manager,
+ Employee $employee,
+ Contract $contract,
+ string $startDate,
+ ?string $endDate,
+ ContractNature $nature,
+ bool $paidLeaveSettled
+ ): void {
+ $period = new EmployeeContractPeriod()
+ ->setEmployee($employee)
+ ->setContract($contract)
+ ->setStartDate(new DateTimeImmutable($startDate))
+ ->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
+ ->setContractNature($nature)
+ ->setPaidLeaveSettled($paidLeaveSettled)
+ ;
+
+ $manager->persist($period);
+ }
+}
diff --git a/src/DataFixtures/EmployeeFixtures.php b/src/DataFixtures/EmployeeFixtures.php
new file mode 100644
index 0000000..330ce63
--- /dev/null
+++ b/src/DataFixtures/EmployeeFixtures.php
@@ -0,0 +1,71 @@
+getReference(FixtureReferences::SITE_MAIN, Site::class);
+
+ $employeeStandard = new Employee()
+ ->setFirstName('Alice')
+ ->setLastName('Martin')
+ ->setSite($site)
+ ->setContract($this->getReference(FixtureReferences::CONTRACT_35, Contract::class))
+ ->setDisplayOrder(1)
+ ;
+
+ $employee4h = new Employee()
+ ->setFirstName('Bruno')
+ ->setLastName('Petit')
+ ->setSite($site)
+ ->setContract($this->getReference(FixtureReferences::CONTRACT_4H, Contract::class))
+ ->setDisplayOrder(2)
+ ;
+
+ $employeeForfait = new Employee()
+ ->setFirstName('Chloe')
+ ->setLastName('Durand')
+ ->setSite($site)
+ ->setContract($this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class))
+ ->setDisplayOrder(3)
+ ;
+
+ $employeeInterim = new Employee()
+ ->setFirstName('David')
+ ->setLastName('Leroy')
+ ->setSite($site)
+ ->setContract($this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class))
+ ->setDisplayOrder(4)
+ ;
+
+ $manager->persist($employeeStandard);
+ $manager->persist($employee4h);
+ $manager->persist($employeeForfait);
+ $manager->persist($employeeInterim);
+ $manager->flush();
+
+ $this->addReference(FixtureReferences::EMPLOYEE_STANDARD, $employeeStandard);
+ $this->addReference(FixtureReferences::EMPLOYEE_4H, $employee4h);
+ $this->addReference(FixtureReferences::EMPLOYEE_FORFAIT, $employeeForfait);
+ $this->addReference(FixtureReferences::EMPLOYEE_INTERIM, $employeeInterim);
+ }
+
+ public function getDependencies(): array
+ {
+ return [
+ SiteFixtures::class,
+ ContractFixtures::class,
+ ];
+ }
+}
diff --git a/src/DataFixtures/EmployeeLeaveBalanceFixtures.php b/src/DataFixtures/EmployeeLeaveBalanceFixtures.php
new file mode 100644
index 0000000..b338be2
--- /dev/null
+++ b/src/DataFixtures/EmployeeLeaveBalanceFixtures.php
@@ -0,0 +1,80 @@
+createOpeningBalance(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
+ LeaveRuleCode::CDI_CDD_NON_FORFAIT,
+ 2026,
+ 0.0,
+ 0.0
+ );
+
+ $this->createOpeningBalance(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
+ LeaveRuleCode::CDI_CDD_NON_FORFAIT,
+ 2026,
+ 0.0,
+ 0.0
+ );
+
+ $this->createOpeningBalance(
+ $manager,
+ $this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
+ LeaveRuleCode::FORFAIT_218,
+ 2026,
+ 0.0,
+ 0.0
+ );
+
+ $manager->flush();
+ }
+
+ public function getDependencies(): array
+ {
+ return [
+ EmployeeFixtures::class,
+ ];
+ }
+
+ private function createOpeningBalance(
+ ObjectManager $manager,
+ Employee $employee,
+ LeaveRuleCode $ruleCode,
+ int $year,
+ float $openingDays,
+ float $openingSaturdays
+ ): void {
+ $balance = new EmployeeLeaveBalance()
+ ->setEmployee($employee)
+ ->setRuleCode($ruleCode)
+ ->setYear($year)
+ ->setOpeningDays($openingDays)
+ ->setOpeningSaturdays($openingSaturdays)
+ ->setAccruedDays(0.0)
+ ->setAccruedSaturdays(0.0)
+ ->setTakenDays(0.0)
+ ->setTakenSaturdays(0.0)
+ ->setClosingDays($openingDays)
+ ->setClosingSaturdays($openingSaturdays)
+ ->setIsLocked(false)
+ ;
+
+ $manager->persist($balance);
+ }
+}
diff --git a/src/DataFixtures/FixtureReferences.php b/src/DataFixtures/FixtureReferences.php
new file mode 100644
index 0000000..0355c04
--- /dev/null
+++ b/src/DataFixtures/FixtureReferences.php
@@ -0,0 +1,31 @@
+setName('SITE TEST')
+ ->setColor('#75aed9')
+ ->setDisplayOrder(1)
+ ;
+
+ $manager->persist($site);
+ $manager->flush();
+
+ $this->addReference(FixtureReferences::SITE_MAIN, $site);
+ }
+}
diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php
new file mode 100644
index 0000000..06a4e85
--- /dev/null
+++ b/src/DataFixtures/UserFixtures.php
@@ -0,0 +1,26 @@
+setUsername('emilie')
+ ->setRoles(['ROLE_ADMIN'])
+ ->setPassword('$2y$13$swd6WU4z7Z4MeZwl9hHaFez8xB/GMZxyrCDQUlFEY55JQUzTW0bPO')
+ ;
+
+ $manager->persist($emilie);
+ $manager->flush();
+
+ $this->addReference(FixtureReferences::USER_ADMIN_EMILIE, $emilie);
+ }
+}
diff --git a/src/Dto/Employees/ContractHistoryItem.php b/src/Dto/Employees/ContractHistoryItem.php
new file mode 100644
index 0000000..d6fc7ff
--- /dev/null
+++ b/src/Dto/Employees/ContractHistoryItem.php
@@ -0,0 +1,27 @@
+ 'exact'])]
+#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
#[ORM\Table(name: 'absences')]
class Absence
diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php
index 92aaa15..8c97575 100644
--- a/src/Entity/Employee.php
+++ b/src/Entity/Employee.php
@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
+use App\Dto\Employees\ContractHistoryItem;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
@@ -74,6 +75,12 @@ class Employee
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
+ #[Groups(['employee:write'])]
+ private ?bool $contractPaidLeaveSettled = null;
+
+ #[Groups(['employee:write'])]
+ private ?string $contractComment = null;
+
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -109,6 +116,15 @@ class Employee
return $this;
}
+ #[Groups(['employee:read'])]
+ public function getInitials(): string
+ {
+ $first = mb_strtoupper(mb_substr(trim($this->firstName), 0, 1));
+ $last = mb_strtoupper(mb_substr(trim($this->lastName), 0, 1));
+
+ return $first.$last;
+ }
+
public function getSite(): ?Site
{
return $this->site;
@@ -186,6 +202,30 @@ class Employee
return $this;
}
+ public function getContractPaidLeaveSettled(): ?bool
+ {
+ return $this->contractPaidLeaveSettled;
+ }
+
+ public function setContractPaidLeaveSettled(?bool $contractPaidLeaveSettled): self
+ {
+ $this->contractPaidLeaveSettled = $contractPaidLeaveSettled;
+
+ return $this;
+ }
+
+ public function getContractComment(): ?string
+ {
+ return $this->contractComment;
+ }
+
+ public function setContractComment(?string $contractComment): self
+ {
+ $this->contractComment = $contractComment;
+
+ return $this;
+ }
+
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
@@ -204,6 +244,36 @@ class Employee
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
+ /**
+ * @return list
+ */
+ #[Groups(['employee:read'])]
+ public function getContractHistory(): array
+ {
+ $periods = $this->contractPeriods->toArray();
+ usort(
+ $periods,
+ static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $b->getStartDate() <=> $a->getStartDate()
+ );
+
+ return array_map(
+ static function (EmployeeContractPeriod $period): ContractHistoryItem {
+ $contract = $period->getContract();
+
+ return new ContractHistoryItem(
+ contractId: $contract?->getId(),
+ contractName: $contract?->getName(),
+ weeklyHours: $contract?->getWeeklyHours(),
+ contractNature: $period->getContractNatureEnum()->value,
+ startDate: $period->getStartDate()->format('Y-m-d'),
+ endDate: $period->getEndDate()?->format('Y-m-d'),
+ comment: $period->getComment(),
+ );
+ },
+ $periods
+ );
+ }
+
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
diff --git a/src/Entity/EmployeeContractPeriod.php b/src/Entity/EmployeeContractPeriod.php
index 9cb276c..a01f63a 100644
--- a/src/Entity/EmployeeContractPeriod.php
+++ b/src/Entity/EmployeeContractPeriod.php
@@ -37,6 +37,12 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ private bool $paidLeaveSettled = false;
+
+ #[ORM\Column(type: 'text', nullable: true)]
+ private ?string $comment = null;
+
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -121,4 +127,28 @@ class EmployeeContractPeriod
{
return $this->createdAt;
}
+
+ public function isPaidLeaveSettled(): bool
+ {
+ return $this->paidLeaveSettled;
+ }
+
+ public function setPaidLeaveSettled(bool $paidLeaveSettled): self
+ {
+ $this->paidLeaveSettled = $paidLeaveSettled;
+
+ return $this;
+ }
+
+ public function getComment(): ?string
+ {
+ return $this->comment;
+ }
+
+ public function setComment(?string $comment): self
+ {
+ $this->comment = $comment;
+
+ return $this;
+ }
}
diff --git a/src/Entity/EmployeeLeaveBalance.php b/src/Entity/EmployeeLeaveBalance.php
new file mode 100644
index 0000000..2bdf959
--- /dev/null
+++ b/src/Entity/EmployeeLeaveBalance.php
@@ -0,0 +1,253 @@
+ 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'])]
+#[ORM\UniqueConstraint(name: 'uniq_employee_leave_balance', columns: ['employee_id', 'rule_code', 'year'])]
+#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_leave_balance_employee_year')]
+class EmployeeLeaveBalance
+{
+ #[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(length: 64, options: ['comment' => 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'])]
+ private string $ruleCode = '';
+
+ #[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice de reference (ex: 2026).'])]
+ private int $year = 0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en jours a l ouverture de l exercice.'])]
+ private float $openingDays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en samedis a l ouverture (0 pour forfait).'])]
+ private float $openingSaturdays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Droits jours acquis sur l exercice courant.'])]
+ private float $accruedDays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Droits samedis acquis sur l exercice courant.'])]
+ private float $accruedSaturdays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Jours de conges consommes sur l exercice.'])]
+ private float $takenDays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Samedis consommes sur l exercice.'])]
+ private float $takenSaturdays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture jours sur l exercice.'])]
+ private float $closingDays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture samedis sur l exercice.'])]
+ private float $closingSaturdays = 0.0;
+
+ #[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
+ private float $fractionedDays = 0.0;
+
+ #[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice 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 getRuleCode(): string
+ {
+ return $this->ruleCode;
+ }
+
+ public function setRuleCode(LeaveRuleCode|string $ruleCode): self
+ {
+ $this->ruleCode = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
+
+ return $this;
+ }
+
+ public function getYear(): int
+ {
+ return $this->year;
+ }
+
+ public function setYear(int $year): self
+ {
+ $this->year = $year;
+
+ return $this;
+ }
+
+ public function getOpeningDays(): float
+ {
+ return $this->openingDays;
+ }
+
+ public function setOpeningDays(float $openingDays): self
+ {
+ $this->openingDays = $openingDays;
+
+ return $this;
+ }
+
+ public function getOpeningSaturdays(): float
+ {
+ return $this->openingSaturdays;
+ }
+
+ public function setOpeningSaturdays(float $openingSaturdays): self
+ {
+ $this->openingSaturdays = $openingSaturdays;
+
+ return $this;
+ }
+
+ public function getAccruedDays(): float
+ {
+ return $this->accruedDays;
+ }
+
+ public function setAccruedDays(float $accruedDays): self
+ {
+ $this->accruedDays = $accruedDays;
+
+ return $this;
+ }
+
+ public function getAccruedSaturdays(): float
+ {
+ return $this->accruedSaturdays;
+ }
+
+ public function setAccruedSaturdays(float $accruedSaturdays): self
+ {
+ $this->accruedSaturdays = $accruedSaturdays;
+
+ return $this;
+ }
+
+ public function getTakenDays(): float
+ {
+ return $this->takenDays;
+ }
+
+ public function setTakenDays(float $takenDays): self
+ {
+ $this->takenDays = $takenDays;
+
+ return $this;
+ }
+
+ public function getTakenSaturdays(): float
+ {
+ return $this->takenSaturdays;
+ }
+
+ public function setTakenSaturdays(float $takenSaturdays): self
+ {
+ $this->takenSaturdays = $takenSaturdays;
+
+ return $this;
+ }
+
+ public function getClosingDays(): float
+ {
+ return $this->closingDays;
+ }
+
+ public function setClosingDays(float $closingDays): self
+ {
+ $this->closingDays = $closingDays;
+
+ return $this;
+ }
+
+ public function getClosingSaturdays(): float
+ {
+ return $this->closingSaturdays;
+ }
+
+ public function setClosingSaturdays(float $closingSaturdays): self
+ {
+ $this->closingSaturdays = $closingSaturdays;
+
+ return $this;
+ }
+
+ public function getFractionedDays(): float
+ {
+ return $this->fractionedDays;
+ }
+
+ public function setFractionedDays(float $fractionedDays): self
+ {
+ $this->fractionedDays = $fractionedDays;
+
+ 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;
+ }
+}
diff --git a/src/Entity/EmployeeRttBalance.php b/src/Entity/EmployeeRttBalance.php
new file mode 100644
index 0000000..598accf
--- /dev/null
+++ b/src/Entity/EmployeeRttBalance.php
@@ -0,0 +1,117 @@
+ '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;
+ }
+}
diff --git a/src/Entity/EmployeeRttPayment.php b/src/Entity/EmployeeRttPayment.php
new file mode 100644
index 0000000..a3a3e2f
--- /dev/null
+++ b/src/Entity/EmployeeRttPayment.php
@@ -0,0 +1,131 @@
+ 'Paiements RTT par employe, mois et exercice.'])]
+#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
+class EmployeeRttPayment
+{
+ #[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.'])]
+ private int $year = 0;
+
+ #[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
+ private int $month = 0;
+
+ #[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
+ private int $minutes = 0;
+
+ #[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
+ private string $rate = '';
+
+ #[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 getMonth(): int
+ {
+ return $this->month;
+ }
+
+ public function setMonth(int $month): self
+ {
+ $this->month = $month;
+
+ return $this;
+ }
+
+ public function getMinutes(): int
+ {
+ return $this->minutes;
+ }
+
+ public function setMinutes(int $minutes): self
+ {
+ $this->minutes = $minutes;
+
+ return $this;
+ }
+
+ public function getRate(): string
+ {
+ return $this->rate;
+ }
+
+ public function setRate(string $rate): self
+ {
+ $this->rate = $rate;
+
+ 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;
+ }
+}
diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php
new file mode 100644
index 0000000..0df2c98
--- /dev/null
+++ b/src/Entity/Notification.php
@@ -0,0 +1,196 @@
+ '\d+'],
+ normalizationContext: ['groups' => ['notification:read']],
+ security: "is_granted('ROLE_USER')"
+ ),
+ new GetCollection(
+ uriTemplate: '/notifications/unread',
+ normalizationContext: ['groups' => ['notification:read']],
+ security: "is_granted('ROLE_USER')",
+ provider: UnreadNotificationsProvider::class,
+ paginationEnabled: false
+ ),
+ new GetCollection(
+ uriTemplate: '/notifications/today',
+ normalizationContext: ['groups' => ['notification:read']],
+ security: "is_granted('ROLE_USER')",
+ provider: NotificationTodayProvider::class,
+ paginationEnabled: false
+ ),
+ new GetCollection(
+ uriTemplate: '/notifications/history',
+ normalizationContext: ['groups' => ['notification:read']],
+ security: "is_granted('ROLE_USER')",
+ provider: NotificationHistoryProvider::class,
+ paginationEnabled: false
+ ),
+ new Post(
+ uriTemplate: '/notifications/mark-all-read',
+ security: "is_granted('ROLE_USER')",
+ input: false,
+ output: false,
+ read: false,
+ processor: MarkAllNotificationsReadProcessor::class
+ ),
+ ]
+)]
+#[ORM\Entity(repositoryClass: NotificationRepository::class)]
+#[ORM\Table(name: 'notifications')]
+#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
+class Notification
+{
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column(type: 'integer')]
+ #[Groups(['notification:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ private ?User $recipient = null;
+
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+ private ?User $actor = null;
+
+ #[ORM\Column(type: 'text')]
+ #[Groups(['notification:read'])]
+ private string $message = '';
+
+ #[ORM\Column(type: 'string', length: 60, options: ['default' => ''])]
+ #[Groups(['notification:read'])]
+ private string $category = '';
+
+ #[ORM\Column(type: 'string', length: 255, options: ['default' => ''])]
+ #[Groups(['notification:read'])]
+ private string $target = '';
+
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ #[Groups(['notification:read'])]
+ private bool $isRead = false;
+
+ #[ORM\Column(type: 'datetime_immutable')]
+ #[Groups(['notification:read'])]
+ private DateTimeImmutable $createdAt;
+
+ public function __construct()
+ {
+ $this->createdAt = new DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getRecipient(): ?User
+ {
+ return $this->recipient;
+ }
+
+ public function setRecipient(?User $recipient): self
+ {
+ $this->recipient = $recipient;
+
+ return $this;
+ }
+
+ public function getActor(): ?User
+ {
+ return $this->actor;
+ }
+
+ public function setActor(?User $actor): self
+ {
+ $this->actor = $actor;
+
+ return $this;
+ }
+
+ #[Groups(['notification:read'])]
+ public function getActorName(): string
+ {
+ return $this->actor?->getUsername() ?? '';
+ }
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ public function setMessage(string $message): self
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ public function getCategory(): string
+ {
+ return $this->category;
+ }
+
+ public function setCategory(string $category): self
+ {
+ $this->category = $category;
+
+ return $this;
+ }
+
+ public function getTarget(): string
+ {
+ return $this->target;
+ }
+
+ public function setTarget(string $target): self
+ {
+ $this->target = $target;
+
+ return $this;
+ }
+
+ public function isRead(): bool
+ {
+ return $this->isRead;
+ }
+
+ public function getIsRead(): bool
+ {
+ return $this->isRead;
+ }
+
+ public function setIsRead(bool $isRead): self
+ {
+ $this->isRead = $isRead;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 329935a..2164fd4 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
+use App\Repository\UserRepository;
use App\State\CurrentUserProvider;
use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection;
@@ -52,7 +53,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
]
)]
-#[ORM\Entity]
+#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
diff --git a/src/Entity/WorkHour.php b/src/Entity/WorkHour.php
index f80681e..6a3dfb2 100644
--- a/src/Entity/WorkHour.php
+++ b/src/Entity/WorkHour.php
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
+use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -106,6 +107,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ #[Groups(['work_hour:read'])]
+ private ?DateTimeImmutable $updatedAt = null;
+
public function getId(): ?int
{
return $this->id;
@@ -274,4 +279,16 @@ class WorkHour
return $this;
}
+
+ public function getUpdatedAt(): ?DateTimeImmutable
+ {
+ return $this->updatedAt;
+ }
+
+ public function setUpdatedAt(?DateTimeImmutable $updatedAt): self
+ {
+ $this->updatedAt = $updatedAt;
+
+ return $this;
+ }
}
diff --git a/src/Enum/LeaveRuleCode.php b/src/Enum/LeaveRuleCode.php
new file mode 100644
index 0000000..df225e6
--- /dev/null
+++ b/src/Enum/LeaveRuleCode.php
@@ -0,0 +1,12 @@
+ $absences
return $qb->getQuery()->getResult();
}
+
+ /**
+ * @return list
+ */
+ public function findByEmployeeAndOverlappingDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
+ {
+ $fromDate = DateTimeImmutable::createFromInterface($from);
+ $toDate = DateTimeImmutable::createFromInterface($to);
+
+ $qb = $this->createQueryBuilder('a')
+ ->leftJoin('a.employee', 'e')
+ ->leftJoin('a.type', 't')
+ ->addSelect('e', 't')
+ ->andWhere('a.employee = :employee')
+ ->andWhere('a.startDate <= :to')
+ ->andWhere('a.endDate >= :from')
+ ->setParameter('employee', $employee)
+ ->setParameter('from', $fromDate)
+ ->setParameter('to', $toDate)
+ ->orderBy('a.startDate', 'ASC')
+ ;
+
+ // @var list $absences
+ return $qb->getQuery()->getResult();
+ }
}
diff --git a/src/Repository/Contract/EmployeeContractPeriodReadRepositoryInterface.php b/src/Repository/Contract/EmployeeContractPeriodReadRepositoryInterface.php
new file mode 100644
index 0000000..8dacc60
--- /dev/null
+++ b/src/Repository/Contract/EmployeeContractPeriodReadRepositoryInterface.php
@@ -0,0 +1,14 @@
+
*/
-final class EmployeeContractPeriodRepository extends ServiceEntityRepository
+final class EmployeeContractPeriodRepository extends ServiceEntityRepository implements EmployeeContractPeriodReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
@@ -72,4 +73,56 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository
->execute()
;
}
+
+ public function hasPaidLeaveSettledClosureBetween(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): bool {
+ $count = $this->createQueryBuilder('p')
+ ->select('COUNT(p.id)')
+ ->andWhere('p.employee = :employee')
+ ->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
+ ->andWhere('p.endDate IS NOT NULL')
+ ->andWhere('p.endDate >= :from')
+ ->andWhere('p.endDate <= :to')
+ ->setParameter('employee', $employee)
+ ->setParameter('paidLeaveSettled', true)
+ ->setParameter('from', $from)
+ ->setParameter('to', $to)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ return (int) $count > 0;
+ }
+
+ public function findLatestPaidLeaveSettledClosureDateBetween(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): ?DateTimeImmutable {
+ $result = $this->createQueryBuilder('p')
+ ->select('p.endDate AS endDate')
+ ->andWhere('p.employee = :employee')
+ ->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
+ ->andWhere('p.endDate IS NOT NULL')
+ ->andWhere('p.endDate >= :from')
+ ->andWhere('p.endDate <= :to')
+ ->setParameter('employee', $employee)
+ ->setParameter('paidLeaveSettled', true)
+ ->setParameter('from', $from)
+ ->setParameter('to', $to)
+ ->orderBy('p.endDate', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ if (!is_array($result) || !isset($result['endDate']) || !$result['endDate'] instanceof DateTimeImmutable) {
+ return null;
+ }
+
+ return $result['endDate'];
+ }
}
diff --git a/src/Repository/EmployeeLeaveBalanceRepository.php b/src/Repository/EmployeeLeaveBalanceRepository.php
new file mode 100644
index 0000000..098a909
--- /dev/null
+++ b/src/Repository/EmployeeLeaveBalanceRepository.php
@@ -0,0 +1,59 @@
+
+ */
+final class EmployeeLeaveBalanceRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, EmployeeLeaveBalance::class);
+ }
+
+ public function findOneByEmployeeRuleAndYear(
+ Employee $employee,
+ LeaveRuleCode|string $ruleCode,
+ int $year
+ ): ?EmployeeLeaveBalance {
+ $ruleCodeValue = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
+
+ return $this->createQueryBuilder('b')
+ ->andWhere('b.employee = :employee')
+ ->andWhere('b.ruleCode = :ruleCode')
+ ->andWhere('b.year = :year')
+ ->setParameter('employee', $employee)
+ ->setParameter('ruleCode', $ruleCodeValue)
+ ->setParameter('year', $year)
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
+
+ public function findEarliestYearForEmployee(Employee $employee): ?int
+ {
+ $result = $this->createQueryBuilder('b')
+ ->select('MIN(b.year) AS year')
+ ->andWhere('b.employee = :employee')
+ ->setParameter('employee', $employee)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ if (!is_array($result) || !array_key_exists('year', $result) || null === $result['year']) {
+ return null;
+ }
+
+ return (int) $result['year'];
+ }
+}
diff --git a/src/Repository/EmployeeRttBalanceRepository.php b/src/Repository/EmployeeRttBalanceRepository.php
new file mode 100644
index 0000000..b438d65
--- /dev/null
+++ b/src/Repository/EmployeeRttBalanceRepository.php
@@ -0,0 +1,34 @@
+
+ */
+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()
+ ;
+ }
+}
diff --git a/src/Repository/EmployeeRttPaymentRepository.php b/src/Repository/EmployeeRttPaymentRepository.php
new file mode 100644
index 0000000..5a2b285
--- /dev/null
+++ b/src/Repository/EmployeeRttPaymentRepository.php
@@ -0,0 +1,47 @@
+
+ */
+final class EmployeeRttPaymentRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, EmployeeRttPayment::class);
+ }
+
+ public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
+ {
+ return $this->findOneBy([
+ 'employee' => $employee,
+ 'year' => $year,
+ 'month' => $month,
+ 'rate' => $rate,
+ ]);
+ }
+
+ /**
+ * @return EmployeeRttPayment[]
+ */
+ public function findByEmployeeAndYear(Employee $employee, int $year): array
+ {
+ return $this->createQueryBuilder('p')
+ ->andWhere('p.employee = :employee')
+ ->andWhere('p.year = :year')
+ ->setParameter('employee', $employee)
+ ->setParameter('year', $year)
+ ->addOrderBy('p.month', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+}
diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php
new file mode 100644
index 0000000..9136aac
--- /dev/null
+++ b/src/Repository/NotificationRepository.php
@@ -0,0 +1,87 @@
+
+ */
+final class NotificationRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, Notification::class);
+ }
+
+ /**
+ * @return list
+ */
+ public function findUnreadByRecipient(User $recipient): array
+ {
+ return $this->createQueryBuilder('n')
+ ->andWhere('n.recipient = :recipient')
+ ->andWhere('n.isRead = :isRead')
+ ->setParameter('recipient', $recipient)
+ ->setParameter('isRead', false)
+ ->orderBy('n.createdAt', 'DESC')
+ ->setMaxResults(50)
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * @return list
+ */
+ public function findTodayByRecipient(User $recipient): array
+ {
+ $todayStart = new DateTimeImmutable('today 00:00:00');
+
+ return $this->createQueryBuilder('n')
+ ->andWhere('n.recipient = :recipient')
+ ->andWhere('n.createdAt >= :todayStart')
+ ->setParameter('recipient', $recipient)
+ ->setParameter('todayStart', $todayStart)
+ ->orderBy('n.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * @return list
+ */
+ public function findLatestByRecipient(User $recipient, int $limit = 10): array
+ {
+ return $this->createQueryBuilder('n')
+ ->andWhere('n.recipient = :recipient')
+ ->setParameter('recipient', $recipient)
+ ->orderBy('n.createdAt', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ public function markAllReadByRecipient(User $recipient): int
+ {
+ return $this->createQueryBuilder('n')
+ ->update()
+ ->set('n.isRead', ':isRead')
+ ->andWhere('n.recipient = :recipient')
+ ->andWhere('n.isRead = :current')
+ ->setParameter('isRead', true)
+ ->setParameter('current', false)
+ ->setParameter('recipient', $recipient)
+ ->getQuery()
+ ->execute()
+ ;
+ }
+}
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
new file mode 100644
index 0000000..50f5d97
--- /dev/null
+++ b/src/Repository/UserRepository.php
@@ -0,0 +1,38 @@
+
+ */
+final class UserRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, User::class);
+ }
+
+ /**
+ * @return list
+ */
+ public function findAllAdmins(): array
+ {
+ /** @var list $users */
+ $users = $this->createQueryBuilder('u')
+ ->orderBy('u.id', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return array_values(array_filter(
+ $users,
+ static fn (User $user): bool => in_array('ROLE_ADMIN', $user->getRoles(), true)
+ ));
+ }
+}
diff --git a/src/Repository/WorkHourRepository.php b/src/Repository/WorkHourRepository.php
index 8c67deb..21eeb4e 100644
--- a/src/Repository/WorkHourRepository.php
+++ b/src/Repository/WorkHourRepository.php
@@ -137,4 +137,23 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
+
+ public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
+ {
+ $workDate = DateTimeImmutable::createFromInterface($date);
+
+ $qb = $this->createQueryBuilder('w')
+ ->select('COUNT(w.id)')
+ ->leftJoin('w.employee', 'e')
+ ->leftJoin('e.site', 's')
+ ->andWhere('s.id = :siteId')
+ ->andWhere('w.workDate = :workDate')
+ ->andWhere('w.isSiteValid = :isSiteValid')
+ ->setParameter('siteId', $siteId)
+ ->setParameter('workDate', $workDate)
+ ->setParameter('isSiteValid', false)
+ ;
+
+ return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
+ }
}
diff --git a/src/Service/Contracts/EmployeeContractChangeRequest.php b/src/Service/Contracts/EmployeeContractChangeRequest.php
new file mode 100644
index 0000000..e1e0e68
--- /dev/null
+++ b/src/Service/Contracts/EmployeeContractChangeRequest.php
@@ -0,0 +1,36 @@
+contractNature
+ || null !== $this->contractStartDate
+ || null !== $this->contractEndDate
+ || null !== $this->contractPaidLeaveSettled
+ || null !== $this->contractComment;
+ }
+
+ public function isCloseOnlyRequest(bool $contractChanged): bool
+ {
+ return !$contractChanged
+ && null === $this->contractStartDate
+ && null === $this->contractNature
+ && null !== $this->contractEndDate;
+ }
+}
diff --git a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
new file mode 100644
index 0000000..57ea84e
--- /dev/null
+++ b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
@@ -0,0 +1,49 @@
+resolveContractNature($employee->getContractNature()),
+ contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'),
+ contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
+ contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
+ contractComment: $employee->getContractComment(),
+ );
+ }
+
+ private function resolveContractNature(?string $raw): ?ContractNature
+ {
+ if (null === $raw || '' === trim($raw)) {
+ return null;
+ }
+
+ return ContractNature::tryFrom(trim($raw))
+ ?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
+ }
+
+ private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
+ {
+ if (null === $raw || '' === trim($raw)) {
+ return null;
+ }
+
+ $value = trim($raw);
+ $date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
+ if (!$date || $date->format('Y-m-d') !== $value) {
+ throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
+ }
+
+ return $date;
+ }
+}
diff --git a/src/Service/Contracts/EmployeeContractPeriodBuilder.php b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
new file mode 100644
index 0000000..a297946
--- /dev/null
+++ b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
@@ -0,0 +1,30 @@
+setEmployee($employee)
+ ->setContract($contract)
+ ->setStartDate($startDate)
+ ->setEndDate($endDate)
+ ->setContractNature($nature)
+ ;
+ }
+}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManager.php b/src/Service/Contracts/EmployeeContractPeriodManager.php
new file mode 100644
index 0000000..afe029c
--- /dev/null
+++ b/src/Service/Contracts/EmployeeContractPeriodManager.php
@@ -0,0 +1,98 @@
+periodValidator->assertPeriodDates($startDate, $endDate, $nature);
+
+ $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
+ if (null !== $covered) {
+ return;
+ }
+
+ $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
+ $this->entityManager->flush();
+ }
+
+ public function closeCurrentPeriod(
+ ?EmployeeContractPeriod $todayPeriod,
+ DateTimeImmutable $requestedEndDate,
+ bool $paidLeaveSettled,
+ ?string $comment = null
+ ): void {
+ if (null === $todayPeriod) {
+ throw new UnprocessableEntityHttpException('No active contract period to close.');
+ }
+
+ $this->periodValidator->assertCloseEndDateCanBeApplied(
+ $todayPeriod->getStartDate(),
+ $todayPeriod->getEndDate(),
+ $requestedEndDate,
+ $todayPeriod->getContractNatureEnum()
+ );
+
+ $todayPeriod->setEndDate($requestedEndDate);
+ $todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
+ $todayPeriod->setComment($comment);
+ $this->entityManager->flush();
+ }
+
+ public function createNextPeriod(
+ Employee $employee,
+ Contract $contract,
+ DateTimeImmutable $startDate,
+ ?DateTimeImmutable $endDate,
+ ContractNature $nature,
+ ?EmployeeContractPeriod $todayPeriod
+ ): void {
+ $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
+
+ if (null !== $todayPeriod) {
+ $this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
+
+ if (null === $todayPeriod->getEndDate()) {
+ $todayPeriod->setEndDate($startDate->modify('-1 day'));
+ }
+ }
+
+ $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
+ $this->entityManager->flush();
+ }
+
+ private function persistNewPeriod(
+ Employee $employee,
+ Contract $contract,
+ DateTimeImmutable $startDate,
+ ?DateTimeImmutable $endDate,
+ ContractNature $nature,
+ ): void {
+ $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
+ $this->entityManager->persist($period);
+ }
+}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
new file mode 100644
index 0000000..c93d350
--- /dev/null
+++ b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
@@ -0,0 +1,38 @@
+requiresEndDate() && null === $endDate) {
+ throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
+ }
+
+ if (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
+ throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
+ }
+ }
+
+ public function assertCloseEndDateCanBeApplied(
+ DateTimeImmutable $startDate,
+ ?DateTimeImmutable $currentEndDate,
+ DateTimeImmutable $requestedEndDate,
+ ContractNature $nature
+ ): void {
+ $this->assertPeriodDates($startDate, $requestedEndDate, $nature, true);
+
+ if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
+ throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
+ }
+ }
+
+ public function assertNextStartDateCompatible(
+ DateTimeImmutable $startDate,
+ EmployeeContractPeriod $currentPeriod
+ ): void {
+ $currentEndDate = $currentPeriod->getEndDate();
+ if (null === $currentEndDate) {
+ if ($startDate <= $currentPeriod->getStartDate()) {
+ throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
+ }
+
+ return;
+ }
+
+ if ($startDate <= $currentEndDate) {
+ throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
+ }
+ }
+}
diff --git a/src/Service/Leave/LeaveBalanceComputationService.php b/src/Service/Leave/LeaveBalanceComputationService.php
new file mode 100644
index 0000000..359bd7b
--- /dev/null
+++ b/src/Service/Leave/LeaveBalanceComputationService.php
@@ -0,0 +1,396 @@
+resolveFirstComputationYear($employee, $ruleCode, $targetYear);
+ if ($targetYear < $firstYear) {
+ return [0.0, 0.0];
+ }
+
+ $previousRemainingDays = 0.0;
+ $previousRemainingSaturdays = 0.0;
+
+ for ($year = $firstYear; $year <= $targetYear; ++$year) {
+ [$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
+
+ $carryDays = 0.0;
+ $carrySaturdays = 0.0;
+ if ($year > $firstYear) {
+ [$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
+ $hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
+ if (!$hasSettlementOnPreviousYear) {
+ $carryDays = $previousRemainingDays;
+ $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0;
+ }
+ }
+
+ $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
+ if ($effectiveFrom > $from) {
+ $carryDays = 0.0;
+ $carrySaturdays = 0.0;
+ }
+
+ $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
+
+ if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
+ $acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
+ $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
+ [$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
+ $previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
+ $previousRemainingSaturdays = 0.0;
+
+ continue;
+ }
+
+ $generatedDays = $this->computeAccruedDays(
+ $this->resolveAnnualDays($employee),
+ $this->resolveDaysAccrualPerMonth($employee),
+ $effectiveFrom,
+ $to
+ );
+ $generatedSaturdays = $this->computeAccruedDays(
+ $this->resolveAnnualSaturdays($employee),
+ $this->resolveSaturdayAccrualPerMonth($employee),
+ $effectiveFrom,
+ $to
+ );
+
+ $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
+ [$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
+
+ $acquiredWithFractioned = $carryDays + $fractionedDays;
+ $takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
+ $remainingAcquired = $acquiredWithFractioned - $takenFromAcquired;
+ $remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
+ $remainingGenerated = $generatedDays - $remainingToImpute;
+
+ $takenFromAcquiredSaturdays = min(max(0.0, $carrySaturdays), $takenSaturdays);
+ $remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
+ $remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
+ $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
+
+ $previousRemainingDays = $remainingAcquired + $remainingGenerated;
+ $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
+ }
+
+ return [$previousRemainingDays, $previousRemainingSaturdays];
+ }
+
+ /**
+ * @return array{DateTimeImmutable, DateTimeImmutable}
+ */
+ public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array
+ {
+ if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
+ return [
+ new DateTimeImmutable(sprintf('%d-01-01', $year)),
+ new DateTimeImmutable(sprintf('%d-12-31', $year)),
+ ];
+ }
+
+ return [
+ new DateTimeImmutable(sprintf('%d-06-01', $year - 1)),
+ new DateTimeImmutable(sprintf('%d-05-31', $year)),
+ ];
+ }
+
+ public function hasPaidLeaveSettledClosureBetween(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): bool {
+ return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to);
+ }
+
+ private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int
+ {
+ $history = $employee->getContractHistory();
+ if ([] === $history) {
+ return $fallbackYear;
+ }
+
+ $oldestStartDate = null;
+ foreach ($history as $item) {
+ $start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
+ if (!$start) {
+ continue;
+ }
+ if (null === $oldestStartDate || $start < $oldestStartDate) {
+ $oldestStartDate = $start;
+ }
+ }
+
+ if (null === $oldestStartDate) {
+ return $fallbackYear;
+ }
+
+ if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
+ return (int) $oldestStartDate->format('Y');
+ }
+
+ $startYear = (int) $oldestStartDate->format('Y');
+ $startMonth = (int) $oldestStartDate->format('n');
+
+ return $startMonth >= 6 ? $startYear + 1 : $startYear;
+ }
+
+ private function resolveEffectivePeriodStart(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): DateTimeImmutable {
+ $latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
+ $start = $from;
+ if (null !== $latestSettledClosure) {
+ $nextDay = $latestSettledClosure->modify('+1 day');
+ if ($nextDay > $start) {
+ $start = $nextDay;
+ }
+ }
+
+ $earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
+ if (null !== $earliestContractStart && $earliestContractStart > $start) {
+ $start = $earliestContractStart;
+ }
+
+ return $start;
+ }
+
+ private function resolveEarliestContractStartWithinRange(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): ?DateTimeImmutable {
+ $earliest = null;
+ foreach ($employee->getContractHistory() as $period) {
+ $start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
+ if (!$start) {
+ continue;
+ }
+
+ $end = null;
+ if (null !== $period->endDate && '' !== trim($period->endDate)) {
+ $end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
+ }
+
+ if ($start > $to) {
+ continue;
+ }
+ if ($end instanceof DateTimeImmutable && $end < $from) {
+ continue;
+ }
+
+ $candidate = $start < $from ? $from : $start;
+ if (null === $earliest || $candidate < $earliest) {
+ $earliest = $candidate;
+ }
+ }
+
+ return $earliest;
+ }
+
+ private function resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float
+ {
+ $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
+
+ return null !== $balance ? $balance->getFractionedDays() : 0.0;
+ }
+
+ private function resolveAnnualDays(Employee $employee): float
+ {
+ return 4 === $employee->getContract()?->getWeeklyHours()
+ ? self::FOUR_HOUR_ANNUAL_DAYS
+ : self::STANDARD_ANNUAL_DAYS;
+ }
+
+ private function resolveAnnualSaturdays(Employee $employee): float
+ {
+ return 4 === $employee->getContract()?->getWeeklyHours()
+ ? 0.0
+ : self::STANDARD_ANNUAL_SATURDAYS;
+ }
+
+ private function resolveDaysAccrualPerMonth(Employee $employee): float
+ {
+ return 4 === $employee->getContract()?->getWeeklyHours()
+ ? self::FOUR_HOUR_ACCRUAL_PER_MONTH
+ : self::STANDARD_ACCRUAL_PER_MONTH;
+ }
+
+ private function resolveSaturdayAccrualPerMonth(Employee $employee): float
+ {
+ return 4 === $employee->getContract()?->getWeeklyHours()
+ ? 0.0
+ : self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
+ }
+
+ private function computeAccruedDays(
+ float $annualCap,
+ float $accrualPerMonth,
+ DateTimeImmutable $periodStart,
+ DateTimeImmutable $periodEnd
+ ): float {
+ if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
+ return 0.0;
+ }
+
+ $monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
+ + ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
+ + 1;
+
+ return min($annualCap, $monthsElapsed * $accrualPerMonth);
+ }
+
+ private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
+ {
+ $publicHolidays = $this->buildPublicHolidayMap($from, $to);
+ $count = 0;
+ for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
+ $weekDay = (int) $cursor->format('N');
+ $dayKey = $cursor->format('Y-m-d');
+ if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
+ ++$count;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
+ {
+ $map = [];
+ $startYear = (int) $from->format('Y');
+ $endYear = (int) $to->format('Y');
+
+ try {
+ for ($year = $startYear; $year <= $endYear; ++$year) {
+ $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
+ foreach ($holidays as $date => $label) {
+ $map[(string) $date] = (string) $label;
+ }
+ }
+ } catch (Throwable) {
+ return [];
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param list $absences
+ *
+ * @return array{float, float}
+ */
+ private function computeTakenAbsences(
+ array $absences,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to,
+ bool $countOnlyCp,
+ bool $splitSaturdays
+ ): array {
+ $takenDays = 0.0;
+ $takenSaturdays = 0.0;
+
+ foreach ($absences as $absence) {
+ if ($countOnlyCp) {
+ $typeCode = strtoupper((string) $absence->getType()?->getCode());
+ if ('C' !== $typeCode) {
+ continue;
+ }
+ }
+
+ if (null === $absence->getType()) {
+ continue;
+ }
+
+ $start = DateTimeImmutable::createFromInterface($absence->getStartDate());
+ $end = DateTimeImmutable::createFromInterface($absence->getEndDate());
+ $rangeStart = $start < $from ? $from : $start;
+ $rangeEnd = $end > $to ? $to : $end;
+ if ($rangeEnd < $rangeStart) {
+ continue;
+ }
+
+ for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
+ [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
+ $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
+ if ($dayAmount <= 0.0) {
+ continue;
+ }
+
+ $isSaturday = $splitSaturdays && '6' === $cursor->format('N');
+ if ($isSaturday) {
+ $takenSaturdays += $dayAmount;
+ } else {
+ $takenDays += $dayAmount;
+ }
+ }
+ }
+
+ return [$takenDays, $takenSaturdays];
+ }
+
+ /**
+ * @return array{bool, bool}
+ */
+ private function resolveSegmentsForDate(Absence $absence, string $date): array
+ {
+ $startYmd = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d');
+ $endYmd = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d');
+ $startHalf = $absence->getStartHalf()->value;
+ $endHalf = $absence->getEndHalf()->value;
+
+ $isSingleDay = $startYmd === $endYmd;
+ $isStartDay = $date === $startYmd;
+ $isEndDay = $date === $endYmd;
+
+ if ($isSingleDay) {
+ return ['AM' === $startHalf, 'PM' === $endHalf];
+ }
+ if ($isStartDay) {
+ return ['AM' === $startHalf, true];
+ }
+ if ($isEndDay) {
+ return [true, 'PM' === $endHalf];
+ }
+
+ return [true, true];
+ }
+}
diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php
new file mode 100644
index 0000000..977d2ec
--- /dev/null
+++ b/src/Service/Rtt/RttRecoveryComputationService.php
@@ -0,0 +1,377 @@
+
+ */
+ 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 $weeks
+ *
+ * @return array
+ */
+ 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 $days
+ * @param array $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 $days
+ * @param array $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);
+ }
+}
diff --git a/src/Service/WorkHours/WorkedHoursCreditPolicy.php b/src/Service/WorkHours/WorkedHoursCreditPolicy.php
index 7a4f72f..69ea800 100644
--- a/src/Service/WorkHours/WorkedHoursCreditPolicy.php
+++ b/src/Service/WorkHours/WorkedHoursCreditPolicy.php
@@ -69,13 +69,8 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
- // Règle forfait:
- // - demi-journée d'absence => 0.5 travaillé
- // - journée complète d'absence => 0 travaillé
- if ($absentMorning xor $absentAfternoon) {
- return 0.5;
- }
-
+ // Règle forfait: les absences ne créditent jamais de présence.
+ // Seules les checkboxes cochées par l'employé comptent.
return 0.0;
}
diff --git a/src/State/EmployeeFractionedDaysProcessor.php b/src/State/EmployeeFractionedDaysProcessor.php
new file mode 100644
index 0000000..b64bc9b
--- /dev/null
+++ b/src/State/EmployeeFractionedDaysProcessor.php
@@ -0,0 +1,88 @@
+employeeRepository->find($employeeId);
+ if (!$employee instanceof Employee) {
+ throw new NotFoundHttpException('Employee not found.');
+ }
+
+ $year = $data->year ?? $this->resolveCurrentYear($employee);
+ $ruleCode = $this->resolveRuleCode($employee);
+
+ $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
+
+ if (null === $balance) {
+ $balance = new EmployeeLeaveBalance();
+ $balance->setEmployee($employee);
+ $balance->setRuleCode($ruleCode);
+ $balance->setYear($year);
+ $this->entityManager->persist($balance);
+ }
+
+ $balance->setFractionedDays($data->fractionedDays);
+ $balance->touch();
+ $this->entityManager->flush();
+
+ $data->year = $year;
+
+ return $data;
+ }
+
+ private function resolveRuleCode(Employee $employee): LeaveRuleCode
+ {
+ if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
+ return LeaveRuleCode::FORFAIT_218;
+ }
+
+ return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
+ }
+
+ private function resolveCurrentYear(Employee $employee): int
+ {
+ $today = new DateTimeImmutable('today');
+
+ if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
+ return (int) $today->format('Y');
+ }
+
+ $month = (int) $today->format('n');
+
+ return $month >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
+ }
+}
diff --git a/src/State/EmployeeFractionedDaysProvider.php b/src/State/EmployeeFractionedDaysProvider.php
new file mode 100644
index 0000000..ab72b4f
--- /dev/null
+++ b/src/State/EmployeeFractionedDaysProvider.php
@@ -0,0 +1,17 @@
+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($employee);
+
+ $summary = new EmployeeLeaveSummary();
+ $summary->year = $year;
+ $summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
+
+ $yearSummary = $this->computeYearSummary($employee, $year);
+ if (null === $yearSummary) {
+ return $summary;
+ }
+
+ $fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
+
+ $summary->isSupported = true;
+ $summary->ruleCode = $yearSummary['ruleCode'];
+ $summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
+ $summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
+ $summary->fractionedDays = $fractionedDays;
+ $summary->accruingDays = $yearSummary['accruingDays'];
+ $summary->takenDays = $yearSummary['takenDays'];
+ $summary->takenSaturdays = $yearSummary['takenSaturdays'];
+ $summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
+ $summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
+
+ return $summary;
+ }
+
+ /**
+ * @return null|array{
+ * ruleCode: string,
+ * acquiredDays: float,
+ * acquiredSaturdays: float,
+ * accruingDays: float,
+ * takenDays: float,
+ * takenSaturdays: float,
+ * remainingDays: float,
+ * remainingSaturdays: float
+ * }
+ */
+ private function computeYearSummary(Employee $employee, int $targetYear): ?array
+ {
+ $firstYear = $this->resolveFirstComputationYear($employee);
+ if ($targetYear < $firstYear) {
+ $targetYear = $firstYear;
+ }
+
+ $previousRemainingDays = 0.0;
+ $previousRemainingSaturdays = 0.0;
+ $targetSummary = null;
+
+ for ($year = $firstYear; $year <= $targetYear; ++$year) {
+ [$from, $to] = $this->resolvePeriodBounds($employee, $year);
+ $leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
+ if (null === $leavePolicy) {
+ if ($year === $targetYear) {
+ return null;
+ }
+
+ continue;
+ }
+
+ $carryDays = 0.0;
+ $carrySaturdays = 0.0;
+ $openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear(
+ $employee,
+ $leavePolicy['ruleCode'],
+ $year
+ );
+ if (null !== $openingBalance) {
+ $carryDays = $openingBalance->getOpeningDays();
+ $carrySaturdays = $leavePolicy['splitSaturdays'] ? $openingBalance->getOpeningSaturdays() : 0.0;
+ } elseif ($year > $firstYear) {
+ $ruleCode = LeaveRuleCode::from($leavePolicy['ruleCode']);
+ [$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
+ ->computeDynamicClosingForYear($employee, $ruleCode, $year - 1)
+ ;
+ [$previousFrom, $previousTo] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $year - 1);
+ $hasSettlement = $this->leaveBalanceComputationService
+ ->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo)
+ ;
+ if ($hasSettlement) {
+ $carryDays = 0.0;
+ $carrySaturdays = 0.0;
+ } elseif (!$leavePolicy['splitSaturdays']) {
+ $carrySaturdays = 0.0;
+ }
+ }
+
+ $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
+ $hasShiftedStart = $effectiveFrom > $from;
+ if ($hasShiftedStart) {
+ $carryDays = 0.0;
+ $carrySaturdays = 0.0;
+ }
+
+ $calculationEnd = $this->resolveCalculationEndDate($leavePolicy['ruleCode'], $year, $to);
+ $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
+ ? $this->computeAccruedDaysFromStart(
+ $leavePolicy['acquiredDays'],
+ $leavePolicy['accrualPerMonth'],
+ $effectiveFrom,
+ $calculationEnd
+ )
+ : 0.0;
+ $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
+ ? $this->computeAccruedDaysFromStart(
+ $leavePolicy['acquiredSaturdays'],
+ $leavePolicy['saturdayAccrualPerMonth'],
+ $effectiveFrom,
+ $calculationEnd
+ )
+ : 0.0;
+ $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
+ [$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
+ $absences,
+ $effectiveFrom,
+ $calculationEnd,
+ $leavePolicy['countOnlyCp'],
+ $leavePolicy['splitSaturdays']
+ );
+ if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
+ $availableAcquired = max(0.0, $carryDays);
+ $takenFromAcquired = min($availableAcquired, $takenDays);
+ $remainingAcquired = $carryDays - $takenFromAcquired;
+ $remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
+ $remainingGenerated = $generatedDays - $remainingToImpute;
+
+ $availableAcquiredSaturdays = max(0.0, $carrySaturdays);
+ $takenFromAcquiredSaturdays = min($availableAcquiredSaturdays, $takenSaturdays);
+ $remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
+ $remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
+ $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
+
+ $acquiredDays = $carryDays;
+ $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
+ $remainingDays = $remainingAcquired;
+ $acquiredSaturdays = $carrySaturdays;
+ $remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);
+
+ $previousRemainingDays = $remainingAcquired + $remainingGenerated;
+ $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
+ } else {
+ // Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
+ $acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
+ $accruingDays = 0.0;
+ $remainingDays = max(0.0, $acquiredDays - $takenDays);
+ $acquiredSaturdays = 0.0;
+ $remainingSaturdays = 0.0;
+
+ $previousRemainingDays = $remainingDays;
+ $previousRemainingSaturdays = 0.0;
+ }
+
+ if ($year === $targetYear) {
+ $targetSummary = [
+ 'ruleCode' => $leavePolicy['ruleCode'],
+ 'acquiredDays' => $acquiredDays,
+ 'acquiredSaturdays' => $acquiredSaturdays,
+ 'accruingDays' => $accruingDays,
+ 'takenDays' => $takenDays,
+ 'takenSaturdays' => $takenSaturdays,
+ 'remainingDays' => $remainingDays,
+ 'remainingSaturdays' => $remainingSaturdays,
+ ];
+ }
+ }
+
+ return $targetSummary;
+ }
+
+ private function resolveEffectivePeriodStart(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): DateTimeImmutable {
+ $latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
+ $start = $from;
+ if (null !== $latestSettledClosure) {
+ $nextDay = $latestSettledClosure->modify('+1 day');
+ if ($nextDay > $start) {
+ $start = $nextDay;
+ }
+ }
+
+ $earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
+ if (null !== $earliestContractStart && $earliestContractStart > $start) {
+ $start = $earliestContractStart;
+ }
+
+ return $start;
+ }
+
+ private function resolveEarliestContractStartWithinRange(
+ Employee $employee,
+ DateTimeImmutable $from,
+ DateTimeImmutable $to
+ ): ?DateTimeImmutable {
+ $earliest = null;
+ foreach ($employee->getContractHistory() as $period) {
+ $start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
+ if (!$start instanceof DateTimeImmutable) {
+ continue;
+ }
+
+ $end = null;
+ if (null !== $period->endDate && '' !== trim($period->endDate)) {
+ $end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
+ }
+
+ if ($start > $to) {
+ continue;
+ }
+ if ($end instanceof DateTimeImmutable && $end < $from) {
+ continue;
+ }
+
+ $candidate = $start < $from ? $from : $start;
+ if (null === $earliest || $candidate < $earliest) {
+ $earliest = $candidate;
+ }
+ }
+
+ return $earliest;
+ }
+
+ private function resolveYear(Employee $employee): int
+ {
+ $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
+ if ('' === $raw) {
+ $today = new DateTimeImmutable('today');
+ if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
+ return (int) $today->format('Y');
+ }
+
+ return $this->resolveCurrentLeaveYear($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 computeAccruedDaysFromStart(
+ float $acquiredDays,
+ float $accrualPerMonth,
+ DateTimeImmutable $periodStart,
+ ?DateTimeImmutable $periodEnd
+ ): float {
+ if ($accrualPerMonth <= 0.0) {
+ return $acquiredDays;
+ }
+
+ if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) {
+ return 0.0;
+ }
+
+ $monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
+ + ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
+ + 1;
+ if ($monthsElapsed < 0) {
+ return 0.0;
+ }
+
+ return min($acquiredDays, $monthsElapsed * $accrualPerMonth);
+ }
+
+ private function resolveCalculationEndDate(
+ string $ruleCode,
+ int $year,
+ DateTimeImmutable $periodEnd
+ ): ?DateTimeImmutable {
+ $today = new DateTimeImmutable('today');
+ $currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
+ ? (int) $today->format('Y')
+ : $this->resolveCurrentLeaveYear($today);
+
+ if ($year < $currentYear) {
+ return $periodEnd;
+ }
+ if ($year > $currentYear) {
+ return null;
+ }
+
+ $lastDayPreviousMonth = $today
+ ->modify('first day of this month')
+ ->modify('-1 day')
+ ;
+
+ return $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
+ }
+
+ /**
+ * @return null|array{
+ * ruleCode: string,
+ * acquiredDays: float,
+ * acquiredSaturdays: float,
+ * accrualPerMonth: float,
+ * saturdayAccrualPerMonth: float,
+ * countOnlyCp: bool,
+ * splitSaturdays: bool
+ * }
+ */
+ private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
+ {
+ $type = $employee->getContract()?->getType();
+ if (ContractType::FORFAIT === $type) {
+ $businessDaysInPeriod = $this->countBusinessDays($from, $to);
+
+ return [
+ 'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
+ 'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
+ 'acquiredSaturdays' => 0.0,
+ 'accrualPerMonth' => 0.0,
+ 'saturdayAccrualPerMonth' => 0.0,
+ 'countOnlyCp' => false,
+ 'splitSaturdays' => false,
+ ];
+ }
+
+ $nature = ContractNature::tryFrom($employee->getCurrentContractNature());
+ if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
+ return null;
+ }
+
+ $weeklyHours = $employee->getContract()?->getWeeklyHours();
+ if (4 === $weeklyHours) {
+ return [
+ 'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
+ 'acquiredDays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_DAYS,
+ 'acquiredSaturdays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS,
+ 'accrualPerMonth' => self::CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH,
+ 'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH,
+ 'countOnlyCp' => true,
+ 'splitSaturdays' => true,
+ ];
+ }
+
+ return [
+ 'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
+ 'acquiredDays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS,
+ 'acquiredSaturdays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS,
+ 'accrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH,
+ 'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH,
+ 'countOnlyCp' => true,
+ 'splitSaturdays' => true,
+ ];
+ }
+
+ private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
+ {
+ $publicHolidays = $this->buildPublicHolidayMap($from, $to);
+ $count = 0;
+ for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
+ $weekDay = (int) $cursor->format('N');
+ $dayKey = $cursor->format('Y-m-d');
+ if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
+ ++$count;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
+ {
+ $map = [];
+ $startYear = (int) $from->format('Y');
+ $endYear = (int) $to->format('Y');
+
+ try {
+ for ($year = $startYear; $year <= $endYear; ++$year) {
+ $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
+ foreach ($holidays as $date => $label) {
+ $map[(string) $date] = (string) $label;
+ }
+ }
+ } catch (Throwable) {
+ return [];
+ }
+
+ return $map;
+ }
+
+ /**
+ * @return array{DateTimeImmutable, DateTimeImmutable}
+ */
+ private function resolvePeriodBounds(Employee $employee, int $year): array
+ {
+ if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
+ return $this->resolveForfaitYearBounds($employee, $year);
+ }
+
+ return $this->resolveLeavePeriodBounds($year);
+ }
+
+ /**
+ * @return array{DateTimeImmutable, DateTimeImmutable}
+ */
+ private function resolveLeavePeriodBounds(int $leaveYear): array
+ {
+ // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
+ $from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1));
+ $to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear));
+
+ return [$from, $to];
+ }
+
+ /**
+ * @return array{DateTimeImmutable, DateTimeImmutable}
+ */
+ private function resolveForfaitYearBounds(Employee $employee, int $year): array
+ {
+ $from = new DateTimeImmutable(sprintf('%d-01-01', $year));
+ $to = new DateTimeImmutable(sprintf('%d-12-31', $year));
+
+ $contractStartRaw = $employee->getCurrentContractStartDate();
+ if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
+ $contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw);
+ if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
+ $from = $contractStart;
+ }
+ }
+
+ $contractEndRaw = $employee->getCurrentContractEndDate();
+ if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
+ $contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw);
+ if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
+ $to = $contractEnd;
+ }
+ }
+
+ return [$from, $to];
+ }
+
+ private function resolveFractionedDays(Employee $employee, string $ruleCode, int $year): float
+ {
+ $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
+
+ return null !== $balance ? $balance->getFractionedDays() : 0.0;
+ }
+
+ private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
+ {
+ $year = (int) $today->format('Y');
+ $month = (int) $today->format('n');
+
+ return $month >= 6 ? $year + 1 : $year;
+ }
+
+ private function resolveFirstComputationYear(Employee $employee): int
+ {
+ $isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
+ $fallbackYear = $isForfait
+ ? (int) new DateTimeImmutable('today')->format('Y')
+ : $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
+
+ $history = $employee->getContractHistory();
+ if ([] === $history) {
+ return $fallbackYear;
+ }
+
+ $oldestStartDate = null;
+ foreach ($history as $item) {
+ $start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
+ if (!$start) {
+ continue;
+ }
+ if (null === $oldestStartDate || $start < $oldestStartDate) {
+ $oldestStartDate = $start;
+ }
+ }
+
+ if (null === $oldestStartDate) {
+ $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
+
+ return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
+ }
+
+ $firstYear = $isForfait
+ ? (int) $oldestStartDate->format('Y')
+ : ((int) $oldestStartDate->format('n') >= 6
+ ? (int) $oldestStartDate->format('Y') + 1
+ : (int) $oldestStartDate->format('Y'));
+
+ $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
+ if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
+ return $oldestBalanceYear;
+ }
+
+ return $firstYear;
+ }
+
+ /**
+ * @param list $absences
+ *
+ * @return array{float, float}
+ */
+ private function computeTakenAbsences(
+ array $absences,
+ DateTimeImmutable $from,
+ ?DateTimeImmutable $to,
+ bool $countOnlyCp,
+ bool $splitSaturdays
+ ): array {
+ $takenDays = 0.0;
+ $takenSaturdays = 0.0;
+
+ if (!$to instanceof DateTimeImmutable || $to < $from) {
+ return [$takenDays, $takenSaturdays];
+ }
+
+ foreach ($absences as $absence) {
+ if ($countOnlyCp) {
+ $typeCode = strtoupper((string) $absence->getType()?->getCode());
+ if ('C' !== $typeCode) {
+ continue;
+ }
+ }
+
+ if (null === $absence->getType()) {
+ continue;
+ }
+
+ $start = DateTimeImmutable::createFromInterface($absence->getStartDate());
+ $end = DateTimeImmutable::createFromInterface($absence->getEndDate());
+ $rangeStart = $start < $from ? $from : $start;
+ $rangeEnd = $end > $to ? $to : $end;
+ if ($rangeEnd < $rangeStart) {
+ continue;
+ }
+
+ for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
+ [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
+ $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
+ if ($dayAmount <= 0.0) {
+ continue;
+ }
+
+ $isSaturday = $splitSaturdays && '6' === $cursor->format('N');
+ if ($isSaturday && $splitSaturdays) {
+ $takenSaturdays += $dayAmount;
+ } else {
+ $takenDays += $dayAmount;
+ }
+ }
+ }
+
+ return [$takenDays, $takenSaturdays];
+ }
+
+ /**
+ * @return array{bool, bool}
+ */
+ private function resolveSegmentsForDate(Absence $absence, string $date): array
+ {
+ $startDate = $absence->getStartDate()->format('Y-m-d');
+ $endDate = $absence->getEndDate()->format('Y-m-d');
+ $startHalf = $absence->getStartHalf()->value;
+ $endHalf = $absence->getEndHalf()->value;
+
+ $isStart = $date === $startDate;
+ $isEnd = $date === $endDate;
+ $isSingleDay = $startDate === $endDate;
+
+ if ($isSingleDay) {
+ return ['AM' === $startHalf, 'PM' === $endHalf];
+ }
+ if ($isStart) {
+ return ['AM' === $startHalf, true];
+ }
+ if ($isEnd) {
+ return [true, 'PM' === $endHalf];
+ }
+
+ return [true, true];
+ }
+}
diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php
new file mode 100644
index 0000000..cb5781c
--- /dev/null
+++ b/src/State/EmployeeRttPaymentProcessor.php
@@ -0,0 +1,86 @@
+employeeRepository->find($employeeId);
+ if (!$employee instanceof Employee) {
+ throw new NotFoundHttpException('Employee not found.');
+ }
+
+ if (!in_array($data->rate, ['25', '50'], true)) {
+ throw new UnprocessableEntityHttpException('rate must be "25" or "50".');
+ }
+
+ if ($data->month < 1 || $data->month > 12) {
+ throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
+ }
+
+ if ($data->minutes < 0) {
+ throw new UnprocessableEntityHttpException('minutes must be >= 0.');
+ }
+
+ $year = $data->year ?? $this->resolveCurrentExerciseYear();
+
+ $payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate);
+
+ if (null === $payment) {
+ $payment = new EmployeeRttPayment();
+ $payment->setEmployee($employee);
+ $payment->setYear($year);
+ $payment->setMonth($data->month);
+ $payment->setRate($data->rate);
+ $this->entityManager->persist($payment);
+ }
+
+ $payment->setMinutes($data->minutes);
+ $this->entityManager->flush();
+
+ $data->year = $year;
+
+ return $data;
+ }
+
+ private function resolveCurrentExerciseYear(): int
+ {
+ $today = new DateTimeImmutable('today');
+ $year = (int) $today->format('Y');
+ $month = (int) $today->format('n');
+
+ return $month >= 6 ? $year + 1 : $year;
+ }
+}
diff --git a/src/State/EmployeeRttPaymentProvider.php b/src/State/EmployeeRttPaymentProvider.php
new file mode 100644
index 0000000..8960953
--- /dev/null
+++ b/src/State/EmployeeRttPaymentProvider.php
@@ -0,0 +1,17 @@
+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
+ );
+
+ $payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
+ $monthBuckets = [];
+
+ foreach ($payments as $payment) {
+ $m = $payment->getMonth();
+ if (!isset($monthBuckets[$m])) {
+ $monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
+ }
+ if ('25' === $payment->getRate()) {
+ $monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
+ } else {
+ $monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
+ }
+ }
+
+ $monthPayments = [];
+ $totalPaidMinutes = 0;
+
+ foreach ($monthBuckets as $m => $bucket) {
+ $monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
+ $totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
+ }
+
+ $summary->totalPaidMinutes = $totalPaidMinutes;
+ $summary->monthPayments = $monthPayments;
+ $summary->availableMinutes -= $totalPaidMinutes;
+
+ 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;
+ }
+}
diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php
index 1d27a18..56a320e 100644
--- a/src/State/EmployeeWriteProcessor.php
+++ b/src/State/EmployeeWriteProcessor.php
@@ -9,9 +9,10 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\Employee;
-use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
-use App\Repository\EmployeeContractPeriodRepository;
+use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
+use App\Service\Contracts\EmployeeContractChangeRequestFactory;
+use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -25,7 +26,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private EntityManagerInterface $entityManager,
- private EmployeeContractPeriodRepository $periodRepository,
+ private EmployeeContractPeriodReadRepositoryInterface $periodRepository,
+ private EmployeeContractChangeRequestFactory $changeRequestFactory,
+ private EmployeeContractPeriodManagerInterface $periodManager,
) {}
public function process(
@@ -51,47 +54,59 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result;
}
- $today = new DateTimeImmutable('today');
- $requestedContractNature = $this->resolveContractNature($data->getContractNature());
- $requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
- $requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
+ $today = new DateTimeImmutable('today');
+ $changeRequest = $this->changeRequestFactory->fromEmployee($data);
if ($isNew) {
- $startDate = $requestedStartDate ?? new DateTimeImmutable('1970-01-01');
- $nature = $requestedContractNature ?? ContractNature::CDI;
- $this->assertPeriodDates($startDate, $requestedEndDate, $nature);
- $this->ensureContractPeriodExists($data, $currentContract, $startDate, $requestedEndDate, $nature);
+ $startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
+ $nature = $changeRequest->contractNature ?? ContractNature::CDI;
+ $this->periodManager->ensureContractPeriodExists(
+ employee: $data,
+ contract: $currentContract,
+ startDate: $startDate,
+ endDate: $changeRequest->contractEndDate,
+ nature: $nature
+ );
return $result;
}
- $hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
- if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
+ if ($this->isSameContract($previousContract, $currentContract) && !$changeRequest->hasPeriodChangeRequest()) {
return $result;
}
- $startDate = $requestedStartDate ?? $today;
- $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
- $nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
- $endDate = $requestedEndDate;
- $this->assertPeriodDates($startDate, $endDate, $nature);
+ $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
+ $currentPeriodContract = $todayPeriod?->getContract();
+ $contractChanged = $currentPeriodContract instanceof Contract
+ ? $currentPeriodContract->getId() !== $currentContract->getId()
+ : true;
+ $isCloseOnlyRequest = $changeRequest->isCloseOnlyRequest($contractChanged);
- if (
- null !== $todayPeriod
- && null === $todayPeriod->getEndDate()
- && $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
- ) {
- $todayPeriod->setContract($currentContract);
- $todayPeriod->setContractNature($nature);
- $todayPeriod->setEndDate($endDate);
- $this->entityManager->flush();
+ if ($isCloseOnlyRequest) {
+ $requestedEndDate = $changeRequest->contractEndDate;
+ if (null === $requestedEndDate) {
+ throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
+ }
+ $this->periodManager->closeCurrentPeriod(
+ $todayPeriod,
+ $requestedEndDate,
+ $changeRequest->contractPaidLeaveSettled ?? false,
+ $changeRequest->contractComment
+ );
return $result;
}
- $this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
- $this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
- $this->entityManager->flush();
+ $startDate = $changeRequest->contractStartDate ?? $today;
+ $nature = $changeRequest->contractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
+ $this->periodManager->createNextPeriod(
+ employee: $data,
+ contract: $currentContract,
+ startDate: $startDate,
+ endDate: $changeRequest->contractEndDate,
+ nature: $nature,
+ todayPeriod: $todayPeriod
+ );
return $result;
}
@@ -116,81 +131,4 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $first->getId() === $second->getId();
}
-
- private function ensureContractPeriodExists(
- Employee $employee,
- Contract $contract,
- DateTimeImmutable $startDate,
- ?DateTimeImmutable $endDate,
- ContractNature $nature,
- ): void {
- $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
- if (null !== $covered) {
- return;
- }
-
- $this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
- $this->entityManager->flush();
- }
-
- private function createPeriod(
- Employee $employee,
- Contract $contract,
- DateTimeImmutable $startDate,
- ?DateTimeImmutable $endDate,
- ContractNature $nature,
- ): void {
- $period = new EmployeeContractPeriod()
- ->setEmployee($employee)
- ->setContract($contract)
- ->setStartDate($startDate)
- ->setEndDate($endDate)
- ->setContractNature($nature)
- ;
-
- $this->entityManager->persist($period);
- }
-
- private function resolveContractNature(?string $raw): ?ContractNature
- {
- if (null === $raw || '' === trim($raw)) {
- return null;
- }
-
- return ContractNature::tryFrom(trim($raw))
- ?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
- }
-
- private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
- {
- if (null === $raw || '' === trim($raw)) {
- return null;
- }
-
- $value = trim($raw);
- $date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
- if (!$date || $date->format('Y-m-d') !== $value) {
- throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
- }
-
- return $date;
- }
-
- private function assertPeriodDates(
- DateTimeImmutable $startDate,
- ?DateTimeImmutable $endDate,
- ContractNature $nature
- ): void {
- if (null !== $endDate && $endDate < $startDate) {
- throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
- }
-
- if ($nature->requiresEndDate() && null === $endDate) {
- throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
- }
-
- if (ContractNature::CDI === $nature && null !== $endDate) {
- throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
- }
- }
}
diff --git a/src/State/MarkAllNotificationsReadProcessor.php b/src/State/MarkAllNotificationsReadProcessor.php
new file mode 100644
index 0000000..0fd0970
--- /dev/null
+++ b/src/State/MarkAllNotificationsReadProcessor.php
@@ -0,0 +1,32 @@
+security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('Authentication required.');
+ }
+
+ $this->notificationRepository->markAllReadByRecipient($user);
+
+ return null;
+ }
+}
diff --git a/src/State/NotificationHistoryProvider.php b/src/State/NotificationHistoryProvider.php
new file mode 100644
index 0000000..b38c236
--- /dev/null
+++ b/src/State/NotificationHistoryProvider.php
@@ -0,0 +1,30 @@
+security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('Authentication required.');
+ }
+
+ return $this->notificationRepository->findLatestByRecipient($user, 5);
+ }
+}
diff --git a/src/State/NotificationTodayProvider.php b/src/State/NotificationTodayProvider.php
new file mode 100644
index 0000000..ed17d8d
--- /dev/null
+++ b/src/State/NotificationTodayProvider.php
@@ -0,0 +1,30 @@
+security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('Authentication required.');
+ }
+
+ return $this->notificationRepository->findUnreadByRecipient($user);
+ }
+}
diff --git a/src/State/UnreadNotificationsProvider.php b/src/State/UnreadNotificationsProvider.php
new file mode 100644
index 0000000..890ea3b
--- /dev/null
+++ b/src/State/UnreadNotificationsProvider.php
@@ -0,0 +1,30 @@
+security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('Authentication required.');
+ }
+
+ return $this->notificationRepository->findUnreadByRecipient($user);
+ }
+}
diff --git a/src/State/WorkHourBulkSiteValidationProcessor.php b/src/State/WorkHourBulkSiteValidationProcessor.php
index b4a1c79..ab3ab96 100644
--- a/src/State/WorkHourBulkSiteValidationProcessor.php
+++ b/src/State/WorkHourBulkSiteValidationProcessor.php
@@ -8,9 +8,15 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkSiteValidation;
use App\ApiResource\WorkHourBulkValidationResult;
+use App\Entity\Notification;
use App\Entity\User;
use App\Entity\WorkHour;
+use App\Repository\UserRepository;
+use App\Repository\WorkHourRepository;
+use App\Security\EmployeeScopeService;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
+use DateTimeImmutable;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -20,6 +26,10 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
+ private WorkHourRepository $workHourRepository,
+ private UserRepository $userRepository,
+ private EmployeeScopeService $employeeScopeService,
+ private EntityManagerInterface $entityManager,
) {}
public function process(
@@ -41,7 +51,7 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
throw new AccessDeniedHttpException('Only site managers can bulk update site validation.');
}
- return $this->executor->execute(
+ $result = $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
@@ -50,5 +60,42 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
$workHour->setIsSiteValid($data->isSiteValid);
}
);
+
+ if ($data->isSiteValid && $result->updated > 0) {
+ $this->createNotificationsIfSiteFullyValidated($user, $data->workDate);
+ }
+
+ return $result;
+ }
+
+ private function createNotificationsIfSiteFullyValidated(User $user, string $workDateValue): void
+ {
+ $workDate = DateTimeImmutable::createFromFormat('Y-m-d', $workDateValue);
+ if (!$workDate) {
+ return;
+ }
+
+ $siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
+
+ foreach ($siteIds as $siteId) {
+ if ($this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate)) {
+ continue;
+ }
+
+ $message = 'a validé les heures';
+
+ foreach ($this->userRepository->findAllAdmins() as $admin) {
+ $notification = new Notification();
+ $notification->setRecipient($admin)
+ ->setActor($user)
+ ->setMessage($message)
+ ->setCategory('Heures')
+ ->setTarget('/hours')
+ ;
+ $this->entityManager->persist($notification);
+ }
+ }
+
+ $this->entityManager->flush();
}
}
diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php
index 3bd9551..5c6f9ec 100644
--- a/src/State/WorkHourBulkUpsertProcessor.php
+++ b/src/State/WorkHourBulkUpsertProcessor.php
@@ -98,6 +98,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
+ $isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
@@ -145,6 +146,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setWorkDate($workDate)
;
$this->hydrateWorkHour($workHour, $normalized);
+ if ($isSelf) {
+ $workHour->setUpdatedAt(new DateTimeImmutable());
+ }
$this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour;
++$result->created;
@@ -169,6 +173,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
}
$this->hydrateWorkHour($workHour, $normalized);
+ if (!$isAdmin) {
+ $workHour->setUpdatedAt(new DateTimeImmutable());
+ }
++$result->processed;
}
diff --git a/src/State/WorkHourSiteValidationProcessor.php b/src/State/WorkHourSiteValidationProcessor.php
index 4005df9..608f54f 100644
--- a/src/State/WorkHourSiteValidationProcessor.php
+++ b/src/State/WorkHourSiteValidationProcessor.php
@@ -6,8 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
+use App\Entity\Notification;
use App\Entity\User;
use App\Entity\WorkHour;
+use App\Repository\UserRepository;
+use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +21,8 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
+ private WorkHourRepository $workHourRepository,
+ private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
) {}
@@ -47,8 +52,37 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
+ $uow = $this->entityManager->getUnitOfWork();
+ $uow->computeChangeSets();
+ $changeSet = $uow->getEntityChangeSet($data);
+ $isSiteValidationChangedToTrue = isset($changeSet['isSiteValid'])
+ && false === $changeSet['isSiteValid'][0]
+ && true === $changeSet['isSiteValid'][1];
+
$this->entityManager->flush();
+ // Notification uniquement quand la dernière ligne du site est validée pour la date.
+ if ($isSiteValidationChangedToTrue) {
+ $workDate = $data->getWorkDate();
+ $hasPending = $this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate);
+ if (!$hasPending) {
+ $message = 'a validé les heures';
+
+ foreach ($this->userRepository->findAllAdmins() as $admin) {
+ $notification = new Notification();
+ $notification->setRecipient($admin)
+ ->setActor($user)
+ ->setMessage($message)
+ ->setCategory('Heures')
+ ->setTarget('/hours')
+ ;
+ $this->entityManager->persist($notification);
+ }
+
+ $this->entityManager->flush();
+ }
+ }
+
return $data;
}
}
diff --git a/symfony.lock b/symfony.lock
index 3ac0e96..e6d16c4 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -36,6 +36,18 @@
"src/Repository/.gitignore"
]
},
+ "doctrine/doctrine-fixtures-bundle": {
+ "version": "4.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
+ },
+ "files": [
+ "src/DataFixtures/AppFixtures.php"
+ ]
+ },
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"recipe": {
diff --git a/tests/Service/Contracts/EmployeeContractChangeRequestFactoryTest.php b/tests/Service/Contracts/EmployeeContractChangeRequestFactoryTest.php
new file mode 100644
index 0000000..32b96fe
--- /dev/null
+++ b/tests/Service/Contracts/EmployeeContractChangeRequestFactoryTest.php
@@ -0,0 +1,72 @@
+buildEmployee()
+ ->setContractNature('CDD')
+ ->setContractStartDate('2026-03-01')
+ ->setContractEndDate('2026-03-10')
+ ->setContractPaidLeaveSettled(true)
+ ;
+
+ $request = $factory->fromEmployee($employee);
+
+ self::assertSame(ContractNature::CDD, $request->contractNature);
+ self::assertSame('2026-03-01', $request->contractStartDate?->format('Y-m-d'));
+ self::assertSame('2026-03-10', $request->contractEndDate?->format('Y-m-d'));
+ self::assertTrue($request->contractPaidLeaveSettled);
+ self::assertTrue($request->hasPeriodChangeRequest());
+ }
+
+ public function testThrowsOnInvalidContractNature(): void
+ {
+ $factory = new EmployeeContractChangeRequestFactory();
+ $employee = $this->buildEmployee()->setContractNature('XYZ');
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractNature must be one of CDI, CDD, INTERIM.');
+ $factory->fromEmployee($employee);
+ }
+
+ public function testThrowsOnInvalidDateFormat(): void
+ {
+ $factory = new EmployeeContractChangeRequestFactory();
+ $employee = $this->buildEmployee()->setContractStartDate('01/03/2026');
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractStartDate must use Y-m-d format.');
+ $factory->fromEmployee($employee);
+ }
+
+ private function buildEmployee(): Employee
+ {
+ $contract = new Contract()
+ ->setName('35h')
+ ->setTrackingMode(Contract::TRACKING_TIME)
+ ->setWeeklyHours(35)
+ ;
+
+ return new Employee()
+ ->setFirstName('Alice')
+ ->setLastName('Martin')
+ ->setContract($contract)
+ ;
+ }
+}
diff --git a/tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php b/tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php
new file mode 100644
index 0000000..90cf501
--- /dev/null
+++ b/tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php
@@ -0,0 +1,118 @@
+validator = new EmployeeContractPeriodValidator();
+ }
+
+ public function testAssertPeriodDatesRejectsEndBeforeStart(): void
+ {
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractEndDate cannot be before contractStartDate.');
+
+ $this->validator->assertPeriodDates(
+ new DateTimeImmutable('2026-03-10'),
+ new DateTimeImmutable('2026-03-01'),
+ ContractNature::CDD
+ );
+ }
+
+ public function testAssertPeriodDatesRejectsMissingEndDateForCdd(): void
+ {
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractEndDate is required for CDD and INTERIM.');
+
+ $this->validator->assertPeriodDates(
+ new DateTimeImmutable('2026-03-01'),
+ null,
+ ContractNature::CDD
+ );
+ }
+
+ public function testAssertPeriodDatesRejectsEndDateForCdiWhenNotAllowed(): void
+ {
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractEndDate must be empty for CDI.');
+
+ $this->validator->assertPeriodDates(
+ new DateTimeImmutable('2026-03-01'),
+ new DateTimeImmutable('2026-03-10'),
+ ContractNature::CDI
+ );
+ }
+
+ public function testAssertCloseEndDateCanBeAppliedRejectsIncrease(): void
+ {
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractEndDate cannot be increased on current contract.');
+
+ $this->validator->assertCloseEndDateCanBeApplied(
+ new DateTimeImmutable('2026-03-01'),
+ new DateTimeImmutable('2026-03-10'),
+ new DateTimeImmutable('2026-03-11'),
+ ContractNature::CDI
+ );
+ }
+
+ public function testAssertNextStartDateCompatibleRejectsWhenNotAfterCurrentOpenStart(): void
+ {
+ $currentPeriod = $this->buildCurrentPeriod('2026-03-05', null);
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractStartDate must be after current contract start date.');
+
+ $this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-05'), $currentPeriod);
+ }
+
+ public function testAssertNextStartDateCompatibleRejectsWhenNotAfterCurrentClosedEnd(): void
+ {
+ $currentPeriod = $this->buildCurrentPeriod('2026-03-01', '2026-03-10');
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->expectExceptionMessage('contractStartDate must be after current contract end date.');
+
+ $this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod);
+ }
+
+ private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod
+ {
+ $contract = new Contract()
+ ->setName('35h')
+ ->setTrackingMode(Contract::TRACKING_TIME)
+ ->setWeeklyHours(35)
+ ;
+ $employee = new Employee()
+ ->setFirstName('Test')
+ ->setLastName('User')
+ ->setContract($contract)
+ ;
+
+ return new EmployeeContractPeriod()
+ ->setEmployee($employee)
+ ->setContract($contract)
+ ->setStartDate(new DateTimeImmutable($startDate))
+ ->setEndDate(null !== $endDate ? new DateTimeImmutable($endDate) : null)
+ ->setContractNature(ContractNature::CDI)
+ ;
+ }
+}
diff --git a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
index e79de91..bab2324 100644
--- a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
+++ b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php
@@ -43,9 +43,9 @@ final class WorkedHoursCreditPolicyTest extends TestCase
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
- $units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false);
-
- self::assertSame(0.5, $units);
+ // Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent.
+ self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false));
+ self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
}
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
diff --git a/tests/State/EmployeeWriteProcessorTest.php b/tests/State/EmployeeWriteProcessorTest.php
new file mode 100644
index 0000000..9481d32
--- /dev/null
+++ b/tests/State/EmployeeWriteProcessorTest.php
@@ -0,0 +1,264 @@
+buildEmployeeWithId(42);
+ $employee->setContractEndDate('2026-03-05');
+ $employee->setContractPaidLeaveSettled(true);
+ $contract = $employee->getContract();
+ self::assertInstanceOf(Contract::class, $contract);
+
+ $todayPeriod = new EmployeeContractPeriod()
+ ->setEmployee($employee)
+ ->setContract($contract)
+ ->setStartDate(new DateTimeImmutable('2026-03-01'))
+ ->setEndDate(null)
+ ->setContractNature(ContractNature::CDI)
+ ;
+
+ $persistProcessor = $this->createMock(ProcessorInterface::class);
+ $removeProcessor = $this->createStub(ProcessorInterface::class);
+ $entityManager = $this->createStub(EntityManagerInterface::class);
+ $periodRepository = $this->createMock(EmployeeContractPeriodReadRepositoryInterface::class);
+ $changeRequestFactory = new EmployeeContractChangeRequestFactory();
+ $periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
+
+ $persistProcessor
+ ->expects(self::once())
+ ->method('process')
+ ->willReturn($employee)
+ ;
+ $periodRepository
+ ->expects(self::once())
+ ->method('findOneCoveringDate')
+ ->with($employee, self::isInstanceOf(DateTimeImmutable::class))
+ ->willReturn($todayPeriod)
+ ;
+ $periodManager
+ ->expects(self::once())
+ ->method('closeCurrentPeriod')
+ ->with(
+ $todayPeriod,
+ self::callback(static fn (DateTimeImmutable $value): bool => '2026-03-05' === $value->format('Y-m-d')),
+ true
+ )
+ ;
+ $periodManager
+ ->expects(self::never())
+ ->method('createNextPeriod')
+ ;
+
+ $this->mockOriginalContract($entityManager, $employee, $contract);
+
+ $processor = new EmployeeWriteProcessor(
+ $persistProcessor,
+ $removeProcessor,
+ $entityManager,
+ $periodRepository,
+ $changeRequestFactory,
+ $periodManager
+ );
+
+ $result = $processor->process($employee, new Patch());
+
+ self::assertSame($employee, $result);
+ }
+
+ public function testDelegatesNonCloseRequestToCreateNextPeriod(): void
+ {
+ $employee = $this->buildEmployeeWithId(43);
+ $employee->setContractStartDate('2026-03-06');
+ $contract = $employee->getContract();
+ self::assertInstanceOf(Contract::class, $contract);
+
+ $todayPeriod = new EmployeeContractPeriod()
+ ->setEmployee($employee)
+ ->setContract($contract)
+ ->setStartDate(new DateTimeImmutable('2026-03-01'))
+ ->setEndDate(null)
+ ->setContractNature(ContractNature::CDI)
+ ;
+
+ $persistProcessor = $this->createMock(ProcessorInterface::class);
+ $removeProcessor = $this->createStub(ProcessorInterface::class);
+ $entityManager = $this->createStub(EntityManagerInterface::class);
+ $periodRepository = $this->createMock(EmployeeContractPeriodReadRepositoryInterface::class);
+ $changeRequestFactory = new EmployeeContractChangeRequestFactory();
+ $periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
+
+ $persistProcessor
+ ->expects(self::once())
+ ->method('process')
+ ->willReturn($employee)
+ ;
+ $periodRepository
+ ->expects(self::once())
+ ->method('findOneCoveringDate')
+ ->with($employee, self::isInstanceOf(DateTimeImmutable::class))
+ ->willReturn($todayPeriod)
+ ;
+ $periodManager
+ ->expects(self::once())
+ ->method('createNextPeriod')
+ ->with(
+ employee: $employee,
+ contract: $contract,
+ startDate: self::callback(static fn (DateTimeImmutable $value): bool => '2026-03-06' === $value->format('Y-m-d')),
+ endDate: null,
+ nature: ContractNature::CDI,
+ todayPeriod: $todayPeriod
+ )
+ ;
+ $periodManager
+ ->expects(self::never())
+ ->method('closeCurrentPeriod')
+ ;
+
+ $this->mockOriginalContract($entityManager, $employee, $contract);
+
+ $processor = new EmployeeWriteProcessor(
+ $persistProcessor,
+ $removeProcessor,
+ $entityManager,
+ $periodRepository,
+ $changeRequestFactory,
+ $periodManager
+ );
+
+ $result = $processor->process($employee, new Patch());
+
+ self::assertSame($employee, $result);
+ }
+
+ public function testSkipsPeriodOperationsWhenContractAndPeriodPayloadAreUnchanged(): void
+ {
+ $employee = $this->buildEmployeeWithId(44);
+ $contract = $employee->getContract();
+ self::assertInstanceOf(Contract::class, $contract);
+
+ $persistProcessor = $this->createMock(ProcessorInterface::class);
+ $removeProcessor = $this->createStub(ProcessorInterface::class);
+ $entityManager = $this->createStub(EntityManagerInterface::class);
+ $periodRepository = $this->createMock(EmployeeContractPeriodReadRepositoryInterface::class);
+ $changeRequestFactory = new EmployeeContractChangeRequestFactory();
+ $periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
+
+ $persistProcessor
+ ->expects(self::once())
+ ->method('process')
+ ->willReturn($employee)
+ ;
+ $periodRepository->expects(self::never())->method('findOneCoveringDate');
+ $periodManager->expects(self::never())->method('closeCurrentPeriod');
+ $periodManager->expects(self::never())->method('createNextPeriod');
+
+ $this->mockOriginalContract($entityManager, $employee, $contract);
+
+ $processor = new EmployeeWriteProcessor(
+ $persistProcessor,
+ $removeProcessor,
+ $entityManager,
+ $periodRepository,
+ $changeRequestFactory,
+ $periodManager
+ );
+
+ $result = $processor->process($employee, new Patch());
+
+ self::assertSame($employee, $result);
+ }
+
+ public function testDeleteOperationDelegatesToRemoveProcessor(): void
+ {
+ $employee = $this->buildEmployeeWithId(45);
+
+ $persistProcessor = $this->createMock(ProcessorInterface::class);
+ $removeProcessor = $this->createMock(ProcessorInterface::class);
+ $entityManager = $this->createStub(EntityManagerInterface::class);
+ $periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
+ $changeRequestFactory = new EmployeeContractChangeRequestFactory();
+ $periodManager = $this->createStub(EmployeeContractPeriodManagerInterface::class);
+
+ $persistProcessor->expects(self::never())->method('process');
+ $removeProcessor
+ ->expects(self::once())
+ ->method('process')
+ ->with($employee, self::isInstanceOf(Delete::class), [], [])
+ ->willReturn(null)
+ ;
+
+ $processor = new EmployeeWriteProcessor(
+ $persistProcessor,
+ $removeProcessor,
+ $entityManager,
+ $periodRepository,
+ $changeRequestFactory,
+ $periodManager
+ );
+
+ $result = $processor->process($employee, new Delete());
+
+ self::assertNull($result);
+ }
+
+ private function buildEmployeeWithId(int $id): Employee
+ {
+ $contract = new Contract()
+ ->setName('39h')
+ ->setTrackingMode(Contract::TRACKING_TIME)
+ ->setWeeklyHours(39)
+ ;
+
+ $employee = new Employee()
+ ->setFirstName('John')
+ ->setLastName('Doe')
+ ->setContract($contract)
+ ;
+
+ $ref = new ReflectionProperty($employee, 'id');
+ $ref->setValue($employee, $id);
+
+ return $employee;
+ }
+
+ private function mockOriginalContract(EntityManagerInterface $entityManager, Employee $employee, Contract $contract): void
+ {
+ $unitOfWork = $this->createStub(UnitOfWork::class);
+ $unitOfWork
+ ->method('getOriginalEntityData')
+ ->with($employee)
+ ->willReturn(['contract' => $contract])
+ ;
+
+ $entityManager
+ ->method('getUnitOfWork')
+ ->willReturn($unitOfWork)
+ ;
+ }
+}