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> $entries * * @return list */ 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 $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']; } }