c8e7f80c72
Auto Tag Develop / tag (push) Successful in 11s
Le récap salaire comptait les congés (C) tombant un dimanche via countAbsencesByCode, alors que l'onglet Congés, le rollover et les jours de présence l'ignoraient déjà. Garde ajoutée (C + dimanche → ignoré) pour aligner : poser une période à cheval sur un week-end (ex. jeu→mar) ne fait plus perdre le dimanche. Correctif au comptage uniquement : les lignes d'absence du dimanche restent créées et affichées sur le calendrier (volonté RH), l'existant cesse de compter sans migration. Périmètre strict : code C (maladie/AT inchangés), samedi inchangé (budget dédié). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
772 lines
26 KiB
PHP
772 lines
26 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 App\Service\PublicHolidayServiceInterface;
|
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
use App\Service\WorkHours\NightHoursCalculator;
|
|
use DateInterval;
|
|
use DateTimeImmutable;
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Options;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Throwable;
|
|
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,
|
|
private PublicHolidayServiceInterface $publicHolidayService,
|
|
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
private NightHoursCalculator $nightHoursCalculator,
|
|
) {}
|
|
|
|
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');
|
|
|
|
// N'inclure que les employés ayant un contrat couvrant tout ou partie du mois.
|
|
// Sans ce filtre, un salarié dont le contrat est terminé (ex. parti en février)
|
|
// apparaît à tort sur le récap des mois suivants.
|
|
$employees = array_values(array_filter(
|
|
$this->employeeRepository->findForPrintBySiteIds([]),
|
|
fn (Employee $employee): bool => $this->hasContractInRange($employee, $from, $to)
|
|
));
|
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
|
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
|
|
|
$year = (int) $from->format('Y');
|
|
$monthNumber = (int) $from->format('n');
|
|
|
|
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
|
|
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
|
|
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
|
|
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
|
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
|
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
|
$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);
|
|
$holidayMap = $this->buildHolidayMap($from, $to);
|
|
|
|
$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, $holidayMap, $ytdAbsenceMap, $year, $from, $to);
|
|
|
|
$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.'"',
|
|
]);
|
|
}
|
|
|
|
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
|
{
|
|
$fromDay = $from->format('Y-m-d');
|
|
$toDay = $to->format('Y-m-d');
|
|
|
|
foreach ($employee->getContractPeriods() as $period) {
|
|
$start = $period->getStartDate()->format('Y-m-d');
|
|
$end = $period->getEndDate()?->format('Y-m-d');
|
|
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @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>
|
|
*/
|
|
/**
|
|
* @return array<int, array{m25: int, m50: int}>
|
|
*/
|
|
private function buildRttPaymentMap(array $rttPayments): array
|
|
{
|
|
$map = [];
|
|
foreach ($rttPayments as $payment) {
|
|
$employeeId = $payment->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId] ??= ['m25' => 0, 'm50' => 0];
|
|
$map[$employeeId]['m25'] += $payment->getBase25Minutes();
|
|
$map[$employeeId]['m50'] += $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<string, string> Y-m-d → label
|
|
*/
|
|
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
$map = [];
|
|
$startYear = (int) $from->format('Y');
|
|
$endYear = (int) $to->format('Y');
|
|
|
|
try {
|
|
for ($year = $startYear; $year <= $endYear; ++$year) {
|
|
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
|
foreach ($holidays as $date => $label) {
|
|
$map[(string) $date] = (string) $label;
|
|
}
|
|
}
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
|
|
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 $holidayMap,
|
|
array $ytdAbsenceMap,
|
|
int $year,
|
|
DateTimeImmutable $monthFrom,
|
|
DateTimeImmutable $monthTo,
|
|
): 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] ?? ['m25' => 0, 'm50' => 0],
|
|
$bonusMap[$employeeId] ?? 0.0,
|
|
$mileageMap[$employeeId] ?? 0.0,
|
|
$observationMap[$employeeId] ?? '',
|
|
$holidayMap,
|
|
$ytdAbsenceMap[$employeeId] ?? [],
|
|
$year,
|
|
$monthFrom,
|
|
$monthTo,
|
|
);
|
|
|
|
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,
|
|
array $rttPaid,
|
|
float $bonusAmount,
|
|
float $mileageKm,
|
|
string $observation,
|
|
array $holidayMap,
|
|
array $ytdAbsences,
|
|
int $year,
|
|
DateTimeImmutable $monthFrom,
|
|
DateTimeImmutable $monthTo,
|
|
): array {
|
|
$contractName = null;
|
|
$presenceDays = 0.0;
|
|
$nightMinutesTotal = 0;
|
|
$nightBasketCount = 0;
|
|
$sundayMinutesTotal = 0;
|
|
$holidayMinutesTotal = 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');
|
|
|
|
$isHoliday = isset($holidayMap[$date]);
|
|
|
|
if ($isDriver) {
|
|
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
|
|
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
|
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
|
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
|
|
// Le panier de nuit ne s'applique pas aux conducteurs (primes repas/nuitée
|
|
// dédiées). Aucun panier de nuit crédité ici.
|
|
|
|
if ($wh->getHasBreakfast()) {
|
|
++$driverBreakfast;
|
|
}
|
|
if ($wh->getHasLunch()) {
|
|
++$driverMeals;
|
|
}
|
|
if ($wh->getHasDinner()) {
|
|
++$driverMeals;
|
|
}
|
|
if ($wh->getHasOvernight()) {
|
|
++$driverOvernight;
|
|
}
|
|
|
|
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
|
|
++$driverSaturdays;
|
|
}
|
|
|
|
if (7 === $dayOfWeek) {
|
|
$sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
|
}
|
|
|
|
if ($isHoliday) {
|
|
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
|
}
|
|
} 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 ($isHoliday) {
|
|
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
|
|
}
|
|
|
|
if ($isForfait) {
|
|
if ($wh->getIsPresentMorning()) {
|
|
$presenceDays += 0.5;
|
|
}
|
|
if ($wh->getIsPresentAfternoon()) {
|
|
$presenceDays += 0.5;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap
|
|
// et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement
|
|
// sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé.
|
|
$n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0;
|
|
if ($isForfait && $n1Budget > 0.0) {
|
|
$ytdConges = array_values(array_filter(
|
|
$ytdAbsences,
|
|
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
|
));
|
|
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
|
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
|
$presenceDays += $split['n1PresenceDays'];
|
|
} else {
|
|
$conges = $this->countAbsencesByCode($absences, ['C']);
|
|
}
|
|
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
|
|
|
$nightHours = round($nightMinutesTotal / 60, 2);
|
|
$paid25Hours = round(($rttPaid['m25'] ?? 0) / 60, 2);
|
|
$paid50Hours = round(($rttPaid['m50'] ?? 0) / 60, 2);
|
|
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
|
$holidayHours = round($holidayMinutesTotal / 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,
|
|
'paid25Hours' => $paid25Hours,
|
|
'paid50Hours' => $paid50Hours,
|
|
'sundayHours' => $sundayHours,
|
|
'holidayHours' => $holidayHours,
|
|
'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;
|
|
|
|
foreach ($ranges as [$from, $to]) {
|
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
|
}
|
|
|
|
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
|
|
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
|
|
* dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent
|
|
* qu'à consommer le budget N-1.
|
|
*
|
|
* @param list<Absence> $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois
|
|
*
|
|
* @return array{count: float, dates: string, n1PresenceDays: float}
|
|
*/
|
|
private function splitForfaitCongesByN1(
|
|
array $ytdConges,
|
|
float $n1Budget,
|
|
DateTimeImmutable $monthFrom,
|
|
DateTimeImmutable $monthTo
|
|
): array {
|
|
usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate());
|
|
|
|
$remaining = $n1Budget;
|
|
$count = 0.0;
|
|
$n1PresenceDays = 0.0;
|
|
$dayKeys = [];
|
|
|
|
foreach ($ytdConges as $absence) {
|
|
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
|
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
|
|
|
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
|
if ((int) $day->format('N') >= 6) {
|
|
continue; // week-ends ignorés
|
|
}
|
|
[$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d'));
|
|
$amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
|
if ($amount <= 0.0) {
|
|
continue;
|
|
}
|
|
|
|
$covered = 0.0;
|
|
if ($remaining > 0.0) {
|
|
$covered = min($remaining, $amount);
|
|
$remaining -= $covered;
|
|
}
|
|
$displayed = $amount - $covered;
|
|
|
|
// Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer.
|
|
if ($day < $monthFrom || $day > $monthTo) {
|
|
continue;
|
|
}
|
|
|
|
$n1PresenceDays += $covered;
|
|
if ($displayed > 0.0) {
|
|
$count += $displayed;
|
|
$dayKeys[] = $day->format('Y-m-d');
|
|
}
|
|
}
|
|
}
|
|
|
|
sort($dayKeys);
|
|
$dayKeys = array_unique($dayKeys);
|
|
|
|
return [
|
|
'count' => $count,
|
|
'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)),
|
|
'n1PresenceDays' => $n1PresenceDays,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
// Un congé (C) posé un dimanche n'est pas décompté comme congé pris : un dimanche
|
|
// ne fait pas partie des congés (cf. récap congés / rollover qui l'ignorent déjà).
|
|
// Le calendrier et son impression continuent d'afficher la ligne (volonté RH).
|
|
// Hors périmètre : maladie/AT et le samedi (budget samedis dédié) sont inchangés.
|
|
if ('C' === $type->getCode() && 7 === (int) $absence->getStartDate()->format('N')) {
|
|
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');
|
|
}
|
|
}
|