Files
pta/app/Controllers/WeekTemplateController.php
2026-08-20 13:22:49 +02:00

554 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\WeekTemplateModel;
use DateTimeImmutable;
use DomainException;
use PDO;
use Throwable;
/**
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
*
* Une semaine type n'est PAS un planning : elle décrit une organisation
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
*
* Deux origines pour une semaine type, qui convergent vers
* WeekTemplateModel::persistTemplate() :
* - store() : capture d'une semaine réellement planifiée ;
* - storeEntries() : composition directe dans l'éditeur.
*
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
*
* Convention de codes d'erreur propre à ce contrôleur :
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
* alors de le remplacer en renvoyant replace: true) ;
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
* reformulation de ce message dans WeekTemplateModel casse la distinction.
*/
final class WeekTemplateController
{
private WeekTemplateModel $templateModel;
private AgentModel $agentModel;
private QuotaModel $quotaModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->templateModel = new WeekTemplateModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->access = new Access($pdo);
}
/**
* GET api/week_templates.php — liste des semaines types d'un agent.
*
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
* show() sert à ouvrir.
*/
public function index(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
$templates = array_map(static function (array $template): array {
return [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
'location_count' => (int) $template['nombre_lieux'],
'updated_at' => (string) $template['date_modification'],
];
}, $this->templateModel->listForAgent((int) $agentId));
JsonResponse::send(200, ['templates' => $templates]);
}
/**
* GET api/week_template.php — contenu détaillé d'une semaine type.
*
* Le modèle est retrouvé par (template_id, agent_id) et non par le seul
* identifiant : c'est ce qui empêche de lire le modèle d'un autre agent en
* changeant l'identifiant dans l'URL.
*
* Les drapeaux `structure_active` et `motif_actif` sont conservés bien que
* toujours vrais à la création : un lieu ou un motif peut être désactivé
* APRÈS l'enregistrement du modèle, et l'éditeur doit le signaler plutôt que
* d'échouer au moment de l'application.
*/
public function show(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
if (!$agentId || !$templateId) {
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
}
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
if ($template === null) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
}
$entries = array_map(static function (array $entry): array {
return [
'jour_semaine' => (int) $entry['jour_semaine'],
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
// L'éditeur manipule des heures au format HH:MM.
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
'structure_nom' => (string) $entry['structure_nom'],
'structure_active' => (int) $entry['structure_actif'] === 1,
'motif_libelle' => (string) $entry['motif_libelle'],
'motif_actif' => (int) $entry['motif_actif'] === 1,
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
];
}, $this->templateModel->entries((int) $templateId));
JsonResponse::send(200, [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'updated_at' => (string) $template['date_modification'],
],
'entries' => $entries,
]);
}
/**
* POST api/save_week_template_entries.php — enregistre un modèle composé
* directement dans l'éditeur (V7.10).
*
* Les listes de motifs et de lieux autorisés sont calculées ICI puis
* transmises au modèle : celui-ci ne connaît pas les règles d'accès et se
* contente de rejeter ce qui n'est pas dans les listes reçues.
*
* Charge utile : agent_id, name, entries[], replace (bool), csrf_token.
*/
public function storeEntries(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$name = (string) ($payload['name'] ?? '');
$entries = $payload['entries'] ?? null;
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !is_array($entries)) {
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
$allowedStructureIds = array_map(
static fn (array $structure): int => (int) $structure['id_structure'],
(new StructureModel($this->pdo))->allActive()
);
try {
$template = $this->templateModel->saveFromEntries(
(int) $agentId,
$name,
$entries,
$allowedMotifIds,
$allowedStructureIds,
$replace
);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer la semaine type.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
],
'message' => sprintf(
'La semaine type « %s » a été enregistrée avec %d plage%s.',
$template['nom'],
$template['nombre_creneaux'],
$template['nombre_creneaux'] > 1 ? 's' : ''
),
]);
}
/**
* POST api/save_week_template.php — capture une semaine planifiée en modèle.
*
* Point de départ historique : l'utilisateur saisit une semaine « normale »,
* puis la fige comme modèle réutilisable.
*
* Charge utile : agent_id, week, name, replace (bool), csrf_token.
*/
public function store(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$week = $this->parseWeek(trim((string) ($payload['week'] ?? '')));
$name = trim((string) ($payload['name'] ?? ''));
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || $week === null) {
JsonResponse::send(422, ['error' => 'Agent ou semaine invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
[$year, $weekNumber] = $week;
$template = $this->templateModel->saveFromWeek((int) $agentId, $year, $weekNumber, $name, $replace);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'template' => $template,
'message' => sprintf('La semaine type « %s » a été enregistrée avec %d créneau%s.', $template['nom'], $template['nombre_creneaux'], $template['nombre_creneaux'] > 1 ? 'x' : ''),
]);
}
/**
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
*
* Deux modes : `merge` complète les créneaux existants, `replace` les
* remplace. Appliquer sur une semaine validée la repasse en brouillon
* (`requires_revalidation`).
*
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
*/
public function apply(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$week = $this->parseWeek(trim((string) ($payload['week'] ?? '')));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
[$year, $weekNumber] = $week;
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’importer la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
$message = sprintf(
'La semaine type « %s » a été appliquée : %d créneau%s importé%s.',
$result['template']['nom'],
$result['inserted'],
$result['inserted'] > 1 ? 'x' : '',
$result['inserted'] > 1 ? 's' : ''
);
if ($reopened) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi(
(int) $agentId,
(new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d')
),
]);
}
/**
* POST api/preview_week_template_period.php — simulation SANS écriture.
*
* Étape obligatoire dans l'interface avant applyPeriod() : une application
* sur période touche des dizaines de semaines et peut rouvrir des plannings
* validés. L'utilisateur doit voir le volume avant de confirmer.
*
* `period_filter` restreint le périmètre au calendrier scolaire :
* all → toutes les semaines de l'intervalle ;
* school → semaines scolaires seulement (périscolaire) ;
* extra → vacances scolaires seulement (extrascolaire).
*
* Charge utile : template_id, agent_id, start_date, end_date, mode,
* period_filter, csrf_token.
*/
public function previewPeriod(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$startDate = trim((string) ($payload['start_date'] ?? ''));
$endDate = trim((string) ($payload['end_date'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all')));
if (
!$templateId
|| !$agentId
|| $startDate === ''
|| $endDate === ''
|| !in_array($mode, ['merge', 'replace'], true)
|| !in_array($periodFilter, ['all', 'school', 'extra'], true)
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$preview = $this->templateModel->previewPeriod(
(int) $templateId,
(int) $agentId,
$startDate,
$endDate,
$mode,
$periodFilter
);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de prévisualiser l’application de la semaine type.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
// Les créneaux détaillés sont utiles au modèle mais pas nécessaires au navigateur.
unset($preview['target_entries']);
JsonResponse::send(200, [
'success' => true,
'preview' => $preview,
]);
}
/**
* POST api/apply_week_template_period.php — applique un modèle sur une période.
*
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
*
* Les quotas sont renvoyés indexés par date de début de période : une
* application à cheval sur deux années civiles touche DEUX quotas annuels
* distincts, et l'interface doit rafraîchir les deux.
*/
public function applyPeriod(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$startDate = trim((string) ($payload['start_date'] ?? ''));
$endDate = trim((string) ($payload['end_date'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$periodFilter = strtolower(trim((string) ($payload['period_filter'] ?? 'all')));
if (
!$templateId
|| !$agentId
|| $startDate === ''
|| $endDate === ''
|| !in_array($mode, ['merge', 'replace'], true)
|| !in_array($periodFilter, ['all', 'school', 'extra'], true)
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$result = $this->templateModel->applyToPeriod(
(int) $templateId,
(int) $agentId,
$startDate,
$endDate,
$mode,
$periodFilter
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’appliquer la semaine type sur la période.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = sprintf(
'La semaine type « %s » a été appliquée du %s au %s : %d créneau%s créé%s sur %d jour%s et %d semaine%s.',
$result['template']['name'],
$result['start_date'],
$result['end_date'],
$result['inserted'],
$result['inserted'] > 1 ? 'x' : '',
$result['inserted'] > 1 ? 's' : '',
$result['days_touched'],
$result['days_touched'] > 1 ? 's' : '',
$result['weeks_touched'],
$result['weeks_touched'] > 1 ? 's' : ''
);
$scopeLabel = match ($result['period_filter']) {
'school' => 'uniquement sur les périodes scolaires',
'extra' => 'uniquement sur les périodes extrascolaires',
default => 'sur toutes les semaines de la période',
};
$message .= ' Périmètre : ' . $scopeLabel . '.';
if ($result['skipped_count'] > 0) {
$message .= sprintf(
' %d créneau%s ignoré%s pour respecter les périodes scolaires/vacances.',
$result['skipped_count'],
$result['skipped_count'] > 1 ? 'x' : '',
$result['skipped_count'] > 1 ? 's' : ''
);
}
if ($result['deleted'] > 0) {
$message .= sprintf(' %d ancien%s créneau%s remplacé%s.', $result['deleted'], $result['deleted'] > 1 ? 's' : '', $result['deleted'] > 1 ? 'x' : '', $result['deleted'] > 1 ? 's' : '');
}
if ($result['reopened_planning_ids'] !== []) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
$quotas = [];
foreach (array_unique([(string) $result['start_date'], (string) $result['end_date']]) as $contextDate) {
$quota = $this->quotaModel->forDateForApi((int) $agentId, $contextDate);
$quotas[(string) $quota['date_debut_periode']] = $quota;
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'result' => $result,
'requires_revalidation' => $result['reopened_planning_ids'] !== [],
'quotas' => $quotas,
]);
}
/**
* POST api/delete_week_template.php — supprime une semaine type.
*
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
* patron, pas un lien vivant vers les plannings produits.
*/
public function destroy(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$templateId = filter_var($payload['template_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$templateId || !$agentId) {
JsonResponse::send(422, ['error' => 'Semaine type ou agent invalide.']);
}
if (!$this->templateModel->delete((int) $templateId, (int) $agentId)) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
}
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
}
/**
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
*
* @return array{0: int, 1: int}|null [année ISO, 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];
return $week >= 1 && $week <= 53 ? [$year, $week] : null;
}
}