feat : ajout de la gestion Congé
This commit is contained in:
2
.idea/SIRH.iml
generated
2
.idea/SIRH.iml
generated
@@ -152,6 +152,8 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/public" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/var" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
2
.idea/php.xml
generated
2
.idea/php.xml
generated
@@ -153,6 +153,8 @@
|
||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
|
||||
6
.idea/sqldialects.xml
generated
Normal file
6
.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -87,6 +87,7 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.93",
|
||||
"phpunit/phpunit": "^12.5"
|
||||
}
|
||||
|
||||
171
composer.lock
generated
171
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Ce document centralise les règles métier actuellement implémentées dans l'application.
|
||||
|
||||
Document complementaire (rollover conges et checklist de lancement):
|
||||
- `doc/leave-rollover.md`
|
||||
|
||||
## 1) Utilisateurs et accès
|
||||
|
||||
- `ROLE_ADMIN`
|
||||
@@ -138,11 +141,61 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- 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)
|
||||
- seule la date de fin est saisissable (préremplie à aujourd'hui)
|
||||
- backend: en mode clôture, seule `contractEndDate` est acceptée
|
||||
- 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é: `0` (saisie RH ultérieure, non calculée automatiquement)
|
||||
- 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é)
|
||||
|
||||
## 10) Notifications
|
||||
|
||||
|
||||
226
doc/leave-rollover.md
Normal file
226
doc/leave-rollover.md
Normal file
@@ -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 >> var/log/leave-rollover.log 2>&1
|
||||
```
|
||||
Prod
|
||||
```cron
|
||||
10 2 * * * cd /var/www/sirh && php bin/console app:leave:rollover --no-interaction >> var/log/leave-rollover.log 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)
|
||||
@@ -86,6 +86,18 @@
|
||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
|
||||
<input
|
||||
id="contract-paid-leave-settled"
|
||||
v-model="contractForm.paidLeaveSettled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Soldé dans le solde de tout compte
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,6 +190,7 @@ type ContractForm = {
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
paidLeaveSettled: boolean
|
||||
}
|
||||
|
||||
type CreateContractForm = {
|
||||
|
||||
@@ -1,7 +1,239 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Bloc Congé (à implémenter)
|
||||
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
|
||||
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><strong class="uppercase font-semibold">Acquis année :</strong> {{ formatCount(summary?.acquiredDays) }} Jours</p>
|
||||
<p><strong class="uppercase font-semibold">Reste à prendre :</strong> {{ formatCount(summary?.remainingDays) }} Jours</p>
|
||||
</div>
|
||||
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><span class="uppercase font-semibold">Samedi acquis :</span> {{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
||||
<p><span class="uppercase font-semibold">Reste à prendre :</span> {{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
||||
</div>
|
||||
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
|
||||
<p><span class="uppercase font-semibold">Acquis fractionné :</span></p>
|
||||
<p>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
||||
</div>
|
||||
<div class="flex flex-col jutify-center items-center py-3">
|
||||
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
||||
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 grid grid-cols-4 gap-10">
|
||||
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
|
||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">{{ month.label }}</div>
|
||||
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
|
||||
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
|
||||
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
|
||||
<div v-if="!day" class="h-6" />
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="h-6 w-6"
|
||||
:class="getDayClass(day.leave)"
|
||||
:style="getDayStyle(day.leave)"
|
||||
:title="getDayTitle(day.leave)"
|
||||
>
|
||||
{{ getDayText(day) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||
import { normalizeDate, toYmd } from '~/utils/date'
|
||||
|
||||
type DayLeaveState = {
|
||||
am: boolean
|
||||
pm: boolean
|
||||
labels: string[]
|
||||
hasCongeTypeC: boolean
|
||||
hasOtherTypes: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
absences: Absence[]
|
||||
summary: EmployeeLeaveSummary | null
|
||||
}>()
|
||||
|
||||
const monthLabels = [
|
||||
'Janvier',
|
||||
'Fevrier',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Aout',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Decembre'
|
||||
] as const
|
||||
|
||||
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
||||
|
||||
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
||||
|
||||
const displayedYear = computed(() => {
|
||||
if (props.summary?.year) return props.summary.year
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth() + 1
|
||||
return month >= 6 ? year + 1 : year
|
||||
})
|
||||
|
||||
const orderedMonthIndexes = computed(() => {
|
||||
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
|
||||
})
|
||||
|
||||
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
|
||||
|
||||
const dayLeaveMap = computed(() => {
|
||||
const map = new Map<string, DayLeaveState>()
|
||||
|
||||
for (const absence of props.absences) {
|
||||
const startYmd = normalizeDate(absence.startDate)
|
||||
const endYmd = normalizeDate(absence.endDate)
|
||||
const start = buildDateFromYmd(startYmd)
|
||||
const end = buildDateFromYmd(endYmd)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
|
||||
|
||||
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
||||
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
|
||||
const existing = map.get(ymd) ?? {
|
||||
am: false,
|
||||
pm: false,
|
||||
labels: [] as string[],
|
||||
hasCongeTypeC: false,
|
||||
hasOtherTypes: false
|
||||
}
|
||||
|
||||
const isStart = ymd === startYmd
|
||||
const isEnd = ymd === endYmd
|
||||
const isSingleDay = startYmd === endYmd
|
||||
|
||||
let am = false
|
||||
let pm = false
|
||||
|
||||
if (isSingleDay) {
|
||||
am = absence.startHalf === 'AM'
|
||||
pm = absence.endHalf === 'PM'
|
||||
} else if (isStart) {
|
||||
am = absence.startHalf === 'AM'
|
||||
pm = true
|
||||
} else if (isEnd) {
|
||||
am = true
|
||||
pm = absence.endHalf === 'PM'
|
||||
} else {
|
||||
am = true
|
||||
pm = true
|
||||
}
|
||||
|
||||
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
|
||||
const typeCode = (absence.type?.code ?? '').toUpperCase()
|
||||
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
|
||||
const hoverLabel = `${typeLabel}${halfSuffix}`
|
||||
|
||||
map.set(ymd, {
|
||||
am: existing.am || am,
|
||||
pm: existing.pm || pm,
|
||||
labels: existing.labels.includes(hoverLabel)
|
||||
? existing.labels
|
||||
: [...existing.labels, hoverLabel],
|
||||
hasCongeTypeC: existing.hasCongeTypeC || typeCode === 'C',
|
||||
hasOtherTypes: existing.hasOtherTypes || typeCode !== 'C'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const months = computed(() => {
|
||||
return orderedMonthIndexes.value.map((monthIndex) => {
|
||||
const label = monthLabels[monthIndex]
|
||||
const monthYear = isForfaitRule.value
|
||||
? displayedYear.value
|
||||
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
|
||||
|
||||
const first = new Date(monthYear, monthIndex, 1)
|
||||
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
|
||||
const mondayBasedFirstDay = (first.getDay() + 6) % 7
|
||||
|
||||
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null } | null> = []
|
||||
|
||||
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
const ymd = toYmd(monthYear, monthIndex, day)
|
||||
cells.push({
|
||||
ymd,
|
||||
label: String(day),
|
||||
leave: dayLeaveMap.value.get(ymd) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
cells
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const getDayClass = (leave: DayLeaveState | null) => {
|
||||
if (!leave) return 'text-primary-500'
|
||||
if (leave.am && leave.pm) {
|
||||
return leave.hasOtherTypes
|
||||
? 'bg-red-600 text-white rounded font-semibold'
|
||||
: 'bg-primary-500 text-white rounded font-semibold'
|
||||
}
|
||||
return 'rounded text-primary-700 font-semibold text-white'
|
||||
}
|
||||
|
||||
const getDayStyle = (leave: DayLeaveState | null) => {
|
||||
if (!leave || (leave.am && leave.pm)) return undefined
|
||||
|
||||
const color = leave.hasOtherTypes ? '#dc2626' : '#222783'
|
||||
const backgroundImage = leave.am
|
||||
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)`
|
||||
: `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)`
|
||||
|
||||
return {
|
||||
backgroundImage,
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
|
||||
return day.label
|
||||
}
|
||||
|
||||
const getDayTitle = (leave: DayLeaveState | null) => {
|
||||
if (!leave || leave.labels.length === 0) return ''
|
||||
return leave.labels.join(' / ')
|
||||
}
|
||||
|
||||
const formatCount = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
const rounded = Math.round(value * 100) / 100
|
||||
if (Number.isInteger(rounded)) return String(rounded)
|
||||
return rounded.toFixed(2).replace('.', ',')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||
import { listAbsences } from '~/services/absences'
|
||||
import { listContracts } from '~/services/contracts'
|
||||
import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary'
|
||||
import { getEmployee, updateEmployee } from '~/services/employees'
|
||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
|
||||
@@ -13,6 +17,8 @@ export const useEmployeeDetailPage = () => {
|
||||
const isLoading = ref(false)
|
||||
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
||||
const contracts = ref<Contract[]>([])
|
||||
const employeeAbsences = ref<Absence[]>([])
|
||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||
const isContractDrawerOpen = ref(false)
|
||||
const isContractSubmitting = ref(false)
|
||||
const isCreateContractDrawerOpen = ref(false)
|
||||
@@ -24,7 +30,8 @@ export const useEmployeeDetailPage = () => {
|
||||
weeklyHours: null as number | null,
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
endDate: '',
|
||||
paidLeaveSettled: false
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -46,6 +53,7 @@ export const useEmployeeDetailPage = () => {
|
||||
})
|
||||
|
||||
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||
const employeeContractWorkLabel = computed(() => {
|
||||
const contract = employee.value?.contract
|
||||
if (!contract) return '-'
|
||||
@@ -126,6 +134,7 @@ export const useEmployeeDetailPage = () => {
|
||||
contractForm.contractNature = active.contractNature
|
||||
contractForm.startDate = active.startDate
|
||||
contractForm.endDate = getTodayYmd()
|
||||
contractForm.paidLeaveSettled = false
|
||||
}
|
||||
|
||||
const openCloseContractDrawer = () => {
|
||||
@@ -171,7 +180,35 @@ export const useEmployeeDetailPage = () => {
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
employee.value = await getEmployee(employeeId)
|
||||
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 from = isForfait
|
||||
? `${leaveYear}-01-01`
|
||||
: `${leaveYear - 1}-06-01`
|
||||
const to = isForfait
|
||||
? `${leaveYear}-12-31`
|
||||
: `${leaveYear}-05-31`
|
||||
const [absences, summary] = await Promise.all([
|
||||
listAbsences({
|
||||
from,
|
||||
to,
|
||||
employeeId: loadedEmployee.id
|
||||
}),
|
||||
showLeaveTab.value
|
||||
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
|
||||
: Promise.resolve(null)
|
||||
])
|
||||
employeeAbsences.value = absences
|
||||
leaveSummary.value = summary
|
||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -198,7 +235,8 @@ export const useEmployeeDetailPage = () => {
|
||||
lastName: employee.value.lastName,
|
||||
siteId: employee.value.site?.id ?? null,
|
||||
contractId: Number(contractForm.contractId),
|
||||
contractEndDate: contractForm.endDate || null
|
||||
contractEndDate: contractForm.endDate || null,
|
||||
contractPaidLeaveSettled: contractForm.paidLeaveSettled
|
||||
})
|
||||
|
||||
isContractDrawerOpen.value = false
|
||||
@@ -262,6 +300,9 @@ export const useEmployeeDetailPage = () => {
|
||||
isLoading,
|
||||
activeTab,
|
||||
contracts,
|
||||
employeeAbsences,
|
||||
leaveSummary,
|
||||
showLeaveTab,
|
||||
contractHistory,
|
||||
employeeContractWorkLabel,
|
||||
contractForm,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-full overflow-auto">
|
||||
<div>
|
||||
|
||||
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
@@ -27,17 +27,18 @@
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="activeTab = 'contract'"
|
||||
>
|
||||
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||
<Icon name="mdi:file-check-outline" size="24" class="align-self"/>
|
||||
Suivi contrat
|
||||
</button>
|
||||
<button
|
||||
v-if="showLeaveTab"
|
||||
class="pb-2 border-b-2 flex items-center gap-3"
|
||||
:class="activeTab === 'leave'
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="activeTab = 'leave'"
|
||||
>
|
||||
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||
<Icon name="mdi:event-blank-outline" size="24" class="align-self"/>
|
||||
Congé
|
||||
</button>
|
||||
<button
|
||||
@@ -47,7 +48,7 @@
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="activeTab = 'rtt'"
|
||||
>
|
||||
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||
<Icon name="mdi:schedule" size="24" class="align-self"/>
|
||||
RTT
|
||||
</button>
|
||||
</div>
|
||||
@@ -86,7 +87,7 @@
|
||||
:on-submit-close-contract="submitContractUpdate"
|
||||
:on-submit-create-contract="submitCreateContract"
|
||||
/>
|
||||
<EmployeesLeaveTab v-else-if="activeTab === 'leave'" />
|
||||
<EmployeesLeaveTab v-else-if="showLeaveTab && activeTab === 'leave'" :absences="employeeAbsences" :summary="leaveSummary" />
|
||||
<EmployeesRttTab v-else />
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +99,9 @@ const {
|
||||
isLoading,
|
||||
activeTab,
|
||||
contracts,
|
||||
employeeAbsences,
|
||||
leaveSummary,
|
||||
showLeaveTab,
|
||||
contractHistory,
|
||||
employeeContractWorkLabel,
|
||||
contractForm,
|
||||
|
||||
@@ -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<Absence[] | { 'hydra:member'?: Absence[] }>(
|
||||
'/absences',
|
||||
query,
|
||||
|
||||
14
frontend/services/dto/employee-leave-summary.ts
Normal file
14
frontend/services/dto/employee-leave-summary.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
10
frontend/services/employee-leave-summary.ts
Normal file
10
frontend/services/employee-leave-summary.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
||||
|
||||
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
|
||||
const api = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (year) query.year = String(year)
|
||||
|
||||
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const updateEmployee = async (
|
||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||
contractStartDate?: string
|
||||
contractEndDate?: string | null
|
||||
contractPaidLeaveSettled?: boolean
|
||||
displayOrder?: number
|
||||
}
|
||||
) => {
|
||||
@@ -83,6 +84,9 @@ export const updateEmployee = async (
|
||||
if (payload.contractEndDate !== undefined) {
|
||||
body.contractEndDate = payload.contractEndDate ?? null
|
||||
}
|
||||
if (payload.contractPaidLeaveSettled !== undefined) {
|
||||
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
|
||||
}
|
||||
|
||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||
toastSuccessKey: 'success.employee.update',
|
||||
|
||||
2
makefile
2
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:
|
||||
|
||||
26
migrations/Version20260304140000.php
Normal file
26
migrations/Version20260304140000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add paid leave settled flag on employee contract periods';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20260304153000.php
Normal file
29
migrations/Version20260304153000.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304153000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create employee leave balances table for annual rollover opening balances';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
48
migrations/Version20260304170000.php
Normal file
48
migrations/Version20260304170000.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add comments on employee_leave_balances table and columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$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.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');
|
||||
}
|
||||
}
|
||||
250
migrations/Version20260304173000.php
Normal file
250
migrations/Version20260304173000.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304173000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add comments on all database tables and columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// absence_types
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/ApiResource/EmployeeLeaveSummary.php
Normal file
34
src/ApiResource/EmployeeLeaveSummary.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\EmployeeLeaveSummaryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/employees/{id}/leave-summary',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: EmployeeLeaveSummaryProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class EmployeeLeaveSummary
|
||||
{
|
||||
public int $year = 0;
|
||||
public bool $isSupported = false;
|
||||
public string $ruleCode = '';
|
||||
public float $acquiredDays = 0.0;
|
||||
public float $remainingDays = 0.0;
|
||||
public float $takenDays = 0.0;
|
||||
public float $acquiredSaturdays = 0.0;
|
||||
public float $remainingSaturdays = 0.0;
|
||||
public float $takenSaturdays = 0.0;
|
||||
public float $fractionedDays = 0.0;
|
||||
public float $accruingDays = 0.0;
|
||||
}
|
||||
188
src/Command/LeaveRolloverCommand.php
Normal file
188
src/Command/LeaveRolloverCommand.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeLeaveBalance;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Service\Leave\LeaveBalanceComputationService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:leave:rollover',
|
||||
description: 'Create yearly leave opening balances (idempotent).'
|
||||
)]
|
||||
final class LeaveRolloverCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EmployeeRepository $employeeRepository,
|
||||
private readonly EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||
private readonly LeaveBalanceComputationService $leaveBalanceComputationService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'force',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Run rollover regardless of business date (manual recovery mode).'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$today = new DateTimeImmutable('today');
|
||||
$force = (bool) $input->getOption('force');
|
||||
|
||||
if (!$force && !$this->isBusinessRolloverDate($today)) {
|
||||
$io->success('No rollover today: business date is neither 01/01 nor 01/06.');
|
||||
|
||||
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) {
|
||||
++$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) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$carryDays, $carrySaturdays] = $this->resolveCarry($employee, $ruleCode, $targetYear);
|
||||
$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);
|
||||
++$created;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf(
|
||||
'Leave rollover done: %d created, %d skipped.',
|
||||
$created,
|
||||
$skipped
|
||||
));
|
||||
|
||||
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();
|
||||
$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];
|
||||
}
|
||||
}
|
||||
97
src/DataFixtures/AbsenceFixtures.php
Normal file
97
src/DataFixtures/AbsenceFixtures.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\AbsenceType;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\HalfDay;
|
||||
use DateTime;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class AbsenceFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
39
src/DataFixtures/AbsenceTypeFixtures.php
Normal file
39
src/DataFixtures/AbsenceTypeFixtures.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\AbsenceType;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class AbsenceTypeFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$definitions = [
|
||||
[FixtureReferences::ABSENCE_TYPE_RTT, 'R', 'RTT', '#feba01', false],
|
||||
[FixtureReferences::ABSENCE_TYPE_CONGE, 'C', 'CONGÉ', '#008000', true],
|
||||
[FixtureReferences::ABSENCE_TYPE_MALADIE, 'M', 'MALADIE', '#ed07da', true],
|
||||
[FixtureReferences::ABSENCE_TYPE_AT, 'AT', 'ACCIDENT DE TRAVAIL', '#e41111', true],
|
||||
[FixtureReferences::ABSENCE_TYPE_FORMATION, 'F', 'FORMATION', '#6c72e0', true],
|
||||
[FixtureReferences::ABSENCE_TYPE_EXCEPTIONNEL, 'EX', 'CONGE EXCEPTIONNEL', '#8834ae', true],
|
||||
[FixtureReferences::ABSENCE_TYPE_ABSENT, 'ABS', 'ABSENT', '#823a21', false],
|
||||
[FixtureReferences::ABSENCE_TYPE_AUTRE, 'AUT', 'AUTRE', '#f07d60', true],
|
||||
];
|
||||
|
||||
foreach ($definitions as [$reference, $code, $label, $color, $countAsWorkedHours]) {
|
||||
$absenceType = new AbsenceType()
|
||||
->setCode($code)
|
||||
->setLabel($label)
|
||||
->setColor($color)
|
||||
->setCountAsWorkedHours($countAsWorkedHours)
|
||||
;
|
||||
$manager->persist($absenceType);
|
||||
$this->addReference($reference, $absenceType);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
55
src/DataFixtures/ContractFixtures.php
Normal file
55
src/DataFixtures/ContractFixtures.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Enum\TrackingMode;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class ContractFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$contract35 = new Contract()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
91
src/DataFixtures/EmployeeContractPeriodFixtures.php
Normal file
91
src/DataFixtures/EmployeeContractPeriodFixtures.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class EmployeeContractPeriodFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
71
src/DataFixtures/EmployeeFixtures.php
Normal file
71
src/DataFixtures/EmployeeFixtures.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\Site;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class EmployeeFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$site = $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
80
src/DataFixtures/EmployeeLeaveBalanceFixtures.php
Normal file
80
src/DataFixtures/EmployeeLeaveBalanceFixtures.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeLeaveBalance;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class EmployeeLeaveBalanceFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
31
src/DataFixtures/FixtureReferences.php
Normal file
31
src/DataFixtures/FixtureReferences.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
final class FixtureReferences
|
||||
{
|
||||
public const SITE_MAIN = 'site.main';
|
||||
|
||||
public const CONTRACT_35 = 'contract.35h';
|
||||
public const CONTRACT_4H = 'contract.4h';
|
||||
public const CONTRACT_FORFAIT = 'contract.forfait';
|
||||
public const CONTRACT_INTERIM = 'contract.interim';
|
||||
|
||||
public const ABSENCE_TYPE_RTT = 'absence_type.rtt';
|
||||
public const ABSENCE_TYPE_CONGE = 'absence_type.conge';
|
||||
public const ABSENCE_TYPE_MALADIE = 'absence_type.maladie';
|
||||
public const ABSENCE_TYPE_AT = 'absence_type.at';
|
||||
public const ABSENCE_TYPE_FORMATION = 'absence_type.formation';
|
||||
public const ABSENCE_TYPE_EXCEPTIONNEL = 'absence_type.exceptionnel';
|
||||
public const ABSENCE_TYPE_ABSENT = 'absence_type.absent';
|
||||
public const ABSENCE_TYPE_AUTRE = 'absence_type.autre';
|
||||
|
||||
public const EMPLOYEE_STANDARD = 'employee.standard_non_forfait';
|
||||
public const EMPLOYEE_4H = 'employee.four_hours';
|
||||
public const EMPLOYEE_FORFAIT = 'employee.forfait';
|
||||
public const EMPLOYEE_INTERIM = 'employee.interim';
|
||||
|
||||
public const USER_ADMIN_EMILIE = 'user.admin.emilie';
|
||||
}
|
||||
26
src/DataFixtures/SiteFixtures.php
Normal file
26
src/DataFixtures/SiteFixtures.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Site;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class SiteFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$site = new Site()
|
||||
->setName('SITE TEST')
|
||||
->setColor('#75aed9')
|
||||
->setDisplayOrder(1)
|
||||
;
|
||||
|
||||
$manager->persist($site);
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(FixtureReferences::SITE_MAIN, $site);
|
||||
}
|
||||
}
|
||||
26
src/DataFixtures/UserFixtures.php
Normal file
26
src/DataFixtures/UserFixtures.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
final class UserFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$emilie = new User()
|
||||
->setUsername('emilie')
|
||||
->setRoles(['ROLE_ADMIN'])
|
||||
->setPassword('$2y$13$swd6WU4z7Z4MeZwl9hHaFez8xB/GMZxyrCDQUlFEY55JQUzTW0bPO')
|
||||
;
|
||||
|
||||
$manager->persist($emilie);
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference(FixtureReferences::USER_ADMIN_EMILIE, $emilie);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationEnabled: false,
|
||||
)]
|
||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||
#[ORM\Table(name: 'absences')]
|
||||
class Absence
|
||||
|
||||
@@ -75,6 +75,9 @@ class Employee
|
||||
#[Groups(['employee:write'])]
|
||||
private ?string $contractEndDate = null;
|
||||
|
||||
#[Groups(['employee:write'])]
|
||||
private ?bool $contractPaidLeaveSettled = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
@@ -187,6 +190,18 @@ class Employee
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContractPaidLeaveSettled(): ?bool
|
||||
{
|
||||
return $this->contractPaidLeaveSettled;
|
||||
}
|
||||
|
||||
public function setContractPaidLeaveSettled(?bool $contractPaidLeaveSettled): self
|
||||
{
|
||||
$this->contractPaidLeaveSettled = $contractPaidLeaveSettled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['employee:read'])]
|
||||
public function getCurrentContractNature(): string
|
||||
{
|
||||
|
||||
@@ -37,6 +37,9 @@ 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: 'datetime_immutable')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -121,4 +124,16 @@ class EmployeeContractPeriod
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function isPaidLeaveSettled(): bool
|
||||
{
|
||||
return $this->paidLeaveSettled;
|
||||
}
|
||||
|
||||
public function setPaidLeaveSettled(bool $paidLeaveSettled): self
|
||||
{
|
||||
$this->paidLeaveSettled = $paidLeaveSettled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
238
src/Entity/EmployeeLeaveBalance.php
Normal file
238
src/Entity/EmployeeLeaveBalance.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EmployeeLeaveBalanceRepository::class)]
|
||||
#[ORM\Table(name: 'employee_leave_balances', options: ['comment' => '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: '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 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;
|
||||
}
|
||||
}
|
||||
12
src/Enum/LeaveRuleCode.php
Normal file
12
src/Enum/LeaveRuleCode.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum LeaveRuleCode: string
|
||||
{
|
||||
case UNSUPPORTED = 'UNSUPPORTED';
|
||||
case CDI_CDD_NON_FORFAIT = 'CDI_CDD_NON_FORFAIT';
|
||||
case FORFAIT_218 = 'FORFAIT_218';
|
||||
}
|
||||
@@ -99,4 +99,29 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
|
||||
// @var list<Absence> $absences
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Absence>
|
||||
*/
|
||||
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<Absence> $absences
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface EmployeeContractPeriodReadRepositoryInterface
|
||||
{
|
||||
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Repository;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -13,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Repository/EmployeeLeaveBalanceRepository.php
Normal file
59
src/Repository/EmployeeLeaveBalanceRepository.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeLeaveBalance;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EmployeeLeaveBalance>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
34
src/Service/Contracts/EmployeeContractChangeRequest.php
Normal file
34
src/Service/Contracts/EmployeeContractChangeRequest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class EmployeeContractChangeRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?ContractNature $contractNature,
|
||||
public ?DateTimeImmutable $contractStartDate,
|
||||
public ?DateTimeImmutable $contractEndDate,
|
||||
public ?bool $contractPaidLeaveSettled,
|
||||
) {}
|
||||
|
||||
public function hasPeriodChangeRequest(): bool
|
||||
{
|
||||
return null !== $this->contractNature
|
||||
|| null !== $this->contractStartDate
|
||||
|| null !== $this->contractEndDate
|
||||
|| null !== $this->contractPaidLeaveSettled;
|
||||
}
|
||||
|
||||
public function isCloseOnlyRequest(bool $contractChanged): bool
|
||||
{
|
||||
return !$contractChanged
|
||||
&& null === $this->contractStartDate
|
||||
&& null === $this->contractNature
|
||||
&& null !== $this->contractEndDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final class EmployeeContractChangeRequestFactory
|
||||
{
|
||||
public function fromEmployee(Employee $employee): EmployeeContractChangeRequest
|
||||
{
|
||||
return new EmployeeContractChangeRequest(
|
||||
contractNature: $this->resolveContractNature($employee->getContractNature()),
|
||||
contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'),
|
||||
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
30
src/Service/Contracts/EmployeeContractPeriodBuilder.php
Normal file
30
src/Service/Contracts/EmployeeContractPeriodBuilder.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class EmployeeContractPeriodBuilder
|
||||
{
|
||||
public function build(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
): EmployeeContractPeriod {
|
||||
return new EmployeeContractPeriod()
|
||||
->setEmployee($employee)
|
||||
->setContract($contract)
|
||||
->setStartDate($startDate)
|
||||
->setEndDate($endDate)
|
||||
->setContractNature($nature)
|
||||
;
|
||||
}
|
||||
}
|
||||
96
src/Service/Contracts/EmployeeContractPeriodManager.php
Normal file
96
src/Service/Contracts/EmployeeContractPeriodManager.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class EmployeeContractPeriodManager implements EmployeeContractPeriodManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private EmployeeContractPeriodBuilder $periodBuilder,
|
||||
private EmployeeContractPeriodValidator $periodValidator,
|
||||
) {}
|
||||
|
||||
public function ensureContractPeriodExists(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
): void {
|
||||
$this->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
|
||||
): 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);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface EmployeeContractPeriodManagerInterface
|
||||
{
|
||||
public function ensureContractPeriodExists(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
): void;
|
||||
|
||||
public function closeCurrentPeriod(
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
DateTimeImmutable $requestedEndDate,
|
||||
bool $paidLeaveSettled
|
||||
): void;
|
||||
|
||||
public function createNextPeriod(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
?EmployeeContractPeriod $todayPeriod
|
||||
): void;
|
||||
}
|
||||
63
src/Service/Contracts/EmployeeContractPeriodValidator.php
Normal file
63
src/Service/Contracts/EmployeeContractPeriodValidator.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Contracts;
|
||||
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final class EmployeeContractPeriodValidator
|
||||
{
|
||||
public function assertPeriodDates(
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
bool $allowCdiEndDate = false
|
||||
): 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 (!$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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/Service/Leave/LeaveBalanceComputationService.php
Normal file
384
src/Service/Leave/LeaveBalanceComputationService.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Leave;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
final readonly class LeaveBalanceComputationService
|
||||
{
|
||||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||||
private const float STANDARD_ANNUAL_DAYS = 25.0;
|
||||
private const float STANDARD_ANNUAL_SATURDAYS = 5.0;
|
||||
private const float STANDARD_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_DAYS / 12.0;
|
||||
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
|
||||
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
|
||||
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
|
||||
|
||||
public function __construct(
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{float, float}
|
||||
*/
|
||||
public function computeDynamicClosingForYear(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
|
||||
{
|
||||
$firstYear = $this->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;
|
||||
}
|
||||
|
||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
$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);
|
||||
|
||||
$takenFromAcquired = min(max(0.0, $carryDays), $takenDays);
|
||||
$remainingAcquired = $carryDays - $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 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<string, string>
|
||||
*/
|
||||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $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];
|
||||
}
|
||||
}
|
||||
653
src/State/EmployeeLeaveSummaryProvider.php
Normal file
653
src/State/EmployeeLeaveSummaryProvider.php
Normal file
@@ -0,0 +1,653 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeLeaveSummary;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Leave\LeaveBalanceComputationService;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
{
|
||||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS = 25.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS = 5.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS / 12.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_DAYS = 10.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS = 0.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH = 0.83;
|
||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||
private LeaveBalanceComputationService $leaveBalanceComputationService,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
|
||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||
}
|
||||
|
||||
$year = $this->resolveYear($employee);
|
||||
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
|
||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
||||
if (null === $yearSummary) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$summary->isSupported = true;
|
||||
$summary->ruleCode = $yearSummary['ruleCode'];
|
||||
$summary->acquiredDays = $yearSummary['acquiredDays'];
|
||||
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
|
||||
$summary->fractionedDays = 0.0;
|
||||
$summary->accruingDays = $yearSummary['accruingDays'];
|
||||
$summary->takenDays = $yearSummary['takenDays'];
|
||||
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
|
||||
$summary->remainingDays = $yearSummary['remainingDays'];
|
||||
$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<string, string>
|
||||
*/
|
||||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 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<Absence> $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];
|
||||
}
|
||||
}
|
||||
@@ -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,22 +54,24 @@ 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;
|
||||
}
|
||||
|
||||
@@ -75,50 +80,32 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
$contractChanged = $currentPeriodContract instanceof Contract
|
||||
? $currentPeriodContract->getId() !== $currentContract->getId()
|
||||
: true;
|
||||
$isCloseOnlyRequest = !$contractChanged
|
||||
&& null === $requestedStartDate
|
||||
&& null === $requestedContractNature
|
||||
&& null !== $requestedEndDate;
|
||||
$isCloseOnlyRequest = $changeRequest->isCloseOnlyRequest($contractChanged);
|
||||
|
||||
if ($isCloseOnlyRequest) {
|
||||
if (null === $todayPeriod) {
|
||||
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
||||
$requestedEndDate = $changeRequest->contractEndDate;
|
||||
if (null === $requestedEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
||||
}
|
||||
|
||||
$currentNature = $todayPeriod->getContractNatureEnum();
|
||||
$this->assertPeriodDates($todayPeriod->getStartDate(), $requestedEndDate, $currentNature, true);
|
||||
|
||||
$currentEndDate = $todayPeriod->getEndDate();
|
||||
if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
|
||||
}
|
||||
|
||||
$todayPeriod->setEndDate($requestedEndDate);
|
||||
$this->entityManager->flush();
|
||||
$this->periodManager->closeCurrentPeriod(
|
||||
$todayPeriod,
|
||||
$requestedEndDate,
|
||||
$changeRequest->contractPaidLeaveSettled ?? false
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$startDate = $requestedStartDate ?? $today;
|
||||
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
$endDate = $requestedEndDate;
|
||||
$this->assertPeriodDates($startDate, $endDate, $nature);
|
||||
|
||||
if (null !== $todayPeriod) {
|
||||
$currentEndDate = $todayPeriod->getEndDate();
|
||||
if (null === $currentEndDate) {
|
||||
if ($startDate <= $todayPeriod->getStartDate()) {
|
||||
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
|
||||
}
|
||||
|
||||
$todayPeriod->setEndDate($startDate->modify('-1 day'));
|
||||
} elseif ($startDate <= $currentEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
@@ -143,82 +130,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,
|
||||
bool $allowCdiEndDate = false
|
||||
): 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 (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
symfony.lock
12
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": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeContractChangeRequestFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreatesRequestFromEmployeePayload(): void
|
||||
{
|
||||
$factory = new EmployeeContractChangeRequestFactory();
|
||||
$employee = $this->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)
|
||||
;
|
||||
}
|
||||
}
|
||||
118
tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php
Normal file
118
tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Service\Contracts\EmployeeContractPeriodValidator;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeContractPeriodValidatorTest extends TestCase
|
||||
{
|
||||
private EmployeeContractPeriodValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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)
|
||||
;
|
||||
}
|
||||
}
|
||||
264
tests/State/EmployeeWriteProcessorTest.php
Normal file
264
tests/State/EmployeeWriteProcessorTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
|
||||
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
|
||||
use App\State\EmployeeWriteProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeWriteProcessorTest extends TestCase
|
||||
{
|
||||
public function testDelegatesCloseOnlyRequestToPeriodManager(): void
|
||||
{
|
||||
$employee = $this->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)
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user