feat(heures) : export PDF des heures (vue jour) par sites (#24)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Résumé Ajoute un bouton **Exporter** (admin uniquement) à droite du titre « Heures » qui génère un **PDF d'une journée**, regroupé par site, reprenant les colonnes de la vue Jour **sans la colonne « Valider »**. - Drawer : champ date (préremplit la date affichée) + cases à cocher des sites (préselectionnées sur le filtre courant). - Portée identique à l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). - Jour/Nuit/Total incluent le crédit d'absence et le crédit virtuel férié. ## Implémentation - Back : `WorkHourDayExport` (ApiResource) + `WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=` (ROLE_ADMIN). - Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source unique de vérité). - Gabarit `templates/work-hour-day-export/print.html.twig` (A4 portrait compact). - Front : `HoursDayExportDrawer.vue` + câblage dans `pages/hours.vue`. - Docs : `doc/hours-day-export.md`, `documentation-content.ts`, `CLAUDE.md`. ## Tests - Test unitaire `YearlyHoursDayRowsTest` ajouté. - Suite complète verte : 173 tests, 359 assertions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #24 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #24.
This commit is contained in:
@@ -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,133 @@ 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, statutColor:?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;
|
||||
$statutColor = ($absenceData['colors'][$ymd] ?? '') ?: null;
|
||||
if (null === $statut && null !== $holidayLabel) {
|
||||
// Férié sans absence : badge bleu clair, comme la vue Jour.
|
||||
$statut = $holidayLabel;
|
||||
$statutColor = '#b3e5fc';
|
||||
}
|
||||
|
||||
$row = [
|
||||
'employeeId' => $employeeId,
|
||||
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
||||
'statut' => $statut,
|
||||
'statutColor' => $statutColor,
|
||||
'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();
|
||||
@@ -169,12 +296,13 @@ class YearlyHoursExportBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
||||
* @return array{credited: array<string, int>, labels: array<string, string>, colors: 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 = [];
|
||||
$colors = [];
|
||||
$absentMorning = [];
|
||||
$absentAfternoon = [];
|
||||
$hasDayAbsence = [];
|
||||
@@ -195,6 +323,7 @@ class YearlyHoursExportBuilder
|
||||
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
||||
if (!isset($labels[$date])) {
|
||||
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
||||
$colors[$date] = $absence->getType()?->getColor() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +335,7 @@ class YearlyHoursExportBuilder
|
||||
return [
|
||||
'credited' => $credited,
|
||||
'labels' => $labels,
|
||||
'colors' => $colors,
|
||||
'absentMorning' => $absentMorning,
|
||||
'absentAfternoon' => $absentAfternoon,
|
||||
'hasDayAbsence' => $hasDayAbsence,
|
||||
|
||||
Reference in New Issue
Block a user