614 lines
19 KiB
PHP
614 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\Entity\Absence;
|
|
use App\Entity\Employee;
|
|
use App\Entity\WorkHour;
|
|
use App\Enum\TrackingMode;
|
|
use App\Repository\AbsenceRepository;
|
|
use App\Repository\BonusRepository;
|
|
use App\Repository\EmployeeRepository;
|
|
use App\Repository\EmployeeRttPaymentRepository;
|
|
use App\Repository\MileageAllowanceRepository;
|
|
use App\Repository\ObservationRepository;
|
|
use App\Repository\WorkHourRepository;
|
|
use App\Service\Contracts\EmployeeContractResolver;
|
|
use DateInterval;
|
|
use DateTimeImmutable;
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Options;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Twig\Environment;
|
|
|
|
class SalaryRecapPrintProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private Environment $twig,
|
|
private readonly RequestStack $requestStack,
|
|
private EmployeeRepository $employeeRepository,
|
|
private WorkHourRepository $workHourRepository,
|
|
private AbsenceRepository $absenceRepository,
|
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
|
private BonusRepository $bonusRepository,
|
|
private MileageAllowanceRepository $mileageAllowanceRepository,
|
|
private ObservationRepository $observationRepository,
|
|
private EmployeeContractResolver $contractResolver,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
|
{
|
|
$request = $this->requestStack->getCurrentRequest();
|
|
if (!$request) {
|
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$month = $request->query->get('month');
|
|
if (!$month || !preg_match('/^\d{4}-\d{2}$/', $month)) {
|
|
return new Response('Missing or invalid month query param (expected YYYY-MM).', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01');
|
|
$to = $from->modify('last day of this month');
|
|
|
|
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
|
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
|
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
|
|
|
$year = (int) $from->format('Y');
|
|
$monthNumber = (int) $from->format('n');
|
|
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
|
|
|
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
|
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
|
$observations = $this->observationRepository->findByMonth($from, $to);
|
|
|
|
$days = $this->buildDays($from, $to);
|
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
|
|
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
|
$absenceMap = $this->buildAbsenceMap($absences);
|
|
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
|
$bonusMap = $this->buildBonusMap($bonuses);
|
|
$mileageMap = $this->buildMileageMap($mileages);
|
|
$observationMap = $this->buildObservationMap($observations);
|
|
|
|
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap);
|
|
|
|
$options = new Options();
|
|
$options->set('isRemoteEnabled', true);
|
|
|
|
$dompdf = new Dompdf($options);
|
|
$html = $this->twig->render('salary-recap/print.html.twig', [
|
|
'from' => $from,
|
|
'to' => $to,
|
|
'siteGroups' => $siteGroups,
|
|
]);
|
|
|
|
$dompdf->loadHtml($html);
|
|
$dompdf->setPaper('A4', 'landscape');
|
|
$dompdf->render();
|
|
|
|
$filename = sprintf(
|
|
'recap_salaire_%s.pdf',
|
|
$from->format('Y-m')
|
|
);
|
|
|
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
$days = [];
|
|
$current = $from;
|
|
|
|
while ($current <= $to) {
|
|
$days[] = $current->format('Y-m-d');
|
|
$current = $current->add(new DateInterval('P1D'));
|
|
}
|
|
|
|
return $days;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, WorkHour>>
|
|
*/
|
|
private function buildWorkHourMap(array $workHours): array
|
|
{
|
|
$map = [];
|
|
foreach ($workHours as $wh) {
|
|
$employeeId = $wh->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$date = $wh->getWorkDate()->format('Y-m-d');
|
|
$map[$employeeId][$date] = $wh;
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, list<Absence>>
|
|
*/
|
|
private function buildAbsenceMap(array $absences): array
|
|
{
|
|
$map = [];
|
|
foreach ($absences as $absence) {
|
|
$employeeId = $absence->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId][] = $absence;
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
private function buildRttPaymentMap(array $rttPayments): array
|
|
{
|
|
$map = [];
|
|
foreach ($rttPayments as $payment) {
|
|
$employeeId = $payment->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, float>
|
|
*/
|
|
private function buildBonusMap(array $bonuses): array
|
|
{
|
|
$map = [];
|
|
foreach ($bonuses as $bonus) {
|
|
$employeeId = $bonus->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId] = ($map[$employeeId] ?? 0.0) + $bonus->getAmount();
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, float>
|
|
*/
|
|
private function buildMileageMap(array $mileages): array
|
|
{
|
|
$map = [];
|
|
foreach ($mileages as $mileage) {
|
|
$employeeId = $mileage->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId] = ($map[$employeeId] ?? 0.0) + $mileage->getKilometers();
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function buildObservationMap(array $observations): array
|
|
{
|
|
$map = [];
|
|
foreach ($observations as $observation) {
|
|
$employeeId = $observation->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId] = $observation->getContent();
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private function aggregateBySite(
|
|
array $employees,
|
|
array $days,
|
|
array $contractMap,
|
|
array $driverMap,
|
|
array $workHourMap,
|
|
array $absenceMap,
|
|
array $rttPaymentMap,
|
|
array $bonusMap,
|
|
array $mileageMap,
|
|
array $observationMap,
|
|
): array {
|
|
$siteGroups = [];
|
|
|
|
foreach ($employees as $employee) {
|
|
$employeeId = $employee->getId();
|
|
$site = $employee->getSite();
|
|
$siteName = $site ? $site->getName() : 'Sans site';
|
|
$siteId = $site ? $site->getId() : 0;
|
|
|
|
$row = $this->buildEmployeeRow(
|
|
$employee,
|
|
$employeeId,
|
|
$days,
|
|
$contractMap[$employeeId] ?? [],
|
|
$driverMap[$employeeId] ?? [],
|
|
$workHourMap[$employeeId] ?? [],
|
|
$absenceMap[$employeeId] ?? [],
|
|
$rttPaymentMap[$employeeId] ?? 0,
|
|
$bonusMap[$employeeId] ?? 0.0,
|
|
$mileageMap[$employeeId] ?? 0.0,
|
|
$observationMap[$employeeId] ?? '',
|
|
);
|
|
|
|
if (!isset($siteGroups[$siteId])) {
|
|
$siteGroups[$siteId] = [
|
|
'name' => $siteName,
|
|
'color' => $site?->getColor() ?? '#ffd7d7',
|
|
'employees' => [],
|
|
];
|
|
}
|
|
|
|
$siteGroups[$siteId]['employees'][] = $row;
|
|
}
|
|
|
|
return $siteGroups;
|
|
}
|
|
|
|
private function buildEmployeeRow(
|
|
Employee $employee,
|
|
int $employeeId,
|
|
array $days,
|
|
array $contractsByDate,
|
|
array $driverByDate,
|
|
array $workHoursByDate,
|
|
array $absences,
|
|
int $rttPaidMinutes,
|
|
float $bonusAmount,
|
|
float $mileageKm,
|
|
string $observation,
|
|
): array {
|
|
$contractName = null;
|
|
$presenceDays = 0.0;
|
|
$nightMinutesTotal = 0;
|
|
$nightBasketCount = 0;
|
|
$sundayMinutesTotal = 0;
|
|
$isDriverAnyDay = false;
|
|
$driverBreakfast = 0;
|
|
$driverMeals = 0;
|
|
$driverOvernight = 0;
|
|
$driverSaturdays = 0;
|
|
$isForfait = false;
|
|
|
|
foreach ($days as $date) {
|
|
$contract = $contractsByDate[$date] ?? null;
|
|
$isDriver = $driverByDate[$date] ?? false;
|
|
$wh = $workHoursByDate[$date] ?? null;
|
|
|
|
if ($contract && null === $contractName) {
|
|
$contractName = $contract->getName();
|
|
$isForfait = TrackingMode::PRESENCE === $contract->getTrackingModeEnum();
|
|
}
|
|
|
|
if ($isDriver) {
|
|
$isDriverAnyDay = true;
|
|
}
|
|
|
|
if (!$wh) {
|
|
continue;
|
|
}
|
|
|
|
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
|
|
|
|
if ($isDriver) {
|
|
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
|
|
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
|
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
|
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
|
|
++$nightBasketCount;
|
|
}
|
|
|
|
if ($wh->getHasBreakfast()) {
|
|
++$driverBreakfast;
|
|
}
|
|
if ($wh->getHasLunch() || $wh->getHasDinner()) {
|
|
++$driverMeals;
|
|
}
|
|
if ($wh->getHasOvernight()) {
|
|
++$driverOvernight;
|
|
}
|
|
|
|
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) {
|
|
++$driverSaturdays;
|
|
}
|
|
|
|
if (7 === $dayOfWeek) {
|
|
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0);
|
|
}
|
|
} else {
|
|
$metrics = $this->computeNightMinutes($wh);
|
|
$nightMinutesTotal += $metrics['nightMinutes'];
|
|
if (($metrics['nightMinutes'] > $metrics['dayMinutes'] && $metrics['nightMinutes'] > 0) || $metrics['nightMinutes'] >= 240) {
|
|
++$nightBasketCount;
|
|
}
|
|
|
|
if (7 === $dayOfWeek) {
|
|
$sundayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
|
|
}
|
|
|
|
// Samedi : les minutes après minuit débordent sur le dimanche
|
|
if (6 === $dayOfWeek) {
|
|
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
|
|
}
|
|
|
|
if ($isForfait) {
|
|
if ($wh->getIsPresentMorning()) {
|
|
$presenceDays += 0.5;
|
|
}
|
|
if ($wh->getIsPresentAfternoon()) {
|
|
$presenceDays += 0.5;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$conges = $this->countAbsencesByCode($absences, ['C']);
|
|
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
|
|
|
$nightHours = round($nightMinutesTotal / 60, 2);
|
|
$paidHours = round($rttPaidMinutes / 60, 2);
|
|
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
|
|
|
return [
|
|
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
|
|
'firstName' => mb_strimwidth($employee->getFirstName() ?? '', 0, 15, '...'),
|
|
'contractName' => $contractName,
|
|
'presenceDays' => $presenceDays,
|
|
'mileageKm' => $mileageKm,
|
|
'nightHours' => $nightHours,
|
|
'nightBasketCount' => $nightBasketCount,
|
|
'paidHours' => $paidHours,
|
|
'sundayHours' => $sundayHours,
|
|
'bonusAmount' => $bonusAmount,
|
|
'congesCount' => $conges['count'],
|
|
'congesDates' => $conges['dates'],
|
|
'maladieCount' => $maladie['count'],
|
|
'maladieDates' => $maladie['dates'],
|
|
'isDriver' => $isDriverAnyDay,
|
|
'driverBreakfast' => $driverBreakfast,
|
|
'driverMeals' => $driverMeals,
|
|
'driverOvernight' => $driverOvernight,
|
|
'driverSaturdays' => $driverSaturdays,
|
|
'observation' => $observation,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{nightMinutes: int, dayMinutes: int}
|
|
*/
|
|
private function computeNightMinutes(WorkHour $workHour): array
|
|
{
|
|
$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 [
|
|
'nightMinutes' => $nightMinutes,
|
|
'dayMinutes' => $dayMinutes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour.
|
|
* Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h).
|
|
*/
|
|
private function computeOverflowAfterMidnight(WorkHour $workHour): int
|
|
{
|
|
$ranges = [
|
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
|
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
|
];
|
|
|
|
$overflow = 0;
|
|
|
|
foreach ($ranges as [$from, $to]) {
|
|
$interval = $this->resolveInterval($from, $to);
|
|
if (null === $interval) {
|
|
continue;
|
|
}
|
|
|
|
[$start, $end] = $interval;
|
|
|
|
// Si le créneau dépasse minuit (1440), la partie au-delà est sur le jour suivant
|
|
if ($end > 1440) {
|
|
$overflow += $end - max($start, 1440);
|
|
}
|
|
}
|
|
|
|
return $overflow;
|
|
}
|
|
|
|
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<Absence> $absences
|
|
* @param list<string> $codes
|
|
*
|
|
* @return array{count: float, dates: string}
|
|
*/
|
|
private function countAbsencesByCode(array $absences, array $codes): array
|
|
{
|
|
$count = 0.0;
|
|
$dayKeys = [];
|
|
|
|
foreach ($absences as $absence) {
|
|
$type = $absence->getType();
|
|
if (!$type || !in_array($type->getCode(), $codes, true)) {
|
|
continue;
|
|
}
|
|
|
|
$startHalf = $absence->getStartHalf();
|
|
$endHalf = $absence->getEndHalf();
|
|
|
|
if ($startHalf === $endHalf) {
|
|
$count += 0.5;
|
|
} else {
|
|
$count += 1.0;
|
|
}
|
|
|
|
$dayKeys[] = $absence->getStartDate()->format('Y-m-d');
|
|
}
|
|
|
|
sort($dayKeys);
|
|
$dayKeys = array_unique($dayKeys);
|
|
|
|
$periods = $this->mergeDaysIntoPeriods($dayKeys);
|
|
|
|
return [
|
|
'count' => $count,
|
|
'dates' => implode(', ', $periods),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $sortedDates Y-m-d sorted
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function mergeDaysIntoPeriods(array $sortedDates): array
|
|
{
|
|
if ([] === $sortedDates) {
|
|
return [];
|
|
}
|
|
|
|
$periods = [];
|
|
$rangeStart = $sortedDates[0];
|
|
$rangeEnd = $sortedDates[0];
|
|
|
|
for ($i = 1, $len = count($sortedDates); $i < $len; ++$i) {
|
|
$prev = new DateTimeImmutable($rangeEnd);
|
|
$current = new DateTimeImmutable($sortedDates[$i]);
|
|
|
|
if (1 === $current->diff($prev)->days) {
|
|
$rangeEnd = $sortedDates[$i];
|
|
} else {
|
|
$periods[] = $this->formatPeriod($rangeStart, $rangeEnd);
|
|
$rangeStart = $sortedDates[$i];
|
|
$rangeEnd = $sortedDates[$i];
|
|
}
|
|
}
|
|
|
|
$periods[] = $this->formatPeriod($rangeStart, $rangeEnd);
|
|
|
|
return $periods;
|
|
}
|
|
|
|
private function formatPeriod(string $start, string $end): string
|
|
{
|
|
$s = new DateTimeImmutable($start)->format('d/m');
|
|
|
|
if ($start === $end) {
|
|
return $s;
|
|
}
|
|
|
|
return 'Du '.$s.' au '.new DateTimeImmutable($end)->format('d/m');
|
|
}
|
|
}
|