diff --git a/migrations/Version20260219180000.php b/migrations/Version20260219180000.php new file mode 100644 index 0000000..2daf023 --- /dev/null +++ b/migrations/Version20260219180000.php @@ -0,0 +1,83 @@ +connection->fetchAllAssociative( + 'SELECT id, employee_id, type_id, start_date, end_date, start_half, end_half, comment + FROM absences + WHERE start_date < end_date + ORDER BY id ASC' + ); + + foreach ($rows as $row) { + $start = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['start_date']); + $end = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['end_date']); + if (!$start instanceof DateTimeImmutable || !$end instanceof DateTimeImmutable) { + continue; + } + + $startHalf = (string) $row['start_half']; + $endHalf = (string) $row['end_half']; + + $days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day')); + 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'); + + if ($isFirst && 'PM' === $startHalf) { + $segmentStartHalf = 'PM'; + $segmentEndHalf = 'PM'; + } elseif ($isLast && 'AM' === $endHalf) { + $segmentStartHalf = 'AM'; + $segmentEndHalf = 'AM'; + } else { + $segmentStartHalf = 'AM'; + $segmentEndHalf = 'PM'; + } + + $this->connection->insert('absences', [ + 'employee_id' => (int) $row['employee_id'], + 'type_id' => (int) $row['type_id'], + 'start_date' => $day, + 'end_date' => $day, + 'start_half' => $segmentStartHalf, + 'end_half' => $segmentEndHalf, + 'comment' => $row['comment'], + ], [ + 'employee_id' => Types::INTEGER, + 'type_id' => Types::INTEGER, + 'start_date' => Types::DATE_IMMUTABLE, + 'end_date' => Types::DATE_IMMUTABLE, + 'start_half' => Types::STRING, + 'end_half' => Types::STRING, + 'comment' => Types::TEXT, + ]); + } + + $this->connection->delete('absences', ['id' => (int) $row['id']], ['id' => Types::INTEGER]); + } + } + + public function down(Schema $schema): void + { + $this->throwIrreversibleMigrationException('Cette migration de decoupage est irreversible.'); + } +} diff --git a/src/Repository/AbsenceRepository.php b/src/Repository/AbsenceRepository.php index fa91931..c64063d 100644 --- a/src/Repository/AbsenceRepository.php +++ b/src/Repository/AbsenceRepository.php @@ -7,6 +7,7 @@ namespace App\Repository; use App\Entity\Absence; use App\Entity\Employee; use DateTimeImmutable; +use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -72,4 +73,29 @@ final class AbsenceRepository extends ServiceEntityRepository // @var list $absences return $qb->getQuery()->getResult(); } + + /** + * @return list + */ + public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array + { + $fromDate = DateTimeImmutable::createFromInterface($from); + $toDate = DateTimeImmutable::createFromInterface($to); + + $qb = $this->createQueryBuilder('a') + ->leftJoin('a.employee', 'e') + ->leftJoin('a.type', 't') + ->addSelect('e', 't') + ->andWhere('a.employee = :employee') + ->andWhere('a.startDate >= :from') + ->andWhere('a.startDate <= :to') + ->setParameter('employee', $employee) + ->setParameter('from', $fromDate) + ->setParameter('to', $toDate) + ->orderBy('a.startDate', 'ASC') + ; + + // @var list $absences + return $qb->getQuery()->getResult(); + } } diff --git a/src/State/AbsenceWriteProcessor.php b/src/State/AbsenceWriteProcessor.php index e1f6699..d8f4b8d 100644 --- a/src/State/AbsenceWriteProcessor.php +++ b/src/State/AbsenceWriteProcessor.php @@ -8,17 +8,22 @@ use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Absence; +use App\Enum\HalfDay; +use App\Repository\AbsenceRepository; use App\Repository\WorkHourRepository; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +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( - #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] - private ProcessorInterface $persistProcessor, - #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] - private ProcessorInterface $removeProcessor, + private EntityManagerInterface $entityManager, + private AbsenceRepository $absenceRepository, private WorkHourRepository $workHourRepository, ) {} @@ -33,14 +38,140 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface return $data; } - if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) { + 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.'); } - if ($operation instanceof DeleteOperationInterface) { - return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + $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.'); } - return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + $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->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->entityManager->persist($absence); + } + + $this->entityManager->flush(); + + return $data; + } + + /** + * @return list + */ + 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); } }