diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php index 7b67a00..1399ef5 100644 --- a/src/Service/WorkHours/YearlyHoursExportBuilder.php +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -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 $employees + * + * @return list + */ + 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(); diff --git a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php new file mode 100644 index 0000000..ba2974b --- /dev/null +++ b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php @@ -0,0 +1,112 @@ +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); + } +}