Compare commits

8 Commits

Author SHA1 Message Date
PTA
3cdbd43c13 Guide d'utilisation accessible depuis toute la navigation
Les écrans expliquaient chacun leur propre action, mais rien ne racontait
l'enchaînement : un nouvel utilisateur ne pouvait pas deviner qu'un lieu
doit exister avant un agent, ni qu'un planning se saisit en brouillon
avant d'être validé. La page « faq » déroule ce parcours complet, du
paramétrage des lieux au suivi annuel, et répond aux questions qui
reviennent (chevauchements, quota dépassé, planning validé à corriger).

Son bouton d'accès est ancré à droite de la barre de navigation plutôt
que rangé dans un groupe d'intention : le guide n'est pas une étape du
travail, c'est un secours permanent. Il est donc volontairement absent de
$navigation — c'est aussi ce qui l'empêche d'être repêché par le filet
« Autres » et d'apparaître deux fois. La barre défilant horizontalement
quand les onglets débordent, le bloc est en position sticky pour rester
atteignable.

La page reste soumise à la règle V7.8 : le droit vient de
acces_role_page, ouvert aux trois rôles. Sans la migration, ni bouton ni
page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:16:45 +02:00
PTA
8d900bede8 Retrait de l'ancienne case « formation déjà prévue dans la répartition annuelle »
La fiche agent portait deux cases à cocher pour la formation depuis l'ajout du
droit à l'enveloppe : celle-ci faisait doublon et disparaît, avec sa colonne
`agent.formation_repartition_annuelle` et tout ce qui la lisait — projection
SQL, création et modification d'agent, formulaires d'administration, JS, et
liste des colonnes obligatoires de SchemaModel.

L'enveloppe de formation ne dépend donc plus que du droit :
AgentModel::trainingEnvelopeMinutes() rend 14 h ou 0.

La colonne est retirée du socle (le CREATE TABLE ne la crée plus sur une base
neuve) et par migration_v7_9c sur les bases existantes. migration_v7_9b perd sa
clause AFTER, qui pointait sur la colonne supprimée et aurait fait échouer
l'installation d'une base neuve ; sa réécriture est sans risque, le script de
migration rejoue un fichier modifié et l'ALTER reste idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:40:56 +02:00
PTA
30cb50031d Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie
Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à
la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit :

- ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de
  planning ni dans le modal de la vue individuelle ;
- se voit refuser toute affectation de ce motif par le serveur, avec un message
  qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ;
- a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE
  FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning.

À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà :
celle-ci dit que la formation est déjà comptée dans la répartition annuelle —
l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les
deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(),
qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués.

Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours
d'édition : le retirer de la liste changerait silencieusement le motif d'une
saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent
alors de corriger la plage.

La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans
l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:30:43 +02:00
PTA
935cc3ea1c Retrait des cumuls « Consommation globale » et « Reste à affecter » du tableau de bord
Ces deux cartes additionnaient les heures de tous les agents suivis, quotités
confondues : le pourcentage et le solde obtenus ne décrivaient la situation
d'aucun agent réel et n'appelaient aucune action. Le bloc « Suivi des quotas
annuels » conserve les indicateurs actionnables (agents suivis, agents sans
aucune heure) et le tableau agent par agent.

Les agrégats correspondants sont retirés du payload de api/dashboard_summary.php
et de leur calcul dans DashboardController : plus aucun consommateur ne les lit.
La tuile « Quotas dépassés » et son compteur de dépassements sont inchangés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:09:15 +02:00
PTA
b13b52b22b Motif de travail par défaut à la saisie et heures travaillées complètes dans les PDF
La liste des motifs d'une nouvelle plage s'ouvrait sur un placeholder vide
« Choisir un motif », alors que le temps de travail est le cas très majoritaire :
elle propose désormais « Heure de travail » (code TRAVAIL), avec repli sur le
premier motif décomptant le quota si le référentiel ne contient pas ce code.

Côté PDF, seul le code générique TRAVAIL était traité comme du temps travaillé.
Les motifs ALP matin, ALP soir, restauration, mercredi périscolaire,
extrascolaire, préparations et ménage de fond tombaient donc :

- dans un total invisible du bilan annuel (ligne « Heures travaillées » à 0 h 00
  alors que les heures étaient bien décomptées plus bas) ;
- dans la couleur grise « Autre absence » des plannings agent et lieu.

La classification est centralisée dans App\Core\MotifCategories, réutilisée par
PtaModel (qui portait déjà la même liste) et par les deux services PDF. Un motif
inconnu du référentiel est rattaché au temps de travail dès lors qu'il décompte
le quota, pour qu'aucun total ne puisse disparaître d'un document.

La ligne « Heures travaillées » détaille en outre les motifs regroupés, et le
total décompté suit maintenant le drapeau compte_dans_quota comme QuotaModel :
il additionnait auparavant les congés, hors quota, en contradiction avec sa
propre note de bas de tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:45:52 +02:00
PTA
13ee16e25d Démarrage de la pile par un simple docker compose up
La pile de test de la branche ne démarrait pas : « Connexion à la base
impossible ». Trois causes cumulées, toutes corrigées.

1. PTA_DB_PORT et DB_PORT étaient passés à 9306. Ces variables désignent le
   port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306, et
   non un port publié sur l'hôte : la connexion était refusée. Elles reviennent
   à 3306, et le port hôte 9306 est désormais réellement publié par le service
   db (sur la boucle locale), pour un client SQL externe.

2. Le service db-migrate manquait sur la branche. Le schéma d'initialisation ne
   contient aucune des tables et vues d'accès V7.8 : sur un volume neuf,
   l'application refusait de démarrer. db-migrate applique maintenant tous les
   database/migration_*.sql, dont celle du tableau de bord V7.9 — plus aucune
   étape SQL manuelle.

3. Le défaut de PTA_DB_PASS (change_me) différait de celui de MARIADB_PASSWORD
   (pta). Sans fichier .env — et ce répertoire n'en a pas, il est ignoré par
   git — l'authentification échouait. Les deux défauts sont alignés.

Le bandeau du tableau de bord ne prétend plus que « tous les lieux ont des
horaires attendus » quand aucun lieu n'est enregistré.

Vérifié : volume détruit puis « docker compose up -d --build » seul suffit à
obtenir les onze pages en 200, l'atterrissage sur le tableau de bord et les deux
migrations appliquées ; un second up les ignore ; la base répond sur
127.0.0.1:9306 ; la pile principale sur 8080 est restée intacte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:53:19 +02:00
PTA
b68ac15de5 V7.9 — Tableau de bord et navigation regroupée pour le Service Enfance
Le Service Enfance ouvrait onze onglets à plat et atterrissait sur l'écran de
saisie, alors que son travail commence par le contrôle. Les chiffres qui
déclenchent une action vivaient sur trois écrans distincts.

- Nouvelle page dashboard.php, réservée au Service Enfance et devenue sa page
  d'atterrissage : quatre tuiles d'action, les brouillons les plus anciens avec
  ouverture directe sur la bonne semaine, les plages de couverture les plus
  déficitaires, et le suivi des quotas annuels par agent.
- Navigation regroupée par intention (Piloter / Planifier / Consulter /
  Paramétrer). Les rôles à quelques pages gardent la barre simple.
- Sous-titre propre à chaque page : le bandeau générique n'apprenait rien.
- Pastille « À valider » alimentée depuis toutes les pages, et non plus
  seulement depuis la page qu'elle annonce.

Aucune règle métier n'est modifiée : le tableau de bord relit ce que
PlanningModel, CoverageModel et QuotaModel calculent déjà, et son endpoint est
gardé par requireService(). Le droit d'accès à la page vient de la migration
V7.9 ; sans elle, l'onglet n'apparaît pas et homeUrl() se replie sur planning.

Vérifié sur la base réelle : les chiffres de quota du tableau de bord sont
identiques à ceux de api/annual_overview.php, les trois rôles rendent leurs
écrans sans erreur, le rendu JS a été exécuté dans un DOM avec la réponse réelle
de l'API, et la page ne produit aucun attribut style inline (CSP).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:48:50 +02:00
PTA
66cc8c8ade Base de travail V7.8 reprise du répertoire de travail
Commit technique : reproduit à l'identique l'état non commité de la copie
de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits)
afin que le travail d'ergonomie qui suit soit isolable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:32:10 +02:00
209 changed files with 925 additions and 17418 deletions

3
.gitignore vendored
View File

@@ -19,9 +19,6 @@ tmp/
/vendor/ /vendor/
/node_modules/ /node_modules/
# Worktrees de travail Claude Code : copies du dépôt, jamais à versionner.
.claude/worktrees/
# Artefacts d'éditeur et de système # Artefacts d'éditeur et de système
.DS_Store .DS_Store
Thumbs.db Thumbs.db

110
CLAUDE.md
View File

@@ -28,7 +28,6 @@ docker compose up -d --build
docker compose up -d --build php nginx docker compose up -d --build php nginx
docker compose logs -f php nginx db docker compose logs -f php nginx db
docker compose logs db-migrate # compte rendu de l'application des migrations
docker compose ps docker compose ps
docker compose down # arrêt en conservant les données docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB docker compose down -v # DÉTRUIT le volume MariaDB
@@ -53,9 +52,7 @@ Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fi
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`, 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 `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 la migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`.
migration de la V7.11 s'appelle `migration_v7_9e_dsi_comptes_journal.sql` : le préfixe suit la
dernière migration en date, pas 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 `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`, Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
@@ -68,8 +65,7 @@ Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'h
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes. Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, **Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`, `coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
`manage_structures.php`, `administration.php`, `dsi.php` :
```php ```php
require __DIR__ . '/page_bootstrap.php'; require __DIR__ . '/page_bootstrap.php';
@@ -122,44 +118,19 @@ l'application entière refuse de démarrer.
### Rôles et périmètres (`app/Core/Access.php`) ### Rôles et périmètres (`app/Core/Access.php`)
Quatre rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout le métier, seul habilité à valider) et des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider).
`DSI` (administration technique : comptes d'accès et journal, aucun droit métier).
`role.php` n'est plus un sélecteur depuis la V7.11 : c'est une **authentification par jeton** (voir `role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
`README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
le compte**, jamais déclarés par l'utilisateur. `Access::select()` a disparu — ne pas le mécanique de droits.
réintroduire.
Les comptes `SERVICE_ENFANCE` et `DSI` exigent un **second facteur** depuis la V7.12 (voir
`README_V7_12.md`) : après le jeton, un mot de passe choisi par le titulaire à sa première connexion
et haché par `password_hash()`. 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. 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 DSI
peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde.
Depuis la V7.27 (`README_V7_27.md`), 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.
`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.
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par **`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en `database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
`migration_v7_9e_dsi_comptes_journal.sql`). `SchemaModel` vérifie au démarrage la présence des sept échouer l'application si la migration manque — c'est volontairement fail-closed.
vues de droits et des trois vues de comptes/journal, 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.
`dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit `dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
@@ -167,37 +138,14 @@ jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite. indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode 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 `requireService()`, `requireDsi()`, `requireDraftEditor()`, commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
`requireStructureEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
`requirePlanningEdit($id)`… Ces gardes émettent
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
garde ni sans `Csrf::assertPayload($payload)`.** garde ni sans `Csrf::assertPayload($payload)`.**
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS : CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
`csrf_token: window.PTA_CONFIG.csrfToken`. `csrf_token: window.PTA_CONFIG.csrfToken`.
### Journalisation (V7.11)
`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par
`app/bootstrap.php`, les appelants n'ont donc rien à transporter :
```php
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
```
Trois règles :
- **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()` ;
- **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 ;
- **le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas
entourer un appel de logique qui pourrait lever.
Le journal sert aussi de compteur au frein anti-force-brute de la connexion
(`JournalModel::echecsRecents()`) : renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE`
désarmerait ce frein silencieusement.
### Contraintes de sécurité à respecter ### Contraintes de sécurité à respecter
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`. - **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
@@ -226,7 +174,7 @@ PTA.modules.agentOverview = { init, load };
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se applique 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 })`. rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
Les assets sont versionnés à la main : `assets/style.css?v=7.23` et `assets/js/*.js?v=7.23` dans Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre `layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon
les navigateurs et le `expires 7d` de nginx servent l'ancienne version. les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
@@ -235,43 +183,14 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de - Le **quota annuel** 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`). 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 en 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.
- **Un agent n'est planifiable qu'entre ses dates de contrat** (V7.15,
`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
contrat à durée indéterminée. Elle est appliquée à l'écriture (`assertDatesWithinContract()` dans
`PlanningController` et `WeekTemplateController`), au contrôle de validation (`HORS_CONTRAT`) et à
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.
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient - Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas). de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
- **Un compte d'accès coupé retire l'agent des listes d'AFFECTATION** (V7.29) : 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.
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est - 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. 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 ; - 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 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). `SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
- 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()`, voir `README_V7_10.md`) — les deux passent par `persistTemplate()`.
`planning.php` propose deux modes exclusifs (voir `README_V7_10.md`) : 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 sur ce mode, sinon un
utilisateur venant corriger un planning identifié atterrirait dans l'éditeur de modèles.
### Piège : logique de validation dupliquée ### Piège : logique de validation dupliquée
@@ -295,8 +214,5 @@ message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation exp
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe. `PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout - Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
déploiement réel. déploiement réel.
- Le **jeton DSI d'installation** (`PTA-INSTALLATION-DSI`) est écrit en clair dans
`database/migration_v7_9e_dsi_comptes_journal.sql`, donc public. La page DSI alerte tant qu'il n'a
pas été régénéré ; c'est la première action à faire après une installation.
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier — - Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
utiles pour comprendre une décision, mais seul le code fait foi. utiles pour comprendre une décision, mais seul le code fait foi.

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# PTA V6 — application PHP MVC multipage
PTA n’est plus une SPA. Chaque fonction principale correspond à une page PHP distincte :
- `planning.php` : saisie, brouillon, validation et semaines types ;
- `pending.php` : plannings en attente de validation ;
- `coverage.php` : contrôle des créneaux sans agent ;
- `structures.php` : planning hebdomadaire par lieu ;
- `agents.php` : planning mensuel et bilan par agent ;
- `vacations.php` : aperçu et enregistrement des vacances scolaires ;
- `administration.php` : lieux, agents et règles de couverture.
`index.php` redirige vers le choix de rôle lors de la première visite, puis vers la page d’accueil autorisée pour le profil sélectionné.
## Architecture
```text
page PHP
→ PageController
→ Models / Services
→ View dédiée
View
→ API JSON uniquement pour les actions asynchrones utiles
```
Les contrôleurs métier, modèles, services PDF et endpoints API de la V5.20 sont conservés. La différence principale porte sur l’interface : plus de routeur JavaScript, plus de vues cachées et plus de chargement de tous les modules sur une seule page.
## JavaScript chargé par page
- Saisie : `planning-editor.js` et `week-templates.js` ;
- À valider : `pending-validation.js` ;
- Couverture : `coverage.js` ;
- Par lieu : `structure-overview.js` ;
- Par agent : `agent-overview.js` ;
- Vacances : `vacation-calendar.js` ;
- Administration : `administration.js` et la partie configuration de `coverage.js`.
`assets/js/page-bootstrap.js` initialise uniquement les modules déclarés par la page et applique les filtres transmis dans l’URL.
## Liens directs
Une page de contrôle peut ouvrir directement le planning concerné :
```text
planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation
```
## Installation
1. Configurer `config.php`.
2. Utiliser le schéma SQL correspondant à la V5.20.
3. Placer le dossier sur le serveur PHP.
4. Ouvrir `index.php` ou `planning.php`.
Aucune migration SQL supplémentaire n’est nécessaire pour passer de la V5.20 à la V6.
## Version 7
Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis/vacances et les contrôles métier associés.
## V7.2 — Vue annuelle
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
## V7.8 — Sécurité et droits en base
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
vues SQL sont décrits dans `README_V7_8.md`. La migration
`database/migration_v7_8_securite_acces.sql` est obligatoire.
## 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`.

101
README_DOCKER.md Normal file
View File

@@ -0,0 +1,101 @@
# PTA - Docker / Nginx / PHP / MariaDB / phpMyAdmin
## Installation
Copier les fichiers de ce kit à la racine du projet PTA :
- `Dockerfile`
- `docker-compose.yml`
- `.env.example`
- `docker/nginx/default.conf`
- `docker/php/php.ini`
- `database/pta_prod_schema_mariadb.sql`
Puis :
```bash
cp .env.example .env
nano .env
```
Changer impérativement `DB_PASSWORD` et `DB_ROOT_PASSWORD`.
Démarrer :
```bash
docker compose up -d --build
```
Application :
```text
http://IP_DU_SERVEUR:8080
```
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
atteint le port.
Sur le serveur lui-même :
```text
http://127.0.0.1:8081
```
Pour y accéder depuis un poste distant, le plus sûr est un tunnel SSH :
```bash
ssh -L 8081:127.0.0.1:8081 utilisateur@serveur
```
Puis ouvrir localement :
```text
http://127.0.0.1:8081
```
## Base de données
Le schéma `database/pta_prod_schema_mariadb.sql` est exécuté automatiquement uniquement
lorsque le volume MariaDB est créé pour la première fois.
Il ne sera PAS rejoué à chaque démarrage du conteneur.
Si la base existe déjà, ne supprimez pas le volume pour appliquer une migration. Exécutez
les migrations explicitement sur la base existante.
## Commandes utiles
```bash
# Etat
docker compose ps
# Logs
docker compose logs -f nginx php db
# Rebuild après modification du PHP / JS / CSS
docker compose up -d --build php nginx
# Arrêt
docker compose down
# Arrêt SANS supprimer les données
docker compose down
# ATTENTION : supprime la base MariaDB persistée
docker compose down -v
```
## Connexion de l'application
Le `config.php` PTA utilise déjà les variables :
- `PTA_DB_HOST`
- `PTA_DB_PORT`
- `PTA_DB_NAME`
- `PTA_DB_USER`
- `PTA_DB_PASS`
Le compose les fournit automatiquement au conteneur PHP.

View File

@@ -109,25 +109,20 @@ de tourner sans contrôle de périmètre.
## Installation ## Installation
La migration est **appliquée automatiquement au déploiement**. Le service jetable `db-migrate`
du compose joue `database/migration_v7_8_securite_acces.sql` dès que MariaDB est sain, et le
service `php` n'est démarré qu'après sa sortie en succès.
1. Sauvegarder la base. 1. Sauvegarder la base.
2. Déployer : 2. Exécuter une seule fois :
```bash
docker compose up -d --build
docker compose logs db-migrate # doit afficher « Base à jour »
```
La migration est idempotente et suivie dans la table `schema_migration` : un second déploiement
l'ignore. Elle reste jouable à la main si besoin :
```sql ```sql
SOURCE database/migration_v7_8_securite_acces.sql; SOURCE database/migration_v7_8_securite_acces.sql;
``` ```
3. Reconstruire les images, phpMyAdmin compris :
```bash
docker compose up -d --build php nginx
docker compose up -d --force-recreate phpmyadmin
```
## Reste à traiter ## Reste à traiter
Le sélecteur de `role.php` n'est toujours **pas une authentification** : Le sélecteur de `role.php` n'est toujours **pas une authentification** :

View File

@@ -2,13 +2,5 @@
declare(strict_types=1); declare(strict_types=1);
/**
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
*
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
* sans la ligne correspondante en base, la page est refusée.
*/
require __DIR__ . '/page_bootstrap.php'; require __DIR__ . '/page_bootstrap.php';
runPage('administration'); runPage('administration');

View File

@@ -2,14 +2,5 @@
declare(strict_types=1); declare(strict_types=1);
/**
* Planning d'un agent mois par mois, tous lieux confondus.
* Un rôle AGENT y est verrouillé sur lui-même et l'écran devient « Mon planning ».
*
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
* sans la ligne correspondante en base, la page est refusée.
*/
require __DIR__ . '/page_bootstrap.php'; require __DIR__ . '/page_bootstrap.php';
runPage('agents'); runPage('agents');

View File

@@ -2,13 +2,5 @@
declare(strict_types=1); declare(strict_types=1);
/**
* Vue annuelle d'un agent et validation de l'année entière.
*
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
* sans la ligne correspondante en base, la page est refusée.
*/
require_once __DIR__ . '/page_bootstrap.php'; require_once __DIR__ . '/page_bootstrap.php';
runPage('annual'); runPage('annual');

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un compte de terrain et renvoie son jeton, affiché une seule fois.
* Charge utile : role (AGENT | RESPONSABLE_STRUCTURE), agent_id, structure_id,
* commentaire, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->creerCompte();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — régénère le jeton d'un compte de terrain ; l'ancien est révoqué.
* Charge utile : compte_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->regenererJeton();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — active, désactive ou supprime un compte de terrain.
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->changerStatutCompte();

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — comptes d'accès des agents et des responsables de lieu (Service Enfance).
* Les comptes Service Enfance et DSI n'y figurent jamais.
* Paramètres : role, actif, q.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->comptes();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — génère un jeton pour chaque agent et chaque lieu actif qui n'en a pas.
* Charge utile : csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AccessAccountController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AccessAccountController($pdo))->genererJetonsManquants();

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — liste des agents (Service Enfance).
* POST — change le lieu de rattachement principal d'un agent.
* Charge utile POST : agent_id, structure_id (nullable), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — contrat horaire annuel d'un agent, au format PDF.
* Paramètres : agent_id, puis date OU year. Sortie binaire : pas de bootstrap JSON.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php'; $pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — grille mensuelle d'un agent, tous lieux confondus.
* Paramètres : agent_id, month (« 2026-02 »).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — semaine d'un agent, tous lieux confondus.
* Paramètres : agent_id, week (« 2026-W08 »).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — planning d'un agent sur 6 ou 7 semaines, au format PDF.
* Paramètres : agent_id, week (départ), weeks (6|7). Sortie binaire.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php'; $pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — bilan annuel d'heures d'un agent, au format PDF.
* Paramètres : agent_id, puis date OU year. Sortie binaire.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php'; $pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — agents sélectionnables dans l'éditeur de planning.
* Paramètre : structure_id (0 ou absent = tous les lieux, Service Enfance).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — calendrier annuel d'un agent (Service Enfance).
* Paramètres : agent_id, year (facultatif, année en cours par défaut).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AnnualOverviewController; use App\Controllers\AnnualOverviewController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — contrôle puis valide TOUTE l'année d'un agent (Service Enfance).
* Tout ou rien : une seule erreur et aucune semaine n'est validée.
* Charge utile : agent_id, year, force_quota, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AnnualOverviewController; use App\Controllers\AnnualOverviewController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — applique une semaine type à UNE semaine (Service Enfance).
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — applique une semaine type sur une période (Service Enfance).
* Opération massive : passer d'abord par preview_week_template_period.php.
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — recopie une semaine entière d'un agent (Service Enfance).
* Porte sur tous les lieux de la semaine source.
* Charge utile : agent_id, source_week, target_week, mode (merge|replace), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — plages sous-effectif d'une semaine (responsable ou Service Enfance).
* Un responsable est forcé sur son propre lieu.
* Paramètres : week, structure_id (facultatif).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\CoverageController; use App\Controllers\CoverageController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un agent, son quota annuel et son PTA (Service Enfance).
* Charge utile : matricule, nom, prenom, structure_id, id_poste, type_contrat,
* quotite_travail, date_debut_contrat, csrf_token, et champs facultatifs.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un lieu d'affectation (Service Enfance).
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\StructureController; use App\Controllers\StructureController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,15 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — données du tableau de bord (Service Enfance).
* scope=badges limite la réponse au compteur de la pastille de navigation :
* c'est la variante appelée depuis toutes les pages, elle doit rester légère.
* Paramètres : scope, week, year (tous facultatifs).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DashboardController; use App\Controllers\DashboardController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — supprime un créneau depuis la fiche agent.
* Peut rouvrir un planning validé. Charge utile : entry_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — supprime une période de vacances (Service Enfance).
* Charge utile : period_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\VacationController; use App\Controllers\VacationController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — supprime une semaine type (Service Enfance).
* Les créneaux déjà appliqués ne sont pas touchés.
* Charge utile : template_id, agent_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — crée un compte d'accès et renvoie SON JETON EN CLAIR (DSI).
* Charge utile : role, agent_id, structure_id, libelle, commentaire, csrf_token.
* Le jeton n'est lisible que dans cette réponse : la base n'en garde que l'empreinte.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->creerCompte();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — régénère le jeton d'un compte, révoquant l'ancien (DSI).
* Charge utile : compte_id, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->regenererJeton();

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — efface le mot de passe d'un compte Service Enfance ou DSI (DSI).
* Charge utile : compte_id, csrf_token.
*
* La DSI ne peut que RETIRER un mot de passe, jamais en poser un : le titulaire
* en choisit un nouveau à sa connexion suivante, avec son jeton inchangé.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->reinitialiserMotDePasse();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — active, désactive ou supprime un compte d'accès (DSI).
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->changerStatutCompte();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — comptes d'accès, identités sans compte et rôles proposés (DSI).
* Paramètres : role, actif, q.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->comptes();

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — génère un jeton pour chaque agent et chaque lieu actif sans compte (DSI).
* Charge utile : csrf_token. Outil de mise en service : la réponse porte tous
* les jetons produits, en clair, et une seule fois.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->genererJetonsManquants();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — recherche paginée dans le journal d'activité (DSI).
* Paramètres : categorie, resultat, role, id_compte, q, du, au, page, taille.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->journal();

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — purge les entrées du journal antérieures à N jours (DSI, minimum 30).
* Charge utile : jours, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\DsiController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DsiController($pdo))->purgerJournal();

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — déplace ou duplique un créneau (glisser-déposer).
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — file des plannings en attente de validation (Service Enfance).
* Triée du plus ancien au plus récent, brouillons vides exclus.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — état complet d'une semaine de saisie.
* Inclut les créneaux de l'agent sur les AUTRES lieux, en lecture seule.
* Paramètres : structure_id, agent_id, week.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — propositions de vacances de l'API data.education.gouv.fr, SANS écriture.
* Un échec du service distant ressort en 502.
* Paramètres : academy, year (année civile).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\VacationController; use App\Controllers\VacationController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — SIMULE l'application d'une semaine type sur une période, sans écrire.
* Étape imposée avant apply_week_template_period.php.
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — enregistre ou supprime une contrainte prioritaire (Service Enfance).
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
* Charge utile : action, agent_id, csrf_token, puis constraint_id ou les champs de la contrainte.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PtaController; use App\Controllers\PtaController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — synthèse du PTA d'un agent à une date de référence (Service Enfance).
* Paramètres : agent_id, date (facultative, aujourd'hui par défaut).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PtaController; use App\Controllers\PtaController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — enregistre ou supprime un souhait de lieu (Service Enfance).
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
* Charge utile : action, agent_id, csrf_token, puis wish_id ou les champs du souhait.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PtaController; use App\Controllers\PtaController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — remplace INTÉGRALEMENT les règles de couverture d'un lieu (Service Enfance).
* Une liste vide supprime toute règle.
* Charge utile : structure_id, rules[], csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\CoverageController; use App\Controllers\CoverageController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — enregistre les périodes de vacances retenues (Service Enfance).
* Plafonné à 50 périodes par envoi.
* Charge utile : periods[], csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\VacationController; use App\Controllers\VacationController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — enregistre la semaine planifiée comme semaine type (Service Enfance).
* Charge utile : agent_id, week, name, replace, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — enregistre une semaine type composée dans l'éditeur (Service Enfance).
* Charge utile : agent_id, name, entries[], replace, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new WeekTemplateController($pdo))->storeEntries();

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — propositions d'adresses de la Base Adresse Nationale, SANS écriture.
* Un échec du service distant ressort en 502.
* Paramètres : q (saisie en cours), limit (facultatif).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AddressController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AddressController($pdo))->search();

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — règles de couverture d'un lieu (Service Enfance).
* Paramètre : structure_id.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\CoverageController; use App\Controllers\CoverageController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — semaine d'un lieu, agent par agent.
* Inclut les agents ponctuellement planifiés, pas seulement les rattachés.
* Paramètres : structure_id, week.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\StructureController; use App\Controllers\StructureController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — planning hebdomadaire d'un lieu, au format PDF.
* Paramètres : structure_id, week. Sortie binaire : pas de bootstrap JSON.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\StructureController; use App\Controllers\StructureController;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php'; $pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — ajoute ou retire un agent d'une équipe (Service Enfance).
* Charge utile : action (ADD|REMOVE), team_id, agent_id, fonction_equipe, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\TeamController; use App\Controllers\TeamController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — change le statut d'une équipe (Service Enfance).
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\TeamController; use App\Controllers\TeamController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — liste des équipes, filtrable par date_debut / date_fin.
* POST — crée une équipe (Service Enfance).
* Charge utile POST : structure_id, type_equipe, libelle, date_debut, date_fin, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\TeamController; use App\Controllers\TeamController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — modifie la fiche d'un agent (Service Enfance).
* Ni le matricule ni la quotité de travail ne sont modifiables ici.
* Charge utile : agent_id, champs de la fiche, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AgentController; use App\Controllers\AgentController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,14 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — modifie un créneau depuis la fiche agent.
* Peut changer de jour, de semaine et de lieu ; peut rouvrir un planning validé.
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\PlanningController; use App\Controllers\PlanningController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* POST — change le type d'un lieu d'affectation (Service Enfance).
* Charge utile : structure_id, type_affectation, csrf_token.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\StructureController; use App\Controllers\StructureController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — périodes de vacances enregistrées (Service Enfance).
* Paramètre : school_year (facultatif, « 2026-2027 »).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\VacationController; use App\Controllers\VacationController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — contenu détaillé d'une semaine type (Service Enfance).
* Paramètres : agent_id, template_id — le couple sert de contrôle de périmètre.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new WeekTemplateController($pdo))->show();

