feat : ajout de la gestion RTT
This commit is contained in:
31
src/ApiResource/EmployeeRttSummary.php
Normal file
31
src/ApiResource/EmployeeRttSummary.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\State\EmployeeRttSummaryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/employees/{id}/rtt-summary',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: EmployeeRttSummaryProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class EmployeeRttSummary
|
||||
{
|
||||
public int $year = 0;
|
||||
public int $carryFromPreviousYearMinutes = 0;
|
||||
public int $currentYearRecoveryMinutes = 0;
|
||||
public int $availableMinutes = 0;
|
||||
|
||||
/** @var list<EmployeeRttWeekSummary> */
|
||||
public array $weeks = [];
|
||||
}
|
||||
134
src/Command/RttRolloverCommand.php
Normal file
134
src/Command/RttRolloverCommand.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeRttBalance;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
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:rtt:rollover',
|
||||
description: 'Create yearly RTT opening balances (idempotent).'
|
||||
)]
|
||||
final class RttRolloverCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EmployeeRepository $employeeRepository,
|
||||
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private readonly RttRecoveryComputationService $rttRecoveryService,
|
||||
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 && '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;
|
||||
}
|
||||
}
|
||||
16
src/Dto/Rtt/EmployeeRttWeekSummary.php
Normal file
16
src/Dto/Rtt/EmployeeRttWeekSummary.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\Rtt;
|
||||
|
||||
final class EmployeeRttWeekSummary
|
||||
{
|
||||
public function __construct(
|
||||
public int $month,
|
||||
public int $weekNumber,
|
||||
public string $weekStart,
|
||||
public string $weekEnd,
|
||||
public int $recoveryMinutes,
|
||||
) {}
|
||||
}
|
||||
117
src/Entity/EmployeeRttBalance.php
Normal file
117
src/Entity/EmployeeRttBalance.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EmployeeRttBalanceRepository::class)]
|
||||
#[ORM\Table(name: 'employee_rtt_balances', options: ['comment' => '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;
|
||||
}
|
||||
}
|
||||
34
src/Repository/EmployeeRttBalanceRepository.php
Normal file
34
src/Repository/EmployeeRttBalanceRepository.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeRttBalance;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EmployeeRttBalance>
|
||||
*/
|
||||
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()
|
||||
;
|
||||
}
|
||||
}
|
||||
377
src/Service/Rtt/RttRecoveryComputationService.php
Normal file
377
src/Service/Rtt/RttRecoveryComputationService.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Rtt;
|
||||
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class RttRecoveryComputationService
|
||||
{
|
||||
public function __construct(
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
public function resolveExerciseBounds(int $exerciseYear): array
|
||||
{
|
||||
return [
|
||||
new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)),
|
||||
new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
|
||||
*/
|
||||
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<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
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<string> $days
|
||||
* @param array<string, ?Contract> $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<string> $days
|
||||
* @param array<string, ?Contract> $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);
|
||||
}
|
||||
}
|
||||
133
src/State/EmployeeRttSummaryProvider.php
Normal file
133
src/State/EmployeeRttSummaryProvider.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private RttRecoveryComputationService $rttRecoveryService,
|
||||
) {}
|
||||
|
||||
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 => [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user