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); } 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), ]); } 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.']); } if ($this->agentModel->findActive((int) $agentId) === 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'); $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds()); $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) { 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), ]); } 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.']); } if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) { JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); } $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 || (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), ]); } 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']), ]); } 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), ]); } 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') ), ]); } 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, ], ]); } 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.', ]); } 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']), ]); } 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]; } 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; } 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', ]); } } } } 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'], ], ]); } } } } private function addControl(array &$controls, string $level, string $code, string $message): void { $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; } }