View File

@@ -4,13 +4,6 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — semaines types d'un agent, avec leurs compteurs (Service Enfance).
* Paramètre : agent_id.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -1,366 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\CompteModel;
use DomainException;
use PDO;
/**
* Jetons d'accès de terrain (V7.27) : le Service Enfance équipe ses agents et
* ses responsables de lieu.
*
* POURQUOI CE CONTRÔLEUR EXISTE À CÔTÉ DE DsiController : c'est le Service
* Enfance qui sait qu'un agent est arrivé, qu'un responsable a perdu son jeton
* ou qu'un remplaçant s'en va. Passer par la DSI pour chaque mouvement ajoutait
* un délai sans ajouter de contrôle. La DSI conserve tout : ses propres comptes,
* ceux du Service Enfance, le journal, la purge.
*
* LA FRONTIÈRE DE PRIVILÈGE, à ne jamais élargir :
*
* - self::ROLES_DELEGUES ne contient QUE AGENT et RESPONSABLE_STRUCTURE.
* Y ajouter SERVICE_ENFANCE ferait du rôle son propre distributeur — une
* auto-promotion sans contrôle externe ; y ajouter DSI lui ouvrirait le
* journal, c'est-à-dire la trace de ses propres actions ;
* - toute méthode qui désigne un compte existant passe par
* compteDelegue() AVANT d'agir. Un identifiant de compte privilégié est
* refusé en 403, jamais silencieusement ignoré ;
* - la liste blanche est doublée en base
* (database/migration_v7_9h_jetons_service_enfance.sql) : une frontière de
* privilège ne doit pas dépendre d'un seul des deux côtés.
*
* Les deux règles de DsiController restent valables ici :
* 1. un jeton en clair ne sort qu'AU MOMENT où il vient d'être produit ;
* 2. toute action est journalisée en catégorie ADMINISTRATION, sans jamais
* recopier le jeton dans la trace.
*/
final class AccessAccountController
{
/**
* Les seuls rôles dont le Service Enfance distribue les jetons.
* Lire l'en-tête de classe avant d'y toucher.
*/
private const ROLES_DELEGUES = [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE];
private Access $access;
private CompteModel $comptes;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->comptes = new CompteModel($pdo);
}
/**
* GET api/acces_comptes.php — comptes de terrain et identités sans compte.
*
* Paramètres GET : role (dans la liste déléguée), actif, q.
*/
public function comptes(): void
{
$this->access->requireService();
$comptes = $this->comptes->lister([
'roles' => self::ROLES_DELEGUES,
'role' => (string) ($_GET['role'] ?? ''),
'actif' => (string) ($_GET['actif'] ?? ''),
'q' => (string) ($_GET['q'] ?? ''),
]);
JsonResponse::send(200, [
'comptes' => $comptes,
// v_acces_compte_manquant ne produit que des identités AGENT et
// RESPONSABLE_STRUCTURE : elle est déjà dans le périmètre délégué.
'manquants' => $this->comptes->manquants(),
'roles' => $this->rolesDelegues(),
]);
}
/**
* POST api/acces_compte_creer.php — crée un compte de terrain et renvoie son
* jeton.
*
* 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.
*
* Charge utile : role, agent_id, structure_id, commentaire, csrf_token.
*/
public function creerCompte(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
$this->assertRoleDelegue($role);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
try {
$resultat = $this->comptes->creer(
$role,
$agentId !== null ? (int) $agentId : null,
$structureId !== null ? (int) $structureId : null,
'',
$commentaire !== '' ? $commentaire : null
);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$compte = $resultat['compte'];
Journal::administration('COMPTE_CREE', [
'cible_table' => 'compte_acces',
'cible_id' => (int) $compte['id_compte'],
'description' => sprintf(
'Création du compte d’accès « %s » (%s) par le Service Enfance.',
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => [
'role' => $compte['role'],
'prefixe' => $compte['jeton_prefixe'],
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(201, [
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
'compte' => $compte,
'jeton' => $resultat['jeton'],
]);
}
/**
* POST api/acces_compte_jeton.php — régénère le jeton d'un compte de terrain.
*
* C'est la procédure du jeton perdu comme du jeton compromis : l'ancien
* cesse immédiatement de fonctionner.
*
* Charge utile : compte_id, csrf_token.
*/
public function regenererJeton(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
$compte = $this->compteDelegue($compteId);
try {
$jeton = $this->comptes->regenererJeton($compteId);
} catch (DomainException $e) {
JsonResponse::send(404, ['error' => $e->getMessage()]);
return;
}
$apres = $this->comptes->trouver($compteId) ?? $compte;
Journal::administration('JETON_REGENERE', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'Régénération du jeton de « %s » par le Service Enfance : l’ancien jeton est révoqué.',
(string) $apres['identite_libelle']
),
'contexte' => [
'role' => $apres['role'],
'prefixe' => $apres['jeton_prefixe'] ?? null,
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(200, [
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
'compte' => $apres,
'jeton' => $jeton,
]);
}
/**
* POST api/acces_compte_statut.php — active, désactive ou supprime un compte
* de terrain.
*
* La suppression efface le compte mais PAS ses traces : le journal conserve
* le libellé figé de l'acteur.
*
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
* csrf_token.
*/
public function changerStatutCompte(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
JsonResponse::send(422, ['error' => 'Action inconnue.']);
}
// La fiche est lue AVANT l'opération, et sa lecture vaut contrôle de
// périmètre : après une suppression, il n'y aurait plus ni rôle à
// vérifier ni libellé à écrire dans le journal. Le compte de la session
// en cours est hors périmètre par construction (c'est un compte
// SERVICE_ENFANCE), il n'y a donc pas d'auto-désactivation à empêcher.
$compte = $this->compteDelegue($compteId);
try {
if ($action === 'SUPPRIMER') {
$this->comptes->supprimer($compteId);
} else {
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
}
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$libelles = [
'ACTIVER' => 'Réactivation',
'DESACTIVER' => 'Désactivation',
'SUPPRIMER' => 'Suppression',
];
Journal::administration('COMPTE_' . $action, [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'%s du compte d’accès « %s » (%s) par le Service Enfance.',
$libelles[$action],
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => [
'role' => $compte['role'],
'prefixe' => $compte['jeton_prefixe'],
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(200, [
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
]);
}
/**
* POST api/acces_comptes_manquants.php — équipe d'un coup toutes les
* identités actives sans compte.
*
* 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.
*
* Charge utile : csrf_token.
*/
public function genererJetonsManquants(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
try {
$produits = $this->comptes->genererManquants();
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
Journal::administration('JETONS_GENERES_EN_LOT', [
'cible_table' => 'compte_acces',
'cible_id' => null,
'description' => sprintf(
'Génération de %d jeton(s) de terrain par le Service Enfance.',
count($produits)
),
'contexte' => ['nombre' => count($produits), 'canal' => 'DELEGATION_SERVICE_ENFANCE'],
]);
JsonResponse::send(200, [
'message' => $produits === []
? 'Tous les agents et tous les lieux actifs disposent déjà d’un compte.'
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
'jetons' => $produits,
]);
}
/**
* Relit un compte et REFUSE tout ce qui sort du périmètre délégué.
*
* Le refus est un 403 explicite et non un 404 : masquer l'existence du
* compte n'apporterait rien (le Service Enfance sait parfaitement que la DSI
* existe) et rendrait le message incompréhensible à l'utilisateur de bonne
* foi qui a suivi un lien périmé.
*
* @return array<string, mixed> Le compte, garanti dans le périmètre.
*/
private function compteDelegue(int $compteId): array
{
$compte = $this->comptes->trouver($compteId);
if ($compte === null) {
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
}
if (!in_array((string) $compte['role'], self::ROLES_DELEGUES, true)) {
Journal::refus('COMPTE_HORS_PERIMETRE', 'Tentative de gestion d’un compte privilégié hors délégation.', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'contexte' => ['role_cible' => $compte['role'], 'statut_http' => 403],
]);
JsonResponse::send(403, [
'error' => 'Les comptes Service Enfance et DSI relèvent de la seule DSI.',
'code' => 'ACCESS_DENIED',
]);
}
return $compte;
}
/** Refuse un rôle demandé hors périmètre délégué, avant toute écriture. */
private function assertRoleDelegue(string $role): void
{
if (in_array($role, self::ROLES_DELEGUES, true)) {
return;
}
Journal::refus('ROLE_HORS_PERIMETRE', 'Tentative de création d’un compte hors délégation.', [
'cible_table' => 'compte_acces',
'cible_id' => null,
'contexte' => ['role_demande' => $role, 'statut_http' => 403],
]);
JsonResponse::send(403, [
'error' => 'Vous ne pouvez créer que des comptes Agent et Responsable de structure. Les comptes Service Enfance et DSI relèvent de la DSI.',
'code' => 'ACCESS_DENIED',
]);
}
/**
* Rôles proposés à la création, avec leur libellé.
*
* @return array<int, array{code: string, libelle: string, identite: string}>
*/
private function rolesDelegues(): array
{
return [
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
];
}
}

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\JsonResponse;
use App\Services\AddressApiService;
use PDO;
use Throwable;
/**
* Recherche d'adresses postales (V7.25), relais vers la Base Adresse Nationale.
*
* Sert l'autocomplétion du champ « Adresse personnelle » de la page Agents. Le
* relais est indispensable : la CSP interdit au navigateur d'appeler un domaine
* tiers (`connect-src 'self'`, voir App\Core\Security).
*
* LECTURE PURE : aucune écriture en base, donc ni jeton CSRF ni journalisation
* (CLAUDE.md — « les lectures ne sont jamais journalisées »). Le garde reste
* celui de l'écriture des fiches agent, `requireService()` : c'est le seul rôle
* qui saisit une adresse.
*
* Un échec du service distant ressort en 502 et non en 500 : c'est la BAN qui
* n'a pas répondu, pas PTA qui a échoué. Côté interface, cet échec n'est jamais
* bloquant — l'adresse reste saisissable à la main.
*/
final class AddressController
{
private Access $access;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
}
/**
* GET api/search_address.php — propositions d'adresses.
* Paramètres GET : q (saisie en cours), limit (facultatif, 1 à 8).
*/
public function search(): void
{
$this->access->requireService();
$query = trim((string) ($_GET['q'] ?? ''));
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
if ($length < AddressApiService::MIN_QUERY_LENGTH) {
JsonResponse::send(422, [
'error' => sprintf('Saisissez au moins %d caractères.', AddressApiService::MIN_QUERY_LENGTH),
]);
}
// Plafond de saisie : la BAN n'exploite pas au-delà, et cela borne le
// coût d'une requête forgée.
if ($length > 120) {
JsonResponse::send(422, ['error' => 'Saisie trop longue pour une recherche d’adresse.']);
}
$limit = filter_var($_GET['limit'] ?? 5, FILTER_VALIDATE_INT);
if ($limit === false) {
$limit = 5;
}
try {
$suggestions = (new AddressApiService())->search($query, $limit);
} catch (Throwable $e) {
JsonResponse::send(502, [
'error' => 'Le service national des adresses est momentanément indisponible. Vous pouvez saisir l’adresse manuellement.',
'details' => (getenv('PTA_DEBUG') ?: '0') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'query' => $query,
'suggestions' => $suggestions,
]);
}
}

View File

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\ContactFormat;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\PdfResponse; use App\Core\PdfResponse;
use App\Core\Request; use App\Core\Request;
@@ -22,18 +20,6 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController final class AgentController
{ {
private AgentModel $agentModel; private AgentModel $agentModel;
@@ -49,13 +35,6 @@ final class AgentController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void public function listForPlanning(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -66,15 +45,6 @@ final class AgentController
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]); JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
} }
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void public function overview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -103,21 +73,6 @@ final class AgentController
]); ]);
} }
/**
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
*
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
* `in_month` de chaque jour permet à l'interface de griser les débordements.
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
* hebdomadaires affichés seraient faux.
*
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
* compris, pour que les totaux de semaine restent justes.
*
* Paramètres GET : agent_id, month (« 2026-02 »).
*/
public function monthOverview(): void public function monthOverview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -194,16 +149,6 @@ final class AgentController
]); ]);
} }
/**
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
*
* La période couverte n'est pas choisie par l'appelant : elle est celle
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
* n'est qu'un raccourci vers le 1er janvier correspondant.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function timeSummaryPdf(): void public function timeSummaryPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -249,15 +194,6 @@ final class AgentController
); );
} }
/**
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
*
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
* sur page suivante.
*
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
*/
public function pdf(): void public function pdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -321,14 +257,6 @@ final class AgentController
); );
} }
/**
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
*
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
* détail des créneaux planifiés.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function hoursContractPdf(): void public function hoursContractPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -368,19 +296,6 @@ final class AgentController
); );
} }
/**
* api/agent_assignments.php — rattachement principal d'un agent.
*
* Endpoint à double verbe, exception au reste de l'API : GET liste les
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
*
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
*
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
*/
public function assignments(): void public function assignments(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -416,16 +331,6 @@ final class AgentController
]); ]);
} }
Journal::ecriture(
'AGENT_RATTACHEMENT_MODIFIE',
'agent',
(int) $agentId,
$structureId === null
? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId)
: sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId),
['id_structure' => $structureId !== null ? (int) $structureId : null]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'message' => $structureId === null 'message' => $structureId === null
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.' ? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
@@ -433,19 +338,6 @@ final class AgentController
]); ]);
} }
/**
* POST api/create_agent.php — création d'un agent.
*
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
* poste existant en base, type de contrat dans l'énumération, dates
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
* avec l'existant, pas une saisie malformée).
*
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
* l'insère dans sa liste sans avoir à recharger toute la page.
*/
public function create(): void public function create(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -485,15 +377,8 @@ final class AgentController
if ($rate === false || $rate <= 0 || $rate > 100) { if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']); JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
} }
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée. if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
// Les règles de forme vivent dans ContactFormat, communes à create() et JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
}
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
} }
if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
@@ -526,10 +411,6 @@ final class AgentController
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.', 'error' => 'Impossible de créer l’agent.',
'details' => Security::debugEnabled() ? $e->getMessage() : null, 'details' => Security::debugEnabled() ? $e->getMessage() : null,
@@ -537,14 +418,6 @@ final class AgentController
} }
$structure = $this->structureModel->findActive((int) $structureId); $structure = $this->structureModel->findActive((int) $structureId);
Journal::ecriture(
'AGENT_CREE',
'agent',
isset($created['id_agent']) ? (int) $created['id_agent'] : null,
sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule),
['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType]
);
JsonResponse::send(201, [ JsonResponse::send(201, [
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']), 'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
'agent' => array_merge($created, [ 'agent' => array_merge($created, [
@@ -571,18 +444,6 @@ final class AgentController
]); ]);
} }
/**
* POST api/update_agent.php — modification de la fiche d'un agent.
*
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
*
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
* rétroactivement tous les suivis.
*/
public function updateProfile(): void public function updateProfile(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -591,8 +452,6 @@ final class AgentController
Csrf::assertPayload($payload); Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$nom = trim((string) ($payload['nom'] ?? ''));
$prenom = trim((string) ($payload['prenom'] ?? ''));
$email = trim((string) ($payload['email'] ?? '')); $email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? '')); $telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? '')); $adresse = trim((string) ($payload['adresse'] ?? ''));
@@ -617,68 +476,27 @@ final class AgentController
if (!$agentId) { if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']); JsonResponse::send(422, ['error' => 'Agent invalide.']);
} }
// La fiche AVANT modification est conservée : elle sert à repérer un if ($this->agentModel->findActive((int) $agentId) === null) {
// renommage et à le nommer explicitement dans le journal.
$agentAvant = $this->agentModel->findActive((int) $agentId);
if ($agentAvant === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']); JsonResponse::send(404, ['error' => 'Agent introuvable.']);
} }
if ($nom === '' || $prenom === '') {
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
}
if (!$postId || !in_array((int) $postId, $validPostIds, true) if (!$postId || !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true) || !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart || !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) { || ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']); JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
} }
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée. if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
// Les règles de forme vivent dans ContactFormat, communes à create() et JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
} }
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) { if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
}
if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30
|| strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']); JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
} }
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) { if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
} }
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
// contrat sous des affectations existantes. Le refus nomme la période
// concernée, pour que l'utilisateur sache quoi retirer avant de
// recommencer.
$orphelins = $this->agentModel->entriesOutsideContract(
(int) $agentId,
$contractStart,
$contractEnd !== '' ? $contractEnd : null
);
if ($orphelins['nombre'] > 0) {
$lisible = static fn(?string $date): string => $date === null
? '—'
: implode('/', array_reverse(explode('-', $date)));
JsonResponse::send(422, [
'error' => sprintf(
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
$orphelins['nombre'],
$lisible($orphelins['premiere']),
$lisible($orphelins['derniere'])
),
'code' => 'HORS_CONTRAT',
]);
}
try { try {
$this->agentModel->updateProfile((int) $agentId, [ $this->agentModel->updateProfile((int) $agentId, [
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null, 'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null, 'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null, 'adresse' => $adresse !== '' ? $adresse : null,
@@ -699,42 +517,12 @@ final class AgentController
]); ]);
} }
// La fiche est relue une seule fois : elle sert à la fois au libellé de
// la trace et à la réponse.
$agent = $this->agentModel->findActive((int) $agentId);
$identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']);
$identiteApres = trim($prenom . ' ' . $nom);
$renomme = $identiteAvant !== $identiteApres;
Journal::ecriture(
'AGENT_MODIFIE',
'agent',
(int) $agentId,
$renomme
// Un renommage doit se lire dans le journal sans avoir à
// comparer deux lignes : l'ancienne identité y figure en clair.
? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant)
: sprintf('Modification de la fiche de l’agent %s.', $identiteApres),
[
'id_structure' => $structureId !== null ? (int) $structureId : null,
'type_contrat' => $contractType,
'identite_precedente' => $renomme ? $identiteAvant : null,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'message' => 'Les informations de l’agent ont été mises à jour.', 'message' => 'Les informations de l’agent ont été mises à jour.',
'agent' => $agent, 'agent' => $this->agentModel->findActive((int) $agentId),
]); ]);
} }
/**
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
* pas de base commune ; toute correction doit être reportée partout.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

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

View File

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

View File

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

View File

@@ -1,394 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\CompteModel;
use App\Models\JournalModel;
use DomainException;
use PDO;
/**
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
*
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
* acces_role_page ne lui ouvre qu'une seule page.
*
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
* (création, régénération, génération en lot). Aucune lecture ne le
* retourne, parce que la base ne l'a plus ;
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
* purge du journal — et sans jamais recopier le jeton dans la trace.
*/
final class DsiController
{
private Access $access;
private CompteModel $comptes;
private JournalModel $journal;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->comptes = new CompteModel($pdo);
$this->journal = new JournalModel($pdo);
}
/**
* GET api/dsi_comptes.php — comptes d'accès et identités sans compte.
*
* Paramètres GET : role, actif, q.
*/
public function comptes(): void
{
$this->access->requireDsi();
$comptes = $this->comptes->lister([
'role' => (string) ($_GET['role'] ?? ''),
'actif' => (string) ($_GET['actif'] ?? ''),
'q' => (string) ($_GET['q'] ?? ''),
]);
JsonResponse::send(200, [
'comptes' => $comptes,
'manquants' => $this->comptes->manquants(),
// Tant qu'un compte porte encore le jeton public de la migration,
// l'interface doit le crier : c'est une porte ouverte documentée.
'jeton_installation_actif' => $this->comptes->installationEnCours(),
'roles' => $this->rolesDisponibles(),
]);
}
/**
* POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton.
*
* Charge utile : role, agent_id, structure_id, libelle, commentaire,
* csrf_token.
*/
public function creerCompte(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$libelle = trim((string) ($payload['libelle'] ?? ''));
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
try {
$resultat = $this->comptes->creer(
$role,
$agentId !== null ? (int) $agentId : null,
$structureId !== null ? (int) $structureId : null,
$libelle,
$commentaire !== '' ? $commentaire : null
);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$compte = $resultat['compte'];
Journal::administration('COMPTE_CREE', [
'cible_table' => 'compte_acces',
'cible_id' => (int) $compte['id_compte'],
'description' => sprintf(
'Création du compte d’accès « %s » (%s).',
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
]);
JsonResponse::send(201, [
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
'compte' => $compte,
'jeton' => $resultat['jeton'],
]);
}
/**
* POST api/dsi_compte_jeton.php — régénère le jeton d'un compte.
*
* L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à
* suivre pour un jeton perdu comme pour un jeton compromis.
*
* Charge utile : compte_id, csrf_token.
*/
public function regenererJeton(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
try {
$jeton = $this->comptes->regenererJeton($compteId);
} catch (DomainException $e) {
JsonResponse::send(404, ['error' => $e->getMessage()]);
return;
}
$compte = $this->comptes->trouver($compteId) ?? [];
Journal::administration('JETON_REGENERE', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'Régénération du jeton de « %s » : l’ancien jeton est révoqué.',
(string) ($compte['identite_libelle'] ?? 'compte inconnu')
),
'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null],
]);
JsonResponse::send(200, [
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
'compte' => $compte,
'jeton' => $jeton,
]);
}
/**
* POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte
* privilégié.
*
* 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.
*
* Charge utile : compte_id, csrf_token.
*/
public function reinitialiserMotDePasse(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
try {
$compte = $this->comptes->reinitialiserMotDePasse($compteId);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
Journal::administration('MOT_DE_PASSE_REINITIALISE', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.',
(string) $compte['identite_libelle']
),
'contexte' => ['role' => $compte['role']],
]);
JsonResponse::send(200, [
'message' => sprintf(
'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.',
(string) $compte['identite_libelle']
),
]);
}
/**
* POST api/dsi_compte_statut.php — active, désactive ou supprime un compte.
*
* La suppression efface le compte mais PAS ses traces : le journal conserve
* le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL).
*
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
* csrf_token.
*/
public function changerStatutCompte(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
JsonResponse::send(422, ['error' => 'Action inconnue.']);
}
// La fiche est lue AVANT l'opération : après une suppression, il n'y
// aurait plus de libellé à écrire dans le journal.
$compte = $this->comptes->trouver($compteId);
if ($compte === null) {
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
return;
}
// Se retirer soi-même son propre accès en pleine session est une erreur
// de manipulation, jamais une intention : on la refuse.
if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') {
JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']);
}
try {
if ($action === 'SUPPRIMER') {
$this->comptes->supprimer($compteId);
} else {
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
}
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$libelles = [
'ACTIVER' => 'Réactivation',
'DESACTIVER' => 'Désactivation',
'SUPPRIMER' => 'Suppression',
];
Journal::administration('COMPTE_' . $action, [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'%s du compte d’accès « %s » (%s).',
$libelles[$action],
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
]);
JsonResponse::send(200, [
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
]);
}
/**
* POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités
* actives sans compte.
*
* C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni
* responsable n'a de jeton, donc personne ne peut entrer. La réponse porte
* tous les jetons produits, en clair — c'est la seule occasion de les lire,
* et elle est faite pour être imprimée ou copiée avant de refermer l'écran.
*
* Charge utile : csrf_token.
*/
public function genererJetonsManquants(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
try {
$produits = $this->comptes->genererManquants();
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
Journal::administration('JETONS_GENERES_EN_LOT', [
'cible_table' => 'compte_acces',
'cible_id' => null,
'description' => sprintf('Génération de %d jeton(s) pour les identités sans compte.', count($produits)),
'contexte' => ['nombre' => count($produits)],
]);
JsonResponse::send(200, [
'message' => $produits === []
? 'Toutes les identités actives disposent déjà d’un compte.'
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
'jetons' => $produits,
]);
}
/**
* GET api/dsi_journal.php — recherche paginée dans le journal.
*
* Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page,
* taille.
*/
public function journal(): void
{
$this->access->requireDsi();
$resultat = $this->journal->rechercher([
'categorie' => (string) ($_GET['categorie'] ?? ''),
'resultat' => (string) ($_GET['resultat'] ?? ''),
'role' => (string) ($_GET['role'] ?? ''),
'id_compte' => (int) ($_GET['id_compte'] ?? 0),
'q' => (string) ($_GET['q'] ?? ''),
'du' => (string) ($_GET['du'] ?? ''),
'au' => (string) ($_GET['au'] ?? ''),
'page' => (int) ($_GET['page'] ?? 1),
'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE),
]);
JsonResponse::send(200, array_merge($resultat, [
'statistiques' => $this->journal->statistiques(),
]));
}
/**
* POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes.
*
* 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.
*
* Charge utile : jours (minimum 30), csrf_token.
*/
public function purgerJournal(): void
{
$this->access->requireDsi();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0);
if ($jours < 30) {
JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']);
}
$supprimees = $this->journal->purger($jours);
Journal::administration('JOURNAL_PURGE', [
'cible_table' => 'journal_activite',
'cible_id' => null,
'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours),
'contexte' => ['jours' => $jours, 'supprimees' => $supprimees],
]);
JsonResponse::send(200, [
'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees),
'supprimees' => $supprimees,
]);
}
/**
* Rôles proposés à la création d'un compte, avec leur libellé.
*
* @return array<int, array{code: string, libelle: string, identite: string}>
*/
private function rolesDisponibles(): array
{
return [
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'],
['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'],
];
}
}

