This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -74,3 +74,41 @@ automatiquement au déploiement par le service `db-migrate` du docker-compose.
## V7.4 — Rôles et droits d’accès
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
## V7.11 — Connexion par jeton, espace DSI et journal d’activité
`role.php` n’est plus un sélecteur de rôle : chaque utilisateur se connecte avec un jeton personnel
distribué par la DSI, dont la base ne conserve qu’une empreinte SHA-256. Un quatrième rôle `DSI`
ouvre `dsi.php` : gestion des comptes d’accès (dont les administrateurs Service Enfance) et lecture
du journal d’activité, qui trace les connexions, les écritures en base et les refus d’accès. La
migration `database/migration_v7_9e_dsi_comptes_journal.sql` est obligatoire ; elle crée aussi le
compte DSI d’installation dont le jeton, public, est à régénérer immédiatement. Voir
`README_V7_11.md`.
## V7.12 — Mot de passe des comptes privilégiés
Les comptes Service Enfance et DSI ajoutent un second facteur à leur jeton : un mot de passe choisi
par leur titulaire à sa première connexion, que la DSI ne peut qu’effacer, jamais lire. Les agents et
les responsables de lieu restent à un seul facteur. Migration
`database/migration_v7_9f_mot_de_passe_admin.sql`. Voir `README_V7_12.md`.
## V7.13 — Nom et prénom modifiables
La fiche agent laisse désormais corriger le nom et le prénom, à côté des autres champs déjà
modifiables. Le matricule et la quotité de travail restent immuables. Un renommage est journalisé
avec l’identité précédente. Aucune migration. Voir `README_V7_13.md`.
## V7.14 — Groupe de navigation « Management »
Les agents et les lieux d’affectation quittent l’onglet « Administration » pour deux onglets
distincts, réunis dans un groupe « Management » de la barre de navigation, au même titre que
« Piloter » ou « Planifier ». « Administration » ne conserve que les horaires de couverture.
Migration `database/migration_v7_9g_management.sql` (droits d’accès aux deux pages). Voir
`README_V7_14.md`.
## V7.15 — Affectations bornées par le contrat
Un agent ne peut plus être planifié avant sa date de début de contrat ni après sa date de fin, quel
que soit le chemin d’écriture (saisie, déplacement, copie de semaine, semaine type). La validation
signale les affectations devenues hors contrat, et raccourcir un contrat sous des affectations
existantes est refusé. Aucune migration. Voir `README_V7_15.md`.

165
docs/README_V7_11.md Normal file
View File

