feat(heures) : export Contingent heures de nuit (liste employés) (#28)
Auto Tag Develop / tag (push) Successful in 9s
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>
This commit was merged in pull request #28.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\QueryParameter;
|
||||
use App\State\NightHoursContingentPrintProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/night-hours-contingent/print',
|
||||
provider: NightHoursContingentPrintProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'year', required: true),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class NightHoursContingentPrint {}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class NightContingentRow
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{nightMinutes: int, nightDays: int}> $months clé 1..12
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $employeeId,
|
||||
public readonly string $employeeName,
|
||||
public readonly array $months,
|
||||
) {}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
|
||||
@@ -34,6 +35,7 @@ final readonly class RttRecoveryComputationService
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private SolidarityDayResolver $solidarityDayResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
string $rttStartDate = '',
|
||||
) {
|
||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||
@@ -359,13 +361,12 @@ final readonly class RttRecoveryComputationService
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -411,35 +412,6 @@ final readonly class RttRecoveryComputationService
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Dto\WorkHours\NightContingentRow;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Construit, par employe, les totaux mensuels d'heures de nuit et le nombre de
|
||||
* nuits travaillees (>= 4h de nuit dans la journee). Fenetre 21h->6h via
|
||||
* NightHoursCalculator. Conducteurs : minutes saisies (nightHoursMinutes).
|
||||
* Aucun credit absence/ferie : seules les heures reellement travaillees comptent.
|
||||
*/
|
||||
final readonly class NightContingentExportBuilder
|
||||
{
|
||||
private const int NIGHT_DAY_THRESHOLD_MINUTES = 240;
|
||||
|
||||
public function __construct(
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return list<NightContingentRow>
|
||||
*/
|
||||
public function buildRows(array $employees, int $year): array
|
||||
{
|
||||
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
||||
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||
|
||||
$byEmployee = [];
|
||||
foreach ($workHours as $wh) {
|
||||
$employeeId = $wh->getEmployee()?->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
$byEmployee[$employeeId][] = $wh;
|
||||
}
|
||||
|
||||
$days = [];
|
||||
foreach ($workHours as $wh) {
|
||||
$days[$wh->getWorkDate()->format('Y-m-d')] = true;
|
||||
}
|
||||
$days = array_keys($days);
|
||||
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||
|
||||
$rows = [];
|
||||
foreach ($employees as $employee) {
|
||||
$employeeId = $employee->getId();
|
||||
if (null === $employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; ++$m) {
|
||||
$months[$m] = ['nightMinutes' => 0, 'nightDays' => 0];
|
||||
}
|
||||
|
||||
foreach ($byEmployee[$employeeId] ?? [] as $wh) {
|
||||
$date = DateTimeImmutable::createFromInterface($wh->getWorkDate());
|
||||
$ymd = $date->format('Y-m-d');
|
||||
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
|
||||
$nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver);
|
||||
if ($nightMin <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = (int) $date->format('n');
|
||||
$months[$month]['nightMinutes'] += $nightMin;
|
||||
if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) {
|
||||
++$months[$month]['nightDays'];
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = new NightContingentRow(
|
||||
employeeId: $employeeId,
|
||||
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||
months: $months,
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\WorkHour;
|
||||
|
||||
/**
|
||||
* Calcul des minutes travaillees de nuit (fenetre 21h->6h).
|
||||
*
|
||||
* Fenetres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440]
|
||||
* (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit.
|
||||
* Source de verite unique partagee par les ecrans Heures et les exports.
|
||||
*/
|
||||
final readonly class NightHoursCalculator
|
||||
{
|
||||
/**
|
||||
* Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes.
|
||||
* Non-conducteurs : somme calculee depuis les plages matin/apres-midi/soir.
|
||||
*/
|
||||
public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int
|
||||
{
|
||||
if ($isDriver) {
|
||||
return $workHour->getNightHoursMinutes() ?? 0;
|
||||
}
|
||||
|
||||
return $this->nightMinutesFromRanges($workHour);
|
||||
}
|
||||
|
||||
public function nightMinutesFromRanges(WorkHour $workHour): int
|
||||
{
|
||||
$ranges = [
|
||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||
];
|
||||
|
||||
$total = 0;
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$total += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{int, int}
|
||||
*/
|
||||
private function resolveInterval(?string $from, ?string $to): ?array
|
||||
{
|
||||
$fromMinutes = $this->toMinutes($from);
|
||||
$toMinutes = $this->toMinutes($to);
|
||||
if (null === $fromMinutes || null === $toMinutes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||
|
||||
return [$fromMinutes, $end];
|
||||
}
|
||||
|
||||
private function toMinutes(?string $time): ?int
|
||||
{
|
||||
if (null === $time || '' === $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class YearlyHoursExportBuilder
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -541,14 +542,12 @@ class YearlyHoursExportBuilder
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -596,35 +595,6 @@ class YearlyHoursExportBuilder
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
if (0 === $minutes) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
@@ -45,6 +46,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -78,10 +80,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
|
||||
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
|
||||
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
@@ -472,7 +474,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$ytdAbsences,
|
||||
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
||||
));
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
||||
$presenceDays += $split['n1PresenceDays'];
|
||||
} else {
|
||||
@@ -524,14 +526,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return [
|
||||
'nightMinutes' => $nightMinutes,
|
||||
@@ -578,27 +579,6 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour.
|
||||
* Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h).
|
||||
@@ -630,14 +610,6 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return $overflow;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
|
||||
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
|
||||
@@ -677,7 +649,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
|
||||
$covered = 0.0;
|
||||
if ($remaining > 0.0) {
|
||||
$covered = min($remaining, $amount);
|
||||
$covered = min($remaining, $amount);
|
||||
$remaining -= $covered;
|
||||
}
|
||||
$displayed = $amount - $covered;
|
||||
|
||||
@@ -28,6 +28,7 @@ use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use App\Service\WorkHours\NightHoursCalculator;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -51,6 +52,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||
private NightHoursCalculator $nightHoursCalculator,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||
@@ -433,14 +435,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
@@ -489,37 +489,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user