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(); $isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate); $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking, $isDriver); $existing = $existingByEmployeeId[$employeeId] ?? null; $isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true); $isSelf = in_array('ROLE_SELF', $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; } $is4hContract = 4 === $contract->getWeeklyHours(); 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 || $is4hContract) { // Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée. $workHour = new WorkHour() ->setEmployee($employee) ->setWorkDate($workDate) ; $this->hydrateWorkHour($workHour, $normalized); if ($isSelf) { $workHour->setUpdatedAt(new DateTimeImmutable()); } $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); if (!$isAdmin) { $workHour->setUpdatedAt(new DateTimeImmutable()); } ++$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, * dayHoursMinutes:?int, * nightHoursMinutes:?int, * hasBreakfast:bool, * hasLunch:bool, * hasOvernight:bool * } */ private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking, bool $isDriver): array { if ($isDriver) { return [ 'morningFrom' => null, 'morningTo' => null, 'afternoonFrom' => null, 'afternoonTo' => null, 'eveningFrom' => null, 'eveningTo' => null, 'isPresentMorning' => false, 'isPresentAfternoon' => false, 'dayHoursMinutes' => $this->normalizeMinutes($entry['dayHoursMinutes'] ?? null, $employeeId, 'dayHoursMinutes'), 'nightHoursMinutes' => $this->normalizeMinutes($entry['nightHoursMinutes'] ?? null, $employeeId, 'nightHoursMinutes'), 'hasBreakfast' => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'), 'hasLunch' => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'), 'hasOvernight' => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'), ]; } 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'), 'dayHoursMinutes' => null, 'nightHoursMinutes' => null, 'hasBreakfast' => false, 'hasLunch' => false, 'hasOvernight' => false, ]; } 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'), 'dayHoursMinutes' => null, 'nightHoursMinutes' => null, 'hasBreakfast' => false, 'hasLunch' => false, 'hasOvernight' => 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 normalizeMinutes(mixed $value, int $employeeId, string $field): ?int { if (null === $value || '' === $value) { return null; } if (!is_int($value) && !is_float($value)) { throw new UnprocessableEntityHttpException(sprintf( 'Employee %d: %s must be an integer (minutes).', $employeeId, $field )); } $minutes = (int) $value; if ($minutes < 0) { throw new UnprocessableEntityHttpException(sprintf( 'Employee %d: %s must be >= 0.', $employeeId, $field )); } return $minutes; } 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, * dayHoursMinutes:?int, * nightHoursMinutes:?int, * hasBreakfast:bool, * hasLunch:bool, * hasOvernight: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'] && (null === $entry['dayHoursMinutes'] || 0 === $entry['dayHoursMinutes']) && (null === $entry['nightHoursMinutes'] || 0 === $entry['nightHoursMinutes']) && false === $entry['hasBreakfast'] && false === $entry['hasLunch'] && false === $entry['hasOvernight']; } /** * @param array{ * morningFrom:?string, * morningTo:?string, * afternoonFrom:?string, * afternoonTo:?string, * eveningFrom:?string, * eveningTo:?string, * isPresentMorning:bool, * isPresentAfternoon:bool, * dayHoursMinutes:?int, * nightHoursMinutes:?int, * hasBreakfast:bool, * hasLunch:bool, * hasOvernight: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']) ->setDayHoursMinutes($entry['dayHoursMinutes']) ->setNightHoursMinutes($entry['nightHoursMinutes']) ->setHasBreakfast($entry['hasBreakfast']) ->setHasLunch($entry['hasLunch']) ->setHasOvernight($entry['hasOvernight']) // 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, * dayHoursMinutes:?int, * nightHoursMinutes:?int, * hasBreakfast:bool, * hasLunch:bool, * hasOvernight: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'] && $workHour->getDayHoursMinutes() === $entry['dayHoursMinutes'] && $workHour->getNightHoursMinutes() === $entry['nightHoursMinutes'] && $workHour->getHasBreakfast() === $entry['hasBreakfast'] && $workHour->getHasLunch() === $entry['hasLunch'] && $workHour->getHasOvernight() === $entry['hasOvernight']; } }