planningModel = new PlanningModel($pdo); $this->agentModel = new AgentModel($pdo); $this->structureModel = new StructureModel($pdo); $this->motifModel = new MotifModel($pdo); $this->quotaModel = new QuotaModel($pdo); $this->vacationModel = new VacationModel($pdo); $this->access = new Access($pdo); } /** * GET api/planning.php — état complet d'une semaine de saisie. * * Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent * sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut * être planifié sur plusieurs lieux, et l'éditeur doit les afficher en * lecture seule pour rendre les chevauchements visibles avant l'échec. * * Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »). */ public function show(): void { $this->access->requireDraftEditor(); $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $weekValue = trim((string) ($_GET['week'] ?? '')); $week = $this->parseWeek($weekValue); if (!$structureId || !$agentId || $week === null) { JsonResponse::send(400, ['error' => 'Paramètres invalides.']); } $this->access->requireStructureEdit((int) $structureId); $structure = $this->structureModel->findActive((int) $structureId); if ($structure === null) { JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']); } $agent = $this->agentModel->findActive((int) $agentId); if ($agent === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } [$year, $weekNumber] = $week; $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); $planning = $this->planningModel->findByContext((int) $structureId, (int) $agentId, $year, $weekNumber); JsonResponse::send(200, [ 'planning' => $planning, 'entries' => $planning ? $this->planningModel->entries((int) $planning['id_planning']) : [], 'other_entries' => $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId), 'current_structure' => $structure, 'agent' => $agent, 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate), 'calendar_days' => $this->vacationModel->typesForIsoWeek($year, $weekNumber), ]); } /** * POST save_planning.php — enregistre le brouillon d'une semaine. * * La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une * liste `entries` vide est un cas nominal (« tout supprimer »), pas une * erreur de saisie. D'où le message spécifique renvoyé dans ce cas. * * Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus * explicite au plus générique : * 1. droits et périmètre ; * 2. motifs fermés à l'agent (message nommant le motif) ; * 3. normalisation/validation ligne à ligne ; * 4. chevauchements internes à la saisie ; * 5. chevauchements avec les autres lieux. * * Charge utile : structure_id, agent_id, week, entries[], csrf_token. */ public function save(): void { $this->access->requireDraftEditor(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); $weekValue = trim((string) ($payload['week'] ?? '')); $rawEntries = $payload['entries'] ?? []; $week = $this->parseWeek($weekValue); if (!$structureId || !$agentId || $week === null) { JsonResponse::send(422, ['error' => 'Lieu d’affectation, agent ou semaine invalide.']); } $this->access->requireStructureEdit((int) $structureId); if (!is_array($rawEntries)) { JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']); } $agent = $this->agentModel->findActive((int) $agentId); if ($agent === null) { JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); } if ($this->structureModel->findActive((int) $structureId) === null) { JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); } [$year, $weekNumber] = $week; $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); // Les motifs autorisés dépendent de l'agent : « Formation » n'est pas // affectable à un agent sans droit à l'enveloppe de formation. $this->assertMotifsAllowedForAgent($rawEntries, $agent); $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent)); $this->assertNoInternalOverlap($entries); $this->assertNoOtherLocationOverlap( $entries, $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId) ); try { $planningId = $this->planningModel->saveDraft( (int) $structureId, (int) $agentId, $year, $weekNumber, $entries ); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); } catch (Throwable $e) { // INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est // PAS défini (défaut '1'), alors que Security::debugEnabled() — // référence documentée — vaut « inactif » par défaut. Le détail // technique de l'exception peut donc remonter au navigateur sur une // installation qui ne définit pas la variable. Le motif est répété // dans les huit contrôleurs ; à unifier sur Security::debugEnabled(). JsonResponse::send(500, [ 'error' => 'Erreur lors de l’enregistrement du planning.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } JsonResponse::send(200, [ 'success' => true, 'planning_id' => $planningId, 'empty' => count($entries) === 0, 'message' => count($entries) === 0 ? 'Toutes les affectations ont été supprimées. Le planning vide a été enregistré.' : 'Brouillon enregistré avec succès.', 'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate), ]); } /** * POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent. * * Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le * repasse alors automatiquement en BROUILLON et renvoie les identifiants * concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le * message doit le dire explicitement, sans quoi l'utilisateur croirait sa * validation intacte. * * Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token. */ public function updateAgentEntry(): void { $this->access->requireDraftEditor(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); $structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT); $motifId = filter_var($payload['motif_id'] ?? null, FILTER_VALIDATE_INT); $date = trim((string) ($payload['date'] ?? '')); $start = trim((string) ($payload['start'] ?? '')); $end = trim((string) ($payload['end'] ?? '')); if (!$entryId || !$structureId || !$motifId) { JsonResponse::send(422, ['error' => 'Créneau, lieu ou motif invalide.']); } $this->access->requireEntryEdit((int) $entryId); $this->access->requireStructureEdit((int) $structureId); $entry = $this->planningModel->findEntryDetailed((int) $entryId); if ($entry === null) { JsonResponse::send(404, ['error' => 'Créneau introuvable.']); } if ($this->structureModel->findActive((int) $structureId) === null) { JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); } $entryAgent = $this->agentModel->findActive((int) $entry['id_agent']); if ($entryAgent === null) { JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); } $this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent); if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) { JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); } // Le « ! » de createFromFormat remet à zéro les champs non fournis : // sans lui, l'heure courante serait injectée et la comparaison de // contrôle ci-dessous laisserait passer des dates approximatives. $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); $validDate = $dateObj && $dateObj->format('Y-m-d') === $date; // La saisie est contrainte au quart d'heure : c'est la granularité de // toute l'application (éditeur, cumuls, PDF). $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); // N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi. if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); } try { $result = $this->planningModel->updateEntryFromAgentView( (int) $entryId, (int) $structureId, (int) $motifId, $date, $start . ':00', $end . ':00' ); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); } catch (Throwable $e) { JsonResponse::send(500, [ 'error' => 'Impossible de modifier le créneau.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } $reopened = count($result['reopened_planning_ids']) > 0; JsonResponse::send(200, [ 'success' => true, 'message' => $reopened ? 'Créneau modifié. Le ou les plannings validés concernés ont été repassés en brouillon et devront être validés à nouveau.' : 'Créneau modifié avec succès.', 'requires_revalidation' => $reopened, 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $date), ]); } /** * POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent. * * Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning * n'est jamais supprimé même s'il devient vide : `planning_empty` permet à * l'interface de le signaler, mais l'enregistrement reste et conserve son * historique. * * Charge utile : entry_id, csrf_token. */ public function deleteAgentEntry(): void { $this->access->requireDraftEditor(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); if (!$entryId) { JsonResponse::send(422, ['error' => 'Créneau invalide.']); } $this->access->requireEntryEdit((int) $entryId); try { $result = $this->planningModel->deleteEntryFromAgentView((int) $entryId); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); } catch (Throwable $e) { JsonResponse::send(500, [ 'error' => 'Impossible de supprimer le créneau.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } $message = $result['remaining_entries'] === 0 ? 'Le dernier créneau a été supprimé. Le planning est maintenant vide.' : 'Créneau supprimé avec succès.'; if ($result['reopened']) { $message .= ' Le planning validé concerné a été repassé en brouillon.'; } JsonResponse::send(200, [ 'success' => true, 'message' => $message, 'planning_empty' => $result['remaining_entries'] === 0, 'requires_revalidation' => $result['reopened'], 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], (string) $result['date']), ]); } /** * POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau. * * Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer * (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine * n'est pas une erreur : le modèle renvoie l'action « none » et le message * l'indique sans alarmer. * * Charge utile : entry_id, target_date, action (move|duplicate), csrf_token. */ public function moveOrDuplicateEntry(): void { $this->access->requireDraftEditor(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT); $targetDate = trim((string) ($payload['target_date'] ?? '')); $action = strtolower(trim((string) ($payload['action'] ?? 'move'))); if (!$entryId || !in_array($action, ['move', 'duplicate'], true)) { JsonResponse::send(422, ['error' => 'Créneau ou action invalide.']); } $this->access->requireEntryEdit((int) $entryId); $entry = $this->planningModel->findEntryDetailed((int) $entryId); if ($entry === null) { JsonResponse::send(404, ['error' => 'Créneau introuvable.']); } $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $targetDate); $validDate = $dateObj && $dateObj->format('Y-m-d') === $targetDate; if (!$validDate || (int) $dateObj->format('N') > 5) { JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']); } try { $result = $this->planningModel->moveOrDuplicateEntry( (int) $entryId, $targetDate, $action === 'duplicate' ); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); } catch (Throwable $e) { JsonResponse::send(500, [ 'error' => $action === 'duplicate' ? 'Impossible de dupliquer le créneau.' : 'Impossible de déplacer le créneau.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } $reopened = count($result['reopened_planning_ids']) > 0; $message = match ($result['action']) { 'duplicate' => 'Créneau dupliqué avec succès.', 'move' => 'Créneau déplacé avec succès.', default => 'Le créneau se trouve déjà sur ce jour.', }; if ($reopened) { $message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.'; } JsonResponse::send(200, [ 'success' => true, 'action' => $result['action'], 'message' => $message, 'requires_revalidation' => $reopened, 'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $targetDate), ]); } /** * POST api/copy_agent_week.php — recopie une semaine entière d'un agent. * * Réservé au Service Enfance (requireService) et non aux responsables : la * copie porte sur TOUS les lieux de l'agent sur la semaine source, elle * déborde donc par nature du périmètre d'un responsable de structure. * * Deux modes : `merge` ajoute aux créneaux existants de la semaine cible, * `replace` les remplace. * * Charge utile : agent_id, source_week, target_week, mode, csrf_token. */ public function copyAgentWeek(): void { $this->access->requireService(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); $sourceWeekValue = trim((string) ($payload['source_week'] ?? '')); $targetWeekValue = trim((string) ($payload['target_week'] ?? '')); $mode = strtolower(trim((string) ($payload['mode'] ?? 'merge'))); $sourceWeek = $this->parseWeek($sourceWeekValue); $targetWeek = $this->parseWeek($targetWeekValue); if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) { JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']); } if ($this->agentModel->findActive((int) $agentId) === null) { JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); } try { $result = $this->planningModel->copyAgentWeek( (int) $agentId, $sourceWeek[0], $sourceWeek[1], $targetWeek[0], $targetWeek[1], $mode ); } catch (DomainException $e) { JsonResponse::send(409, ['error' => $e->getMessage()]); } catch (Throwable $e) { JsonResponse::send(500, [ 'error' => 'Impossible de dupliquer la semaine.', 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, ]); } $message = sprintf( '%d créneau(x) ont été copiés vers %s.', (int) $result['copied_entries'], $targetWeekValue ); if ($result['reopened_planning_ids']) { $message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; } JsonResponse::send(200, [ 'success' => true, 'message' => $message, 'requires_revalidation' => count($result['reopened_planning_ids']) > 0, 'quota' => $this->quotaModel->forDateForApi( (int) $agentId, (new DateTimeImmutable())->setISODate($targetWeek[0], $targetWeek[1], 1)->format('Y-m-d') ), ]); } /** * GET api/pending_plannings.php — file d'attente de validation. * * Alimente l'écran pending.php. Les totaux distinguent les minutes saisies * (`total_minutes`) des minutes réellement décomptées du quota * (`counted_minutes`) : congés et absences non décomptées figurent au * planning sans peser sur le quota annuel. */ public function pendingValidation(): void { $this->access->requireService(); $plannings = $this->planningModel->pendingValidation(); $totalMinutes = array_sum(array_map( static fn(array $planning): int => (int) $planning['total_minutes'], $plannings )); $countedMinutes = array_sum(array_map( static fn(array $planning): int => (int) $planning['minutes_decomptees'], $plannings )); JsonResponse::send(200, [ 'plannings' => $plannings, 'summary' => [ 'count' => count($plannings), 'total_minutes' => $totalMinutes, 'counted_minutes' => $countedMinutes, ], ]); } /** * POST validate_planning.php — contrôle puis valide un planning. * * C'est ICI que vivent les règles de validation réellement appliquées (et * non dans sp_valider_planning côté SQL, jamais appelée). * * Quatre contrôles bloquants (ERREUR → 422, aucune validation) : * - AUCUN_CRENEAU : planning vide ; * - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ; * - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ; * - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ * VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables : * un brouillon concurrent peut encore être corrigé, le bloquer * empêcherait toute validation dans un ordre donné. * * Un contrôle non bloquant (ALERTE → 409) : * - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste * possible, mais exige un rappel explicite avec force_quota: true. Le * dépassement est une décision de gestion, pas une anomalie technique. * * Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur * voie l'ensemble des problèmes d'un coup plutôt qu'un par requête. * * Charge utile : planning_id, force_quota (bool), csrf_token. */ public function validate(): void { $this->access->requireService(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT); $forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN); if (!$planningId) { JsonResponse::send(422, ['error' => 'Planning invalide.']); } $planning = $this->planningModel->findDetailed((int) $planningId); if ($planning === null) { JsonResponse::send(404, ['error' => 'Planning introuvable.']); } $controls = []; if ($this->planningModel->countEntries((int) $planningId) === 0) { $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); } foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) { $this->addControl( $controls, 'ERREUR', 'CRENEAU_HORS_SEMAINE', sprintf('L’affectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour']) ); } foreach ($this->planningModel->internalOverlaps((int) $planningId) as $row) { $this->addControl( $controls, 'ERREUR', 'CHEVAUCHEMENT_INTERNE', sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour']) ); } foreach ($this->planningModel->validatedExternalOverlaps((int) $planningId) as $row) { $this->addControl( $controls, 'ERREUR', 'CHEVAUCHEMENT_AUTRE_STRUCTURE', sprintf( 'L’affectation #%d chevauche l’affectation #%d déjà validée sur le lieu « %s » le %s.', $row['c1'], $row['c2'], $row['structure_nom'], $row['date_jour'] ) ); } $quota = $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']); if ((int) $quota['minutes_restantes'] < 0) { $this->addControl( $controls, 'ALERTE', 'QUOTA_DEPASSE', sprintf( 'Le quota annuel est dépassé de %s. Quota cible : %s ; affecté : %s.', $this->quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle'], $quota['quota_cible']['libelle'], $quota['affectees']['libelle'] ) ); } $errors = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ERREUR')); $alerts = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ALERTE')); if ($errors) { JsonResponse::send(422, [ 'success' => false, 'status' => 'errors', 'controls' => $controls, 'quota' => $quota, 'error' => 'Le planning contient des erreurs bloquantes.', ]); } if ($alerts && !$forceQuota) { JsonResponse::send(409, [ 'success' => false, 'status' => 'warning', 'controls' => $controls, 'quota' => $quota, 'message' => 'Le planning dépasse le quota annuel. Une confirmation est nécessaire.', ]); } $this->planningModel->markValidated((int) $planningId); $this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.'); JsonResponse::send(200, [ 'success' => true, 'status' => 'validated', 'controls' => $controls, 'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']), 'message' => 'Planning validé avec succès.', ]); } /** * POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON. * * Réouverture volontaire, à distinguer de la réouverture automatique * déclenchée par la modification d'un créneau. Le modèle applique un UPDATE * conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre * utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès * trompeur. * * Charge utile : planning_id, csrf_token. */ public function reopen(): void { $this->access->requireDraftEditor(); Request::requireMethod('POST'); $payload = Request::json(); Csrf::assertPayload($payload); $planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT); if (!$planningId) { JsonResponse::send(422, ['error' => 'Planning invalide.']); } $this->access->requirePlanningEdit((int) $planningId); $planning = $this->planningModel->findDetailed((int) $planningId); if ($planning === null) { JsonResponse::send(404, ['error' => 'Planning introuvable.']); } if ($planning['statut'] !== 'VALIDE') { JsonResponse::send(409, ['error' => 'Ce planning est déjà modifiable.']); } if (!$this->planningModel->reopen((int) $planningId)) { JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']); } JsonResponse::send(200, [ 'success' => true, 'status' => 'BROUILLON', 'message' => 'Le planning est repassé en brouillon. Vous pouvez maintenant corriger les affectations puis le valider à nouveau.', 'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']), ]); } /** * Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »). * * La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…) * comptent bien une semaine 53. * * @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null. */ private function parseWeek(string $value): ?array { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { return null; } $year = (int) $matches[1]; $week = (int) $matches[2]; if ($week < 1 || $week > 53) { return null; } return [$year, $week]; } /** * Refuse les affectations portant un motif fermé à l'agent, avec un message * qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais * sans dire pourquoi. L'interface masque déjà ces motifs — reste les * plannings saisis avant le retrait du droit, et les appels directs. * * @param array $rawEntries */ private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void { if (AgentModel::hasTrainingRight($agent)) { return; } $restricted = []; foreach ($this->motifModel->allActive() as $motif) { if (MotifCategories::requiresTrainingRight((string) $motif['code'])) { $restricted[(int) $motif['id_motif']] = (string) $motif['libelle']; } } if ($restricted === []) { return; } foreach ($rawEntries as $entry) { if (!is_array($entry)) { continue; } $motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT); if (isset($restricted[$motifId])) { JsonResponse::send(422, [ 'error' => sprintf( 'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.', $restricted[$motifId], (string) ($agent['prenom'] ?? ''), (string) ($agent['nom'] ?? '') ), ]); } } } /** * Valide et met en forme les créneaux reçus du client. * * Rien de ce qui vient du navigateur n'est repris tel quel : date réelle, * motif appartenant à la liste ouverte à CET agent, horaires au quart * d'heure, fin après début, et appartenance à la semaine visée. Toute ligne * fautive interrompt la requête en 422 en nommant son numéro, seul repère * utilisable par l'utilisateur pour la retrouver dans l'éditeur. * * Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME. * * @param array $entries Créneaux bruts issus du JSON. * @param array $validMotifs Identifiants de motifs autorisés. * * @return array */ private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array { $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekEnd = $weekStart->modify('+6 days'); $normalized = []; foreach ($entries as $index => $entry) { $date = (string) ($entry['date'] ?? ''); $motifId = filter_var($entry['motif_id'] ?? null, FILTER_VALIDATE_INT); $start = (string) ($entry['start'] ?? ''); $end = (string) ($entry['end'] ?? ''); $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); $validDate = $dateObj && $dateObj->format('Y-m-d') === $date; $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); if (!$validDate || !$motifId || !in_array((int) $motifId, $validMotifs, true) || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { JsonResponse::send(422, ['error' => 'Affectation invalide à la ligne ' . ($index + 1) . '.']); } if ($dateObj < $weekStart || $dateObj > $weekEnd) { JsonResponse::send(422, ['error' => 'Une affectation se trouve en dehors de la semaine sélectionnée.']); } $normalized[] = [ 'date' => $date, 'motif_id' => (int) $motifId, 'start' => $start . ':00', 'end' => $end . ':00', ]; } return $normalized; } /** * Refuse deux créneaux qui se recouvrent dans la MÊME saisie. * * Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines * de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement * strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides. * * @param array $entries */ private function assertNoInternalOverlap(array $entries): void { for ($i = 0, $count = count($entries); $i < $count; $i++) { for ($j = $i + 1; $j < $count; $j++) { $first = $entries[$i]; $second = $entries[$j]; if ($first['date'] === $second['date'] && $first['start'] < $second['end'] && $first['end'] > $second['start']) { JsonResponse::send(409, [ 'error' => sprintf( 'Deux affectations saisies se chevauchent le %s (%s-%s et %s-%s).', $first['date'], substr($first['start'], 0, 5), substr($first['end'], 0, 5), substr($second['start'], 0, 5), substr($second['end'], 0, 5) ), 'code' => 'CHEVAUCHEMENT_SAISIE', ]); } } } } /** * Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu. * * À la différence de validate(), ce contrôle-ci oppose aussi les brouillons : * un agent ne peut pas être à deux endroits à la fois, même en cours de * saisie. La réponse porte un objet `conflict` détaillé pour que l'interface * puisse nommer le lieu et l'horaire en cause. * * Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour * être comparables aux créneaux normalisés. * * @param array $entries * @param array> $existingEntries */ private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void { foreach ($entries as $entry) { foreach ($existingEntries as $existing) { $existingStart = $existing['heure_debut'] . ':00'; $existingEnd = $existing['heure_fin'] . ':00'; if ($entry['date'] === $existing['date_jour'] && $entry['start'] < $existingEnd && $entry['end'] > $existingStart) { JsonResponse::send(409, [ 'error' => sprintf( 'Double affectation impossible : l’agent est déjà planifié sur le lieu « %s » le %s de %s à %s (%s).', $existing['structure_nom'], $existing['date_jour'], $existing['heure_debut'], $existing['heure_fin'], strtolower((string) $existing['statut']) ), 'code' => 'CHEVAUCHEMENT_AUTRE_STRUCTURE', 'conflict' => [ 'structure' => $existing['structure_nom'], 'date' => $existing['date_jour'], 'start' => $existing['heure_debut'], 'end' => $existing['heure_fin'], 'status' => $existing['statut'], ], ]); } } } } /** * Empile un contrôle au format commun {niveau, code, message}. * * @param array $controls * @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO. */ private function addControl(array &$controls, string $level, string $code, string $message): void { $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; } }