feat(overtime-contingent) : export PDF groupé par site (heures supp payées)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\OvertimeContingentPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/overtime-contingent/print',
|
||||||
|
provider: OvertimeContingentPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'year', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: false),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class OvertimeContingentPrint {}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?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\Enum\ContractType;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\WorkHours\OvertimeContingentExportBuilder;
|
||||||
|
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;
|
||||||
|
|
||||||
|
final class OvertimeContingentPrintProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private OvertimeContingentExportBuilder $exportBuilder,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Filtre sites optionnel (vide = tout le perimetre).
|
||||||
|
$rawSiteIds = (string) $request->query->get('siteIds', '');
|
||||||
|
$siteIds = array_values(array_filter(array_map('intval', array_filter(explode(',', $rawSiteIds), 'strlen'))));
|
||||||
|
|
||||||
|
// Perimetre selon le profil : admin -> tous, chef de site -> ses sites.
|
||||||
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$bySite = [];
|
||||||
|
$siteMeta = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
if (!$this->hasContractInRange($employee, $from, $to)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Exclure les forfait (contrat courant).
|
||||||
|
$currentContract = $this->contractResolver->resolveForEmployeeAndDate($employee, $today);
|
||||||
|
if (null !== $currentContract && ContractType::FORFAIT === $currentContract->getType()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$site = $employee->getSite();
|
||||||
|
if (null === $site) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$siteId = $site->getId();
|
||||||
|
if ([] !== $siteIds && !in_array($siteId, $siteIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$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[] = $row->months[$m] > 0 ? $this->formatMinutes($row->months[$m]) : '—';
|
||||||
|
}
|
||||||
|
$renderRows[] = [
|
||||||
|
'employeeName' => $row->employeeName,
|
||||||
|
'cells' => $cells,
|
||||||
|
'totalHours' => $this->formatMinutes($row->totalMinutes),
|
||||||
|
'capHours' => $row->capHours,
|
||||||
|
'exceeded' => $row->totalMinutes > $row->capHours * 60,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
|
||||||
|
$html = $this->twig->render('overtime-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_supp_%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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 16px; }
|
||||||
|
body { font-family: DejaVu Sans, sans-serif; font-size: 10px; color: #000; }
|
||||||
|
h1 { font-size: 15px; margin: 0 0 2px; }
|
||||||
|
.meta { font-size: 9px; color: #555; margin-bottom: 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #999; padding: 2px 3px; text-align: center; }
|
||||||
|
th { background: #d9d9d9; }
|
||||||
|
td.name, th.name { text-align: left; width: 150px; padding-left: 4px; padding-right: 6px; }
|
||||||
|
td.data, th.data { width: 44px; font-size: 9px; }
|
||||||
|
td.total, th.total { width: 90px; font-weight: bold; white-space: nowrap; }
|
||||||
|
td.exceeded { color: #c00; }
|
||||||
|
tr.site-title td { text-align: left; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Contingent heures supplémentaires payées — {{ year }}</h1>
|
||||||
|
<div class="meta">Édité le {{ exportedAt }}</div>
|
||||||
|
|
||||||
|
{% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="name">Nom</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th class="data">{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th class="total">Total payé / payable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr class="site-title">
|
||||||
|
<td colspan="14" style="background: {{ group.siteColor|default('#eee') }}">{{ group.siteName }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for row in group.rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="name">{{ row.employeeName }}</td>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td class="data">{{ cell }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="total{{ row.exceeded ? ' exceeded' : '' }}">{{ row.totalHours }} / {{ row.capHours }} h</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user