@@ -0,0 +1,165 @@
# PTA V7.11 — Connexion par jeton, espace DSI et journal d'activité
## Le problème traité
Depuis l'origine, `role.php` était un **sélecteur, pas une authentification** : n'importe qui
ouvrant l'application choisissait librement son rôle et son identité, y compris « Service Enfance »,
qui valide les plannings de toute la collectivité. Les READMEs successifs et `CLAUDE.md` la
désignaient comme *la faille ouverte la plus importante du projet*.
Deux conséquences, au-delà du contrôle d'accès lui-même :
- **aucune action n'était imputable** — un planning validé, un agent modifié, un lieu créé ne
portaient le nom de personne ;
- **rien n'était traçable** : pas de journal, donc aucune façon de répondre à « qui a rouvert cette
semaine ? » ou « depuis quand cet accès est-il utilisé ? ».
## Ce qui change
### 1. On entre avec un jeton, plus avec un choix
Chaque utilisateur — agent, responsable de lieu, administrateur du Service Enfance, DSI — reçoit un
**jeton personnel** de la forme `PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX`, tiré au sort par le serveur sur
120 bits d'entropie, dans un alphabet sans caractères ambigus (ni `I`, ni `O`, ni `0`, ni `1`).
Il n'y a **aucun mot de passe** dans PTA : rien à choisir, rien à renouveler périodiquement, aucune
procédure de réinitialisation à écrire. Un jeton perdu ne se retrouve pas, il se **régénère**, ce qui
révoque l'ancien à la seconde.
Deux façons de le présenter, l'une et l'autre acceptées :
- le **champ de saisie** de `role.php` (POST) — le jeton ne transite alors ni par l'URL, ni par
l'historique du navigateur, ni par les journaux de nginx ;
- le **lien direct** `role.php?token=…` — plus commode à envoyer par courriel, mais le jeton
s'inscrit dans l'historique et les journaux du serveur web, et un lien transféré donne l'accès.
La session ouverte, une redirection immédiate retire le jeton de la barre d'adresse.
Le rôle, l'agent et le lieu ne sont plus déclarés par l'utilisateur : ils sont **lus sur son compte**.
Le reste de la mécanique de droits n'a pas bougé d'une ligne — `Access` interroge les mêmes vues SQL
qu'en V7.8.
### 2. La base ne conserve jamais le jeton
`compte_acces` ne stocke qu'une **empreinte SHA-256** du jeton et ses onze premiers caractères, qui
servent uniquement à reconnaître une ligne dans la liste. Un vol du dump SQL ou un accès à phpMyAdmin
ne donne donc **aucun accès applicatif**.
Conséquence assumée, visible partout dans l'interface : **un jeton n'est affiché qu'une seule fois**,
dans le panneau « Jetons à transmettre » qui suit sa création. Il faut le copier à ce moment-là.
L'empreinte n'est volontairement pas salée, contrairement à ce qu'on ferait d'un mot de passe : un
jeton est tiré par le serveur, pas devinable par dictionnaire, et l'absence de sel est ce qui permet
de retrouver le compte en **une lecture d'index** à la connexion plutôt qu'en comparant à chaque
compte.
### 3. Un quatrième rôle : DSI
`DSI` rejoint `AGENT`, `RESPONSABLE_STRUCTURE` et `SERVICE_ENFANCE` dans `acces_role_page` et
`acces_role_permission`. C'est une administration **technique**, pas fonctionnelle : la DSI ne voit
ni planning, ni quota, ni agent — une seule page lui est ouverte, `dsi.php`, qui contient :
- **les comptes d'accès** : création, régénération de jeton, désactivation, suppression, filtres par
type, par état et par nom ;
- **« Générer les jetons manquants »** : équipe d'un coup tous les agents et tous les lieux actifs
qui n'ont pas encore de compte. C'est l'outil de mise en service — sans lui, personne ne pourrait
entrer le jour du déploiement ;
- **le journal d'activité** : recherche filtrée et paginée, quatre chiffres de tête, purge des
entrées anciennes.
Deux permissions nouvelles, `can_manage_access` et `can_read_journal`, sont accordées à la seule DSI
et **refusées explicitement** aux trois rôles métier.
### 4. Ce qui est journalisé — et ce qui ne l'est pas
`journal_activite` est alimenté par `App\Core\Journal`, jamais par des déclencheurs SQL : la base ne
sait pas qui agit, la connexion MariaDB étant la même pour tout le monde.
| Catégorie | Contenu |
| --- | --- |
| `CONNEXION` | Connexion réussie, jeton refusé, blocage anti-force-brute, déconnexion |
| `ECRITURE` | Toute modification en base, avec la table et l'identifiant visés |
| `ACCES_REFUSE` | Page interdite, rôle insuffisant, périmètre dépassé, jeton CSRF invalide |
| `ADMINISTRATION` | Actions de la DSI sur les comptes et le journal, purge comprise |
| `SYSTEME` | Réservé aux incidents techniques |
**Les lectures ne sont pas tracées** : elles représenteraient l'essentiel du volume pour une valeur
d'audit très faible.
Deux garanties tenues par le code :
- écrire dans le journal **ne peut jamais faire échouer l'action journalisée** — toute erreur
d'insertion est avalée et renvoyée dans `error_log` ;
- la trace est **figée** : rôle et libellé de l'acteur sont copiés au moment de l'action, et la clé
étrangère vers `compte_acces` est en `ON DELETE SET NULL`. Supprimer un compte n'efface pas son
histoire, et renommer un agent ne la réécrit pas.
### 5. Freins et garde-fous
- **Anti-force-brute** : au-delà de 10 jetons refusés en 15 minutes depuis une même adresse IP, plus
aucun jeton n'est même comparé, et le blocage est journalisé. Le compteur, c'est le journal
lui-même — aucune table supplémentaire.
- **Révocation immédiate** : `Access` relit le compte à **chaque requête**. Désactiver un compte,
désactiver l'agent qui le porte ou fermer son lieu referme la session en cours à la requête
suivante, sans attendre son expiration.
- **On ne se coupe pas la branche** : la DSI ne peut ni désactiver ni supprimer le compte avec lequel
elle est connectée, et le modèle refuse de retirer le dernier accès DSI actif.
- **Purge plancher à 30 jours** : une purge plus courte reviendrait à effacer les traces d'un
incident avant même qu'il soit constaté.
## Mise en service
1. Jouer les migrations (`docker compose up -d --build` s'en charge) ; `migration_v7_9e_…` crée les
tables, les vues, le rôle DSI et **un compte DSI d'installation**.
2. Se connecter avec le **jeton d'installation**, écrit en clair dans la migration :
```
PTA-INSTALLATION-DSI
```
Il est **public** — il est versionné dans le dépôt. La page DSI affiche un bandeau rouge tant
qu'il n'a pas été remplacé.
3. **Régénérer immédiatement ce jeton** (bouton « Régénérer » sur la ligne du compte DSI), le copier
et le conserver. Créer au passage un second compte DSI si plusieurs personnes doivent administrer.
4. Cliquer sur **« Générer les jetons manquants »** : un jeton par agent et par lieu actif. Le
panneau les affiche tous, une seule fois — les copier avant de le refermer, puis les distribuer.
5. Créer les comptes **Service Enfance** un par un (ils ne correspondent à aucune identité en base,
leur libellé est saisi à la main).
### Perte du jeton DSI
C'est le seul cas qui ne se résout pas dans l'interface. Procédure de secours, en SQL :
```sql
DELETE FROM compte_acces WHERE id_compte = 1;
```
puis rejouer la migration (`docker compose run --rm db-migrate` après avoir touché le fichier, ou
directement l'`INSERT` de la section 3 de la migration) : le compte d'installation est recréé avec
son jeton public, à régénérer aussitôt.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9e_dsi_comptes_journal.sql` | Rôle DSI, `compte_acces`, `journal_activite`, trois vues, compte d'installation |
| `app/Core/Journal.php` | Écriture des traces ; connexion posée par `app/bootstrap.php` |
| `app/Core/Access.php` | `ROLE_DSI`, `openSession()` (remplace `select()`), relecture du compte, journalisation des refus |
| `app/Models/CompteModel.php` | Jetons : génération, empreinte, authentification, cycle de vie des comptes |
| `app/Models/JournalModel.php` | Lecture paginée, statistiques, compteur d'échecs, purge |
| `app/Controllers/RoleController.php` | Connexion par jeton (champ et lien), déconnexion, frein anti-force-brute |
| `app/Controllers/DsiController.php` | Les sept endpoints de l'espace DSI |
| `dsi.php`, `api/dsi_*.php` | Points d'entrée minces |
| `app/Views/pages/role.php` | Écran de connexion (le sélecteur de rôle a disparu) |
| `app/Views/partials/dsi.php`, `assets/js/dsi.js` | Interface de l'espace DSI |
## Ce que la V7.11 ne fait pas
- **Pas d'annuaire ni de SSO** : le jeton reste un secret partagé hors de l'application (courriel,
papier). Un raccordement LDAP ou OpenID Connect resterait à faire, et ne toucherait que
`RoleController` et `CompteModel`.
- **Pas d'expiration automatique des jetons** : un jeton vit tant que la DSI ne le régénère pas. Une
date de péremption serait une colonne de plus sur `compte_acces` et une condition de plus dans
`v_acces_compte`.
- **Pas d'export du journal** : il se consulte à l'écran, filtre par filtre. Un export CSV
demanderait un endpoint supplémentaire.
- **Pas de purge automatique** : la purge est déclenchée à la main par la DSI, et journalisée.

133
docs/README_V7_12.md Normal file
View File

@@ -0,0 +1,133 @@
# PTA V7.12 — Second facteur pour les comptes Service Enfance et DSI
## Le problème traité
La V7.11 a remplacé le sélecteur de rôle par un jeton personnel. C'était un progrès décisif, mais un
jeton reste **un secret qui circule** : il est produit par la DSI, transmis par courriel ou sur
papier, parfois recopié dans un carnet. Un jeton intercepté suffisait à valider les plannings de
toute la collectivité (Service Enfance), voire à distribuer de nouveaux accès (DSI).
Le risque n'est pas le même selon le compte. Un agent ne peut que lire son propre planning ; un
responsable ne touche qu'aux brouillons de son lieu. Ces deux rôles-là restent à un seul facteur —
alourdir leur connexion coûterait plus qu'il ne protégerait.
## Ce qui change
### 1. Deux facteurs, mais seulement là où c'est justifié
| Rôle | Jeton | Mot de passe |
| --- | --- | --- |
| Agent | oui | — |
| Responsable de structure | oui | — |
| **Service Enfance** | oui | **oui** |
| **DSI** | oui | **oui** |
La règle vit **en base**, dans la vue `v_acces_compte` (`mot_de_passe_requis`), comme le reste des
droits depuis la V7.8 : étendre le second facteur à un autre rôle est une modification SQL, pas un
redéploiement.
Les deux secrets sont volontairement de nature différente : le jeton est **remis** par la DSI, le
mot de passe est **choisi** par le titulaire et n'est jamais affiché nulle part.
### 2. Le mot de passe se choisit à la première connexion
La DSI ne pose aucun mot de passe initial, et ne peut donc en connaître aucun. Le parcours :
1. la DSI crée le compte et remet le jeton ;
2. à la première connexion, le jeton est reconnu et l'écran **impose de choisir un mot de passe** ;
3. aux connexions suivantes : jeton, puis mot de passe.
Entre les deux étapes, **rien n'est ouvert** : la session ne porte qu'une attente horodatée, périmée
au bout de cinq minutes. Un jeton validé sans mot de passe ne donne accès à aucune page — vérifié :
`dsi.php` renvoie alors vers l'écran de connexion.
Le compte est **relu en base entre les deux étapes** : si la DSI révoque le compte pendant que
l'utilisateur tape son mot de passe, la session ne s'ouvre pas.
### 3. Politique : la longueur, et rien d'autre
- **12 caractères minimum**, 72 maximum. Aucune règle de composition : exiger une majuscule et un
chiffre produit `Motdepasse1`, pas un secret. Une phrase dont on se souvient vaut mieux.
- La borne haute n'est pas arbitraire : `password_hash()` utilise bcrypt, qui **ignore
silencieusement** tout ce qui dépasse 72 octets. Mieux vaut refuser que laisser croire.
- Le jeton lui-même est refusé comme mot de passe : le recopier ramènerait les deux facteurs à un
seul secret.
- Hachage par `password_hash()` (bcrypt), avec remise à niveau automatique si PHP change
d'algorithme par défaut. C'est un secret choisi par un humain, donc attaquable par dictionnaire :
contrairement au jeton, le hachage lent a ici tout son sens.
### 4. Changement et oubli
- **Changer son mot de passe** : bouton « Mot de passe » dans l'en-tête, visible des seuls comptes
concernés. L'ancien mot de passe est exigé — sans quoi un poste laissé ouvert suffirait à
confisquer un compte.
- **Oubli** : la DSI **efface** le mot de passe depuis sa page (bouton « Effacer le mot de passe »).
Elle ne le remplace pas : le titulaire en choisit un nouveau à sa connexion suivante, avec son
jeton inchangé. La DSI ne connaît donc jamais le mot de passe de personne, pas même à l'instant de
la réinitialisation.
- La colonne « État » de la liste des comptes affiche « Mot de passe défini » ou « Mot de passe à
définir ». Le second n'est pas une anomalie : c'est l'état normal d'un compte neuf.
### 5. Le frein anti-force-brute couvre les deux facteurs
Le compteur du journal (`JournalModel::echecsRecents()`) additionne désormais `JETON_REFUSE` et
`MOT_DE_PASSE_REFUSE` : essayer mille mots de passe derrière un jeton volé bute sur le même mur
qu'essayer mille jetons — 10 échecs en 15 minutes par adresse IP, puis plus rien n'est comparé.
### 6. Ce que le journal enregistre en plus
| Action | Catégorie | Quand |
| --- | --- | --- |
| `JETON_ACCEPTE_ATTENTE_MDP` | `CONNEXION` | Jeton reconnu, mot de passe attendu |
| `MOT_DE_PASSE_REFUSE` | `CONNEXION` | Mot de passe incorrect derrière un jeton valide |
| `MOT_DE_PASSE_DEFINI` | `ECRITURE` | Premier choix par le titulaire |
| `MOT_DE_PASSE_MODIFIE` | `ECRITURE` | Changement par le titulaire |
| `MOT_DE_PASSE_REINITIALISE` | `ADMINISTRATION` | Effacement par la DSI |
`CONNEXION_REUSSIE` porte en contexte les facteurs réellement présentés (`["jeton"]` ou
`["jeton","mot_de_passe"]`) : le journal distingue donc une connexion à un facteur d'une connexion à
deux.
## Mise en service
Rien à faire hors la migration `database/migration_v7_9f_mot_de_passe_admin.sql`, appliquée
automatiquement au démarrage. **Les comptes Service Enfance et DSI existants n'ont pas de mot de
passe** : leur prochaine connexion, jeton en main, leur en fera choisir un. Aucun jeton n'est
invalidé, aucun compte n'est bloqué.
Prévenez les titulaires concernés : ils verront un écran qu'ils n'attendent pas.
### Si le dernier accès DSI perd son mot de passe
Le cas ne se résout pas dans l'interface, puisqu'il faudrait être connecté pour le faire. En SQL :
```sql
UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = 1;
```
La connexion suivante, avec le jeton, redemandera d'en définir un. Si le jeton lui-même est perdu,
voir la procédure de `README_V7_11.md`.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9f_mot_de_passe_admin.sql` | Colonnes `mot_de_passe_hash` / `date_mot_de_passe`, vue `v_acces_compte` enrichie de `mot_de_passe_requis` et `mot_de_passe_defini` |
| `app/Controllers/RoleController.php` | Connexion en deux étapes, définition, vérification, changement |
| `app/Models/CompteModel.php` | Politique, hachage, vérification, réinitialisation |
| `app/Models/JournalModel.php` | Frein étendu au second facteur |
| `app/Controllers/DsiController.php`, `api/dsi_compte_mot_de_passe.php` | Effacement par la DSI |
| `app/Views/pages/role.php` | Les quatre états de l'écran de connexion |
| `app/Views/layouts/header.php` | Lien « Mot de passe », pour les seuls comptes concernés |
## Ce que la V7.12 ne fait pas
- **Pas de verrouillage du compte** après N échecs : seul le frein par adresse IP s'applique. Un
verrouillage par compte permettrait à quiconque connaît un jeton de bloquer volontairement un
administrateur.
- **Pas d'expiration ni d'historique des mots de passe** : forcer un changement tous les trois mois
produit `Motdepasse-Mars`, `Motdepasse-Juin`. Le mot de passe se change quand il y a une raison.
- **Pas de réinitialisation par courriel** : il n'y a pas d'envoi de courriel dans PTA, et la boîte
aux lettres deviendrait le vrai facteur d'authentification.
- **Pas de TOTP** : le jeton joue déjà le rôle du « quelque chose que l'on détient ». Un
authentificateur mobile serait le prolongement naturel si le besoin s'en faisait sentir.

51
docs/README_V7_13.md Normal file
View File

@@ -0,0 +1,51 @@
# PTA V7.13 — Nom et prénom modifiables sur la fiche agent
## Le problème traité
La modale « Modifier les informations de l’agent » couvrait tout sauf l’identité : coordonnées,
diplôme, poste, contrat, lieu principal, commentaire PTA. Le nom et le prénom, eux, n’étaient saisis
qu’à la création.
Or ils changent : mariage, divorce, nom d’usage, ou plus prosaïquement une faute de frappe lors de la
saisie initiale. La seule correction possible passait par une requête SQL — donc par quelqu’un qui a
l’accès à la base, ce que la V7.11 s’était justement employée à restreindre.
## Ce qui change
Deux champs, **Prénom** et **Nom**, ouvrent la modale d’édition. Ils sont obligatoires et bornés à
100 caractères, comme à la création.
Restent volontairement hors d’atteinte :
- le **matricule** — c’est la clé de rapprochement avec les outils RH, et les plannings comme les PDF
déjà produits s’y réfèrent ;
- 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.
Un renommage se lit **directement dans le journal**, sans avoir à comparer deux lignes :
```
AGENT_MODIFIE Modification de la fiche de l’agent Marc-Antoine LOUIS, renommé depuis « Marc Louis ».
{"id_structure":1,"type_contrat":"PERMANENT","identite_precedente":"Marc Louis"}
```
L’identité précédente n’apparaît que s’il y a effectivement eu changement : une modification de
coordonnées garde la trace courte.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `app/Views/partials/administration.php` | Les deux champs dans la modale, `data-nom` / `data-prenom` sur la ligne du tableau |
| `assets/js/administration.js` | Pré-remplissage depuis les `data-*`, envoi, rafraîchissement de la ligne |
| `app/Controllers/AgentController.php` | Validation, fiche relue avant modification pour repérer le renommage |
| `app/Models/AgentModel.php` | `nom` et `prenom` dans l’`UPDATE` de `updateProfile()` |
Aucune migration : les colonnes existent depuis le socle.
## Point d’attention
Les `data-*` de la ligne du tableau sont la SOURCE de la modale : elle s’ouvre à partir d’eux, sans
appel réseau. Ajouter un champ modifiable impose donc de le poser à trois endroits — le partiel
(attribut `data-*`), `setAgentRowData()` et `openAgentProfileModal()` — sans quoi il s’ouvre vide
puis écrase la valeur existante à l’enregistrement.

80
docs/README_V7_14.md Normal file
View File

@@ -0,0 +1,80 @@
# PTA V7.14 — Groupe « Management » : les agents et les lieux ont chacun leur onglet
## Le problème traité
L’onglet « Administration » réunissait trois objets sans rapport les uns avec les autres :
- le référentiel des **agents** (création, fiches, rattachements) ;
- le référentiel des **lieux d’affectation** (création, type périscolaire/extrascolaire) ;
- les **horaires de couverture attendus**, qui ne décrivent ni une personne ni un lieu mais une
exigence de présence.
L’écran était aussi organisé à l’envers : les deux formulaires de création côte à côte en haut, puis
les deux tableaux plus bas, séparés par les horaires de couverture. Créer un agent puis vérifier sa
fiche demandait de traverser tout l’écran.
## Ce qui change
### 1. Un groupe de navigation « Management », deux onglets
La barre de navigation gagne un cinquième groupe, au même titre que « Piloter », « Planifier »,
« Consulter » et « Paramétrer » :
| Groupe | Onglets |
| --- | --- |
| Piloter | Tableau de bord, À valider, Couverture |
| Planifier | Saisie, Équipes |
| Consulter | Par agent, Par lieu, Vue annuelle, PTA détaillé |
| **Management** | **Agents**, **Lieux d’affectation** |
| Paramétrer | Vacances, Administration, Accès & journaux |
Chaque onglet porte **la création puis la liste** de son objet : « Ajouter un agent » puis
« Informations des agents » ; « Ajouter un lieu » puis « Paramètres des lieux ». C’est l’ordre du
travail réel.
Le groupe se place volontairement **avant** « Paramétrer » : on y crée et corrige au fil de l’eau,
là où « Paramétrer » se règle une fois pour l’année.
### 2. « Administration » ne garde que la couverture
Il ne reste que l’encadré « Horaires de couverture des lieux » — le paramètre que contrôle l’onglet
« Couverture ». Le sous-titre de la page le dit, et la page ne charge plus que le module `coverage` :
les 578 lignes du module `administration` partent avec les deux nouveaux onglets.
### 3. Rien n’a changé côté serveur
Endpoints, gardes, contrôles et modules JS sont **identiques** : c’est un déplacement de markup, pas
une réécriture. Les identifiants HTML — points d’ancrage du module `administration` — sont conservés
à la lettre, et ce module fonctionne indifféremment sur l’un ou l’autre onglet : ses écouteurs sont
tous posés en `?.`, donc sans effet quand l’élément visé n’est pas sur la page.
Deux adaptations d’affichage : le raccourci du tableau de bord pointe sur « Agents », et le guide
d’utilisation renvoie aux nouveaux onglets. La tuile « Lieux à paramétrer » continue de mener à
« Administration » : elle parle bien de couverture.
## Une singularité de nommage à connaître
Les deux méthodes de `PageController` s’appellent `manage_agents()` et `manage_structures()`, en
snake_case, à rebours de tout le reste du projet. Ce n’est pas un oubli : le nom de la méthode **est**
la clé de page passée à `runPage()` et inscrite dans `acces_role_page`, où les clés sont en
minuscules (`dashboard`, `coverage`, `pending`…). Garder les deux identiques prime sur l’uniformité
de style — c’est cette identité qui permet à PTA de se passer de routeur.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `database/migration_v7_9g_management.sql` | Droits d’accès aux deux pages pour le Service Enfance |
| `manage_agents.php`, `manage_structures.php` | Points d’entrée |
| `app/Views/pages/manage_*.php`, `app/Views/partials/manage_*.php` | Vues et markup déplacé |
| `app/Views/partials/administration.php` | Ne conserve que les horaires de couverture |
| `app/Controllers/PageController.php` | `manage_agents()`, `manage_structures()`, `administration()` allégé |
| `app/Views/layouts/header.php` | Groupe « Management » dans `$navigationGroups` |
| `app/Views/partials/dashboard.php`, `app/Views/partials/faq.php` | Renvois mis à jour |
## Point d’attention
Sans la migration, les pages sont refusées et les onglets n’apparaissent pas — c’est la règle de PTA
depuis la V7.8 : **les pages autorisées sont lues en base**, jamais écrites en PHP. La migration
supprime au passage la ligne `management` d’une version intermédiaire : une page inconnue laissée
dans `acces_role_page` finirait dans le groupe de repêchage « Autres » de la barre de navigation.

90
docs/README_V7_15.md Normal file
View File

@@ -0,0 +1,90 @@
# PTA V7.15 — Les affectations sont bornées par le contrat de l’agent
## Le problème traité
La fiche agent portait `date_debut_contrat` et `date_fin_contrat` depuis l’origine — saisies,
contrôlées l’une par rapport à l’autre, affichées dans les écrans et les PDF. Mais **aucun contrôle
de planification ne les lisait** : rien n’empêchait de planifier un agent trois mois avant son
arrivée, ou un contrat temporaire l’année suivant son départ.
Les conséquences n’étaient pas cosmétiques : ces heures entraient dans les cumuls annuels, dans le
quota, dans les contrôles de couverture — un lieu pouvait passer pour couvert par un agent qui
n’était pas encore embauché.
## Ce qui change
### 1. Toute écriture de créneau est bornée
La règle est posée sur **tous** les chemins qui créent ou déplacent une affectation :
| Chemin | Ce qui est contrôlé |
| --- | --- |
| Enregistrement d’un brouillon | Chaque date saisie |
| Modification d’un créneau (fiche agent) | La nouvelle date |
| Déplacement / duplication d’un créneau | La date cible |
| Copie d’une semaine entière | Les cinq jours ouvrés de la semaine cible |
| Application d’une semaine type | Les cinq jours ouvrés de la semaine visée |
| Duplication sur une période | Les deux bornes de la période |
Le refus est un **422** portant `code: HORS_CONTRAT`, la liste des dates refusées, et une phrase qui
nomme la période réellement couverte :
> Marc Louis n’est pas sous contrat à cette période : son contrat court à partir du 04/05/2026.
> Date(s) refusée(s) : 02/03/2026.
La règle vaut pour **tous les motifs**, congés et absences compris : hors contrat, il n’y a rien à
décompter.
Une borne absente **ouvre**, elle ne ferme jamais : pas de date de fin signifie contrat à durée
indéterminée ; pas de date de début (fiche ancienne) signifie qu’aucune borne basse n’est opposée,
plutôt que d’inventer une date.
### 2. La validation révèle les affectations déjà hors contrat
Les gardes ci-dessus empêchent d’en créer de nouvelles ; elles ne disent rien de celles qui
existaient avant. Un contrôle bloquant `HORS_CONTRAT` rejoint donc les quatre contrôles de
validation existants, à la validation d’une semaine comme à celle d’une année entière :
> L’affectation #824 du 2026-05-11 est en dehors des dates de contrat de l’agent.
La comparaison est faite en SQL sur la fiche agent jointe : c’est l’état du contrat **au moment du
contrôle** qui fait foi.
### 3. Le contrat ne peut plus glisser sous des affectations existantes
Contrôle miroir, sans lequel le premier ne servirait à rien : modifier les dates de contrat d’un
agent est refusé si cela laisserait des affectations au-dehors. Le message dit combien et sur quelle
période, pour qu’on sache quoi retirer :
> Ces dates de contrat laisseraient 6 affectation(s) en dehors du contrat, entre le 11/05/2026 et le
> 14/08/2026. Supprimez-les d’abord, ou élargissez les dates.
## Vérifications effectuées
Onze cas exécutés sur la pile, tous conformes : saisie avant le début de contrat (refusée), dans le
contrat (acceptée), sur la semaine à cheval contenant le premier jour (acceptée), copie de semaine
hors contrat, saisie après la date de fin, déplacement et modification de créneau vers une date hors
contrat, raccourcissement de contrat sous des affectations existantes, contrôle de validation sur une
donnée devenue illégitime, application d’une semaine type sur une semaine puis sur une période hors
contrat.
## Fichiers
| Fichier | Rôle |
| --- | --- |
| `app/Models/AgentModel.php` | `contractRange()`, `contractCoversDate()`, `datesOutsideContract()`, `contractViolationMessage()`, `entriesOutsideContract()` |
| `app/Controllers/PlanningController.php` | Garde sur les quatre chemins d’écriture, contrôle de validation |
| `app/Controllers/WeekTemplateController.php` | Garde sur l’application à une semaine et sur une période |
| `app/Controllers/AnnualOverviewController.php` | Même contrôle à la validation annuelle |
| `app/Controllers/AgentController.php` | Refus d’un contrat qui orphelinerait des affectations |
| `app/Models/PlanningModel.php` | `entriesOutsideContract()` pour le contrôle de validation |
Aucune migration : les colonnes existent depuis le socle, seule leur exploitation change.
## Point d’attention
La garde est **dupliquée** dans `PlanningController` et `WeekTemplateController`, faute de base
commune entre contrôleurs — comme l’est déjà `parseWeek()`. Ce qui n’est pas dupliqué, c’est le
message : il est construit par `AgentModel::contractViolationMessage()`, pour que l’utilisateur lise
la même phrase quel que soit l’écran. **Toute nouvelle voie d’écriture de créneaux doit poser la même
garde** — le contrôle de validation n’est qu’un filet, pas un substitut.