Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -20,6 +20,18 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController
{
private AgentModel $agentModel;
@@ -35,6 +47,13 @@ final class AgentController
$this->access = new Access($pdo);
}
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void
{
$this->access->requireDraftEditor();
@@ -45,6 +64,15 @@ final class AgentController
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
}
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -73,6 +101,21 @@ final class AgentController
]);
}
/**
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
*
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
* `in_month` de chaque jour permet à l'interface de griser les débordements.
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
* hebdomadaires affichés seraient faux.
*
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
* compris, pour que les totaux de semaine restent justes.
*
* Paramètres GET : agent_id, month (« 2026-02 »).
*/
public function monthOverview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -149,6 +192,16 @@ final class AgentController
]);
}
/**
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
*
* La période couverte n'est pas choisie par l'appelant : elle est celle
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
* n'est qu'un raccourci vers le 1er janvier correspondant.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function timeSummaryPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -194,6 +247,15 @@ final class AgentController
);
}
/**
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
*
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
* sur page suivante.
*
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
*/
public function pdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -257,6 +319,14 @@ final class AgentController
);
}
/**
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
*
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
* détail des créneaux planifiés.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function hoursContractPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -296,6 +366,19 @@ final class AgentController
);
}
/**
* api/agent_assignments.php — rattachement principal d'un agent.
*
* Endpoint à double verbe, exception au reste de l'API : GET liste les
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
*
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
*
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
*/
public function assignments(): void
{
$this->access->requireService();
@@ -338,6 +421,19 @@ final class AgentController
]);
}
/**
* POST api/create_agent.php — création d'un agent.
*
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
* poste existant en base, type de contrat dans l'énumération, dates
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
* avec l'existant, pas une saisie malformée).
*
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
* l'insère dans sa liste sans avoir à recharger toute la page.
*/
public function create(): void
{
$this->access->requireService();
@@ -411,6 +507,10 @@ final class AgentController
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
@@ -444,6 +544,16 @@ final class AgentController
]);
}
/**
* POST api/update_agent.php — modification de la fiche d'un agent.
*
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
* modifiable, c'est la clé de rapprochement avec les outils RH.
*
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
* rétroactivement tous les suivis.
*/
public function updateProfile(): void
{
$this->access->requireService();
@@ -523,6 +633,12 @@ final class AgentController
]);
}
/**
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
* pas de base commune ; toute correction doit être reportée partout.
*
* @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)) {

View File

@@ -14,12 +14,28 @@ use App\Models\QuotaModel;
use PDO;
use Throwable;
/**
* Vue annuelle d'un agent et validation de l'année en une opération.
*
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
* s'apprécie le quota annualisé (voir QuotaModel).
*
* Réservé au Service Enfance : valider une année couvre par nature tous les
* lieux où l'agent est intervenu.
*/
final class AnnualOverviewController
{
public function __construct(private PDO $pdo)
{
}
/**
* GET api/annual_overview.php — répartition de l'année d'un agent.
*
* L'année est facultative et retombe sur l'année en cours.
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
*/
public function overview(): void
{
(new Access($this->pdo))->requireService();
@@ -42,12 +58,27 @@ final class AnnualOverviewController
}
/**
* POST api/annual_validate.php — contrôle puis validation de toute l'année.
*
* Contrôle puis validation en une seule opération de tous les plannings
* en brouillon de l'agent pour l'année civile affichée.
*
* Aucune validation partielle : si une erreur est trouvée, aucun planning
* n'est validé. Les semaines concernées sont retournées au front afin de
* pouvoir les ouvrir dans un nouvel onglet et les corriger.
* pouvoir les ouvrir dans un nouvel onglet et les corriger. Un succès
* partiel laisserait l'utilisateur devant une année à moitié validée, sans
* moyen simple de savoir ce qui reste.
*
* Trois issues, alignées sur PlanningController::validate() :
* - 422 + status « errors » : au moins une ERREUR, rien n'est validé ;
* - 409 + status « warning » : quota annuel dépassé, confirmation attendue
* par un rappel avec force_quota: true ;
* - 200 + status « validated » ou « nothing_to_validate ».
*
* Contrairement à validate() unitaire, le quota est ici évalué à l'échelle
* de l'ANNÉE (annualForApi) et non de la semaine.
*
* Charge utile : agent_id, year, force_quota (bool), csrf_token.
*/
public function validateYear(): void
{
@@ -153,6 +184,24 @@ final class AnnualOverviewController
}
}
/**
* Contrôles bloquants d'un planning, dans la variante « année ».
*
* DIFFÉRENCE VOULUE avec PlanningController::validate() : les chevauchements
* inter-lieux sont recherchés via externalOverlaps() (tous statuts) et non
* validatedExternalOverlaps() (validés seulement). Une validation annuelle
* traite en un bloc l'ensemble des brouillons de l'agent : ils vont tous
* devenir opposables, deux brouillons qui se recouvrent doivent donc être
* signalés dès maintenant.
*
* Un contrôle INFO est ajouté quand rien n'a été trouvé, pour que
* l'interface affiche « aucune anomalie » plutôt qu'une liste vide
* indistincte d'une absence de contrôle.
*
* @param array<string, mixed> $planning
*
* @return array<int, array{niveau: string, code: string, message: string}>
*/
private function controlsForPlanning(PlanningModel $model, array $planning): array
{
$planningId = (int) $planning['id_planning'];
@@ -203,6 +252,13 @@ final class AnnualOverviewController
return $controls;
}
/**
* Empile un contrôle au format commun {niveau, code, message}.
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
* pas de base commune.
*
* @param array<int, array{niveau: string, code: string, message: string}> $controls
*/
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

