Files
SIRH/src/State/WorkHourBulkUpsertProcessor.php
tristan 5ff7e356be
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
fix : validation RH qui invalidé les sites
2026-02-27 10:21:54 +01:00

368 lines
14 KiB
PHP

<?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\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
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,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
) {}
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))
;
$absenceByEmployeeId = [];
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
$absenceEmployeeId = $absence->getEmployee()?->getId();
if ($absenceEmployeeId) {
$absenceByEmployeeId[$absenceEmployeeId] = true;
}
}
$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));
}
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
if (null === $contract) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d has no active contract on %s.',
$employeeId,
$data->workDate
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
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 (!$isAdmin && $existing?->isSiteValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: site validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
// Si aucune donnée n'a changé, on ne touche pas la ligne:
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
;
$this->hydrateWorkHour($workHour, $normalized);
$this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour;
++$result->created;
}
++$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'),
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
// même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
];
}
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 invalide la validation chef de site.
->setIsSiteValid(false)
// 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'];
}
}