Files
SIRH/src/State/EmployeeRttSummaryProvider.php
tristan 0257e59671
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
[#SIRH-21] Revoir l'affichage des RTT pour les semaines qui se chevauchent (#13)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 08:54:55 +00:00

327 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeRttSummary;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\RttMonthPayment;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeRttSummaryProvider implements ProviderInterface
{
private ?string $rttStartDate;
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private EmployeeScopeService $employeeScopeService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private RttRecoveryComputationService $rttRecoveryService,
private WorkHourRepository $workHourRepository,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$employeeId = (int) ($uriVariables['id'] ?? 0);
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('id must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
throw new AccessDeniedHttpException('Employee outside your scope.');
}
$year = $this->resolveYear();
$today = new DateTimeImmutable('today');
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $week): array => [
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
if ($year > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
} else {
// Exclude the current (incomplete) week: limit to last Sunday
$isoDay = (int) $today->format('N'); // 1=Monday .. 7=Sunday
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
$summary = new EmployeeRttSummary();
$summary->year = $year;
$summary->carryMonth = $carryMonth;
$summary->carryFromPreviousYearMinutes = $carry->totalMinutes;
$summary->carryBase25Minutes = $carry->base25Minutes;
$summary->carryBonus25Minutes = $carry->bonus25Minutes;
$summary->carryBase50Minutes = $carry->base50Minutes;
$summary->carryBonus50Minutes = $carry->bonus50Minutes;
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
// Pass rttStartDate only if it falls within this exercise
if (null !== $this->rttStartDate) {
$startDate = new DateTimeImmutable($this->rttStartDate);
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
$summary->rttStartDate = $this->rttStartDate;
}
}
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($summary->weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
} else {
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$summary->weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
}
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
$monthBuckets = [];
foreach ($payments as $payment) {
$m = $payment->getMonth();
if (!isset($monthBuckets[$m])) {
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$monthPayments = [];
$totalPaidMinutes = 0;
foreach ($monthBuckets as $m => $bucket) {
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
}
$summary->totalPaidMinutes = $totalPaidMinutes;
$summary->monthPayments = $monthPayments;
$summary->availableMinutes -= $totalPaidMinutes;
return $summary;
}
/**
* @return array{WeekRecoveryDetail, int} [carry, month]
*/
private function resolveCarry(Employee $employee, int $year): array
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return [
new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
),
$balance->getMonth(),
];
}
return [
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
5,
];
}
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;
}
/**
* If the employee's contract ends within the current week, cap the check range to that end date.
*/
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
/**
* Build week summaries, splitting weeks that span two months into two entries
* with values distributed proportionally based on daily worked minutes.
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
// Week spans two months — split proportionally by daily worked minutes
$monthMinutes = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
}
$totalWorked = array_sum($monthMinutes);
foreach ([$startMonth, $endMonth] as $month) {
$portion = $monthMinutes[$month] ?? 0;
$ratio = $totalWorked > 0 ? $portion / $totalWorked : 0.0;
$result[] = new EmployeeRttWeekSummary(
month: $month,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
}