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
209 changed files with 17417 additions and 924 deletions

3
.gitignore vendored
View File

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

110
CLAUDE.md
View File

@@ -28,6 +28,7 @@ docker compose up -d --build
docker compose up -d --build php nginx docker compose up -d --build php nginx
docker compose logs -f php nginx db docker compose logs -f php nginx db
docker compose logs db-migrate # compte rendu de l'application des migrations
docker compose ps docker compose ps
docker compose down # arrêt en conservant les données docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB docker compose down -v # DÉTRUIT le volume MariaDB
@@ -52,7 +53,9 @@ Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fi
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`, idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`,
`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une `INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. 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 `PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`, Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
@@ -65,7 +68,8 @@ Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'h
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes. Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, **Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` : `coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`,
`manage_structures.php`, `administration.php`, `dsi.php` :
```php ```php
require __DIR__ . '/page_bootstrap.php'; require __DIR__ . '/page_bootstrap.php';
@@ -118,19 +122,44 @@ l'application entière refuse de démarrer.
### Rôles et périmètres (`app/Core/Access.php`) ### Rôles et périmètres (`app/Core/Access.php`)
Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition Quatre rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider). 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` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement `role.php` n'est plus un sélecteur depuis la V7.11 : c'est une **authentification par jeton** (voir
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la `README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur
mécanique de droits. 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 **`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en `database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de
échouer l'application si la migration manque — c'est volontairement fail-closed. `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 `dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
@@ -138,14 +167,37 @@ jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite. indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`, commence par un garde `requireService()`, `requireDsi()`, `requireDraftEditor()`,
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent `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 directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
garde ni sans `Csrf::assertPayload($payload)`.** garde ni sans `Csrf::assertPayload($payload)`.**
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS : CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
`csrf_token: window.PTA_CONFIG.csrfToken`. `csrf_token: window.PTA_CONFIG.csrfToken`.
### Journalisation (V7.11)
`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par
`app/bootstrap.php`, les appelants n'ont donc rien à transporter :
```php
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
```
Trois règles :
- **tout endpoint mutant doit journaliser son succès**, après l'écriture en base et avant le
`JsonResponse::send()` — au même titre qu'il porte un garde et un `Csrf::assertPayload()` ;
- **les lectures ne sont jamais journalisées** (choix de volume) ; les refus le sont, et `Access`
comme `Csrf` s'en chargent déjà — inutile de les doubler dans les contrôleurs ;
- **le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas
entourer un appel de logique qui pourrait lever.
Le journal sert aussi de compteur au frein anti-force-brute de la connexion
(`JournalModel::echecsRecents()`) : renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE`
désarmerait ce frein silencieusement.
### Contraintes de sécurité à respecter ### Contraintes de sécurité à respecter
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`. - **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
@@ -174,7 +226,7 @@ PTA.modules.agentOverview = { init, load };
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`. rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans 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 `layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon
les navigateurs et le `expires 7d` de nginx servent l'ancienne version. les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
@@ -183,14 +235,43 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de - Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`). contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
- Les 1 607 h sont un volume **NET de travail effectif** : `365 - 104 (week-ends) - 25 (congés
payés) - 8 (jours fériés) = 228 j × 7 h = 1 596 h`, arrondies à 1 600 h, plus les 7 h de la
journée de solidarité. Congés payés et jours fériés en sont donc **déjà retranchés** : leur
imputer des heures de quota les décompterait une seconde fois. Une tentative de décompter un
forfait de 7 h par jour de CP a été introduite puis annulée pour cette raison — ne pas la
réintroduire.
- **Un agent n'est planifiable qu'entre ses dates de contrat** (V7.15,
`date_debut_contrat` / `date_fin_contrat`). La règle vaut pour TOUS les motifs, congés compris :
hors contrat, il n'y a rien à décompter. Une borne absente ouvre — pas de date de fin signifie
contrat à durée indéterminée. Elle est appliquée à l'écriture (`assertDatesWithinContract()` dans
`PlanningController` et `WeekTemplateController`), au contrôle de validation (`HORS_CONTRAT`) et à
l'inverse, sur la fiche agent : raccourcir un contrat sous des affectations existantes est refusé.
Toute nouvelle voie d'écriture de créneaux doit poser la même garde.
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient - Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas). de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
- **Un compte d'accès coupé retire l'agent des listes d'AFFECTATION** (V7.29) : sélecteur de la
saisie (`AgentModel::listForLocation()`) et composition des équipes (`allSelectable()`). Un agent
**sans compte** reste proposé — c'est l'état normal avant distribution des jetons, le filtrer
viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel
« Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès. `allActive()` reste
la liste non filtrée ; ne pas confondre les deux.
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est - Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures. qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ; - Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
elles sont importées depuis l'API publique data.education.gouv.fr via elles sont importées depuis l'API publique data.education.gouv.fr via
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant). `SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
- Une **semaine type** (`modele_semaine_agent` / `modele_semaine_creneau`) appartient à **un agent**
et décrit un lundi→vendredi générique. Ce n'est pas un planning : rien n'est planifié tant qu'elle
n'est pas appliquée à une semaine ou dupliquée sur une période. Elle se compose soit à partir
d'une semaine réellement planifiée (`saveFromWeek()`), soit directement dans l'éditeur
(`saveFromEntries()`, voir `README_V7_10.md`) — les deux passent par `persistTemplate()`.
`planning.php` propose deux modes exclusifs (voir `README_V7_10.md`) : l'éditeur de semaine type,
ouvert par défaut, et la saisie semaine par semaine. Le second reste la cible de tous les liens
profonds : dès que l'URL porte `week` ou `focus`, `page-bootstrap.js` bascule sur ce mode, sinon un
utilisateur venant corriger un planning identifié atterrirait dans l'éditeur de modèles.
### Piège : logique de validation dupliquée ### Piège : logique de validation dupliquée
@@ -214,5 +295,8 @@ message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation exp
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe. `PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout - Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
déploiement réel. déploiement réel.
- Le **jeton DSI d'installation** (`PTA-INSTALLATION-DSI`) est écrit en clair dans
`database/migration_v7_9e_dsi_comptes_journal.sql`, donc public. La page DSI alerte tant qu'il n'a
pas été régénéré ; c'est la première action à faire après une installation.
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier — - Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
utiles pour comprendre une décision, mais seul le code fait foi. utiles pour comprendre une décision, mais seul le code fait foi.

View File

@@ -1,75 +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.8 — Sécurité et droits en base
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
vues SQL sont décrits dans `README_V7_8.md`. La migration
`database/migration_v7_8_securite_acces.sql` est obligatoire.
## V7.4 — Rôles et droits d’accès
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — semaines types d'un agent, avec leurs compteurs (Service Enfance).
* Paramètre : agent_id.
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\WeekTemplateController; use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

@@ -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,7 +5,9 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\ContactFormat;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\PdfResponse; use App\Core\PdfResponse;
use App\Core\Request; use App\Core\Request;
@@ -20,6 +22,18 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController final class AgentController
{ {
private AgentModel $agentModel; private AgentModel $agentModel;
@@ -35,6 +49,13 @@ final class AgentController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void public function listForPlanning(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -45,6 +66,15 @@ final class AgentController
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]); JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
} }
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void public function overview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -73,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 public function monthOverview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -149,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 public function timeSummaryPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -194,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 public function pdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -257,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 public function hoursContractPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -296,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 public function assignments(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -331,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, [ JsonResponse::send(200, [
'message' => $structureId === null 'message' => $structureId === null
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.' ? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
@@ -338,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 public function create(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -377,8 +485,15 @@ final class AgentController
if ($rate === false || $rate <= 0 || $rate > 100) { if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']); JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
} }
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { // Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); // 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 if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
@@ -411,6 +526,10 @@ final class AgentController
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.', 'error' => 'Impossible de créer l’agent.',
'details' => Security::debugEnabled() ? $e->getMessage() : null, 'details' => Security::debugEnabled() ? $e->getMessage() : null,
@@ -418,6 +537,14 @@ final class AgentController
} }
$structure = $this->structureModel->findActive((int) $structureId); $structure = $this->structureModel->findActive((int) $structureId);
Journal::ecriture(
'AGENT_CREE',
'agent',
isset($created['id_agent']) ? (int) $created['id_agent'] : null,
sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule),
['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType]
);
JsonResponse::send(201, [ JsonResponse::send(201, [
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']), 'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
'agent' => array_merge($created, [ 'agent' => array_merge($created, [
@@ -444,6 +571,18 @@ final class AgentController
]); ]);
} }
/**
* POST api/update_agent.php — modification de la fiche d'un agent.
*
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
*
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
* rétroactivement tous les suivis.
*/
public function updateProfile(): void public function updateProfile(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -452,6 +591,8 @@ final class AgentController
Csrf::assertPayload($payload); Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT); $agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$nom = trim((string) ($payload['nom'] ?? ''));
$prenom = trim((string) ($payload['prenom'] ?? ''));
$email = trim((string) ($payload['email'] ?? '')); $email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? '')); $telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? '')); $adresse = trim((string) ($payload['adresse'] ?? ''));
@@ -476,27 +617,68 @@ final class AgentController
if (!$agentId) { if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']); 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.']); JsonResponse::send(404, ['error' => 'Agent introuvable.']);
} }
if ($nom === '' || $prenom === '') {
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
}
if (!$postId || !in_array((int) $postId, $validPostIds, true) if (!$postId || !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true) || !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart || !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) { || ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']); JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
} }
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { // Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); // 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.']); JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
} }
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) { if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
} }
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
// contrat sous des affectations existantes. Le refus nomme la période
// concernée, pour que l'utilisateur sache quoi retirer avant de
// recommencer.
$orphelins = $this->agentModel->entriesOutsideContract(
(int) $agentId,
$contractStart,
$contractEnd !== '' ? $contractEnd : null
);
if ($orphelins['nombre'] > 0) {
$lisible = static fn(?string $date): string => $date === null
? '—'
: implode('/', array_reverse(explode('-', $date)));
JsonResponse::send(422, [
'error' => sprintf(
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
$orphelins['nombre'],
$lisible($orphelins['premiere']),
$lisible($orphelins['derniere'])
),
'code' => 'HORS_CONTRAT',
]);
}
try { try {
$this->agentModel->updateProfile((int) $agentId, [ $this->agentModel->updateProfile((int) $agentId, [
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null, 'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null, 'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null, 'adresse' => $adresse !== '' ? $adresse : null,
@@ -517,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, [ JsonResponse::send(200, [
'message' => 'Les informations de l’agent ont été mises à jour.', '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 private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

@@ -6,6 +6,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\Request; use App\Core\Request;
use App\Models\AnnualOverviewModel; use App\Models\AnnualOverviewModel;
@@ -14,12 +15,28 @@ use App\Models\QuotaModel;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Vue annuelle d'un agent et validation de l'année en une opération.
*
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
* s'apprécie le quota annualisé (voir QuotaModel).
*
* Réservé au Service Enfance : valider une année couvre par nature tous les
* lieux où l'agent est intervenu.
*/
final class AnnualOverviewController final class AnnualOverviewController
{ {
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
} }
/**
* GET api/annual_overview.php — répartition de l'année d'un agent.
*
* L'année est facultative et retombe sur l'année en cours.
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
*/
public function overview(): void public function overview(): void
{ {
(new Access($this->pdo))->requireService(); (new Access($this->pdo))->requireService();
@@ -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 * Contrôle puis validation en une seule opération de tous les plannings
* en brouillon de l'agent pour l'année civile affichée. * en brouillon de l'agent pour l'année civile affichée.
* *
* Aucune validation partielle : si une erreur est trouvée, aucun planning * Aucune validation partielle : si une erreur est trouvée, aucun planning
* n'est validé. Les semaines concernées sont retournées au front afin de * n'est validé. Les semaines concernées sont retournées au front afin de
* pouvoir les ouvrir dans un nouvel onglet et les corriger. * 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 public function validateYear(): void
{ {
@@ -133,6 +165,19 @@ final class AnnualOverviewController
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings); $ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
$validatedCount = $planningModel->markValidatedMany($ids); $validatedCount = $planningModel->markValidatedMany($ids);
Journal::ecriture(
'ANNEE_VALIDEE',
'planning',
null,
sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year),
[
'id_agent' => (int) $agentId,
'annee' => (int) $year,
'id_plannings' => $ids,
'quota_force' => $forceQuota,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'status' => 'validated', 'status' => 'validated',
@@ -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 private function controlsForPlanning(PlanningModel $model, array $planning): array
{ {
$planningId = (int) $planning['id_planning']; $planningId = (int) $planning['id_planning'];
@@ -162,6 +225,19 @@ final class AnnualOverviewController
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
} }
foreach ($model->entriesOutsideContract($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'HORS_CONTRAT',
sprintf(
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
$row['id_creneau'],
$row['date_jour']
)
);
}
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) { foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
$this->addControl( $this->addControl(
$controls, $controls,
@@ -203,6 +279,13 @@ final class AnnualOverviewController
return $controls; return $controls;
} }
/**
* Empile un contrôle au format commun {niveau, code, message}.
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
* pas de base commune.
*
* @param array<int, array{niveau: string, code: string, message: string}> $controls
*/
private function addControl(array &$controls, string $level, string $code, string $message): void private function addControl(array &$controls, string $level, string $code, string $message): void
{ {
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

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

View File

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

View File

@@ -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

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

View File

@@ -6,6 +6,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\MotifCategories; use App\Core\MotifCategories;
use App\Core\Request; use App\Core\Request;
@@ -20,6 +21,26 @@ use DomainException;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController final class PlanningController
{ {
private PlanningModel $planningModel; private PlanningModel $planningModel;
@@ -30,6 +51,10 @@ final class PlanningController
private VacationModel $vacationModel; private VacationModel $vacationModel;
private Access $access; private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
$this->planningModel = new PlanningModel($pdo); $this->planningModel = new PlanningModel($pdo);
@@ -41,6 +66,16 @@ final class PlanningController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/planning.php — état complet d'une semaine de saisie.
*
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
* lecture seule pour rendre les chevauchements visibles avant l'échec.
*
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
*/
public function show(): void public function show(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -79,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 public function save(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -113,6 +165,7 @@ final class PlanningController
// affectable à un agent sans droit à l'enveloppe de formation. // affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent); $this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent)); $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertDatesWithinContract($agent, array_column($entries, 'date'));
$this->assertNoInternalOverlap($entries); $this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap( $this->assertNoOtherLocationOverlap(
$entries, $entries,
@@ -130,12 +183,26 @@ final class PlanningController
} catch (DomainException $e) { } catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]); JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) { } catch (Throwable $e) {
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
// référence documentée — vaut « inactif » par défaut. Le détail
// technique de l'exception peut donc remonter au navigateur sur une
// installation qui ne définit pas la variable. Le motif est répété
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Erreur lors de l’enregistrement du planning.', 'error' => 'Erreur lors de l’enregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]); ]);
} }
Journal::ecriture(
'PLANNING_ENREGISTRE',
'planning',
$planningId,
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'planning_id' => $planningId, 'planning_id' => $planningId,
@@ -147,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 public function updateAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -183,13 +261,20 @@ final class PlanningController
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
} }
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
// sans lui, l'heure courante serait injectée et la comparaison de
// contrôle ci-dessous laisserait passer des dates approximatives.
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date; $validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
// La saisie est contrainte au quart d'heure : c'est la granularité de
// toute l'application (éditeur, cumuls, PDF).
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
} }
$this->assertDatesWithinContract($entryAgent, [$date]);
try { try {
$result = $this->planningModel->updateEntryFromAgentView( $result = $this->planningModel->updateEntryFromAgentView(
@@ -210,6 +295,14 @@ final class PlanningController
} }
$reopened = count($result['reopened_planning_ids']) > 0; $reopened = count($result['reopened_planning_ids']) > 0;
Journal::ecriture(
'CRENEAU_MODIFIE',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $reopened 'message' => $reopened
@@ -221,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 public function deleteAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -253,6 +356,14 @@ final class PlanningController
$message .= ' Le planning validé concerné a été repassé en brouillon.'; $message .= ' Le planning validé concerné a été repassé en brouillon.';
} }
Journal::ecriture(
'CRENEAU_SUPPRIME',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -263,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 public function moveOrDuplicateEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -289,6 +410,12 @@ final class PlanningController
if (!$validDate || (int) $dateObj->format('N') > 5) { if (!$validDate || (int) $dateObj->format('N') > 5) {
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']); JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
} }
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
try { try {
$result = $this->planningModel->moveOrDuplicateEntry( $result = $this->planningModel->moveOrDuplicateEntry(
(int) $entryId, (int) $entryId,
@@ -316,6 +443,14 @@ final class PlanningController
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.'; $message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
} }
Journal::ecriture(
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
'creneau_horaire',
(int) $entryId,
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'action' => $result['action'], 'action' => $result['action'],
@@ -325,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 public function copyAgentWeek(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -342,9 +489,15 @@ final class PlanningController
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) { if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']); JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
} }
if ($this->agentModel->findActive((int) $agentId) === null) { $agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']); JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
} }
// La copie ne connaît pas d'avance les jours réellement recopiés : on
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
// entièrement hors contrat.
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
try { try {
$result = $this->planningModel->copyAgentWeek( $result = $this->planningModel->copyAgentWeek(
@@ -373,6 +526,25 @@ final class PlanningController
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.'; $message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
} }
Journal::ecriture(
'SEMAINE_COPIEE',
'planning',
null,
sprintf(
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
(int) $result['copied_entries'],
$sourceWeekValue,
$targetWeekValue,
(int) $agentId
),
[
'id_agent' => (int) $agentId,
'semaine_source' => $sourceWeekValue,
'semaine_cible' => $targetWeekValue,
'mode' => $mode,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
@@ -384,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 public function pendingValidation(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -407,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 public function validate(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -430,6 +639,19 @@ final class PlanningController
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.'); $this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
} }
foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'HORS_CONTRAT',
sprintf(
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
$row['id_creneau'],
$row['date_jour']
)
);
}
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) { foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
$this->addControl( $this->addControl(
$controls, $controls,
@@ -504,6 +726,25 @@ final class PlanningController
$this->planningModel->markValidated((int) $planningId); $this->planningModel->markValidated((int) $planningId);
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.'); $this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
// Journalisé APRÈS le passage en base : une validation refusée par les
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
Journal::ecriture(
'PLANNING_VALIDE',
'planning',
(int) $planningId,
sprintf(
'Planning validé pour l’agent #%d, semaine du %s.',
(int) $planning['id_agent'],
(string) $planning['date_debut']
),
[
'id_agent' => (int) $planning['id_agent'],
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
'date_debut' => (string) $planning['date_debut'],
'quota_force' => $forceQuota,
]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'status' => 'validated', 'status' => 'validated',
@@ -513,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 public function reopen(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -537,6 +789,18 @@ final class PlanningController
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']); JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
} }
Journal::ecriture(
'PLANNING_ROUVERT',
'planning',
(int) $planningId,
sprintf(
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
(int) $planning['id_agent'],
(string) $planning['date_debut']
),
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'status' => 'BROUILLON', 'status' => 'BROUILLON',
@@ -545,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 private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
@@ -602,6 +874,22 @@ final class PlanningController
} }
} }
/**
* Valide et met en forme les créneaux reçus du client.
*
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
* motif appartenant à la liste ouverte à CET agent, horaires au quart
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
*
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
*
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -636,6 +924,15 @@ final class PlanningController
return $normalized; return $normalized;
} }
/**
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
*
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void private function assertNoInternalOverlap(array $entries): void
{ {
for ($i = 0, $count = count($entries); $i < $count; $i++) { for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -659,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 private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{ {
foreach ($entries as $entry) { foreach ($entries as $entry) {
@@ -689,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 private function addControl(array &$controls, string $level, string $code, string $message): void
{ {
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\Request; use App\Core\Request;
use App\Models\VacationModel; use App\Models\VacationModel;
@@ -14,6 +15,22 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Périodes de vacances scolaires.
*
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
* journée, qui conditionne à son tour les motifs pertinents et l'application
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
* simple calendrier d'affichage.
*
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
* humain l'ait relue — les libellés et découpages de l'API varient d'une
* académie à l'autre.
*
* Réservé au Service Enfance.
*/
final class VacationController final class VacationController
{ {
private VacationModel $vacationModel; private VacationModel $vacationModel;
@@ -25,6 +42,10 @@ final class VacationController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/vacation_periods.php — périodes enregistrées.
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
*/
public function index(): void public function index(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -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 public function preview(): void
{ {
$this->access->requireService(); $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 public function save(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -129,6 +178,13 @@ final class VacationController
} }
$count = $this->vacationModel->saveMany($periods); $count = $this->vacationModel->saveMany($periods);
Journal::ecriture(
'VACANCES_ENREGISTREES',
'periode_vacance',
null,
sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count),
['nombre' => $count]
);
JsonResponse::send(200, [ JsonResponse::send(200, [
'success' => true, 'success' => true,
'count' => $count, 'count' => $count,
@@ -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 public function delete(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -151,15 +216,27 @@ final class VacationController
JsonResponse::send(404, ['error' => 'Période introuvable.']); JsonResponse::send(404, ['error' => 'Période introuvable.']);
} }
Journal::ecriture(
'VACANCES_SUPPRIMEES',
'periode_vacance',
(int) $id,
sprintf('Période de vacances #%d supprimée.', (int) $id)
);
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']); JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
} }
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
private function isDate(string $value): bool private function isDate(string $value): bool
{ {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value; return $date !== false && $date->format('Y-m-d') === $value;
} }
/**
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
* immédiatement la première : « 2026-2028 » est refusé.
*/
private function isSchoolYear(string $value): bool private function isSchoolYear(string $value): bool
{ {
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,42 @@ use DatePeriod;
use DateTimeImmutable; use DateTimeImmutable;
use RuntimeException; use RuntimeException;
/**
* Construction du calendrier annuel d'un agent, du 1er janvier au 31 décembre.
*
* Produit une structure entièrement prête à l'affichage : les 365 jours sont
* calculés côté serveur, avec leurs libellés, classes CSS et infobulles. Le
* JavaScript ne fait que rendre le tableau — c'est ce qui garantit que le
* calendrier reste identique à l'écran, au PDF et à l'impression.
*
* MODÈLE DES QUATRE COLONNES par jour (constante CELL_KEYS) :
* AP = Animation Périscolaire PP = Préparation Périscolaire
* AE = Animation Extrascolaire PE = Préparation Extrascolaire
*
* Le classement d'un créneau dans l'une des quatre cases dépend de son motif et,
* pour les motifs génériques, du caractère scolaire ou non de la journée
* (voir cellForEntry()).
*/
final class AnnualOverviewModel extends BaseModel final class AnnualOverviewModel extends BaseModel
{ {
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE']; private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
/**
* Calendrier annuel complet et ses totaux.
*
* Les créneaux sont lus EN UNE FOIS pour toute l'année puis indexés par date
* en mémoire : 365 requêtes seraient inenvisageables.
*
* Trois familles de totaux cumulées en parallèle : par case (AP/PP/AE/PE),
* par statut (validé / brouillon) et par motif particulier (congé,
* formation, absence…). Elles répondent à trois questions différentes et ne
* se déduisent pas les unes des autres.
*
* @return array<string, mixed> agent, year, quota_periods, calendar_totals,
* months (12 entrées) et legend.
*
* @throws RuntimeException Si l'agent est introuvable ou inactif.
*/
public function overview(int $agentId, int $year): array public function overview(int $agentId, int $year): array
{ {
$agentModel = new AgentModel($this->pdo); $agentModel = new AgentModel($this->pdo);
@@ -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 private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
{ {
$cells = []; $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 private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
{ {
$quotaModel = new QuotaModel($this->pdo); $quotaModel = new QuotaModel($this->pdo);
@@ -210,6 +275,14 @@ final class AnnualOverviewModel extends BaseModel
return array_values($periods); return array_values($periods);
} }
/**
* Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif.
*
* Un motif non reconnu bascule sur le contexte de la journée : animation
* extrascolaire pendant les vacances, périscolaire sinon. C'est le
* comportement le moins surprenant pour un motif ajouté en base après coup,
* et il évite qu'un temps disparaisse du calendrier.
*/
private function cellForEntry(string $code, bool $isVacation): string private function cellForEntry(string $code, bool $isVacation): string
{ {
return match ($code) { return match ($code) {
@@ -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 private function shortCode(string $code): ?string
{ {
return match ($code) { 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 private function classForCode(string $code, string $cellKey): string
{ {
return match ($code) { 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 private function strongestClass(string $current, string $candidate): string
{ {
$priority = [ $priority = [
@@ -268,6 +362,12 @@ final class AnnualOverviewModel extends BaseModel
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current; return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
} }
/**
* Contenu textuel d'une case : rien si vide, les codes courts s'il y en a,
* la durée compacte sinon.
*
* @param array<int, string> $codes
*/
private function cellDisplay(int $minutes, array $codes): string private function cellDisplay(int $minutes, array $codes): string
{ {
if ($minutes <= 0) { if ($minutes <= 0) {
@@ -279,6 +379,15 @@ final class AnnualOverviewModel extends BaseModel
return $this->formatCompactMinutes($minutes); return $this->formatCompactMinutes($minutes);
} }
/**
* Période de vacances contenant cette date, ou null.
* Copie locale de VacationModel::vacationForDate() : les périodes sont déjà
* chargées en mémoire pour toute l'année, inutile de repasser par le modèle.
*
* @param array<int, array<string, mixed>> $vacations
*
* @return array<string, mixed>|null
*/
private function vacationForDate(string $date, array $vacations): ?array private function vacationForDate(string $date, array $vacations): ?array
{ {
foreach ($vacations as $vacation) { foreach ($vacations as $vacation) {
@@ -289,6 +398,7 @@ final class AnnualOverviewModel extends BaseModel
return null; return null;
} }
/** Durée en minutes entre deux HH:MM, plancher à 0. */
private function minutesBetween(string $start, string $end): int private function minutesBetween(string $start, string $end): int
{ {
[$startHour, $startMinute] = array_map('intval', explode(':', $start)); [$startHour, $startMinute] = array_map('intval', explode(':', $start));
@@ -296,6 +406,14 @@ final class AnnualOverviewModel extends BaseModel
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute)); return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
} }
/**
* Décline chaque total en trois formes : minutes brutes, durée lisible
* (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites.
*
* @param array<string, int> $totals
*
* @return array<string, array{minutes: int, duration: string, compact: string}>
*/
private function formatTotals(array $totals): array private function formatTotals(array $totals): array
{ {
$formatted = []; $formatted = [];
@@ -309,6 +427,7 @@ final class AnnualOverviewModel extends BaseModel
return $formatted; return $formatted;
} }
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
private function formatMinutes(int $minutes): string private function formatMinutes(int $minutes): string
{ {
$sign = $minutes < 0 ? '-' : ''; $sign = $minutes < 0 ? '-' : '';
@@ -316,6 +435,7 @@ final class AnnualOverviewModel extends BaseModel
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
} }
/** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */
private function formatCompactMinutes(int $minutes): string private function formatCompactMinutes(int $minutes): string
{ {
if ($minutes <= 0) { if ($minutes <= 0) {
@@ -326,6 +446,7 @@ final class AnnualOverviewModel extends BaseModel
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins); return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
} }
/** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */
private function monthName(int $month): string private function monthName(int $month): string
{ {
return [ return [
@@ -335,6 +456,7 @@ final class AnnualOverviewModel extends BaseModel
][$month]; ][$month];
} }
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
private function weekdayShort(int $weekday): string private function weekdayShort(int $weekday): string
{ {
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday]; return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];

View File

@@ -6,6 +6,13 @@ namespace App\Models;
use PDO; 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 abstract class BaseModel
{ {
public function __construct(protected PDO $pdo) 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 PDO;
use Throwable; 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 final class CoverageModel extends BaseModel
{ {
private const DAY_LABELS = [ private const DAY_LABELS = [
@@ -18,6 +35,11 @@ final class CoverageModel extends BaseModel
5 => 'Vendredi', 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 public function rulesForStructure(int $structureId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -33,6 +55,16 @@ final class CoverageModel extends BaseModel
return $stmt->fetchAll(); 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 public function replaceRules(int $structureId, array $rules): void
{ {
try { 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 public function reportForStructure(int $structureId, int $year, int $weekNumber): array
{ {
$rules = $this->rulesForStructure($structureId); $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 private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -195,6 +254,21 @@ final class CoverageModel extends BaseModel
return $stmt->fetchAll(); 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 private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
{ {
$lastIndex = count($target) - 1; $lastIndex = count($target) - 1;
@@ -212,12 +286,14 @@ final class CoverageModel extends BaseModel
$target[] = $slot; $target[] = $slot;
} }
/** « 09:30 » → 570. Tronque à HH:MM, les horaires arrivant parfois en HH:MM:SS. */
private function timeToMinutes(string $time): int private function timeToMinutes(string $time): int
{ {
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5))); [$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
return ($hours * 60) + $minutes; return ($hours * 60) + $minutes;
} }
/** 570 → « 09:30 ». Opération inverse de timeToMinutes(). */
private function minutesToTime(int $minutes): string private function minutesToTime(int $minutes): string
{ {
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60); return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);

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

@@ -6,8 +6,27 @@ namespace App\Models;
use App\Core\MotifCategories; 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 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 public function allActive(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -18,6 +37,11 @@ final class MotifModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Identifiants des motifs actifs.
*
* @return int[]
*/
public function activeIds(): array public function activeIds(): array
{ {
return array_map( return array_map(
@@ -29,6 +53,14 @@ final class MotifModel extends BaseModel
/** /**
* Motifs réellement affectables à un agent : « Formation » disparaît de la * 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. * 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 public function allActiveForAgent(array $agent): array
{ {

View File

@@ -7,8 +7,40 @@ namespace App\Models;
use DateTimeImmutable; use DateTimeImmutable;
use Throwable; use Throwable;
/**
* Accès aux plannings hebdomadaires et à leurs créneaux.
*
* Modèle de données : `planning` (agent × semaine × lieu) porte des
* `creneau_horaire` typés par un `motif_planning`. La table `semaine` est un
* référentiel créé À LA DEMANDE (voir ensureWeek()) : une semaine n'existe en
* base qu'à partir du moment où quelque chose y est planifié.
*
* Trois conventions valent pour tout ce fichier :
*
* 1. TRANSACTIONS — toute méthode qui écrit plusieurs lignes ouvre une
* transaction et la déroule intégralement en cas d'échec. Le `catch` réémet
* l'exception : c'est au contrôleur de la traduire en réponse HTTP.
*
* 2. VERROUS — les lectures qui précèdent une écriture portent `FOR UPDATE`.
* Deux utilisateurs peuvent travailler sur le même agent en même temps ;
* sans verrou, un contrôle d'antériorité serait fait sur un état déjà périmé.
*
* 3. RÉOUVERTURE AUTOMATIQUE — modifier le contenu d'un planning VALIDÉ le
* repasse en BROUILLON, et les identifiants concernés remontent dans
* `reopened_planning_ids`. Une validation ne doit jamais survivre à la
* modification de ce qu'elle attestait.
*
* Test de chevauchement, identique partout : `debut1 < fin2 && fin1 > debut2`.
* Deux créneaux jointifs (10:00-12:00 et 12:00-14:00) ne se chevauchent pas.
*/
final class PlanningModel extends BaseModel final class PlanningModel extends BaseModel
{ {
/**
* Retrouve le planning d'un triplet (lieu, agent, semaine) — la clé
* fonctionnelle d'un planning.
*
* @return array<string, mixed>|null null si rien n'a encore été saisi.
*/
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -30,6 +62,15 @@ final class PlanningModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Créneaux d'un planning, motif joint.
*
* Les heures sortent en HH:MM (TIME_FORMAT) et non en HH:MM:SS : c'est le
* format attendu par les champs de saisie du navigateur. Les écritures, à
* l'inverse, passent en HH:MM:SS.
*
* @return array<int, array<string, mixed>>
*/
public function entries(int $planningId): array public function entries(int $planningId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -47,6 +88,15 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Créneaux de l'agent sur les AUTRES lieux, la même semaine.
*
* Affichés en lecture seule dans l'éditeur : un agent pouvant être planifié
* sur plusieurs lieux, c'est le seul moyen de voir un conflit avant de
* l'enregistrer.
*
* @return array<int, array<string, mixed>>
*/
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -78,6 +128,31 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Enregistre le brouillon d'une semaine : REMPLACEMENT INTÉGRAL.
*
* Les créneaux existants du planning sont supprimés puis réinsérés. C'est
* plus simple et plus sûr qu'une réconciliation ligne à ligne, au prix de la
* perte des identifiants de créneaux — aucune donnée n'y étant rattachée,
* c'est sans conséquence.
*
* Un planning VALIDÉ est refusé : il faut le rouvrir explicitement d'abord.
* C'est la différence avec les modifications unitaires, qui rouvrent d'elles-
* mêmes ; ici l'utilisateur écrase toute une semaine, le geste doit être
* conscient.
*
* La semaine est créée si besoin par un INSERT … ON DUPLICATE KEY UPDATE
* avec LAST_INSERT_ID(id_semaine) : cette astuce MariaDB fait remonter
* l'identifiant EXISTANT via lastInsertId() plutôt qu'un zéro. Le repli par
* SELECT couvre les configurations où l'astuce ne joue pas.
*
* @param array<int, array{date: string, motif_id: int, start: string, end: string}> $entries
* Créneaux déjà normalisés par PlanningController::normalizeEntries().
*
* @return int Identifiant du planning créé ou mis à jour.
*
* @throws \DomainException Si le planning est déjà validé.
*/
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -168,6 +243,22 @@ final class PlanningModel extends BaseModel
} }
} }
/**
* Tous les brouillons NON VIDES, toutes structures confondues.
*
* Le `HAVING COUNT(...) > 0` écarte les plannings sans créneau : ils
* existent (un planning vidé n'est jamais supprimé) mais n'ont rien à
* valider, et pollueraient la file de travail.
*
* Deux totaux distincts : `total_minutes` (tout le temps saisi) et
* `minutes_decomptees` (seulement ce qui pèse sur le quota).
* `date_fin_ouvrable` est le vendredi, la semaine s'arrêtant au jour ouvré.
*
* Tri par semaine croissante : les premiers éléments sont les plus anciens,
* donc les plus urgents — DashboardController s'appuie sur cet ordre.
*
* @return array<int, array<string, mixed>>
*/
public function pendingValidation(): array public function pendingValidation(): array
{ {
$stmt = $this->pdo->query( $stmt = $this->pdo->query(
@@ -211,6 +302,12 @@ final class PlanningModel extends BaseModel
/** /**
* Plannings en brouillon de l'agent qui contiennent au moins un créneau * Plannings en brouillon de l'agent qui contiennent au moins un créneau
* dans l'année civile demandée. * dans l'année civile demandée.
*
* Le filtrage se fait sur la DATE DES CRÉNEAUX (sous-requête EXISTS) et non
* sur l'année de la semaine : une semaine à cheval sur deux années civiles
* doit être reprise par la validation annuelle des deux années.
*
* @return array<int, array<string, mixed>>
*/ */
public function pendingValidationForAgentYear(int $agentId, int $year): array public function pendingValidationForAgentYear(int $agentId, int $year): array
{ {
@@ -254,6 +351,13 @@ final class PlanningModel extends BaseModel
/** /**
* Chevauchements avec n'importe quel autre planning de l'agent, * Chevauchements avec n'importe quel autre planning de l'agent,
* qu'il soit déjà validé ou encore en brouillon. * qu'il soit déjà validé ou encore en brouillon.
*
* Variante LARGE, utilisée par la validation ANNUELLE : tous les brouillons
* de l'agent y sont validés d'un bloc, deux brouillons qui se recouvrent
* doivent donc être signalés. La validation unitaire utilise au contraire
* validatedExternalOverlaps(), plus permissive.
*
* @return array<int, array<string, mixed>> Paires de créneaux en conflit.
*/ */
public function externalOverlaps(int $planningId): array public function externalOverlaps(int $planningId): array
{ {
@@ -277,6 +381,11 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Planning et bornes de sa semaine, par identifiant.
*
* @return array<string, mixed>|null
*/
public function findDetailed(int $planningId): ?array public function findDetailed(int $planningId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -291,6 +400,7 @@ final class PlanningModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/** Nombre de créneaux ; zéro déclenche le contrôle bloquant AUCUN_CRENEAU. */
public function countEntries(int $planningId): int public function countEntries(int $planningId): int
{ {
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'); $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
@@ -298,6 +408,15 @@ final class PlanningModel extends BaseModel
return (int) $stmt->fetchColumn(); return (int) $stmt->fetchColumn();
} }
/**
* Créneaux sortis des bornes de leur semaine.
*
* Ne devrait jamais rien renvoyer : la saisie contrôle déjà l'appartenance.
* Ce filet rattrape les données produites par un import, une reprise ou une
* version antérieure du code.
*
* @return array<int, array{id_creneau: int, date_jour: string}>
*/
public function entriesOutsideWeek(int $planningId, string $start, string $end): array public function entriesOutsideWeek(int $planningId, string $start, string $end): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -310,6 +429,42 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Chevauchements entre créneaux d'un MÊME planning.
*
* `c2.id_creneau > c1.id_creneau` évite de sortir chaque paire deux fois et
* empêche un créneau de se comparer à lui-même.
*
* @return array<int, array{c1: int, c2: int, date_jour: string}>
*/
/**
* Créneaux du planning tombant hors des dates de contrat de leur agent.
*
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
* agent jointe : c'est elle qui fait foi au moment du contrôle.
*
* @return array<int, array{id_creneau: int, date_jour: string}>
*/
public function entriesOutsideContract(int $planningId): array
{
$stmt = $this->pdo->prepare(
'SELECT c.id_creneau, c.date_jour
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
JOIN agent a ON a.id_agent = p.id_agent
WHERE c.id_planning = :id_planning
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
ORDER BY c.date_jour'
);
$stmt->execute(['id_planning' => $planningId]);
return $stmt->fetchAll();
}
public function internalOverlaps(int $planningId): array public function internalOverlaps(int $planningId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -327,6 +482,16 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Chevauchements avec les seuls plannings DÉJÀ VALIDÉS d'un autre lieu.
*
* Variante RESTREINTE, utilisée par la validation unitaire : seul un
* planning validé est opposable. Opposer les brouillons rendrait toute
* validation impossible dès que deux brouillons concurrents existent, sans
* ordre de résolution évident.
*
* @return array<int, array{c1: int, c2: int, date_jour: string, structure_nom: string}>
*/
public function validatedExternalOverlaps(int $planningId): array public function validatedExternalOverlaps(int $planningId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -349,6 +514,11 @@ final class PlanningModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Créneau enrichi de son planning et de sa semaine.
*
* @return array<string, mixed>|null
*/
public function findEntryDetailed(int $entryId): ?array public function findEntryDetailed(int $entryId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -371,6 +541,25 @@ final class PlanningModel extends BaseModel
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine. * Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
* Le planning cible est créé si nécessaire et tout planning validé modifié * Le planning cible est créé si nécessaire et tout planning validé modifié
* repasse automatiquement en brouillon. * repasse automatiquement en brouillon.
*
* C'est la méthode la plus délicate du modèle : la nouvelle date peut
* relever d'une autre SEMAINE et le nouveau lieu d'un autre PLANNING. Le
* créneau est alors RATTACHÉ à un autre planning (mise à jour de
* `id_planning`) plutôt que supprimé puis recréé, ce qui préserve son
* identifiant.
*
* Jusqu'à DEUX plannings peuvent être rouverts : celui d'origine et celui de
* destination.
*
* La recherche de conflit couvre TOUS les lieux de l'agent, pas seulement le
* lieu visé : c'est l'agent qui ne peut pas être à deux endroits à la fois.
*
* @param string $start Heure de début en HH:MM:SS.
* @param string $end Heure de fin en HH:MM:SS.
*
* @return array{entry_id: int, agent_id: int, year: int, week: int, source_planning_id: int, target_planning_id: int, reopened_planning_ids: int[]}
*
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
*/ */
public function updateEntryFromAgentView( public function updateEntryFromAgentView(
int $entryId, int $entryId,
@@ -501,6 +690,18 @@ final class PlanningModel extends BaseModel
* Si le planning était validé, il repasse en brouillon afin de signaler * Si le planning était validé, il repasse en brouillon afin de signaler
* que son contenu a été modifié. Le planning est conservé même s'il ne * que son contenu a été modifié. Le planning est conservé même s'il ne
* contient plus aucun créneau. * contient plus aucun créneau.
*
* Le contrôle `rowCount() !== 1` après le DELETE détecte la suppression
* concurrente : le créneau était encore là au SELECT … FOR UPDATE mais plus
* au DELETE. On préfère annuler et demander un rechargement plutôt que de
* confirmer une suppression qui n'a rien fait.
*
* `remaining_entries` permet à l'interface d'annoncer un planning devenu
* vide, cas nominal et non erreur.
*
* @return array{entry_id: int, planning_id: int, agent_id: int, year: int, week: int, date: string, reopened: bool, remaining_entries: int}
*
* @throws \DomainException Créneau introuvable ou déjà supprimé.
*/ */
public function deleteEntryFromAgentView(int $entryId): array public function deleteEntryFromAgentView(int $entryId): array
{ {
@@ -573,6 +774,23 @@ final class PlanningModel extends BaseModel
* Déplace ou duplique un créneau vers n'importe quel jour ouvré. * Déplace ou duplique un créneau vers n'importe quel jour ouvré.
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un * Le lieu, le motif et les horaires sont conservés, y compris lors d'un
* changement de semaine. * changement de semaine.
*
* Sert le glisser-déposer de l'éditeur. Trois particularités :
*
* - Déposer sur le jour d'origine en mode déplacement est un NON-ÉVÉNEMENT :
* on rend « action: noop » sans rien écrire, plutôt qu'une erreur pour un
* geste de souris sans conséquence.
* - En DUPLICATION, le créneau source est inclus dans la recherche de
* conflit ($excludeEntryId = 0) : la copie entrerait en collision avec lui
* si elle atterrissait au même endroit. En DÉPLACEMENT il en est exclu,
* puisqu'il libère sa place.
* - Une duplication ne rouvre PAS le planning source : celui-ci n'est pas
* modifié, seule la destination l'est.
*
* @return array{entry_id: int, created_entry_id: int|null, agent_id: int, year: int, week: int, planning_id: int, target_planning_id: int, reopened_planning_ids: int[], action: string}
* `action` vaut « move », « duplicate » ou « noop ».
*
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
*/ */
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
{ {
@@ -720,6 +938,31 @@ final class PlanningModel extends BaseModel
} }
} }
/**
* Recopie tous les créneaux d'un agent d'une semaine vers une autre.
*
* Le report se fait par JOUR DE LA SEMAINE, pas par date : un créneau du
* mardi source atterrit sur le mardi cible. `WEEKDAY() + 1` donne 1 (lundi)
* à 5 (vendredi) ; le filtre `BETWEEN 0 AND 4` écarte le week-end, qui ne
* devrait pas exister mais peut subsister d'une reprise de données.
*
* La copie couvre TOUS les lieux de la semaine source, d'où la réservation
* de l'opération au Service Enfance.
*
* Deux modes :
* - `replace` : les créneaux existants de la semaine cible sont supprimés,
* tous lieux confondus ;
* - `merge` : ils sont conservés, et le moindre chevauchement fait
* ÉCHOUER l'opération entière. Une copie partielle laisserait un état
* difficile à interpréter — mieux vaut ne rien faire et le dire.
*
* @param string $mode « merge » ou « replace ».
*
* @return array{agent_id: int, year: int, week: int, copied_entries: int, reopened_planning_ids: int[], mode: string}
*
* @throws \DomainException Mode invalide, semaines identiques, source vide,
* ou conflit en mode merge.
*/
public function copyAgentWeek( public function copyAgentWeek(
int $agentId, int $agentId,
int $sourceYear, int $sourceYear,
@@ -874,6 +1117,17 @@ final class PlanningModel extends BaseModel
} }
} }
/**
* Retourne l'identifiant de la semaine, en la créant si elle n'existe pas.
*
* La table `semaine` est peuplée à la demande : aucune pré-génération de
* calendrier. LAST_INSERT_ID(id_semaine) dans le ON DUPLICATE KEY UPDATE
* fait remonter l'identifiant EXISTANT par lastInsertId(), ce qui évite un
* SELECT supplémentaire dans le cas courant ; le repli couvre les
* configurations où l'astuce ne joue pas.
*
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
*/
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -901,7 +1155,17 @@ final class PlanningModel extends BaseModel
return $weekId; return $weekId;
} }
/** @return array{id_planning:int,statut:string} */ /**
* Retourne le planning du triplet (agent, semaine, lieu), en le créant au
* besoin en BROUILLON.
*
* Le statut est renvoyé avec l'identifiant, car l'appelant en a besoin pour
* décider s'il doit rouvrir le planning après modification.
*
* À N'APPELER QUE DANS UNE TRANSACTION : le SELECT porte un FOR UPDATE.
*
* @return array{id_planning:int,statut:string}
*/
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -932,7 +1196,17 @@ final class PlanningModel extends BaseModel
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON']; return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
} }
/** @return int[] */ /**
* Repasse en BROUILLON les plannings listés et efface leur date de validation.
*
* Les identifiants sont dédoublonnés : un même planning peut être signalé
* deux fois (source et destination confondues). La liste est renvoyée telle
* qu'appliquée, pour remonter au client dans `reopened_planning_ids`.
*
* @param array<int, int|string> $planningIds
*
* @return int[]
*/
private function reopenPlanningIds(array $planningIds): array private function reopenPlanningIds(array $planningIds): array
{ {
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds)))); $planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
@@ -948,7 +1222,17 @@ final class PlanningModel extends BaseModel
} }
/** @param int[] $planningIds */ /**
* Valide un lot de plannings.
*
* La clause `statut = 'BROUILLON'` rend l'opération sûre en concurrence :
* un planning validé entre-temps n'est pas revalidé, et rowCount() renvoie
* le nombre RÉELLEMENT traité — c'est lui qui est annoncé à l'utilisateur.
*
* @param int[] $planningIds
*
* @return int Nombre de plannings effectivement validés.
*/
public function markValidatedMany(array $planningIds): int public function markValidatedMany(array $planningIds): int
{ {
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0))); $planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
@@ -965,6 +1249,13 @@ final class PlanningModel extends BaseModel
return $stmt->rowCount(); return $stmt->rowCount();
} }
/**
* Valide un planning unitaire.
*
* Sans condition de statut, contrairement à markValidatedMany() : l'appelant
* (PlanningController::validate()) a déjà lu le planning et joué tous ses
* contrôles.
*/
public function markValidated(int $planningId): void public function markValidated(int $planningId): void
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -973,6 +1264,13 @@ final class PlanningModel extends BaseModel
$stmt->execute(['planning_id' => $planningId]); $stmt->execute(['planning_id' => $planningId]);
} }
/**
* Repasse un planning VALIDÉ en BROUILLON, sur demande explicite.
*
* @return bool false si le planning n'était plus validé — un autre
* utilisateur est passé entre-temps, et l'appelant doit
* répondre 409 plutôt que d'annoncer un succès.
*/
public function reopen(int $planningId): bool public function reopen(int $planningId): bool
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(

View File

@@ -11,15 +11,56 @@ use DateTimeImmutable;
use PDO; use PDO;
use RuntimeException; use RuntimeException;
/**
* PTA annuel d'un agent : répartition des temps et contrôles réglementaires.
*
* C'est le modèle qui porte le plus de RÈGLES DE DROIT DU TRAVAIL de
* l'application. Les seuils sont exprimés en minutes et écrits en dur ; ils sont
* rappelés ci-dessous pour qu'une évolution réglementaire soit repérable d'un
* coup d'œil :
*
* 600 min = 10 h — durée quotidienne maximale de travail effectif ;
* 720 min = 12 h — amplitude quotidienne maximale (du premier début à la
* dernière fin, pauses comprises) ;
* 360 min = 6 h — travail continu maximal sans pause d'au moins 20 minutes ;
* 660 min = 11 h — repos minimal entre deux journées ;
* 2880 min = 48 h — durée hebdomadaire maximale ;
* 2640 min = 44 h — moyenne hebdomadaire maximale sur 12 semaines glissantes ;
* 420 min = 7 h — durée attendue d'un ménage de fond ;
* 25 jours de congés annuels et 2 journées de fractionnement attendus.
*
* Les contrôles suivent la forme commune {niveau, code, message}, ERREUR pour
* un dépassement réglementaire, ALERTE pour un écart aux règles métier locales,
* INFO pour un simple constat. À la différence de PlanningController::validate(),
* RIEN N'EST BLOQUÉ ici : le PTA est un écran d'analyse, l'arbitrage revient à
* l'agent de planification.
*/
final class PtaModel extends BaseModel final class PtaModel extends BaseModel
{ {
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */ /** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
private const WORK_CODES = MotifCategories::WORK_CODES; private const WORK_CODES = MotifCategories::WORK_CODES;
/** Motifs de préparation, interdits aux agents de restauration/entretien. */
private const PREPARATION_CODES = [ private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI', 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
]; ];
/**
* Synthèse complète du PTA d'un agent à une date d'observation.
*
* La période analysée n'est PAS choisie par l'appelant : c'est celle du
* quota (année civile), déduite de $contextDate.
*
* Les contrôles proviennent de quatre sources indépendantes — éligibilité,
* analyse des créneaux, contraintes individuelles et ménage de fond — puis
* sont triés ERREUR, ALERTE, INFO : l'utilisateur doit voir les blocages en
* premier.
*
* @return array<string, mixed> agent, pta, quota, categories, counters,
* controls, constraints, wishes, vacations.
*
* @throws RuntimeException Si l'agent est introuvable ou inactif.
*/
public function summary(int $agentId, string $contextDate): array public function summary(int $agentId, string $contextDate): array
{ {
$agentModel = new AgentModel($this->pdo); $agentModel = new AgentModel($this->pdo);
@@ -72,6 +113,17 @@ final class PtaModel extends BaseModel
]; ];
} }
/**
* Insère une contrainte individuelle.
*
* $data est passé TEL QUEL à execute() : ses clés doivent correspondre
* exactement aux paramètres nommés de la requête. La validation métier est
* entièrement faite par PtaController::constraint().
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé.
*/
public function saveConstraint(array $data): int public function saveConstraint(array $data): int
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -89,6 +141,11 @@ final class PtaModel extends BaseModel
return (int) $this->pdo->lastInsertId(); return (int) $this->pdo->lastInsertId();
} }
/**
* Supprime une contrainte, à condition qu'elle appartienne bien à l'agent.
*
* @return bool false si elle n'existe pas ou relève d'un autre agent.
*/
public function deleteConstraint(int $constraintId, int $agentId): bool public function deleteConstraint(int $constraintId, int $agentId): bool
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -98,6 +155,18 @@ final class PtaModel extends BaseModel
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Enregistre un souhait de lieu, en création ou en mise à jour.
*
* Le ON DUPLICATE KEY UPDATE s'appuie sur l'unicité (agent, lieu) : un agent
* n'a qu'un seul avis par lieu, réémettre un souhait le remplace au lieu de
* l'empiler. Il RÉACTIVE aussi un souhait précédemment retiré.
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé, ou 0 en cas de mise à jour (lastInsertId()
* ne renvoie rien d'exploitable dans ce cas).
*/
public function saveWish(array $data): int public function saveWish(array $data): int
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -117,6 +186,11 @@ final class PtaModel extends BaseModel
return (int) ($this->pdo->lastInsertId() ?: 0); return (int) ($this->pdo->lastInsertId() ?: 0);
} }
/**
* Supprime un souhait, à condition qu'il appartienne bien à l'agent.
*
* @return bool false si absent ou relevant d'un autre agent.
*/
public function deleteWish(int $wishId, int $agentId): bool public function deleteWish(int $wishId, int $agentId): bool
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -126,6 +200,20 @@ final class PtaModel extends BaseModel
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Retourne le PTA de la période, en le CRÉANT s'il n'existe pas.
*
* Création paresseuse volontaire : un agent créé avant la mise en place du
* PTA, ou dont on consulte une année non encore ouverte, obtient son
* enregistrement à la première consultation plutôt qu'une page en erreur.
*
* Le PTA est identifié par (agent, date_debut, date_fin) et non par la seule
* année : c'est la période de quota qui fait foi.
*
* Effet de bord assumé : une simple lecture peut écrire en base.
*
* @return array<string, mixed>
*/
private function ensurePta(array $agent, array $quota): array private function ensurePta(array $agent, array $quota): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -174,6 +262,40 @@ final class PtaModel extends BaseModel
return $stmt->fetch() ?: []; return $stmt->fetch() ?: [];
} }
/**
* Analyse tous les créneaux de la période — méthode centrale du modèle.
*
* Déroulé :
* 1. VENTILATION de chaque créneau dans une catégorie métier
* (categoryForEntry()), avec cumul des minutes et des jours distincts ;
* 2. AGRÉGATS par jour (durée, amplitude, créneaux triés) et par semaine ;
* 3. CONTRÔLES RÉGLEMENTAIRES, dans cet ordre :
* - durée quotidienne (> 10 h) et amplitude (> 12 h) ;
* - travail continu > 6 h sans coupure d'au moins 20 minutes. Deux
* créneaux séparés de moins de 20 minutes sont considérés comme
* continus, la pause étant trop courte pour compter ;
* - repos entre deux journées CONSÉCUTIVEMENT PLANIFIÉES : les dates
* sans créneau sont ignorées, un week-end n'a pas à être contrôlé ;
* - durée hebdomadaire > 48 h ;
* - moyenne > 44 h sur 12 semaines glissantes. Le contrôle s'arrête à la
* PREMIÈRE fenêtre fautive : les suivantes se recouvrent et
* produiraient des dizaines de messages redondants ;
* 4. CONTRÔLES MÉTIER LOCAUX : durée attendue des mercredis et de
* l'extrascolaire selon le poste, semaines de vacances incomplètes pour
* la famille ANIMATION, interdiction de préparation pour la famille
* RESTAURATION_ENTRETIEN, enveloppe de formation, et objectifs de
* 25 jours de congés / 2 journées de fractionnement.
*
* Un contrôle INFO est ajouté si rien n'a été trouvé, pour que l'interface
* affiche « aucune anomalie » plutôt qu'une liste vide ambiguë.
*
* @param array<string, mixed> $agent
* @param array<int, array<string, mixed>> $entries
* @param array<int, array<string, mixed>> $vacations
* @param array<string, mixed> $quota
*
* @return array{categories: array<int, array<string, mixed>>, counters: array<string, mixed>, controls: array<int, array<string, string>>, daily: array<string, mixed>, weekly: array<string, int>}
*/
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
{ {
$categories = [ $categories = [
@@ -449,6 +571,15 @@ final class PtaModel extends BaseModel
]; ];
} }
/**
* Vérifie que l'agent relève bien du dispositif PTA : contrat permanent et
* poste renseigné. Sans poste, aucune des règles de durée attendue ne peut
* s'appliquer.
*
* @param array<string, mixed> $agent
*
* @return array<int, array<string, string>>
*/
private function eligibilityControls(array $agent): array private function eligibilityControls(array $agent): array
{ {
$controls = []; $controls = [];
@@ -463,6 +594,24 @@ final class PtaModel extends BaseModel
return $controls; return $controls;
} }
/**
* Confronte les journées planifiées aux contraintes individuelles.
*
* Chaque contrainte produit d'abord une ALERTE de rappel, visible même sans
* dépassement : l'agent de planification doit savoir qu'elle existe. Un
* dépassement du maximum quotidien produit ensuite une ERREUR.
*
* Une contrainte sans date de fin est réputée toujours en vigueur.
*
* LIMITE CONNUE : `maximum_minutes_semaine`, `quotite_temporaire` et les
* interdictions matin/midi/soir sont enregistrés et affichés, mais AUCUN
* contrôle automatique ne les exploite encore.
*
* @param array<string, mixed> $analysis Sortie d'analyseEntries().
* @param array<int, array<string, mixed>> $constraints
*
* @return array<int, array<string, string>>
*/
private function constraintControls(array $analysis, array $constraints): array private function constraintControls(array $analysis, array $constraints): array
{ {
$controls = []; $controls = [];
@@ -495,6 +644,18 @@ final class PtaModel extends BaseModel
return $controls; return $controls;
} }
/**
* Contrôle des ménages de fond : au moins DEUX agents ensemble, et 7 h par
* agent.
*
* La sous-requête EXISTS restreint l'analyse aux (date, lieu) où CET agent
* est lui-même sur un ménage de fond ; l'agrégat compte alors tous les
* agents présents sur ce même ménage, y compris ceux qui ne sont pas
* l'agent consulté. Sans elle, la requête balaierait tous les ménages de
* fond de la collectivité.
*
* @return array<int, array<string, string>>
*/
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -543,6 +704,15 @@ final class PtaModel extends BaseModel
return $controls; return $controls;
} }
/**
* Contraintes actives qui INTERSECTENT la période.
*
* Le COALESCE(date_fin, '9999-12-31') traite une contrainte sans terme comme
* courant indéfiniment ; un simple `date_fin >= :date_debut` l'écarterait,
* NULL n'étant comparable à rien.
*
* @return array<int, array<string, mixed>>
*/
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -560,6 +730,14 @@ final class PtaModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Souhaits de lieux de l'agent.
*
* Le FIELD() force les INTERDICTIONS en tête, avant les préférences : une
* interdiction est une contrainte, une préférence un simple souhait.
*
* @return array<int, array<string, mixed>>
*/
private function wishesForAgent(int $agentId): array private function wishesForAgent(int $agentId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -574,6 +752,21 @@ final class PtaModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Classe un créneau dans une catégorie métier du PTA.
*
* Le motif générique `TRAVAIL` est ventilé selon le CONTEXTE : extrascolaire
* pendant les vacances, mercredi périscolaire le mercredi (weekday 3),
* périscolaire sinon. C'est l'héritage des saisies antérieures aux motifs
* détaillés, qui doivent rester correctement réparties.
*
* Un motif inconnu suit son drapeau `compte_dans_quota` : périscolaire s'il
* compte, non décompté sinon — le comportement le moins surprenant pour un
* motif ajouté en base après coup.
*
* @param int $weekday 1 (lundi) à 7 (dimanche).
* @param bool $counts Valeur de compte_dans_quota du motif.
*/
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
{ {
return match ($code) { return match ($code) {
@@ -592,6 +785,7 @@ final class PtaModel extends BaseModel
}; };
} }
/** Libellé affichable d'une catégorie ; le code brut sert de repli. */
private function categoryLabel(string $code): string private function categoryLabel(string $code): string
{ {
return match ($code) { return match ($code) {
@@ -609,6 +803,15 @@ final class PtaModel extends BaseModel
}; };
} }
/**
* Période de vacances contenant cette date, ou null.
* Copie locale : les périodes sont déjà chargées en mémoire pour toute
* l'année analysée.
*
* @param array<int, array<string, mixed>> $vacations
*
* @return array<string, mixed>|null
*/
private function vacationForDate(string $date, array $vacations): ?array private function vacationForDate(string $date, array $vacations): ?array
{ {
foreach ($vacations as $vacation) { foreach ($vacations as $vacation) {
@@ -619,6 +822,13 @@ final class PtaModel extends BaseModel
return null; return null;
} }
/**
* Durée en minutes entre deux horaires.
*
* SANS plancher à zéro, contrairement à ses homonymes des autres modèles :
* le calcul de la pause continue a besoin des valeurs négatives pour
* détecter deux créneaux qui se recouvrent.
*/
private function minutesBetween(string $start, string $end): int private function minutesBetween(string $start, string $end): int
{ {
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5))); [$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
@@ -626,6 +836,7 @@ final class PtaModel extends BaseModel
return ($eh * 60 + $em) - ($sh * 60 + $sm); return ($eh * 60 + $em) - ($sh * 60 + $sm);
} }
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
private function formatMinutes(int $minutes): string private function formatMinutes(int $minutes): string
{ {
$sign = $minutes < 0 ? '-' : ''; $sign = $minutes < 0 ? '-' : '';
@@ -633,11 +844,19 @@ final class PtaModel extends BaseModel
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
} }
/** « 2026-02-17 » → « 17/02/2026 », format des messages utilisateur. */
private function formatDate(string $date): string private function formatDate(string $date): string
{ {
return (new DateTimeImmutable($date))->format('d/m/Y'); return (new DateTimeImmutable($date))->format('d/m/Y');
} }
/**
* Fabrique un contrôle au format commun {niveau, code, message}.
*
* @param string $level ERREUR, ALERTE ou INFO.
*
* @return array{niveau: string, code: string, message: string}
*/
private function control(string $level, string $code, string $message): array private function control(string $level, string $code, string $message): array
{ {
return ['niveau' => $level, 'code' => $code, 'message' => $message]; return ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

@@ -16,8 +16,28 @@ use InvalidArgumentException;
*/ */
final class QuotaModel extends BaseModel final class QuotaModel extends BaseModel
{ {
/**
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
* les 7 h de la journée de solidarité).
*
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
* travail effectif alimente le compteur, via `compte_dans_quota`.
*/
public const REFERENCE_MINUTES = 1607 * 60; public const REFERENCE_MINUTES = 1607 * 60;
/**
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
*
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
* fausserait un suivi déjà entamé.
*
* @param float $rate Quotité en pourcentage (100 = temps plein).
*/
public function createForAgent(int $agentId, int $year, float $rate): void public function createForAgent(int $agentId, int $year, float $rate): void
{ {
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100)); $target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
@@ -43,6 +63,19 @@ final class QuotaModel extends BaseModel
* *
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période * Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat. * 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
*
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
* un brouillon consomme déjà le quota du point de vue de la planification,
* la distinction ne sert qu'à indiquer ce qui est acquis.
*
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
*
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
* ALERTE à la validation, jamais bloqué.
*
* @return array<string, mixed> Période de référence enrichie des compteurs.
*/ */
public function forDate(int $agentId, string $contextDate): array public function forDate(int $agentId, string $contextDate): array
{ {
@@ -90,16 +123,33 @@ final class QuotaModel extends BaseModel
]); ]);
} }
/**
* Idem forDate(), avec les libellés lisibles ajoutés par enrich().
* C'est cette variante que consomment tous les endpoints JSON.
*
* @return array<string, mixed>
*/
public function forDateForApi(int $agentId, string $contextDate): array public function forDateForApi(int $agentId, string $contextDate): array
{ {
return $this->enrich($this->forDate($agentId, $contextDate)); return $this->enrich($this->forDate($agentId, $contextDate));
} }
/**
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
* la période étant de toute façon l'année civile complète.
*
* @return array<string, mixed>
*/
public function annual(int $agentId, int $year): array public function annual(int $agentId, int $year): array
{ {
return $this->forDate($agentId, sprintf('%04d-01-01', $year)); return $this->forDate($agentId, sprintf('%04d-01-01', $year));
} }
/**
* Idem annual(), enrichi des libellés.
*
* @return array<string, mixed>
*/
public function annualForApi(int $agentId, int $year): array public function annualForApi(int $agentId, int $year): array
{ {
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year)); return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
@@ -108,6 +158,16 @@ final class QuotaModel extends BaseModel
/** /**
* Période de référence PTA : toujours l'année civile. * Période de référence PTA : toujours l'année civile.
* La date de contrat est retournée uniquement à titre informatif. * La date de contrat est retournée uniquement à titre informatif.
*
* Les clés `periode_contractuelle`, `periode_civile` et
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
* pouvait être ancrée sur la date de contrat. Elles sont figées
* (false / true / false) et conservées pour ne pas casser les écrans qui les
* lisent encore. Ne pas construire de nouvelle logique dessus.
*
* @return array<string, mixed>
*
* @throws InvalidArgumentException Si la date de contexte est malformée.
*/ */
public function referencePeriod(int $agentId, string $contextDate): array public function referencePeriod(int $agentId, string $contextDate): array
{ {
@@ -141,6 +201,16 @@ final class QuotaModel extends BaseModel
]; ];
} }
/**
* Ajoute les versions lisibles des compteurs à un quota brut.
*
* Les clés en minutes restent présentes : le JavaScript calcule sur les
* minutes et n'affiche que les libellés.
*
* @param array<string, mixed> $quota
*
* @return array<string, mixed>
*/
public function enrich(array $quota): array public function enrich(array $quota): array
{ {
return array_merge($quota, [ return array_merge($quota, [
@@ -152,6 +222,16 @@ final class QuotaModel extends BaseModel
]); ]);
} }
/**
* Met en forme une durée en minutes sous ses trois représentations.
*
* Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait
* avant la division, sans quoi intdiv() et le modulo produiraient un libellé
* incohérent du type « -1 h -30 ».
*
* @return array{minutes: int, heures_decimales: float, libelle: string}
* Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ».
*/
public function formatMinutes(int $minutes): array public function formatMinutes(int $minutes): array
{ {
$sign = $minutes < 0 ? -1 : 1; $sign = $minutes < 0 ? -1 : 1;
@@ -166,6 +246,24 @@ final class QuotaModel extends BaseModel
]; ];
} }
/**
* Retrouve la ligne de quota la plus pertinente pour une année donnée.
*
* Le tri du ORDER BY applique une règle de repli en trois temps :
* 1. l'année demandée exactement ;
* 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus
* récente reste la meilleure estimation) ;
* 3. sinon l'année POSTÉRIEURE la plus proche.
*
* Sans aucune ligne — agent créé avant la mise en place des quotas — on
* retombe sur un temps plein théorique plutôt que sur un quota nul, qui
* afficherait un dépassement à la première heure planifiée.
*
* `quota_source_annee` indique quelle année a réellement servi, pour que
* l'interface puisse signaler un quota emprunté à une autre année.
*
* @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int}
*/
private function quotaProfile(int $agentId, int $year): array private function quotaProfile(int $agentId, int $year): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(

View File

@@ -4,8 +4,36 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
/**
* Contrôle de conformité du schéma, joué à CHAQUE requête par app/bootstrap.php.
*
* Ce garde-fou existe parce que les migrations sont des fichiers SQL appliqués
* par un service séparé : rien ne garantit que la base en face du code soit à
* jour. Plutôt que de laisser l'application échouer plus tard sur une erreur SQL
* cryptique au milieu d'un écran, on refuse de démarrer avec un message qui
* nomme précisément ce qui manque et quel fichier jouer.
*
* CONSÉQUENCE POUR LE DÉVELOPPEUR : ajouter une table ou une colonne obligatoire
* impose de compléter les listes ci-dessous EN MÊME TEMPS que la migration.
* Oublier de le faire ne casse rien tout de suite — c'est justement le piège :
* l'application démarrera sur une base incomplète.
*
* Le coût est de trois requêtes sur information_schema par requête HTTP,
* accepté au regard du volume d'utilisateurs de l'application.
*/
final class SchemaModel extends BaseModel final class SchemaModel extends BaseModel
{ {
/**
* Vérifie tables, colonnes puis vues d'accès.
*
* Seules les colonnes AJOUTÉES PAR MIGRATION sont listées, pas l'intégralité
* du schéma : ce sont les seules susceptibles de manquer sur une base créée
* à partir de database/pta_prod_schema_mariadb.sql, qui ne contient que le
* socle métier.
*
* @throws \RuntimeException Au premier manque constaté, avec le détail et
* le fichier SQL à exécuter.
*/
public function assertCurrent(): void public function assertCurrent(): void
{ {
$requiredTables = [ $requiredTables = [
@@ -28,6 +56,8 @@ final class SchemaModel extends BaseModel
'equipe_pta', 'equipe_pta',
'equipe_pta_membre', 'equipe_pta_membre',
'structure_creneau_reference', 'structure_creneau_reference',
'compte_acces',
'journal_activite',
]; ];
$placeholders = implode(',', array_fill(0, count($requiredTables), '?')); $placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
@@ -54,6 +84,7 @@ final class SchemaModel extends BaseModel
'planning' => ['date_validation'], 'planning' => ['date_validation'],
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'], 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
'structure' => ['type_affectation'], 'structure' => ['type_affectation'],
'compte_acces' => ['mot_de_passe_hash'],
]; ];
foreach ($requiredColumns as $table => $columns) { foreach ($requiredColumns as $table => $columns) {
@@ -80,6 +111,7 @@ final class SchemaModel extends BaseModel
} }
$this->assertAccessViews(); $this->assertAccessViews();
$this->assertJetonViews();
} }
/** /**
@@ -117,4 +149,39 @@ final class SchemaModel extends BaseModel
); );
} }
} }
/**
* Vues des comptes à jeton et du journal (V7.11).
*
* Même logique fail-closed que pour les vues de droits : sans elles,
* personne ne peut plus s'authentifier et aucune action ne serait tracée.
* Mieux vaut un message explicite au démarrage qu'une application ouverte
* sans journal.
*/
private function assertJetonViews(): void
{
$requiredViews = [
'v_acces_compte',
'v_acces_compte_manquant',
'v_journal_activite',
];
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredViews);
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
if ($missing) {
throw new \RuntimeException(
'Comptes d’accès indisponibles : vue(s) manquante(s) : '
. implode(', ', $missing)
. '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.'
);
}
}
} }

View File

@@ -4,8 +4,24 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
/**
* Référentiel des lieux d'affectation (`structure`).
*
* Toutes les lectures filtrent sur `actif = TRUE` : un lieu désactivé disparaît
* des listes et ne peut plus être choisi, mais ses plannings passés restent
* intacts. Aucune méthode de désactivation n'est exposée dans cette version, la
* bascule se fait en SQL.
*
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) donne le contexte par défaut
* du lieu ; il reste croisé avec le calendrier des vacances (VacationModel).
*/
final class StructureModel extends BaseModel final class StructureModel extends BaseModel
{ {
/**
* Tous les lieux actifs, triés par nom.
*
* @return array<int, array<string, mixed>>
*/
public function allActive(): array public function allActive(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -13,6 +29,12 @@ final class StructureModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Lieu actif par identifiant.
*
* @return array<string, mixed>|null null si inexistant OU désactivé — les
* appelants traitent les deux cas de la même façon.
*/
public function findActive(int $id): ?array public function findActive(int $id): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -22,6 +44,10 @@ final class StructureModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Vrai si le code est déjà pris, DÉSACTIVÉS COMPRIS : le code reste unique
* en base, réutiliser celui d'un lieu fermé violerait la contrainte.
*/
public function codeExists(string $code): bool public function codeExists(string $code): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
@@ -29,6 +55,14 @@ final class StructureModel extends BaseModel
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
/**
* Crée un lieu actif et renvoie sa fiche complète.
*
* La fiche est reconstituée en PHP plutôt que relue en base : l'appelant en
* a besoin pour l'insérer dans la liste affichée sans recharger la page.
*
* @return array<string, mixed>
*/
public function create(string $code, string $name, ?string $address, string $typeAffectation): array public function create(string $code, string $name, ?string $address, string $typeAffectation): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -51,6 +85,10 @@ final class StructureModel extends BaseModel
]; ];
} }
/**
* Change le type d'un lieu. La valeur est contrôlée par le contrôleur, ce
* modèle ne fait qu'écrire.
*/
public function updateType(int $structureId, string $typeAffectation): void public function updateType(int $structureId, string $typeAffectation): void
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -62,6 +100,20 @@ final class StructureModel extends BaseModel
]); ]);
} }
/**
* Agents à afficher pour un lieu et une semaine.
*
* Réunit DEUX populations, d'où les LEFT JOIN et le OR de la clause WHERE :
* - les agents RATTACHÉS au lieu (a.id_structure), même sans planning ;
* - les agents PLANIFIÉS sur le lieu cette semaine, quel que soit leur
* rattachement (sem.id_semaine IS NOT NULL).
*
* Omettre la seconde donnerait une vue fausse de l'effectif présent, un
* agent pouvant travailler sur plusieurs lieux la même semaine.
* `id_structure_principale` permet à l'interface de les distinguer.
*
* @return array<int, array<string, mixed>>
*/
public function agentsForOverview(int $structureId, int $year, int $week): array public function agentsForOverview(int $structureId, int $year, int $week): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -88,6 +140,14 @@ final class StructureModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Créneaux d'un lieu sur une semaine, tous agents confondus.
*
* Une seule lecture, regroupée ensuite par agent en PHP par l'appelant,
* plutôt qu'une requête par agent. Heures en HH:MM, format d'affichage.
*
* @return array<int, array<string, mixed>>
*/
public function entriesForOverview(int $structureId, int $year, int $week): array public function entriesForOverview(int $structureId, int $year, int $week): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(

View File

@@ -6,8 +6,38 @@ namespace App\Models;
use Throwable; use Throwable;
/**
* Équipes des mercredis et des vacances, et leurs contrôles d'encadrement.
*
* Le NOMBRE D'AGENTS REQUIS est calculé en SQL, au plus contraignant des deux
* règles :
* GREATEST(minimum_agents,
* CEIL(enfants_moins_6 / ratio_moins_6)
* + CEIL(enfants_6_plus / ratio_6_plus))
* Chaque tranche d'âge est arrondie au SUPÉRIEUR séparément avant d'être
* additionnée : un demi-agent n'existe pas, et sommer avant d'arrondir
* sous-estimerait l'encadrement.
*
* Les contrôles suivent la forme commune {niveau, code, message}. Aucun n'est
* bloquant : ils signalent, l'arbitrage restant humain.
*
* Cycle de vie propre aux équipes : BROUILLON → A_CONTROLER → VALIDEE, sans
* transition imposée.
*/
final class TeamModel extends BaseModel final class TeamModel extends BaseModel
{ {
/**
* Toutes les équipes, avec leurs membres et leurs contrôles.
*
* Le filtre de dates cherche les équipes qui INTERSECTENT l'intervalle
* (fin >= début demandé ET début <= fin demandée) et non celles qui y sont
* incluses : une équipe à cheval sur les bornes doit remonter.
*
* ATTENTION AU COÛT : deux requêtes supplémentaires par équipe (membres puis
* contrôles). Acceptable sur quelques dizaines d'équipes, à revoir au-delà.
*
* @return array<int, array<string, mixed>>
*/
public function all(?string $startDate = null, ?string $endDate = null): array public function all(?string $startDate = null, ?string $endDate = null): array
{ {
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom, $sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
@@ -56,6 +86,15 @@ final class TeamModel extends BaseModel
return $teams; return $teams;
} }
/**
* Une équipe par identifiant.
*
* Passe par all() plutôt que par une requête dédiée, afin de bénéficier des
* mêmes agrégats et contrôles. Coûteux, mais cette méthode n'est pas sur un
* chemin critique.
*
* @return array<string, mixed>|null
*/
public function find(int $teamId): ?array public function find(int $teamId): ?array
{ {
foreach ($this->all() as $team) { foreach ($this->all() as $team) {
@@ -66,6 +105,17 @@ final class TeamModel extends BaseModel
return null; return null;
} }
/**
* Crée une équipe, toujours en BROUILLON.
*
* $data est passé tel quel à execute() : ses clés doivent correspondre
* exactement aux paramètres nommés. La validation est faite par
* TeamController::index().
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé.
*/
public function create(array $data): int public function create(array $data): int
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -85,6 +135,20 @@ final class TeamModel extends BaseModel
return (int) $this->pdo->lastInsertId(); return (int) $this->pdo->lastInsertId();
} }
/**
* Ajoute un agent à une équipe, ou met à jour sa fonction s'il y est déjà.
*
* Un seul refus est automatique : une INTERDICTION active de l'agent pour ce
* lieu (agent_structure_souhait). Tout le reste — contraintes médicales,
* temps partiel thérapeutique — est signalé dans les contrôles de l'équipe
* et laissé à l'appréciation humaine, ces situations pouvant être compatibles
* avec certaines affectations.
*
* Les RuntimeException levées ici portent un message destiné à l'utilisateur
* final, que le contrôleur renvoie tel quel en 422.
*
* @throws \RuntimeException Équipe introuvable ou interdiction active.
*/
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
{ {
$team = $this->teamDates($teamId); $team = $this->teamDates($teamId);
@@ -125,6 +189,11 @@ final class TeamModel extends BaseModel
]); ]);
} }
/**
* Retire un agent d'une équipe.
*
* @return bool false s'il n'en faisait pas partie.
*/
public function removeMember(int $teamId, int $agentId): bool public function removeMember(int $teamId, int $agentId): bool
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -134,12 +203,27 @@ final class TeamModel extends BaseModel
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Change le statut d'une équipe. La valeur est contrôlée par le contrôleur ;
* aucune transition n'est imposée, y compris un retour en arrière.
*/
public function setStatus(int $teamId, string $status): void public function setStatus(int $teamId, string $status): void
{ {
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id'); $stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
$stmt->execute(['statut' => $status, 'id' => $teamId]); $stmt->execute(['statut' => $status, 'id' => $teamId]);
} }
/**
* Membres d'une équipe, enrichis de tout ce dont les contrôles ont besoin :
* diplôme, poste, lieu de rattachement, souhait exprimé pour ce lieu, et
* nombre de contraintes actives sur la période de l'équipe (sous-requête
* corrélée, calculée pour la seule fenêtre de dates de l'équipe).
*
* Le FIELD() ordonne par fonction — responsable, animation, restauration —
* plutôt qu'alphabétiquement : c'est l'ordre de lecture d'un trombinoscope.
*
* @return array<int, array<string, mixed>>
*/
private function members(int $teamId): array private function members(int $teamId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -171,6 +255,23 @@ final class TeamModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Contrôles d'une équipe.
*
* Deux ERREUR (effectif insuffisant, taux de diplômés sous le seuil), deux
* ALERTE (aucun responsable identifié, membre sous contrainte médicale) et
* une INFO signalant qu'une préférence de l'agent est satisfaite. Un INFO
* « conforme » est ajouté si rien n'a été relevé, pour distinguer l'absence
* d'anomalie d'une absence de contrôle.
*
* Le taux de diplômés est calculé sur les agents AFFECTÉS, pas sur les
* agents requis : c'est la composition réelle de l'équipe qui compte.
*
* @param array<string, mixed> $team
* @param array<int, array<string, mixed>> $members
*
* @return array<int, array<string, string>>
*/
private function controls(array $team, array $members): array private function controls(array $team, array $members): array
{ {
$controls = []; $controls = [];
@@ -225,6 +326,11 @@ final class TeamModel extends BaseModel
return $controls; return $controls;
} }
/**
* Lieu et dates d'une équipe — lecture légère, sans les agrégats de all().
*
* @return array<string, mixed>|null
*/
private function teamDates(int $teamId): ?array private function teamDates(int $teamId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(

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