View File

@@ -15,27 +15,6 @@ use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use PDO; use PDO;
/**
* Rendu de toutes les pages HTML de l'application.
*
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
* de $activePage qui surligne l'onglet courant.
*
* Ajouter une page suppose donc, ensemble :
* 1. un fichier mince à la racine ;
* 2. une méthode ici ;
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
* 4. un module JS ;
* 5. l'inscription de la clé dans $navigation / $navigationGroups
* (Views/layouts/header.php) ;
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
*
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
* injecte et page-bootstrap.js appelle init() sur chaque module.
*/
final class PageController extends Controller final class PageController extends Controller
{ {
private Access $access; private Access $access;
@@ -45,10 +24,6 @@ final class PageController extends Controller
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
*/
public function dashboard(): void public function dashboard(): void
{ {
$this->access->requirePage('dashboard'); $this->access->requirePage('dashboard');
@@ -63,20 +38,6 @@ final class PageController extends Controller
]); ]);
} }
/**
* Écran de saisie — cœur de l'application.
*
* Le lieu est VERROUILLÉ pour un responsable de structure
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
* qu'un confort d'interface, le serveur retranche de toute façon.
*
* Les scripts de semaine type ne sont chargés que si la permission
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
* (éditeur de semaine type / saisie semaine par semaine, voir
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
* les paramètres des liens profonds venus des autres écrans.
*/
public function planning(): void public function planning(): void
{ {
$this->access->requirePage('planning'); $this->access->requirePage('planning');
@@ -86,14 +47,10 @@ final class PageController extends Controller
$modules = ['planning']; $modules = ['planning'];
if (!empty($permissions['can_use_templates'])) { if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js'; $scripts[] = 'week-templates.js';
$scripts[] = 'week-template-editor.js';
$modules[] = 'weekTemplates'; $modules[] = 'weekTemplates';
$modules[] = 'weekTemplateEditor';
} }
$this->renderPage('planning', 'Saisie du planning', 'planning', [ $this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => !empty($permissions['can_use_templates']) 'pageSubtitle' => 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
'structures' => $this->loadStructures(), 'structures' => $this->loadStructures(),
'motifs' => $this->loadMotifs(), 'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(), 'currentWeek' => $this->currentWeek(),
@@ -110,7 +67,6 @@ final class PageController extends Controller
]); ]);
} }
/** File des plannings en attente de validation (Service Enfance). */
public function pending(): void public function pending(): void
{ {
$this->access->requirePage('pending'); $this->access->requirePage('pending');
@@ -121,7 +77,6 @@ final class PageController extends Controller
]); ]);
} }
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
public function coverage(): void public function coverage(): void
{ {
$this->access->requirePage('coverage'); $this->access->requirePage('coverage');
@@ -139,7 +94,6 @@ final class PageController extends Controller
]); ]);
} }
/** Planning d'un lieu sur une semaine, en lecture seule. */
public function structures(): void public function structures(): void
{ {
$this->access->requirePage('structures'); $this->access->requirePage('structures');
@@ -157,12 +111,6 @@ final class PageController extends Controller
]); ]);
} }
/**
* Planning d'un agent, mois par mois et tous lieux confondus.
*
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
* sur « Mon planning » : la page sert alors de vue personnelle.
*/
public function agents(): void public function agents(): void
{ {
$this->access->requirePage('agents'); $this->access->requirePage('agents');
@@ -184,7 +132,6 @@ final class PageController extends Controller
]); ]);
} }
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
public function annual(): void public function annual(): void
{ {
$this->access->requirePage('annual'); $this->access->requirePage('annual');
@@ -200,12 +147,11 @@ final class PageController extends Controller
]); ]);
} }
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
public function pta(): void public function pta(): void
{ {
$this->access->requirePage('pta'); $this->access->requirePage('pta');
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [ $this->renderPage('pta', 'PTA annuel par agent', 'pta', [
'pageSubtitle' => 'Le PTA détaillé d\'un agent : souhaits de lieux, contraintes et répartition des heures.', 'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.',
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'], 'scripts' => ['pta.js'],
@@ -217,15 +163,12 @@ final class PageController extends Controller
]); ]);
} }
/** Composition des équipes sur les mercredis et les vacances. */
public function teams(): void public function teams(): void
{ {
$this->access->requirePage('teams'); $this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [ $this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.', 'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
// Liste d'AFFECTATION : un agent dont l'accès a été coupé n'est plus 'agents' => (new AgentModel($this->pdo))->allActive(),
// proposé à la composition d'une équipe (V7.29).
'agents' => (new AgentModel($this->pdo))->allSelectable(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'], 'scripts' => ['teams.js'],
'modules' => ['teams'], 'modules' => ['teams'],
@@ -236,11 +179,6 @@ final class PageController extends Controller
]); ]);
} }
/**
* Périodes de vacances scolaires : elles déterminent le contexte
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
* l'API data.education.gouv.fr (SchoolHolidayApiService).
*/
public function vacations(): void public function vacations(): void
{ {
$this->access->requirePage('vacations'); $this->access->requirePage('vacations');
@@ -251,99 +189,16 @@ final class PageController extends Controller
]); ]);
} }
/** Référentiels : agents, lieux, postes, règles de couverture. */
public function administration(): void public function administration(): void
{ {
$this->access->requirePage('administration'); $this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', 'administration', [ $this->renderPage('administration', 'Administration', 'administration', [
'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.', 'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
// Depuis la V7.14, cet écran ne porte plus les agents ni les lieux : 'agents' => (new AgentModel($this->pdo))->allActive(),
// ils ont leur propre page (management). Seule la liste des lieux
// reste nécessaire, pour son sélecteur.
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
// V7.27 : la distribution des jetons de terrain rejoint cet écran. 'posts' => (new AgentModel($this->pdo))->allPosts(),
// Le contenu de la section vient entièrement de l'API — le HTML 'scripts' => ['administration.js', 'coverage.js'],
// servi ne porte aucune information sur les comptes. 'modules' => ['administration', 'coverage'],
// `comptes-acces.js` porte la fabrique de l'écran des comptes,
// partagée avec l'espace DSI : il doit précéder `access-tokens.js`,
// qui l'appelle dès son chargement.
'scripts' => ['coverage.js', 'comptes-acces.js', 'access-tokens.js'],
'modules' => ['coverage', 'accessTokens'],
]);
}
/**
* Management — Agents (V7.14) : création, liste et fiche des agents.
*
* Ce référentiel partageait l'écran « Administration » avec celui des lieux
* et les horaires de couverture, trois objets sans rapport. Chacun a
* désormais son onglet, réunis sous le groupe « Management » de la barre de
* navigation.
*
* NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : 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. Les garder identiques
* prime sur l'uniformité de style — voir l'en-tête de cette classe.
*
* La liste des lieux est nécessaire ici aussi : le formulaire de création
* demande le lieu principal de l'agent.
*/
public function manage_agents(): void
{
$this->access->requirePage('manage_agents');
$agentModel = new AgentModel($this->pdo);
$this->renderPage('manage_agents', 'Agents', 'manage_agents', [
'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.',
'agents' => $agentModel->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => $agentModel->allPosts(),
// V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe
// ou rétablit l'accès de l'agent. Il n'ouvre aucun droit nouveau —
// il réutilise les endpoints délégués de la V7.27.
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js', 'agent-access.js'],
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat', 'agentAccess'],
]);
}
/**
* Management — Lieux d'affectation (V7.14) : création et paramètres.
*
* Les horaires de couverture d'un lieu restent sur « Administration » : ils
* ne décrivent pas le lieu mais une exigence de présence.
*/
public function manage_structures(): void
{
$this->access->requirePage('manage_structures');
$this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [
'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.',
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['administration.js'],
'modules' => ['administration'],
]);
}
/**
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
*
* Page d'administration TECHNIQUE, ouverte au seul rôle DSI par
* acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien
* à connaître des agents ni des plannings ; les seules listes qu'elle
* manipule (identités sans compte) sont servies par l'API.
*/
public function dsi(): void
{
$this->access->requirePage('dsi');
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
// Même fabrique que l'onglet Administration, et même contrainte
// d'ordre : elle doit être chargée avant `dsi.js`.
'scripts' => ['comptes-acces.js', 'dsi.js'],
'modules' => ['dsi'],
'pageConfig' => [
// Lien profond « journal de ce compte » : filtre d'emblée le
// journal sur le compte désigné.
'compteId' => $this->positiveInt($_GET['compte_id'] ?? null),
],
]); ]);
} }
@@ -356,26 +211,11 @@ final class PageController extends Controller
public function faq(): void public function faq(): void
{ {
$this->access->requirePage('faq'); $this->access->requirePage('faq');
$this->renderPage('faq', 'Guide d\'utilisation', 'faq', [ $this->renderPage('faq', 'Guide d’utilisation', 'faq', [
'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.', 'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.',
]); ]);
} }
/**
* Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu.
*
* Pose les clés attendues par header.php et footer.php avec des valeurs par
* défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra
* donc correctement au lieu de lever une notice sur variable indéfinie.
*
* Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la
* CSP à nonce, et $cspNonce est ce que chaque <script> inline d'une vue doit
* porter pour être exécuté.
*
* @param string $view Vue de pages/, sans extension.
* @param string $activePage Clé de l'onglet à surligner.
* @param array<string, mixed> $data Surcharges spécifiques à la page.
*/
private function renderPage(string $view, string $title, string $activePage, array $data = []): void private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{ {
Security::sendHtmlHeaders(); Security::sendHtmlHeaders();
@@ -399,15 +239,6 @@ final class PageController extends Controller
], $data)); ], $data));
} }
/**
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
*
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
* inaffichable dans la liste déroulante.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructures(): array private function loadStructures(): array
{ {
$model = new StructureModel($this->pdo); $model = new StructureModel($this->pdo);
@@ -422,15 +253,6 @@ final class PageController extends Controller
return $structure ? [$structure] : []; return $structure ? [$structure] : [];
} }
/**
* Variante pour l'éditeur de la fiche agent.
*
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
* Enfance a donc besoin de la liste complète même s'il consulte un agent
* rattaché ailleurs.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructuresForAgentEditor(): array private function loadStructuresForAgentEditor(): array
{ {
if ($this->access->role() === Access::ROLE_SERVICE) { if ($this->access->role() === Access::ROLE_SERVICE) {
@@ -439,13 +261,6 @@ final class PageController extends Controller
return $this->loadStructures(); return $this->loadStructures();
} }
/**
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
* inclut les agents rattachés ailleurs mais planifiés sur place.)
*
* @return array<int, array<string, mixed>>
*/
private function loadAgents(): array private function loadAgents(): array
{ {
$model = new AgentModel($this->pdo); $model = new AgentModel($this->pdo);
@@ -460,53 +275,28 @@ final class PageController extends Controller
return $structureId ? $model->allVisibleForStructure($structureId) : []; return $structureId ? $model->allVisibleForStructure($structureId) : [];
} }
/**
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
* refuse (PlanningController::assertMotifsAllowedForAgent()).
*
* @return array<int, array<string, mixed>>
*/
private function loadMotifs(): array private function loadMotifs(): array
{ {
return (new MotifModel($this->pdo))->allActive(); return (new MotifModel($this->pdo))->allActive();
} }
/**
* Semaine courante au format « 2026-W08 ».
*
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
* quand la semaine ISO appartient à l'année civile voisine.
* Fuseau explicite : le conteneur PHP tourne en UTC.
*/
private function currentWeek(): string private function currentWeek(): string
{ {
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW'); return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
} }
// ---------------------------------------------------------------------
// Assainissement des paramètres d'URL. Ces valeurs partent dans
// window.PTA_CONFIG et pilotent les filtres appliqués par
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
// ---------------------------------------------------------------------
/** Identifiant d'entité : strictement positif, ou null. */
private function positiveInt(mixed $value): ?int private function positiveInt(mixed $value): ?int
{ {
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); $number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $number === false ? null : (int) $number; return $number === false ? null : (int) $number;
} }
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
private function weekValue(mixed $value): ?string private function weekValue(mixed $value): ?string
{ {
$value = trim((string) $value); $value = trim((string) $value);
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null; return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
} }
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function dateValue(mixed $value): ?string private function dateValue(mixed $value): ?string
{ {
$value = trim((string) $value); $value = trim((string) $value);
@@ -514,14 +304,12 @@ final class PageController extends Controller
return $date && $date->format('Y-m-d') === $value ? $value : null; return $date && $date->format('Y-m-d') === $value ? $value : null;
} }
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
private function yearValue(mixed $value): ?int private function yearValue(mixed $value): ?int
{ {
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]); $number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $number === false ? null : (int) $number; return $number === false ? null : (int) $number;
} }
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
private function monthValue(mixed $value): ?string private function monthValue(mixed $value): ?string
{ {
$value = trim((string) $value); $value = trim((string) $value);

View File

@@ -6,7 +6,6 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\MotifCategories; use App\Core\MotifCategories;
use App\Core\Request; use App\Core\Request;
@@ -21,26 +20,6 @@ use DomainException;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController final class PlanningController
{ {
private PlanningModel $planningModel; private PlanningModel $planningModel;
@@ -51,10 +30,6 @@ final class PlanningController
private VacationModel $vacationModel; private VacationModel $vacationModel;
private Access $access; private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
$this->planningModel = new PlanningModel($pdo); $this->planningModel = new PlanningModel($pdo);
@@ -66,16 +41,6 @@ final class PlanningController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/planning.php — état complet d'une semaine de saisie.
*
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
* lecture seule pour rendre les chevauchements visibles avant l'échec.
*
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
*/
public function show(): void public function show(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -114,23 +79,6 @@ final class PlanningController
]); ]);
} }
/**
* POST save_planning.php — enregistre le brouillon d'une semaine.
*
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
*
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
* explicite au plus générique :
* 1. droits et périmètre ;
* 2. motifs fermés à l'agent (message nommant le motif) ;
* 3. normalisation/validation ligne à ligne ;
* 4. chevauchements internes à la saisie ;
* 5. chevauchements avec les autres lieux.
*
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
*/
public function save(): void public function save(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -165,7 +113,6 @@ final class PlanningController
// affectable à un agent sans droit à l'enveloppe de formation. // affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent); $this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent)); $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertDatesWithinContract($agent, array_column($entries, 'date'));
$this->assertNoInternalOverlap($entries); $this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap( $this->assertNoOtherLocationOverlap(
$entries, $entries,
@@ -183,26 +130,12 @@ final class PlanningController
} catch (DomainException $e) { } catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]); JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) { } catch (Throwable $e) {
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
// référence documentée — vaut « inactif » par défaut. Le détail
// technique de l'exception peut donc remonter au navigateur sur une
// installation qui ne définit pas la variable. Le motif est répété
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Erreur lors de l’enregistrement du planning.', 'error' => 'Erreur lors de l’enregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]); ]);
} }
Journal::ecriture(
'PLANNING_ENREGISTRE',
'planning',
$planningId,
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'planning_id' => $planningId, 'planning_id' => $planningId,
@@ -214,17 +147,6 @@ final class PlanningController
]); ]);
} }
/**
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
*
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
* validation intacte.
*
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
*/
public function updateAgentEntry(): void public function updateAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -261,20 +183,13 @@ final class PlanningController
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
} }
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
// sans lui, l'heure courante serait injectée et la comparaison de
// contrôle ci-dessous laisserait passer des dates approximatives.
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date; $validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
// La saisie est contrainte au quart d'heure : c'est la granularité de
// toute l'application (éditeur, cumuls, PDF).
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
} }
$this->assertDatesWithinContract($entryAgent, [$date]);
try { try {
$result = $this->planningModel->updateEntryFromAgentView( $result = $this->planningModel->updateEntryFromAgentView(
@@ -295,14 +210,6 @@ final class PlanningController
} }
$reopened = count($result['reopened_planning_ids']) > 0; $reopened = count($result['reopened_planning_ids']) > 0;
Journal::ecriture(
'CRENEAU_MODIFIE',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $reopened 'message' => $reopened
@@ -314,16 +221,6 @@ final class PlanningController
} }
/**
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
*
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
* l'interface de le signaler, mais l'enregistrement reste et conserve son
* historique.
*
* Charge utile : entry_id, csrf_token.
*/
public function deleteAgentEntry(): void public function deleteAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -356,14 +253,6 @@ final class PlanningController
$message .= ' Le planning validé concerné a été repassé en brouillon.'; $message .= ' Le planning validé concerné a été repassé en brouillon.';
} }
Journal::ecriture(
'CRENEAU_SUPPRIME',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -374,16 +263,6 @@ final class PlanningController
} }
/**
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
*
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
* l'indique sans alarmer.
*
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
*/
public function moveOrDuplicateEntry(): void public function moveOrDuplicateEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -410,12 +289,6 @@ final class PlanningController
if (!$validDate || (int) $dateObj->format('N') > 5) { if (!$validDate || (int) $dateObj->format('N') > 5) {
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']); JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
} }
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
try { try {
$result = $this->planningModel->moveOrDuplicateEntry( $result = $this->planningModel->moveOrDuplicateEntry(
(int) $entryId, (int) $entryId,
@@ -443,14 +316,6 @@ final class PlanningController
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.'; $message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
} }
Journal::ecriture(
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'action' => $result['action'], 'action' => $result['action'],
@@ -460,18 +325,6 @@ final class PlanningController
]); ]);
} }
/**
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
*
* Réservé au Service Enfance (requireService) et non aux responsables : la
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
* déborde donc par nature du périmètre d'un responsable de structure.
*
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
* `replace` les remplace.
*
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
*/
public function copyAgentWeek(): void public function copyAgentWeek(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -489,15 +342,9 @@ final class PlanningController
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) { if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']); JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
} }
$agent = $this->agentModel->findActive((int) $agentId); if ($this->agentModel->findActive((int) $agentId) === null) {
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
} }
// La copie ne connaît pas d'avance les jours réellement recopiés : on
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
// entièrement hors contrat.
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
try { try {
$result = $this->planningModel->copyAgentWeek( $result = $this->planningModel->copyAgentWeek(
@@ -526,25 +373,6 @@ final class PlanningController
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; $message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
} }
Journal::ecriture(
'SEMAINE_COPIEE',
'planning',
null,
sprintf(
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
(int) $result['copied_entries'],
$sourceWeekValue,
$targetWeekValue,
(int) $agentId
),
[
'id_agent' => (int) $agentId,
'semaine_source' => $sourceWeekValue,
'semaine_cible' => $targetWeekValue,
'mode' => $mode,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -556,14 +384,6 @@ final class PlanningController
]); ]);
} }
/**
* GET api/pending_plannings.php — file d'attente de validation.
*
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
* (`total_minutes`) des minutes réellement décomptées du quota
* (`counted_minutes`) : congés et absences non décomptées figurent au
* planning sans peser sur le quota annuel.
*/
public function pendingValidation(): void public function pendingValidation(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -587,35 +407,6 @@ final class PlanningController
]); ]);
} }
/**
* POST validate_planning.php — contrôle puis valide un planning.
*
* C'est ICI que vivent les règles de validation réellement appliquées (et
* non dans sp_valider_planning côté SQL, jamais appelée).
*
* Quatre contrôles bloquants (ERREUR → 422, aucune validation) :
* - AUCUN_CRENEAU : planning vide ;
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
* - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou
* postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en
* créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat
* raccourci a rendues illégitimes ;
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
* un brouillon concurrent peut encore être corrigé, le bloquer
* empêcherait toute validation dans un ordre donné.
*
* Un contrôle non bloquant (ALERTE → 409) :
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
* possible, mais exige un rappel explicite avec force_quota: true. Le
* dépassement est une décision de gestion, pas une anomalie technique.
*
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
*
* Charge utile : planning_id, force_quota (bool), csrf_token.
*/
public function validate(): void public function validate(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -639,19 +430,6 @@ final class PlanningController
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
} }
foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'HORS_CONTRAT',
sprintf(
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
$row['id_creneau'],
$row['date_jour']
)
);
}
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) { foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
$this->addControl( $this->addControl(
$controls, $controls,
@@ -726,25 +504,6 @@ final class PlanningController
$this->planningModel->markValidated((int) $planningId); $this->planningModel->markValidated((int) $planningId);
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.'); $this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
// Journalisé APRÈS le passage en base : une validation refusée par les
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
Journal::ecriture(
'PLANNING_VALIDE',
'planning',
(int) $planningId,
sprintf(
'Planning validé pour l’agent #%d, semaine du %s.',
(int) $planning['id_agent'],
(string) $planning['date_debut']
),
[
'id_agent' => (int) $planning['id_agent'],
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
'date_debut' => (string) $planning['date_debut'],
'quota_force' => $forceQuota,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'status' => 'validated', 'status' => 'validated',
@@ -754,17 +513,6 @@ final class PlanningController
]); ]);
} }
/**
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
*
* Réouverture volontaire, à distinguer de la réouverture automatique
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
* trompeur.
*
* Charge utile : planning_id, csrf_token.
*/
public function reopen(): void public function reopen(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -789,18 +537,6 @@ final class PlanningController
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']); JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
} }
Journal::ecriture(
'PLANNING_ROUVERT',
'planning',
(int) $planningId,
sprintf(
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
(int) $planning['id_agent'],
(string) $planning['date_debut']
),
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'status' => 'BROUILLON', 'status' => 'BROUILLON',
@@ -809,14 +545,6 @@ final class PlanningController
]); ]);
} }
/**
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
*
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
* comptent bien une semaine 53.
*
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
@@ -874,22 +602,6 @@ final class PlanningController
} }
} }
/**
* Valide et met en forme les créneaux reçus du client.
*
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
* motif appartenant à la liste ouverte à CET agent, horaires au quart
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
*
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
*
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -924,15 +636,6 @@ final class PlanningController
return $normalized; return $normalized;
} }
/**
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
*
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void private function assertNoInternalOverlap(array $entries): void
{ {
for ($i = 0, $count = count($entries); $i < $count; $i++) { for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -956,20 +659,6 @@ final class PlanningController
} }
} }
/**
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
*
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
* un agent ne peut pas être à deux endroits à la fois, même en cours de
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
* puisse nommer le lieu et l'horaire en cause.
*
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
* être comparables aux créneaux normalisés.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
* @param array<int, array<string, mixed>> $existingEntries
*/
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{ {
foreach ($entries as $entry) { foreach ($entries as $entry) {
@@ -1000,58 +689,6 @@ final class PlanningController
} }
} }
/**
* Empile un contrôle au format commun {niveau, code, message}.
*
* @param array<int, array{niveau: string, code: string, message: string}> $controls
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
*/
/**
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
*
* Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de
* contrat : le planifier avant son arrivée ou après son départ produit des
* heures que personne ne fera, et fausse les cumuls annuels comme les
* contrôles de couverture. La règle s'applique à TOUS les motifs, congés et
* absences compris — hors contrat, il n'y a rien à décompter.
*
* Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie
* l'ensemble des jours refusés, et non le premier.
*
* @param array<string, mixed> $agent Fiche agent.
* @param array<int, string> $dates Dates AAAA-MM-JJ visées par l'écriture.
*/
private function assertDatesWithinContract(array $agent, array $dates): void
{
$hors = AgentModel::datesOutsideContract($agent, $dates);
if ($hors === []) {
return;
}
JsonResponse::send(422, [
'error' => AgentModel::contractViolationMessage($agent, $hors),
'code' => 'HORS_CONTRAT',
'dates' => $hors,
]);
}
/**
* Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ.
* Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à
* être contrôlés, aucun créneau ne peut y être posé.
*
* @return array<int, string>
*/
private function workingDaysOfWeek(int $year, int $weekNumber): array
{
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
return array_map(
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
range(0, 4)
);
}
private function addControl(array &$controls, string $level, string $code, string $message): void private function addControl(array &$controls, string $level, string $code, string $message): void
{ {
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

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

View File

@@ -7,438 +7,61 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\Security; use App\Core\Security;
use App\Core\Session; use App\Models\AgentModel;
use App\Models\CompteModel; use App\Models\StructureModel;
use App\Models\JournalModel;
use DomainException;
use PDO; use PDO;
/**
* Connexion — point d'entrée de l'application (role.php).
*
* JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son
* rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une
* session ne peut naître que de la présentation d'un JETON valide, distribué par
* la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours
* les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur.
*
* La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application
* — Service Enfance (qui valide les plannings de toute la collectivité) et DSI
* (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets
* sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la
* DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire
* à sa première connexion et n'est connu que de lui. La DSI ne peut que
* l'effacer, jamais le lire ni le remplacer.
*
* La connexion se fait donc en deux étapes, matérialisées par $etape :
* « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ;
* « definition » — premier accès d'un compte privilégié : choisir son mot de passe ;
* « verification » — accès suivants : le saisir ;
* « changement » — session ouverte, le titulaire remplace son mot de passe.
*
* Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente
* horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé
* sans mot de passe ne donne accès à aucune page.
*
* Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT
* tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un.
*/
final class RoleController extends Controller final class RoleController extends Controller
{ {
/** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */
private const FENETRE_MINUTES = 15;
private const ECHECS_MAX = 10;
/** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */
private const ATTENTE_KEY = 'pta_auth_attente';
private const ATTENTE_SECONDES = 300;
private CompteModel $comptes;
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
$this->comptes = new CompteModel($pdo);
} }
/**
* Affiche l'écran de connexion et traite ses chemins, faute de routeur :
* - GET ?action=reset : déconnexion, puis retour à cet écran ;
* - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ;
* - GET ?token=… : première étape par lien direct ;
* - POST etape=mot_de_passe : seconde étape (définition ou vérification) ;
* - POST : première étape par le champ de saisie.
*
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
* données arrivent en formulaire classique et un échec doit produire une page
* HTML, pas une réponse JSON.
*/
public function index(): void public function index(): void
{ {
$access = new Access($this->pdo); $access = new Access($this->pdo);
// Le changement de rôle modifie l'état de la session : il est protégé
// par le jeton CSRF au même titre que les écritures de l'API.
if (($_GET['action'] ?? '') === 'reset') { if (($_GET['action'] ?? '') === 'reset') {
$this->deconnecter($access);
}
$error = '';
$notice = '';
$etape = 'jeton';
$methode = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
// Changement volontaire du mot de passe : le seul chemin qui exige une
// session DÉJÀ ouverte.
if (($_GET['action'] ?? '') === 'mot_de_passe' || ($_POST['etape'] ?? '') === 'changement') {
[$etape, $error, $notice] = $this->changerMotDePasse($access, $methode);
} elseif ($methode === 'POST') {
if (!$this->csrfValide()) {
$error = 'La session a expiré. Rechargez la page et recommencez.';
$etape = $this->attente() !== null ? $this->etapeAttendue() : 'jeton';
} elseif (($_POST['etape'] ?? '') === 'mot_de_passe') {
[$etape, $error] = $this->traiterMotDePasse($access);
} else {
[$etape, $error] = $this->connecter($access, (string) ($_POST['token'] ?? ''), 'FORMULAIRE');
}
} elseif (isset($_GET['token'])) {
[$etape, $error] = $this->connecter($access, (string) $_GET['token'], 'LIEN');
} elseif ($this->attente() !== null) {
// Rechargement de la page au milieu de la connexion : on reste sur la
// seconde étape plutôt que de renvoyer l'utilisateur à son jeton.
$etape = $this->etapeAttendue();
}
$attente = $this->attente();
Security::sendHtmlHeaders();
$this->render('pages/role', [
'pageTitle' => 'Connexion',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'error' => $error,
'notice' => $notice,
'etape' => $etape,
'titulaire' => (string) ($attente['libelle'] ?? ''),
'longueurMinimale' => CompteModel::MOT_DE_PASSE_MINIMUM,
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
'homeUrl' => $access->isConfigured() ? $access->homeUrl() : 'role.php',
]);
}
/**
* Première étape : vérifie le jeton.
*
* Ne revient QUE si la connexion n'est pas allée à son terme — une connexion
* complète redirige et termine le script.
*
* @param string $origine « FORMULAIRE » ou « LIEN », conservé au journal :
* c'est ce qui permet à la DSI de voir si les liens
* directs circulent.
*
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
*/
private function connecter(Access $access, string $jetonBrut, string $origine): array
{
$jeton = CompteModel::normaliser($jetonBrut);
if ($jeton === '') {
return ['jeton', 'Saisissez votre jeton d’accès.'];
}
$blocage = $this->blocageEnCours();
if ($blocage !== '') {
return ['jeton', $blocage];
}
$compte = $this->comptes->authentifier($jeton);
if ($compte === null) {
// Ni le jeton présenté ni aucun fragment ne sont journalisés : une
// trace du secret dans le journal en ferait un secret de moins.
Journal::connexion('JETON_REFUSE', [
'resultat' => 'REFUS',
'description' => 'Jeton d’accès inconnu, révoqué ou porté par une identité inactive.',
'contexte' => ['origine' => $origine],
]);
return ['jeton', 'Ce jeton d’accès est inconnu ou n’est plus valide. Rapprochez-vous de la DSI.'];
}
// Agents et responsables : le jeton suffit, la session s'ouvre ici.
if (!CompteModel::exigeMotDePasse($compte)) {
$this->ouvrirSession($access, $compte, ['jeton'], $origine);
}
// Comptes privilégiés : on retient l'attente et on demande le second
// facteur. Rien n'est ouvert tant qu'il n'est pas fourni.
$this->poserAttente($compte, $origine);
Journal::connexion('JETON_ACCEPTE_ATTENTE_MDP', [
'id_compte' => (int) $compte['id_compte'],
'role' => (string) $compte['role'],
'acteur' => (string) $compte['identite_libelle'],
'description' => 'Jeton accepté ; mot de passe attendu pour ouvrir la session.',
'contexte' => ['origine' => $origine],
]);
return [$this->etapeAttendue(), ''];
}
/**
* Seconde étape : définition ou vérification du mot de passe.
*
* Le compte est RELU EN BASE plutôt que repris de la session : entre les deux
* étapes, la DSI a pu le désactiver, ou l'agent qui le porte être fermé.
*
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
*/
private function traiterMotDePasse(Access $access): array
{
$attente = $this->attente();
if ($attente === null) {
return ['jeton', 'La connexion a expiré. Saisissez de nouveau votre jeton d’accès.'];
}
$compte = $this->comptes->trouverPourAuthentification((int) $attente['compte_id']);
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
$this->oublierAttente();
return ['jeton', 'Ce compte n’est plus accessible. Rapprochez-vous de la DSI.'];
}
$motDePasse = (string) ($_POST['mot_de_passe'] ?? '');
// Premier accès : le titulaire choisit son mot de passe. Personne ne le
// lui a transmis, et personne ne pourra le lire ensuite.
if (!CompteModel::motDePasseDefini($compte)) {
try {
$this->comptes->definirMotDePasse(
(int) $compte['id_compte'],
$motDePasse,
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
);
} catch (DomainException $e) {
return ['definition', $e->getMessage()];
}
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine'], true);
}
$blocage = $this->blocageEnCours();
if ($blocage !== '') {
return ['verification', $blocage];
}
if (!$this->comptes->verifierMotDePasse($compte, $motDePasse)) {
Journal::connexion('MOT_DE_PASSE_REFUSE', [
'resultat' => 'REFUS',
'id_compte' => (int) $compte['id_compte'],
'role' => (string) $compte['role'],
'acteur' => (string) $compte['identite_libelle'],
'description' => 'Mot de passe incorrect après un jeton valide.',
]);
return ['verification', 'Mot de passe incorrect.'];
}
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine']);
return ['jeton', '']; // Inatteignable : ouvrirSession() redirige.
}
/**
* Changement de mot de passe par son titulaire, session ouverte.
*
* L'ancien mot de passe est exigé : sans lui, un poste laissé déverrouillé
* suffirait à confisquer un compte.
*
* @return array{0: string, 1: string, 2: string} [étape, erreur, message de succès]
*/
private function changerMotDePasse(Access $access, string $methode): array
{
if (!$access->isConfigured()) {
return ['jeton', 'Connectez-vous avant de changer votre mot de passe.', ''];
}
$compte = $this->comptes->trouverPourAuthentification((int) $access->accountId());
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
return ['jeton', 'Votre type de compte n’utilise pas de mot de passe.', ''];
}
if ($methode !== 'POST') {
return ['changement', '', ''];
}
if (!$this->csrfValide()) {
return ['changement', 'La session a expiré. Rechargez la page et recommencez.', ''];
}
if (!$this->comptes->verifierMotDePasse($compte, (string) ($_POST['mot_de_passe_actuel'] ?? ''))) {
Journal::refus('MOT_DE_PASSE_REFUSE', 'Mot de passe actuel incorrect lors d’un changement.');
return ['changement', 'Le mot de passe actuel est incorrect.', ''];
}
try {
$this->comptes->definirMotDePasse(
(int) $compte['id_compte'],
(string) ($_POST['mot_de_passe'] ?? ''),
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
);
} catch (DomainException $e) {
return ['changement', $e->getMessage(), ''];
}
Journal::ecriture(
'MOT_DE_PASSE_MODIFIE',
'compte_acces',
(int) $compte['id_compte'],
'Mot de passe changé par son titulaire.'
);
return ['changement', '', 'Votre mot de passe a été modifié.'];
}
/**
* Ouvre la session et redirige : cette méthode NE REVIENT JAMAIS.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
* @param array<int, string> $facteurs Facteurs réellement présentés.
* @param bool $premier Vrai si le mot de passe vient d'être défini.
*/
private function ouvrirSession(Access $access, array $compte, array $facteurs, string $origine, bool $premier = false): never
{
$this->oublierAttente();
$access->openSession($compte);
$this->comptes->enregistrerConnexion((int) $compte['id_compte']);
if ($premier) {
// Journalisé APRÈS l'ouverture de session, pour que la trace porte le
// nom du titulaire plutôt que d'être anonyme.
Journal::ecriture(
'MOT_DE_PASSE_DEFINI',
'compte_acces',
(int) $compte['id_compte'],
'Mot de passe défini par son titulaire à sa première connexion.'
);
}
Journal::connexion('CONNEXION_REUSSIE', [
'description' => sprintf(
'%s s’est connecté (%s).',
(string) ($compte['identite_libelle'] ?? $compte['libelle']),
(string) $compte['role']
),
'contexte' => ['origine' => $origine, 'facteurs' => $facteurs],
]);
// La redirection efface le jeton de la barre d'adresse quand il est
// arrivé par un lien direct.
header('Location: ' . $access->homeUrl(), true, 302);
exit;
}
/** Déconnexion : trace d'abord, vide la session ensuite, redirige enfin. */
private function deconnecter(Access $access): never
{
$provided = (string) ($_GET['csrf_token'] ?? ''); $provided = (string) ($_GET['csrf_token'] ?? '');
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) { if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
if ($access->isConfigured()) {
// L'ordre compte : après clear(), la session ne dit plus qui
// vient de partir et la trace serait anonyme.
Journal::connexion('DECONNEXION', ['description' => 'Fin de session à la demande de l’utilisateur.']);
}
$this->oublierAttente();
$access->clear(); $access->clear();
} }
header('Location: role.php', true, 302); header('Location: role.php', true, 302);
exit; exit;
} }
/** $error = '';
* Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
* aucun secret n'est même comparé. $expected = Csrf::ensureToken();
*
* @return string Message à afficher, vide si la voie est libre.
*/
private function blocageEnCours(): string
{
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES);
if ($echecs < self::ECHECS_MAX) {
return '';
}
// Le blocage est tracé pour que la DSI voie une attaque en cours plutôt
// que d'avoir à la déduire.
Journal::connexion('CONNEXION_BLOQUEE', [
'resultat' => 'REFUS',
'description' => sprintf(
'Trop de secrets refusés depuis %s : connexion bloquée.',
$ip !== '' ? $ip : 'une adresse inconnue'
),
'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES],
]);
return sprintf(
'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.',
self::FENETRE_MINUTES
);
}
/**
* Mémorise le compte reconnu par son jeton, en attendant son mot de passe.
*
* @param array<string, mixed> $compte
*/
private function poserAttente(array $compte, string $origine): void
{
Session::start();
// Regénération de l'identifiant AVANT de poser l'attente : un identifiant
// de session imposé par un tiers ne doit pas porter cette étape.
session_regenerate_id(true);
$_SESSION[self::ATTENTE_KEY] = [
'compte_id' => (int) $compte['id_compte'],
'libelle' => (string) $compte['identite_libelle'],
'definition' => !CompteModel::motDePasseDefini($compte),
'origine' => $origine,
'expire' => time() + self::ATTENTE_SECONDES,
];
}
/**
* Attente en cours, ou null si elle est absente ou périmée.
*
* @return array<string, mixed>|null
*/
private function attente(): ?array
{
Session::start();
$attente = $_SESSION[self::ATTENTE_KEY] ?? null;
if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) {
$this->oublierAttente();
return null;
}
return $attente;
}
private function oublierAttente(): void
{
Session::start();
unset($_SESSION[self::ATTENTE_KEY]);
}
/** « definition » au premier accès, « verification » ensuite. */
private function etapeAttendue(): string
{
return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification';
}
/** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */
private function csrfValide(): bool
{
$provided = (string) ($_POST['csrf_token'] ?? ''); $provided = (string) ($_POST['csrf_token'] ?? '');
if ($provided === '' || !hash_equals($expected, $provided)) {
$error = 'La session a expiré. Rechargez la page et recommencez.';
} else {
try {
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
header('Location: ' . $access->homeUrl(), true, 302);
exit;
} catch (\DomainException $e) {
$error = $e->getMessage();
}
}
}
return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided); Security::sendHtmlHeaders();
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'error' => $error,
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
]);
} }
} }

