b5bd4db5f1
Auto Tag Develop / tag (push) Successful in 9s
## Résumé Nouvel export PDF **Contingent heures de nuit** dans le drawer Export de la liste employés. - PDF **A4 paysage** : lignes = employés (groupés par site, triés displayOrder/nom/prénom), colonnes = 12 mois civils, chaque mois avec 2 sous-colonnes **H.nuit** et **N.jours**. - Heures de nuit = minutes dans la fenêtre **21h→6h** via un service partagé `NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` — duplication supprimée, sans changement de comportement). - **Conducteurs inclus** via `WorkHour.nightHoursMinutes`. Statut conducteur résolu par date. - **N.jours** = nb de jours où les minutes de nuit ≥ 240 (4h). Aucun crédit absence/férié. - Périmètre via `EmployeeRepository::findScoped` (admin → tous, chef de site → ses sites), endpoint `GET /night-hours-contingent/print?year=YYYY` (`ROLE_USER`). - Sélecteur d'année (année civile). Colonne Nom calibrée, séparateurs de mois épais. ## Composants - Service `NightHoursCalculator`, builder `NightContingentExportBuilder`, DTO `NightContingentRow` - Provider `NightHoursContingentPrintProvider` + opération API `NightHoursContingentPrint` - Gabarit `templates/night-hours-contingent/print.html.twig` - Option frontend dans `frontend/pages/employees/index.vue` - Docs : `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts` ## Tests - Nouveaux tests unitaires : `NightHoursCalculatorTest` (fenêtre 21h→6h, passage minuit, bornes), `NightContingentExportBuilderTest` (agrégation mensuelle, règle ≥4h=1j, conducteur, cas sans heures) - Suite complète : **208 tests OK** - Rendu PDF validé visuellement (Twig→Dompdf) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #28 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
155 lines
5.5 KiB
PHP
155 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProviderInterface;
|
|
use App\Entity\Employee;
|
|
use App\Entity\User;
|
|
use App\Repository\EmployeeRepository;
|
|
use App\Service\WorkHours\NightContingentExportBuilder;
|
|
use DateTimeImmutable;
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Options;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
use Twig\Environment;
|
|
|
|
class NightHoursContingentPrintProvider implements ProviderInterface
|
|
{
|
|
public function __construct(
|
|
private Environment $twig,
|
|
private readonly RequestStack $requestStack,
|
|
private EmployeeRepository $employeeRepository,
|
|
private NightContingentExportBuilder $exportBuilder,
|
|
private readonly Security $security,
|
|
) {}
|
|
|
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
|
{
|
|
$user = $this->security->getUser();
|
|
if (!$user instanceof User) {
|
|
throw new AccessDeniedHttpException('Authentication required.');
|
|
}
|
|
|
|
$request = $this->requestStack->getCurrentRequest();
|
|
if (!$request) {
|
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
|
|
if ($year < 2000 || $year > 2100) {
|
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
|
}
|
|
|
|
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
|
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
|
|
|
// Perimetre selon le profil : admin -> tous, chef de site -> ses sites.
|
|
$employees = $this->employeeRepository->findScoped($user);
|
|
|
|
// Regroupement par site (ordre displayOrder), employes avec contrat sur l'annee.
|
|
$bySite = [];
|
|
$siteMeta = [];
|
|
foreach ($employees as $employee) {
|
|
if (!$this->hasContractInRange($employee, $from, $to)) {
|
|
continue;
|
|
}
|
|
$site = $employee->getSite();
|
|
if (null === $site) {
|
|
continue;
|
|
}
|
|
$siteId = $site->getId();
|
|
$bySite[$siteId][] = $employee;
|
|
$siteMeta[$siteId] ??= [
|
|
'name' => $site->getName(),
|
|
'order' => $site->getDisplayOrder(),
|
|
'color' => $site->getColor(),
|
|
];
|
|
}
|
|
|
|
uasort($siteMeta, static function (array $a, array $b): int {
|
|
return [$a['order'], $a['name']] <=> [$b['order'], $b['name']];
|
|
});
|
|
|
|
$groups = [];
|
|
foreach ($siteMeta as $siteId => $meta) {
|
|
$siteEmployees = $bySite[$siteId];
|
|
// Meme tri que le calendrier : displayOrder, puis nom, puis prenom.
|
|
usort($siteEmployees, static function (Employee $a, Employee $b): int {
|
|
return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
|
|
<=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
|
|
});
|
|
|
|
$rows = $this->exportBuilder->buildRows($siteEmployees, $year);
|
|
|
|
$renderRows = [];
|
|
foreach ($rows as $row) {
|
|
$cells = [];
|
|
for ($m = 1; $m <= 12; ++$m) {
|
|
$cells[] = [
|
|
'hours' => $this->formatMinutes($row->months[$m]['nightMinutes']),
|
|
'days' => $row->months[$m]['nightDays'],
|
|
];
|
|
}
|
|
$renderRows[] = [
|
|
'employeeName' => $row->employeeName,
|
|
'cells' => $cells,
|
|
];
|
|
}
|
|
|
|
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
|
|
}
|
|
|
|
$options = new Options();
|
|
$options->set('isRemoteEnabled', true);
|
|
$dompdf = new Dompdf($options);
|
|
|
|
$html = $this->twig->render('night-hours-contingent/print.html.twig', [
|
|
'groups' => $groups,
|
|
'year' => $year,
|
|
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
|
|
]);
|
|
|
|
$dompdf->loadHtml($html);
|
|
$dompdf->setPaper('A4', 'landscape');
|
|
$dompdf->render();
|
|
|
|
$filename = sprintf('contingent_heures_nuit_%d.pdf', $year);
|
|
|
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => sprintf('attachment; filename="%s"', $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;
|
|
}
|
|
|
|
private function formatMinutes(int $minutes): string
|
|
{
|
|
$h = intdiv($minutes, 60);
|
|
$m = $minutes % 60;
|
|
|
|
return sprintf('%dh%02d', $h, $m);
|
|
}
|
|
}
|