feat(heures) : calcul des lignes jour pour export PDF

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:21:41 +02:00
parent edbb1f7b29
commit b917fd2e41
2 changed files with 239 additions and 4 deletions
@@ -11,8 +11,8 @@ 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\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
@@ -22,8 +22,8 @@ use Throwable;
class YearlyHoursExportBuilder
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
@@ -103,6 +103,129 @@ class YearlyHoursExportBuilder
return $this->buildForEmployees([$employee], $from, $to);
}
/**
* Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures).
* Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité.
* Les employés sans contrat ce jour sont exclus (comme l'écran).
*
* @param list<Employee> $employees
*
* @return list<array{employeeId:int, employeeName:string, statut:?string,
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
* total:string, isWeekend:bool, isHoliday:bool}>
*/
public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array
{
$ymd = $date->format('Y-m-d');
$days = [$ymd];
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees);
$absences = $this->absenceRepository->findForPrint($date, $date, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($date, $date);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
$isoDay = (int) $date->format('N');
$isWeekend = $isoDay >= 6;
$holidayLabel = $holidayMap[$ymd] ?? null;
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$contract = $contractMap[$employeeId][$ymd] ?? null;
// Hors contrat ce jour → exclu (avant embauche / après départ / suspension).
if (null === $contract) {
continue;
}
$wh = $workHourMap[$employeeId][$ymd] ?? null;
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
$hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false;
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
$mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver);
$creditedMinutes = $absenceData['credited'][$ymd] ?? 0;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
$date,
$hasAbsence,
$workDaysMap[$employeeId][$ymd] ?? null,
);
$statut = $absenceData['labels'][$ymd] ?? null;
if (null === $statut && null !== $holidayLabel) {
$statut = $holidayLabel;
}
$row = [
'employeeId' => $employeeId,
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'statut' => $statut,
'morningFrom' => '',
'morningTo' => '',
'afternoonFrom' => '',
'afternoonTo' => '',
'eveningFrom' => '',
'eveningTo' => '',
'dayHours' => '',
'nightHours' => '',
'total' => '',
'isWeekend' => $isWeekend,
'isHoliday' => null !== $holidayLabel,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$ymd] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshop = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$dayMin = $metrics->dayMinutes;
$nightMin = $metrics->nightMinutes;
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$dayMin += $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['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : '';
$row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : '';
$row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : '';
}
$rows[] = $row;
}
return $rows;
}
public function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
/**
* @internal
*/
final class YearlyHoursDayRowsTest extends TestCase
{
public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
{
$date = new DateTimeImmutable('2026-06-08'); // lundi
$contract = new Contract();
$contract->setName('35h');
$contract->setTrackingMode(Contract::TRACKING_TIME);
$contract->setWeeklyHours(35);
$withContract = new Employee();
$withContract->setFirstName('Jean')->setLastName('Dupont');
$this->setEmployeeId($withContract, 1);
$noContract = new Employee();
$noContract->setFirstName('Paul')->setLastName('Martin');
$this->setEmployeeId($noContract, 2);
$workHour = new WorkHour();
$workHour->setEmployee($withContract)
->setWorkDate($date)
->setMorningFrom('08:00')->setMorningTo('12:00')
->setAfternoonFrom('13:00')->setAfternoonTo('17:00')
;
$workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
$workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]);
$absenceRepo = $this->createStub(AbsenceReadRepositoryInterface::class);
$absenceRepo->method('findForPrint')->willReturn([]);
$contractResolver = $this->createStub(EmployeeContractResolver::class);
$contractResolver->method('resolveForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => $contract],
2 => ['2026-06-08' => null],
]);
$contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => false],
2 => ['2026-06-08' => false],
]);
$contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([
1 => ['2026-06-08' => null],
2 => ['2026-06-08' => null],
]);
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
$holidayService->method('getHolidaysDayByYears')->willReturn([]);
// No holiday on this Monday → virtual credit resolves to 0 via the real resolver.
$virtualResolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$holidayService,
$contractResolver,
);
$builder = new YearlyHoursExportBuilder(
$workHourRepo,
$absenceRepo,
$contractResolver,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
$holidayService,
$virtualResolver,
);
$rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date);
self::assertCount(1, $rows);
self::assertSame(1, $rows[0]['employeeId']);
self::assertSame('Dupont Jean', $rows[0]['employeeName']);
self::assertSame('08:00', $rows[0]['morningFrom']);
self::assertSame('17:00', $rows[0]['afternoonTo']);
self::assertSame('8h', $rows[0]['total']);
self::assertSame('8h', $rows[0]['dayHours']);
self::assertSame('', $rows[0]['nightHours']);
self::assertNull($rows[0]['statut']);
self::assertFalse($rows[0]['isWeekend']);
}
private function setEmployeeId(Employee $employee, int $id): void
{
$ref = new ReflectionProperty(Employee::class, 'id');
$ref->setAccessible(true);
$ref->setValue($employee, $id);
}
}