View File

@@ -6,7 +6,6 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\PdfResponse; use App\Core\PdfResponse;
use App\Core\Request; use App\Core\Request;
@@ -18,15 +17,6 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
*
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
*
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
* modification réservées au Service Enfance.
*/
final class StructureController final class StructureController
{ {
private StructureModel $structureModel; private StructureModel $structureModel;
@@ -42,19 +32,6 @@ final class StructureController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
*
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
*
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
* agent : une seule lecture, puis regroupement en mémoire.
*
* Paramètres GET : structure_id, week.
*/
public function overview(): void public function overview(): void
{ {
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
@@ -102,16 +79,6 @@ final class StructureController
]); ]);
} }
/**
* GET api/structure_planning_pdf.php — même vue, au format PDF.
*
* Reprend volontairement le calcul de overview() plutôt que de le
* factoriser : les deux méthodes doivent rester alignées, toute évolution
* de l'une appelle la même dans l'autre.
*
* Endpoint PDF : les erreurs sortent en texte brut avec un code HTTP, jamais
* en JSON (voir AgentController).
*/
public function pdf(): void public function pdf(): void
{ {
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT); $structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
@@ -164,20 +131,6 @@ final class StructureController
); );
} }
/**
* POST api/create_structure.php — création d'un lieu d'affectation.
*
* Le code est normalisé avant contrôle : espaces remplacés par des
* underscores, puis passage en majuscules. Il sert d'identifiant lisible
* (nom de fichier PDF, exports), d'où le format restreint à
* [A-Z0-9_-]{2,50}. Un doublon ressort en 409, pas en 422 : c'est un conflit
* avec l'existant.
*
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) détermine le contexte
* par défaut du lieu ; il reste croisé avec le calendrier des vacances.
*
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
*/
public function create(): void public function create(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -216,28 +169,12 @@ final class StructureController
]); ]);
} }
Journal::ecriture(
'STRUCTURE_CREEE',
'structure',
isset($structure['id_structure']) ? (int) $structure['id_structure'] : null,
sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code),
['code' => $code, 'type_affectation' => $typeAffectation]
);
JsonResponse::send(201, [ JsonResponse::send(201, [
'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name), 'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name),
'structure' => $structure, 'structure' => $structure,
]); ]);
} }
/**
* POST api/update_structure.php — change le type d'un lieu.
*
* Seul le type est modifiable : le code sert de référence stable, et le nom
* n'est pas exposé à l'édition dans cette version.
*
* Charge utile : structure_id, type_affectation, csrf_token.
*/
public function updateType(): void public function updateType(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -277,11 +214,6 @@ final class StructureController
]); ]);
} }
/**
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

@@ -6,7 +6,6 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\Request; use App\Core\Request;
use App\Models\AgentModel; use App\Models\AgentModel;
@@ -16,21 +15,6 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Équipes des mercredis et des vacances.
*
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
* pouvant évoluer.
*
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
* BROUILLON → A_CONTROLER → VALIDEE.
*
* Réservé au Service Enfance.
*/
final class TeamController final class TeamController
{ {
private TeamModel $teamModel; private TeamModel $teamModel;
@@ -46,21 +30,6 @@ final class TeamController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
*
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
* actives sur une période.
*
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
* ratio à zéro provoquerait une division par zéro au calcul du taux
* d'encadrement.
*
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
* date_fin, effectifs et ratios facultatifs, csrf_token.
*/
public function index(): void public function index(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -115,33 +84,12 @@ final class TeamController
'pourcentage_diplomes_min' => $qualified, 'pourcentage_diplomes_min' => $qualified,
'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null, 'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null,
]); ]);
Journal::ecriture(
'EQUIPE_CREEE',
'equipe_pta',
(int) $id,
sprintf('Création de l’équipe « %s » sur le lieu #%d.', $label, (int) $structureId),
['id_structure' => (int) $structureId, 'type_equipe' => $type]
);
JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]); JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]);
} catch (Throwable $e) { } catch (Throwable $e) {
JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]); JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]);
} }
} }
/**
* POST api/team_members.php — ajoute ou retire un agent d'une équipe.
*
* `action` vaut ADD (défaut) ou REMOVE. Trois fonctions possibles :
* RESPONSABLE, ANIMATION, RESTAURATION_ENTRETIEN — elles conditionnent le
* calcul du taux d'encadrement.
*
* L'exception d'ajout ressort en 422 avec SON message : le modèle y place
* les refus métier lisibles (agent déjà membre, par exemple), destinés à
* l'utilisateur.
*
* Charge utile : action, team_id, agent_id, fonction_equipe, commentaire,
* csrf_token.
*/
public function member(): void public function member(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -173,28 +121,12 @@ final class TeamController
$function, $function,
trim((string) ($payload['commentaire'] ?? '')) ?: null trim((string) ($payload['commentaire'] ?? '')) ?: null
); );
Journal::ecriture(
'EQUIPE_MEMBRE_AJOUTE',
'equipe_pta_membre',
(int) $teamId,
sprintf('Agent #%d ajouté à l’équipe #%d (%s).', (int) $agentId, (int) $teamId, $function),
['id_agent' => (int) $agentId, 'fonction_equipe' => $function]
);
JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']); JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']);
} catch (Throwable $e) { } catch (Throwable $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]); JsonResponse::send(422, ['error' => $e->getMessage()]);
} }
} }
/**
* POST api/team_status.php — fait avancer une équipe dans son cycle de vie.
*
* Aucune transition n'est imposée : tout statut valide est acceptable depuis
* n'importe quel autre, y compris un retour en arrière. Le suivi est
* indicatif, pas un verrou.
*
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
*/
public function status(): void public function status(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -207,13 +139,6 @@ final class TeamController
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']); JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
} }
$this->teamModel->setStatus((int) $teamId, $status); $this->teamModel->setStatus((int) $teamId, $status);
Journal::ecriture(
'EQUIPE_STATUT_MODIFIE',
'equipe_pta',
(int) $teamId,
sprintf('Équipe #%d passée au statut %s.', (int) $teamId, $status),
['statut' => $status]
);
JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']); JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']);
} }
} }

