[#322] Page horaire (#4)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #322          |        Page horaire         |

## Description de la PR
[#322] Page horaire

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #4.
This commit is contained in:
2026-02-20 11:23:52 +00:00
committed by Autin
parent f6c1f7eead
commit ee16779777
85 changed files with 6232 additions and 242 deletions

View File

@@ -6,12 +6,12 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -27,7 +27,8 @@ class AbsencePrintProvider implements ProviderInterface
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
@@ -109,50 +110,12 @@ class AbsencePrintProvider implements ProviderInterface
private function loadEmployees(array $siteIds): array
{
$qb = $this->entityManager
->getRepository(Employee::class)
->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC')
;
if ([] !== $siteIds) {
$qb->andWhere('s.id IN (:siteIds)')
->setParameter('siteIds', $siteIds)
;
}
// @var list<Employee> $result
return $qb->getQuery()->getResult();
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
}
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->entityManager
->getRepository(Absence::class)
->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->andWhere('a.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @var list<Absence> $result
return $qb->getQuery()->getResult();
return $this->absenceRepository->findForPrint($from, $to, $employees);
}
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
@@ -202,13 +165,13 @@ class AbsencePrintProvider implements ProviderInterface
if ($isSameDay) {
if ($startHalf === $endHalf) {
$halfLabel = $startHalf;
$halfLabel = $startHalf->value;
}
} else {
if ($isStartDay && 'PM' === $startHalf) {
if ($isStartDay && HalfDay::PM === $startHalf) {
$halfLabel = 'PM';
}
if ($isEndDay && 'AM' === $endHalf) {
if ($isEndDay && HalfDay::AM === $endHalf) {
$halfLabel = 'AM';
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use DateInterval;
use DatePeriod;
use DateTime;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Absence) {
return $data;
}
$employee = $data->getEmployee();
if (null === $employee) {
return $data;
}
if ($operation instanceof DeleteOperationInterface) {
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
$segments = $this->expandAbsenceRange($data);
if ([] === $segments) {
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
}
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$existing = $this->absenceRepository->findByEmployeeAndDateRange($employee, $from, $to);
foreach ($existing as $existingAbsence) {
if ($existingAbsence->getId() === $data->getId()) {
continue;
}
throw new ConflictHttpException('Cette période chevauche déjà une absence existante.');
}
$first = array_shift($segments);
if (null === $first) {
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
}
$data
->setStartDate($this->toMutableDate($first['date']))
->setEndDate($this->toMutableDate($first['date']))
->setStartHalf($first['startHalf'])
->setEndHalf($first['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $first);
$this->entityManager->persist($data);
foreach ($segments as $segment) {
$absence = new Absence()
->setEmployee($employee)
->setType($data->getType())
->setComment($data->getComment())
->setStartDate($this->toMutableDate($segment['date']))
->setEndDate($this->toMutableDate($segment['date']))
->setStartHalf($segment['startHalf'])
->setEndHalf($segment['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $segment);
$this->entityManager->persist($absence);
}
$this->entityManager->flush();
return $data;
}
/**
* @return list<array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay}>
*/
private function expandAbsenceRange(Absence $absence): array
{
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
if ($start > $end) {
throw new UnprocessableEntityHttpException('La date de fin ne peut pas être avant la date de début.');
}
if (
$start->format('Y-m-d') === $end->format('Y-m-d')
&& HalfDay::PM === $absence->getStartHalf()
&& HalfDay::AM === $absence->getEndHalf()
) {
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
}
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$segments = [];
foreach ($days as $day) {
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast;
if ($isSame) {
$segments[] = [
'date' => $day,
'startHalf' => $absence->getStartHalf(),
'endHalf' => $absence->getEndHalf(),
];
continue;
}
if ($isFirst && HalfDay::PM === $absence->getStartHalf()) {
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::PM,
'endHalf' => HalfDay::PM,
];
continue;
}
if ($isLast && HalfDay::AM === $absence->getEndHalf()) {
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::AM,
'endHalf' => HalfDay::AM,
];
continue;
}
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::AM,
'endHalf' => HalfDay::PM,
];
}
return $segments;
}
private function toMutableDate(DateTimeImmutable $date): DateTime
{
return DateTime::createFromImmutable($date);
}
/**
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
*/
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
{
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
if (null === $workHour) {
return;
}
// Demi-journée matin: on efface uniquement la plage du matin.
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
$workHour
->setMorningFrom(null)
->setMorningTo(null)
;
return;
}
// Demi-journée après-midi: on efface après-midi + soirée.
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
$workHour
->setAfternoonFrom(null)
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
;
return;
}
// Journée complète: on efface toutes les plages horaires.
$workHour
->setMorningFrom(null)
->setMorningTo(null)
->setAfternoonFrom(null)
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ScopedEmployeeProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EmployeeRepository $employeeRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
return $this->employeeRepository->findScoped($user);
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkUpsertResult {
// Endpoint dédié au bulk: on refuse tout autre payload.
if (!$data instanceof WorkHourBulkUpsert) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
if (!$workDate || $workDate->format('Y-m-d') !== $data->workDate) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
if ([] === $data->entries) {
throw new UnprocessableEntityHttpException('entries must contain at least one employee.');
}
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
$employeeIds = $this->extractEmployeeIds($data->entries);
$employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);
if (count($employeesById) !== count($employeeIds)) {
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
}
$existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
;
$result = new WorkHourBulkUpsertResult();
foreach ($data->entries as $entry) {
$employeeId = (int) $entry['employeeId'];
$employee = $employeesById[$employeeId] ?? null;
if (!$employee) {
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null;
if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->entityManager->remove($existing);
++$result->deleted;
}
++$result->processed;
continue;
}
if ($existing) {
$workHour = $existing;
++$result->updated;
} else {
// Upsert: création si aucune ligne n'existe pour (employé, date).
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
;
$this->entityManager->persist($workHour);
++$result->created;
}
$this->hydrateWorkHour($workHour, $normalized);
++$result->processed;
}
$this->entityManager->flush();
return $result;
}
/**
* @param list<array<string, mixed>> $entries
*
* @return list<int>
*/
private function extractEmployeeIds(array $entries): array
{
$ids = [];
foreach ($entries as $index => $entry) {
if (!is_array($entry) || !array_key_exists('employeeId', $entry)) {
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId is required.', $index));
}
$employeeId = (int) $entry['employeeId'];
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId must be a positive integer.', $index));
}
if (isset($ids[$employeeId])) {
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in the same bulk payload.', $employeeId));
}
$ids[$employeeId] = $employeeId;
}
return array_values($ids);
}
/**
* @param array<string, mixed> $entry
*
* @return array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* }
*/
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
{
if ($isPresenceTracking) {
return [
'morningFrom' => null,
'morningTo' => null,
'afternoonFrom' => null,
'afternoonTo' => null,
'eveningFrom' => null,
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
];
}
return [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
'isPresentMorning' => false,
'isPresentAfternoon' => false,
];
}
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
{
if (null === $value || '' === $value) {
return null;
}
if (!is_string($value)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must be a string in HH:MM format.',
$employeeId,
$field
));
}
$time = trim($value);
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must use HH:MM format.',
$employeeId,
$field
));
}
return $time;
}
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
{
if (!is_bool($value)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must be a boolean.',
$employeeId,
$field
));
}
return $value;
}
/**
* @param array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function isEntryEmpty(array $entry): bool
{
return null === $entry['morningFrom']
&& null === $entry['morningTo']
&& null === $entry['afternoonFrom']
&& null === $entry['afternoonTo']
&& null === $entry['eveningFrom']
&& null === $entry['eveningTo']
&& false === $entry['isPresentMorning']
&& false === $entry['isPresentAfternoon'];
}
/**
* @param array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
{
$workHour
->setMorningFrom($entry['morningFrom'])
->setMorningTo($entry['morningTo'])
->setAfternoonFrom($entry['afternoonFrom'])
->setAfternoonTo($entry['afternoonTo'])
->setEveningFrom($entry['eveningFrom'])
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
// Toute modification utilisateur repasse la ligne en attente de validation RH.
->setIsValid(false)
;
}
/**
* @param array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
{
return $workHour->getMorningFrom() === $entry['morningFrom']
&& $workHour->getMorningTo() === $entry['morningTo']
&& $workHour->getAfternoonFrom() === $entry['afternoonFrom']
&& $workHour->getAfternoonTo() === $entry['afternoonTo']
&& $workHour->getEveningFrom() === $entry['eveningFrom']
&& $workHour->getEveningTo() === $entry['eveningTo']
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourDayContext;
use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourDayContextProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
{
$user = $this->security->getUser();
// Endpoint protégé: on exige un utilisateur authentifié.
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
}
$dateKey = $workDate->format('Y-m-d');
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
// Ignore les absences orphelines ou hors scope utilisateur.
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $dateKey);
// Pas de segment absent sur ce jour: rien à injecter dans la ligne.
if (!$absentMorning && !$absentAfternoon) {
continue;
}
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
$rowsByEmployeeId[$employeeId]->addAbsence(
label: $absence->getType()?->getLabel(),
morning: $absentMorning,
afternoon: $absentAfternoon,
creditedMinutes: $creditedMinutes,
creditedPresenceUnits: $creditedPresenceUnits
);
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(
static fn (DayContextRow $row): array => $row->toArray(),
array_values($rowsByEmployeeId)
);
return $response;
}
private function resolveWorkDate(): DateTimeImmutable
{
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('workDate') ?? '');
// Sans paramètre, on cible la date du jour.
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format pour éviter les ambiguïtés de parsing.
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
return $date;
}
}

View File

@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourWeeklySummary;
use App\Dto\WorkHours\WeeklyDaySummary;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
{
$user = $this->security->getUser();
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$anchorDate = $this->resolveAnchorDate();
[$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate);
$employees = $this->employeeRepository->findScoped($user);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
$summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days;
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
return $summary;
}
private function resolveAnchorDate(): DateTimeImmutable
{
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('weekStart') ?? '');
// Sans paramètre, on ancre la semaine sur aujourd'hui.
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format attendu.
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
}
return $date;
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable, list<string>}
*/
private function resolveWeek(DateTimeImmutable $anchorDate): array
{
// Convention ISO: semaine de lundi (1) à dimanche (7).
$dayOfWeek = (int) $anchorDate->format('N');
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
$weekEnd = $weekStart->modify('+6 days');
$days = [];
for ($i = 0; $i < 7; ++$i) {
$days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d');
}
return [$weekStart, $weekEnd, $days];
}
/**
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
*
* @return list<WeeklySummaryRow>
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
{
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByEmployeeDate[$employeeId][$dateKey] = [
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
];
}
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
$absenceByEmployeeDate = [];
$absenceLabelByEmployeeDate = [];
$absenceColorByEmployeeDate = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
// On ne crédite que les dates couvertes par l'intervalle d'absence.
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$absenceByEmployeeDate[$employeeId][$date] = true;
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
}
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
}
}
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
}
}
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$daily = [];
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? new WorkMetrics();
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
// Les absences "comptées comme travaillées" alimentent le total du jour.
$metrics->addCreditedMinutes($creditedMinutes);
$present = null;
if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
$weeklyDayMinutes += $metrics->dayMinutes;
$weeklyNightMinutes += $metrics->nightMinutes;
$weeklyTotalMinutes += $metrics->totalMinutes;
if (null !== $present) {
$weeklyPresenceCount += $present;
}
$daily[] = new WeeklyDaySummary(
date: $date,
dayMinutes: $metrics->dayMinutes,
nightMinutes: $metrics->nightMinutes,
totalMinutes: $metrics->totalMinutes,
present: $present,
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
);
}
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
$weeklyOvertimeTotalMinutes = $isPresenceTracking
? 0
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
$rows[] = new WeeklySummaryRow(
employeeId: $employeeId,
firstName: $employee->getFirstName(),
lastName: $employee->getLastName(),
siteName: $employee->getSite()?->getName(),
contractName: $employee->getContract()?->getName(),
contractType: $employee->getContract()?->getType()->value,
trackingMode: $employee->getContract()?->getTrackingMode(),
daily: $daily,
weeklyDayMinutes: $weeklyDayMinutes,
weeklyNightMinutes: $weeklyNightMinutes,
weeklyTotalMinutes: $weeklyTotalMinutes,
weeklyPresenceCount: $weeklyPresenceCount,
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
);
}
return $rows;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @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;
}
// Si fin <= début, on considère un passage à minuit.
$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 intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
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);
}
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
{
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
return 0;
}
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
$referenceHours = max(35, $contractWeeklyHours);
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
}
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
{
// Règle métier:
// - contrats <= 35h: 25% entre 35h et 43h
// - contrats >= 39h: 25% entre 39h et 43h
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
return (int) round($trancheMinutes * 0.25);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
// Bonus 50% appliqué au-delà de 43h.
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(Employee $employee): bool
{
$contract = $employee->getContract();
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),
$contract?->getWeeklyHours()
);
return ContractType::INTERIM === $type;
}
}