feat(overtime-contingent) : contingent d'heures supplémentaires payées (#29)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Résumé
Suivi par **année civile** (Janv–Déc) des heures supplémentaires payées des employés non-forfait (chauffeurs inclus) face au plafond légal (**350 h** chauffeurs / **220 h** autres).
- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`.
- **Export PDF** `GET /overtime-contingent/print?year=&siteIds=` (ROLE_USER, périmètre `findScoped`) : groupé par site, colonnes Janv–Déc + colonne `Total payé / payable`. Drawer liste employés (année + sites).
- Heures payées = `base25 + base50` (hors majoration). Mapping exercice→civil : `mois ≥ 6 ? exercice−1 : exercice`.
- Cœur partagé pur `OvertimePaidContingentCalculator`.
- Ajout « Année civile » dans le titre des deux exports PDF (contingent H.supp. et heures de nuit).
## Tests
- 214 tests PHPUnit verts (calculateur : mapping civil, base-only, plafond ; builder : ventilation mensuelle, ligne à zéro).
## Hors périmètre (consigné)
- Bug latent `SalaryRecapPrintProvider` : rattachement des paiements RTT des mois Juin–Déc par année civile sur un stockage par exercice. À traiter séparément.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #29
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #29.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\EmployeeOvertimeContingentProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/employees/{id}/overtime-contingent',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: EmployeeOvertimeContingentProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class EmployeeOvertimeContingent
|
||||
{
|
||||
public int $year = 0;
|
||||
public int $paidMinutes = 0;
|
||||
public int $capHours = 0;
|
||||
public bool $isDriver = false;
|
||||
}
|
||||
@@ -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,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class OvertimeContingentRow
|
||||
{
|
||||
/**
|
||||
* @param array<int, int> $months clé 1..12 -> minutes base payées
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $employeeId,
|
||||
public readonly string $employeeName,
|
||||
public readonly array $months,
|
||||
public readonly int $totalMinutes,
|
||||
public readonly int $capHours,
|
||||
) {}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||
*/
|
||||
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||
class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
@@ -60,4 +60,31 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paiements de plusieurs employés sur plusieurs exercices (fetch groupé,
|
||||
* évite le N+1 sur l'export PDF). Jointure employé chargée.
|
||||
*
|
||||
* @param list<Employee> $employees
|
||||
* @param list<int> $years années d'exercice
|
||||
*
|
||||
* @return EmployeeRttPayment[]
|
||||
*/
|
||||
public function findByEmployeesAndYears(array $employees, array $years): array
|
||||
{
|
||||
if ([] === $employees || [] === $years) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->createQueryBuilder('p')
|
||||
->andWhere('p.employee IN (:employees)')
|
||||
->andWhere('p.year IN (:years)')
|
||||
->setParameter('employees', $employees)
|
||||
->setParameter('years', $years)
|
||||
->innerJoin('p.employee', 'e')
|
||||
->addSelect('e')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Dto\WorkHours\OvertimeContingentRow;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
|
||||
/**
|
||||
* Construit, par employé, les heures supp payées (base, hors bonus) ventilées
|
||||
* par mois civil pour l'année civile demandée, le total et le plafond légal.
|
||||
*/
|
||||
final readonly class OvertimeContingentExportBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private OvertimePaidContingentCalculator $calculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<OvertimeContingentRow>
|
||||
*/
|
||||
public function buildRows(array $employees, int $civilYear): array
|
||||
{
|
||||
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||
$payments = $this->rttPaymentRepository->findByEmployeesAndYears(
|
||||
$employees,
|
||||
[$civilYear, $civilYear + 1],
|
||||
);
|
||||
|
||||
$byEmployee = [];
|
||||
foreach ($payments as $payment) {
|
||||
$employeeId = $payment->getEmployee()?->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
$byEmployee[$employeeId][] = $payment;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$employeePayments = $byEmployee[$employeeId] ?? [];
|
||||
$months = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||
|
||||
$rows[] = new OvertimeContingentRow(
|
||||
employeeId: $employeeId,
|
||||
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||
months: $months,
|
||||
totalMinutes: array_sum($months),
|
||||
capHours: $this->calculator->capHours($employee->getIsDriver()),
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\EmployeeRttPayment;
|
||||
|
||||
/**
|
||||
* Convertit les paiements RTT (stockés par exercice Juin N-1 -> Mai N + mois)
|
||||
* en agrégats par ANNEE CIVILE (Janv-Déc). Heures payées = base25 + base50,
|
||||
* hors majoration (bonus). Plafond : 350 h chauffeur, 220 h autres.
|
||||
*/
|
||||
final readonly class OvertimePaidContingentCalculator
|
||||
{
|
||||
public const int CAP_HOURS_DRIVER = 350;
|
||||
public const int CAP_HOURS_DEFAULT = 220;
|
||||
|
||||
/**
|
||||
* @param iterable<EmployeeRttPayment> $payments paiements d'un employé
|
||||
* (typiquement exercices civilYear et civilYear+1)
|
||||
*
|
||||
* @return array<int, int> clé 1..12 -> minutes base payées (base25+base50)
|
||||
*/
|
||||
public function monthlyBaseMinutes(iterable $payments, int $civilYear): array
|
||||
{
|
||||
$months = array_fill(1, 12, 0);
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
$month = $payment->getMonth();
|
||||
$paymentCivilYear = $month >= 6 ? $payment->getYear() - 1 : $payment->getYear();
|
||||
if ($paymentCivilYear !== $civilYear) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$months[$month] += $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<EmployeeRttPayment> $payments
|
||||
*/
|
||||
public function totalBaseMinutes(iterable $payments, int $civilYear): int
|
||||
{
|
||||
return array_sum($this->monthlyBaseMinutes($payments, $civilYear));
|
||||
}
|
||||
|
||||
public function capHours(bool $isDriver): int
|
||||
{
|
||||
return $isDriver ? self::CAP_HOURS_DRIVER : self::CAP_HOURS_DEFAULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeOvertimeContingent;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class EmployeeOvertimeContingentProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private OvertimePaidContingentCalculator $calculator,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeOvertimeContingent
|
||||
{
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$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.');
|
||||
}
|
||||
|
||||
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||
$payments = array_merge(
|
||||
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year),
|
||||
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year + 1),
|
||||
);
|
||||
|
||||
$output = new EmployeeOvertimeContingent();
|
||||
$output->year = $year;
|
||||
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year);
|
||||
$output->isDriver = $employee->getIsDriver();
|
||||
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user