@@ -13,6 +13,19 @@ use App\Models\StructureModel;
use PDO;
use Throwable;
/**
* Contrôle de couverture : effectif planifié comparé au minimum attendu.
*
* Chaque lieu peut déclarer des règles de couverture (jour, plage horaire,
* nombre minimum d'agents). Ce contrôleur confronte ces règles aux plannings
* réels et remonte les « trous ».
*
* Deux niveaux de droits distincts :
* - alerts() : responsable et Service Enfance, un responsable étant forcé sur
* son propre lieu quoi qu'il demande ;
* - rules() / saveRules() : Service Enfance seul, les règles étant un
* référentiel et non une donnée de saisie.
*/
final class CoverageController
{
private CoverageModel $coverageModel;
@@ -26,6 +39,22 @@ final class CoverageController
$this->access = new Access($pdo);
}
/**
* GET api/coverage_alerts.php — trous de couverture d'une semaine.
*
* Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé
* est ignoré pour un responsable, écrasé par son propre lieu : un simple 403
* l'empêcherait de consulter son écran avec une URL partagée.
*
* Trois familles de retours, à ne pas confondre :
* - `alerts` : plages réellement sous-couvertes ;
* - `validation_warnings` : couverture assurée par des plannings encore en
* brouillon, donc pas encore fiable ;
* - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas
* produire d'alerte, et les taire les ferait passer pour conformes.
*
* Paramètres GET : week, structure_id (facultatif).
*/
public function alerts(): void
{
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
@@ -100,6 +129,9 @@ final class CoverageController
]);
}
/**
* GET api/structure_coverage_rules.php — règles de couverture d'un lieu.
*/
public function rules(): void
{
$this->access->requireService();
@@ -119,6 +151,19 @@ final class CoverageController
]);
}
/**
* POST api/save_structure_coverage_rules.php — remplace les règles d'un lieu.
*
* Remplacement INTÉGRAL : la liste reçue devient l'unique jeu de règles du
* lieu, et une liste vide supprime toute règle (le lieu redevient « non
* configuré »). Il n'y a pas d'ajout ni de suppression unitaire.
*
* Les règles sont validées ici — jour ouvré, horaires au quart d'heure, fin
* après début, minimum dans [1, 50], puis absence de chevauchement — avant
* d'être confiées au modèle, qui n'effectue aucun contrôle métier.
*
* Charge utile : structure_id, rules[], csrf_token.
*/
public function saveRules(): void
{
$this->access->requireService();
@@ -182,6 +227,15 @@ final class CoverageController
]);
}
/**
* Refuse deux plages de couverture qui se recouvrent le même jour.
*
* Les règles sont regroupées par jour puis triées : il suffit alors de
* comparer chaque début à la fin précédente, en O(n log n) et non en O(n²).
* Des plages jointives sont acceptées (une fin égale à un début).
*
* @param array<int, array{jour_semaine: int, heure_debut: string, heure_fin: string}> $rules
*/
private function assertNoOverlap(array $rules): void
{
$byDay = [];
@@ -203,6 +257,7 @@ final class CoverageController
}
}
/** HH:MM au quart d'heure, entre 00:00 et 23:45. */
private function validQuarterHour(string $time): bool
{
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
@@ -211,17 +266,24 @@ final class CoverageController
return true;
}
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
/** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */
private function durationMinutes(string $start, string $end): int
{
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
}
/**
* 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)) {

View File

@@ -25,6 +25,8 @@ use PDO;
*/
final class DashboardController
{
/** Nombre d'éléments détaillés par bloc : un aperçu, pas une liste complète.
* Chaque bloc renvoie vers l'écran dédié pour la suite. */
private const APERCU_MAX = 5;
private const QUOTA_APERCU_MAX = 8;
@@ -45,6 +47,19 @@ final class DashboardController
$this->vacationModel = new VacationModel($pdo);
}
/**
* GET api/dashboard_summary.php — données du tableau de bord.
*
* Deux portées :
* - scope=badges : uniquement le compteur de plannings en attente. Cette
* variante est appelée depuis TOUTES les pages pour alimenter la pastille
* de navigation ; elle doit rester à une seule requête SQL ;
* - portée complète : les quatre blocs de l'écran, ce qui représente
* plusieurs dizaines de requêtes et n'est acceptable que sur le tableau
* de bord lui-même.
*
* Paramètres GET : scope (badges|absent), week, year (facultatifs).
*/
public function summary(): void
{
$this->access->requireService();
@@ -264,6 +279,12 @@ final class DashboardController
}
/**
* Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante.
*
* Contrairement aux endpoints de saisie, une semaine illisible n'est pas une
* erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer
* la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC.
*
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
*/
private function resolveWeek(mixed $value): array
@@ -294,6 +315,9 @@ final class DashboardController
];
}
/**
* Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée.
*/
private function resolveYear(mixed $value, int $fallback): int
{
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);

