460 lines
17 KiB
PHP
460 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service\Rtt;
|
|
|
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
|
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\DailyReferenceMinutesResolver;
|
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
use DateTimeImmutable;
|
|
|
|
final readonly class RttRecoveryComputationService
|
|
{
|
|
private ?DateTimeImmutable $rttStartDate;
|
|
|
|
public function __construct(
|
|
private WorkHourRepository $workHourRepository,
|
|
private AbsenceRepository $absenceRepository,
|
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
private EmployeeContractResolver $contractResolver,
|
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
|
string $rttStartDate = '',
|
|
) {
|
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
|
}
|
|
|
|
/**
|
|
* @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{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) {
|
|
$weeks[] = [
|
|
'weekNumber' => (int) $effectiveStart->format('W'),
|
|
'start' => $start,
|
|
'end' => $end,
|
|
];
|
|
}
|
|
$weekStart = $weekStart->modify('+7 days');
|
|
}
|
|
|
|
return $weeks;
|
|
}
|
|
|
|
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear, ?DateTimeImmutable $limitDate = null): WeekRecoveryDetail
|
|
{
|
|
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
|
$weeks = $this->buildWeeksForExercise($from, $to);
|
|
$weekRanges = array_map(
|
|
static fn (array $week): array => [
|
|
'weekNumber' => (int) $week['weekNumber'],
|
|
'start' => $week['start'],
|
|
'end' => $week['end'],
|
|
],
|
|
$weeks
|
|
);
|
|
|
|
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $limitDate);
|
|
|
|
$total = new WeekRecoveryDetail();
|
|
foreach ($byWeek as $detail) {
|
|
$total = new WeekRecoveryDetail(
|
|
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
|
|
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
|
|
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
|
|
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
|
|
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
|
|
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
|
|
);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
|
|
*
|
|
* @return array<string, WeekRecoveryDetail>
|
|
*/
|
|
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);
|
|
$workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$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 = [];
|
|
$hasAbsenceByDate = [];
|
|
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);
|
|
if ($absentMorning || $absentAfternoon) {
|
|
$hasAbsenceByDate[$date] = true;
|
|
}
|
|
$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] = new WeekRecoveryDetail();
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
|
|
$results[$weekKey] = new WeekRecoveryDetail();
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->rttStartDate instanceof DateTimeImmutable && $effectiveEnd < $this->rttStartDate) {
|
|
$results[$weekKey] = new WeekRecoveryDetail();
|
|
|
|
continue;
|
|
}
|
|
|
|
$weekDays = [];
|
|
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
|
|
$weekDays[] = $cursor->format('Y-m-d');
|
|
}
|
|
|
|
$weeklyTotalMinutes = 0;
|
|
$dailyWorkedMinutes = [];
|
|
$employeeContractsByDate = [];
|
|
foreach ($weekDays as $date) {
|
|
$contractAtDate = $contractsByDate[$employeeId][$date] ?? null;
|
|
$employeeContractsByDate[$date] = $contractAtDate;
|
|
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
|
|
continue;
|
|
}
|
|
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
|
|
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
|
|
$effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
|
|
$contractAtDate,
|
|
new DateTimeImmutable($date),
|
|
$metrics->totalMinutes,
|
|
$hasAbsenceByDate[$date] ?? false,
|
|
$workDaysByDate[$employeeId][$date] ?? null,
|
|
);
|
|
$weeklyTotalMinutes += $effectiveMinutes;
|
|
$dailyWorkedMinutes[$date] = $effectiveMinutes;
|
|
}
|
|
|
|
if ([] === $weekDays) {
|
|
$results[$weekKey] = new WeekRecoveryDetail();
|
|
|
|
continue;
|
|
}
|
|
|
|
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
|
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
|
$weekContractType = ContractType::resolve(
|
|
$weekAnchorContract?->getName(),
|
|
$weekAnchorContract?->getTrackingMode(),
|
|
$weekAnchorContract?->getWeeklyHours()
|
|
);
|
|
$isCustomContract = ContractType::CUSTOM === $weekContractType;
|
|
$overtimeReferenceMinutes = $isCustomContract
|
|
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
|
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
|
? 0
|
|
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
|
|
|
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
|
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
|
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
|
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
|
|
|
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
|
$totalMinutes = 0;
|
|
} elseif ($isCustomContract) {
|
|
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
|
} else {
|
|
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
|
}
|
|
|
|
$results[$weekKey] = new WeekRecoveryDetail(
|
|
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
|
base25Minutes: $base25,
|
|
bonus25Minutes: $bonus25,
|
|
base50Minutes: $base50,
|
|
bonus50Minutes: $bonus50,
|
|
totalMinutes: $totalMinutes,
|
|
dailyMinutes: $dailyWorkedMinutes,
|
|
);
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
|
{
|
|
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
|
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
|
|
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
|
|
|
|
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
|
|
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
|
|
|
|
return new WorkMetrics(
|
|
dayMinutes: $driverDay + $driverWorkshop,
|
|
nightMinutes: $driverNight,
|
|
totalMinutes: $totalMinutes,
|
|
);
|
|
}
|
|
|
|
$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 computeWeeklyCustomReferenceMinutes(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();
|
|
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
|
|
}
|
|
}
|