Files
pta/docs/README.md
2026-08-25 15:12:11 +02:00

1252 lines
66 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# PTA — Planning de Travail Annualisé
Documentation technique de l'application. Elle s'adresse aux développeurs qui reprennent le projet :
elle explique ce que fait l'application, comment elle est construite, et documente l'intégralité des
points d'entrée HTTP.
> Les fichiers `README_V7_*.md` de ce dossier documentent chacun **une version successive** et
> servent d'historique des décisions. Ce document-ci décrit l'**état courant** du code. En cas de
> divergence, seul le code fait foi.
---
## Sommaire
1. [Ce que fait l'application](#1-ce-que-fait-lapplication)
2. [Contraintes techniques structurantes](#2-contraintes-techniques-structurantes)
3. [Démarrage](#3-démarrage)
4. [Architecture](#4-architecture)
5. [Rôles, authentification et droits](#5-rôles-authentification-et-droits)
6. [Journalisation](#6-journalisation)
7. [Sécurité — contraintes à respecter](#7-sécurité--contraintes-à-respecter)
8. [JavaScript](#8-javascript)
9. [Modèle métier](#9-modèle-métier)
10. [Piège : logique de validation dupliquée](#10-️-piège--logique-de-validation-dupliquée)
11. [Les pages](#11-les-pages)
12. [**Référence des endpoints API**](#12-référence-des-endpoints-api)
· [Planning](#121-planning--saisie-et-validation)
· [Semaines types](#122-semaines-types)
· [Agents](#123-agents)
· [PDF](#124-pdf)
· [Lieux & couverture](#125-lieux-daffectation-et-couverture)
· [Annuel & PTA](#126-vue-annuelle-et-pta)
· [Équipes](#127-équipes)
· [Vacances](#128-vacances-scolaires)
· [Tableau de bord](#129-tableau-de-bord-et-recherche-dadresse)
· [Comptes délégués](#1210-comptes-daccès-délégués--service-enfance-v727)
· [Espace DSI](#1211-espace-dsi--comptes-privilégiés-et-journal-v711)
13. [Base de données](#13-base-de-données)
14. [Où chercher quoi](#14-où-chercher-quoi)
15. [Checklist avant commit](#15-checklist-avant-commit)
16. [Historique des versions](#16-historique-des-versions)
---
## 1. Ce que fait l'application
PTA gère la **planification annualisée** des agents d'accueils de loisirs périscolaires et
extrascolaires d'une collectivité.
Le principe métier : un agent ne travaille pas un nombre d'heures fixe chaque semaine, mais un
**volume annuel** (1 607 h de référence, proratisé par sa quotité de travail). Les semaines sont
donc inégales — beaucoup d'heures pendant les vacances scolaires, moins pendant le temps scolaire —
et l'application sert à composer ces semaines, à vérifier en continu que le cumul reste cohérent
avec le volume dû, et à valider le résultat.
L'application permet de :
- saisir semaine par semaine les créneaux d'un agent sur un lieu (`planning.php`) ;
- composer des **semaines types** réutilisables et les appliquer sur toute une période ;
- suivre le **quota annuel** consommé, mois par mois et sur l'année (`annual.php`, `pta.php`) ;
- contrôler la **couverture** : les plages horaires d'un lieu où il manque des agents ;
- faire **valider** les plannings par le Service Enfance, avec des contrôles bloquants ;
- composer les **équipes** des mercredis et des vacances, avec taux d'encadrement ;
- importer le calendrier des **vacances scolaires** depuis l'API officielle ;
- éditer des **PDF** (planning d'agent, planning de lieu, bilan d'heures, contrat horaire) ;
- administrer les **comptes d'accès** (jetons) et consulter le **journal d'activité**.
---
## 2. Contraintes techniques structurantes
**Aucune dépendance externe.** Pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
suite de tests. Uniquement **PHP 8.3 + PDO/MariaDB + JavaScript vanilla**. Le générateur PDF
(`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont volontairement réécrits à
la main. N'introduisez pas de dépendance sans raison forte.
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
utilisateur, commentaires, noms de méthodes métier. Conservez cette langue pour tout nouveau code.
**Pas de routeur.** Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes, à la racine ou
dans `api/`.
---
## 3. Démarrage
```bash
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build
```
L'application est alors sur `http://localhost:8080`, phpMyAdmin sur `http://127.0.0.1:8081`.
### Services docker-compose
| Service | Rôle |
|--------------|------|
| `db` | MariaDB 11.4. Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué qu'à la **création initiale du volume**. |
| `db-migrate` | Conteneur jetable qui applique les `database/migration_*.sql` à chaque démarrage. |
| `php` | php-fpm 8.3. **Attend la sortie en succès de `db-migrate`** : une migration en échec empêche l'application de tourner sur une base incomplète. |
| `nginx` | Sert les pages PHP et les assets statiques. Publié sur `APP_PORT`. |
| `phpmyadmin` | N'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. |
### ⚠️ Le code est COPIÉ dans l'image, pas monté
Toute modification PHP/JS/CSS exige un rebuild des **deux** services — nginx sert lui-même les
assets statiques depuis sa propre copie de `/var/www/html` :
```bash
docker compose up -d --build php nginx
```
### Commandes utiles
```bash
docker compose logs -f php nginx db
docker compose logs db-migrate # compte rendu de l'application des migrations
docker compose exec db mariadb -upta -p pta
docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
```
### Après une installation neuve
1. Se connecter avec le **jeton DSI d'installation** `PTA-INSTALLATION-DSI`, écrit en clair dans
`database/migration_v7_9e_dsi_comptes_journal.sql` — il est donc **public**.
2. Le régénérer immédiatement depuis la page « Accès & journaux ». L'écran alerte tant que ce n'est
pas fait.
3. Générer les jetons manquants pour équiper les agents et les responsables de lieu.
4. Changer le mot de passe MariaDB du `.env`, encore à sa valeur d'exemple.
### Migrations
Les fichiers `database/migration_*.sql` sont appliqués automatiquement par `db-migrate`
(`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante. Le script tient un
registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué et inchangé est
ignoré, un fichier modifié est **rejoué**.
Deux règles :
- **Toute migration doit être idempotente** : `CREATE TABLE IF NOT EXISTS`,
`CREATE OR REPLACE VIEW`, `INSERT … ON DUPLICATE KEY UPDATE`.
- **Les fichiers sont pris dans l'ordre alphabétique.** Nommer une migration `migration_v7_10_…` la
ferait passer *avant* `migration_v7_9_…`. C'est pourquoi les migrations récentes suivent le
préfixe de la précédente (`…_v7_9e_`, `…_v7_9f_`, `…_v7_9g_`, `…_v7_9h_`) et non le numéro de
version fonctionnelle.
> `PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du
> réseau Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte
> (`APP_PORT`, `PMA_PORT` de `.env`) : les modifier coupe l'accès à la base.
---
## 4. Architecture
### 4.1 Arborescence
```
/ points d'entrée « pages » (planning.php, agents.php, …)
├── api/ points d'entrée JSON et PDF
├── app/
│ ├── autoload.php autoloader PSR-4 maison
│ ├── bootstrap.php session + PDO + contrôle de schéma + journal
│ ├── Controllers/ logique applicative, gardes, validation
│ ├── Core/ Access, Csrf, Journal, JsonResponse, Request, Security, Session…
│ ├── Models/ SQL PDO uniquement, aucune règle d'accès
│ ├── Services/ appels d'API externes et génération PDF
│ └── Views/
│ ├── layouts/ header.php (nav) et footer.php (injection JS)
│ ├── pages/ une vue par page
│ └── partials/ markup réutilisable
├── assets/ style.css et js/ (une IIFE par module)
├── database/ schéma initial + migrations
├── docker/ Dockerfiles, conf nginx, script de migration
└── docs/ cette documentation
```
### 4.2 Deux types de points d'entrée
**Pages HTML** — fichiers à la racine :
```php
require __DIR__ . '/page_bootstrap.php';
runPage('planning'); // -> PageController::planning()
```
`page_bootstrap.php` attrape tout `Throwable` et rend une page d'erreur HTML autonome ; le détail
technique reste journalisé côté serveur par `Security::reportThrowable()`.
**Endpoints JSON/PDF** — `api/*.php`, plus `save_planning.php`, `validate_planning.php` et
`reopen_planning.php` restés à la racine pour raisons historiques :
```php
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new PlanningController($pdo))->show();
```
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** : ils émettent des
octets binaires via `PdfResponse::inline()` et sortent leurs erreurs en texte brut.
### 4.3 Chaîne d'un appel
```
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
page.php → PageController → Models → Views/pages/x.php
├── layouts/header.php (nav filtrée par rôle)
├── partials/x.php (markup)
└── layouts/footer.php (window.PTA_CONFIG + scripts)
```
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
d'environnement `PTA_DB_*`), appelle `SchemaModel::assertCurrent()` **à chaque requête** et pose la
connexion du journal.
> **Ajouter une table ou une colonne obligatoire impose de mettre à jour les listes de
> `SchemaModel`**, sinon l'application entière refuse de démarrer — ou, pire, démarre sans le
> contrôle.
### 4.4 Ajouter un endpoint
1. Créer le fichier mince dans `api/` (voir modèle ci-dessus).
2. Ajouter la méthode au contrôleur, avec **dans cet ordre** :
garde `Access` → `Request::requireMethod('POST')` → `Request::json()` →
`Csrf::assertPayload($payload)` → validation → modèle → `Journal::ecriture()` →
`JsonResponse::send()`.
### 4.5 Ajouter une page
1. Fichier PHP à la racine (`require page_bootstrap.php` + `runPage('cle')`).
2. Méthode dans `PageController` — **son nom EST la clé de page** utilisée par `acces_role_page`
(d'où `manage_agents()` en snake_case, à rebours du reste du projet).
3. Vue `app/Views/pages/cle.php` + partiel `app/Views/partials/cle.php`.
4. Module JS, déclaré dans les tableaux `scripts` / `modules` de la méthode.
5. Inscription de la clé dans `$navigation` **et** dans un groupe de `$navigationGroups`
(`app/Views/layouts/header.php`).
6. **Migration SQL** insérant la ligne dans `acces_role_page` : les pages autorisées sont lues en
base, pas écrites en PHP.
Une page absente des groupes reste affichée dans un groupe « Autres » : ce filet évite qu'un oubli
la rende inatteignable.
**Seule exception assumée : `faq`** (guide d'utilisation) est volontairement absente de
`$navigation` et rendue par le bloc `.view-tab-help` ancré à droite de la barre. L'omettre de
`$navigation` est ce qui l'empêche d'être aussi repêchée par « Autres » et d'apparaître deux fois.
Elle reste soumise à `acces_role_page` (`migration_v7_9d_faq.sql`).
---
## 5. Rôles, authentification et droits
### 5.1 Les quatre rôles
| Rôle | Périmètre |
|------|-----------|
| `AGENT` | Lecture seule sur son propre planning. |
| `RESPONSABLE_STRUCTURE` | Édition des brouillons de **son seul lieu**. |
| `SERVICE_ENFANCE` | Tout le métier ; **seul habilité à valider**. Distribue les jetons de terrain. |
| `DSI` | Administration technique : comptes d'accès et journal. **Aucun droit métier.** |
### 5.2 Authentification par jeton (V7.11)
`role.php` n'est **pas** un sélecteur de rôle. Une session ne peut naître que de
`Access::openSession()`, appelée après vérification d'un jeton par `CompteModel::authentifier()`.
Le rôle, l'agent et le lieu sont **lus sur le compte**, jamais déclarés par l'utilisateur. La base ne
conserve qu'une empreinte SHA-256 du jeton.
`Access::select()` a disparu — **ne pas le réintroduire.**
### 5.3 Second facteur pour les comptes privilégiés (V7.12)
Les comptes `SERVICE_ENFANCE` et `DSI` exigent, après le jeton, un **mot de passe** choisi par le
titulaire à sa première connexion et haché par `password_hash()`. Entre les deux étapes, la session
ne porte qu'une attente horodatée de cinq minutes : **un jeton validé sans mot de passe n'ouvre
rien.**
La règle « qui doit avoir un mot de passe » est portée par la vue
(`v_acces_compte.mot_de_passe_requis`), pas par du PHP. La DSI peut **effacer** un mot de passe,
jamais en **poser** un — sans quoi elle connaîtrait celui de tout le monde.
Un frein anti-force-brute compte les échecs récents via `JournalModel::echecsRecents()` :
10 échecs sur 15 minutes bloquent. **Renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE`
désarmerait ce frein silencieusement.**
### 5.4 Délégation des jetons de terrain (V7.27)
Le Service Enfance distribue lui-même les jetons des rôles `AGENT` et `RESPONSABLE_STRUCTURE`,
depuis l'onglet « Administration » (`AccessAccountController`, `api/acces_*.php`).
**La délégation s'arrête à ces deux rôles** : un compte `SERVICE_ENFANCE` créé par le Service
Enfance serait une auto-promotion, un compte `DSI` lui ouvrirait le journal de ses propres actions.
La borne est posée **des deux côtés** — `AccessAccountController::ROLES_DELEGUES` en PHP,
`can_manage_field_access` en base — et ne doit être élargie ni d'un côté ni de l'autre. La DSI reste
seule sur les comptes privilégiés, les mots de passe et le journal.
### 5.5 Relecture du compte à chaque requête
`Access` relit le compte à **chaque requête** (`v_acces_compte`, filtrée sur `actif` et
`identite_active`) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé
referment la session en cours. **Ne pas mettre ce résultat en cache entre deux requêtes.**
### 5.6 Les droits vivent en SQL, pas en PHP
**`Access.php` ne contient aucune règle de droit.** Tout est lu dans les vues créées par
`database/migration_v7_8_securite_acces.sql` :
`v_acces_role_page`, `v_acces_role_permission`, `v_acces_identite_agent`,
`v_acces_identite_structure`, `v_acces_agent_par_structure`, `v_acces_perimetre_creneau`,
`v_acces_perimetre_planning` — plus `v_acces_compte`, `v_acces_compte_manquant` et
`v_journal_activite` (V7.11).
Modifier un droit se fait **en SQL** dans `acces_role_page` / `acces_role_permission`, sans
redéploiement. **Une permission absente de la base vaut « refusé ».**
`SchemaModel` vérifie au démarrage la présence des dix vues et fait échouer l'application si une
migration manque — c'est volontairement **fail-closed**, et `docker/db/apply-migrations.sh` refait le
même contrôle au déploiement.
Permissions actuelles :
| Permission | Ce qu'elle ouvre |
|------------|------------------|
| `read_only` | Marque un rôle en lecture seule (AGENT). |
| `can_edit_draft` | Éditer les brouillons — garde `requireDraftEditor()`. |
| `can_edit_cross_structure` | Sortir du périmètre d'un seul lieu. |
| `can_validate` | Valider un planning. |
| `can_reopen` | Repasser un planning validé en brouillon. |
| `can_view_pending` | Voir la file d'attente de validation. |
| `can_use_templates` | Semaines types (conditionne aussi le chargement des scripts). |
| `can_copy_full_week` | Recopier une semaine entière d'un agent. |
| `can_forecast_next_year` | Projeter sur l'année suivante. |
| `can_manage_agents` | Référentiel des agents. |
| `can_manage_structures` | Référentiel des lieux. |
| `can_manage_coverage` | Règles de couverture. |
| `can_manage_teams` | Équipes des mercredis et vacances. |
| `can_manage_vacations` | Calendrier des vacances scolaires. |
| `can_manage_pta` | Contraintes et souhaits du PTA. |
| `can_manage_access` | Comptes d'accès, tous rôles (DSI). |
| `can_manage_field_access` | Comptes d'accès **de terrain uniquement** (Service Enfance, V7.27). |
| `can_read_journal` | Journal d'activité (DSI). |
### 5.7 Les gardes de contrôleur
Les contrôles sont **doublés** : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde. Ces gardes émettent directement un 401/403 JSON et **coupent l'exécution** —
l'appelant n'a donc pas à tester leur retour.
| Garde | Ce qu'il vérifie |
|-------|------------------|
| `requirePage($cle)` | Page autorisée pour le rôle (`acces_role_page`). Pages HTML uniquement. |
| `requireRoles([…])` | Appartenance à une liste de rôles. |
| `requireService()` | Rôle `SERVICE_ENFANCE`. |
| `requireDsi()` | Rôle `DSI`. |
| `requireDraftEditor()` | Permission `can_edit_draft`. |
| `requireStructureView($id)` | Droit de **consulter** ce lieu. |
| `requireStructureEdit($id)` | Droit d'**éditer** ce lieu (un responsable est borné au sien). |
| `requireAgentView($id)` | Droit de consulter cet agent (un AGENT est borné à lui-même). |
| `requireEntryEdit($id)` | Droit d'éditer ce créneau (`v_acces_perimetre_creneau`). |
| `requirePlanningEdit($id)` | Droit d'éditer ce planning (`v_acces_perimetre_planning`). |
**Ne jamais ajouter un endpoint mutant sans garde ni sans `Csrf::assertPayload($payload)`.**
### 5.8 CSRF
Le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête — ce choix évite la
requête préliminaire CORS et garde les appels `fetch()` identiques d'un module à l'autre.
```js
csrf_token: window.PTA_CONFIG.csrfToken
```
Il vit aussi longtemps que la session : il n'est pas renouvelé à chaque requête, sans quoi deux
onglets ouverts s'invalideraient l'un l'autre. La comparaison passe par `hash_equals()`.
`role.php` fait exception et vérifie son CSRF à la main : les données y arrivent en formulaire
classique et un échec doit produire une page HTML, pas du JSON.
---
## 6. Journalisation
`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par
`app/bootstrap.php` — les appelants n'ont rien à transporter :
```php
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
```
Cinq catégories : `CONNEXION`, `ECRITURE`, `ACCES_REFUSE`, `ADMINISTRATION`, `SYSTEME`.
**Trois règles :**
1. **Tout endpoint mutant doit journaliser son succès**, après l'écriture en base et avant le
`JsonResponse::send()` — au même titre qu'il porte un garde et un `Csrf::assertPayload()`.
2. **Les lectures ne sont jamais journalisées** (choix de volume). Les refus le sont, et `Access`
comme `Csrf` s'en chargent déjà — inutile de les doubler dans les contrôleurs.
3. **Le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas
entourer un appel de logique qui pourrait lever.
Actions existantes (extrait) : `CONNEXION_REUSSIE`, `JETON_REFUSE`, `MOT_DE_PASSE_REFUSE`,
`CONNEXION_BLOQUEE`, `DECONNEXION`, `COMPTE_CREE`, `JETON_REGENERE`, `JETONS_GENERES_EN_LOT`,
`MOT_DE_PASSE_REINITIALISE`, `JOURNAL_PURGE`, `PLANNING_ENREGISTRE`, `PLANNING_VALIDE`,
`PLANNING_ROUVERT`, `CRENEAU_MODIFIE`, `CRENEAU_SUPPRIME`, `SEMAINE_COPIEE`,
`SEMAINE_TYPE_APPLIQUEE_PERIODE`, `AGENT_CREE`, `AGENT_MODIFIE`, `STRUCTURE_CREEE`,
`COUVERTURE_ENREGISTREE`, `VACANCES_ENREGISTREES`, `EQUIPE_CREEE`, `CSRF_INVALIDE`,
`ROLE_HORS_PERIMETRE`, `COMPTE_HORS_PERIMETRE`.
---
## 7. Sécurité — contraintes à respecter
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
Tout `<script>` inline ajouté à une vue **doit** porter `nonce="<?= $cspNonce ?>"`. Aucun
`onclick=` ni `style=` inline ne fonctionnera.
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et vaut donc en contexte texte comme en
contexte attribut. **Ne pas la réimplémenter localement** : une variante incomplète avait
introduit un XSS stocké.
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus doit
être placé **avant** `location ~ \.php$`, sinon il est sans effet. Sont déjà refusés : `/app/`,
`/database/`, `/docker/`, les fichiers d'amorçage (`config.php`, `db.php`, `page_bootstrap.php`),
les `README*` et les extensions `.env .sql .ini .log .bak .dump .yml .yaml .md`. Ces règles doivent
suivre toute réorganisation de l'arborescence.
- **`PTA_DEBUG` reste à `0`.** Les détails d'exception passent par `Security::reportThrowable()`, qui
journalise côté serveur et ne renvoie rien au client.
- **`session_start()` ne doit jamais être appelé directement** : passer par `App\Core\Session::start()`.
- **Ne jamais redéfinir `PMA_USER` / `PMA_PASSWORD`** dans le compose : cela rouvre phpMyAdmin sans
mot de passe.
---
## 8. JavaScript
Pas de bundler, pas de module ES. Chaque fichier est une **IIFE** qui lit le global `window.PTA`
publié par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
```js
PTA.modules.agentOverview = { init, load };
```
**Ordre de chargement imposé** : `core.js` d'abord (il publie `window.PTA`), puis les modules de la
page, puis `page-bootstrap.js` en dernier (il appelle leurs `init()`).
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
`footer.php` les injecte avec deux globales :
- `window.PTA_CONFIG` — jeton CSRF, profil d'accès, permissions, référentiel des motifs (valable
pour toute l'application) ;
- `window.PTA_PAGE_CONFIG` — modules à initialiser et paramètres d'URL déjà assainis.
`page-bootstrap.js` applique ensuite les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`,
`focus`). Les modules se rafraîchissent entre eux via l'appel optionnel
`PTA.modules.autre?.refresh?.({ silent: true })`.
### ⚠️ Versionnage manuel des assets
Les assets sont versionnés à la main : `assets/style.css?v=7.29` et `assets/js/*.js?v=7.29`.
**Bumper ce numéro aux trois endroits après toute modification de JS/CSS :**
1. `app/Views/layouts/header.php`
2. `app/Views/layouts/footer.php`
3. `app/Views/pages/role.php` — qui porte sa propre balise `<head>`
Sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
### Modules disponibles
| Fichier | Module | Pages |
|---------|--------|-------|
| `core.js` | *(socle)* | toutes |
| `page-bootstrap.js` | *(amorçage)* | toutes |
| `dashboard.js` | `dashboard` | `dashboard` |
| `planning-editor.js` | `planning` | `planning` |
| `week-templates.js`, `week-template-editor.js` | `weekTemplates`, `weekTemplateEditor` | `planning`, si `can_use_templates` |
| `pending-validation.js` | `pendingValidation` | `pending` |
| `coverage.js` | `coverage` | `coverage`, `administration` |
| `structure-overview.js` | `structureOverview` | `structures` |
| `agent-overview.js` | `agentOverview` | `agents` |
| `annual-overview.js` | `annualOverview` | `annual` |
| `pta.js` | `pta` | `pta` |
| `teams.js` | `teams` | `teams` |
| `vacation-calendar.js` | `vacationCalendar` | `vacations` |
| `administration.js` | `administration` | `manage_agents`, `manage_structures` |
| `address-autocomplete.js`, `field-format.js` | `addressAutocomplete`, `fieldFormat` | `manage_agents` |
| `agent-access.js` | `agentAccess` | `manage_agents` |
| `access-tokens.js` | `accessTokens` | `administration` |
| `dsi.js` | `dsi` | `dsi` |
---
## 9. Modèle métier
### 9.1 Le quota annuel
- Il suit l'**année civile** (01/01 → 31/12), **jamais** l'année scolaire ni la date de contrat.
- 1 607 h de référence, proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
- Les 1 607 h sont un volume **NET de travail effectif** :
`365 − 104 (week-ends) − 25 (congés payés) − 8 (jours fériés) = 228 j × 7 h = 1 596 h`,
arrondies à 1 600 h, plus les 7 h de la journée de solidarité.
> **Congés payés et jours fériés sont donc DÉJÀ retranchés.** Leur imputer des heures de quota les
> décompterait une seconde fois. Une tentative de décompter un forfait de 7 h par jour de CP a été
> introduite puis annulée pour cette raison — **ne pas la réintroduire.**
### 9.2 Les objets
- Un **planning** = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`.
- Il contient des **créneaux** (`creneau_horaire`) porteurs d'un **motif** (`motif_planning`). Le
flag `compte_dans_quota` du motif décide de l'imputation : `CONGE`, `AUTRE_ABSENCE` et `JNT` ne
comptent pas.
- Une **semaine type** (`modele_semaine_agent` / `modele_semaine_creneau`) appartient à **un agent**
et décrit un lundi→vendredi générique. **Ce n'est pas un planning** : rien n'est planifié tant
qu'elle n'est pas appliquée à une semaine ou dupliquée sur une période. Elle se compose soit à
partir d'une semaine réellement planifiée (`saveFromWeek()`), soit directement dans l'éditeur
(`saveFromEntries()`) — les deux passent par `persistTemplate()`.
- Un agent peut être planifié sur **plusieurs lieux la même semaine** ; `id_structure` sur l'agent
n'est qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
- Les **vacances scolaires** (`periode_vacance`) déterminent le contexte périscolaire /
extrascolaire ; elles sont importées depuis l'API publique data.education.gouv.fr via
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un **accès sortant**).
### 9.3 Dates de contrat (V7.15)
**Un agent n'est planifiable qu'entre ses dates de contrat** (`date_debut_contrat` /
`date_fin_contrat`). La règle vaut pour **tous les motifs, congés compris** : hors contrat, il n'y a
rien à décompter. Une borne absente ouvre — pas de date de fin signifie CDI.
Elle est appliquée :
- à l'écriture — `assertDatesWithinContract()` dans `PlanningController` **et**
`WeekTemplateController` ;
- au contrôle de validation — code `HORS_CONTRAT` ;
- à l'inverse, sur la fiche agent : raccourcir un contrat sous des affectations existantes est
refusé.
> **Toute nouvelle voie d'écriture de créneaux doit poser la même garde.**
### 9.4 Comptes d'accès et listes d'affectation (V7.29)
Un **compte d'accès coupé retire l'agent des listes d'AFFECTATION** : sélecteur de la saisie
(`AgentModel::listForLocation()`) et composition des équipes (`allSelectable()`).
Un agent **sans compte** reste proposé — c'est l'état normal avant distribution des jetons, le
filtrer viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel
« Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès.
`allActive()` reste la liste non filtrée ; **ne pas confondre les deux.**
### 9.5 Les deux modes de `planning.php`
Deux modes **exclusifs** : l'éditeur de semaine type (ouvert par défaut) et la saisie semaine par
semaine. Le second reste la cible de tous les liens profonds — dès que l'URL porte `week` ou `focus`,
`page-bootstrap.js` bascule dessus, sinon un utilisateur venant corriger un planning identifié
atterrirait dans l'éditeur de modèles.
---
## 10. ⚠️ Piège : logique de validation dupliquée
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
`v_plannings_en_attente_validation`, `v_pta_annuel_synthese`, `v_equipe_pta_controle`,
`v_planning_agent_semaine`) et des procédures (`sp_controler_planning`, `sp_valider_planning`) —
**aucune n'est appelée par le code PHP.**
Les contrôles réellement appliqués vivent dans `PlanningController::validate()`,
`AnnualOverviewController::validateYear()` et `PtaModel`. Modifier une règle métier impose de traiter
**le PHP** ; le SQL correspondant est du patrimoine à ne pas prendre pour la source de vérité.
### Forme des contrôles renvoyés au client
```json
{ "niveau": "ERREUR|ALERTE|INFO", "code": "…", "message": "…" }
```
| Niveau | Effet | Code HTTP |
|--------|-------|-----------|
| `ERREUR` | Bloque, rien n'est validé | 422 |
| `ALERTE` | Demande une confirmation explicite | 409, à rappeler avec `force_quota: true` |
| `INFO` | Informatif | 200 |
Codes de validation d'un planning :
- `AUCUN_CRENEAU` (ERREUR) — planning vide ;
- `CRENEAU_HORS_SEMAINE` (ERREUR) — créneau hors des bornes de la semaine ;
- `CHEVAUCHEMENT_INTERNE` (ERREUR) — deux créneaux du planning se recouvrent ;
- `HORS_CONTRAT` (ERREUR) — affectation hors des dates de contrat ;
- `CHEVAUCHEMENT_AUTRE_STRUCTURE` (ERREUR) — 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é.*
- `QUOTA_DEPASSE` (ALERTE) — 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.
---
## 11. Les pages
| Clé | Fichier | Titre | Rôles typiques |
|-----|---------|-------|----------------|
| `dashboard` | `dashboard.php` | Tableau de bord | Service Enfance (accueil de ce rôle) |
| `planning` | `planning.php` | Saisie | Responsable, Service Enfance |
| `pending` | `pending.php` | À valider | Service Enfance |
| `coverage` | `coverage.php` | Couverture | Responsable, Service Enfance |
| `structures` | `structures.php` | Par lieu | tous sauf DSI |
| `agents` | `agents.php` | Par agent / **Mon planning** | tous sauf DSI |
| `annual` | `annual.php` | Vue annuelle | Service Enfance |
| `pta` | `pta.php` | PTA détaillé | Service Enfance |
| `teams` | `teams.php` | Équipes | Service Enfance |
| `vacations` | `vacations.php` | Vacances | Service Enfance |
| `manage_agents` | `manage_agents.php` | Agents | Service Enfance |
| `manage_structures` | `manage_structures.php` | Lieux d'affectation | Service Enfance |
| `administration` | `administration.php` | Administration (couverture + jetons de terrain) | Service Enfance |
| `dsi` | `dsi.php` | Accès & journaux | DSI |
| `faq` | `faq.php` | Guide d'utilisation | les trois rôles métier, pas la DSI (hors `$navigation`) |
| — | `role.php` | Connexion par jeton | anonyme |
| — | `index.php` | Aiguillage vers `Access::homeUrl()` ou `role.php` | — |
Groupes de navigation : **Piloter** (`dashboard`, `pending`, `coverage`) · **Planifier**
(`planning`, `teams`) · **Consulter** (`agents`, `structures`, `annual`, `pta`) · **Management**
(`manage_agents`, `manage_structures`) · **Paramétrer** (`vacations`, `administration`, `dsi`).
> `Access::homeUrl()` se replie sur `planning.php` si `dashboard` n'est pas autorisé (migration non
> jouée). **Ce repli est indispensable** : sans lui, `requirePage()` renverrait indéfiniment vers une
> page interdite.
### Liens profonds
Une page de contrôle peut ouvrir directement le planning concerné :
```
planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation
```
Paramètres reconnus par `page-bootstrap.js` : `structure_id`, `agent_id`, `week`, `month`, `year`,
`date`, `date_debut`, `date_fin`, `compte_id`, `focus`, `notice`.
---
## 12. Référence des endpoints API
### Conventions communes
- **Verbe** : les lectures sont en `GET` (paramètres en query string), les écritures en `POST`
(corps JSON). Deux endpoints acceptent les deux verbes, signalés ci-dessous.
- **Corps** : `Content-Type: application/json`. Toute charge utile mutante porte `csrf_token`.
- **Réponse** : JSON `UTF-8`, avec `X-Content-Type-Options: nosniff` et `Cache-Control: no-store`
(les réponses dépendent du rôle en session : jamais de cache partagé).
- **Endpoints PDF** : sortent des octets binaires via `PdfResponse::inline()`, et leurs erreurs en
**texte brut** avec un code HTTP — jamais en JSON.
#### Codes de retour
| Code | Signification |
|------|---------------|
| 200 | Succès |
| 201 | Ressource créée |
| 400 | Paramètres de requête absents ou mal formés |
| 401 / 403 | Session absente, rôle insuffisant, hors périmètre, ou CSRF invalide |
| 404 | Ressource introuvable — ou hors périmètre, quand le révéler renseignerait |
| 405 | Verbe HTTP non autorisé |
| 409 | Conflit : doublon, chevauchement, ou **ALERTE** demandant confirmation |
| 422 | Charge utile invalide, ou **ERREUR** de validation métier |
| 500 | Erreur serveur (détail journalisé, jamais renvoyé sauf `PTA_DEBUG=1`) |
| 502 | Un service externe (BAN, data.education.gouv.fr) n'a pas répondu |
Exemple d'appel mutant :
```js
const reponse = await fetch('save_planning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
structure_id: 2,
agent_id: 7,
week: '2026-W31',
entries: [{ date: '2026-07-27', start: '08:00', end: '12:00', motif_id: 1 }],
csrf_token: window.PTA_CONFIG.csrfToken,
}),
});
```
---
### 12.1 Planning — saisie et validation
| Endpoint | Verbe | Contrôleur | Garde |
|----------|-------|-----------|-------|
| `api/planning.php` | GET | `PlanningController::show` | `requireDraftEditor` + `requireStructureEdit` |
| `save_planning.php` | POST | `PlanningController::save` | `requireDraftEditor` + `requireStructureEdit` |
| `api/update_agent_entry.php` | POST | `PlanningController::updateAgentEntry` | `requireDraftEditor` + `requireEntryEdit` + `requireStructureEdit` |
| `api/delete_agent_entry.php` | POST | `PlanningController::deleteAgentEntry` | `requireDraftEditor` + `requireEntryEdit` |
| `api/move_or_duplicate_entry.php` | POST | `PlanningController::moveOrDuplicateEntry` | `requireDraftEditor` + `requireEntryEdit` |
| `api/copy_agent_week.php` | POST | `PlanningController::copyAgentWeek` | `requireService` |
| `api/pending_plannings.php` | GET | `PlanningController::pendingValidation` | `requireService` |
| `validate_planning.php` | POST | `PlanningController::validate` | `requireService` |
| `reopen_planning.php` | POST | `PlanningController::reopen` | `requireDraftEditor` + `requirePlanningEdit` |
**`GET api/planning.php`** — état complet d'une semaine de saisie.
Paramètres : `structure_id`, `agent_id`, `week` (`2026-W08`).
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`) : l'éditeur doit les afficher en lecture seule pour rendre les
chevauchements visibles avant l'échec.
**`POST save_planning.php`** — enregistre le brouillon d'une semaine.
Charge utile : `structure_id`, `agent_id`, `week`, `entries[]`, `csrf_token`.
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.
Ordre des contrôles, du moins coûteux au plus coûteux et du plus explicite au plus générique :
① droits et périmètre → ② motifs fermés à l'agent → ③ normalisation ligne à ligne →
④ chevauchements internes → ⑤ chevauchements avec les autres lieux.
**`POST api/update_agent_entry.php`** — modifie un créneau depuis la fiche agent.
Charge utile : `entry_id`, `structure_id`, `motif_id`, `date`, `start`, `end`, `csrf_token`.
Un créneau peut appartenir à un planning déjà `VALIDE` : le modèle le repasse alors automatiquement
en `BROUILLON`, d'où le drapeau **`requires_revalidation`** dans la réponse.
**`POST api/delete_agent_entry.php`** — supprime un créneau depuis la fiche agent.
Charge utile : `entry_id`, `csrf_token`.
Peut également 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 conserve son
historique.
**`POST api/move_or_duplicate_entry.php`** — glisser-déposer d'un créneau.
Charge utile : `entry_id`, `target_date`, `action` (`move` | `duplicate`), `csrf_token`.
`duplicate` correspond au 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`.
**`POST api/copy_agent_week.php`** — recopie une semaine entière d'un agent.
Charge utile : `agent_id`, `source_week`, `target_week`, `mode` (`merge` | `replace`), `csrf_token`.
Réservé au Service Enfance 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.
**`GET api/pending_plannings.php`** — file d'attente de validation (alimente `pending.php`).
Les totaux distinguent les minutes saisies (`total_minutes`) des minutes réellement décomptées du
quota (`counted_minutes`).
**`POST validate_planning.php`** — contrôle puis valide un planning.
Charge utile : `planning_id`, `force_quota` (bool), `csrf_token`.
**C'est ici que vivent les règles de validation réellement appliquées** — voir §10 pour la liste des
codes et les trois issues (422 / 409 / 200).
**`POST reopen_planning.php`** — repasse un planning `VALIDE` en `BROUILLON`.
Charge utile : `planning_id`, `csrf_token`.
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 : un retour à zéro ligne signifie
qu'un autre utilisateur est passé entre-temps → **409**, plutôt qu'un succès trompeur.
---
### 12.2 Semaines types
Tous ces endpoints exigent `requireService`.
| Endpoint | Verbe | Méthode |
|----------|-------|---------|
| `api/week_templates.php` | GET | `WeekTemplateController::index` |
| `api/week_template.php` | GET | `WeekTemplateController::show` |
| `api/save_week_template_entries.php` | POST | `WeekTemplateController::storeEntries` |
| `api/save_week_template.php` | POST | `WeekTemplateController::store` |
| `api/apply_week_template.php` | POST | `WeekTemplateController::apply` |
| `api/preview_week_template_period.php` | POST | `WeekTemplateController::previewPeriod` |
| `api/apply_week_template_period.php` | POST | `WeekTemplateController::applyPeriod` |
| `api/delete_week_template.php` | POST | `WeekTemplateController::destroy` |
**`GET api/week_templates.php`** — liste des semaines types d'un agent. Paramètre : `agent_id`.
Ne renvoie que des **compteurs**, pas le contenu : la liste sert à choisir, `show()` sert à ouvrir.
**`GET api/week_template.php`** — contenu détaillé. Paramètres : `template_id`, `agent_id`.
Le modèle est retrouvé par **le couple** (`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'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, et l'éditeur doit le
signaler plutôt que d'échouer à l'application.
**`POST api/save_week_template_entries.php`** — enregistre un modèle composé directement dans
l'éditeur. Charge utile : `agent_id`, `name`, `entries[]`, `replace` (bool), `csrf_token`.
Les listes de motifs et de lieux autorisés sont calculées **dans le contrôleur** 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.
**`POST api/save_week_template.php`** — capture une semaine planifiée en modèle.
Charge utile : `agent_id`, `week`, `name`, `replace` (bool), `csrf_token`.
**`POST api/apply_week_template.php`** — applique un modèle à **une** semaine.
Charge utile : `template_id`, `agent_id`, `week`, `mode` (`merge` | `replace`), `csrf_token`.
Appliquer sur une semaine validée la repasse en brouillon (`requires_revalidation`).
**`POST api/preview_week_template_period.php`** — simulation **sans écriture**.
Charge utile : `template_id`, `agent_id`, `start_date`, `end_date`, `mode`, `period_filter`,
`csrf_token`.
**É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 · `school` → semaines scolaires (périscolaire) ·
`extra` → vacances scolaires (extrascolaire).
**`POST api/apply_week_template_period.php`** — même charge utile, avec écriture.
Le message de retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
l'opération est massive et difficile à annuler à la main. 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.
**`POST api/delete_week_template.php`** — supprime une semaine type.
Charge utile : `template_id`, `agent_id`, `csrf_token`.
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 est un patron, pas un lien vivant vers les plannings produits.
---
### 12.3 Agents
| Endpoint | Verbe | Méthode | Garde |
|----------|-------|---------|-------|
| `api/agents.php` | GET | `AgentController::listForPlanning` | `requireDraftEditor` + `requireStructureEdit` |
| `api/agent_overview.php` | GET | `AgentController::overview` | `requireAgentView` |
| `api/agent_month_overview.php` | GET | `AgentController::monthOverview` | `requireAgentView` |
| `api/agent_assignments.php` | GET / POST | `AgentController::assignments` | `requireService` |
| `api/create_agent.php` | POST | `AgentController::create` | `requireService` |
| `api/update_agent.php` | POST | `AgentController::updateProfile` | `requireService` |
**`GET api/agents.php`** — agents sélectionnables dans l'éditeur. Paramètre : `structure_id`.
`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.
**`GET api/agent_overview.php`** — semaine d'un agent, **tous lieux confondus**.
Paramètres : `agent_id`, `week`.
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`.
**`GET api/agent_month_overview.php`** — grille mensuelle. Paramètres : `agent_id`, `month`
(`2026-02`).
La grille est **alignée sur les semaines ISO** : elle 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 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.
**`api/agent_assignments.php`** — rattachement principal d'un agent. **Endpoint à double verbe** :
GET liste les agents (`allActive()`), POST modifie un rattachement.
Charge utile (POST) : `agent_id`, `structure_id` (nullable), `csrf_token`.
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.
**`POST api/create_agent.php`** — création d'un agent.
Charge utile : `matricule`, `nom`, `prenom`, `id_poste`, `type_contrat`, `structure_id`,
`quotite_travail`, `date_debut_contrat`, `date_fin_contrat`, `email`, `telephone`, `adresse`,
`est_diplome`, `diplome_libelle`, `droit_enveloppe_formation`, `commentaire_pta`, `csrf_token`.
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 insertion dans la liste sans rechargement.
**`POST api/update_agent.php`** — modification de la fiche.
Mêmes contrôles que `create()`, **deux champs exceptés, jamais modifiables** :
- le **matricule** : c'est la clé de rapprochement avec les outils RH ;
- la **quotité de travail** : elle détermine le quota annuel déjà consommé, la changer en cours
d'année fausserait rétroactivement tous les suivis.
Le nom et le prénom, **eux**, sont modifiables (V7.13) : mariage, divorce, ou faute de frappe. Un
renommage est journalisé avec l'identité précédente. Raccourcir un contrat sous des affectations
existantes est **refusé** (422).
---
### 12.4 PDF
| Endpoint | Méthode | Garde | Paramètres |
|----------|---------|-------|-----------|
| `api/agent_planning_pdf.php` | `AgentController::pdf` | `requireAgentView` | `agent_id`, `week`, `weeks` |
| `api/agent_time_summary_pdf.php` | `AgentController::timeSummaryPdf` | `requireAgentView` | `agent_id`, `date` **ou** `year` |
| `api/agent_hours_contract_pdf.php` | `AgentController::hoursContractPdf` | `requireAgentView` | `agent_id`, `date` **ou** `year` |
| `api/structure_planning_pdf.php` | `StructureController::pdf` | `requireStructureView` | `structure_id`, `week` |
Tous en `GET`. Ils **n'appellent pas `JsonResponse::bootstrap()`** et sortent leurs erreurs en texte
brut.
**`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.
**`api/agent_time_summary_pdf.php`** — bilan annuel d'heures, ventilé par motif.
La période couverte **n'est pas choisie par l'appelant** : c'est celle calculée par `QuotaModel`
(année civile 01/01 → 31/12). Le paramètre `year` n'est qu'un raccourci vers le 1er janvier
correspondant.
**`api/agent_hours_contract_pdf.php`** — contrat horaire annuel. Document contractuel : il n'affiche
que le volume annualisé dû, **sans le détail des créneaux planifiés**.
**`api/structure_planning_pdf.php`** — planning d'un lieu.
Reprend **volontairement** le calcul de `StructureController::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.
---
### 12.5 Lieux d'affectation et couverture
| Endpoint | Verbe | Méthode | Garde |
|----------|-------|---------|-------|
| `api/structure_overview.php` | GET | `StructureController::overview` | `requireStructureView` |
| `api/create_structure.php` | POST | `StructureController::create` | `requireService` |
| `api/update_structure.php` | POST | `StructureController::updateType` | `requireService` |
| `api/coverage_alerts.php` | GET | `CoverageController::alerts` | `requireRoles([RESPONSABLE, SERVICE])` |
| `api/structure_coverage_rules.php` | GET | `CoverageController::rules` | `requireService` |
| `api/save_structure_coverage_rules.php` | POST | `CoverageController::saveRules` | `requireService` |
**`GET api/structure_overview.php`** — semaine d'un lieu, agent par agent.
Paramètres : `structure_id`, `week`.
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 (une seule lecture, puis regroupement en mémoire) plutôt que
par une requête par agent.
**`POST api/create_structure.php`** — création d'un lieu.
Charge utile : `code`, `nom`, `adresse`, `type_affectation`, `csrf_token`.
Le code est normalisé avant contrôle (espaces → underscores, puis 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**. `type_affectation` (`PERISCOLAIRE` | `EXTRASCOLAIRE`) détermine le contexte par
défaut du lieu ; il reste croisé avec le calendrier des vacances.
**`POST api/update_structure.php`** — change le type d'un lieu.
Charge utile : `structure_id`, `type_affectation`, `csrf_token`.
**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.
**`GET api/coverage_alerts.php`** — trous de couverture d'une semaine.
Paramètres : `week`, `structure_id` (facultatif — sans lui, balaie **tous** les lieux actifs).
Pour un responsable, le `structure_id` demandé est **ignoré et écrasé** par son propre lieu : un
simple 403 l'empêcherait de consulter son écran depuis 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.
**`GET api/structure_coverage_rules.php`** — règles d'un lieu. Paramètre : `structure_id`.
**`POST api/save_structure_coverage_rules.php`** — **remplace intégralement** les règles d'un lieu.
Charge utile : `structure_id`, `rules[]`, `csrf_token`.
La liste reçue devient l'unique jeu de règles ; 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 **dans le contrôleur** — 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.
---
### 12.6 Vue annuelle et PTA
Tous ces endpoints exigent `requireService`.
| Endpoint | Verbe | Méthode |
|----------|-------|---------|
| `api/annual_overview.php` | GET | `AnnualOverviewController::overview` |
| `api/annual_validate.php` | POST | `AnnualOverviewController::validateYear` |
| `api/pta_summary.php` | GET | `PtaController::summary` |
| `api/pta_constraint.php` | POST | `PtaController::constraint` |
| `api/pta_wish.php` | POST | `PtaController::wish` |
**`GET api/annual_overview.php`** — répartition de l'année d'un agent.
Paramètres : `agent_id`, `year` (facultatif, borné à [2000, 2100], retombe sur l'année en cours).
**`POST api/annual_validate.php`** — contrôle puis validation de **toute l'année** en une opération.
Charge utile : `agent_id`, `year`, `force_quota` (bool), `csrf_token`.
**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 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é, rappeler 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.
**`GET api/pta_summary.php`** — synthèse du PTA d'un agent à une date donnée.
Paramètres : `agent_id`, `date` (facultative, `AAAA-MM-JJ`, défaut aujourd'hui).
La date sert de **point d'observation** : elle détermine la période de quota retenue et les
contraintes en vigueur ce jour-là.
**`POST api/pta_constraint.php`** — enregistre ou supprime une contrainte prioritaire.
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` /
`interdit_midi` / `interdit_soir`.
Deux types seulement, volontairement fermés : `MEDICALE` et `TEMPS_PARTIEL_THERAPEUTIQUE`. Une date
de fin vide signifie « sans terme connu ». 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.
**`POST api/pta_wish.php`** — enregistre ou supprime un souhait de lieu.
Charge utile : `action` (`SAVE` | `DELETE`), `agent_id`, `csrf_token`, puis `wish_id`, ou
`structure_id` / `type_souhait` / `priorite` / `distance_km` / `commentaire`.
Deux types : `PREFERENCE` et `INTERDICTION`. La priorité (1 à 5) ordonne plusieurs préférences entre
elles ; la distance en kilomètres sert à arbitrer les affectations éloignées.
---
### 12.7 Équipes
Tous ces endpoints exigent `requireService`.
**`api/teams.php`** — **endpoint à double verbe**.
GET : liste, avec `date_debut` / `date_fin` facultatifs pour ne montrer que les équipes actives sur
une période.
POST (`TeamController::index`) : création. Charge utile : `structure_id`, `type_equipe`, `libelle`,
`date_debut`, `date_fin`, `nombre_enfants_moins_6`, `nombre_enfants_6_plus`, `ratio_moins_6`,
`ratio_6_plus`, `minimum_agents`, `pourcentage_diplomes_min`, `csrf_token`.
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, et un ratio à zéro provoquerait une division par zéro
au calcul du taux d'encadrement.
**`POST api/team_members.php`** (`TeamController::member`) — ajoute ou retire un agent.
Charge utile : `action` (`ADD` par défaut | `REMOVE`), `team_id`, `agent_id`, `fonction_equipe`,
`commentaire`, `csrf_token`.
Trois fonctions : `RESPONSABLE`, `ANIMATION`, `RESTAURATION_ENTRETIEN` — elles conditionnent le
calcul du taux d'encadrement. L'exception d'ajout ressort en **422 avec son propre message** : le
modèle y place les refus métier lisibles (agent déjà membre, par exemple), destinés à l'utilisateur.
**`POST api/team_status.php`** (`TeamController::status`) — fait avancer une équipe dans son cycle de
vie. Charge utile : `team_id`, `statut` (`BROUILLON` | `A_CONTROLER` | `VALIDEE`), `csrf_token`.
**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.
---
### 12.8 Vacances scolaires
Tous ces endpoints exigent `requireService`.
**`GET api/vacation_periods.php`** (`VacationController::index`) — périodes enregistrées.
Paramètre : `school_year` (facultatif, `2026-2027`).
**`GET api/preview_school_holidays.php`** (`VacationController::preview`) — propositions de l'API
officielle, **sans aucune écriture en base**. Paramètres : `academy`, `year` (année civile).
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.*
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.
**`POST api/save_vacation_periods.php`** (`VacationController::save`) — enregistre les périodes
retenues. Charge utile : `periods[]`, `csrf_token`.
Plafond de **50 périodes** par envoi : 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` ; une valeur
inconnue retombe sur `MANUEL` au lieu de faire échouer l'enregistrement.
**`POST api/delete_vacation_period.php`** (`VacationController::delete`) — supprime une période.
Charge utile : `period_id`, `csrf_token`.
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é**.
---
### 12.9 Tableau de bord et recherche d'adresse
**`GET api/dashboard_summary.php`** (`DashboardController::summary`, `requireService`).
Paramètres : `scope`, `week`, `year` (tous facultatifs).
Deux portées, **à ne pas confondre** :
- `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 (paramètre absent) — les quatre blocs de l'écran, soit plusieurs dizaines de
requêtes, acceptable **uniquement** sur le tableau de bord lui-même.
**`GET api/search_address.php`** (`AddressController::search`, `requireService`) — propositions
d'adresses via la Base Adresse Nationale. Paramètres : `q` (saisie en cours), `limit` (facultatif,
1 à 8). Le garde est celui de l'écriture des fiches agent : c'est le seul rôle qui saisit une
adresse.
---
### 12.10 Comptes d'accès délégués — Service Enfance (V7.27)
Tous ces endpoints exigent `requireService` **et** vérifient que le compte visé relève des
`ROLES_DELEGUES` (`AGENT`, `RESPONSABLE_STRUCTURE`). Un compte hors périmètre ressort en **403** et
le refus est journalisé (`COMPTE_HORS_PERIMETRE` / `ROLE_HORS_PERIMETRE`).
**`GET api/acces_comptes.php`** (`comptes`) — comptes de terrain et identités sans compte.
Paramètres : `role` (dans la liste déléguée), `actif`, `q`.
**`POST api/acces_compte_creer.php`** (`creerCompte`) — crée un compte de terrain et **renvoie son
jeton en clair**. Charge utile : `role`, `agent_id`, `structure_id`, `commentaire`, `csrf_token`.
Le libellé **n'est pas saisi** : pour ces deux rôles il est toujours déduit de l'identité (nom de
l'agent, nom du lieu) par `CompteModel`. *Un titulaire ne peut donc pas être maquillé sous un autre
nom dans la liste.*
**`POST api/acces_compte_jeton.php`** (`regenererJeton`) — régénère le jeton d'un compte de terrain.
Charge utile : `compte_id`, `csrf_token`.
C'est la procédure du jeton perdu comme du jeton compromis : **l'ancien cesse immédiatement de
fonctionner.**
**`POST api/acces_compte_statut.php`** (`changerStatutCompte`) — active, désactive ou supprime.
Charge utile : `compte_id`, `action` (`ACTIVER` | `DESACTIVER` | `SUPPRIMER`), `csrf_token`.
La suppression efface le compte **mais pas ses traces** : le journal conserve le libellé figé de
l'acteur.
**`POST api/acces_comptes_manquants.php`** (`genererJetonsManquants`) — équipe d'un coup toutes les
identités actives sans compte. Charge utile : `csrf_token`.
Sans danger d'élargissement : `v_acces_compte_manquant` ne connaît que des agents et des lieux,
jamais un compte privilégié. **La réponse porte tous les jetons produits, en clair — seule occasion
de les lire.**
---
### 12.11 Espace DSI — comptes privilégiés et journal (V7.11)
Tous ces endpoints exigent `requireDsi`.
**`GET api/dsi_comptes.php`** (`comptes`) — comptes d'accès et identités sans compte.
Paramètres : `role`, `actif`, `q`.
**`POST api/dsi_compte_creer.php`** (`creerCompte`) — crée un compte, **tous rôles**, et renvoie son
jeton. Charge utile : `role`, `agent_id`, `structure_id`, `libelle`, `commentaire`, `csrf_token`.
**`POST api/dsi_compte_jeton.php`** (`regenererJeton`) — régénère un jeton.
Charge utile : `compte_id`, `csrf_token`.
**`POST api/dsi_compte_mot_de_passe.php`** (`reinitialiserMotDePasse`) — **efface** le mot de passe
d'un compte privilégié. Charge utile : `compte_id`, `csrf_token`.
La DSI ne **remplace** pas le mot de passe, elle le **retire** : le titulaire en choisira un nouveau
à sa prochaine connexion, avec son jeton. *C'est ce qui garantit que personne d'autre que lui ne l'a
jamais connu — y compris la personne qui vient de le réinitialiser.*
**`POST api/dsi_compte_statut.php`** (`changerStatutCompte`) — active, désactive ou supprime.
Charge utile : `compte_id`, `action`, `csrf_token`.
Refuse (422) de désactiver ou supprimer **le compte avec lequel on est connecté**. La suppression
conserve les traces du journal (clé étrangère en `ON DELETE SET NULL`).
**`POST api/dsi_comptes_manquants.php`** (`genererJetonsManquants`) — **outil de mise en service** :
à l'installation, aucun agent ni responsable n'a de jeton, donc personne ne peut entrer.
Charge utile : `csrf_token`. La réponse porte tous les jetons produits **en clair** — seule occasion
de les lire, faite pour être imprimée ou copiée avant de refermer l'écran.
**`GET api/dsi_journal.php`** (`journal`) — recherche paginée dans le journal.
Paramètres : `categorie`, `resultat`, `role`, `id_compte`, `q`, `du`, `au`, `page`, `taille`.
**`POST api/dsi_journal_purge.php`** (`purgerJournal`) — supprime les entrées les plus anciennes.
Charge utile : `jours` (**minimum 30**), `csrf_token`.
La purge est elle-même journalisée, et l'est **après** coup : sa propre trace ne peut donc pas être
emportée par la purge qu'elle décrit.
---
## 13. Base de données
### Tables du socle métier
`structure` · `poste_agent` · `agent` · `agent_structure` · `tranche_age` · `structure_capacite` ·
`structure_couverture_horaire` · `structure_creneau_reference` · `motif_planning` ·
`quota_agent_annuel` · `pta_annuel` · `semaine` · `planning` · `creneau_horaire` ·
`modele_semaine_agent` · `modele_semaine_creneau` · `periode_vacance` · `agent_structure_souhait` ·
`agent_contrainte` · `equipe_pta` · `equipe_pta_membre`
### Tables ajoutées par migration
`compte_acces` (V7.11) · `journal_activite` (V7.11) · `acces_role_page` (V7.8) ·
`acces_role_permission` (V7.8) · `schema_migration` (registre du script de migration)
### Migrations, dans l'ordre d'application
| Fichier | Apport |
|---------|--------|
| `migration_v7_8_securite_acces.sql` | Matrice des droits en vues SQL (V7.8) |
| `migration_v7_9_tableau_de_bord.sql` | Page `dashboard` (V7.9) |
| `migration_v7_9b_droit_enveloppe_formation.sql` | Colonne `droit_enveloppe_formation` |
| `migration_v7_9c_retrait_formation_repartition.sql` | Retrait de la formation de la répartition |
| `migration_v7_9d_faq.sql` | Page `faq` pour les trois rôles |
| `migration_v7_9e_dsi_comptes_journal.sql` | Rôle DSI, `compte_acces`, `journal_activite` (V7.11) |
| `migration_v7_9f_mot_de_passe_admin.sql` | Second facteur des comptes privilégiés (V7.12) |
| `migration_v7_9g_management.sql` | Pages `manage_agents` / `manage_structures` (V7.14) |
| `migration_v7_9h_jetons_service_enfance.sql` | `can_manage_field_access` (V7.27) |
> Rappel : **ordre alphabétique**, pas ordre de version. Nommer une nouvelle migration en suivant le
> préfixe de la dernière en date (`migration_v7_9i_…`), jamais le numéro de version fonctionnelle.
---
## 14. Où chercher quoi
| Besoin | Fichier |
|--------|---------|
| Règles de validation d'un planning | `app/Controllers/PlanningController::validate()` |
| Validation annuelle | `app/Controllers/AnnualOverviewController::validateYear()` |
| Calcul du quota | `app/Models/QuotaModel.php` |
| Contrôle des rôles et périmètres | `app/Core/Access.php` (+ vues SQL de la V7.8) |
| Authentification, jetons, mots de passe | `app/Controllers/RoleController.php`, `app/Models/CompteModel.php` |
| Traces d'activité | `app/Core/Journal.php`, `app/Models/JournalModel.php` |
| En-têtes de sécurité et CSP | `app/Core/Security.php` |
| Génération PDF | `app/Services/SimplePdf.php` + les trois services `*PdfService.php` |
| Import des vacances | `app/Services/SchoolHolidayApiService.php` |
| Recherche d'adresse | `app/Services/AddressApiService.php` |
| Contrôle de schéma au démarrage | `app/Models/SchemaModel.php` |
| Navigation et groupes d'onglets | `app/Views/layouts/header.php` |
| Injection des globales JS | `app/Views/layouts/footer.php` |
| Amorçage des modules JS | `assets/js/page-bootstrap.js` |
| Refus d'accès nginx | `docker/nginx/default.conf` |
| Application des migrations | `docker/db/apply-migrations.sh` |
---
## 15. Checklist avant commit
- [ ] `find . -name '*.php' -not -path './.git/*' -exec php -l {} \;` passe.
- [ ] Tout endpoint mutant a : un garde `Access`, `Request::requireMethod('POST')`,
`Csrf::assertPayload()`, un `Journal::ecriture()` de succès.
- [ ] Toute nouvelle voie d'écriture de créneaux appelle `assertDatesWithinContract()`.
- [ ] Nouvelle table ou colonne obligatoire → listes de `SchemaModel` mises à jour.
- [ ] Nouvelle migration → idempotente, et nommée selon l'**ordre alphabétique** voulu.
- [ ] Nouvelle page → `$navigation`, `$navigationGroups`, `acces_role_page` (migration SQL).
- [ ] JS/CSS modifié → `?v=` bumpé dans `header.php`, `footer.php` **et** `pages/role.php`.
- [ ] Aucun `onclick=` ni `style=` inline ; tout `<script>` inline porte le `nonce`.
- [ ] Échappement côté JS via `PTA.utils.escapeHtml`, jamais une variante locale.
- [ ] `docker compose up -d --build php nginx` pour voir l'effet du changement.
---
## 16. Historique des versions
Chaque fichier documente **une version successive** et explique *pourquoi* une décision a été prise.
Ils restent utiles pour comprendre un choix, mais **seul le code fait foi**.
| Fichier | Sujet |
|---------|-------|
| `README_V6.md` | Passage de la SPA à l'architecture multipage |
| `README_V7.md` | Socle PTA annuel, équipes mercredis/vacances, contrôles métier |
| `README_V7_1.md` → `README_V7_7.md` | Vue annuelle, rôles et droits, ajustements successifs |
| `README_V7_8.md` | Sécurité et matrice des droits déplacée en vues SQL |
| `README_V7_9.md` | Tableau de bord du Service Enfance |
| `README_V7_10.md` | Éditeur de semaine type, application sur période |
| `README_V7_11.md` | Connexion par jeton, espace DSI, journal d'activité |
| `README_V7_12.md` | Mot de passe des comptes privilégiés |
| `README_V7_13.md` | Nom et prénom modifiables sur la fiche agent |
| `README_V7_14.md` | Groupe de navigation « Management » |
| `README_V7_15.md` | Affectations bornées par les dates de contrat |
| `README_V7_27.md` | Distribution des jetons de terrain par le Service Enfance |