View File

@@ -15,6 +15,27 @@ use DateTimeImmutable;
use DateTimeZone;
use PDO;
/**
* Rendu de toutes les pages HTML de l'application.
*
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
* de $activePage qui surligne l'onglet courant.
*
* Ajouter une page suppose donc, ensemble :
* 1. un fichier mince à la racine ;
* 2. une méthode ici ;
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
* 4. un module JS ;
* 5. l'inscription de la clé dans $navigation / $navigationGroups
* (Views/layouts/header.php) ;
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
*
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
* injecte et page-bootstrap.js appelle init() sur chaque module.
*/
final class PageController extends Controller
{
private Access $access;
@@ -24,6 +45,10 @@ final class PageController extends Controller
$this->access = new Access($pdo);
}
/**
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
*/
public function dashboard(): void
{
$this->access->requirePage('dashboard');
@@ -38,6 +63,20 @@ final class PageController extends Controller
]);
}
/**
* Écran de saisie — cœur de l'application.
*
* Le lieu est VERROUILLÉ pour un responsable de structure
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
* qu'un confort d'interface, le serveur retranche de toute façon.
*
* Les scripts de semaine type ne sont chargés que si la permission
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
* (éditeur de semaine type / saisie semaine par semaine, voir
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
* les paramètres des liens profonds venus des autres écrans.
*/
public function planning(): void
{
$this->access->requirePage('planning');
@@ -71,6 +110,7 @@ final class PageController extends Controller
]);
}
/** File des plannings en attente de validation (Service Enfance). */
public function pending(): void
{
$this->access->requirePage('pending');
@@ -81,6 +121,7 @@ final class PageController extends Controller
]);
}
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
public function coverage(): void
{
$this->access->requirePage('coverage');
@@ -98,6 +139,7 @@ final class PageController extends Controller
]);
}
/** Planning d'un lieu sur une semaine, en lecture seule. */
public function structures(): void
{
$this->access->requirePage('structures');
@@ -115,6 +157,12 @@ final class PageController extends Controller
]);
}
/**
* Planning d'un agent, mois par mois et tous lieux confondus.
*
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
* sur « Mon planning » : la page sert alors de vue personnelle.
*/
public function agents(): void
{
$this->access->requirePage('agents');
@@ -136,6 +184,7 @@ final class PageController extends Controller
]);
}
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
public function annual(): void
{
$this->access->requirePage('annual');
@@ -151,6 +200,7 @@ final class PageController extends Controller
]);
}
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
public function pta(): void
{
$this->access->requirePage('pta');
@@ -167,6 +217,7 @@ final class PageController extends Controller
]);
}
/** Composition des équipes sur les mercredis et les vacances. */
public function teams(): void
{
$this->access->requirePage('teams');
@@ -183,6 +234,11 @@ final class PageController extends Controller
]);
}
/**
* Périodes de vacances scolaires : elles déterminent le contexte
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
* l'API data.education.gouv.fr (SchoolHolidayApiService).
*/
public function vacations(): void
{
$this->access->requirePage('vacations');
@@ -193,6 +249,7 @@ final class PageController extends Controller
]);
}
/** Référentiels : agents, lieux, postes, règles de couverture. */
public function administration(): void
{
$this->access->requirePage('administration');
@@ -220,6 +277,21 @@ final class PageController extends Controller
]);
}
/**
* Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu.
*
* Pose les clés attendues par header.php et footer.php avec des valeurs par
* défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra
* donc correctement au lieu de lever une notice sur variable indéfinie.
*
* Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la
* CSP à nonce, et $cspNonce est ce que chaque <script> inline d'une vue doit
* porter pour être exécuté.
*
* @param string $view Vue de pages/, sans extension.
* @param string $activePage Clé de l'onglet à surligner.
* @param array<string, mixed> $data Surcharges spécifiques à la page.
*/
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{
Security::sendHtmlHeaders();
@@ -243,6 +315,15 @@ final class PageController extends Controller
], $data));
}
/**
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
*
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
* inaffichable dans la liste déroulante.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructures(): array
{
$model = new StructureModel($this->pdo);
@@ -257,6 +338,15 @@ final class PageController extends Controller
return $structure ? [$structure] : [];
}
/**
* Variante pour l'éditeur de la fiche agent.
*
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
* Enfance a donc besoin de la liste complète même s'il consulte un agent
* rattaché ailleurs.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructuresForAgentEditor(): array
{
if ($this->access->role() === Access::ROLE_SERVICE) {
@@ -265,6 +355,13 @@ final class PageController extends Controller
return $this->loadStructures();
}
/**
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
* inclut les agents rattachés ailleurs mais planifiés sur place.)
*
* @return array<int, array<string, mixed>>
*/
private function loadAgents(): array
{
$model = new AgentModel($this->pdo);
@@ -279,28 +376,53 @@ final class PageController extends Controller
return $structureId ? $model->allVisibleForStructure($structureId) : [];
}
/**
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
* refuse (PlanningController::assertMotifsAllowedForAgent()).
*
* @return array<int, array<string, mixed>>
*/
private function loadMotifs(): array
{
return (new MotifModel($this->pdo))->allActive();
}
/**
* Semaine courante au format « 2026-W08 ».
*
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
* quand la semaine ISO appartient à l'année civile voisine.
* Fuseau explicite : le conteneur PHP tourne en UTC.
*/
private function currentWeek(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
}
// ---------------------------------------------------------------------
// Assainissement des paramètres d'URL. Ces valeurs partent dans
// window.PTA_CONFIG et pilotent les filtres appliqués par
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
// ---------------------------------------------------------------------
/** Identifiant d'entité : strictement positif, ou null. */
private function positiveInt(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $number === false ? null : (int) $number;
}
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
private function weekValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
}
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function dateValue(mixed $value): ?string
{
$value = trim((string) $value);
@@ -308,12 +430,14 @@ final class PageController extends Controller
return $date && $date->format('Y-m-d') === $value ? $value : null;
}
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
private function yearValue(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $number === false ? null : (int) $number;
}
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
private function monthValue(mixed $value): ?string
{
$value = trim((string) $value);

View File

@@ -20,6 +20,26 @@ use DomainException;
use PDO;
use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController
{
private PlanningModel $planningModel;
@@ -30,6 +50,10 @@ final class PlanningController
private VacationModel $vacationModel;
private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo)
{
$this->planningModel = new PlanningModel($pdo);
@@ -41,6 +65,16 @@ final class PlanningController
$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();
@@ -79,6 +113,23 @@ final class PlanningController
]);
}
/**
* 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();
@@ -130,6 +181,12 @@ final class PlanningController
} 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,
@@ -147,6 +204,17 @@ final class PlanningController
]);
}
/**
* 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();
@@ -183,10 +251,16 @@ final class PlanningController
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.']);
}
@@ -221,6 +295,16 @@ final class PlanningController
}
/**
* 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();
@@ -263,6 +347,16 @@ final class PlanningController
}
/**
* 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();
@@ -325,6 +419,18 @@ final class PlanningController
]);
}
/**
* 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();
@@ -384,6 +490,14 @@ final class PlanningController
]);
}
/**
* 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();
@@ -407,6 +521,31 @@ final class PlanningController
]);
}
/**
* 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();
@@ -513,6 +652,17 @@ final class PlanningController
]);
}
/**
* 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();
@@ -545,6 +695,14 @@ final class PlanningController
]);
}
/**
* 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)) {
@@ -602,6 +760,22 @@ final class PlanningController
}
}
/**
* 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<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -636,6 +810,15 @@ final class PlanningController
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<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -659,6 +842,20 @@ final class PlanningController
}
}
/**
* 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<int, array{date: string, start: string, end: string}> $entries
* @param array<int, array<string, mixed>> $existingEntries
*/
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{
foreach ($entries as $entry) {
@@ -689,6 +886,12 @@ final class PlanningController
}
}
/**
* Empile un contrôle au format commun {niveau, code, message}.
*
* @param array<int, array{niveau: string, code: string, message: string}> $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];

View File

@@ -15,6 +15,21 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* PTA détaillé d'un agent : contraintes prioritaires et souhaits de lieux.
*
* Ces deux référentiels décrivent ce qui DOIT ou NE DOIT PAS être planifié pour
* un agent. Ils sont consultatifs : rien ici n'empêche techniquement une saisie
* contraire, l'arbitrage reste à l'agent de planification.
*
* Les deux endpoints d'écriture sont à double action, pilotée par le champ
* `action` de la charge utile (« SAVE » par défaut, ou « DELETE ») : c'est un
* écart au reste de l'API, où un endpoint = une opération. La branche DELETE
* sort par JsonResponse::send(), qui termine le script — le code de SAVE qui
* suit n'est donc jamais atteint dans ce cas.
*
* Réservé au Service Enfance.
*/
final class PtaController
{
private PtaModel $ptaModel;
@@ -30,6 +45,15 @@ final class PtaController
$this->access = new Access($pdo);
}
/**
* GET api/pta_summary.php — synthèse du PTA d'un agent à une date donnée.
*
* La date sert de point d'observation : elle détermine la période de quota
* retenue et les contraintes en vigueur ce jour-là. Elle retombe sur
* aujourd'hui si elle n'est pas fournie.
*
* Paramètres GET : agent_id, date (facultative, « AAAA-MM-JJ »).
*/
public function summary(): void
{
$this->access->requireService();
@@ -50,6 +74,24 @@ final class PtaController
}
}
/**
* POST api/pta_constraint.php — enregistre ou supprime une contrainte.
*
* Deux types seulement, volontairement fermés : MEDICALE et
* TEMPS_PARTIEL_THERAPEUTIQUE. Une date de fin vide signifie « sans terme
* connu », cas courant d'un aménagement médical en cours.
*
* Le commentaire est OBLIGATOIRE : une contrainte restreint la planification
* d'un agent, elle doit rester justifiable a posteriori.
*
* Les maximums arrivent en HEURES depuis le formulaire et sont convertis en
* MINUTES pour la base, unité de tous les cumuls de l'application.
*
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
* l'action constraint_id, ou type_contrainte / date_debut / date_fin /
* commentaire / quotite_temporaire / maximum_heures_jour /
* maximum_heures_semaine / interdit_matin|midi|soir.
*/
public function constraint(): void
{
$this->access->requireService();
@@ -115,10 +157,26 @@ final class PtaController
]);
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
} catch (Throwable $e) {
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
// navigateur SANS AUCUNE condition de debug, contrairement au reste
// du projet. Idem dans wish(). À aligner sur
// Security::debugEnabled() — voir la note de PlanningController::save().
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]);
}
}
/**
* POST api/pta_wish.php — enregistre ou supprime un souhait de lieu.
*
* Deux types : PREFERENCE (l'agent souhaite y être affecté) et INTERDICTION
* (il ne doit pas l'être). La priorité, de 1 à 5, permet d'ordonner
* plusieurs préférences entre elles ; la distance en kilomètres sert à
* arbitrer les affectations éloignées.
*
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
* l'action wish_id, ou structure_id / type_souhait / priorite /
* distance_km / commentaire.
*/
public function wish(): void
{
$this->access->requireService();

View File

@@ -12,12 +12,41 @@ use App\Models\AgentModel;
use App\Models\StructureModel;
use PDO;
/**
* Sélection du rôle et de l'identité de travail.
*
* ATTENTION : ce n'est PAS une authentification. L'utilisateur choisit
* librement qui il est, sans mot de passe ni vérification. C'est la faille
* ouverte la plus importante du projet, assumée pour cette version. Un passage
* en production doit remplacer cet écran par une authentification réelle — le
* reste de la mécanique de droits (Access, vues SQL) n'a pas à bouger, il
* suffit que la session soit renseignée par un compte vérifié.
*
* Contrairement aux autres pages, ce contrôleur n'est pas appelé par runPage() :
* il s'exécute AVANT tout contrôle d'accès, puisqu'il est ce qui permet d'en
* avoir un.
*/
final class RoleController extends Controller
{
public function __construct(private PDO $pdo)
{
}
/**
* Affiche le sélecteur et traite sa soumission.
*
* Trois chemins dans une seule méthode, faute de routeur :
* - GET ?action=reset : abandon du rôle courant, puis redirection ;
* - POST : tentative de sélection, puis redirection vers
* l'accueil du rôle ou réaffichage avec $error ;
* - GET simple : affichage du formulaire.
*
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
* données arrivent ici en formulaire classique ($_POST / $_GET) et non en
* JSON, et un échec doit produire une page HTML, pas une réponse JSON.
* Un jeton invalide sur le reset est ignoré EN SILENCE : l'utilisateur est
* simplement renvoyé au sélecteur, sans message d'échec inutile.
*/
public function index(): void
{
$access = new Access($this->pdo);

View File

@@ -17,6 +17,15 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
*
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
*
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
* modification réservées au Service Enfance.
*/
final class StructureController
{
private StructureModel $structureModel;
@@ -32,6 +41,19 @@ final class StructureController
$this->access = new Access($pdo);
}
/**
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
*
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
*
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
* agent : une seule lecture, puis regroupement en mémoire.
*
* Paramètres GET : structure_id, week.
*/
public function overview(): void
{
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
@@ -79,6 +101,16 @@ final class StructureController
]);
}
/**
* GET api/structure_planning_pdf.php — même vue, au format PDF.
*
* Reprend volontairement le calcul de overview() plutôt que de le
* factoriser : les deux méthodes doivent rester alignées, toute évolution
* de l'une appelle la même dans l'autre.
*
* Endpoint PDF : les erreurs sortent en texte brut avec un code HTTP, jamais
* en JSON (voir AgentController).
*/
public function pdf(): void
{
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
@@ -131,6 +163,20 @@ final class StructureController
);
}
/**
* POST api/create_structure.php — création d'un lieu d'affectation.
*
* Le code est normalisé avant contrôle : espaces remplacés par des
* underscores, puis passage en majuscules. Il sert d'identifiant lisible
* (nom de fichier PDF, exports), d'où le format restreint à
* [A-Z0-9_-]{2,50}. Un doublon ressort en 409, pas en 422 : c'est un conflit
* avec l'existant.
*
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) détermine le contexte
* par défaut du lieu ; il reste croisé avec le calendrier des vacances.
*
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
*/
public function create(): void
{
$this->access->requireService();
@@ -175,6 +221,14 @@ final class StructureController
]);
}
/**
* POST api/update_structure.php — change le type d'un lieu.
*
* Seul le type est modifiable : le code sert de référence stable, et le nom
* n'est pas exposé à l'édition dans cette version.
*
* Charge utile : structure_id, type_affectation, csrf_token.
*/
public function updateType(): void
{
$this->access->requireService();
@@ -214,6 +268,11 @@ final class StructureController
]);
}
/**
* 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)) {

View File

@@ -15,6 +15,21 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Équipes des mercredis et des vacances.
*
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
* pouvant évoluer.
*
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
* BROUILLON → A_CONTROLER → VALIDEE.
*
* Réservé au Service Enfance.
*/
final class TeamController
{
private TeamModel $teamModel;
@@ -30,6 +45,21 @@ final class TeamController
$this->access = new Access($pdo);
}
/**
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
*
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
* actives sur une période.
*
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
* ratio à zéro provoquerait une division par zéro au calcul du taux
* d'encadrement.
*
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
* date_fin, effectifs et ratios facultatifs, csrf_token.
*/
public function index(): void
{
$this->access->requireService();
@@ -90,6 +120,20 @@ final class TeamController
}
}
/**
* POST api/team_members.php — ajoute ou retire un agent d'une équipe.
*
* `action` vaut ADD (défaut) ou REMOVE. Trois fonctions possibles :
* RESPONSABLE, ANIMATION, RESTAURATION_ENTRETIEN — elles conditionnent le
* calcul du taux d'encadrement.
*
* L'exception d'ajout ressort en 422 avec SON message : le modèle y place
* les refus métier lisibles (agent déjà membre, par exemple), destinés à
* l'utilisateur.
*
* Charge utile : action, team_id, agent_id, fonction_equipe, commentaire,
* csrf_token.
*/
public function member(): void
{
$this->access->requireService();
@@ -127,6 +171,15 @@ final class TeamController
}
}
/**
* POST api/team_status.php — fait avancer une équipe dans son cycle de vie.
*
* Aucune transition n'est imposée : tout statut valide est acceptable depuis
* n'importe quel autre, y compris un retour en arrière. Le suivi est
* indicatif, pas un verrou.
*
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
*/
public function status(): void
{
$this->access->requireService();

View File

@@ -14,6 +14,22 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Périodes de vacances scolaires.
*
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
* journée, qui conditionne à son tour les motifs pertinents et l'application
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
* simple calendrier d'affichage.
*
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
* humain l'ait relue — les libellés et découpages de l'API varient d'une
* académie à l'autre.
*
* Réservé au Service Enfance.
*/
final class VacationController
{
private VacationModel $vacationModel;
@@ -25,6 +41,10 @@ final class VacationController
$this->access = new Access($pdo);
}
/**
* GET api/vacation_periods.php — périodes enregistrées.
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
*/
public function index(): void
{
$this->access->requireService();
@@ -38,6 +58,19 @@ final class VacationController
]);
}
/**
* GET api/preview_school_holidays.php — propositions de l'API officielle,
* SANS aucune écriture en base.
*
* Le 502 (et non 500) en cas d'échec dit ce qui s'est passé : c'est le
* service distant qui n'a pas répondu, pas PTA qui a échoué. Le conteneur
* php a besoin d'un accès réseau sortant pour cet appel.
*
* Les `warnings` signalent les périodes que le service n'a pas su
* interpréter ; elles sont montrées à l'utilisateur plutôt que tues.
*
* Paramètres GET : academy, year (année civile).
*/
public function preview(): void
{
$this->access->requireService();
@@ -78,6 +111,21 @@ final class VacationController
]);
}
/**
* POST api/save_vacation_periods.php — enregistre les périodes retenues.
*
* Le plafond de 50 périodes correspond à un large excédent d'une année
* scolaire complète ; il borne le coût d'un envoi malformé ou répété.
*
* Les libellés, académies et zones sont tronqués avec les fonctions mb_* aux
* longueurs des colonnes : une troncature en octets couperait un caractère
* accentué en deux et produirait de l'UTF-8 invalide en base.
*
* `source` distingue MANUEL de DATA_GOUV pour tracer l'origine ; une valeur
* inconnue retombe sur MANUEL au lieu de faire échouer l'enregistrement.
*
* Charge utile : periods[], csrf_token.
*/
public function save(): void
{
$this->access->requireService();
@@ -136,6 +184,15 @@ final class VacationController
]);
}
/**
* POST api/delete_vacation_period.php — supprime une période.
*
* Aucun contrôle d'usage : supprimer une période change le contexte
* scolaire/extrascolaire des journées concernées, mais ne touche aucun
* créneau déjà planifié.
*
* Charge utile : period_id, csrf_token.
*/
public function delete(): void
{
$this->access->requireService();
@@ -154,12 +211,17 @@ final class VacationController
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
}
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function isDate(string $value): bool
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value;
}
/**
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
* immédiatement la première : « 2026-2028 » est refusé.
*/
private function isSchoolYear(string $value): bool
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {

View File

@@ -19,6 +19,28 @@ 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;
@@ -34,6 +56,12 @@ final class WeekTemplateController
$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();
@@ -59,7 +87,16 @@ final class WeekTemplateController
}
/**
* Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur.
* 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
{
@@ -103,7 +140,14 @@ final class WeekTemplateController
}
/**
* Enregistre une semaine type composée dans l'éditeur.
* 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
{
@@ -172,6 +216,14 @@ final class WeekTemplateController
]);
}
/**
* 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();
@@ -214,6 +266,15 @@ final class WeekTemplateController
]);
}
/**
* 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();
@@ -268,6 +329,21 @@ final class WeekTemplateController
]);
}
/**
* 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();
@@ -323,6 +399,18 @@ final class WeekTemplateController
]);
}
/**
* 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();
@@ -420,6 +508,14 @@ final class WeekTemplateController
]);
}
/**
* 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();
@@ -440,6 +536,11 @@ final class WeekTemplateController
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)) {