Compare commits

7 Commits

Author SHA1 Message Date
PTA
90e8a06ca0 last update 2026-08-27 11:38:59 +02:00
PTA
1a67080268 update 2026-08-25 15:12:11 +02:00
PTA
44730eefca Ajout des vérifications des types dans les formulaires (adresses, email, telephone) 2026-08-21 13:50:05 +02:00
PTA
b3e53f83b8 Modification page "A valider" pour regrouper les créneaux des agents 2026-08-21 11:44:08 +02:00
PTA
94e7f09eb4 update 2026-08-20 17:00:06 +02:00
PTA
5d8c0993a6 Ajout des commentaires dans le code 2026-08-20 13:22:49 +02:00
PTA
4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00
221 changed files with 21218 additions and 1005 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
# Le Dockerfile fait COPY . /var/www/html : tout ce qui est listé ici reste
# hors de l'image, donc hors de la racine web et hors des couches publiées.
.env
.env.*
!.env.example
.git
.gitignore
.dockerignore
# Historique et documentation de version : inutiles dans l'image.
README*.md
CLAUDE.md
# Journaux, sauvegardes et artefacts locaux
*.log
*.sql.gz
*.dump
logs/
tmp/
.idea/
.vscode/
.DS_Store

View File

@@ -4,12 +4,17 @@ APP_PORT=8080
TZ=Europe/Paris
# MariaDB
# Remplacer impérativement les deux mots de passe avant tout déploiement.
DB_NAME=pta
DB_USER=pta
DB_PASSWORD=pta
DB_ROOT_PASSWORD=pta
DB_PASSWORD=a_remplacer
DB_ROOT_PASSWORD=a_remplacer_aussi
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
PTA_DEBUG=0
# phpMyAdmin
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
# phpMyAdmin demande désormais un identifiant MariaDB à chaque connexion.
PMA_BIND=127.0.0.1
PMA_PORT=8081

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Configuration locale : contient les mots de passe MariaDB.
# Seul .env.example doit être versionné.
.env
.env.*
!.env.example
# Dumps et sauvegardes de base
*.sql.gz
*.dump
database/backup/
database/*_backup*.sql
# Journaux et fichiers temporaires
*.log
logs/
tmp/
# Dépendances éventuelles
/vendor/
/node_modules/
# Worktrees de travail Claude Code : copies du dépôt, jamais à versionner.
.claude/worktrees/
# Artefacts d'éditeur et de système
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~

302
CLAUDE.md Normal file
View File

@@ -0,0 +1,302 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Contexte
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la
planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
**Aucune dépendance** : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
sans raison forte : le PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont
volontairement réécrits à la main.
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
## Commandes
```bash
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
docker compose up -d --build php nginx
docker compose logs -f php nginx db
docker compose logs db-migrate # compte rendu de l'application des migrations
docker compose ps
docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB
# Accès SQL direct
docker compose exec db mariadb -upta -p pta
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
```
Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume**
MariaDB (`docker-entrypoint-initdb.d`) : il ne contient que le socle métier, et **aucune** des tables
ou vues ajoutées par les migrations.
Les fichiers `database/migration_*.sql` sont appliqués automatiquement au démarrage par le service
jetable `db-migrate` (`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante.
Le service `php` attend sa sortie en succès : une migration en échec empêche l'application de
tourner sur une base incomplète, plutôt que de la laisser renvoyer une erreur 500 à l'accueil.
Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`,
`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. C'est pourquoi la
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
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
`PMA_PORT` de `.env`) : les modifier coupe l'accès à la base.
## Architecture
### Deux types de points d'entrée, tous à la racine ou dans `api/`
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`,
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`,
`manage_structures.php`, `administration.php`, `dsi.php` :
```php
require __DIR__ . '/page_bootstrap.php';
runPage('planning'); // -> PageController::planning()
```
`page_bootstrap.php` attrape les `Throwable` et affiche une page d'erreur HTML.
**Endpoints JSON/PDF** — `api/*.php` plus `save_planning.php`, `validate_planning.php`,
`reopen_planning.php` (ces trois-là sont restés à la racine pour raisons historiques) :
```php
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new PlanningController($pdo))->show();
```
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** (ils émettent des
octets binaires via `PdfResponse::inline()`).
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
partiel `app/Views/partials/` + module JS + **inscription de la clé dans le tableau `$navigation` de
`app/Views/layouts/header.php`, dans un groupe de `$navigationGroups`, et dans `acces_role_page` par
une migration SQL** (les pages autorisées sont lues en base, pas écrites en PHP — voir plus bas).
Une page absente des groupes reste affichée dans un groupe « Autres » : le filet évite qu'un oubli
la rende inatteignable. Chaque page passe aussi un `pageSubtitle` décrivant l'action de l'écran.
Seule exception assumée : `faq` (guide d'utilisation) est **volontairement absente de `$navigation`**
et rendue par le bloc `.view-tab-help` ancré à droite de la barre, sous condition `$showHelpTab`.
L'omettre de `$navigation` est ce qui l'empêche d'être aussi repêchée par le filet « Autres » et
d'apparaître deux fois. Elle reste soumise à `acces_role_page`
(`database/migration_v7_9d_faq.sql`, les trois rôles) : sans migration, ni bouton ni page.
### Chaîne d'un appel
```
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
page.php → PageController → Models → Views/pages/x.php
├── layouts/header.php (nav filtrée par rôle)
├── partials/x.php (markup)
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
```
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
d'environnement `PTA_DB_*`) et appelle `SchemaModel::assertCurrent()` **à chaque requête**. Ajouter
une table ou une colonne obligatoire impose de mettre à jour les listes de `SchemaModel`, sinon
l'application entière refuse de démarrer.
### 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
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout le métier, seul habilité à valider) et
`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
`README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après
vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur
le compte**, jamais déclarés par l'utilisateur. `Access::select()` a disparu — ne pas le
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
`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
la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de
`migration_v7_9e_dsi_comptes_journal.sql`). `SchemaModel` vérifie au démarrage la présence des sept
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
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.php` — ce repli est
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
commence par un garde `requireService()`, `requireDsi()`, `requireDraftEditor()`,
`requireStructureEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`,
`requirePlanningEdit($id)`… Ces gardes émettent
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
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_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
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
Tout `<script>` inline ajouté à une vue doit porter `nonce="<?= $cspNonce ?>"`, et aucun
`onclick=` / `style=` inline ne fonctionnera.
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et est donc valable en contexte texte
comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète
qui avait introduit un XSS stocké.
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus
doit être placé **avant** `location ~ \.php$`, sinon il est sans effet.
- `PTA_DEBUG` reste à `0`. Les détails d'exception passent par `Security::reportThrowable()`, qui
journalise côté serveur et ne renvoie rien au client.
- `session_start()` ne doit jamais être appelé directement : passer par `App\Core\Session::start()`.
### JavaScript
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global `window.PTA` publié
par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
```js
PTA.modules.agentOverview = { init, load };
```
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
`footer.php` les injecte, puis `page-bootstrap.js` appelle `init()` sur chaque module déclaré et
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 })`.
Les assets sont versionnés à la main : `assets/style.css?v=7.23` et `assets/js/*.js?v=7.23` dans
`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
les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
### Modèle métier
- 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`).
- 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
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).
- **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
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
elles sont importées depuis l'API publique data.education.gouv.fr via
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
- 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
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
`v_plannings_en_attente_validation`, …) et des procédures (`sp_controler_planning`,
`sp_valider_planning`) — **aucune n'est appelée par le code PHP**. Les contrôles réellement
appliqués vivent dans `PlanningController::validate()`, `AnnualOverviewController::validateYear()`
et `PtaModel`. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
patrimoine à ne pas prendre pour la source de vérité.
Les contrôles renvoyés au client suivent partout la forme `{niveau: ERREUR|ALERTE|INFO, code,
message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation explicite
(409 + rappel avec `force_quota: true`).
## Points d'attention
- nginx interdit l'accès direct à `/app/`, `/database/`, `/docker/`, aux fichiers d'amorçage et aux
extensions de configuration (`docker/nginx/default.conf`) — ces règles doivent suivre toute
réorganisation de l'arborescence.
- phpMyAdmin n'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. Ne jamais redéfinir
`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
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 —
utiles pour comprendre une décision, mais seul le code fait foi.

View File

@@ -1,69 +0,0 @@
# 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.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`.

View File

@@ -1,96 +0,0 @@
# 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.
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

@@ -2,5 +2,13 @@
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';
runPage('administration');

View File

@@ -2,5 +2,14 @@
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';
runPage('agents');

View File

@@ -2,5 +2,13 @@
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';
runPage('annual');

View File

@@ -0,0 +1,20 @@
<?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

@@ -0,0 +1,19 @@
<?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

@@ -0,0 +1,19 @@
<?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();

20
api/acces_comptes.php Normal file
View File

@@ -0,0 +1,20 @@
<?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

@@ -0,0 +1,19 @@
<?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,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

21
api/dashboard_summary.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
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\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DashboardController($pdo))->summary();

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

20
api/dsi_compte_creer.php Normal file
View File

@@ -0,0 +1,20 @@
<?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();

19
api/dsi_compte_jeton.php Normal file
View File

@@ -0,0 +1,19 @@
<?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

@@ -0,0 +1,22 @@
<?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();

19
api/dsi_compte_statut.php Normal file
View File

@@ -0,0 +1,19 @@
<?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();

19
api/dsi_comptes.php Normal file
View File

@@ -0,0 +1,19 @@
<?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

@@ -0,0 +1,20 @@
<?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();

19
api/dsi_journal.php Normal file
View File

@@ -0,0 +1,19 @@
<?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();

19
api/dsi_journal_purge.php Normal file
View File

@@ -0,0 +1,19 @@
<?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,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -0,0 +1,19 @@
<?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();

20
api/search_address.php Normal file
View File

@@ -0,0 +1,20 @@
<?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,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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;
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,14 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

19
api/week_template.php Normal file
View File

@@ -0,0 +1,19 @@
<?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,6 +4,13 @@ declare(strict_types=1);
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\Core\JsonResponse;

View File

@@ -0,0 +1,366 @@
<?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

@@ -0,0 +1,78 @@
<?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,10 +5,13 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\ContactFormat;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
@@ -19,6 +22,18 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController
{
private AgentModel $agentModel;
@@ -34,6 +49,13 @@ final class AgentController
$this->access = new Access($pdo);
}
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void
{
$this->access->requireDraftEditor();
@@ -44,6 +66,15 @@ final class AgentController
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
}
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -72,6 +103,21 @@ final class AgentController
]);
}
/**
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
*
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
* `in_month` de chaque jour permet à l'interface de griser les débordements.
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
* hebdomadaires affichés seraient faux.
*
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
* compris, pour que les totaux de semaine restent justes.
*
* Paramètres GET : agent_id, month (« 2026-02 »).
*/
public function monthOverview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -148,6 +194,16 @@ final class AgentController
]);
}
/**
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
*
* La période couverte n'est pas choisie par l'appelant : elle est celle
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
* n'est qu'un raccourci vers le 1er janvier correspondant.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function timeSummaryPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -193,6 +249,15 @@ final class AgentController
);
}
/**
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
*
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
* sur page suivante.
*
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
*/
public function pdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -256,6 +321,14 @@ final class AgentController
);
}
/**
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
*
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
* détail des créneaux planifiés.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function hoursContractPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -295,6 +368,19 @@ final class AgentController
);
}
/**
* api/agent_assignments.php — rattachement principal d'un agent.
*
* Endpoint à double verbe, exception au reste de l'API : GET liste les
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
*
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
*
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
*/
public function assignments(): void
{
$this->access->requireService();
@@ -330,6 +416,16 @@ 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, [
'message' => $structureId === null
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
@@ -337,6 +433,19 @@ final class AgentController
]);
}
/**
* POST api/create_agent.php — création d'un agent.
*
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
* poste existant en base, type de contrat dans l'énumération, dates
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
* avec l'existant, pas une saisie malformée).
*
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
* l'insère dans sa liste sans avoir à recharger toute la page.
*/
public function create(): void
{
$this->access->requireService();
@@ -360,7 +469,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
@@ -374,8 +485,15 @@ final class AgentController
if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// 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
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
@@ -404,17 +522,29 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
$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, [
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
'agent' => array_merge($created, [
@@ -435,12 +565,24 @@ final class AgentController
'id_poste' => (int) $postId,
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]),
]);
}
/**
* 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
{
$this->access->requireService();
@@ -449,6 +591,8 @@ final class AgentController
Csrf::assertPayload($payload);
$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'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? ''));
@@ -460,7 +604,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN);
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null;
@@ -471,27 +617,68 @@ final class AgentController
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
// La fiche AVANT modification est conservée : elle sert à repérer un
// renommage et à le nommer explicitement dans le journal.
$agentAvant = $this->agentModel->findActive((int) $agentId);
if ($agentAvant === null) {
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)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($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.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// 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 (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
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($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.']);
}
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
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 {
$this->agentModel->updateProfile((int) $agentId, [
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
@@ -501,7 +688,7 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null,
]);
@@ -512,12 +699,42 @@ 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, [
'message' => 'Les informations de l’agent ont été mises à jour.',
'agent' => $this->agentModel->findActive((int) $agentId),
'agent' => $agent,
]);
}
/**
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
* pas de base commune ; toute correction doit être reportée partout.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

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

View File

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

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\JsonResponse;
use App\Models\CoverageModel;
use App\Models\DashboardModel;
use App\Models\PlanningModel;
use App\Models\StructureModel;
use App\Models\VacationModel;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
/**
* Tableau de bord du Service Enfance.
*
* Le tableau de bord n'ouvre aucun droit et n'applique aucune règle nouvelle :
* il rassemble sur un seul écran les compteurs déjà calculés par les écrans
* « À valider », « Couverture » et « Vue annuelle », et les transforme en
* liens directs vers l'action correspondante.
*/
final class DashboardController
{
/** Nombre d'éléments détaillés par bloc : un aperçu, pas une liste complète.
* Chaque bloc renvoie vers l'écran dédié pour la suite. */
private const APERCU_MAX = 5;
private const QUOTA_APERCU_MAX = 8;
private Access $access;
private DashboardModel $dashboardModel;
private PlanningModel $planningModel;
private CoverageModel $coverageModel;
private StructureModel $structureModel;
private VacationModel $vacationModel;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->dashboardModel = new DashboardModel($pdo);
$this->planningModel = new PlanningModel($pdo);
$this->coverageModel = new CoverageModel($pdo);
$this->structureModel = new StructureModel($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
{
$this->access->requireService();
// Portée « badges » : uniquement le compteur de la pastille de
// navigation, appelé depuis toutes les pages. Il doit rester léger.
if (trim((string) ($_GET['scope'] ?? '')) === 'badges') {
JsonResponse::send(200, [
'validation' => ['count' => $this->dashboardModel->pendingCount()],
]);
}
$week = $this->resolveWeek($_GET['week'] ?? null);
// L'année du quota suit l'année ISO de la semaine affichée : une
// semaine à cheval sur deux années civiles reste rattachée à l'année
// qui contient son jeudi, comme partout ailleurs dans PTA.
$year = $this->resolveYear($_GET['year'] ?? null, (int) $week['iso_year']);
JsonResponse::send(200, [
'semaine' => $this->weekPayload($week),
'validation' => $this->validationPayload(),
'couverture' => $this->coveragePayload((int) $week['iso_year'], (int) $week['iso_week']),
'quota' => $this->quotaPayload($year),
'perimetre' => $this->dashboardModel->scope(),
]);
}
/**
* File de validation : le volume total, le volume décompté et les
* brouillons les plus anciens, qui sont ceux à traiter en premier.
*/
private function validationPayload(): array
{
$plannings = $this->planningModel->pendingValidation();
$totalMinutes = 0;
$countedMinutes = 0;
$structures = [];
$agents = [];
foreach ($plannings as $planning) {
$totalMinutes += (int) $planning['total_minutes'];
$countedMinutes += (int) $planning['minutes_decomptees'];
$structures[(int) $planning['id_structure']] = true;
$agents[(int) $planning['id_agent']] = true;
}
// pendingValidation() trie déjà par semaine croissante : les premiers
// éléments sont les plus anciens, donc les plus urgents.
$apercu = array_slice($plannings, 0, self::APERCU_MAX);
return [
'count' => count($plannings),
'total_minutes' => $totalMinutes,
'minutes_decomptees' => $countedMinutes,
'structure_count' => count($structures),
'agent_count' => count($agents),
'semaine_la_plus_ancienne' => $plannings[0]['date_debut'] ?? null,
'apercu' => array_map(static function (array $planning): array {
return [
'id_planning' => (int) $planning['id_planning'],
'id_agent' => (int) $planning['id_agent'],
'id_structure' => (int) $planning['id_structure'],
'agent_nom' => (string) $planning['agent_nom'],
'agent_prenom' => (string) $planning['agent_prenom'],
'structure_nom' => (string) $planning['structure_nom'],
'annee' => (int) $planning['annee'],
'numero_semaine' => (int) $planning['numero_semaine'],
'date_debut' => (string) $planning['date_debut'],
'date_fin_ouvrable' => (string) ($planning['date_fin_ouvrable'] ?? $planning['date_fin']),
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
'total_minutes' => (int) $planning['total_minutes'],
'minutes_decomptees' => (int) $planning['minutes_decomptees'],
];
}, $apercu),
];
}
/**
* Couverture de la semaine affichée, tous lieux actifs confondus.
* Reprend exactement le calcul de l'écran « Couverture ».
*/
private function coveragePayload(int $year, int $weekNumber): array
{
$gaps = [];
$unconfigured = [];
$affectedStructures = [];
$gapMinutes = 0;
$validationWarningCount = 0;
foreach ($this->structureModel->allActive() as $structure) {
$structureId = (int) $structure['id_structure'];
$report = $this->coverageModel->reportForStructure($structureId, $year, $weekNumber);
if (!$report['configured']) {
$unconfigured[] = [
'id_structure' => $structureId,
'nom' => (string) $structure['nom'],
'code' => (string) $structure['code'],
];
continue;
}
$validationWarningCount += (int) $report['summary']['validation_warning_count'];
foreach ($report['gaps'] as $gap) {
$gaps[] = [
'id_structure' => $structureId,
'structure_nom' => (string) $structure['nom'],
'date' => (string) $gap['date'],
'jour_libelle' => (string) $gap['jour_libelle'],
'heure_debut' => (string) $gap['heure_debut'],
'heure_fin' => (string) $gap['heure_fin'],
'minimum_agents' => (int) $gap['minimum_agents'],
'agents_planifies' => (int) $gap['agents_planifies'],
'agents_manquants' => (int) $gap['agents_manquants'],
];
$affectedStructures[$structureId] = true;
}
$gapMinutes += (int) $report['summary']['gap_minutes'];
}
// Les manques les plus importants d'abord : c'est là que le service
// doit agir en priorité.
usort($gaps, static function (array $a, array $b): int {
return [$b['agents_manquants'], $a['date'], $a['heure_debut']]
<=> [$a['agents_manquants'], $b['date'], $b['heure_debut']];
});
return [
'gap_count' => count($gaps),
'gap_minutes' => $gapMinutes,
'structure_count' => count($affectedStructures),
'validation_warning_count' => $validationWarningCount,
'unconfigured_count' => count($unconfigured),
'unconfigured' => array_slice($unconfigured, 0, self::APERCU_MAX),
'apercu' => array_slice($gaps, 0, self::APERCU_MAX),
];
}
/**
* Suivi des quotas : combien d'agents dépassent leur quota annuel et combien
* n'ont encore aucune heure planifiée. Les cumuls tous agents confondus
* (consommation globale, reste à affecter) ne sont volontairement pas exposés :
* une somme d'agents aux quotités différentes ne décrit aucune situation réelle.
*/
private function quotaPayload(int $year): array
{
$tracking = $this->dashboardModel->quotaTracking($year);
$overCount = 0;
$emptyCount = 0;
foreach ($tracking as $agent) {
if ($agent['minutes_restantes'] < 0) {
$overCount++;
}
if ($agent['minutes_affectees'] === 0) {
$emptyCount++;
}
}
// Tri décroissant sur le taux : les dépassements remontent en tête,
// les agents non planifiés restent visibles en bas de liste.
usort($tracking, static function (array $a, array $b): int {
return $b['taux_consommation'] <=> $a['taux_consommation'];
});
return [
'annee' => $year,
'agent_count' => count($tracking),
'depassement_count' => $overCount,
'sans_heure_count' => $emptyCount,
'apercu' => array_slice($tracking, 0, self::QUOTA_APERCU_MAX),
];
}
/**
* Contexte de la semaine affichée : dates, et caractère scolaire ou
* extrascolaire déduit des périodes de vacances enregistrées.
*/
private function weekPayload(array $week): array
{
/** @var DateTimeImmutable $start */
$start = $week['start'];
$end = $start->modify('+4 days');
$days = $this->vacationModel->typesForIsoWeek((int) $week['iso_year'], (int) $week['iso_week']);
$vacationLabels = [];
$vacationDays = 0;
foreach ($days as $day) {
if ($day['type_affectation'] === 'EXTRASCOLAIRE') {
$vacationDays++;
if ($day['vacances'] !== null) {
$vacationLabels[$day['vacances']['libelle']] = true;
}
}
}
if ($vacationDays === 0) {
$contexte = 'PERISCOLAIRE';
} elseif ($vacationDays === count($days)) {
$contexte = 'EXTRASCOLAIRE';
} else {
$contexte = 'MIXTE';
}
return [
'valeur' => $week['value'],
'annee' => (int) $week['iso_year'],
'numero' => (int) $week['iso_week'],
'date_debut' => $start->format('Y-m-d'),
'date_fin' => $end->format('Y-m-d'),
'contexte' => $contexte,
'jours_vacances' => $vacationDays,
'vacances_libelles' => array_keys($vacationLabels),
];
}
/**
* Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante.
*
* Contrairement aux endpoints de saisie, une semaine illisible n'est pas une
* erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer
* la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC.
*
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
*/
private function resolveWeek(mixed $value): array
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})-W(\d{2})$/', $raw, $matches) === 1) {
$isoYear = (int) $matches[1];
$isoWeek = (int) $matches[2];
if ($isoWeek >= 1 && $isoWeek <= 53) {
$start = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))
->setISODate($isoYear, $isoWeek, 1)
->setTime(0, 0);
return [
'value' => $raw,
'iso_year' => $isoYear,
'iso_week' => $isoWeek,
'start' => $start,
];
}
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
return [
'value' => $now->format('o-\WW'),
'iso_year' => (int) $now->format('o'),
'iso_week' => (int) $now->format('W'),
'start' => $now->setISODate((int) $now->format('o'), (int) $now->format('W'), 1)->setTime(0, 0),
];
}
/**
* Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée.
*/
private function resolveYear(mixed $value, int $fallback): int
{
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $year === false ? $fallback : (int) $year;
}
}

