Files
SIRH/src/State/AbsenceWriteProcessor.php
tristan 057d6bf06f
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
[#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #9
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 07:52:49 +00:00

313 lines
11 KiB
PHP

<?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\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DatePeriod;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
private AuditLogger $auditLogger,
) {}
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;
}
$user = $this->security->getUser();
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($operation instanceof DeleteOperationInterface) {
if ($this->isLockedByValidation($employee, $data->getStartDate(), $data->getEndDate(), $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
$startDate = $data->getStartDate()->format('d/m/Y');
$endDate = $data->getEndDate()->format('d/m/Y');
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'delete',
'absence',
$data->getId(),
sprintf('Absence %s supprimée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
['old' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
DateTimeImmutable::createFromInterface($data->getStartDate()),
);
$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->isLockedByValidation($employee, $from, $to, $isAdmin)) {
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);
}
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
$startDate = $data->getStartDate()->format('d/m/Y');
$endDate = $data->getEndDate()->format('d/m/Y');
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'create',
'absence',
null,
sprintf('Absence %s créée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
['new' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
DateTimeImmutable::createFromInterface($data->getStartDate()),
);
$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'));
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
$segments = [];
foreach ($days as $day) {
if (isset($publicHolidays[$day->format('Y-m-d')])) {
continue;
}
$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);
}
private function isLockedByValidation(Employee $employee, DateTimeInterface $from, DateTimeInterface $to, bool $isAdmin): bool
{
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
return true;
}
if ($isAdmin) {
return false;
}
return $this->workHourRepository->hasSiteValidatedInRange($employee, $from, $to);
}
/**
* @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)
->setIsSiteValid(false)
->setIsValid(false)
;
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)
->setIsSiteValid(false)
->setIsValid(false)
;
return;
}
// Journée complète: on efface toutes les plages horaires.
$workHour
->setMorningFrom(null)
->setMorningTo(null)
->setAfternoonFrom(null)
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
}
/**
* @return array<string, string>
*/
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
}