View File

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

View File

@@ -6,42 +6,16 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\Request; use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\QuotaModel; use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\WeekTemplateModel; use App\Models\WeekTemplateModel;
use DateTimeImmutable; use DateTimeImmutable;
use DomainException; use DomainException;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
*
* Une semaine type n'est PAS un planning : elle décrit une organisation
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
*
* Deux origines pour une semaine type, qui convergent vers
* WeekTemplateModel::persistTemplate() :
* - store() : capture d'une semaine réellement planifiée ;
* - storeEntries() : composition directe dans l'éditeur.
*
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
*
* Convention de codes d'erreur propre à ce contrôleur :
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
* alors de le remplacer en renvoyant replace: true) ;
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
* reformulation de ce message dans WeekTemplateModel casse la distinction.
*/
final class WeekTemplateController final class WeekTemplateController
{ {
private WeekTemplateModel $templateModel; private WeekTemplateModel $templateModel;
@@ -57,12 +31,6 @@ final class WeekTemplateController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/week_templates.php — liste des semaines types d'un agent.
*
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
* show() sert à ouvrir.
*/
public function index(): void public function index(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -87,152 +55,6 @@ final class WeekTemplateController
JsonResponse::send(200, ['templates' => $templates]); JsonResponse::send(200, ['templates' => $templates]);
} }
/**
* GET api/week_template.php — contenu détaillé d'une semaine type.
*
* Le modèle est retrouvé par (template_id, agent_id) et non par le seul
* identifiant : c'est ce qui empêche de lire le modèle d'un autre agent en
* changeant l'identifiant dans l'URL.
*
* Les drapeaux `structure_active` et `motif_actif` sont conservés bien que
* toujours vrais à la création : un lieu ou un motif peut être désactivé
* APRÈS l'enregistrement du modèle, et l'éditeur doit le signaler plutôt que
* d'échouer au moment de l'application.
*/
public function show(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
if (!$agentId || !$templateId) {
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
}
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
if ($template === null) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
}
$entries = array_map(static function (array $entry): array {
return [
'jour_semaine' => (int) $entry['jour_semaine'],
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
// L'éditeur manipule des heures au format HH:MM.
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
'structure_nom' => (string) $entry['structure_nom'],
'structure_active' => (int) $entry['structure_actif'] === 1,
'motif_libelle' => (string) $entry['motif_libelle'],
'motif_actif' => (int) $entry['motif_actif'] === 1,
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
];
}, $this->templateModel->entries((int) $templateId));
JsonResponse::send(200, [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'updated_at' => (string) $template['date_modification'],
],
'entries' => $entries,
]);
}
/**
* POST api/save_week_template_entries.php — enregistre un modèle composé
* directement dans l'éditeur (V7.10).
*
* Les listes de motifs et de lieux autorisés sont calculées ICI puis
* transmises au modèle : celui-ci ne connaît pas les règles d'accès et se
* contente de rejeter ce qui n'est pas dans les listes reçues.
*
* Charge utile : agent_id, name, entries[], replace (bool), csrf_token.
*/
public function storeEntries(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$name = (string) ($payload['name'] ?? '');
$entries = $payload['entries'] ?? null;
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !is_array($entries)) {
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
$allowedStructureIds = array_map(
static fn (array $structure): int => (int) $structure['id_structure'],
(new StructureModel($this->pdo))->allActive()
);
try {
$template = $this->templateModel->saveFromEntries(
(int) $agentId,
$name,
$entries,
$allowedMotifIds,
$allowedStructureIds,
$replace
);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer la semaine type.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
Journal::ecriture(
'SEMAINE_TYPE_ENREGISTREE',
'modele_semaine_agent',
(int) $template['id_modele'],
sprintf('Semaine type « %s » enregistrée depuis l’éditeur pour l’agent #%d.', (string) $template['nom'], (int) $agentId),
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
);
JsonResponse::send(200, [
'success' => true,
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
],
'message' => sprintf(
'La semaine type « %s » a été enregistrée avec %d plage%s.',
$template['nom'],
$template['nombre_creneaux'],
$template['nombre_creneaux'] > 1 ? 's' : ''
),
]);
}
/**
* POST api/save_week_template.php — capture une semaine planifiée en modèle.
*
* Point de départ historique : l'utilisateur saisit une semaine « normale »,
* puis la fige comme modèle réutilisable.
*
* Charge utile : agent_id, week, name, replace (bool), csrf_token.
*/
public function store(): void public function store(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -268,14 +90,6 @@ final class WeekTemplateController
]); ]);
} }
Journal::ecriture(
'SEMAINE_TYPE_ENREGISTREE',
'modele_semaine_agent',
isset($template['id_modele']) ? (int) $template['id_modele'] : null,
sprintf('Semaine type « %s » enregistrée depuis une semaine planifiée pour l’agent #%d.', $name, (int) $agentId),
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'template' => $template, 'template' => $template,
@@ -283,15 +97,6 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
*
* Deux modes : `merge` complète les créneaux existants, `replace` les
* remplace. Appliquer sur une semaine validée la repasse en brouillon
* (`requires_revalidation`).
*
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
*/
public function apply(): void public function apply(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -307,18 +112,12 @@ final class WeekTemplateController
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) { if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']); JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
} }
$agent = $this->agentModel->findActive((int) $agentId); if ($this->agentModel->findActive((int) $agentId) === null) {
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
} }
[$year, $weekNumber] = $week;
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
$this->assertDatesWithinContract($agent, array_map(
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
range(0, 4)
));
try { try {
[$year, $weekNumber] = $week;
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode); $result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
} catch (DomainException $e) { } catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]); JsonResponse::send(409, ['error' => $e->getMessage()]);
@@ -341,27 +140,6 @@ final class WeekTemplateController
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; $message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
} }
Journal::ecriture(
'SEMAINE_TYPE_APPLIQUEE',
'planning',
null,
sprintf(
'Semaine type « %s » appliquée à l’agent #%d sur la semaine %d de %d : %d créneau(x).',
(string) $result['template']['nom'],
(int) $agentId,
$weekNumber,
$year,
(int) $result['inserted']
),
[
'id_agent' => (int) $agentId,
'id_modele' => (int) $templateId,
'annee' => $year,
'semaine' => $weekNumber,
'mode' => $mode,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -373,21 +151,6 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/preview_week_template_period.php — simulation SANS écriture.
*
* Étape obligatoire dans l'interface avant applyPeriod() : une application
* sur période touche des dizaines de semaines et peut rouvrir des plannings
* validés. L'utilisateur doit voir le volume avant de confirmer.
*
* `period_filter` restreint le périmètre au calendrier scolaire :
* all → toutes les semaines de l'intervalle ;
* school → semaines scolaires seulement (périscolaire) ;
* extra → vacances scolaires seulement (extrascolaire).
*
* Charge utile : template_id, agent_id, start_date, end_date, mode,
* period_filter, csrf_token.
*/
public function previewPeriod(): void public function previewPeriod(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -412,11 +175,9 @@ final class WeekTemplateController
) { ) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
} }
$agent = $this->agentModel->findActive((int) $agentId); if ($this->agentModel->findActive((int) $agentId) === null) {
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
} }
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
try { try {
$preview = $this->templateModel->previewPeriod( $preview = $this->templateModel->previewPeriod(
@@ -445,18 +206,6 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/apply_week_template_period.php — applique un modèle sur une période.
*
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
*
* Les quotas sont renvoyés indexés par date de début de période : une
* application à cheval sur deux années civiles touche DEUX quotas annuels
* distincts, et l'interface doit rafraîchir les deux.
*/
public function applyPeriod(): void public function applyPeriod(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -481,11 +230,9 @@ final class WeekTemplateController
) { ) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']); JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
} }
$agent = $this->agentModel->findActive((int) $agentId); if ($this->agentModel->findActive((int) $agentId) === null) {
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
} }
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
try { try {
$result = $this->templateModel->applyToPeriod( $result = $this->templateModel->applyToPeriod(
@@ -547,27 +294,6 @@ final class WeekTemplateController
$quotas[(string) $quota['date_debut_periode']] = $quota; $quotas[(string) $quota['date_debut_periode']] = $quota;
} }
Journal::ecriture(
'SEMAINE_TYPE_APPLIQUEE_PERIODE',
'planning',
null,
sprintf(
'Semaine type #%d appliquée à l’agent #%d du %s au %s.',
(int) $templateId,
(int) $agentId,
$startDate,
$endDate
),
[
'id_agent' => (int) $agentId,
'id_modele' => (int) $templateId,
'date_debut' => $startDate,
'date_fin' => $endDate,
'mode' => $mode,
'filtre_periode' => $periodFilter,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -577,14 +303,6 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/delete_week_template.php — supprime une semaine type.
*
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
* patron, pas un lien vivant vers les plannings produits.
*/
public function destroy(): void public function destroy(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -602,67 +320,9 @@ final class WeekTemplateController
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']); JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
} }
Journal::ecriture(
'SEMAINE_TYPE_SUPPRIMEE',
'modele_semaine_agent',
(int) $templateId,
sprintf('Semaine type #%d supprimée pour l’agent #%d.', (int) $templateId, (int) $agentId),
['id_agent' => (int) $agentId]
);
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']); JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
} }
/**
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
/**
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
*
* Copie assumée de PlanningController::assertDatesWithinContract() : les deux
* contrôleurs n'ont pas de base commune, et la règle doit s'appliquer des
* deux côtés. Le MESSAGE, lui, est construit une seule fois par AgentModel —
* c'est lui qui doit rester unique, pas la garde.
*
* @param array<string, mixed> $agent
* @param array<int, string> $dates
*/
private function assertDatesWithinContract(array $agent, array $dates): void
{
$hors = AgentModel::datesOutsideContract($agent, $dates);
if ($hors === []) {
return;
}
JsonResponse::send(422, [
'error' => AgentModel::contractViolationMessage($agent, $hors),
'code' => 'HORS_CONTRAT',
'dates' => $hors,
]);
}
/**
* Même règle, pour une PÉRIODE entière.
*
* Seules les bornes sont contrôlées, et c'est suffisant : le contrat est un
* intervalle continu, donc une période dont les deux extrémités y tombent y
* est entièrement contenue. Contrôler chacun des jours d'une duplication
* annuelle coûterait 365 comparaisons pour la même réponse.
*
* Le refus porte volontairement sur la période DEMANDÉE et non sur les seuls
* jours réellement écrits : personne ne doit avoir à deviner qu'une
* duplication « du 1er janvier au 31 décembre » n'a rien produit en novembre
* parce que le contrat s'arrêtait en octobre.
*
* @param array<string, mixed> $agent
*/
private function assertPeriodWithinContract(array $agent, string $startDate, string $endDate): void
{
$this->assertDatesWithinContract($agent, [$startDate, $endDate]);
}
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

@@ -16,90 +16,43 @@ use PDO;
*/ */
final class Access final class Access
{ {
/** Lecture seule sur son propre planning. */
public const ROLE_AGENT = 'AGENT'; public const ROLE_AGENT = 'AGENT';
/** Édition des brouillons de son seul lieu d'affectation. */
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE'; public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
/** Périmètre complet ; seul rôle habilité à valider un planning. */
public const ROLE_SERVICE = 'SERVICE_ENFANCE'; public const ROLE_SERVICE = 'SERVICE_ENFANCE';
/**
* Administration technique (V7.11) : comptes d'accès, jetons et journal.
* Aucun droit métier — la DSI ne voit ni les plannings, ni les agents.
*/
public const ROLE_DSI = 'DSI';
private const SESSION_KEY = 'pta_access'; private const SESSION_KEY = 'pta_access';
/** @var array<int, string> Les quatre rôles connus de l'application. */ /** @var array<string, array<string, bool>> */
private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI];
/**
* Caches par requête : les vues d'accès sont interrogées une seule fois par
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
* qu'une modification SQL des droits s'applique sans redéploiement.
*
* @var array<string, array<string, bool>>
*/
private array $permissionCache = []; private array $permissionCache = [];
/** @var array<string, array<int, string>> */ /** @var array<string, array<int, string>> */
private array $pageCache = []; private array $pageCache = [];
/**
* Compte d'accès de la session, relu UNE FOIS par requête.
*
* Il est relu à chaque requête et non conservé en session : c'est ce qui
* fait qu'un jeton désactivé par la DSI coupe l'accès à la requête suivante,
* sans attendre l'expiration de la session.
*
* @var array<string, mixed>|null
*/
private ?array $accountCache = null;
private bool $accountLoaded = false;
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
Session::start(); Session::start();
} }
/**
* Vrai si la sélection en session est complète et pointe encore vers une
* identité active. Un agent ou un lieu désactivé en base fait donc retomber
* l'utilisateur sur role.php à la requête suivante.
*/
public function isConfigured(): bool public function isConfigured(): bool
{ {
$role = $this->role(); $role = $this->role();
if ($role === null) {
return false;
}
// Depuis la V7.11, toute session est adossée à un compte d'accès actif :
// pas de compte vivant, pas de session. Un jeton révoqué, un agent
// désactivé ou un lieu fermé referment donc l'accès immédiatement.
$compte = $this->accountRecord();
if ($compte === null) {
return false;
}
if ($role === self::ROLE_AGENT) { if ($role === self::ROLE_AGENT) {
return $this->agentId() !== null && $this->agentRecord() !== null; return $this->agentId() !== null && $this->agentRecord() !== null;
} }
if ($role === self::ROLE_RESPONSABLE) { if ($role === self::ROLE_RESPONSABLE) {
return $this->structureId() !== null && $this->structureRecord() !== null; return $this->structureId() !== null && $this->structureRecord() !== null;
} }
return true; return $role === self::ROLE_SERVICE;
} }
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
public function role(): ?string public function role(): ?string
{ {
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? ''))); $role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
return in_array($role, self::ROLES, true) ? $role : null; return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
? $role
: null;
} }
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
public function agentId(): ?int public function agentId(): ?int
{ {
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [ $value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
@@ -108,13 +61,6 @@ final class Access
return $value === false ? null : (int) $value; return $value === false ? null : (int) $value;
} }
/**
* Lieu de référence de la session.
*
* Pour un AGENT, il n'est pas stocké en session mais dérivé de son
* rattachement en base : l'agent ne le choisit pas, et le déplacer d'un lieu
* à l'autre doit prendre effet immédiatement.
*/
public function structureId(): ?int public function structureId(): ?int
{ {
if ($this->role() === self::ROLE_AGENT) { if ($this->role() === self::ROLE_AGENT) {
@@ -130,98 +76,40 @@ final class Access
return $value === false ? null : (int) $value; return $value === false ? null : (int) $value;
} }
/** public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
* Ouvre la session de travail à partir d'un compte d'accès authentifié.
*
* DEPUIS LA V7.11, C'EST LE SEUL CHEMIN D'ENTRÉE : l'ancien sélecteur libre
* (select()) a disparu, une session ne peut plus naître que de la
* présentation d'un jeton valide. Le rôle, l'agent et le lieu ne sont plus
* choisis par l'utilisateur mais LUS SUR LE COMPTE — c'est ce qui rend la
* mécanique de droits opposable.
*
* La vérification du jeton elle-même appartient à CompteModel : cette
* méthode reçoit un compte déjà authentifié.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public function openSession(array $compte): void
{ {
// Regénération de l'identifiant de session à chaque ouverture : parade $role = strtoupper(trim($role));
// classique à la fixation de session. if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
session_regenerate_id(true); throw new \DomainException('Rôle invalide.');
}
$_SESSION[self::SESSION_KEY] = [
'role' => (string) $compte['role'], $selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
? (int) $compte['id_agent'] if ($role === self::ROLE_AGENT) {
: null, if (!$agentId || !$this->activeAgentExists($agentId)) {
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null throw new \DomainException('Sélectionnez un agent actif.');
? (int) $compte['id_structure'] }
: null, $selection['agent_id'] = $agentId;
'compte_id' => (int) $compte['id_compte'], } elseif ($role === self::ROLE_RESPONSABLE) {
// Libellé figé pour le journal : il évite une jointure à chaque trace. if (!$structureId || !$this->activeStructureExists($structureId)) {
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''), throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
]; }
$selection['structure_id'] = $structureId;
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle }
// connexion invalide le jeton précédent.
unset($_SESSION['csrf_token']); session_regenerate_id(true);
$this->accountCache = null; $_SESSION[self::SESSION_KEY] = $selection;
$this->accountLoaded = false; // Le jeton CSRF suit le cycle de vie de la session : un changement de
// rôle invalide le jeton précédent.
unset($_SESSION['csrf_token']);
} }
/** Ferme la session de travail (déconnexion). */
public function clear(): void public function clear(): void
{ {
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']); unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true); session_regenerate_id(true);
$this->accountCache = null;
$this->accountLoaded = false;
} }
/** Identifiant du compte d'accès de la session, ou null. */
public function accountId(): ?int
{
$value = filter_var($_SESSION[self::SESSION_KEY]['compte_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
/**
* Instantané de session destiné au JOURNAL, lisible sans instancier Access
* ni ouvrir de connexion.
*
* Il expose la clé de session privée sous une forme stable : Journal (et lui
* seul) a besoin de connaître l'acteur courant depuis un contexte statique,
* sans pouvoir dépendre d'une instance.
*
* @return array{id_compte: int|null, role: string|null, acteur: string|null}
*/
public static function sessionSnapshot(): array
{
$selection = $_SESSION[self::SESSION_KEY] ?? [];
$compteId = filter_var($selection['compte_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
$role = strtoupper(trim((string) ($selection['role'] ?? '')));
$acteur = trim((string) ($selection['acteur'] ?? ''));
return [
'id_compte' => $compteId === false ? null : (int) $compteId,
'role' => in_array($role, self::ROLES, true) ? $role : null,
'acteur' => $acteur === '' ? null : $acteur,
];
}
/**
* Profil affichable de la session, publié tel quel dans window.PTA_CONFIG
* par layouts/footer.php et réutilisé par les en-têtes de PDF.
*
* @return array<string, mixed> role, role_label, label, agent_id,
* structure_id, permissions, et selon le rôle
* matricule / structure_name / structure_code.
*/
public function profile(): array public function profile(): array
{ {
$role = $this->role(); $role = $this->role();
@@ -232,7 +120,6 @@ final class Access
'label' => $this->roleLabel(), 'label' => $this->roleLabel(),
'agent_id' => $this->agentId(), 'agent_id' => $this->agentId(),
'structure_id' => $this->structureId(), 'structure_id' => $this->structureId(),
'account_id' => $this->accountId(),
'permissions' => $permissions, 'permissions' => $permissions,
]; ];
@@ -251,14 +138,6 @@ final class Access
$profile['structure_name'] = (string) $structure['nom']; $profile['structure_name'] = (string) $structure['nom'];
$profile['structure_code'] = (string) $structure['code']; $profile['structure_code'] = (string) $structure['code'];
} }
} else {
// Service Enfance et DSI ne portent aucune identité métier : le
// libellé affiché est celui du compte (« Direction des systèmes
// d'information », « Service Enfance — Camille »…).
$compte = $this->accountRecord();
if ($compte !== null) {
$profile['label'] = (string) $compte['identite_libelle'];
}
} }
return $profile; return $profile;
@@ -293,7 +172,6 @@ final class Access
return $this->permissionCache[$role]; return $this->permissionCache[$role];
} }
/** Raccourci de lecture d'une permission ; absente = refusée. */
public function hasPermission(string $permission): bool public function hasPermission(string $permission): bool
{ {
return $this->permissions()[$permission] ?? false; return $this->permissions()[$permission] ?? false;
@@ -322,10 +200,6 @@ final class Access
return $this->pageCache[$role]; return $this->pageCache[$role];
} }
/**
* $page est la clé logique de la page (« planning », « dashboard »…), telle
* qu'inscrite dans acces_role_page et passée à runPage().
*/
public function canAccessPage(string $page): bool public function canAccessPage(string $page): bool
{ {
return in_array($page, $this->allowedPages(), true); return in_array($page, $this->allowedPages(), true);
@@ -346,19 +220,10 @@ final class Access
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(), self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(), self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php', self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
// La DSI n'a qu'une page ; le repli sur role.php évite la boucle de
// redirection si la migration V7.11 n'est pas jouée.
self::ROLE_DSI => $this->canAccessPage('dsi') ? 'dsi.php' : 'role.php',
default => 'role.php', default => 'role.php',
}; };
} }
/**
* Garde des pages HTML : redirige au lieu de renvoyer une erreur, puis
* TERMINE le script. Sans rôle configuré on renvoie vers le sélecteur ;
* page interdite, on renvoie vers l'accueil du rôle avec access_denied=1
* pour que l'interface puisse expliquer le refus.
*/
public function requirePage(string $page): void public function requirePage(string $page): void
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
@@ -366,108 +231,47 @@ final class Access
exit; exit;
} }
if (!$this->canAccessPage($page)) { if (!$this->canAccessPage($page)) {
Journal::refus(
'PAGE_REFUSEE',
sprintf('Accès refusé à la page « %s ».', $page),
['contexte' => ['page' => $page]]
);
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302); header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
exit; exit;
} }
} }
/**
* Garde des endpoints JSON : émet un 401/403 JSON et TERMINE le script.
* Toutes les gardes require*() ci-dessous en dérivent.
*
* @param array<int, string> $roles Rôles acceptés.
*/
public function requireRoles(array $roles): void public function requireRoles(array $roles): void
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
$this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [ JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
'endpoint' => $this->endpointCourant(),
]);
JsonResponse::send(401, ['error' => 'Connectez-vous avec votre jeton d’accès avant de continuer.', 'code' => 'ROLE_REQUIRED']);
} }
if (!in_array($this->role(), $roles, true)) { if (!in_array($this->role(), $roles, true)) {
$this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [
'endpoint' => $this->endpointCourant(),
'roles_attendus' => array_values($roles),
]);
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']); JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
} }
} }
/**
* Journalise un refus d'API. Méthode privée volontairement minuscule : elle
* n'existe que pour que les gardes ci-dessous tiennent en deux lignes.
*
* @param array<string, mixed> $contexte
*/
private function refuser(int $status, string $action, string $description, array $contexte = []): void
{
Journal::refus($action, $description, [
'contexte' => array_merge(['statut_http' => $status], $contexte),
]);
}
/** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */
private function endpointCourant(): string
{
$chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
return $chemin !== '' ? basename($chemin) : 'inconnu';
}
/** Réservé au Service Enfance : validation, référentiels, administration. */
public function requireService(): void public function requireService(): void
{ {
$this->requireRoles([self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_SERVICE]);
} }
/**
* Réservé à la DSI : gestion des comptes d'accès et lecture du journal.
*
* Cette garde double le contrôle de page : la page « dsi » n'est autorisée
* qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même
* si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL.
*/
public function requireDsi(): void
{
$this->requireRoles([self::ROLE_DSI]);
}
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
public function requireDraftEditor(): void public function requireDraftEditor(): void
{ {
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
} }
/** Consultation d'un lieu : hors Service Enfance, seul le lieu de rattachement. */
public function requireStructureView(int $structureId): void public function requireStructureView(int $structureId): void
{ {
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) { if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
$this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']); JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
} }
} }
/** Édition dans un lieu : un responsable est confiné au sien. */
public function requireStructureEdit(int $structureId): void public function requireStructureEdit(int $structureId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) { if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
$this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']); JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
} }
} }
/**
* Consultation d'un agent. Un responsable y a droit dès que l'agent est
* planifié chez lui, même s'il y est rattaché ailleurs : un agent peut
* travailler sur plusieurs lieux la même semaine (v_acces_agent_par_structure).
*/
public function requireAgentView(int $agentId): void public function requireAgentView(int $agentId): void
{ {
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
@@ -480,14 +284,9 @@ final class Access
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) { if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
return; return;
} }
$this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']); JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
} }
/**
* Édition d'un créneau, dont le périmètre est déduit du planning porteur.
* Un identifiant inconnu donne un 404 avant tout contrôle de rôle plus fin.
*/
public function requireEntryEdit(int $entryId): void public function requireEntryEdit(int $entryId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
@@ -505,7 +304,6 @@ final class Access
$this->requireStructureEdit((int) $structureId); $this->requireStructureEdit((int) $structureId);
} }
/** Édition d'un planning identifié, périmètre déduit de son lieu. */
public function requirePlanningEdit(int $planningId): void public function requirePlanningEdit(int $planningId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
@@ -523,74 +321,35 @@ final class Access
$this->requireStructureEdit((int) $structureId); $this->requireStructureEdit((int) $structureId);
} }
/**
* Lieu à pré-sélectionner et à verrouiller dans l'interface, ou null quand
* l'utilisateur peut choisir librement (Service Enfance).
*/
public function editableStructureId(): ?int public function editableStructureId(): ?int
{ {
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null; return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
} }
/** Libellé lisible du rôle, affiché dans l'en-tête et sur les PDF. */
private function roleLabel(): string private function roleLabel(): string
{ {
return match ($this->role()) { return match ($this->role()) {
self::ROLE_AGENT => 'Agent', self::ROLE_AGENT => 'Agent',
self::ROLE_RESPONSABLE => 'Responsable de structure', self::ROLE_RESPONSABLE => 'Responsable de structure',
self::ROLE_SERVICE => 'Service Enfance', self::ROLE_SERVICE => 'Service Enfance',
self::ROLE_DSI => 'DSI',
default => 'Rôle non sélectionné', default => 'Rôle non sélectionné',
}; };
} }
/** private function activeAgentExists(int $agentId): bool
* Compte d'accès de la session, relu une fois par requête.
*
* La vue ne renvoie le compte que s'il est actif ET que l'identité qu'il
* porte l'est aussi (`identite_active`) : désactiver un agent suffit donc à
* lui couper l'accès, sans avoir à toucher à son jeton.
*
* @return array<string, mixed>|null
*/
private function accountRecord(): ?array
{ {
if ($this->accountLoaded) { $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
return $this->accountCache; $stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false;
} }
$this->accountLoaded = true; private function activeStructureExists(int $structureId): bool
$this->accountCache = null; {
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
$compteId = $this->accountId(); $stmt->execute(['id' => $structureId]);
if ($compteId === null) { return $stmt->fetchColumn() !== false;
return null;
} }
$stmt = $this->pdo->prepare(
'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle
FROM v_acces_compte
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['id' => $compteId]);
$compte = $stmt->fetch();
// Le rôle de la session doit toujours correspondre à celui du compte :
// une session forgée ou survivant à un changement de rôle du compte est
// refusée plutôt que réparée.
if ($compte !== false && (string) $compte['role'] === $this->role()) {
$this->accountCache = $compte;
}
return $this->accountCache;
}
/**
* Fiche de l'agent en session.
*
* @return array<string, mixed>|null
*/
private function agentRecord(): ?array private function agentRecord(): ?array
{ {
$agentId = $this->agentId(); $agentId = $this->agentId();
@@ -606,11 +365,6 @@ final class Access
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Fiche du lieu en session.
*
* @return array<string, mixed>|null
*/
private function structureRecord(): ?array private function structureRecord(): ?array
{ {
$structureId = $this->structureId(); $structureId = $this->structureId();
@@ -626,10 +380,6 @@ final class Access
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Vrai si l'agent apparaît dans le périmètre du responsable : rattachement
* par défaut OU présence d'un planning dans son lieu.
*/
private function responsibleCanViewAgent(int $agentId): bool private function responsibleCanViewAgent(int $agentId): bool
{ {
$structureId = $this->structureId(); $structureId = $this->structureId();

View File

@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Contrôle de FORME des coordonnées d'un agent : adresse e-mail et numéro de
* téléphone. Source de vérité UNIQUE côté serveur — create() et update() de
* AgentController s'y réfèrent toutes les deux, et toute nouvelle voie
* d'écriture d'un contact doit faire de même plutôt que recopier une regex.
*
* Une copie de ces règles vit dans assets/js/field-format.js pour le retour
* immédiat à la saisie. C'est l'application habituelle du principe du projet :
* l'interface prévient, le serveur tranche. Modifier une règle ici impose donc
* de reporter la modification dans le module JS — l'inverse n'est jamais vrai.
*
* PORTÉE VOLONTAIREMENT LIMITÉE : ces deux champs sont FACULTATIFS et purement
* informatifs (ils n'ouvrent aucune session, n'envoient aucun message). Le
* contrôle attrape la faute de frappe, il ne prétend pas prouver qu'une boîte
* ou une ligne existe. Une valeur vide reste toujours acceptée.
*/
final class ContactFormat
{
/**
* Adresse e-mail. Regex délibérément plus stricte que la RFC 5322, qui
* autorise des formes que personne ne saisit dans un annuaire d'agents
* (commentaires, guillemets, adresses IP littérales). Elle est DOUBLÉE de
* FILTER_VALIDATE_EMAIL : la regex écarte ce que le filtre tolère (domaine
* sans point, par exemple « agent@mairie »), le filtre écarte ce que la
* regex laisserait passer.
*/
private const EMAIL_PATTERN = '/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/';
/**
* Numéro français, une fois les séparateurs retirés : 10 chiffres commençant
* par 0, ou la même chose en forme internationale (+33 / 0033). Couvre le
* fixe comme le mobile, métropole et outre-mer.
*/
private const PHONE_FR_PATTERN = '/^(?:\+33|0033|0)[1-9]\d{8}$/';
/**
* Repli international E.164 pour un agent joignable à l'étranger — refuser
* un numéro valide serait pire que d'en accepter un exotique, le champ
* n'étant qu'informatif.
*/
private const PHONE_INTERNATIONAL_PATTERN = '/^\+[1-9]\d{7,14}$/';
/** Séparateurs tolérés à la saisie : espaces (insécable compris), . - / ( ). */
private const PHONE_SEPARATORS = '/[\s.\-\/()\x{00A0}\x{202F}]/u';
/** Vrai si l'adresse e-mail est exploitable. La chaîne vide est refusée : c'est à l'appelant de traiter « non renseigné » avant. */
public static function isEmail(string $value): bool
{
$value = trim($value);
if ($value === '') {
return false;
}
return preg_match(self::EMAIL_PATTERN, $value) === 1
&& filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
}
/** Vrai si le numéro de téléphone est exploitable, séparateurs de saisie ignorés. */
public static function isPhone(string $value): bool
{
$compact = self::compactPhone($value);
if ($compact === '') {
return false;
}
return preg_match(self::PHONE_FR_PATTERN, $compact) === 1
|| preg_match(self::PHONE_INTERNATIONAL_PATTERN, $compact) === 1;
}
/**
* Numéro débarrassé de ses séparateurs, seule forme sur laquelle les regex
* travaillent. « 06 12 34 56 78 », « 06.12.34.56.78 » et « 0612345678 »
* décrivent la même ligne : les distinguer n'apporterait rien.
*/
public static function compactPhone(string $value): string
{
return (string) preg_replace(self::PHONE_SEPARATORS, '', trim($value));
}
}

View File

@@ -4,22 +4,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
/**
* Base des contrôleurs qui rendent une page HTML.
*
* Les contrôleurs d'API n'en héritent pas : ils écrivent directement leur
* réponse via JsonResponse::send() et n'ont donc aucune vue à rendre.
*/
abstract class Controller abstract class Controller
{ {
/**
* Rend une vue de app/Views/ en lui exposant $data comme variables locales.
*
* @param string $view Chemin relatif sans extension, ex. « pages/planning ».
* @param array<string, mixed> $data Variables mises à disposition de la vue.
*
* @throws \RuntimeException Si le fichier de vue n'existe pas.
*/
protected function render(string $view, array $data = []): void protected function render(string $view, array $data = []): void
{ {
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php'; $viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
@@ -27,8 +13,6 @@ abstract class Controller
throw new \RuntimeException('Vue introuvable : ' . $view); throw new \RuntimeException('Vue introuvable : ' . $view);
} }
// EXTR_SKIP : une clé de $data ne peut pas écraser $view ni $viewFile,
// ce qui rendrait le require imprévisible.
extract($data, EXTR_SKIP); extract($data, EXTR_SKIP);
require $viewFile; require $viewFile;
} }

View File

@@ -4,21 +4,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
/**
* Jeton anti-CSRF, transporté dans le corps JSON et non dans un en-tête.
*
* Le jeton est publié dans window.PTA_CONFIG.csrfToken par layouts/footer.php ;
* le JS le recopie dans chaque charge utile mutante sous la clé « csrf_token ».
* Ce choix évite d'avoir à gérer une requête préliminaire CORS et garde les
* appels fetch() identiques d'un module à l'autre.
*/
final class Csrf final class Csrf
{ {
/**
* Renvoie le jeton de la session courante, en le créant au premier appel.
* Le jeton 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.
*/
public static function ensureToken(): string public static function ensureToken(): string
{ {
Session::start(); Session::start();
@@ -30,27 +17,12 @@ final class Csrf
return (string) $_SESSION['csrf_token']; return (string) $_SESSION['csrf_token'];
} }
/**
* Vérifie le jeton d'une charge utile et interrompt la requête si besoin.
*
* À appeler en tête de TOUT endpoint mutant, sans exception. La comparaison
* passe par hash_equals() pour ne pas être sensible au temps d'exécution.
*
* @param array<string, mixed> $payload Corps JSON déjà décodé.
*/
public static function assertPayload(array $payload): void public static function assertPayload(array $payload): void
{ {
$expected = self::ensureToken(); $expected = self::ensureToken();
$provided = (string) ($payload['csrf_token'] ?? ''); $provided = (string) ($payload['csrf_token'] ?? '');
if ($provided === '' || !hash_equals($expected, $provided)) { if ($provided === '' || !hash_equals($expected, $provided)) {
// Un jeton CSRF absent ou faux est le plus souvent une session
// expirée, parfois une tentative réelle : dans les deux cas la trace
// a sa place dans le journal (V7.11).
Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [
'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))],
]);
// send() termine le script : rien ne s'exécute après ce point.
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']); JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
} }
} }

View File

@@ -1,182 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use Throwable;
/**
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
*
* Trois familles d'événements sont tracées, et seulement celles-là :
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
* - ECRITURE : toute modification en base, avec la table et l'identifiant
* visés ;
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
* et SYSTEME, réservé aux incidents techniques.
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
* volume pour une valeur d'audit très faible.
*
* DEUX RÈGLES ABSOLUES :
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
* planification des agents.
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
*
* La connexion PDO est posée une fois par app/bootstrap.php
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
* comme Csrf — n'ont ainsi rien à transporter.
*/
final class Journal
{
public const CONNEXION = 'CONNEXION';
public const ECRITURE = 'ECRITURE';
public const ACCES_REFUSE = 'ACCES_REFUSE';
public const ADMINISTRATION = 'ADMINISTRATION';
public const SYSTEME = 'SYSTEME';
private static ?PDO $pdo = null;
/**
* Enregistre la connexion utilisée pour écrire les traces.
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
*/
public static function useConnection(PDO $pdo): void
{
self::$pdo = $pdo;
}
/**
* Trace une entrée, une sortie ou un échec d'authentification.
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function connexion(string $action, array $options = []): void
{
self::enregistrer(self::CONNEXION, $action, $options);
}
/**
* Trace une modification en base.
*
* @param string $action Code stable, ex. « PLANNING_VALIDE ».
* @param string $cibleTable Table principale touchée.
* @param int|null $cibleId Identifiant de la ligne visée.
* @param string $description Phrase lisible dans la page DSI.
* @param array<string, mixed> $contexte Détail structuré (semaine, lieu…).
*/
public static function ecriture(
string $action,
string $cibleTable,
?int $cibleId,
string $description,
array $contexte = []
): void {
self::enregistrer(self::ECRITURE, $action, [
'cible_table' => $cibleTable,
'cible_id' => $cibleId,
'description' => $description,
'contexte' => $contexte,
]);
}
/**
* Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES.
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function refus(string $action, string $description, array $options = []): void
{
self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [
'description' => $description,
'resultat' => 'REFUS',
]));
}
/**
* Trace une action d'administration technique (comptes, jetons, journal).
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function administration(string $action, array $options = []): void
{
self::enregistrer(self::ADMINISTRATION, $action, $options);
}
/**
* Écrit une ligne de journal. Ne lève jamais d'exception.
*
* L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est
* indispensable à la connexion, où la trace est écrite AVANT que la session
* porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session).
*
* @param string $categorie Une des constantes de classe.
* @param string $action Code stable de l'action.
* @param array<string, mixed> $options resultat, cible_table, cible_id,
* description, contexte, id_compte,
* role, acteur.
*/
public static function enregistrer(string $categorie, string $action, array $options = []): void
{
if (self::$pdo === null) {
return;
}
try {
$session = Access::sessionSnapshot();
$contexte = $options['contexte'] ?? [];
$stmt = self::$pdo->prepare(
'INSERT INTO journal_activite
(categorie, action, resultat, id_compte, role, acteur,
cible_table, cible_id, description, contexte,
adresse_ip, agent_utilisateur)
VALUES
(:categorie, :action, :resultat, :id_compte, :role, :acteur,
:cible_table, :cible_id, :description, :contexte,
:adresse_ip, :agent_utilisateur)'
);
$stmt->execute([
'categorie' => $categorie,
'action' => mb_substr($action, 0, 60),
'resultat' => $options['resultat'] ?? 'SUCCES',
'id_compte' => $options['id_compte'] ?? $session['id_compte'],
'role' => $options['role'] ?? $session['role'],
'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180),
'cible_table' => self::tronquer($options['cible_table'] ?? null, 64),
'cible_id' => $options['cible_id'] ?? null,
'description' => self::tronquer($options['description'] ?? null, 500),
// JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit
// pas faire échouer l'encodage, donc la trace tout entière.
'contexte' => $contexte === []
? null
: json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
// REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par
// le client et falsifiable. Derrière un reverse proxy, c'est au
// proxy de réécrire REMOTE_ADDR.
'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45),
'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255),
]);
} catch (Throwable $e) {
// Règle 1 : le journal ne casse jamais l'action journalisée.
error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage()));
}
}
/** Tronque à la longueur de la colonne, en préservant les caractères accentués. */
private static function tronquer(?string $valeur, int $longueur): ?string
{
if ($valeur === null) {
return null;
}
$valeur = trim($valeur);
return $valeur === '' ? null : mb_substr($valeur, 0, $longueur);
}
}

View File

@@ -4,35 +4,15 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
/**
* Sortie JSON des endpoints d'API.
*
* Le JavaScript de PTA refuse toute réponse qui n'est pas du JSON : une notice
* PHP ou une page d'erreur HTML se traduirait côté client par un message
* incompréhensible. bootstrap() garantit donc que même un warning ou une
* exception non rattrapée ressort en JSON.
*
* Les endpoints PDF sont la seule exception : ils émettent des octets binaires
* et n'appellent volontairement pas bootstrap() (voir PdfResponse).
*/
final class JsonResponse final class JsonResponse
{ {
/**
* Installe les gestionnaires d'erreur/exception qui convertissent tout
* incident en réponse JSON 500. À appeler en toute première instruction du
* point d'entrée, avant même l'ouverture de la connexion PDO.
*/
public static function bootstrap(): void public static function bootstrap(): void
{ {
ini_set('display_errors', '0'); ini_set('display_errors', '0');
ini_set('html_errors', '0'); ini_set('html_errors', '0');
error_reporting(E_ALL); error_reporting(E_ALL);
// Les warnings/notices deviennent des ErrorException : elles remontent
// ainsi au gestionnaire d'exception ci-dessous au lieu de polluer la
// sortie avec du HTML qui casserait le JSON.
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
// Respecte @ et error_reporting() : on laisse alors PHP gérer.
if (!(error_reporting() & $severity)) { if (!(error_reporting() & $severity)) {
return false; return false;
} }
@@ -54,27 +34,15 @@ final class JsonResponse
}); });
} }
/**
* Émet la réponse JSON et TERMINE le script.
*
* Ce exit final est ce qui permet aux gardes (Access, Csrf, Request) de
* s'écrire en une ligne sans que l'appelant ait à tester leur retour.
*
* @param int $status Code HTTP (200, 403, 409, 422, 500…).
* @param array<string, mixed> $payload Corps sérialisé en JSON.
*/
public static function send(int $status, array $payload): void public static function send(int $status, array $payload): void
{ {
if (!headers_sent()) { if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
// Les réponses dépendent du rôle en session : jamais de cache partagé.
header('Cache-Control: no-store'); header('Cache-Control: no-store');
} }
http_response_code($status); http_response_code($status);
// UNESCAPED_UNICODE garde les accents lisibles ; INVALID_UTF8_SUBSTITUTE
// évite qu'une donnée mal encodée en base fasse échouer tout l'encodage.
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit; exit;
} }

View File

@@ -4,24 +4,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
/**
* Émission d'un PDF généré par les services de app/Services/.
*
* Les points d'entrée PDF n'appellent PAS JsonResponse::bootstrap() : ils
* produisent un flux binaire, et un gestionnaire d'erreur qui écrirait du JSON
* au milieu corromprait le document.
*/
final class PdfResponse final class PdfResponse
{ {
/**
* Envoie le document en affichage direct dans le navigateur, puis TERMINE
* le script.
*
* @param string $content Octets du PDF déjà assemblé.
* @param string $filename Nom proposé ; il est assaini avant d'atterrir dans
* l'en-tête Content-Disposition, où un guillemet ou
* un retour ligne permettrait une injection d'en-tête.
*/
public static function inline(string $content, string $filename): void public static function inline(string $content, string $filename): void
{ {
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf'; $safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
@@ -33,8 +17,6 @@ final class PdfResponse
header('Content-Type: application/pdf'); header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $safeFilename . '"'); header('Content-Disposition: inline; filename="' . $safeFilename . '"');
header('Content-Length: ' . strlen($content)); header('Content-Length: ' . strlen($content));
// Un planning peut être revalidé d'une minute à l'autre : on interdit
// au navigateur de resservir une version périmée.
header('Cache-Control: private, max-age=0, must-revalidate'); header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public'); header('Pragma: public');
} }

View File

@@ -4,19 +4,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
/**
* Lecture de la requête entrante pour les endpoints JSON.
*
* Les deux méthodes coupent l'exécution en cas de non-conformité plutôt que de
* lever une exception : l'appelant peut donc enchaîner sans bloc try/catch.
*/
final class Request final class Request
{ {
/**
* Décode le corps JSON de la requête.
*
* @return array<string, mixed>
*/
public static function json(): array public static function json(): array
{ {
$payload = json_decode((string) file_get_contents('php://input'), true); $payload = json_decode((string) file_get_contents('php://input'), true);
@@ -27,9 +16,6 @@ final class Request
return $payload; return $payload;
} }
/**
* Impose le verbe HTTP attendu (typiquement « POST » pour un endpoint mutant).
*/
public static function requireMethod(string $method): void public static function requireMethod(string $method): void
{ {
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) { if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {

View File

@@ -8,19 +8,6 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Référentiel des agents et lecture de leurs créneaux, tous lieux confondus.
*
* Deux notions à ne pas confondre :
* - `agent.id_structure` est le lieu de rattachement PAR DÉFAUT. Il ne limite
* pas où l'agent peut être planifié ;
* - `agent_structure` est l'HISTORIQUE des rattachements, avec date de début et
* de fin. Il est tenu à jour par updateProfile() et updateAssignment().
*
* Créer un agent ne se limite pas à insérer une ligne : createWithDefaults()
* ouvre aussi son quota annuel et son PTA. Un agent sans quota afficherait un
* dépassement dès sa première heure planifiée.
*/
final class AgentModel extends BaseModel final class AgentModel extends BaseModel
{ {
/** Enveloppe annuelle complémentaire de formation : 14 h. */ /** Enveloppe annuelle complémentaire de formation : 14 h. */
@@ -46,161 +33,6 @@ final class AgentModel extends BaseModel
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0; return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
} }
/**
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
*
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
* manquent :
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
* - pas de date de fin : contrat à durée indéterminée, la borne haute
* n'existe pas.
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
*
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
* findActive() ou allActive().
*
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
*/
public static function contractRange(array $agent): array
{
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
}
/**
* Vrai si la date tombe dans les bornes du contrat.
*
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
* de construire deux objets DateTime par créneau contrôlé.
*/
public static function contractCoversDate(array $agent, string $date): bool
{
[$debut, $fin] = self::contractRange($agent);
if ($debut !== null && $date < $debut) {
return false;
}
return !($fin !== null && $date > $fin);
}
/**
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
*
* @param array<int, string> $dates
*
* @return array<int, string>
*/
public static function datesOutsideContract(array $agent, array $dates): array
{
$hors = [];
foreach ($dates as $date) {
$date = trim((string) $date);
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
$hors[$date] = true;
}
}
$hors = array_keys($hors);
sort($hors);
return $hors;
}
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
public static function contractPeriodLabel(array $agent): string
{
[$debut, $fin] = self::contractRange($agent);
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
if ($debut !== null && $fin !== null) {
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
}
if ($debut !== null) {
return sprintf('à partir du %s', $lisible($debut));
}
if ($fin !== null) {
return sprintf('jusqu’au %s', $lisible($fin));
}
return 'sans date de contrat renseignée';
}
/**
* Message de refus commun à tous les points d'écriture, pour que
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
*
* @param array<int, string> $datesHorsContrat
*/
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
{
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
if (count($datesHorsContrat) > 3) {
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
}
return sprintf(
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? ''),
self::contractPeriodLabel($agent),
implode(', ', $exemples)
);
}
/**
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
*
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
* pas laisser derrière lui des affectations orphelines. Interroge donc les
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
*
* @return array{nombre: int, premiere: string|null, derniere: string|null}
*/
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
WHERE p.id_agent = :agent_id
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
);
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
// émulation, un même nom ne peut pas apparaître deux fois.
$stmt->execute([
'agent_id' => $agentId,
'debut' => $debut,
'debut_compare' => $debut,
'fin' => $fin,
'fin_compare' => $fin,
]);
$ligne = $stmt->fetch() ?: [];
return [
'nombre' => (int) ($ligne['nombre'] ?? 0),
'premiere' => $ligne['premiere'] ?? null,
'derniere' => $ligne['derniere'] ?? null,
];
}
/**
* Projection commune à toutes les lectures d'agent, complétée par la clause
* WHERE de chaque appelant.
*
* Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de
* rattachement, et doit rester listé dans ce cas.
*
* `structure_nom` et `structure_principale_nom` (idem pour le type) sont le
* MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus
* récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et
* les modules JS.
*/
private const AGENT_SELECT = <<<'SQL' private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
@@ -220,41 +52,6 @@ final class AgentModel extends BaseModel
LEFT JOIN structure s ON s.id_structure = a.id_structure LEFT JOIN structure s ON s.id_structure = a.id_structure
SQL; SQL;
/**
* Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est
* désactivé (V7.29).
*
* Trois précisions, toutes portées par le `NOT EXISTS` :
*
* - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu
* son jeton, ce qui est l'état normal avant distribution : le filtrer
* viderait les listes d'une installation neuve ;
* - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui
* est déjà exigé par ailleurs. Ce sont deux décisions distinctes :
* fermer une fiche d'agent et couper un accès ;
* - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de
* consultation (planning par agent, vue annuelle, PTA détaillé) et le
* référentiel « Agents » continuent de montrer l'agent — sans quoi on ne
* pourrait plus ni lire son année passée, ni lui rétablir son accès.
*
* La table compte_acces est garantie présente : SchemaModel::assertCurrent()
* refuse de démarrer si les vues de comptes manquent.
*/
private const SANS_COMPTE_COUPE = <<<'SQL'
AND NOT EXISTS (
SELECT 1 FROM compte_acces ca
WHERE ca.id_agent = a.id_agent AND ca.actif = 0
)
SQL;
/**
* Tous les agents actifs, y compris ceux dont l'accès est coupé.
*
* Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte,
* voir allSelectable().
*
* @return array<int, array<string, mixed>>
*/
public function allActive(): array public function allActive(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -262,28 +59,6 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Agents proposables dans une liste d'affectation : actifs ET dont le compte
* d'accès n'a pas été coupé.
*
* @return array<int, array<string, mixed>>
*/
public function allSelectable(): array
{
return $this->pdo->query(
self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY a.nom, a.prenom"
)->fetchAll();
}
/**
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
* y ont un planning, quel que soit leur rattachement.
*
* C'est le périmètre d'un responsable de structure, aligné sur la vue
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
*
* @return array<int, array<string, mixed>>
*/
public function allVisibleForStructure(int $structureId): array public function allVisibleForStructure(int $structureId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -307,14 +82,6 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
*
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
* répartition sur l'année civile entière.
*
* @return array<int, array<string, mixed>>
*/
public function allEligibleForPta(): array public function allEligibleForPta(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -326,15 +93,6 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Référentiel des postes.
*
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
* alimentent les calculs de PtaModel.
*
* @return array<int, array<string, mixed>>
*/
public function allPosts(): array public function allPosts(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -347,19 +105,6 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Agents du sélecteur de l'éditeur de planning.
*
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
* planifié ailleurs que sur son rattachement. Le tri fait simplement
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
* visuellement.
*
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
*
* @return array<int, array<string, mixed>>
*/
public function listForLocation(int $selectedLocation = 0): array public function listForLocation(int $selectedLocation = 0): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -371,18 +116,13 @@ final class AgentModel extends BaseModel
FROM agent a FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " WHERE a.actif = TRUE
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom" ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
); );
$stmt->execute(['selected_location' => $selectedLocation]); $stmt->execute(['selected_location' => $selectedLocation]);
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Fiche complète d'un agent actif.
*
* @return array<string, mixed>|null null si inexistant OU désactivé.
*/
public function findActive(int $agentId): ?array public function findActive(int $agentId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -392,10 +132,6 @@ final class AgentModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
* rapprochement avec les outils RH, elle reste unique en base.
*/
public function matriculeExists(string $matricule): bool public function matriculeExists(string $matricule): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
@@ -403,25 +139,6 @@ final class AgentModel extends BaseModel
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
/**
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
* 1. la fiche `agent` ;
* 2. sa première ligne d'historique `agent_structure` ;
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
*
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
* civile, jamais la date anniversaire du contrat.
*
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
* capturé plus haut dans $agentId.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
*/
public function createWithDefaults(array $data): array public function createWithDefaults(array $data): array
{ {
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']); $contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
@@ -543,32 +260,6 @@ final class AgentModel extends BaseModel
} }
} }
/**
* Met à jour la fiche d'un agent et, si son rattachement change, son
* historique `agent_structure`.
*
* L'historique n'est touché QUE si le lieu change réellement, sans quoi
* chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne,
* rendant l'historique illisible.
*
* La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont
* la date de début est dans le FUTUR : on referme alors sur cette date de
* début, pour ne pas produire une période qui finit avant de commencer.
*
* Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le
* passé : la ligne existante est réactivée plutôt que dupliquée.
*
* bindValue() explicite ici (et non un tableau passé à execute()) car
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
*
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @throws \RuntimeException Si l'agent n'existe pas.
*/
public function updateProfile(int $agentId, array $data): void public function updateProfile(int $agentId, array $data): void
{ {
try { try {
@@ -585,9 +276,7 @@ final class AgentModel extends BaseModel
$update = $this->pdo->prepare( $update = $this->pdo->prepare(
'UPDATE agent 'UPDATE agent
SET nom = :nom, SET email = :email,
prenom = :prenom,
email = :email,
telephone = :telephone, telephone = :telephone,
adresse = :adresse, adresse = :adresse,
est_diplome = :est_diplome, est_diplome = :est_diplome,
@@ -602,8 +291,6 @@ final class AgentModel extends BaseModel
WHERE id_agent = :agent_id' WHERE id_agent = :agent_id'
); );
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT); $update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':nom', $data['nom']);
$update->bindValue(':prenom', $data['prenom']);
$update->bindValue(':email', $data['email']); $update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']); $update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']); $update->bindValue(':adresse', $data['adresse']);
@@ -653,15 +340,6 @@ final class AgentModel extends BaseModel
} }
} }
/**
* Change le seul rattachement principal, sans toucher au reste de la fiche.
*
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
*
* @param int|null $structureId null pour détacher l'agent de tout lieu par
* défaut — il reste planifiable ponctuellement.
*/
public function updateAssignment(int $agentId, ?int $structureId): void public function updateAssignment(int $agentId, ?int $structureId): void
{ {
try { try {
@@ -705,14 +383,6 @@ final class AgentModel extends BaseModel
} }
} }
/**
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
*
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
* telle que l'utilisateur la conçoit.
*
* @return array<int, array<string, mixed>>
*/
public function entriesForWeek(int $agentId, int $year, int $week): array public function entriesForWeek(int $agentId, int $year, int $week): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -737,15 +407,6 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
*
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
* bornes de semaine.
*
* @return array<int, array<string, mixed>>
*/
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -772,18 +433,6 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
*
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
* un motif en deux lignes.
*
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
* est ce que le lecteur cherche en premier.
*
* @return array<int, array<string, mixed>>
*/
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -809,11 +458,6 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Cumul par motif sur une année civile complète.
*
* @return array<int, array<string, mixed>>
*/
public function timeSummaryForYear(int $agentId, int $year): array public function timeSummaryForYear(int $agentId, int $year): array
{ {
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)); return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));

