458 lines
19 KiB
PHP
458 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\ApiResource\WorkHourWeeklySummary;
|
|
use App\Dto\WorkHours\WeeklyDaySummary;
|
|
use App\Dto\WorkHours\WeeklySummaryRow;
|
|
use App\Dto\WorkHours\WorkMetrics;
|
|
use App\Entity\Absence;
|
|
use App\Entity\Contract;
|
|
use App\Entity\Employee;
|
|
use App\Entity\User;
|
|
use App\Entity\WorkHour;
|
|
use App\Enum\ContractNature;
|
|
use App\Enum\ContractType;
|
|
use App\Enum\TrackingMode;
|
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
use App\Service\Contracts\EmployeeContractResolver;
|
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
use DateTimeImmutable;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
|
|
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private Security $security,
|
|
private RequestStack $requestStack,
|
|
private EmployeeScopedRepositoryInterface $employeeRepository,
|
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
private EmployeeContractResolver $contractResolver,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
|
{
|
|
$user = $this->security->getUser();
|
|
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
|
|
if (!$user instanceof User) {
|
|
throw new AccessDeniedHttpException('Authentication required.');
|
|
}
|
|
|
|
$anchorDate = $this->resolveAnchorDate();
|
|
[$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate);
|
|
|
|
$employees = $this->employeeRepository->findScoped($user);
|
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
|
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
|
|
|
$summary = new WorkHourWeeklySummary();
|
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
|
$summary->days = $days;
|
|
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
|
|
|
return $summary;
|
|
}
|
|
|
|
private function resolveAnchorDate(): DateTimeImmutable
|
|
{
|
|
$query = $this->requestStack->getCurrentRequest()?->query;
|
|
$raw = (string) ($query?->get('weekStart') ?? '');
|
|
|
|
// Sans paramètre, on ancre la semaine sur aujourd'hui.
|
|
if ('' === $raw) {
|
|
return new DateTimeImmutable('today');
|
|
}
|
|
|
|
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
|
// Validation stricte du format attendu.
|
|
if (!$date || $date->format('Y-m-d') !== $raw) {
|
|
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
|
|
}
|
|
|
|
return $date;
|
|
}
|
|
|
|
/**
|
|
* @return array{DateTimeImmutable, DateTimeImmutable, list<string>}
|
|
*/
|
|
private function resolveWeek(DateTimeImmutable $anchorDate): array
|
|
{
|
|
// Convention ISO: semaine de lundi (1) à dimanche (7).
|
|
$dayOfWeek = (int) $anchorDate->format('N');
|
|
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
|
|
$weekEnd = $weekStart->modify('+6 days');
|
|
|
|
$days = [];
|
|
for ($i = 0; $i < 7; ++$i) {
|
|
$days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d');
|
|
}
|
|
|
|
return [$weekStart, $weekEnd, $days];
|
|
}
|
|
|
|
/**
|
|
* @param list<Employee> $employees
|
|
* @param list<WorkHour> $workHours
|
|
* @param list<Absence> $absences
|
|
* @param list<string> $days
|
|
*
|
|
* @return list<WeeklySummaryRow>
|
|
*/
|
|
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
|
{
|
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
|
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
|
$metricsByEmployeeDate = [];
|
|
foreach ($workHours as $workHour) {
|
|
$employeeId = $workHour->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
|
|
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
|
|
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
|
|
$metricsByEmployeeDate[$employeeId][$dateKey] = [
|
|
'metrics' => $this->computeMetrics($workHour),
|
|
'isPresentMorning' => $workHour->getIsPresentMorning(),
|
|
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
|
|
];
|
|
}
|
|
|
|
$creditedByEmployeeDate = [];
|
|
$creditedPresenceByEmployeeDate = [];
|
|
$absenceByEmployeeDate = [];
|
|
$absentMorningByEmployeeDate = [];
|
|
$absentAfternoonByEmployeeDate = [];
|
|
$absenceLabelByEmployeeDate = [];
|
|
$absenceColorByEmployeeDate = [];
|
|
foreach ($absences as $absence) {
|
|
$employeeId = $absence->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
|
|
$start = $absence->getStartDate()->format('Y-m-d');
|
|
$end = $absence->getEndDate()->format('Y-m-d');
|
|
foreach ($days as $date) {
|
|
// On ne crédite que les dates couvertes par l'intervalle d'absence.
|
|
if ($date < $start || $date > $end) {
|
|
continue;
|
|
}
|
|
|
|
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
|
if ($absentMorning || $absentAfternoon) {
|
|
$absenceByEmployeeDate[$employeeId][$date] = true;
|
|
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
|
|
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
|
|
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
|
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
|
}
|
|
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
|
|
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
|
|
}
|
|
}
|
|
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
|
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
|
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
|
|
}
|
|
}
|
|
|
|
$rows = [];
|
|
foreach ($employees as $employee) {
|
|
$employeeId = $employee->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
|
|
$weeklyDayMinutes = 0;
|
|
$weeklyNightMinutes = 0;
|
|
$weeklyTotalMinutes = 0;
|
|
$weeklyPresenceCount = 0.0;
|
|
$daily = [];
|
|
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
|
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
|
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
|
?? null;
|
|
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
|
|
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
|
|
?? ContractNature::CDI;
|
|
$employeeContractsByDate = [];
|
|
foreach ($days as $date) {
|
|
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
|
|
}
|
|
|
|
foreach ($days as $date) {
|
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
|
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
|
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
|
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
|
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
|
$metrics->addCreditedMinutes($creditedMinutes);
|
|
$present = null;
|
|
if ($isPresenceTracking) {
|
|
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
|
|
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
|
|
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
|
$afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
|
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
|
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
|
}
|
|
|
|
$weeklyDayMinutes += $metrics->dayMinutes;
|
|
$weeklyNightMinutes += $metrics->nightMinutes;
|
|
$weeklyTotalMinutes += $metrics->totalMinutes;
|
|
if (null !== $present) {
|
|
$weeklyPresenceCount += $present;
|
|
}
|
|
|
|
$daily[] = new WeeklyDaySummary(
|
|
date: $date,
|
|
dayMinutes: $metrics->dayMinutes,
|
|
nightMinutes: $metrics->nightMinutes,
|
|
totalMinutes: $metrics->totalMinutes,
|
|
present: $present,
|
|
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
|
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
|
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
|
);
|
|
}
|
|
|
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
|
|
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
|
? 0
|
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
|
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
|
? 0
|
|
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
|
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
|
? 0
|
|
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
|
$weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
|
? 0
|
|
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
|
|
|
$rows[] = new WeeklySummaryRow(
|
|
employeeId: $employeeId,
|
|
firstName: $employee->getFirstName(),
|
|
lastName: $employee->getLastName(),
|
|
siteName: $employee->getSite()?->getName(),
|
|
contractName: $weekAnchorContract?->getName(),
|
|
contractType: $weekAnchorContract?->getType()->value,
|
|
trackingMode: $weekAnchorContract?->getTrackingMode(),
|
|
daily: $daily,
|
|
weeklyDayMinutes: $weeklyDayMinutes,
|
|
weeklyNightMinutes: $weeklyNightMinutes,
|
|
weeklyTotalMinutes: $weeklyTotalMinutes,
|
|
weeklyPresenceCount: $weeklyPresenceCount,
|
|
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
|
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
|
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
|
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
|
);
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Si fin <= début, on considère un passage à minuit.
|
|
$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;
|
|
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
|
|
$windows = [[0, 360], [1260, 1440]];
|
|
$total = 0;
|
|
|
|
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
|
|
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 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 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
|
|
{
|
|
// Bonus 50% appliqué au-delà de 43h.
|
|
$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
|
|
{
|
|
// Week-end hors base de référence.
|
|
if ($isoWeekDay >= 6) {
|
|
return 0;
|
|
}
|
|
|
|
if (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);
|
|
}
|
|
}
|