diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..7db35a3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(npx vue-tsc:*)", + "Bash(npx nuxi:*)", + "Bash(php:*)", + "Bash(docker compose:*)", + "Bash(make test:*)" + ] + } +} diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml deleted file mode 100644 index 3fadc3d..0000000 --- a/.idea/sqldialects.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/doc/functional-rules.md b/doc/functional-rules.md index c1d6a89..3e31acc 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -2,8 +2,9 @@ Ce document centralise les règles métier actuellement implémentées dans l'application. -Document complementaire (rollover conges et checklist de lancement): -- `doc/leave-rollover.md` +Documents complementaires: +- `doc/leave-rollover.md` (rollover conges et checklist de lancement) +- `doc/rtt-rollover.md` (rollover RTT et checklist de lancement) ## 1) Utilisateurs et accès @@ -196,6 +197,31 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu) - exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile) - hors périmètre phase 1: `INTERIM` (retour non supporté) + - onglet `RTT`: + - endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY` + - exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice) + - affichage: + - détail hebdomadaire (semaine ISO) regroupé par mois + - total mensuel des minutes de récupération + - compteur global exercice = `report N-1 + acquis N` + - attribution mensuelle des semaines: + - une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine + - si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine + - logique de calcul: + - base identique aux calculs d'heures supplémentaires de la vue semaine Heures + - minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%` + - contrats `INTERIM` et suivi `PRESENCE`: récupération à `0` + - compteur global: + - affiché en **jours** (1 jour = 7h = 420 minutes) + - report: + - le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent + - si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité + - sinon, le calcul dynamique sur l'exercice N-1 est effectué + - rollover automatique: + - commande: `php bin/console app:rtt:rollover` + - s'exécute le `1er juin` (même cron que le rollover congés) + - calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice + - idempotent (ne recrée pas si la ligne existe) ## 10) Notifications diff --git a/doc/rtt-rollover.md b/doc/rtt-rollover.md new file mode 100644 index 0000000..5af58db --- /dev/null +++ b/doc/rtt-rollover.md @@ -0,0 +1,163 @@ +# Rollover RTT - Regles et Mise en Production + +Document de reference pour expliquer le fonctionnement metier du report RTT N-1 et preparer le lancement en production. + +## 1) Objectif + +Permettre le report des heures supplementaires (RTT) d'un exercice a l'autre et fiabiliser les soldes. + +Principe: +- le solde d'ouverture est stocke par exercice +- au changement d'exercice, on ouvre la nouvelle periode avec un "solde d'ouverture" (report N-1) +- au go-live, les soldes d'ouverture sont importes manuellement (CSV ou insertion SQL) + +## 2) Exercice metier + +- exercice RTT: du `1er juin` au `31 mai` +- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026) +- employes eligibles: tous sauf `INTERIM` et suivi `PRESENCE` + +## 3) Logique de compteurs + +- `report N-1`: + - correspond au solde d'ouverture (`opening_minutes`) + - source prioritaire: table `employee_rtt_balances` + - fallback: calcul dynamique de la somme des minutes de recuperation de l'exercice precedent +- `acquis N`: + - somme des minutes de recuperation hebdomadaires de l'exercice en cours + - calcul: `HS totales + bonus 25% + bonus 50%` par semaine +- `disponible`: + - `report N-1 + acquis N` +- affichage du compteur global: en **jours** (1 jour = 7h = 420 minutes) + +## 4) Attribution mensuelle des semaines + +- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine +- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine +- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois + +## 5) Table cible + +Table `employee_rtt_balances` (une ligne par employe et exercice): +- `employee_id` +- `year` +- `opening_minutes` +- `is_locked` +- `created_at`, `updated_at` + +Contrainte unique: +- `(employee_id, year)` + +Etat implementation: +- la table est creee +- le calcul de synthese RTT lit en priorite `opening_minutes` de cette table quand une ligne existe pour `(employee, year)` +- si aucune ligne n'existe, le calcul dynamique sur l'exercice N-1 est effectue + +### Definition des colonnes + +- `employee_id`: + - identifiant employe (FK vers `employees`) + - une ligne de solde par employe / exercice +- `year`: + - annee d'exercice (annee de fin) + - `2026` = 01/06/2025 -> 31/05/2026 +- `opening_minutes`: + - report N-1 en minutes (solde d'ouverture) + - correspond a la somme des minutes de recuperation de l'exercice precedent +- `is_locked`: + - `false` sur exercice ouvert (recalcul possible) + - `true` apres validation RH (exercice fige) +- `created_at`, `updated_at`: + - trace technique creation / mise a jour + +## 6) Rollover automatique + +Commande quotidienne (cron) idempotente. + +- commande Symfony: `php bin/console app:rtt:rollover` +- comportement date metier: + - le `01/06`: calcule et persiste le report pour chaque employe eligible + - les autres jours: sortie sans action +- option manuelle: `--force` pour executer hors date metier (reprise/correction) + +Date d'effet: +- au `1er juin` (meme date que le rollover conges non forfait) + +Traitement par employe: +1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE) +2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence) +3. calculer la somme des minutes de recuperation de l'exercice N-1 +4. creer la ligne du nouvel exercice avec ce total en `opening_minutes` + +## 7) Donnees a fournir au go-live + +La RH doit fournir les soldes RTT a reporter. + +Colonnes minimales: +- `employee_id` (id interne) +- `year` +- `opening_minutes` (total en minutes) + +Format recommande: +- CSV UTF-8 +- separateur `;` + +Exemple: +```csv +employee_id;year;opening_minutes +42;2026;1260 +17;2026;840 +``` + +Equivalent en insertion SQL directe: +```sql +INSERT INTO employee_rtt_balances (employee_id, year, opening_minutes, is_locked, created_at, updated_at) +VALUES + (42, 2026, 1260, false, NOW(), NOW()), + (17, 2026, 840, false, NOW(), NOW()); +``` + +Conversion rapide: `1260 minutes = 21h00 = 3.00 jours` (1 jour = 420 min = 7h) + +## 8) Checklist mise en prod + +1. Executer la migration (`employee_rtt_balances`) +2. Importer les soldes d'ouverture N-1 (CSV ou SQL) +3. Verifier 3 cas metier: + - CDI 39h avec heures supp sur l'exercice precedent + - CDI 35h sans heures supp (report = 0) + - INTERIM (doit etre ignore, pas de ligne creee) +4. Activer le cron de rollover +5. Geler (`is_locked`) les exercices historicises valides + +Exemple cron (tous les jours a 02:15, juste apres le rollover conges): +Dev +```cron +15 2 * * * cd /var/www/html && php bin/console app:rtt:rollover --no-interaction >> var/log/rtt-rollover.log 2>&1 +``` +Prod +```cron +15 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction >> var/log/rtt-rollover.log 2>&1 +``` +Explication de la ligne cron: +- `15 2 * * *`: tous les jours a 02:15 +- `php bin/console app:rtt:rollover --no-interaction`: execute le rollover sans confirmation + - hors `01/06`, la commande sort en no-op (normal) +- `>> var/log/rtt-rollover.log 2>&1`: log sortie standard et erreurs + +Execution manuelle forcee: +```bash +php bin/console app:rtt:rollover --force --no-interaction +``` + +Exemple de verification rapide: +```bash +tail -n 50 /var/www/html/var/log/rtt-rollover.log +``` + +## 9) Points de vigilance + +- Ne jamais modifier `opening_minutes` apres validation RH sans procedure explicite +- Garder une trace de toute correction manuelle (auteur, date, motif) +- Le calcul dynamique N-1 (fallback) parcourt toutes les heures de l'exercice precedent: preferer l'import explicite pour les exercices historiques +- La commande de rollover est idempotente: si une ligne existe deja, l'employe est ignore (pas d'ecrasement) diff --git a/frontend/components/employees/LeaveTab.vue b/frontend/components/employees/LeaveTab.vue index 760ee66..0addee5 100644 --- a/frontend/components/employees/LeaveTab.vue +++ b/frontend/components/employees/LeaveTab.vue @@ -1,56 +1,65 @@ diff --git a/frontend/composables/useEmployeeDetailPage.ts b/frontend/composables/useEmployeeDetailPage.ts index 5f9ba25..4f6e3f6 100644 --- a/frontend/composables/useEmployeeDetailPage.ts +++ b/frontend/composables/useEmployeeDetailPage.ts @@ -1,11 +1,13 @@ import type { Contract } from '~/services/dto/contract' import type { Absence } from '~/services/dto/absence' import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary' +import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary' import type { ContractHistoryItem, Employee } from '~/services/dto/employee' import { CONTRACT_TYPES } from '~/services/dto/contract' import { listAbsences } from '~/services/absences' import { listContracts } from '~/services/contracts' import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary' +import { getEmployeeRttSummary } from '~/services/employee-rtt-summary' import { getEmployee, updateEmployee } from '~/services/employees' import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract' @@ -19,6 +21,7 @@ export const useEmployeeDetailPage = () => { const contracts = ref([]) const employeeAbsences = ref([]) const leaveSummary = ref(null) + const rttSummary = ref(null) const isContractDrawerOpen = ref(false) const isContractSubmitting = ref(false) const isCreateContractDrawerOpen = ref(false) @@ -188,13 +191,14 @@ export const useEmployeeDetailPage = () => { const leaveYear = isForfait ? now.getFullYear() : (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()) + const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear() const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01` const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31` - const [absences, summary] = await Promise.all([ + const [absences, summary, rtt] = await Promise.all([ listAbsences({ from, to, @@ -202,10 +206,12 @@ export const useEmployeeDetailPage = () => { }), showLeaveTab.value ? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear) - : Promise.resolve(null) + : Promise.resolve(null), + getEmployeeRttSummary(loadedEmployee.id, rttYear) ]) employeeAbsences.value = absences leaveSummary.value = summary + rttSummary.value = rtt if (!showLeaveTab.value && activeTab.value === 'leave') { activeTab.value = 'contract' } @@ -302,6 +308,7 @@ export const useEmployeeDetailPage = () => { contracts, employeeAbsences, leaveSummary, + rttSummary, showLeaveTab, contractHistory, employeeContractWorkLabel, diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue index 55ebbcb..8dc8407 100644 --- a/frontend/pages/employees/[id].vue +++ b/frontend/pages/employees/[id].vue @@ -1,5 +1,5 @@ @@ -101,6 +108,7 @@ const { contracts, employeeAbsences, leaveSummary, + rttSummary, showLeaveTab, contractHistory, employeeContractWorkLabel, diff --git a/frontend/services/dto/employee-rtt-summary.ts b/frontend/services/dto/employee-rtt-summary.ts new file mode 100644 index 0000000..282b154 --- /dev/null +++ b/frontend/services/dto/employee-rtt-summary.ts @@ -0,0 +1,16 @@ +export type EmployeeRttWeekSummary = { + month: number + weekNumber: number + weekStart: string + weekEnd: string + recoveryMinutes: number +} + +export type EmployeeRttSummary = { + year: number + carryFromPreviousYearMinutes: number + currentYearRecoveryMinutes: number + availableMinutes: number + weeks: EmployeeRttWeekSummary[] +} + diff --git a/frontend/services/employee-rtt-summary.ts b/frontend/services/employee-rtt-summary.ts new file mode 100644 index 0000000..8888379 --- /dev/null +++ b/frontend/services/employee-rtt-summary.ts @@ -0,0 +1,8 @@ +import type { EmployeeRttSummary } from './dto/employee-rtt-summary' + +export const getEmployeeRttSummary = async (employeeId: number, year?: number) => { + const api = useApi() + const query = year ? { year } : {} + return api.get(`/employees/${employeeId}/rtt-summary`, query, { toast: false }) +} + diff --git a/migrations/Version20260306120000.php b/migrations/Version20260306120000.php new file mode 100644 index 0000000..f76319e --- /dev/null +++ b/migrations/Version20260306120000.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE employee_rtt_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, year INT NOT NULL, opening_minutes INT NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_employee_rtt_balance ON employee_rtt_balances (employee_id, year)'); + $this->addSql('CREATE INDEX idx_rtt_balance_employee_year ON employee_rtt_balances (employee_id, year)'); + $this->addSql('ALTER TABLE employee_rtt_balances ADD CONSTRAINT FK_rtt_balance_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE employee_rtt_balances'); + } +} diff --git a/src/ApiResource/EmployeeRttSummary.php b/src/ApiResource/EmployeeRttSummary.php new file mode 100644 index 0000000..f22dbcb --- /dev/null +++ b/src/ApiResource/EmployeeRttSummary.php @@ -0,0 +1,31 @@ + */ + public array $weeks = []; +} diff --git a/src/Command/RttRolloverCommand.php b/src/Command/RttRolloverCommand.php new file mode 100644 index 0000000..77a6fa9 --- /dev/null +++ b/src/Command/RttRolloverCommand.php @@ -0,0 +1,134 @@ +addOption( + 'force', + null, + InputOption::VALUE_NONE, + 'Run rollover regardless of business date (manual recovery mode).' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $today = new DateTimeImmutable('today'); + $force = (bool) $input->getOption('force'); + + if (!$force && '06-01' !== $today->format('m-d')) { + $io->success('No RTT rollover today: business date is not 01/06.'); + + return Command::SUCCESS; + } + + $targetYear = $this->resolveTargetYear($today); + $created = 0; + $skipped = 0; + + foreach ($this->employeeRepository->findAll() as $employee) { + if (!$employee instanceof Employee) { + continue; + } + + if (!$this->isEligible($employee)) { + ++$skipped; + + continue; + } + + $existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear); + if (null !== $existing) { + ++$skipped; + + continue; + } + + $previousYear = $targetYear - 1; + $carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear); + + $balance = new EmployeeRttBalance() + ->setEmployee($employee) + ->setYear($targetYear) + ->setOpeningMinutes($carryMinutes) + ->setIsLocked(false) + ; + + $this->entityManager->persist($balance); + ++$created; + } + + $this->entityManager->flush(); + + $io->success(sprintf( + 'RTT rollover done: %d created, %d skipped.', + $created, + $skipped + )); + + return Command::SUCCESS; + } + + private function resolveTargetYear(DateTimeImmutable $today): int + { + $year = (int) $today->format('Y'); + $month = (int) $today->format('n'); + + return $month >= 6 ? $year + 1 : $year; + } + + private function isEligible(Employee $employee): bool + { + $contract = $employee->getContract(); + if (null === $contract) { + return false; + } + + if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) { + return false; + } + + $type = ContractType::resolve( + $contract->getName(), + $contract->getTrackingMode(), + $contract->getWeeklyHours() + ); + + return ContractType::INTERIM !== $type; + } +} diff --git a/src/Dto/Rtt/EmployeeRttWeekSummary.php b/src/Dto/Rtt/EmployeeRttWeekSummary.php new file mode 100644 index 0000000..71af998 --- /dev/null +++ b/src/Dto/Rtt/EmployeeRttWeekSummary.php @@ -0,0 +1,16 @@ + 'Soldes RTT par employe et exercice (report N-1).'])] +#[ORM\UniqueConstraint(name: 'uniq_employee_rtt_balance', columns: ['employee_id', 'year'])] +#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_balance_employee_year')] +class EmployeeRttBalance +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Employee::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private ?Employee $employee = null; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])] + private int $year = 0; + + #[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])] + private int $openingMinutes = 0; + + #[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])] + private bool $isLocked = false; + + #[ORM\Column(type: 'datetime_immutable')] + private DateTimeImmutable $createdAt; + + #[ORM\Column(type: 'datetime_immutable')] + private DateTimeImmutable $updatedAt; + + public function __construct() + { + $now = new DateTimeImmutable(); + $this->createdAt = $now; + $this->updatedAt = $now; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmployee(): ?Employee + { + return $this->employee; + } + + public function setEmployee(Employee $employee): self + { + $this->employee = $employee; + + return $this; + } + + public function getYear(): int + { + return $this->year; + } + + public function setYear(int $year): self + { + $this->year = $year; + + return $this; + } + + public function getOpeningMinutes(): int + { + return $this->openingMinutes; + } + + public function setOpeningMinutes(int $openingMinutes): self + { + $this->openingMinutes = $openingMinutes; + + return $this; + } + + public function isLocked(): bool + { + return $this->isLocked; + } + + public function setIsLocked(bool $isLocked): self + { + $this->isLocked = $isLocked; + + return $this; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function touch(): self + { + $this->updatedAt = new DateTimeImmutable(); + + return $this; + } +} diff --git a/src/Repository/EmployeeRttBalanceRepository.php b/src/Repository/EmployeeRttBalanceRepository.php new file mode 100644 index 0000000..b438d65 --- /dev/null +++ b/src/Repository/EmployeeRttBalanceRepository.php @@ -0,0 +1,34 @@ + + */ +final class EmployeeRttBalanceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EmployeeRttBalance::class); + } + + public function findOneByEmployeeAndYear(Employee $employee, int $year): ?EmployeeRttBalance + { + return $this->createQueryBuilder('b') + ->andWhere('b.employee = :employee') + ->andWhere('b.year = :year') + ->setParameter('employee', $employee) + ->setParameter('year', $year) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/src/Service/Rtt/RttRecoveryComputationService.php b/src/Service/Rtt/RttRecoveryComputationService.php new file mode 100644 index 0000000..977d2ec --- /dev/null +++ b/src/Service/Rtt/RttRecoveryComputationService.php @@ -0,0 +1,377 @@ + + */ + public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $dayOfWeek = (int) $from->format('N'); + $weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1)); + + $weeks = []; + while ($weekStart <= $to) { + $start = $weekStart; + $end = $start->modify('+6 days'); + $effectiveStart = $start < $from ? $from : $start; + $effectiveEnd = $end > $to ? $to : $end; + + if ($effectiveEnd >= $effectiveStart) { + $saturday = $start->modify('+5 days'); + $monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday); + $weeks[] = [ + 'month' => (int) $monthAnchor->format('n'), + 'weekNumber' => (int) $effectiveStart->format('W'), + 'start' => $start, + 'end' => $end, + ]; + } + $weekStart = $weekStart->modify('+7 days'); + } + + return $weeks; + } + + public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int + { + [$from, $to] = $this->resolveExerciseBounds($exerciseYear); + $weeks = $this->buildWeeksForExercise($from, $to); + $weekRanges = array_map( + static fn (array $week): array => [ + 'month' => (int) $week['month'], + 'weekNumber' => (int) $week['weekNumber'], + 'start' => $week['start'], + 'end' => $week['end'], + ], + $weeks + ); + + $byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null); + + return array_sum($byWeek); + } + + /** + * @param list $weeks + * + * @return array + */ + public function computeRecoveryByWeek( + Employee $employee, + array $weeks, + DateTimeImmutable $periodFrom, + DateTimeImmutable $periodTo, + ?DateTimeImmutable $limitDate + ): array { + if ([] === $weeks) { + return []; + } + + $days = []; + for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) { + $days[] = $cursor->format('Y-m-d'); + } + + $contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days); + $naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days); + $employeeId = (int) $employee->getId(); + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]); + $absences = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]); + + $metricsByDate = []; + foreach ($workHours as $workHour) { + $dateKey = $workHour->getWorkDate()->format('Y-m-d'); + $metricsByDate[$dateKey] = $this->computeMetrics($workHour); + } + + $creditedByDate = []; + foreach ($absences as $absence) { + $start = $absence->getStartDate()->format('Y-m-d'); + $end = $absence->getEndDate()->format('Y-m-d'); + for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) { + $date = $cursor->format('Y-m-d'); + if ($date < $start || $date > $end) { + continue; + } + + [$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); + $creditedByDate[$date] = ($creditedByDate[$date] ?? 0) + + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); + } + } + + $results = []; + foreach ($weeks as $week) { + $weekStart = $week['start']; + $weekEnd = $week['end']; + $weekKey = $weekStart->format('Y-m-d'); + $effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart; + $effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd; + + if ($effectiveEnd < $effectiveStart) { + $results[$weekKey] = 0; + + continue; + } + + if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) { + $results[$weekKey] = 0; + + continue; + } + + $weekDays = []; + for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) { + $weekDays[] = $cursor->format('Y-m-d'); + } + + $weeklyTotalMinutes = 0; + $employeeContractsByDate = []; + foreach ($weekDays as $date) { + $employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null; + if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) { + continue; + } + $metrics = $metricsByDate[$date] ?? new WorkMetrics(); + $metrics->addCreditedMinutes($creditedByDate[$date] ?? 0); + $weeklyTotalMinutes += $metrics->totalMinutes; + } + + if ([] === $weekDays) { + $results[$weekKey] = 0; + + continue; + } + + $weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI; + $weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null; + $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); + $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature); + $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate); + $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate); + $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking + ? 0 + : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes); + $weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) + ? 0 + : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes); + $weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses) + ? 0 + : $this->computeOvertime50BonusMinutes($weeklyTotalMinutes); + $results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses) + ? 0 + : $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes; + } + + return $results; + } + + private function computeMetrics(WorkHour $workHour): WorkMetrics + { + $ranges = [ + [$workHour->getMorningFrom(), $workHour->getMorningTo()], + [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()], + [$workHour->getEveningFrom(), $workHour->getEveningTo()], + ]; + + $totalMinutes = 0; + $nightMinutes = 0; + foreach ($ranges as [$from, $to]) { + $totalMinutes += $this->intervalMinutes($from, $to); + $nightMinutes += $this->nightIntervalMinutes($from, $to); + } + + $dayMinutes = max(0, $totalMinutes - $nightMinutes); + + return new WorkMetrics( + dayMinutes: $dayMinutes, + nightMinutes: $nightMinutes, + totalMinutes: $totalMinutes, + ); + } + + /** + * @return null|array{int, int} + */ + private function resolveInterval(?string $from, ?string $to): ?array + { + $fromMinutes = $this->toMinutes($from); + $toMinutes = $this->toMinutes($to); + if (null === $fromMinutes || null === $toMinutes) { + return null; + } + + $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; + + return [$fromMinutes, $end]; + } + + private function toMinutes(?string $time): ?int + { + if (null === $time || '' === $time) { + return null; + } + [$hours, $minutes] = array_map('intval', explode(':', $time)); + + return ($hours * 60) + $minutes; + } + + private function intervalMinutes(?string $from, ?string $to): int + { + $interval = $this->resolveInterval($from, $to); + if (null === $interval) { + return 0; + } + [$start, $end] = $interval; + + return max(0, $end - $start); + } + + private function nightIntervalMinutes(?string $from, ?string $to): int + { + $interval = $this->resolveInterval($from, $to); + if (null === $interval) { + return 0; + } + + [$start, $end] = $interval; + $windows = [[0, 360], [1260, 1440]]; + $total = 0; + + for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { + $shift = $dayOffset * 1440; + foreach ($windows as [$windowStart, $windowEnd]) { + $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift); + } + } + + return $total; + } + + private function overlap(int $startA, int $endA, int $startB, int $endB): int + { + $start = max($startA, $startB); + $end = min($endA, $endB); + + return max(0, $end - $start); + } + + /** + * @param list $days + * @param array $contractsByDate + */ + private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int + { + $total = 0; + foreach ($days as $date) { + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $contract = $contractsByDate[$date] ?? null; + $hours = $contract?->getWeeklyHours(); + $referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null; + $total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay); + } + + return $total; + } + + /** + * @param list $days + * @param array $contractsByDate + */ + private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int + { + $total = 0; + foreach ($days as $date) { + $isoDay = (int) new DateTimeImmutable($date)->format('N'); + $contract = $contractsByDate[$date] ?? null; + $hours = $contract?->getWeeklyHours(); + $startHours = (null !== $hours && $hours >= 39) ? 39 : 35; + $total += $this->resolveDailyReferenceMinutes($startHours, $isoDay); + } + + return $total; + } + + private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int + { + $trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes); + + return (int) round($trancheMinutes * 0.25); + } + + private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int + { + $trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60)); + + return (int) round($trancheMinutes * 0.5); + } + + private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool + { + if (ContractNature::INTERIM === $contractNature) { + return true; + } + + $type = ContractType::resolve( + $contract?->getName(), + $contract?->getTrackingMode(), + $contract?->getWeeklyHours() + ); + + return ContractType::INTERIM === $type; + } + + private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int + { + if ($isoWeekDay >= 6 || null === $weeklyHours || $weeklyHours <= 0) { + return 0; + } + if (39 === $weeklyHours) { + return 5 === $isoWeekDay ? 7 * 60 : 8 * 60; + } + if (35 === $weeklyHours) { + return 7 * 60; + } + + return (int) round(($weeklyHours * 60) / 5); + } +} diff --git a/src/State/EmployeeRttSummaryProvider.php b/src/State/EmployeeRttSummaryProvider.php new file mode 100644 index 0000000..c446fa2 --- /dev/null +++ b/src/State/EmployeeRttSummaryProvider.php @@ -0,0 +1,133 @@ +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $employeeId = (int) ($uriVariables['id'] ?? 0); + if ($employeeId <= 0) { + throw new UnprocessableEntityHttpException('id must be a positive integer.'); + } + + $employee = $this->employeeRepository->find($employeeId); + if (!$employee instanceof Employee) { + throw new NotFoundHttpException('Employee not found.'); + } + + if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) { + throw new AccessDeniedHttpException('Employee outside your scope.'); + } + + $year = $this->resolveYear(); + $today = new DateTimeImmutable('today'); + $currentExerciseYear = $this->resolveCurrentExerciseYear($today); + [$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year); + $weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo); + $weekRanges = array_map( + static fn (array $week): array => [ + 'month' => (int) $week['month'], + 'weekNumber' => (int) $week['weekNumber'], + 'start' => $week['start'], + 'end' => $week['end'], + ], + $weeks + ); + + $limitDate = null; + if ($year > $currentExerciseYear) { + $limitDate = $periodFrom->modify('-1 day'); + } + + $currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate); + $carryMinutes = $this->resolveCarryMinutes($employee, $year); + + $summary = new EmployeeRttSummary(); + $summary->year = $year; + $summary->carryFromPreviousYearMinutes = $carryMinutes; + $summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart); + $summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes; + $summary->weeks = array_map( + static fn (array $week) => new EmployeeRttWeekSummary( + month: (int) $week['month'], + weekNumber: (int) $week['weekNumber'], + weekStart: $week['start']->format('Y-m-d'), + weekEnd: $week['end']->format('Y-m-d'), + recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0), + ), + $weekRanges + ); + + return $summary; + } + + private function resolveCarryMinutes(Employee $employee, int $year): int + { + $balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year); + if (null !== $balance) { + return $balance->getOpeningMinutes(); + } + + return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1); + } + + private function resolveYear(): int + { + $raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? ''); + if ('' === $raw) { + return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today')); + } + if (!preg_match('/^\d{4}$/', $raw)) { + throw new UnprocessableEntityHttpException('year must use YYYY format.'); + } + + $year = (int) $raw; + if ($year < 2000 || $year > 2100) { + throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.'); + } + + return $year; + } + + private function resolveCurrentExerciseYear(DateTimeImmutable $today): int + { + $year = (int) $today->format('Y'); + $month = (int) $today->format('n'); + + return $month >= 6 ? $year + 1 : $year; + } +}