+
Acquis année : {{
+ formatCount(summary?.acquiredDays)
+ }} Jours
+
Reste à prendre :
+ {{ formatCount(summary?.remainingDays) }} Jours
-
-
-
-
- {{ getDayText(day) }}
+
+
Samedi acquis :
+ {{ formatCount(summary?.acquiredSaturdays) }} Jours
+
Reste à prendre :
+ {{ formatCount(summary?.remainingSaturdays) }} Jours
+
+
+
Acquis fractionné :
+
{{ formatCount(summary?.fractionedDays) }} Jours
+
+
+
En cours d'acquisition :
+
{{ formatCount(summary?.accruingDays) }} Jours
+
+
+
+
+
+
+ {{ month.label }}
+
+
+
+
+
+
+
+ {{ getDayText(day) }}
+
+
+
-
-
-
+
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 @@
-
+
Chargement...
@@ -10,7 +10,7 @@
Employé introuvable.
-
+
{{ employee.firstName }} {{ employee.lastName }}
@@ -53,42 +53,49 @@
-
-
-
-
+
+
+
+
+
@@ -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;
+ }
+}