View File

@@ -0,0 +1,394 @@
<?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

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\StructureModel;
@@ -14,6 +15,27 @@ use DateTimeImmutable;
use DateTimeZone;
use PDO;
/**
* Rendu de toutes les pages HTML de l'application.
*
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
* de $activePage qui surligne l'onglet courant.
*
* Ajouter une page suppose donc, ensemble :
* 1. un fichier mince à la racine ;
* 2. une méthode ici ;
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
* 4. un module JS ;
* 5. l'inscription de la clé dans $navigation / $navigationGroups
* (Views/layouts/header.php) ;
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
*
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
* injecte et page-bootstrap.js appelle init() sur chaque module.
*/
final class PageController extends Controller
{
private Access $access;
@@ -23,6 +45,38 @@ final class PageController extends Controller
$this->access = new Access($pdo);
}
/**
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
*/
public function dashboard(): void
{
$this->access->requirePage('dashboard');
$this->renderPage('dashboard', 'Tableau de bord', 'dashboard', [
'pageSubtitle' => 'Ce qui attend le Service Enfance : plannings à valider, couverture des lieux et suivi des quotas annuels.',
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['dashboard.js'],
'modules' => ['dashboard'],
'pageConfig' => [
'week' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
],
]);
}
/**
* Écran de saisie — cœur de l'application.
*
* Le lieu est VERROUILLÉ pour un responsable de structure
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
* qu'un confort d'interface, le serveur retranche de toute façon.
*
* Les scripts de semaine type ne sont chargés que si la permission
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
* (éditeur de semaine type / saisie semaine par semaine, voir
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
* les paramètres des liens profonds venus des autres écrans.
*/
public function planning(): void
{
$this->access->requirePage('planning');
@@ -32,9 +86,14 @@ final class PageController extends Controller
$modules = ['planning'];
if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js';
$scripts[] = 'week-template-editor.js';
$modules[] = 'weekTemplates';
$modules[] = 'weekTemplateEditor';
}
$this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => !empty($permissions['can_use_templates'])
? '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(),
'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(),
@@ -51,20 +110,24 @@ final class PageController extends Controller
]);
}
/** File des plannings en attente de validation (Service Enfance). */
public function pending(): void
{
$this->access->requirePage('pending');
$this->renderPage('pending', 'Plannings à valider', 'pending', [
'pageSubtitle' => 'La file des brouillons prêts à être contrôlés. Ouvrir un planning charge l’écran de saisie sur la bonne semaine.',
'scripts' => ['pending-validation.js'],
'modules' => ['pendingValidation'],
]);
}
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
public function coverage(): void
{
$this->access->requirePage('coverage');
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
'pageSubtitle' => 'Les plages où le nombre d’agents planifiés passe sous le minimum attendu du lieu.',
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['coverage.js'],
@@ -76,11 +139,13 @@ final class PageController extends Controller
]);
}
/** Planning d'un lieu sur une semaine, en lecture seule. */
public function structures(): void
{
$this->access->requirePage('structures');
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
$this->renderPage('structures', 'Planning par lieu', 'structures', [
'pageSubtitle' => 'Le planning d’un lieu sur une semaine : les agents rattachés et ceux qui y sont réellement planifiés. Lecture seule.',
'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['structure-overview.js'],
@@ -92,11 +157,20 @@ final class PageController extends Controller
]);
}
/**
* Planning d'un agent, mois par mois et tous lieux confondus.
*
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
* sur « Mon planning » : la page sert alors de vue personnelle.
*/
public function agents(): void
{
$this->access->requirePage('agents');
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
'pageSubtitle' => $forcedAgent
? 'Vos horaires mois par mois, tous lieux d’affectation confondus.'
: 'Le planning d’un agent mois par mois, tous lieux d’affectation confondus, avec le suivi de son quota.',
'agents' => $this->loadAgents(),
'structures' => $this->loadStructuresForAgentEditor(),
'motifs' => $this->loadMotifs(),
@@ -110,10 +184,12 @@ final class PageController extends Controller
]);
}
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
public function annual(): void
{
$this->access->requirePage('annual');
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
'pageSubtitle' => 'L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une fois.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'scripts' => ['annual-overview.js'],
'modules' => ['annualOverview'],
@@ -124,10 +200,12 @@ final class PageController extends Controller
]);
}
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
public function pta(): void
{
$this->access->requirePage('pta');
$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.',
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'],
@@ -139,11 +217,15 @@ final class PageController extends Controller
]);
}
/** Composition des équipes sur les mercredis et les vacances. */
public function teams(): void
{
$this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'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
// proposé à la composition d'une équipe (V7.29).
'agents' => (new AgentModel($this->pdo))->allSelectable(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'],
'modules' => ['teams'],
@@ -154,33 +236,158 @@ final class PageController extends Controller
]);
}
/**
* Périodes de vacances scolaires : elles déterminent le contexte
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
* l'API data.education.gouv.fr (SchoolHolidayApiService).
*/
public function vacations(): void
{
$this->access->requirePage('vacations');
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
'pageSubtitle' => 'Les périodes de vacances qui déterminent le contexte périscolaire ou extrascolaire de chaque journée.',
'scripts' => ['vacation-calendar.js'],
'modules' => ['vacationCalendar'],
]);
}
/** Référentiels : agents, lieux, postes, règles de couverture. */
public function administration(): void
{
$this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', 'administration', [
'agents' => (new AgentModel($this->pdo))->allActive(),
'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.',
// Depuis la V7.14, cet écran ne porte plus les agents ni les lieux :
// ils ont leur propre page (management). Seule la liste des lieux
// reste nécessaire, pour son sélecteur.
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => (new AgentModel($this->pdo))->allPosts(),
'scripts' => ['administration.js', 'coverage.js'],
'modules' => ['administration', 'coverage'],
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
// Le contenu de la section vient entièrement de l'API — le HTML
// servi ne porte aucune information sur les comptes.
// `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),
],
]);
}
/**
* Guide d'utilisation. Page purement statique : aucun script, aucun appel
* API, aucune donnée métier. Elle suit malgré tout la règle des droits en
* base — tant que la migration n'est pas jouée, la page est refusée et le
* bouton d'accès n'apparaît pas dans la barre de navigation.
*/
public function faq(): void
{
$this->access->requirePage('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.',
]);
}
/**
* Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu.
*
* Pose les clés attendues par header.php et footer.php avec des valeurs par
* défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra
* donc correctement au lieu de lever une notice sur variable indéfinie.
*
* Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la
* CSP à nonce, et $cspNonce est ce que chaque <script> inline d'une vue doit
* porter pour être exécuté.
*
* @param string $view Vue de pages/, sans extension.
* @param string $activePage Clé de l'onglet à surligner.
* @param array<string, mixed> $data Surcharges spécifiques à la page.
*/
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{
Security::sendHtmlHeaders();
$profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([
'pageTitle' => $title,
// Chaque page explique en une phrase ce qu'on y fait : le bandeau
// générique ne disait rien et obligeait à ouvrir l'écran pour
// comprendre à quoi il servait.
'pageSubtitle' => 'Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.',
'activePage' => $activePage,
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'scripts' => [],
'modules' => [],
@@ -192,6 +399,15 @@ final class PageController extends Controller
], $data));
}
/**
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
*
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
* inaffichable dans la liste déroulante.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructures(): array
{
$model = new StructureModel($this->pdo);
@@ -206,6 +422,15 @@ final class PageController extends Controller
return $structure ? [$structure] : [];
}
/**
* Variante pour l'éditeur de la fiche agent.
*
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
* Enfance a donc besoin de la liste complète même s'il consulte un agent
* rattaché ailleurs.
*
* @return array<int, array<string, mixed>>
*/
private function loadStructuresForAgentEditor(): array
{
if ($this->access->role() === Access::ROLE_SERVICE) {
@@ -214,6 +439,13 @@ final class PageController extends Controller
return $this->loadStructures();
}
/**
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
* inclut les agents rattachés ailleurs mais planifiés sur place.)
*
* @return array<int, array<string, mixed>>
*/
private function loadAgents(): array
{
$model = new AgentModel($this->pdo);
@@ -228,28 +460,53 @@ final class PageController extends Controller
return $structureId ? $model->allVisibleForStructure($structureId) : [];
}
/**
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
* refuse (PlanningController::assertMotifsAllowedForAgent()).
*
* @return array<int, array<string, mixed>>
*/
private function loadMotifs(): array
{
return (new MotifModel($this->pdo))->allActive();
}
/**
* Semaine courante au format « 2026-W08 ».
*
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
* quand la semaine ISO appartient à l'année civile voisine.
* Fuseau explicite : le conteneur PHP tourne en UTC.
*/
private function currentWeek(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
}
// ---------------------------------------------------------------------
// Assainissement des paramètres d'URL. Ces valeurs partent dans
// window.PTA_CONFIG et pilotent les filtres appliqués par
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
// ---------------------------------------------------------------------
/** Identifiant d'entité : strictement positif, ou null. */
private function positiveInt(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $number === false ? null : (int) $number;
}
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
private function weekValue(mixed $value): ?string
{
$value = trim((string) $value);
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
}
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function dateValue(mixed $value): ?string
{
$value = trim((string) $value);
@@ -257,12 +514,14 @@ final class PageController extends Controller
return $date && $date->format('Y-m-d') === $value ? $value : null;
}
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
private function yearValue(mixed $value): ?int
{
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $number === false ? null : (int) $number;
}
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
private function monthValue(mixed $value): ?string
{
$value = trim((string) $value);

View File

@@ -6,7 +6,9 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\MotifCategories;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\MotifModel;
@@ -19,6 +21,26 @@ use DomainException;
use PDO;
use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController
{
private PlanningModel $planningModel;
@@ -29,6 +51,10 @@ final class PlanningController
private VacationModel $vacationModel;
private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo)
{
$this->planningModel = new PlanningModel($pdo);
@@ -40,6 +66,16 @@ final class PlanningController
$this->access = new Access($pdo);
}
/**
* GET api/planning.php — état complet d'une semaine de saisie.
*
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
* lecture seule pour rendre les chevauchements visibles avant l'échec.
*
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
*/
public function show(): void
{
$this->access->requireDraftEditor();
@@ -78,6 +114,23 @@ final class PlanningController
]);
}
/**
* POST save_planning.php — enregistre le brouillon d'une semaine.
*
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
*
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
* explicite au plus générique :
* 1. droits et périmètre ;
* 2. motifs fermés à l'agent (message nommant le motif) ;
* 3. normalisation/validation ligne à ligne ;
* 4. chevauchements internes à la saisie ;
* 5. chevauchements avec les autres lieux.
*
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
*/
public function save(): void
{
$this->access->requireDraftEditor();
@@ -98,7 +151,8 @@ final class PlanningController
if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
@@ -107,7 +161,11 @@ final class PlanningController
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds());
// Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
// affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertDatesWithinContract($agent, array_column($entries, 'date'));
$this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap(
$entries,
@@ -125,12 +183,26 @@ final class PlanningController
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
// référence documentée — vaut « inactif » par défaut. Le détail
// technique de l'exception peut donc remonter au navigateur sur une
// installation qui ne définit pas la variable. Le motif est répété
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
JsonResponse::send(500, [
'error' => 'Erreur lors de l’enregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
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, [
'success' => true,
'planning_id' => $planningId,
@@ -142,6 +214,17 @@ final class PlanningController
]);
}
/**
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
*
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
* validation intacte.
*
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
*/
public function updateAgentEntry(): void
{
$this->access->requireDraftEditor();
@@ -169,17 +252,29 @@ final class PlanningController
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
}
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
}
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
// sans lui, l'heure courante serait injectée et la comparaison de
// contrôle ci-dessous laisserait passer des dates approximatives.
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
// La saisie est contrainte au quart d'heure : c'est la granularité de
// toute l'application (éditeur, cumuls, PDF).
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
}
$this->assertDatesWithinContract($entryAgent, [$date]);
try {
$result = $this->planningModel->updateEntryFromAgentView(
@@ -200,6 +295,14 @@ final class PlanningController
}
$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, [
'success' => true,
'message' => $reopened
@@ -211,6 +314,16 @@ final class PlanningController
}
/**
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
*
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
* l'interface de le signaler, mais l'enregistrement reste et conserve son
* historique.
*
* Charge utile : entry_id, csrf_token.
*/
public function deleteAgentEntry(): void
{
$this->access->requireDraftEditor();
@@ -243,6 +356,14 @@ final class PlanningController
$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, [
'success' => true,
'message' => $message,
@@ -253,6 +374,16 @@ final class PlanningController
}
/**
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
*
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
* l'indique sans alarmer.
*
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
*/
public function moveOrDuplicateEntry(): void
{
$this->access->requireDraftEditor();
@@ -279,6 +410,12 @@ final class PlanningController
if (!$validDate || (int) $dateObj->format('N') > 5) {
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 {
$result = $this->planningModel->moveOrDuplicateEntry(
(int) $entryId,
@@ -306,6 +443,14 @@ final class PlanningController
$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, [
'success' => true,
'action' => $result['action'],
@@ -315,6 +460,18 @@ final class PlanningController
]);
}
/**
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
*
* Réservé au Service Enfance (requireService) et non aux responsables : la
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
* déborde donc par nature du périmètre d'un responsable de structure.
*
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
* `replace` les remplace.
*
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
*/
public function copyAgentWeek(): void
{
$this->access->requireService();
@@ -332,9 +489,15 @@ final class PlanningController
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
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 {
$result = $this->planningModel->copyAgentWeek(
@@ -363,6 +526,25 @@ final class PlanningController
$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, [
'success' => true,
'message' => $message,
@@ -374,6 +556,14 @@ final class PlanningController
]);
}
/**
* GET api/pending_plannings.php — file d'attente de validation.
*
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
* (`total_minutes`) des minutes réellement décomptées du quota
* (`counted_minutes`) : congés et absences non décomptées figurent au
* planning sans peser sur le quota annuel.
*/
public function pendingValidation(): void
{
$this->access->requireService();
@@ -397,6 +587,35 @@ 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
{
$this->access->requireService();
@@ -420,6 +639,19 @@ final class PlanningController
$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) {
$this->addControl(
$controls,
@@ -494,6 +726,25 @@ final class PlanningController
$this->planningModel->markValidated((int) $planningId);
$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, [
'success' => true,
'status' => 'validated',
@@ -503,6 +754,17 @@ final class PlanningController
]);
}
/**
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
*
* Réouverture volontaire, à distinguer de la réouverture automatique
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
* trompeur.
*
* Charge utile : planning_id, csrf_token.
*/
public function reopen(): void
{
$this->access->requireDraftEditor();
@@ -527,6 +789,18 @@ final class PlanningController
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, [
'success' => true,
'status' => 'BROUILLON',
@@ -535,6 +809,14 @@ final class PlanningController
]);
}
/**
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
*
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
* comptent bien une semaine 53.
*
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
*/
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
@@ -550,6 +832,64 @@ final class PlanningController
return [$year, $week];
}
/**
* Refuse les affectations portant un motif fermé à l'agent, avec un message
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
* plannings saisis avant le retrait du droit, et les appels directs.
*
* @param array<int, mixed> $rawEntries
*/
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
{
if (AgentModel::hasTrainingRight($agent)) {
return;
}
$restricted = [];
foreach ($this->motifModel->allActive() as $motif) {
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
}
}
if ($restricted === []) {
return;
}
foreach ($rawEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
if (isset($restricted[$motifId])) {
JsonResponse::send(422, [
'error' => sprintf(
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
$restricted[$motifId],
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? '')
),
]);
}
}
}
/**
* Valide et met en forme les créneaux reçus du client.
*
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
* motif appartenant à la liste ouverte à CET agent, horaires au quart
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
*
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
*
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -584,6 +924,15 @@ final class PlanningController
return $normalized;
}
/**
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
*
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -607,6 +956,20 @@ final class PlanningController
}
}
/**
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
*
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
* un agent ne peut pas être à deux endroits à la fois, même en cours de
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
* puisse nommer le lieu et l'horaire en cause.
*
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
* être comparables aux créneaux normalisés.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
* @param array<int, array<string, mixed>> $existingEntries
*/
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{
foreach ($entries as $entry) {
@@ -637,6 +1000,58 @@ 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
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

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

View File

@@ -7,53 +7,438 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Models\AgentModel;
use App\Models\StructureModel;
use App\Core\Journal;
use App\Core\Security;
use App\Core\Session;
use App\Models\CompteModel;
use App\Models\JournalModel;
use DomainException;
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
{
/** 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)
{
$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
{
$access = new Access($this->pdo);
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'] ?? '');
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();
}
header('Location: role.php', true, 302);
exit;
}
$error = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$expected = Csrf::ensureToken();
$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();
}
}
/**
* Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus
* aucun secret n'est même comparé.
*
* @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 '';
}
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'error' => $error,
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
// 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'] ?? '');
return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided);
}
}

View File

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

View File

@@ -6,6 +6,7 @@ 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\AgentModel;
@@ -15,6 +16,21 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Équipes des mercredis et des vacances.
*
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
* pouvant évoluer.
*
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
* BROUILLON → A_CONTROLER → VALIDEE.
*
* Réservé au Service Enfance.
*/
final class TeamController
{
private TeamModel $teamModel;
@@ -30,6 +46,21 @@ final class TeamController
$this->access = new Access($pdo);
}
/**
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
*
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
* actives sur une période.
*
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
* ratio à zéro provoquerait une division par zéro au calcul du taux
* d'encadrement.
*
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
* date_fin, effectifs et ratios facultatifs, csrf_token.
*/
public function index(): void
{
$this->access->requireService();
@@ -84,12 +115,33 @@ final class TeamController
'pourcentage_diplomes_min' => $qualified,
'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]);
} catch (Throwable $e) {
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
{
$this->access->requireService();
@@ -121,12 +173,28 @@ final class TeamController
$function,
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.']);
} catch (Throwable $e) {
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
{
$this->access->requireService();
@@ -139,6 +207,13 @@ final class TeamController
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
}
$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.']);
}
}

View File

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

View File

@@ -6,16 +6,42 @@ 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\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\WeekTemplateModel;
use DateTimeImmutable;
use DomainException;
use PDO;
use Throwable;
/**
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
*
* Une semaine type n'est PAS un planning : elle décrit une organisation
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
*
* Deux origines pour une semaine type, qui convergent vers
* WeekTemplateModel::persistTemplate() :
* - store() : capture d'une semaine réellement planifiée ;
* - storeEntries() : composition directe dans l'éditeur.
*
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
*
* Convention de codes d'erreur propre à ce contrôleur :
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
* alors de le remplacer en renvoyant replace: true) ;
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
* reformulation de ce message dans WeekTemplateModel casse la distinction.
*/
final class WeekTemplateController
{
private WeekTemplateModel $templateModel;
@@ -31,6 +57,12 @@ final class WeekTemplateController
$this->access = new Access($pdo);
}
/**
* GET api/week_templates.php — liste des semaines types d'un agent.
*
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
* show() sert à ouvrir.
*/
public function index(): void
{
$this->access->requireService();
@@ -55,6 +87,152 @@ final class WeekTemplateController
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
{
$this->access->requireService();
@@ -90,6 +268,14 @@ 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, [
'success' => true,
'template' => $template,
@@ -97,6 +283,15 @@ final class WeekTemplateController
]);
}
/**
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
*
* Deux modes : `merge` complète les créneaux existants, `replace` les
* remplace. Appliquer sur une semaine validée la repasse en brouillon
* (`requires_revalidation`).
*
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
*/
public function apply(): void
{
$this->access->requireService();
@@ -112,12 +307,18 @@ final class WeekTemplateController
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
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 {
[$year, $weekNumber] = $week;
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
@@ -140,6 +341,27 @@ final class WeekTemplateController
$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, [
'success' => true,
'message' => $message,
@@ -151,6 +373,21 @@ final class WeekTemplateController
]);
}
/**
* POST api/preview_week_template_period.php — simulation SANS écriture.
*
* Étape obligatoire dans l'interface avant applyPeriod() : une application
* sur période touche des dizaines de semaines et peut rouvrir des plannings
* validés. L'utilisateur doit voir le volume avant de confirmer.
*
* `period_filter` restreint le périmètre au calendrier scolaire :
* all → toutes les semaines de l'intervalle ;
* school → semaines scolaires seulement (périscolaire) ;
* extra → vacances scolaires seulement (extrascolaire).
*
* Charge utile : template_id, agent_id, start_date, end_date, mode,
* period_filter, csrf_token.
*/
public function previewPeriod(): void
{
$this->access->requireService();
@@ -175,9 +412,11 @@ final class WeekTemplateController
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
try {
$preview = $this->templateModel->previewPeriod(
@@ -206,6 +445,18 @@ final class WeekTemplateController
]);
}
/**
* POST api/apply_week_template_period.php — applique un modèle sur une période.
*
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
*
* Les quotas sont renvoyés indexés par date de début de période : une
* application à cheval sur deux années civiles touche DEUX quotas annuels
* distincts, et l'interface doit rafraîchir les deux.
*/
public function applyPeriod(): void
{
$this->access->requireService();
@@ -230,9 +481,11 @@ final class WeekTemplateController
) {
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
try {
$result = $this->templateModel->applyToPeriod(
@@ -294,6 +547,27 @@ final class WeekTemplateController
$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, [
'success' => true,
'message' => $message,
@@ -303,6 +577,14 @@ final class WeekTemplateController
]);
}
/**
* POST api/delete_week_template.php — supprime une semaine type.
*
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
* patron, pas un lien vivant vers les plannings produits.
*/
public function destroy(): void
{
$this->access->requireService();
@@ -320,9 +602,67 @@ final class WeekTemplateController
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.']);
}
/**
* 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
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

@@ -6,41 +6,100 @@ namespace App\Core;
use PDO;
/**
* Contrôle des rôles et des périmètres.
*
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
* ne fait que poser la question à la base et appliquer la réponse.
*/
final class Access
{
/** Lecture seule sur son propre planning. */
public const ROLE_AGENT = 'AGENT';
/** Édition des brouillons de son seul lieu d'affectation. */
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
/** Périmètre complet ; seul rôle habilité à valider un planning. */
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';
/** @var array<int, string> Les quatre rôles connus de l'application. */
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 = [];
/** @var array<string, array<int, string>> */
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)
{
if (session_status() !== PHP_SESSION_ACTIVE) {
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
{
$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) {
return $this->agentId() !== null && $this->agentRecord() !== null;
}
if ($role === self::ROLE_RESPONSABLE) {
return $this->structureId() !== null && $this->structureRecord() !== null;
}
return $role === self::ROLE_SERVICE;
return true;
}
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
public function role(): ?string
{
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
? $role
: null;
return in_array($role, self::ROLES, true) ? $role : null;
}
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
public function agentId(): ?int
{
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
@@ -49,6 +108,13 @@ final class Access
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
{
if ($this->role() === self::ROLE_AGENT) {
@@ -64,37 +130,98 @@ final class Access
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
{
$role = strtoupper(trim($role));
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
throw new \DomainException('Rôle invalide.');
}
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
if ($role === self::ROLE_AGENT) {
if (!$agentId || !$this->activeAgentExists($agentId)) {
throw new \DomainException('Sélectionnez un agent actif.');
}
$selection['agent_id'] = $agentId;
} elseif ($role === self::ROLE_RESPONSABLE) {
if (!$structureId || !$this->activeStructureExists($structureId)) {
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
}
$selection['structure_id'] = $structureId;
}
$_SESSION[self::SESSION_KEY] = $selection;
// Regénération de l'identifiant de session à chaque ouverture : parade
// classique à la fixation de session.
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = [
'role' => (string) $compte['role'],
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
? (int) $compte['id_agent']
: null,
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null
? (int) $compte['id_structure']
: null,
'compte_id' => (int) $compte['id_compte'],
// Libellé figé pour le journal : il évite une jointure à chaque trace.
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''),
];
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle
// connexion invalide le jeton précédent.
unset($_SESSION['csrf_token']);
$this->accountCache = null;
$this->accountLoaded = false;
}
/** Ferme la session de travail (déconnexion). */
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY]);
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
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
{
$role = $this->role();
@@ -105,6 +232,7 @@ final class Access
'label' => $this->roleLabel(),
'agent_id' => $this->agentId(),
'structure_id' => $this->structureId(),
'account_id' => $this->accountId(),
'permissions' => $permissions,
];
@@ -123,61 +251,114 @@ final class Access
$profile['structure_name'] = (string) $structure['nom'];
$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;
}
/**
* Permissions fonctionnelles lues dans v_acces_role_permission.
* Une permission absente de la base vaut « refusé ».
*
* @return array<string, bool>
*/
public function permissions(): array
{
$role = $this->role();
$service = $role === self::ROLE_SERVICE;
$responsable = $role === self::ROLE_RESPONSABLE;
return [
'read_only' => $role === self::ROLE_AGENT,
'can_edit_draft' => $service || $responsable,
'can_edit_cross_structure' => $service,
'can_validate' => $service,
'can_reopen' => $service || $responsable,
'can_use_templates' => $service,
'can_copy_full_week' => $service,
'can_view_pending' => $service,
'can_manage_coverage' => $service,
'can_manage_agents' => $service,
'can_manage_structures' => $service,
'can_manage_vacations' => $service,
'can_manage_teams' => $service,
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
if ($role === null) {
return [];
}
if (!isset($this->permissionCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
);
$stmt->execute(['role' => $role]);
$permissions = [];
foreach ($stmt->fetchAll() as $row) {
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
}
$this->permissionCache[$role] = $permissions;
}
return $this->permissionCache[$role];
}
/** Raccourci de lecture d'une permission ; absente = refusée. */
public function hasPermission(string $permission): bool
{
return $this->permissions()[$permission] ?? false;
}
/**
* Pages autorisées lues dans v_acces_role_page.
*
* @return array<int, string>
*/
public function allowedPages(): array
{
return match ($this->role()) {
self::ROLE_AGENT => ['agents', 'structures'],
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
default => [],
};
$role = $this->role();
if ($role === null) {
return [];
}
if (!isset($this->pageCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
);
$stmt->execute(['role' => $role]);
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
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
{
return in_array($page, $this->allowedPages(), true);
}
/**
* Page d'atterrissage du rôle.
*
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
* repli sur « planning » est indispensable, car la page « dashboard » n'est
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
* requirePage() renverrait indéfiniment vers une page interdite.
*/
public function homeUrl(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => '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',
};
}
/**
* 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
{
if (!$this->isConfigured()) {
@@ -185,47 +366,108 @@ final class Access
exit;
}
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);
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
{
if (!$this->isConfigured()) {
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
$this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [
'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)) {
$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']);
}
}
/**
* 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
{
$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
{
$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
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
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']);
}
}
/** Édition dans un lieu : un responsable est confiné au sien. */
public function requireStructureEdit(int $structureId): void
{
$this->requireDraftEditor();
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']);
}
}
/**
* 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
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
@@ -238,9 +480,14 @@ final class Access
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
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']);
}
/**
* É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
{
$this->requireDraftEditor();
@@ -248,10 +495,7 @@ final class Access
return;
}
$stmt = $this->pdo->prepare(
'SELECT p.id_structure
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
@@ -261,13 +505,16 @@ final class Access
$this->requireStructureEdit((int) $structureId);
}
/** Édition d'un planning identifié, périmètre déduit de son lieu. */
public function requirePlanningEdit(int $planningId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
$stmt = $this->pdo->prepare(
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
);
$stmt->execute(['id' => $planningId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
@@ -276,35 +523,74 @@ final class Access
$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
{
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
{
return match ($this->role()) {
self::ROLE_AGENT => 'Agent',
self::ROLE_RESPONSABLE => 'Responsable de structure',
self::ROLE_SERVICE => 'Service Enfance',
self::ROLE_DSI => 'DSI',
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
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
$stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false;
if ($this->accountLoaded) {
return $this->accountCache;
}
private function activeStructureExists(int $structureId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
$stmt->execute(['id' => $structureId]);
return $stmt->fetchColumn() !== false;
$this->accountLoaded = true;
$this->accountCache = null;
$compteId = $this->accountId();
if ($compteId === null) {
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
{
$agentId = $this->agentId();
@@ -312,16 +598,19 @@ final class Access
return null;
}
$stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
s.nom AS structure_nom
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
FROM v_acces_identite_agent
WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
/**
* Fiche du lieu en session.
*
* @return array<string, mixed>|null
*/
private function structureRecord(): ?array
{
$structureId = $this->structureId();
@@ -330,13 +619,17 @@ final class Access
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
FROM v_acces_identite_structure
WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
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
{
$structureId = $this->structureId();
@@ -344,25 +637,11 @@ final class Access
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM agent a
WHERE a.id_agent = :agent_id
AND a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1 FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
'SELECT 1 FROM v_acces_agent_par_structure
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agentId,
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
}

View File

@@ -0,0 +1,85 @@
<?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,8 +4,22 @@ declare(strict_types=1);
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
{
/**
* 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
{
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
@@ -13,6 +27,8 @@ abstract class Controller
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);
require $viewFile;
}

View File

@@ -4,13 +4,24 @@ declare(strict_types=1);
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
{
/**
* 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
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@@ -19,12 +30,27 @@ final class Csrf
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
{
$expected = self::ensureToken();
$provided = (string) ($payload['csrf_token'] ?? '');
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.']);
}
}

182
app/Core/Journal.php Normal file
View File

@@ -0,0 +1,182 @@
<?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,15 +4,35 @@ declare(strict_types=1);
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
{
/**
* 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
{
ini_set('display_errors', '0');
ini_set('html_errors', '0');
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 {
// Respecte @ et error_reporting() : on laisse alors PHP gérer.
if (!(error_reporting() & $severity)) {
return false;
}
@@ -21,30 +41,40 @@ final class JsonResponse
});
set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf(
'[PTA API] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
// Security::reportThrowable journalise systématiquement le détail
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
// explicitement activé. Par défaut, rien ne fuit.
$payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
if (Security::debugEnabled()) {
$payload['details'] = $exception->getMessage();
}
Security::reportThrowable($exception, 'Erreur API.');
self::send(500, $payload);
});
}
/**
* É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
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
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');
}
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);
exit;
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Classement métier des motifs de planning, partagé par les modèles et les PDF.
*
* Le référentiel `motif_planning` distingue le temps de travail effectif (ALP matin,
* restauration, ALP soir, mercredi périscolaire, extrascolaire, préparations, ménage de fond…)
* du temps décompté mais non travaillé (formation, absence) et du temps hors quota
* (congé, autre absence, JNT). Tous ces motifs de travail doivent être présentés
* comme des heures travaillées, et pas seulement le motif générique `TRAVAIL`.
*/
final class MotifCategories
{
public const TRAVAIL = 'TRAVAIL';
public const FORMATION = 'FORMATION';
public const CONGE = 'CONGE';
public const ABSENCE = 'ABSENCE';
public const NON_DECOMPTE = 'NON_DECOMPTE';
/** Motifs comptant comme du temps de travail effectif. */
public const WORK_CODES = [
'TRAVAIL',
'ALP_MATIN',
'RESTAURATION',
'ALP_SOIR',
'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE',
'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI',
'MENAGE_FOND',
'JOUR_FRACTIONNEMENT',
];
/** Motifs décomptés du quota sans être du temps de travail effectif. */
private const FORMATION_CODES = ['FORMATION'];
private const ABSENCE_CODES = ['ABSENCE', 'MALADIE'];
private const CONGE_CODES = ['CONGE'];
private const NON_DECOMPTE_CODES = ['AUTRE_ABSENCE', 'JNT'];
public static function normalizeCode(?string $code): string
{
return strtoupper(trim((string) $code));
}
/**
* Motif dont l'affectation dépend d'un droit porté par l'agent : la formation
* n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h
* (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()).
*/
public static function requiresTrainingRight(?string $code): bool
{
return self::normalizeCode($code) === self::FORMATION;
}
/**
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté
* en base après coup, et il évite qu'un total disparaisse des documents.
*/
public static function isWork(?string $code, bool $countsInQuota = false): bool
{
return self::category($code, $countsInQuota) === self::TRAVAIL;
}
/**
* @return self::TRAVAIL|self::FORMATION|self::CONGE|self::ABSENCE|self::NON_DECOMPTE
*/
public static function category(?string $code, bool $countsInQuota = false): string
{
$normalized = self::normalizeCode($code);
if (in_array($normalized, self::WORK_CODES, true)) {
return self::TRAVAIL;
}
if (in_array($normalized, self::FORMATION_CODES, true)) {
return self::FORMATION;
}
if (in_array($normalized, self::ABSENCE_CODES, true)) {
return self::ABSENCE;
}
if (in_array($normalized, self::CONGE_CODES, true)) {
return self::CONGE;
}
if (in_array($normalized, self::NON_DECOMPTE_CODES, true)) {
return self::NON_DECOMPTE;
}
return $countsInQuota ? self::TRAVAIL : self::NON_DECOMPTE;
}
}

View File

@@ -4,8 +4,24 @@ declare(strict_types=1);
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
{
/**
* 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
{
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
@@ -17,6 +33,8 @@ final class PdfResponse
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
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('Pragma: public');
}

View File

@@ -4,8 +4,19 @@ declare(strict_types=1);
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
{
/**
* Décode le corps JSON de la requête.
*
* @return array<string, mixed>
*/
public static function json(): array
{
$payload = json_decode((string) file_get_contents('php://input'), true);
@@ -16,6 +27,9 @@ final class Request
return $payload;
}
/**
* Impose le verbe HTTP attendu (typiquement « POST » pour un endpoint mutant).
*/
public static function requireMethod(string $method): void
{
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {

89
app/Core/Security.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* En-têtes de sécurité des réponses HTML.
*
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
* stricte sans dégrader l'interface.
*/
final class Security
{
private static ?string $nonce = null;
public static function nonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
public static function sendHtmlHeaders(): void
{
if (headers_sent()) {
return;
}
$nonce = self::nonce();
$policy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"img-src 'self' data:",
"style-src 'self'",
"font-src 'self'",
// Aucune requête sortante depuis le navigateur : les appels à
// data.education.gouv.fr sont faits côté serveur.
"connect-src 'self'",
sprintf("script-src 'self' 'nonce-%s'", $nonce),
];
header('Content-Security-Policy: ' . implode('; ', $policy));
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
if (Session::isHttps()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* Vrai uniquement si le mode debug a été activé explicitement.
* Par défaut, aucun détail technique ne sort du serveur.
*/
public static function debugEnabled(): bool
{
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
}
/**
* Journalise l'exception côté serveur et renvoie le message à afficher.
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
*/
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
{
error_log(sprintf(
'[PTA] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
return self::debugEnabled()
? $publicMessage . ' Détail : ' . $exception->getMessage()
: $publicMessage;
}
}

59
app/Core/Session.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Démarrage unique et durci de la session PHP.
*
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
*/
final class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
self::$started = true;
return;
}
if (!headers_sent()) {
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => self::isHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_start();
self::$started = true;
}
/**
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
* de confiance. Sans cette détection, le cookie de session serait marqué
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
*/
public static function isHttps(): bool
{
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
}

View File

@@ -8,13 +8,204 @@ use DateTimeImmutable;
use PDO;
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
{
/** Enveloppe annuelle complémentaire de formation : 14 h. */
public const ENVELOPPE_FORMATION_MINUTES = 840;
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas se voir
* affecter le motif « Formation ». Le droit vaut par défaut lorsque
* l'information n'est pas projetée : ne jamais retirer un droit sur une
* donnée absente.
*/
public static function hasTrainingRight(array $agent): bool
{
return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1;
}
/**
* Minutes de formation ouvertes à l'agent sur l'année : l'enveloppe de 14 h
* s'il y a droit, rien sinon.
*/
public static function trainingEnvelopeMinutes(array $agent): int
{
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'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
a.formation_repartition_annuelle, a.commentaire_pta,
a.droit_enveloppe_formation, a.commentaire_pta,
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation,
@@ -29,6 +220,41 @@ final class AgentModel extends BaseModel
LEFT JOIN structure s ON s.id_structure = a.id_structure
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
{
return $this->pdo->query(
@@ -36,6 +262,28 @@ final class AgentModel extends BaseModel
)->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
{
$stmt = $this->pdo->prepare(
@@ -59,6 +307,14 @@ final class AgentModel extends BaseModel
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
{
return $this->pdo->query(
@@ -70,6 +326,15 @@ final class AgentModel extends BaseModel
)->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
{
return $this->pdo->query(
@@ -82,6 +347,19 @@ final class AgentModel extends BaseModel
)->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
{
$stmt = $this->pdo->prepare(
@@ -93,13 +371,18 @@ final class AgentModel extends BaseModel
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.actif = TRUE
WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . "
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
);
$stmt->execute(['selected_location' => $selectedLocation]);
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
{
$stmt = $this->pdo->prepare(
@@ -109,6 +392,10 @@ final class AgentModel extends BaseModel
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
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
@@ -116,6 +403,25 @@ final class AgentModel extends BaseModel
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
{
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
@@ -129,7 +435,9 @@ final class AgentModel extends BaseModel
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
$trainingEnvelope = self::trainingEnvelopeMinutes([
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
]);
try {
$this->pdo->beginTransaction();
@@ -139,13 +447,13 @@ final class AgentModel extends BaseModel
matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta,
droit_enveloppe_formation, commentaire_pta,
id_structure, actif
) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta,
:droit_enveloppe_formation, :commentaire_pta,
:structure_id, TRUE
)'
);
@@ -162,7 +470,7 @@ final class AgentModel extends BaseModel
'id_poste' => $data['id_poste'],
'type_contrat' => $data['type_contrat'],
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
'commentaire_pta' => $data['commentaire_pta'],
'structure_id' => $data['structure_id'],
]);
@@ -235,6 +543,32 @@ 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
{
try {
@@ -251,7 +585,9 @@ final class AgentModel extends BaseModel
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
SET nom = :nom,
prenom = :prenom,
email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
@@ -260,12 +596,14 @@ final class AgentModel extends BaseModel
id_poste = :id_poste,
type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle,
droit_enveloppe_formation = :droit_enveloppe_formation,
commentaire_pta = :commentaire_pta,
id_structure = :structure_id
WHERE id_agent = :agent_id'
);
$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(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);
@@ -275,7 +613,7 @@ final class AgentModel extends BaseModel
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
$update->bindValue(':type_contrat', $data['type_contrat']);
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
if ($newStructureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
@@ -315,6 +653,15 @@ 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
{
try {
@@ -358,6 +705,14 @@ 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
{
$stmt = $this->pdo->prepare(
@@ -382,6 +737,15 @@ final class AgentModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -408,6 +772,18 @@ final class AgentModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -433,6 +809,11 @@ final class AgentModel extends BaseModel
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
{
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));

View File

@@ -9,10 +9,42 @@ use DatePeriod;
use DateTimeImmutable;
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
{
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
{
$agentModel = new AgentModel($this->pdo);
@@ -100,6 +132,27 @@ 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
{
$cells = [];
@@ -186,6 +239,18 @@ 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
{
$quotaModel = new QuotaModel($this->pdo);
@@ -210,6 +275,14 @@ final class AnnualOverviewModel extends BaseModel
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
{
return match ($code) {
@@ -222,6 +295,12 @@ 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
{
return match ($code) {
@@ -236,6 +315,10 @@ 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
{
return match ($code) {
@@ -251,6 +334,17 @@ 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
{
$priority = [
@@ -268,6 +362,12 @@ final class AnnualOverviewModel extends BaseModel
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
{
if ($minutes <= 0) {
@@ -279,6 +379,15 @@ final class AnnualOverviewModel extends BaseModel
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
{
foreach ($vacations as $vacation) {
@@ -289,6 +398,7 @@ final class AnnualOverviewModel extends BaseModel
return null;
}
/** Durée en minutes entre deux HH:MM, plancher à 0. */
private function minutesBetween(string $start, string $end): int
{
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
@@ -296,6 +406,14 @@ final class AnnualOverviewModel extends BaseModel
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
{
$formatted = [];
@@ -309,6 +427,7 @@ final class AnnualOverviewModel extends BaseModel
return $formatted;
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
@@ -316,6 +435,7 @@ final class AnnualOverviewModel extends BaseModel
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
{
if ($minutes <= 0) {
@@ -326,6 +446,7 @@ final class AnnualOverviewModel extends BaseModel
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
{
return [
@@ -335,6 +456,7 @@ final class AnnualOverviewModel extends BaseModel
][$month];
}
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
private function weekdayShort(int $weekday): string
{
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];

View File

@@ -6,6 +6,13 @@ namespace App\Models;
use PDO;
/**
* Base commune des modèles : porte la connexion PDO partagée.
*
* Les modèles ne contiennent que du SQL et la mise en forme de ses résultats ;
* les règles d'accès sont appliquées en amont par les contrôleurs
* (voir App\Core\Access). Un modèle ne filtre donc jamais par rôle de lui-même.
*/
abstract class BaseModel
{
public function __construct(protected PDO $pdo)

706
app/Models/CompteModel.php Normal file
View File

@@ -0,0 +1,706 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Core\Access;
use DomainException;
/**
* Comptes d'accès et jetons (V7.11).
*
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
* un post-it.
*
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
*
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
*/
final class CompteModel extends BaseModel
{
/**
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
* caractères qu'on confond à la lecture en sont exclus.
*/
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
private const GROUPES = 4;
private const TAILLE_GROUPE = 6;
/**
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
*/
public const MOT_DE_PASSE_MINIMUM = 12;
/**
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
* qu'un préfixe de 72.
*/
public const MOT_DE_PASSE_MAXIMUM = 72;
/**
* Empreinte du jeton d'installation posé par la migration V7.11.
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
* tant que le compte DSI initial n'a pas été renouvelé.
*/
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
/**
* Vérifie un jeton présenté à la connexion.
*
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
* jeton techniquement valide.
*
* @return array<string, mixed>|null Le compte, ou null si le jeton est
* inconnu, révoqué ou porté par une
* identité inactive.
*/
public function authentifier(string $jeton): ?array
{
$jeton = self::normaliser($jeton);
if ($jeton === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT * FROM v_acces_compte
WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['empreinte' => self::empreinte($jeton)]);
return $stmt->fetch() ?: null;
}
/**
* Relit un compte par son identifiant, pour la SECONDE étape de connexion.
*
* Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif
* et identité active : entre la saisie du jeton et celle du mot de passe, la
* DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage,
* là où trouver() la met en forme pour l'API et le retire.
*
* @return array<string, mixed>|null
*/
public function trouverPourAuthentification(int $compteId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM v_acces_compte
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['id' => $compteId]);
return $stmt->fetch() ?: null;
}
/** Horodate une connexion réussie et incrémente son compteur. */
public function enregistrerConnexion(int $compteId): void
{
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET date_derniere_connexion = NOW(),
nombre_connexions = nombre_connexions + 1
WHERE id_compte = :id'
);
$stmt->execute(['id' => $compteId]);
}
/**
* Liste des comptes pour la page DSI et pour la page « Jetons d'accès » du
* Service Enfance.
*
* `roles` BORNE la liste à un ensemble de rôles. C'est ce qui permet à la
* délégation V7.27 de ne jamais laisser voir un compte privilégié : le
* contrôleur passe sa liste blanche, et aucun filtre d'interface ne peut
* l'élargir — `role` ne fait que restreindre à l'intérieur de `roles`.
*
* @param array<string, mixed> $filtres role, actif (0|1), q (texte libre),
* roles (array<int, string> facultatif).
*
* @return array<int, array<string, mixed>>
*/
public function lister(array $filtres = []): array
{
$conditions = [];
$params = [];
$rolesAutorises = array_values(array_filter(array_map(
static fn (mixed $role): string => strtoupper(trim((string) $role)),
(array) ($filtres['roles'] ?? [])
)));
if ($rolesAutorises !== []) {
$marqueurs = [];
foreach ($rolesAutorises as $index => $roleAutorise) {
$marqueurs[] = ':role_autorise_' . $index;
$params['role_autorise_' . $index] = $roleAutorise;
}
$conditions[] = 'role IN (' . implode(', ', $marqueurs) . ')';
}
$role = (string) ($filtres['role'] ?? '');
// Le filtre d'interface ne peut que restreindre : un rôle hors de la
// liste blanche ne rend rien, il ne la contourne pas.
if ($role !== '' && ($rolesAutorises === [] || in_array(strtoupper($role), $rolesAutorises, true))) {
$conditions[] = 'role = :role';
$params['role'] = $role;
} elseif ($role !== '') {
$conditions[] = '1 = 0';
}
if (isset($filtres['actif']) && $filtres['actif'] !== '') {
$conditions[] = 'actif = :actif';
$params['actif'] = (int) $filtres['actif'];
}
$recherche = trim((string) ($filtres['q'] ?? ''));
if ($recherche !== '') {
// UN MARQUEUR PAR COLONNE, et non un seul répété quatre fois : PDO
// tourne ici sans émulation de requêtes préparées (ATTR_EMULATE_
// PREPARES = 0), et MariaDB refuse alors un même paramètre nommé
// employé plusieurs fois — « Invalid parameter number ». Répéter
// « :q » faisait échouer toute recherche en 500.
$colonnes = ['identite_libelle', 'libelle', 'matricule', 'jeton_prefixe'];
$comparaisons = [];
foreach ($colonnes as $index => $colonne) {
$comparaisons[] = $colonne . ' LIKE :q_' . $index;
$params['q_' . $index] = '%' . $recherche . '%';
}
$conditions[] = '(' . implode(' OR ', $comparaisons) . ')';
}
$where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
$stmt = $this->pdo->prepare(
'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure,
matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire,
date_creation, date_rotation, date_derniere_connexion, nombre_connexions,
mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe
FROM v_acces_compte' . $where . '
ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle'
);
$stmt->execute($params);
return array_map(
fn (array $ligne): array => $this->presenter($ligne),
$stmt->fetchAll()
);
}
/**
* Un compte par son identifiant.
*
* @return array<string, mixed>|null
*/
public function trouver(int $compteId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1');
$stmt->execute(['id' => $compteId]);
$compte = $stmt->fetch();
return $compte === false ? null : $this->presenter($compte);
}
/**
* Identités actives encore dépourvues de compte.
*
* @return array<int, array<string, mixed>>
*/
public function manquants(): array
{
$stmt = $this->pdo->query(
'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle'
);
return $stmt->fetchAll();
}
/**
* Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire.
*
* @return array{compte: array<string, mixed>, jeton: string}
*
* @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas.
*/
public function creer(
string $role,
?int $agentId,
?int $structureId,
string $libelle,
?string $commentaire = null
): array {
$role = strtoupper(trim($role));
$libelle = trim($libelle);
if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) {
throw new DomainException('Rôle inconnu.');
}
// Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son
// identité : sans identité, la session n'aurait aucun lieu ni aucun
// agent à borner. Les deux autres rôles n'ont pas de périmètre à borner.
if ($role === Access::ROLE_AGENT) {
$structureId = null;
if ($agentId === null) {
throw new DomainException('Choisissez l’agent titulaire du jeton.');
}
$libelle = $this->libelleAgent($agentId);
} elseif ($role === Access::ROLE_RESPONSABLE) {
$agentId = null;
if ($structureId === null) {
throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.');
}
$libelle = $this->libelleStructure($structureId);
} else {
$agentId = null;
$structureId = null;
if ($libelle === '') {
throw new DomainException('Indiquez le nom du titulaire du jeton.');
}
}
$jeton = self::genererJeton();
$stmt = $this->pdo->prepare(
'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire)
VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)'
);
try {
$stmt->execute([
'role' => $role,
'libelle' => mb_substr($libelle, 0, 180),
'id_agent' => $agentId,
'id_structure' => $structureId,
'empreinte' => self::empreinte($jeton),
'prefixe' => self::prefixe($jeton),
'commentaire' => $commentaire !== null && trim($commentaire) !== ''
? mb_substr(trim($commentaire), 0, 255)
: null,
]);
} catch (\PDOException $e) {
// 23000 = violation de contrainte : les index uniques sur id_agent et
// id_structure garantissent un seul compte par identité.
if ($e->getCode() === '23000') {
throw new DomainException('Cette identité possède déjà un compte d’accès.');
}
throw $e;
}
$compte = $this->trouver((int) $this->pdo->lastInsertId());
return ['compte' => $compte ?? [], 'jeton' => $jeton];
}
/**
* Régénère le jeton d'un compte : l'ancien cesse immédiatement de
* fonctionner, y compris pour les sessions ouvertes — Access relit le compte
* à chaque requête, mais c'est l'empreinte qui a changé, pas le compte.
*
* @return string Le nouveau jeton, en clair, à transmettre au titulaire.
*
* @throws DomainException Si le compte n'existe pas.
*/
public function regenererJeton(int $compteId): string
{
if ($this->trouver($compteId) === null) {
throw new DomainException('Compte introuvable.');
}
$jeton = self::genererJeton();
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET jeton_empreinte = :empreinte,
jeton_prefixe = :prefixe,
date_rotation = NOW()
WHERE id_compte = :id'
);
$stmt->execute([
'empreinte' => self::empreinte($jeton),
'prefixe' => self::prefixe($jeton),
'id' => $compteId,
]);
return $jeton;
}
/**
* Active ou désactive un compte.
*
* @throws DomainException Si l'opération laisserait l'application sans
* aucune DSI en état de se connecter.
*/
public function definirActif(int $compteId, bool $actif): void
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
if (!$actif) {
$this->assurerDerniereDsi($compte);
}
$stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id');
$stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]);
}
/**
* Supprime définitivement un compte. Les traces qu'il a laissées dans le
* journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le
* libellé de l'acteur y est figé.
*
* @throws DomainException Si c'est le dernier accès DSI actif.
*/
public function supprimer(int $compteId): void
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
$this->assurerDerniereDsi($compte);
$stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id');
$stmt->execute(['id' => $compteId]);
}
/**
* Génère en une passe un jeton pour chaque agent et chaque lieu actif qui
* n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer
* les comptes un par un avant que quiconque puisse se connecter.
*
* @return array<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
* Les jetons produits, en clair. Cette liste est la seule occasion
* de les lire : elle doit être distribuée puis refermée.
*/
public function genererManquants(): array
{
$produits = [];
foreach ($this->manquants() as $identite) {
$role = (string) $identite['role'];
$agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null;
$structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null;
$resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']);
$produits[] = [
'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0),
'role' => $role,
'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']),
'jeton' => $resultat['jeton'],
];
}
return $produits;
}
/** Nombre de comptes DSI actifs, hors compte $exclu. */
public function compterDsiActives(?int $exclu = null): int
{
// Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées,
// un même paramètre nommé ne peut donc pas être répété dans la requête,
// et aucun compte ne porte l'identifiant 0.
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM v_acces_compte
WHERE role = :role AND actif = 1 AND identite_active = 1
AND id_compte <> :exclu'
);
$stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]);
return (int) $stmt->fetchColumn();
}
/**
* Vrai tant qu'un compte actif porte encore le jeton d'installation, dont
* l'empreinte est écrite en clair dans la migration versionnée — donc
* publique. L'interface DSI s'en sert pour alerter en rouge.
*
* La question est posée à la base plutôt qu'à la liste affichée : un filtre
* de recherche ne doit pas faire disparaître l'alerte.
*/
public function installationEnCours(): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1 FROM compte_acces
WHERE jeton_empreinte = :empreinte AND actif = 1
LIMIT 1'
);
$stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]);
return $stmt->fetchColumn() !== false;
}
/**
* Ce compte réclame-t-il un mot de passe en plus de son jeton ?
*
* La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des
* rôles concernés se modifie en SQL, comme le reste des droits depuis la
* V7.8.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public static function exigeMotDePasse(array $compte): bool
{
return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1;
}
/**
* Le titulaire a-t-il déjà choisi son mot de passe ?
* Faux à la création du compte comme après une réinitialisation par la DSI :
* la connexion suivante impose alors d'en définir un.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public static function motDePasseDefini(array $compte): bool
{
return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1;
}
/**
* Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a
* changé d'algorithme par défaut depuis sa création.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte, avec son hachage.
*/
public function verifierMotDePasse(array $compte, string $motDePasse): bool
{
$hash = (string) ($compte['mot_de_passe_hash'] ?? '');
if ($hash === '' || !password_verify($motDePasse, $hash)) {
return false;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT));
}
return true;
}
/**
* Enregistre le mot de passe choisi par le titulaire.
*
* @throws DomainException Si la politique n'est pas respectée.
*/
public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void
{
if ($motDePasse !== $confirmation) {
throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.');
}
$this->assurerPolitique($motDePasse);
$this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT));
}
/**
* Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le
* RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec
* son jeton — la DSI ne connaît donc jamais le mot de passe de personne.
*
* @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe.
*/
public function reinitialiserMotDePasse(int $compteId): array
{
$compte = $this->trouver($compteId);
if ($compte === null) {
throw new DomainException('Compte introuvable.');
}
if (!$compte['mot_de_passe_requis']) {
throw new DomainException('Ce type de compte n’utilise pas de mot de passe.');
}
$stmt = $this->pdo->prepare(
'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id'
);
$stmt->execute(['id' => $compteId]);
return $compte;
}
/**
* Politique de mot de passe : la longueur, et rien d'autre.
*
* @throws DomainException Si le mot de passe est trop court, trop long ou
* trop proche du jeton.
*/
private function assurerPolitique(string $motDePasse): void
{
// Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et
// un accent compte pour deux.
$longueur = strlen($motDePasse);
if ($longueur < self::MOT_DE_PASSE_MINIMUM) {
throw new DomainException(sprintf(
'Le mot de passe doit contenir au moins %d caractères.',
self::MOT_DE_PASSE_MINIMUM
));
}
if ($longueur > self::MOT_DE_PASSE_MAXIMUM) {
throw new DomainException(sprintf(
'Le mot de passe ne peut pas dépasser %d caractères.',
self::MOT_DE_PASSE_MAXIMUM
));
}
if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) {
// Recopier son jeton dans le champ mot de passe ramènerait les deux
// facteurs à un seul secret.
throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.');
}
}
/** Écrit une empreinte de mot de passe et horodate le changement. */
private function enregistrerHash(int $compteId, string $hash): void
{
$stmt = $this->pdo->prepare(
'UPDATE compte_acces
SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW()
WHERE id_compte = :id'
);
$stmt->execute(['hash' => $hash, 'id' => $compteId]);
}
/** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */
public static function genererJeton(): string
{
$groupes = [];
for ($groupe = 0; $groupe < self::GROUPES; $groupe++) {
$caracteres = '';
for ($index = 0; $index < self::TAILLE_GROUPE; $index++) {
// random_int() et non rand() : le tirage doit être imprévisible.
$caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
}
$groupes[] = $caracteres;
}
return 'PTA-' . implode('-', $groupes);
}
/** Empreinte stockée en base. */
public static function empreinte(string $jeton): string
{
return hash('sha256', self::normaliser($jeton));
}
/**
* Nettoie un jeton saisi à la main : espaces, casse et espaces insécables
* collés par un copier-coller depuis un courriel.
*/
public static function normaliser(string $jeton): string
{
$jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton);
return strtoupper(trim($jeton));
}
/** Début du jeton, conservé en base pour l'identifier sans le révéler. */
private static function prefixe(string $jeton): string
{
return mb_substr(self::normaliser($jeton), 0, 11);
}
/**
* Met en forme une ligne de v_acces_compte pour l'API : types corrects,
* empreinte remplacée par le seul drapeau qui intéresse l'interface.
*
* @param array<string, mixed> $ligne
*
* @return array<string, mixed>
*/
private function presenter(array $ligne): array
{
return [
'id_compte' => (int) $ligne['id_compte'],
'role' => (string) $ligne['role'],
'libelle' => (string) $ligne['libelle'],
'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']),
'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1,
'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null,
'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null,
'matricule' => $ligne['matricule'] ?? null,
'structure_nom' => $ligne['structure_nom'] ?? null,
'jeton_prefixe' => (string) $ligne['jeton_prefixe'],
// Le jeton d'installation est public : l'interface doit pouvoir
// crier tant qu'il n'a pas été remplacé.
'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION,
'actif' => (int) $ligne['actif'] === 1,
// Le hachage ne sort jamais du serveur : l'interface n'a besoin que
// de savoir si un mot de passe est exigé et s'il a été choisi.
'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1,
'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1,
'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null,
'commentaire' => $ligne['commentaire'] ?? null,
'date_creation' => $ligne['date_creation'],
'date_rotation' => $ligne['date_rotation'],
'date_derniere_connexion' => $ligne['date_derniere_connexion'],
'nombre_connexions' => (int) $ligne['nombre_connexions'],
];
}
/**
* Interdit de se couper soi-même la branche : le dernier accès DSI actif ne
* peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer
* la migration pour récupérer la main.
*
* @param array<string, mixed> $compte
*/
private function assurerDerniereDsi(array $compte): void
{
if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) {
return;
}
if ($this->compterDsiActives((int) $compte['id_compte']) === 0) {
throw new DomainException(
'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.'
);
}
}
/** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */
private function libelleAgent(int $agentId): string
{
$stmt = $this->pdo->prepare(
'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
$libelle = $stmt->fetchColumn();
if ($libelle === false) {
throw new DomainException('Agent introuvable ou inactif.');
}
return (string) $libelle;
}
/** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */
private function libelleStructure(int $structureId): string
{
$stmt = $this->pdo->prepare(
'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
$libelle = $stmt->fetchColumn();
if ($libelle === false) {
throw new DomainException('Lieu d’affectation introuvable ou inactif.');
}
return (string) $libelle;
}
}

View File

@@ -8,6 +8,23 @@ use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Règles de couverture d'un lieu et détection des plages sous-effectif.
*
* PRINCIPE DU CALCUL : chaque règle (jour, plage, minimum d'agents) est
* découpée en créneaux de 15 MINUTES, l'effectif présent est compté sur chacun,
* puis les créneaux consécutifs identiques sont refusionnés en intervalles
* lisibles. Ce détour par le quart d'heure est ce qui permet de traiter
* correctement des créneaux qui se recouvrent partiellement — un simple
* comptage par plage donnerait un effectif faux dès que deux agents ont des
* horaires décalés. Le pas de 15 minutes est aussi la granularité de saisie de
* toute l'application.
*
* Deux niveaux de signalement :
* - `gaps` : effectif PLANIFIÉ insuffisant — un vrai manque ;
* - `validation_warnings` : effectif atteint, mais pas encore VALIDÉ. La
* couverture n'est donc pas garantie, sans être en défaut.
*/
final class CoverageModel extends BaseModel
{
private const DAY_LABELS = [
@@ -18,6 +35,11 @@ final class CoverageModel extends BaseModel
5 => 'Vendredi',
];
/**
* Règles de couverture d'un lieu, triées par jour puis par horaire.
*
* @return array<int, array<string, mixed>>
*/
public function rulesForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
@@ -33,6 +55,16 @@ final class CoverageModel extends BaseModel
return $stmt->fetchAll();
}
/**
* Remplace INTÉGRALEMENT les règles d'un lieu, en une transaction.
*
* Une liste vide supprime toute règle : le lieu redevient « non configuré »
* et cesse de produire des alertes. Il n'y a pas d'ajout ni de suppression
* unitaire, la validation métier (chevauchements compris) étant faite en
* amont par CoverageController::saveRules().
*
* @param array<int, array<string, mixed>> $rules Règles déjà validées.
*/
public function replaceRules(int $structureId, array $rules): void
{
try {
@@ -71,6 +103,18 @@ final class CoverageModel extends BaseModel
}
}
/**
* Rapport de couverture d'un lieu sur une semaine.
*
* Un lieu sans règle renvoie `configured: false` et AUCUNE alerte : il n'y a
* pas d'attendu, donc pas de manque. L'appelant doit le signaler à part —
* taire ces lieux les ferait passer pour conformes.
*
* L'analyse s'arrête au VENDREDI (weekStart + 4 jours), conformément au
* modèle métier.
*
* @return array{configured: bool, rules: array<int, array<string, mixed>>, gaps: array<int, array<string, mixed>>, validation_warnings: array<int, array<string, mixed>>, summary: array{gap_count: int, gap_minutes: int, validation_warning_count: int}}
*/
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
{
$rules = $this->rulesForStructure($structureId);
@@ -172,6 +216,21 @@ final class CoverageModel extends BaseModel
];
}
/**
* Créneaux comptant pour la couverture d'un lieu.
*
* ATTENTION : le filtre porte sur le seul motif `TRAVAIL`. Les motifs de
* travail plus fins (ALP_MATIN, RESTAURATION, ALP_SOIR, EXTRASCOLAIRE…),
* pourtant reconnus comme du travail effectif par
* App\Core\MotifCategories::WORK_CODES, ne sont PAS comptés ici. Un lieu
* planifié exclusivement avec ces motifs apparaîtra donc entièrement
* découvert. À revoir si les alertes remontent des manques inexpliqués.
*
* Les agents désactivés sont exclus (jointure sur `a.actif = TRUE`) : ils ne
* couvrent plus rien.
*
* @return array<int, array<string, mixed>>
*/
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
{
$stmt = $this->pdo->prepare(
@@ -195,6 +254,21 @@ final class CoverageModel extends BaseModel
return $stmt->fetchAll();
}
/**
* Ajoute un créneau de 15 minutes, en le FUSIONNANT avec le précédent quand
* c'est le même jour, qu'il enchaîne exactement (fin = début) et que tous
* les compteurs comparés sont identiques.
*
* Sans cette fusion, une matinée découverte produirait seize alertes de
* quinze minutes au lieu d'une seule ligne de 8h00 à 12h00.
*
* Ne regarde que le DERNIER élément : les créneaux sont produits dans
* l'ordre chronologique, une fusion plus lointaine n'a pas lieu d'être.
*
* @param array<int, array<string, mixed>> $target Liste enrichie sur place.
* @param array<string, mixed> $slot Créneau candidat.
* @param array<int, string> $comparisonKeys Compteurs devant coïncider.
*/
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
{
$lastIndex = count($target) - 1;
@@ -212,12 +286,14 @@ final class CoverageModel extends BaseModel
$target[] = $slot;
}
/** « 09:30 » → 570. Tronque à HH:MM, les horaires arrivant parfois en HH:MM:SS. */
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
return ($hours * 60) + $minutes;
}
/** 570 → « 09:30 ». Opération inverse de timeToMinutes(). */
private function minutesToTime(int $minutes): string
{
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Models;
/**
* Agrégats de pilotage du tableau de bord.
*
* Ce modèle ne porte AUCUNE règle métier nouvelle : il ne fait que compter et
* additionner ce que les autres écrans affichent déjà en détail. Le calcul du
* quota reprend exactement la logique de QuotaModel (année civile, motifs
* décomptés uniquement, quota de l'année la plus proche à défaut de l'année
* exacte) afin qu'un agent n'affiche jamais deux valeurs différentes selon
* l'écran consulté.
*/
final class DashboardModel extends BaseModel
{
/**
* Suivi du quota annuel de tous les agents actifs, en deux requêtes.
*
* @return array<int, array<string, mixed>>
*/
public function quotaTracking(int $year): array
{
$profiles = $this->quotaProfiles($year);
$rows = $this->allocatedMinutes($year);
$tracking = [];
foreach ($rows as $row) {
$agentId = (int) $row['id_agent'];
$profile = $profiles[$agentId] ?? [
'quotite_travail' => 100.0,
'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES,
'annee' => $year,
];
$target = (int) $profile['quota_cible_minutes'];
$validated = (int) $row['minutes_valides'];
$draft = (int) $row['minutes_brouillon'];
$allocated = $validated + $draft;
$tracking[] = [
'id_agent' => $agentId,
'matricule' => (string) ($row['matricule'] ?? ''),
'nom' => (string) $row['nom'],
'prenom' => (string) $row['prenom'],
'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null,
'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null,
'quotite_travail' => (float) $profile['quotite_travail'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $profile['annee'],
'quota_estime' => !isset($profiles[$agentId]),
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0,
];
}
return $tracking;
}
/**
* Minutes décomptées de l'année civile, par agent actif.
*
* @return array<int, array<string, mixed>>
*/
private function allocatedMinutes(int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom,
s.nom AS structure_nom,
po.libelle AS poste_libelle,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN planning p ON p.id_agent = a.id_agent
LEFT JOIN creneau_horaire c
ON c.id_planning = p.id_planning
AND c.date_jour BETWEEN :date_debut AND :date_fin
LEFT JOIN motif_planning m
ON m.id_motif = c.id_motif
AND m.compte_dans_quota = TRUE
WHERE a.actif = TRUE
GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'date_debut' => sprintf('%04d-01-01', $year),
'date_fin' => sprintf('%04d-12-31', $year),
]);
return $stmt->fetchAll();
}
/**
* Quota retenu pour chaque agent : année exacte si elle existe, sinon
* l'année passée la plus proche, sinon l'année future la plus proche.
* Même ordre de préférence que QuotaModel::quotaProfile().
*
* @return array<int, array<string, mixed>>
*/
private function quotaProfiles(int $year): array
{
$stmt = $this->pdo->prepare(
'SELECT id_agent, annee, quotite_travail, quota_cible_minutes
FROM (
SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes,
ROW_NUMBER() OVER (
PARTITION BY q.id_agent
ORDER BY CASE
WHEN q.annee = :year_exact THEN 0
WHEN q.annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN q.annee <= :year_latest THEN q.annee END DESC,
CASE WHEN q.annee > :year_future THEN q.annee END ASC
) AS rang
FROM quota_agent_annuel q
) AS classement
WHERE rang = 1'
);
$stmt->execute([
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$profiles = [];
foreach ($stmt->fetchAll() as $row) {
$profiles[(int) $row['id_agent']] = [
'annee' => (int) $row['annee'],
'quotite_travail' => (float) $row['quotite_travail'],
'quota_cible_minutes' => (int) $row['quota_cible_minutes'],
];
}
return $profiles;
}
/**
* Nombre de brouillons contenant au moins une affectation.
* Requête volontairement légère : elle alimente la pastille de navigation
* rechargée sur chaque page.
*/
public function pendingCount(): int
{
$stmt = $this->pdo->query(
"SELECT COUNT(*)
FROM (
SELECT p.id_planning
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning
) AS brouillons_servis"
);
return (int) $stmt->fetchColumn();
}
/**
* Nombre d'agents actifs et de lieux actifs, pour situer les compteurs.
*
* @return array{agents: int, structures: int}
*/
public function scope(): array
{
return [
'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(),
'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(),
];
}
}

265
app/Models/JournalModel.php Normal file
View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
/**
* Lecture du journal d'activité pour la page DSI (V7.11).
*
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
* n'est jamais loin d'une action métier. La seule exception est la purge, une
* opération d'exploitation déclenchée à la main par la DSI.
*
* Le volume est la contrainte de conception : un journal se consulte par
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
* total est demandé séparément plutôt que ramené ligne par ligne.
*/
final class JournalModel extends BaseModel
{
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
public const TAILLE_PAGE = 50;
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
private const TAILLE_PAGE_MAX = 200;
/**
* Recherche paginée dans le journal.
*
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
* q (texte libre), du, au (AAAA-MM-JJ),
* page, taille.
*
* @return array{entrees: array<int, array<string, mixed>>, total: int, page: int, taille: int, pages: int}
*/
public function rechercher(array $filtres = []): array
{
[$where, $params] = $this->conditions($filtres);
$taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE);
$taille = max(10, min(self::TAILLE_PAGE_MAX, $taille));
$page = max(1, (int) ($filtres['page'] ?? 1));
$total = $this->compter($where, $params);
$pages = $total === 0 ? 1 : (int) ceil($total / $taille);
// Une page vidée par un filtre plus restrictif ne doit pas renvoyer un
// écran blanc : on se replie sur la dernière page réellement peuplée.
$page = min($page, $pages);
$stmt = $this->pdo->prepare(
'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role,
acteur, cible_table, cible_id, description, contexte, adresse_ip,
agent_utilisateur, jeton_prefixe
FROM v_journal_activite' . $where . '
ORDER BY date_evenement DESC, id_journal DESC
LIMIT :limite OFFSET :decalage'
);
foreach ($params as $nom => $valeur) {
$stmt->bindValue(':' . $nom, $valeur);
}
// LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de
// requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50'
// que MariaDB refuse.
$stmt->bindValue(':limite', $taille, PDO::PARAM_INT);
$stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT);
$stmt->execute();
return [
'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()),
'total' => $total,
'page' => $page,
'taille' => $taille,
'pages' => $pages,
];
}
/**
* Quelques chiffres de tête pour le bandeau de la page DSI.
*
* @return array<string, mixed>
*/
public function statistiques(): array
{
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn();
$stmt = $this->pdo->query(
'SELECT categorie, COUNT(*) AS nombre
FROM journal_activite
WHERE date_evenement >= NOW() - INTERVAL 7 DAY
GROUP BY categorie'
);
$parCategorie = [];
foreach ($stmt->fetchAll() as $ligne) {
$parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre'];
}
$refus24h = (int) $this->pdo->query(
'SELECT COUNT(*) FROM journal_activite
WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY'
)->fetchColumn();
$connexions24h = (int) $this->pdo->query(
'SELECT COUNT(*) FROM journal_activite
WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\'
AND date_evenement >= NOW() - INTERVAL 1 DAY'
)->fetchColumn();
$plusAncienne = $this->pdo->query(
'SELECT MIN(date_evenement) FROM journal_activite'
)->fetchColumn();
return [
'total' => $total,
'sept_jours' => $parCategorie,
'refus_24h' => $refus24h,
'connexions_24h' => $connexions24h,
'plus_ancienne' => $plusAncienne ?: null,
];
}
/**
* Nombre de secrets refusés pour une adresse IP sur les dernières minutes.
*
* C'est la mesure qui alimente le frein anti-force-brute de la page de
* connexion : le journal sert ici de compteur, sans table supplémentaire.
* Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et
* mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton
* volé doit buter sur le même mur qu'essayer mille jetons.
*/
public function echecsRecents(string $adresseIp, int $minutes): int
{
if ($adresseIp === '') {
return 0;
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM journal_activite
WHERE categorie = \'CONNEXION\'
AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\')
AND adresse_ip = :ip
AND date_evenement >= NOW() - INTERVAL :minutes MINUTE'
);
$stmt->bindValue(':ip', $adresseIp);
$stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT);
$stmt->execute();
return (int) $stmt->fetchColumn();
}
/**
* Purge les entrées antérieures à $jours jours.
*
* Le plancher de 30 jours est volontaire : une purge trop courte reviendrait
* à effacer les traces d'un incident avant même qu'il soit constaté.
*
* @return int Nombre de lignes supprimées.
*/
public function purger(int $jours): int
{
$jours = max(30, $jours);
$stmt = $this->pdo->prepare(
'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY'
);
$stmt->bindValue(':jours', $jours, PDO::PARAM_INT);
$stmt->execute();
return $stmt->rowCount();
}
/**
* Construit la clause WHERE commune à la recherche et au comptage.
*
* @param array<string, mixed> $filtres
*
* @return array{0: string, 1: array<string, mixed>}
*/
private function conditions(array $filtres): array
{
$conditions = [];
$params = [];
foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) {
$valeur = trim((string) ($filtres[$filtre] ?? ''));
if ($valeur !== '') {
$conditions[] = $colonne . ' = :' . $filtre;
$params[$filtre] = $valeur;
}
}
$compteId = (int) ($filtres['id_compte'] ?? 0);
if ($compteId > 0) {
$conditions[] = 'id_compte = :id_compte';
$params['id_compte'] = $compteId;
}
$du = trim((string) ($filtres['du'] ?? ''));
if ($du !== '') {
$conditions[] = 'date_evenement >= :du';
$params['du'] = $du . ' 00:00:00';
}
$au = trim((string) ($filtres['au'] ?? ''));
if ($au !== '') {
$conditions[] = 'date_evenement <= :au';
$params['au'] = $au . ' 23:59:59';
}
$recherche = trim((string) ($filtres['q'] ?? ''));
if ($recherche !== '') {
$conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)';
$params['q'] = '%' . $recherche . '%';
}
return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params];
}
/**
* @param array<string, mixed> $params
*/
private function compter(string $where, array $params): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
/**
* Met en forme une entrée pour l'API. `contexte` est décodé ici : le client
* reçoit un objet, pas une chaîne JSON à réanalyser.
*
* @param array<string, mixed> $ligne
*
* @return array<string, mixed>
*/
private function presenter(array $ligne): array
{
$contexte = null;
if (!empty($ligne['contexte'])) {
$decode = json_decode((string) $ligne['contexte'], true);
$contexte = is_array($decode) ? $decode : null;
}
return [
'id_journal' => (int) $ligne['id_journal'],
'date_evenement' => (string) $ligne['date_evenement'],
'categorie' => (string) $ligne['categorie'],
'action' => (string) $ligne['action'],
'resultat' => (string) $ligne['resultat'],
'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null,
'role' => $ligne['role'],
'acteur' => $ligne['acteur'],
'cible_table' => $ligne['cible_table'],
'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null,
'description' => $ligne['description'],
'contexte' => $contexte,
'adresse_ip' => $ligne['adresse_ip'],
'agent_utilisateur' => $ligne['agent_utilisateur'],
'jeton_prefixe' => $ligne['jeton_prefixe'],
];
}
}

View File

@@ -4,8 +4,29 @@ declare(strict_types=1);
namespace App\Models;
use App\Core\MotifCategories;
/**
* Référentiel des motifs de créneau (`motif_planning`).
*
* Le motif porte le drapeau `compte_dans_quota`, qui décide si le temps saisi
* s'impute ou non sur le quota annuel de l'agent. C'est le seul point de
* bascule : CONGE, AUTRE_ABSENCE et JNT ne comptent pas (voir
* QuotaModel::REFERENCE_MINUTES pour le pourquoi).
*
* Les motifs sont classés fonctionnellement par App\Core\MotifCategories, qui
* distingue travail effectif, formation, congé et absence.
*/
final class MotifModel extends BaseModel
{
/**
* Tous les motifs actifs, dans l'ordre d'affichage du référentiel.
*
* Liste NON filtrée par agent : voir allActiveForAgent() pour la variante
* qui applique les droits.
*
* @return array<int, array<string, mixed>>
*/
public function allActive(): array
{
return $this->pdo->query(
@@ -16,6 +37,11 @@ final class MotifModel extends BaseModel
)->fetchAll();
}
/**
* Identifiants des motifs actifs.
*
* @return int[]
*/
public function activeIds(): array
{
return array_map(
@@ -23,4 +49,37 @@ final class MotifModel extends BaseModel
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
);
}
/**
* Motifs réellement affectables à un agent : « Formation » disparaît de la
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
*
* C'est la liste de référence des contrôles de saisie : tout motif absent
* d'ici est refusé côté serveur, quelle que soit l'interface utilisée.
*
* @param array<string, mixed> $agent Fiche agent (doit porter
* droit_enveloppe_formation).
*
* @return array<int, array<string, mixed>>
*/
public function allActiveForAgent(array $agent): array
{
if (AgentModel::hasTrainingRight($agent)) {
return $this->allActive();
}
return array_values(array_filter(
$this->allActive(),
static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code'])
));
}
/** @return int[] */
public function activeIdsForAgent(array $agent): array
{
return array_map(
static fn(array $motif): int => (int) $motif['id_motif'],
$this->allActiveForAgent($agent)
);
}
}

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