View File

@@ -9,42 +9,10 @@ use DatePeriod;
use DateTimeImmutable; use DateTimeImmutable;
use RuntimeException; use RuntimeException;
/**
* Construction du calendrier annuel d'un agent, du 1er janvier au 31 décembre.
*
* Produit une structure entièrement prête à l'affichage : les 365 jours sont
* calculés côté serveur, avec leurs libellés, classes CSS et infobulles. Le
* JavaScript ne fait que rendre le tableau — c'est ce qui garantit que le
* calendrier reste identique à l'écran, au PDF et à l'impression.
*
* MODÈLE DES QUATRE COLONNES par jour (constante CELL_KEYS) :
* AP = Animation Périscolaire PP = Préparation Périscolaire
* AE = Animation Extrascolaire PE = Préparation Extrascolaire
*
* Le classement d'un créneau dans l'une des quatre cases dépend de son motif et,
* pour les motifs génériques, du caractère scolaire ou non de la journée
* (voir cellForEntry()).
*/
final class AnnualOverviewModel extends BaseModel final class AnnualOverviewModel extends BaseModel
{ {
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE']; private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
/**
* Calendrier annuel complet et ses totaux.
*
* Les créneaux sont lus EN UNE FOIS pour toute l'année puis indexés par date
* en mémoire : 365 requêtes seraient inenvisageables.
*
* Trois familles de totaux cumulées en parallèle : par case (AP/PP/AE/PE),
* par statut (validé / brouillon) et par motif particulier (congé,
* formation, absence…). Elles répondent à trois questions différentes et ne
* se déduisent pas les unes des autres.
*
* @return array<string, mixed> agent, year, quota_periods, calendar_totals,
* months (12 entrées) et legend.
*
* @throws RuntimeException Si l'agent est introuvable ou inactif.
*/
public function overview(int $agentId, int $year): array public function overview(int $agentId, int $year): array
{ {
$agentModel = new AgentModel($this->pdo); $agentModel = new AgentModel($this->pdo);
@@ -132,27 +100,6 @@ final class AnnualOverviewModel extends BaseModel
]; ];
} }
/**
* Construit UNE journée du calendrier à partir de ses créneaux.
*
* Les quatre cases sont toujours présentes, même vides : la grille HTML doit
* garder un nombre de colonnes constant.
*
* Une case peut recevoir plusieurs créneaux ; elle prend alors la classe CSS
* du motif le plus « fort » (voir strongestClass()), et son contenu affiche
* les codes courts plutôt que la durée dès qu'un motif particulier est
* présent — « CA » est plus parlant que « 7h00 » pour un jour de congé.
*
* `structure_id` retient le lieu du PREMIER créneau du jour, ou à défaut le
* rattachement de l'agent : il sert à construire les liens profonds vers
* l'écran de saisie.
*
* @param array<int, array<string, mixed>> $entries Créneaux de la journée.
* @param array<string, mixed>|null $vacation Période de vacances, ou null.
* @param array<string, mixed> $agent Fiche de l'agent.
*
* @return array<string, mixed>
*/
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
{ {
$cells = []; $cells = [];
@@ -239,18 +186,6 @@ final class AnnualOverviewModel extends BaseModel
]; ];
} }
/**
* Périodes de quota à afficher pour l'année.
*
* Trois dates de contexte sont interrogées (1er janvier, 31 décembre, et le
* début de contrat s'il tombe dans l'année), puis dédoublonnées par leurs
* bornes. Depuis que le quota suit l'année civile, elles donnent toutes la
* même période : le résultat ne contient donc en pratique qu'une entrée. La
* mécanique est conservée parce qu'elle reste juste si une règle de
* proratisation en cours d'année est réintroduite.
*
* @return array<int, array<string, mixed>>
*/
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
{ {
$quotaModel = new QuotaModel($this->pdo); $quotaModel = new QuotaModel($this->pdo);
@@ -275,14 +210,6 @@ final class AnnualOverviewModel extends BaseModel
return array_values($periods); return array_values($periods);
} }
/**
* Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif.
*
* Un motif non reconnu bascule sur le contexte de la journée : animation
* extrascolaire pendant les vacances, périscolaire sinon. C'est le
* comportement le moins surprenant pour un motif ajouté en base après coup,
* et il évite qu'un temps disparaisse du calendrier.
*/
private function cellForEntry(string $code, bool $isVacation): string private function cellForEntry(string $code, bool $isVacation): string
{ {
return match ($code) { return match ($code) {
@@ -295,12 +222,6 @@ final class AnnualOverviewModel extends BaseModel
}; };
} }
/**
* Abréviation affichée dans la case pour les motifs PARTICULIERS.
*
* @return string|null null pour les motifs de travail ordinaires, qui
* affichent alors leur durée plutôt qu'un code.
*/
private function shortCode(string $code): ?string private function shortCode(string $code): ?string
{ {
return match ($code) { return match ($code) {
@@ -315,10 +236,6 @@ final class AnnualOverviewModel extends BaseModel
}; };
} }
/**
* Classe CSS d'un créneau, à partir de son motif et, à défaut, de sa case.
* Les classes correspondantes sont définies dans assets/style.css.
*/
private function classForCode(string $code, string $cellKey): string private function classForCode(string $code, string $cellKey): string
{ {
return match ($code) { return match ($code) {
@@ -334,17 +251,6 @@ final class AnnualOverviewModel extends BaseModel
}; };
} }
/**
* Arbitre la classe d'une case contenant plusieurs motifs.
*
* Une seule couleur par case : c'est la plus « forte » qui l'emporte, selon
* une priorité croissante allant du travail ordinaire à la journée non
* travaillée. Une demi-journée de congé doit rester visible à côté d'une
* demi-journée travaillée ; l'inverse ferait disparaître l'information.
*
* Le `>=` fait gagner le dernier créneau à priorité égale, ce qui est sans
* conséquence puisque la classe est alors la même.
*/
private function strongestClass(string $current, string $candidate): string private function strongestClass(string $current, string $candidate): string
{ {
$priority = [ $priority = [
@@ -362,12 +268,6 @@ final class AnnualOverviewModel extends BaseModel
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current; return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
} }
/**
* Contenu textuel d'une case : rien si vide, les codes courts s'il y en a,
* la durée compacte sinon.
*
* @param array<int, string> $codes
*/
private function cellDisplay(int $minutes, array $codes): string private function cellDisplay(int $minutes, array $codes): string
{ {
if ($minutes <= 0) { if ($minutes <= 0) {
@@ -379,15 +279,6 @@ final class AnnualOverviewModel extends BaseModel
return $this->formatCompactMinutes($minutes); return $this->formatCompactMinutes($minutes);
} }
/**
* Période de vacances contenant cette date, ou null.
* Copie locale de VacationModel::vacationForDate() : les périodes sont déjà
* chargées en mémoire pour toute l'année, inutile de repasser par le modèle.
*
* @param array<int, array<string, mixed>> $vacations
*
* @return array<string, mixed>|null
*/
private function vacationForDate(string $date, array $vacations): ?array private function vacationForDate(string $date, array $vacations): ?array
{ {
foreach ($vacations as $vacation) { foreach ($vacations as $vacation) {
@@ -398,7 +289,6 @@ final class AnnualOverviewModel extends BaseModel
return null; return null;
} }
/** Durée en minutes entre deux HH:MM, plancher à 0. */
private function minutesBetween(string $start, string $end): int private function minutesBetween(string $start, string $end): int
{ {
[$startHour, $startMinute] = array_map('intval', explode(':', $start)); [$startHour, $startMinute] = array_map('intval', explode(':', $start));
@@ -406,14 +296,6 @@ final class AnnualOverviewModel extends BaseModel
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute)); return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
} }
/**
* Décline chaque total en trois formes : minutes brutes, durée lisible
* (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites.
*
* @param array<string, int> $totals
*
* @return array<string, array{minutes: int, duration: string, compact: string}>
*/
private function formatTotals(array $totals): array private function formatTotals(array $totals): array
{ {
$formatted = []; $formatted = [];
@@ -427,7 +309,6 @@ final class AnnualOverviewModel extends BaseModel
return $formatted; return $formatted;
} }
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
private function formatMinutes(int $minutes): string private function formatMinutes(int $minutes): string
{ {
$sign = $minutes < 0 ? '-' : ''; $sign = $minutes < 0 ? '-' : '';
@@ -435,7 +316,6 @@ final class AnnualOverviewModel extends BaseModel
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
} }
/** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */
private function formatCompactMinutes(int $minutes): string private function formatCompactMinutes(int $minutes): string
{ {
if ($minutes <= 0) { if ($minutes <= 0) {
@@ -446,7 +326,6 @@ final class AnnualOverviewModel extends BaseModel
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins); return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
} }
/** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */
private function monthName(int $month): string private function monthName(int $month): string
{ {
return [ return [
@@ -456,7 +335,6 @@ final class AnnualOverviewModel extends BaseModel
][$month]; ][$month];
} }
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
private function weekdayShort(int $weekday): string private function weekdayShort(int $weekday): string
{ {
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday]; return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];

Some files were not shown because too many files have changed in this diff Show More