a41bd632cf
Auto Tag Develop / tag (push) Successful in 11s
## Correctifs RH (branche fix/retour-rh) ### Vue Jour (Heures) - Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait. ### RTT — heures supplémentaires - Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%. ### Récap salaire (PDF mensuel) - Forfait : congés imputés **N-1** non affichés et comptés en présence. - Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné). - **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé). ### Exports heures annuelles (par salarié + tous) - **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes). - Samedis/dimanches en **gris plus foncé**. ### Panier de nuit - **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire). ## Tests - 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche). ## À noter (hors scope) - L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #21 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
503 lines
17 KiB
PHP
503 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service\WorkHours;
|
|
|
|
use App\Dto\WorkHours\WorkMetrics;
|
|
use App\Entity\Absence;
|
|
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\PublicHolidayServiceInterface;
|
|
use DateInterval;
|
|
use DateTimeImmutable;
|
|
use Throwable;
|
|
|
|
class YearlyHoursExportBuilder
|
|
{
|
|
public function __construct(
|
|
private WorkHourRepository $workHourRepository,
|
|
private AbsenceRepository $absenceRepository,
|
|
private EmployeeContractResolver $contractResolver,
|
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
private PublicHolidayServiceInterface $publicHolidayService,
|
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public 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;
|
|
}
|
|
|
|
/**
|
|
* @param list<Employee> $employees
|
|
*
|
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
|
*/
|
|
public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
$days = $this->buildDays($from, $to);
|
|
|
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
|
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
|
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
|
$holidayMap = $this->buildHolidayMap($from, $to);
|
|
|
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
|
$absenceMap = $this->buildAbsenceMap($absences, $days);
|
|
|
|
$results = [];
|
|
foreach ($employees as $employee) {
|
|
$employeeId = $employee->getId();
|
|
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
|
|
|
|
$segments = $this->buildSegments(
|
|
$days,
|
|
$contractMap[$employeeId] ?? [],
|
|
$driverMap[$employeeId] ?? [],
|
|
$workHourMap[$employeeId] ?? [],
|
|
$absenceData,
|
|
$workDaysMap[$employeeId] ?? [],
|
|
$holidayMap,
|
|
);
|
|
|
|
if ([] === $segments) {
|
|
continue;
|
|
}
|
|
|
|
$results[] = [
|
|
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
|
'contractLabel' => $this->buildContractLabel($employee),
|
|
'segments' => $segments,
|
|
];
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
|
*/
|
|
public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
{
|
|
return $this->buildForEmployees([$employee], $from, $to);
|
|
}
|
|
|
|
public function buildContractLabel(Employee $employee): ?string
|
|
{
|
|
$contract = $employee->getContract();
|
|
if (null === $contract) {
|
|
return null;
|
|
}
|
|
|
|
$natureRaw = $employee->getCurrentContractNature();
|
|
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
|
$natureLabel = match ($nature) {
|
|
ContractNature::CDI => 'CDI',
|
|
ContractNature::CDD => 'CDD',
|
|
ContractNature::INTERIM => 'Intérim',
|
|
};
|
|
|
|
$contractType = $contract->getType();
|
|
if (ContractType::FORFAIT === $contractType) {
|
|
return $natureLabel.' Forfait';
|
|
}
|
|
|
|
$weeklyHours = $contract->getWeeklyHours();
|
|
if (null !== $weeklyHours && $weeklyHours > 0) {
|
|
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
|
}
|
|
|
|
$name = $contract->getName();
|
|
|
|
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
|
}
|
|
|
|
/**
|
|
* @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 $days): array
|
|
{
|
|
$map = [];
|
|
foreach ($absences as $absence) {
|
|
$employeeId = $absence->getEmployee()?->getId();
|
|
if (!$employeeId) {
|
|
continue;
|
|
}
|
|
$map[$employeeId][] = $absence;
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
|
*/
|
|
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
|
|
{
|
|
$credited = [];
|
|
$labels = [];
|
|
$absentMorning = [];
|
|
$absentAfternoon = [];
|
|
$hasDayAbsence = [];
|
|
|
|
foreach ($absences as $absence) {
|
|
$start = $absence->getStartDate()->format('Y-m-d');
|
|
$end = $absence->getEndDate()->format('Y-m-d');
|
|
|
|
foreach ($days as $date) {
|
|
if ($date < $start || $date > $end) {
|
|
continue;
|
|
}
|
|
|
|
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
|
if ($isMorning || $isAfternoon) {
|
|
$hasDayAbsence[$date] = true;
|
|
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
|
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
|
if (!isset($labels[$date])) {
|
|
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
|
}
|
|
}
|
|
|
|
$credited[$date] = ($credited[$date] ?? 0)
|
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'credited' => $credited,
|
|
'labels' => $labels,
|
|
'absentMorning' => $absentMorning,
|
|
'absentAfternoon' => $absentAfternoon,
|
|
'hasDayAbsence' => $hasDayAbsence,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, ?array<int, int>> $workDaysMinutesByDate
|
|
* @param array<string, string> $holidayMap
|
|
*
|
|
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
|
*/
|
|
private function buildSegments(
|
|
array $days,
|
|
array $contractsByDate,
|
|
array $driverByDate,
|
|
array $workHoursByDate,
|
|
array $absenceData,
|
|
array $workDaysMinutesByDate,
|
|
array $holidayMap,
|
|
): array {
|
|
$segments = [];
|
|
$currentMode = null;
|
|
$currentRows = [];
|
|
$currentName = null;
|
|
|
|
$firstDataDate = null;
|
|
foreach ($days as $date) {
|
|
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
|
|| ($absenceData['hasDayAbsence'][$date] ?? false)
|
|
|| isset($holidayMap[$date]);
|
|
if ($hasRow) {
|
|
$firstDataDate = $date;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (null === $firstDataDate) {
|
|
return [];
|
|
}
|
|
|
|
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
|
|
|
foreach ($days as $date) {
|
|
if ($date < $firstDataDate || $date > $todayYmd) {
|
|
continue;
|
|
}
|
|
|
|
$contract = $contractsByDate[$date] ?? null;
|
|
$isDriver = $driverByDate[$date] ?? false;
|
|
$wh = $workHoursByDate[$date] ?? null;
|
|
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
|
$holidayLabel = $holidayMap[$date] ?? null;
|
|
$isHoliday = null !== $holidayLabel;
|
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
|
$isWeekend = $isoDay >= 6;
|
|
|
|
// Tous les jours contractés sont affichés, même vides ou non saisis (lignes
|
|
// « manquantes » signalées par la RH). Seuls les jours hors contrat (avant
|
|
// embauche, après départ, suspension) sont omis.
|
|
if (!$hasData && null === $contract) {
|
|
continue;
|
|
}
|
|
|
|
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
|
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
|
$contractName = $contract?->getName();
|
|
|
|
if ($mode !== $currentMode) {
|
|
if (null !== $currentMode && [] !== $currentRows) {
|
|
$segments[] = [
|
|
'mode' => $currentMode,
|
|
'contractName' => $currentName,
|
|
'rows' => $currentRows,
|
|
];
|
|
}
|
|
$currentMode = $mode;
|
|
$currentRows = [];
|
|
$currentName = $contractName;
|
|
}
|
|
|
|
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
|
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
|
$hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false;
|
|
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
|
|
$contract,
|
|
new DateTimeImmutable($date),
|
|
$hasAbsence,
|
|
$workDaysMinutesByDate[$date] ?? null,
|
|
);
|
|
|
|
$row = [
|
|
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
|
'absenceLabel' => $absenceLabel,
|
|
'holidayLabel' => $holidayLabel,
|
|
'isWeekend' => $isWeekend,
|
|
];
|
|
|
|
if ('presence' === $mode) {
|
|
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
|
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
|
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
|
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
|
$total = $morning + $afternoon;
|
|
|
|
$row['presentMorning'] = $morning > 0;
|
|
$row['presentAfternoon'] = $afternoon > 0;
|
|
$row['total'] = $total > 0 ? (string) $total : '';
|
|
} elseif ('driver' === $mode) {
|
|
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
|
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
|
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
|
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
|
if ($virtualMinutes > $totalMin) {
|
|
$totalMin = $virtualMinutes;
|
|
}
|
|
|
|
$row['dayHours'] = $this->formatMinutes($dayMin);
|
|
$row['nightHours'] = $this->formatMinutes($nightMin);
|
|
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
|
$row['total'] = $this->formatMinutes($totalMin);
|
|
} else {
|
|
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
|
$metrics->addCreditedMinutes($creditedMinutes);
|
|
$totalMin = $metrics->totalMinutes;
|
|
if ($virtualMinutes > $totalMin) {
|
|
$totalMin = $virtualMinutes;
|
|
}
|
|
|
|
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
|
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
|
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
|
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
|
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
|
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
|
$row['total'] = $this->formatMinutes($totalMin);
|
|
}
|
|
|
|
$currentRows[] = $row;
|
|
}
|
|
|
|
if (null !== $currentMode && [] !== $currentRows) {
|
|
$segments[] = [
|
|
'mode' => $currentMode,
|
|
'contractName' => $currentName,
|
|
'rows' => $currentRows,
|
|
];
|
|
}
|
|
|
|
return $segments;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
|
{
|
|
if ($isDriver) {
|
|
return 'driver';
|
|
}
|
|
|
|
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
|
return 'presence';
|
|
}
|
|
|
|
return 'time';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private function formatMinutes(int $minutes): string
|
|
{
|
|
if (0 === $minutes) {
|
|
return '';
|
|
}
|
|
|
|
$h = intdiv($minutes, 60);
|
|
$m = $minutes % 60;
|
|
|
|
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
|
}
|
|
}
|