Compare commits
8 Commits
main
...
worktree-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdbd43c13 | ||
|
|
8d900bede8 | ||
|
|
30cb50031d | ||
|
|
935cc3ea1c | ||
|
|
b13b52b22b | ||
|
|
13ee16e25d | ||
|
|
b68ac15de5 | ||
|
|
66cc8c8ade |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,9 +19,6 @@ tmp/
|
||||
/vendor/
|
||||
/node_modules/
|
||||
|
||||
# Worktrees de travail Claude Code : copies du dépôt, jamais à versionner.
|
||||
.claude/worktrees/
|
||||
|
||||
# Artefacts d'éditeur et de système
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
110
CLAUDE.md
110
CLAUDE.md
@@ -28,7 +28,6 @@ docker compose up -d --build
|
||||
docker compose up -d --build php nginx
|
||||
|
||||
docker compose logs -f php nginx db
|
||||
docker compose logs db-migrate # compte rendu de l'application des migrations
|
||||
docker compose ps
|
||||
docker compose down # arrêt en conservant les données
|
||||
docker compose down -v # DÉTRUIT le volume MariaDB
|
||||
@@ -53,9 +52,7 @@ Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fi
|
||||
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
|
||||
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`,
|
||||
`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une
|
||||
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. C'est pourquoi la
|
||||
migration de la V7.11 s'appelle `migration_v7_9e_dsi_comptes_journal.sql` : le préfixe suit la
|
||||
dernière migration en date, pas le numéro de version fonctionnelle.
|
||||
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`.
|
||||
|
||||
`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`,
|
||||
@@ -68,8 +65,7 @@ Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'h
|
||||
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
|
||||
|
||||
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
|
||||
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`,
|
||||
`manage_structures.php`, `administration.php`, `dsi.php` :
|
||||
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||
|
||||
```php
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
@@ -122,44 +118,19 @@ l'application entière refuse de démarrer.
|
||||
|
||||
### Rôles et périmètres (`app/Core/Access.php`)
|
||||
|
||||
Quatre rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
|
||||
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout le métier, seul habilité à valider) et
|
||||
`DSI` (administration technique : comptes d'accès et journal, aucun droit métier).
|
||||
Trois 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).
|
||||
|
||||
`role.php` n'est plus un sélecteur depuis la V7.11 : c'est une **authentification par jeton** (voir
|
||||
`README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après
|
||||
vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur
|
||||
le compte**, jamais déclarés par l'utilisateur. `Access::select()` a disparu — ne pas le
|
||||
réintroduire.
|
||||
|
||||
Les comptes `SERVICE_ENFANCE` et `DSI` exigent un **second facteur** depuis la V7.12 (voir
|
||||
`README_V7_12.md`) : après le jeton, un mot de passe choisi par le titulaire à sa première connexion
|
||||
et haché par `password_hash()`. La règle « qui doit avoir un mot de passe » est portée par la vue
|
||||
(`v_acces_compte.mot_de_passe_requis`), pas par du PHP. Entre les deux étapes, la session ne porte
|
||||
qu'une attente horodatée de cinq minutes : **un jeton validé sans mot de passe n'ouvre rien**. La DSI
|
||||
peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde.
|
||||
|
||||
Depuis la V7.27 (`README_V7_27.md`), le Service Enfance distribue lui-même les jetons des rôles
|
||||
`AGENT` et `RESPONSABLE_STRUCTURE`, depuis l'onglet « Administration »
|
||||
(`AccessAccountController`, `api/acces_*.php`). **La délégation s'arrête à ces deux rôles** : un
|
||||
compte `SERVICE_ENFANCE` créé par le Service Enfance serait une auto-promotion, un compte `DSI` lui
|
||||
ouvrirait le journal de ses propres actions. La borne est posée des deux côtés —
|
||||
`AccessAccountController::ROLES_DELEGUES` en PHP, `can_manage_field_access` en base — et ne doit être
|
||||
élargie ni d'un côté ni de l'autre. La DSI reste seule sur les comptes privilégiés, les mots de passe
|
||||
et le journal.
|
||||
|
||||
`Access` relit le compte à **chaque requête** (`v_acces_compte`, filtrée sur `actif` et
|
||||
`identite_active`) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé
|
||||
referment la session en cours. Ne pas mettre ce résultat en cache entre deux requêtes.
|
||||
`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
|
||||
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
|
||||
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
|
||||
mécanique de droits.
|
||||
|
||||
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
|
||||
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
|
||||
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
|
||||
la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de
|
||||
`migration_v7_9e_dsi_comptes_journal.sql`). `SchemaModel` vérifie au démarrage la présence des sept
|
||||
vues de droits et des trois vues de comptes/journal, et fait échouer l'application si une migration
|
||||
manque — c'est volontairement fail-closed, et `docker/db/apply-migrations.sh` refait le même contrôle
|
||||
au déploiement.
|
||||
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
|
||||
échouer l'application si la migration manque — c'est volontairement fail-closed.
|
||||
|
||||
`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
|
||||
@@ -167,37 +138,14 @@ jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.
|
||||
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
|
||||
|
||||
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
|
||||
commence par un garde `requireService()`, `requireDsi()`, `requireDraftEditor()`,
|
||||
`requireStructureEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`,
|
||||
`requirePlanningEdit($id)`… Ces gardes émettent
|
||||
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
|
||||
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
|
||||
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
|
||||
garde ni sans `Csrf::assertPayload($payload)`.**
|
||||
|
||||
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
|
||||
`csrf_token: window.PTA_CONFIG.csrfToken`.
|
||||
|
||||
### Journalisation (V7.11)
|
||||
|
||||
`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par
|
||||
`app/bootstrap.php`, les appelants n'ont donc rien à transporter :
|
||||
|
||||
```php
|
||||
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
|
||||
```
|
||||
|
||||
Trois règles :
|
||||
|
||||
- **tout endpoint mutant doit journaliser son succès**, après l'écriture en base et avant le
|
||||
`JsonResponse::send()` — au même titre qu'il porte un garde et un `Csrf::assertPayload()` ;
|
||||
- **les lectures ne sont jamais journalisées** (choix de volume) ; les refus le sont, et `Access`
|
||||
comme `Csrf` s'en chargent déjà — inutile de les doubler dans les contrôleurs ;
|
||||
- **le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas
|
||||
entourer un appel de logique qui pourrait lever.
|
||||
|
||||
Le journal sert aussi de compteur au frein anti-force-brute de la connexion
|
||||
(`JournalModel::echecsRecents()`) : renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE`
|
||||
désarmerait ce frein silencieusement.
|
||||
|
||||
### Contraintes de sécurité à respecter
|
||||
|
||||
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
|
||||
@@ -226,7 +174,7 @@ PTA.modules.agentOverview = { init, load };
|
||||
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
|
||||
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
|
||||
|
||||
Les assets sont versionnés à la main : `assets/style.css?v=7.23` et `assets/js/*.js?v=7.23` dans
|
||||
Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans
|
||||
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
|
||||
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.
|
||||
@@ -235,43 +183,14 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||
|
||||
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
|
||||
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
|
||||
- Les 1 607 h sont un volume **NET de travail effectif** : `365 - 104 (week-ends) - 25 (congés
|
||||
payés) - 8 (jours fériés) = 228 j × 7 h = 1 596 h`, arrondies à 1 600 h, plus les 7 h de la
|
||||
journée de solidarité. Congés payés et jours fériés en sont donc **déjà retranchés** : leur
|
||||
imputer des heures de quota les décompterait une seconde fois. Une tentative de décompter un
|
||||
forfait de 7 h par jour de CP a été introduite puis annulée pour cette raison — ne pas la
|
||||
réintroduire.
|
||||
- **Un agent n'est planifiable qu'entre ses dates de contrat** (V7.15,
|
||||
`date_debut_contrat` / `date_fin_contrat`). La règle vaut pour TOUS les motifs, congés compris :
|
||||
hors contrat, il n'y a rien à décompter. Une borne absente ouvre — pas de date de fin signifie
|
||||
contrat à durée indéterminée. Elle est appliquée à l'écriture (`assertDatesWithinContract()` dans
|
||||
`PlanningController` et `WeekTemplateController`), au contrôle de validation (`HORS_CONTRAT`) et à
|
||||
l'inverse, sur la fiche agent : raccourcir un contrat sous des affectations existantes est refusé.
|
||||
Toute nouvelle voie d'écriture de créneaux doit poser la même garde.
|
||||
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
|
||||
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
|
||||
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
|
||||
- **Un compte d'accès coupé retire l'agent des listes d'AFFECTATION** (V7.29) : sélecteur de la
|
||||
saisie (`AgentModel::listForLocation()`) et composition des équipes (`allSelectable()`). Un agent
|
||||
**sans compte** reste proposé — c'est l'état normal avant distribution des jetons, le filtrer
|
||||
viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel
|
||||
« Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès. `allActive()` reste
|
||||
la liste non filtrée ; ne pas confondre les deux.
|
||||
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
|
||||
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
|
||||
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
|
||||
elles sont importées depuis l'API publique data.education.gouv.fr via
|
||||
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
|
||||
- Une **semaine type** (`modele_semaine_agent` / `modele_semaine_creneau`) appartient à **un agent**
|
||||
et décrit un lundi→vendredi générique. Ce n'est pas un planning : rien n'est planifié tant qu'elle
|
||||
n'est pas appliquée à une semaine ou dupliquée sur une période. Elle se compose soit à partir
|
||||
d'une semaine réellement planifiée (`saveFromWeek()`), soit directement dans l'éditeur
|
||||
(`saveFromEntries()`, voir `README_V7_10.md`) — les deux passent par `persistTemplate()`.
|
||||
|
||||
`planning.php` propose deux modes exclusifs (voir `README_V7_10.md`) : l'éditeur de semaine type,
|
||||
ouvert par défaut, et la saisie semaine par semaine. Le second reste la cible de tous les liens
|
||||
profonds : dès que l'URL porte `week` ou `focus`, `page-bootstrap.js` bascule sur ce mode, sinon un
|
||||
utilisateur venant corriger un planning identifié atterrirait dans l'éditeur de modèles.
|
||||
|
||||
### Piège : logique de validation dupliquée
|
||||
|
||||
@@ -295,8 +214,5 @@ message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation exp
|
||||
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
|
||||
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
|
||||
déploiement réel.
|
||||
- Le **jeton DSI d'installation** (`PTA-INSTALLATION-DSI`) est écrit en clair dans
|
||||
`database/migration_v7_9e_dsi_comptes_journal.sql`, donc public. La page DSI alerte tant qu'il n'a
|
||||
pas été régénéré ; c'est la première action à faire après une installation.
|
||||
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
|
||||
utiles pour comprendre une décision, mais seul le code fait foi.
|
||||
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# PTA V6 — application PHP MVC multipage
|
||||
|
||||
PTA n’est plus une SPA. Chaque fonction principale correspond à une page PHP distincte :
|
||||
|
||||
- `planning.php` : saisie, brouillon, validation et semaines types ;
|
||||
- `pending.php` : plannings en attente de validation ;
|
||||
- `coverage.php` : contrôle des créneaux sans agent ;
|
||||
- `structures.php` : planning hebdomadaire par lieu ;
|
||||
- `agents.php` : planning mensuel et bilan par agent ;
|
||||
- `vacations.php` : aperçu et enregistrement des vacances scolaires ;
|
||||
- `administration.php` : lieux, agents et règles de couverture.
|
||||
|
||||
`index.php` redirige vers le choix de rôle lors de la première visite, puis vers la page d’accueil autorisée pour le profil sélectionné.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
page PHP
|
||||
→ PageController
|
||||
→ Models / Services
|
||||
→ View dédiée
|
||||
|
||||
View
|
||||
→ API JSON uniquement pour les actions asynchrones utiles
|
||||
```
|
||||
|
||||
Les contrôleurs métier, modèles, services PDF et endpoints API de la V5.20 sont conservés. La différence principale porte sur l’interface : plus de routeur JavaScript, plus de vues cachées et plus de chargement de tous les modules sur une seule page.
|
||||
|
||||
## JavaScript chargé par page
|
||||
|
||||
- Saisie : `planning-editor.js` et `week-templates.js` ;
|
||||
- À valider : `pending-validation.js` ;
|
||||
- Couverture : `coverage.js` ;
|
||||
- Par lieu : `structure-overview.js` ;
|
||||
- Par agent : `agent-overview.js` ;
|
||||
- Vacances : `vacation-calendar.js` ;
|
||||
- Administration : `administration.js` et la partie configuration de `coverage.js`.
|
||||
|
||||
`assets/js/page-bootstrap.js` initialise uniquement les modules déclarés par la page et applique les filtres transmis dans l’URL.
|
||||
|
||||
## Liens directs
|
||||
|
||||
Une page de contrôle peut ouvrir directement le planning concerné :
|
||||
|
||||
```text
|
||||
planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Configurer `config.php`.
|
||||
2. Utiliser le schéma SQL correspondant à la V5.20.
|
||||
3. Placer le dossier sur le serveur PHP.
|
||||
4. Ouvrir `index.php` ou `planning.php`.
|
||||
|
||||
Aucune migration SQL supplémentaire n’est nécessaire pour passer de la V5.20 à la V6.
|
||||
|
||||
## Version 7
|
||||
|
||||
Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis/vacances et les contrôles métier associés.
|
||||
|
||||
## V7.2 — Vue annuelle
|
||||
|
||||
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
|
||||
|
||||
|
||||
## V7.8 — Sécurité et droits en base
|
||||
|
||||
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
|
||||
vues SQL sont décrits dans `README_V7_8.md`. La migration
|
||||
`database/migration_v7_8_securite_acces.sql` est obligatoire.
|
||||
|
||||
## V7.4 — Rôles et droits d’accès
|
||||
|
||||
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
|
||||
101
README_DOCKER.md
Normal file
101
README_DOCKER.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# PTA - Docker / Nginx / PHP / MariaDB / phpMyAdmin
|
||||
|
||||
## Installation
|
||||
|
||||
Copier les fichiers de ce kit à la racine du projet PTA :
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
- `docker/nginx/default.conf`
|
||||
- `docker/php/php.ini`
|
||||
- `database/pta_prod_schema_mariadb.sql`
|
||||
|
||||
Puis :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Changer impérativement `DB_PASSWORD` et `DB_ROOT_PASSWORD`.
|
||||
|
||||
Démarrer :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Application :
|
||||
|
||||
```text
|
||||
http://IP_DU_SERVEUR:8080
|
||||
```
|
||||
|
||||
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
|
||||
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
|
||||
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
|
||||
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
|
||||
atteint le port.
|
||||
|
||||
Sur le serveur lui-même :
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Pour y accéder depuis un poste distant, le plus sûr est un tunnel SSH :
|
||||
|
||||
```bash
|
||||
ssh -L 8081:127.0.0.1:8081 utilisateur@serveur
|
||||
```
|
||||
|
||||
Puis ouvrir localement :
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
## Base de données
|
||||
|
||||
Le schéma `database/pta_prod_schema_mariadb.sql` est exécuté automatiquement uniquement
|
||||
lorsque le volume MariaDB est créé pour la première fois.
|
||||
|
||||
Il ne sera PAS rejoué à chaque démarrage du conteneur.
|
||||
|
||||
Si la base existe déjà, ne supprimez pas le volume pour appliquer une migration. Exécutez
|
||||
les migrations explicitement sur la base existante.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
# Etat
|
||||
docker compose ps
|
||||
|
||||
# Logs
|
||||
docker compose logs -f nginx php db
|
||||
|
||||
# Rebuild après modification du PHP / JS / CSS
|
||||
docker compose up -d --build php nginx
|
||||
|
||||
# Arrêt
|
||||
docker compose down
|
||||
|
||||
# Arrêt SANS supprimer les données
|
||||
docker compose down
|
||||
|
||||
# ATTENTION : supprime la base MariaDB persistée
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Connexion de l'application
|
||||
|
||||
Le `config.php` PTA utilise déjà les variables :
|
||||
|
||||
- `PTA_DB_HOST`
|
||||
- `PTA_DB_PORT`
|
||||
- `PTA_DB_NAME`
|
||||
- `PTA_DB_USER`
|
||||
- `PTA_DB_PASS`
|
||||
|
||||
Le compose les fournit automatiquement au conteneur PHP.
|
||||
@@ -109,25 +109,20 @@ de tourner sans contrôle de périmètre.
|
||||
|
||||
## Installation
|
||||
|
||||
La migration est **appliquée automatiquement au déploiement**. Le service jetable `db-migrate`
|
||||
du compose joue `database/migration_v7_8_securite_acces.sql` dès que MariaDB est sain, et le
|
||||
service `php` n'est démarré qu'après sa sortie en succès.
|
||||
|
||||
1. Sauvegarder la base.
|
||||
2. Déployer :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs db-migrate # doit afficher « Base à jour »
|
||||
```
|
||||
|
||||
La migration est idempotente et suivie dans la table `schema_migration` : un second déploiement
|
||||
l'ignore. Elle reste jouable à la main si besoin :
|
||||
2. Exécuter une seule fois :
|
||||
|
||||
```sql
|
||||
SOURCE database/migration_v7_8_securite_acces.sql;
|
||||
```
|
||||
|
||||
3. Reconstruire les images, phpMyAdmin compris :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build php nginx
|
||||
docker compose up -d --force-recreate phpmyadmin
|
||||
```
|
||||
|
||||
## Reste à traiter
|
||||
|
||||
Le sélecteur de `role.php` n'est toujours **pas une authentification** :
|
||||
@@ -2,13 +2,5 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
runPage('administration');
|
||||
|
||||
@@ -2,14 +2,5 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Planning d'un agent mois par mois, tous lieux confondus.
|
||||
* Un rôle AGENT y est verrouillé sur lui-même et l'écran devient « Mon planning ».
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
runPage('agents');
|
||||
|
||||
@@ -2,13 +2,5 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vue annuelle d'un agent et validation de l'année entière.
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/page_bootstrap.php';
|
||||
runPage('annual');
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un compte de terrain et renvoie son jeton, affiché une seule fois.
|
||||
* Charge utile : role (AGENT | RESPONSABLE_STRUCTURE), agent_id, structure_id,
|
||||
* commentaire, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->creerCompte();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — régénère le jeton d'un compte de terrain ; l'ancien est révoqué.
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->regenererJeton();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — active, désactive ou supprime un compte de terrain.
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->changerStatutCompte();
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — comptes d'accès des agents et des responsables de lieu (Service Enfance).
|
||||
* Les comptes Service Enfance et DSI n'y figurent jamais.
|
||||
* Paramètres : role, actif, q.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->comptes();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — génère un jeton pour chaque agent et chaque lieu actif qui n'en a pas.
|
||||
* Charge utile : csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->genererJetonsManquants();
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — liste des agents (Service Enfance).
|
||||
* POST — change le lieu de rattachement principal d'un agent.
|
||||
* Charge utile POST : agent_id, structure_id (nullable), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — contrat horaire annuel d'un agent, au format PDF.
|
||||
* Paramètres : agent_id, puis date OU year. Sortie binaire : pas de bootstrap JSON.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — grille mensuelle d'un agent, tous lieux confondus.
|
||||
* Paramètres : agent_id, month (« 2026-02 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaine d'un agent, tous lieux confondus.
|
||||
* Paramètres : agent_id, week (« 2026-W08 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — planning d'un agent sur 6 ou 7 semaines, au format PDF.
|
||||
* Paramètres : agent_id, week (départ), weeks (6|7). Sortie binaire.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — bilan annuel d'heures d'un agent, au format PDF.
|
||||
* Paramètres : agent_id, puis date OU year. Sortie binaire.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — agents sélectionnables dans l'éditeur de planning.
|
||||
* Paramètre : structure_id (0 ou absent = tous les lieux, Service Enfance).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — calendrier annuel d'un agent (Service Enfance).
|
||||
* Paramètres : agent_id, year (facultatif, année en cours par défaut).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AnnualOverviewController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — contrôle puis valide TOUTE l'année d'un agent (Service Enfance).
|
||||
* Tout ou rien : une seule erreur et aucune semaine n'est validée.
|
||||
* Charge utile : agent_id, year, force_quota, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AnnualOverviewController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — applique une semaine type à UNE semaine (Service Enfance).
|
||||
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — applique une semaine type sur une période (Service Enfance).
|
||||
* Opération massive : passer d'abord par preview_week_template_period.php.
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — recopie une semaine entière d'un agent (Service Enfance).
|
||||
* Porte sur tous les lieux de la semaine source.
|
||||
* Charge utile : agent_id, source_week, target_week, mode (merge|replace), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — plages sous-effectif d'une semaine (responsable ou Service Enfance).
|
||||
* Un responsable est forcé sur son propre lieu.
|
||||
* Paramètres : week, structure_id (facultatif).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un agent, son quota annuel et son PTA (Service Enfance).
|
||||
* Charge utile : matricule, nom, prenom, structure_id, id_poste, type_contrat,
|
||||
* quotite_travail, date_debut_contrat, csrf_token, et champs facultatifs.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un lieu d'affectation (Service Enfance).
|
||||
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,15 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — données du tableau de bord (Service Enfance).
|
||||
* scope=badges limite la réponse au compteur de la pastille de navigation :
|
||||
* c'est la variante appelée depuis toutes les pages, elle doit rester légère.
|
||||
* Paramètres : scope, week, year (tous facultatifs).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DashboardController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime un créneau depuis la fiche agent.
|
||||
* Peut rouvrir un planning validé. Charge utile : entry_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime une période de vacances (Service Enfance).
|
||||
* Charge utile : period_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime une semaine type (Service Enfance).
|
||||
* Les créneaux déjà appliqués ne sont pas touchés.
|
||||
* Charge utile : template_id, agent_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un compte d'accès et renvoie SON JETON EN CLAIR (DSI).
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire, csrf_token.
|
||||
* Le jeton n'est lisible que dans cette réponse : la base n'en garde que l'empreinte.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->creerCompte();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — régénère le jeton d'un compte, révoquant l'ancien (DSI).
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->regenererJeton();
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — efface le mot de passe d'un compte Service Enfance ou DSI (DSI).
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* La DSI ne peut que RETIRER un mot de passe, jamais en poser un : le titulaire
|
||||
* en choisit un nouveau à sa connexion suivante, avec son jeton inchangé.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->reinitialiserMotDePasse();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — active, désactive ou supprime un compte d'accès (DSI).
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->changerStatutCompte();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — comptes d'accès, identités sans compte et rôles proposés (DSI).
|
||||
* Paramètres : role, actif, q.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->comptes();
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — génère un jeton pour chaque agent et chaque lieu actif sans compte (DSI).
|
||||
* Charge utile : csrf_token. Outil de mise en service : la réponse porte tous
|
||||
* les jetons produits, en clair, et une seule fois.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->genererJetonsManquants();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — recherche paginée dans le journal d'activité (DSI).
|
||||
* Paramètres : categorie, resultat, role, id_compte, q, du, au, page, taille.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->journal();
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — purge les entrées du journal antérieures à N jours (DSI, minimum 30).
|
||||
* Charge utile : jours, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->purgerJournal();
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — déplace ou duplique un créneau (glisser-déposer).
|
||||
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — file des plannings en attente de validation (Service Enfance).
|
||||
* Triée du plus ancien au plus récent, brouillons vides exclus.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — état complet d'une semaine de saisie.
|
||||
* Inclut les créneaux de l'agent sur les AUTRES lieux, en lecture seule.
|
||||
* Paramètres : structure_id, agent_id, week.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — propositions de vacances de l'API data.education.gouv.fr, SANS écriture.
|
||||
* Un échec du service distant ressort en 502.
|
||||
* Paramètres : academy, year (année civile).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — SIMULE l'application d'une semaine type sur une période, sans écrire.
|
||||
* Étape imposée avant apply_week_template_period.php.
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre ou supprime une contrainte prioritaire (Service Enfance).
|
||||
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
|
||||
* Charge utile : action, agent_id, csrf_token, puis constraint_id ou les champs de la contrainte.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — synthèse du PTA d'un agent à une date de référence (Service Enfance).
|
||||
* Paramètres : agent_id, date (facultative, aujourd'hui par défaut).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre ou supprime un souhait de lieu (Service Enfance).
|
||||
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
|
||||
* Charge utile : action, agent_id, csrf_token, puis wish_id ou les champs du souhait.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — remplace INTÉGRALEMENT les règles de couverture d'un lieu (Service Enfance).
|
||||
* Une liste vide supprime toute règle.
|
||||
* Charge utile : structure_id, rules[], csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre les périodes de vacances retenues (Service Enfance).
|
||||
* Plafonné à 50 périodes par envoi.
|
||||
* Charge utile : periods[], csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre la semaine planifiée comme semaine type (Service Enfance).
|
||||
* Charge utile : agent_id, week, name, replace, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre une semaine type composée dans l'éditeur (Service Enfance).
|
||||
* Charge utile : agent_id, name, entries[], replace, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new WeekTemplateController($pdo))->storeEntries();
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — propositions d'adresses de la Base Adresse Nationale, SANS écriture.
|
||||
* Un échec du service distant ressort en 502.
|
||||
* Paramètres : q (saisie en cours), limit (facultatif).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AddressController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AddressController($pdo))->search();
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — règles de couverture d'un lieu (Service Enfance).
|
||||
* Paramètre : structure_id.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaine d'un lieu, agent par agent.
|
||||
* Inclut les agents ponctuellement planifiés, pas seulement les rattachés.
|
||||
* Paramètres : structure_id, week.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — planning hebdomadaire d'un lieu, au format PDF.
|
||||
* Paramètres : structure_id, week. Sortie binaire : pas de bootstrap JSON.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — ajoute ou retire un agent d'une équipe (Service Enfance).
|
||||
* Charge utile : action (ADD|REMOVE), team_id, agent_id, fonction_equipe, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — change le statut d'une équipe (Service Enfance).
|
||||
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — liste des équipes, filtrable par date_debut / date_fin.
|
||||
* POST — crée une équipe (Service Enfance).
|
||||
* Charge utile POST : structure_id, type_equipe, libelle, date_debut, date_fin, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — modifie la fiche d'un agent (Service Enfance).
|
||||
* Ni le matricule ni la quotité de travail ne sont modifiables ici.
|
||||
* Charge utile : agent_id, champs de la fiche, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — modifie un créneau depuis la fiche agent.
|
||||
* Peut changer de jour, de semaine et de lieu ; peut rouvrir un planning validé.
|
||||
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — change le type d'un lieu d'affectation (Service Enfance).
|
||||
* Charge utile : structure_id, type_affectation, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — périodes de vacances enregistrées (Service Enfance).
|
||||
* Paramètre : school_year (facultatif, « 2026-2027 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — contenu détaillé d'une semaine type (Service Enfance).
|
||||
* Paramètres : agent_id, template_id — le couple sert de contrôle de périmètre.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new WeekTemplateController($pdo))->show();
|
||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaines types d'un agent, avec leurs compteurs (Service Enfance).
|
||||
* Paramètre : agent_id.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CompteModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Jetons d'accès de terrain (V7.27) : le Service Enfance équipe ses agents et
|
||||
* ses responsables de lieu.
|
||||
*
|
||||
* POURQUOI CE CONTRÔLEUR EXISTE À CÔTÉ DE DsiController : c'est le Service
|
||||
* Enfance qui sait qu'un agent est arrivé, qu'un responsable a perdu son jeton
|
||||
* ou qu'un remplaçant s'en va. Passer par la DSI pour chaque mouvement ajoutait
|
||||
* un délai sans ajouter de contrôle. La DSI conserve tout : ses propres comptes,
|
||||
* ceux du Service Enfance, le journal, la purge.
|
||||
*
|
||||
* LA FRONTIÈRE DE PRIVILÈGE, à ne jamais élargir :
|
||||
*
|
||||
* - self::ROLES_DELEGUES ne contient QUE AGENT et RESPONSABLE_STRUCTURE.
|
||||
* Y ajouter SERVICE_ENFANCE ferait du rôle son propre distributeur — une
|
||||
* auto-promotion sans contrôle externe ; y ajouter DSI lui ouvrirait le
|
||||
* journal, c'est-à-dire la trace de ses propres actions ;
|
||||
* - toute méthode qui désigne un compte existant passe par
|
||||
* compteDelegue() AVANT d'agir. Un identifiant de compte privilégié est
|
||||
* refusé en 403, jamais silencieusement ignoré ;
|
||||
* - la liste blanche est doublée en base
|
||||
* (database/migration_v7_9h_jetons_service_enfance.sql) : une frontière de
|
||||
* privilège ne doit pas dépendre d'un seul des deux côtés.
|
||||
*
|
||||
* Les deux règles de DsiController restent valables ici :
|
||||
* 1. un jeton en clair ne sort qu'AU MOMENT où il vient d'être produit ;
|
||||
* 2. toute action est journalisée en catégorie ADMINISTRATION, sans jamais
|
||||
* recopier le jeton dans la trace.
|
||||
*/
|
||||
final class AccessAccountController
|
||||
{
|
||||
/**
|
||||
* Les seuls rôles dont le Service Enfance distribue les jetons.
|
||||
* Lire l'en-tête de classe avant d'y toucher.
|
||||
*/
|
||||
private const ROLES_DELEGUES = [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE];
|
||||
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/acces_comptes.php — comptes de terrain et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role (dans la liste déléguée), actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'roles' => self::ROLES_DELEGUES,
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
// v_acces_compte_manquant ne produit que des identités AGENT et
|
||||
// RESPONSABLE_STRUCTURE : elle est déjà dans le périmètre délégué.
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
'roles' => $this->rolesDelegues(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_creer.php — crée un compte de terrain et renvoie son
|
||||
* jeton.
|
||||
*
|
||||
* Le libellé n'est pas saisi : pour ces deux rôles il est TOUJOURS déduit de
|
||||
* l'identité (nom de l'agent, nom du lieu), par CompteModel. Un titulaire ne
|
||||
* peut donc pas être maquillé sous un autre nom dans la liste.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, commentaire, csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$this->assertRoleDelegue($role);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
'',
|
||||
$commentaire !== '' ? $commentaire : null
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $resultat['compte'];
|
||||
Journal::administration('COMPTE_CREE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => (int) $compte['id_compte'],
|
||||
'description' => sprintf(
|
||||
'Création du compte d’accès « %s » (%s) par le Service Enfance.',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $compte['role'],
|
||||
'prefixe' => $compte['jeton_prefixe'],
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
|
||||
'compte' => $compte,
|
||||
'jeton' => $resultat['jeton'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_jeton.php — régénère le jeton d'un compte de terrain.
|
||||
*
|
||||
* C'est la procédure du jeton perdu comme du jeton compromis : l'ancien
|
||||
* cesse immédiatement de fonctionner.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$compte = $this->compteDelegue($compteId);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$apres = $this->comptes->trouver($compteId) ?? $compte;
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » par le Service Enfance : l’ancien jeton est révoqué.',
|
||||
(string) $apres['identite_libelle']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $apres['role'],
|
||||
'prefixe' => $apres['jeton_prefixe'] ?? null,
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $apres,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_statut.php — active, désactive ou supprime un compte
|
||||
* de terrain.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur.
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
|
||||
|
||||
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Action inconnue.']);
|
||||
}
|
||||
|
||||
// La fiche est lue AVANT l'opération, et sa lecture vaut contrôle de
|
||||
// périmètre : après une suppression, il n'y aurait plus ni rôle à
|
||||
// vérifier ni libellé à écrire dans le journal. Le compte de la session
|
||||
// en cours est hors périmètre par construction (c'est un compte
|
||||
// SERVICE_ENFANCE), il n'y a donc pas d'auto-désactivation à empêcher.
|
||||
$compte = $this->compteDelegue($compteId);
|
||||
|
||||
try {
|
||||
if ($action === 'SUPPRIMER') {
|
||||
$this->comptes->supprimer($compteId);
|
||||
} else {
|
||||
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
|
||||
}
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$libelles = [
|
||||
'ACTIVER' => 'Réactivation',
|
||||
'DESACTIVER' => 'Désactivation',
|
||||
'SUPPRIMER' => 'Suppression',
|
||||
];
|
||||
|
||||
Journal::administration('COMPTE_' . $action, [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'%s du compte d’accès « %s » (%s) par le Service Enfance.',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $compte['role'],
|
||||
'prefixe' => $compte['jeton_prefixe'],
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_comptes_manquants.php — équipe d'un coup toutes les
|
||||
* identités actives sans compte.
|
||||
*
|
||||
* Sans danger d'élargissement : v_acces_compte_manquant ne connaît que des
|
||||
* agents et des lieux, jamais un compte privilégié. La réponse porte tous
|
||||
* les jetons produits, en clair — seule occasion de les lire.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
try {
|
||||
$produits = $this->comptes->genererManquants();
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('JETONS_GENERES_EN_LOT', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf(
|
||||
'Génération de %d jeton(s) de terrain par le Service Enfance.',
|
||||
count($produits)
|
||||
),
|
||||
'contexte' => ['nombre' => count($produits), 'canal' => 'DELEGATION_SERVICE_ENFANCE'],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Tous les agents et tous les lieux actifs disposent déjà d’un compte.'
|
||||
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
|
||||
'jetons' => $produits,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit un compte et REFUSE tout ce qui sort du périmètre délégué.
|
||||
*
|
||||
* Le refus est un 403 explicite et non un 404 : masquer l'existence du
|
||||
* compte n'apporterait rien (le Service Enfance sait parfaitement que la DSI
|
||||
* existe) et rendrait le message incompréhensible à l'utilisateur de bonne
|
||||
* foi qui a suivi un lien périmé.
|
||||
*
|
||||
* @return array<string, mixed> Le compte, garanti dans le périmètre.
|
||||
*/
|
||||
private function compteDelegue(int $compteId): array
|
||||
{
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
}
|
||||
|
||||
if (!in_array((string) $compte['role'], self::ROLES_DELEGUES, true)) {
|
||||
Journal::refus('COMPTE_HORS_PERIMETRE', 'Tentative de gestion d’un compte privilégié hors délégation.', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'contexte' => ['role_cible' => $compte['role'], 'statut_http' => 403],
|
||||
]);
|
||||
JsonResponse::send(403, [
|
||||
'error' => 'Les comptes Service Enfance et DSI relèvent de la seule DSI.',
|
||||
'code' => 'ACCESS_DENIED',
|
||||
]);
|
||||
}
|
||||
|
||||
return $compte;
|
||||
}
|
||||
|
||||
/** Refuse un rôle demandé hors périmètre délégué, avant toute écriture. */
|
||||
private function assertRoleDelegue(string $role): void
|
||||
{
|
||||
if (in_array($role, self::ROLES_DELEGUES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::refus('ROLE_HORS_PERIMETRE', 'Tentative de création d’un compte hors délégation.', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'contexte' => ['role_demande' => $role, 'statut_http' => 403],
|
||||
]);
|
||||
JsonResponse::send(403, [
|
||||
'error' => 'Vous ne pouvez créer que des comptes Agent et Responsable de structure. Les comptes Service Enfance et DSI relèvent de la DSI.',
|
||||
'code' => 'ACCESS_DENIED',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDelegues(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Services\AddressApiService;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Recherche d'adresses postales (V7.25), relais vers la Base Adresse Nationale.
|
||||
*
|
||||
* Sert l'autocomplétion du champ « Adresse personnelle » de la page Agents. Le
|
||||
* relais est indispensable : la CSP interdit au navigateur d'appeler un domaine
|
||||
* tiers (`connect-src 'self'`, voir App\Core\Security).
|
||||
*
|
||||
* LECTURE PURE : aucune écriture en base, donc ni jeton CSRF ni journalisation
|
||||
* (CLAUDE.md — « les lectures ne sont jamais journalisées »). Le garde reste
|
||||
* celui de l'écriture des fiches agent, `requireService()` : c'est le seul rôle
|
||||
* qui saisit une adresse.
|
||||
*
|
||||
* Un échec du service distant ressort en 502 et non en 500 : c'est la BAN qui
|
||||
* n'a pas répondu, pas PTA qui a échoué. Côté interface, cet échec n'est jamais
|
||||
* bloquant — l'adresse reste saisissable à la main.
|
||||
*/
|
||||
final class AddressController
|
||||
{
|
||||
private Access $access;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/search_address.php — propositions d'adresses.
|
||||
* Paramètres GET : q (saisie en cours), limit (facultatif, 1 à 8).
|
||||
*/
|
||||
public function search(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
|
||||
if ($length < AddressApiService::MIN_QUERY_LENGTH) {
|
||||
JsonResponse::send(422, [
|
||||
'error' => sprintf('Saisissez au moins %d caractères.', AddressApiService::MIN_QUERY_LENGTH),
|
||||
]);
|
||||
}
|
||||
// Plafond de saisie : la BAN n'exploite pas au-delà, et cela borne le
|
||||
// coût d'une requête forgée.
|
||||
if ($length > 120) {
|
||||
JsonResponse::send(422, ['error' => 'Saisie trop longue pour une recherche d’adresse.']);
|
||||
}
|
||||
|
||||
$limit = filter_var($_GET['limit'] ?? 5, FILTER_VALIDATE_INT);
|
||||
if ($limit === false) {
|
||||
$limit = 5;
|
||||
}
|
||||
|
||||
try {
|
||||
$suggestions = (new AddressApiService())->search($query, $limit);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(502, [
|
||||
'error' => 'Le service national des adresses est momentanément indisponible. Vous pouvez saisir l’adresse manuellement.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '0') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'query' => $query,
|
||||
'suggestions' => $suggestions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\ContactFormat;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
@@ -22,18 +20,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Fiche agent : consultation, référentiel des agents et documents PDF.
|
||||
*
|
||||
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
|
||||
* DIFFÉRENTES et qu'il ne faut pas mélanger :
|
||||
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
|
||||
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
|
||||
* un flux binaire. Leurs points d'entrée n'appellent PAS
|
||||
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
|
||||
* http_response_code() suivi d'un `return` et d'un message en texte brut.
|
||||
* Y écrire du JSON corromprait le document téléchargé.
|
||||
*/
|
||||
final class AgentController
|
||||
{
|
||||
private AgentModel $agentModel;
|
||||
@@ -49,13 +35,6 @@ final class AgentController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
|
||||
*
|
||||
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
|
||||
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
|
||||
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
|
||||
*/
|
||||
public function listForPlanning(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -66,15 +45,6 @@ final class AgentController
|
||||
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
|
||||
*
|
||||
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
|
||||
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
|
||||
* endroits. En lecture seule pour le rôle AGENT.
|
||||
*
|
||||
* Paramètres GET : agent_id, week (« 2026-W08 »).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -103,21 +73,6 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
|
||||
*
|
||||
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
|
||||
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
|
||||
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
|
||||
* `in_month` de chaque jour permet à l'interface de griser les débordements.
|
||||
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
|
||||
* hebdomadaires affichés seraient faux.
|
||||
*
|
||||
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
|
||||
* compris, pour que les totaux de semaine restent justes.
|
||||
*
|
||||
* Paramètres GET : agent_id, month (« 2026-02 »).
|
||||
*/
|
||||
public function monthOverview(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -194,16 +149,6 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
|
||||
*
|
||||
* La période couverte n'est pas choisie par l'appelant : elle est celle
|
||||
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
|
||||
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
|
||||
* n'est qu'un raccourci vers le 1er janvier correspondant.
|
||||
*
|
||||
* Paramètres GET : agent_id, puis date OU year.
|
||||
*/
|
||||
public function timeSummaryPdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -249,15 +194,6 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
|
||||
*
|
||||
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
|
||||
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
|
||||
* sur page suivante.
|
||||
*
|
||||
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
|
||||
*/
|
||||
public function pdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -321,14 +257,6 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
|
||||
*
|
||||
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
|
||||
* détail des créneaux planifiés.
|
||||
*
|
||||
* Paramètres GET : agent_id, puis date OU year.
|
||||
*/
|
||||
public function hoursContractPdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -368,19 +296,6 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* api/agent_assignments.php — rattachement principal d'un agent.
|
||||
*
|
||||
* Endpoint à double verbe, exception au reste de l'API : GET liste les
|
||||
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
|
||||
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
|
||||
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
|
||||
*
|
||||
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
|
||||
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
|
||||
*
|
||||
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
|
||||
*/
|
||||
public function assignments(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -416,16 +331,6 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_RATTACHEMENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$structureId === null
|
||||
? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId)
|
||||
: sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId),
|
||||
['id_structure' => $structureId !== null ? (int) $structureId : null]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $structureId === null
|
||||
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
|
||||
@@ -433,19 +338,6 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/create_agent.php — création d'un agent.
|
||||
*
|
||||
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
|
||||
* poste existant en base, type de contrat dans l'énumération, dates
|
||||
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
|
||||
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
|
||||
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
|
||||
* avec l'existant, pas une saisie malformée).
|
||||
*
|
||||
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
|
||||
* l'insère dans sa liste sans avoir à recharger toute la page.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -485,15 +377,8 @@ final class AgentController
|
||||
if ($rate === false || $rate <= 0 || $rate > 100) {
|
||||
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
|
||||
}
|
||||
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
|
||||
// Les règles de forme vivent dans ContactFormat, communes à create() et
|
||||
// update(), et recopiées dans assets/js/field-format.js pour le retour
|
||||
// immédiat à la saisie.
|
||||
if ($email !== '' && !ContactFormat::isEmail($email)) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
|
||||
}
|
||||
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
|
||||
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
|
||||
}
|
||||
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) {
|
||||
@@ -526,10 +411,6 @@ final class AgentController
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Seul endroit du projet qui utilise correctement
|
||||
// Security::debugEnabled() (fermé par défaut). Les autres blocs
|
||||
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
|
||||
// OUVERT — voir la note dans PlanningController::save().
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de créer l’agent.',
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
@@ -537,14 +418,6 @@ final class AgentController
|
||||
}
|
||||
|
||||
$structure = $this->structureModel->findActive((int) $structureId);
|
||||
Journal::ecriture(
|
||||
'AGENT_CREE',
|
||||
'agent',
|
||||
isset($created['id_agent']) ? (int) $created['id_agent'] : null,
|
||||
sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule),
|
||||
['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType]
|
||||
);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
|
||||
'agent' => array_merge($created, [
|
||||
@@ -571,18 +444,6 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_agent.php — modification de la fiche d'un agent.
|
||||
*
|
||||
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
|
||||
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
|
||||
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
|
||||
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
|
||||
*
|
||||
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
|
||||
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
|
||||
* rétroactivement tous les suivis.
|
||||
*/
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -591,8 +452,6 @@ final class AgentController
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$nom = trim((string) ($payload['nom'] ?? ''));
|
||||
$prenom = trim((string) ($payload['prenom'] ?? ''));
|
||||
$email = trim((string) ($payload['email'] ?? ''));
|
||||
$telephone = trim((string) ($payload['telephone'] ?? ''));
|
||||
$adresse = trim((string) ($payload['adresse'] ?? ''));
|
||||
@@ -617,68 +476,27 @@ final class AgentController
|
||||
if (!$agentId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide.']);
|
||||
}
|
||||
// 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) {
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
|
||||
}
|
||||
if ($nom === '' || $prenom === '') {
|
||||
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
|
||||
}
|
||||
if (!$postId || !in_array((int) $postId, $validPostIds, true)
|
||||
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|
||||
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|
||||
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
|
||||
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
|
||||
}
|
||||
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
|
||||
// Les règles de forme vivent dans ContactFormat, communes à create() et
|
||||
// update(), et recopiées dans assets/js/field-format.js pour le retour
|
||||
// immédiat à la saisie.
|
||||
if ($email !== '' && !ContactFormat::isEmail($email)) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
|
||||
}
|
||||
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) {
|
||||
if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
|
||||
}
|
||||
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
|
||||
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
|
||||
}
|
||||
|
||||
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
|
||||
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
|
||||
// contrat sous des affectations existantes. Le refus nomme la période
|
||||
// concernée, pour que l'utilisateur sache quoi retirer avant de
|
||||
// recommencer.
|
||||
$orphelins = $this->agentModel->entriesOutsideContract(
|
||||
(int) $agentId,
|
||||
$contractStart,
|
||||
$contractEnd !== '' ? $contractEnd : null
|
||||
);
|
||||
if ($orphelins['nombre'] > 0) {
|
||||
$lisible = static fn(?string $date): string => $date === null
|
||||
? '—'
|
||||
: implode('/', array_reverse(explode('-', $date)));
|
||||
JsonResponse::send(422, [
|
||||
'error' => sprintf(
|
||||
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
|
||||
$orphelins['nombre'],
|
||||
$lisible($orphelins['premiere']),
|
||||
$lisible($orphelins['derniere'])
|
||||
),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->agentModel->updateProfile((int) $agentId, [
|
||||
'nom' => $nom,
|
||||
'prenom' => $prenom,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'telephone' => $telephone !== '' ? $telephone : null,
|
||||
'adresse' => $adresse !== '' ? $adresse : null,
|
||||
@@ -699,42 +517,12 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
// La fiche est relue une seule fois : elle sert à la fois au libellé de
|
||||
// la trace et à la réponse.
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
|
||||
$identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']);
|
||||
$identiteApres = trim($prenom . ' ' . $nom);
|
||||
$renomme = $identiteAvant !== $identiteApres;
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$renomme
|
||||
// Un renommage doit se lire dans le journal sans avoir à
|
||||
// comparer deux lignes : l'ancienne identité y figure en clair.
|
||||
? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant)
|
||||
: sprintf('Modification de la fiche de l’agent %s.', $identiteApres),
|
||||
[
|
||||
'id_structure' => $structureId !== null ? (int) $structureId : null,
|
||||
'type_contrat' => $contractType,
|
||||
'identite_precedente' => $renomme ? $identiteAvant : null,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les informations de l’agent ont été mises à jour.',
|
||||
'agent' => $agent,
|
||||
'agent' => $this->agentModel->findActive((int) $agentId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
|
||||
* pas de base commune ; toute correction doit être reportée partout.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AnnualOverviewModel;
|
||||
@@ -15,28 +14,12 @@ use App\Models\QuotaModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Vue annuelle d'un agent et validation de l'année en une opération.
|
||||
*
|
||||
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
|
||||
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
|
||||
* s'apprécie le quota annualisé (voir QuotaModel).
|
||||
*
|
||||
* Réservé au Service Enfance : valider une année couvre par nature tous les
|
||||
* lieux où l'agent est intervenu.
|
||||
*/
|
||||
final class AnnualOverviewController
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/annual_overview.php — répartition de l'année d'un agent.
|
||||
*
|
||||
* L'année est facultative et retombe sur l'année en cours.
|
||||
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
(new Access($this->pdo))->requireService();
|
||||
@@ -59,27 +42,12 @@ final class AnnualOverviewController
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/annual_validate.php — contrôle puis validation de toute l'année.
|
||||
*
|
||||
* Contrôle puis validation en une seule opération de tous les plannings
|
||||
* en brouillon de l'agent pour l'année civile affichée.
|
||||
*
|
||||
* Aucune validation partielle : si une erreur est trouvée, aucun planning
|
||||
* n'est validé. Les semaines concernées sont retournées au front afin de
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger. 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.
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger.
|
||||
*/
|
||||
public function validateYear(): void
|
||||
{
|
||||
@@ -165,19 +133,6 @@ final class AnnualOverviewController
|
||||
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
|
||||
$validatedCount = $planningModel->markValidatedMany($ids);
|
||||
|
||||
Journal::ecriture(
|
||||
'ANNEE_VALIDEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'annee' => (int) $year,
|
||||
'id_plannings' => $ids,
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -198,24 +153,6 @@ final class AnnualOverviewController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôles bloquants d'un planning, dans la variante « année ».
|
||||
*
|
||||
* DIFFÉRENCE VOULUE avec PlanningController::validate() : les chevauchements
|
||||
* inter-lieux sont recherchés via externalOverlaps() (tous statuts) et non
|
||||
* validatedExternalOverlaps() (validés seulement). Une validation annuelle
|
||||
* traite en un bloc l'ensemble des brouillons de l'agent : ils vont tous
|
||||
* devenir opposables, deux brouillons qui se recouvrent doivent donc être
|
||||
* signalés dès maintenant.
|
||||
*
|
||||
* Un contrôle INFO est ajouté quand rien n'a été trouvé, pour que
|
||||
* l'interface affiche « aucune anomalie » plutôt qu'une liste vide
|
||||
* indistincte d'une absence de contrôle.
|
||||
*
|
||||
* @param array<string, mixed> $planning
|
||||
*
|
||||
* @return array<int, array{niveau: string, code: string, message: string}>
|
||||
*/
|
||||
private function controlsForPlanning(PlanningModel $model, array $planning): array
|
||||
{
|
||||
$planningId = (int) $planning['id_planning'];
|
||||
@@ -225,19 +162,6 @@ final class AnnualOverviewController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideContract($planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
@@ -279,13 +203,6 @@ final class AnnualOverviewController
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empile un contrôle au format commun {niveau, code, message}.
|
||||
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
|
||||
* pas de base commune.
|
||||
*
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
*/
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CoverageModel;
|
||||
@@ -14,19 +13,6 @@ use App\Models\StructureModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contrôle de couverture : effectif planifié comparé au minimum attendu.
|
||||
*
|
||||
* Chaque lieu peut déclarer des règles de couverture (jour, plage horaire,
|
||||
* nombre minimum d'agents). Ce contrôleur confronte ces règles aux plannings
|
||||
* réels et remonte les « trous ».
|
||||
*
|
||||
* Deux niveaux de droits distincts :
|
||||
* - alerts() : responsable et Service Enfance, un responsable étant forcé sur
|
||||
* son propre lieu quoi qu'il demande ;
|
||||
* - rules() / saveRules() : Service Enfance seul, les règles étant un
|
||||
* référentiel et non une donnée de saisie.
|
||||
*/
|
||||
final class CoverageController
|
||||
{
|
||||
private CoverageModel $coverageModel;
|
||||
@@ -40,22 +26,6 @@ final class CoverageController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/coverage_alerts.php — trous de couverture d'une semaine.
|
||||
*
|
||||
* Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé
|
||||
* est ignoré pour un responsable, écrasé par son propre lieu : un simple 403
|
||||
* l'empêcherait de consulter son écran avec une URL partagée.
|
||||
*
|
||||
* Trois familles de retours, à ne pas confondre :
|
||||
* - `alerts` : plages réellement sous-couvertes ;
|
||||
* - `validation_warnings` : couverture assurée par des plannings encore en
|
||||
* brouillon, donc pas encore fiable ;
|
||||
* - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas
|
||||
* produire d'alerte, et les taire les ferait passer pour conformes.
|
||||
*
|
||||
* Paramètres GET : week, structure_id (facultatif).
|
||||
*/
|
||||
public function alerts(): void
|
||||
{
|
||||
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
|
||||
@@ -130,9 +100,6 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_coverage_rules.php — règles de couverture d'un lieu.
|
||||
*/
|
||||
public function rules(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -152,19 +119,6 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_structure_coverage_rules.php — remplace les règles d'un lieu.
|
||||
*
|
||||
* Remplacement INTÉGRAL : la liste reçue devient l'unique jeu de règles du
|
||||
* lieu, et une liste vide supprime toute règle (le lieu redevient « non
|
||||
* configuré »). Il n'y a pas d'ajout ni de suppression unitaire.
|
||||
*
|
||||
* Les règles sont validées ici — jour ouvré, horaires au quart d'heure, fin
|
||||
* après début, minimum dans [1, 50], puis absence de chevauchement — avant
|
||||
* d'être confiées au modèle, qui n'effectue aucun contrôle métier.
|
||||
*
|
||||
* Charge utile : structure_id, rules[], csrf_token.
|
||||
*/
|
||||
public function saveRules(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -222,29 +176,12 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'COUVERTURE_ENREGISTREE',
|
||||
'structure_couverture_horaire',
|
||||
(int) $structureId,
|
||||
sprintf('Horaires de couverture du lieu #%d enregistrés : %d plage(s).', (int) $structureId, count($rules)),
|
||||
['id_structure' => (int) $structureId, 'nombre_plages' => count($rules)]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les horaires de couverture du lieu ont été enregistrés.',
|
||||
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse deux plages de couverture qui se recouvrent le même jour.
|
||||
*
|
||||
* Les règles sont regroupées par jour puis triées : il suffit alors de
|
||||
* comparer chaque début à la fin précédente, en O(n log n) et non en O(n²).
|
||||
* Des plages jointives sont acceptées (une fin égale à un début).
|
||||
*
|
||||
* @param array<int, array{jour_semaine: int, heure_debut: string, heure_fin: string}> $rules
|
||||
*/
|
||||
private function assertNoOverlap(array $rules): void
|
||||
{
|
||||
$byDay = [];
|
||||
@@ -266,7 +203,6 @@ final class CoverageController
|
||||
}
|
||||
}
|
||||
|
||||
/** HH:MM au quart d'heure, entre 00:00 et 23:45. */
|
||||
private function validQuarterHour(string $time): bool
|
||||
{
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
|
||||
@@ -275,24 +211,17 @@ final class CoverageController
|
||||
return true;
|
||||
}
|
||||
|
||||
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
|
||||
private function timeToMinutes(string $time): int
|
||||
{
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
/** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */
|
||||
private function durationMinutes(string $start, string $end): int
|
||||
{
|
||||
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -25,8 +25,6 @@ use PDO;
|
||||
*/
|
||||
final class DashboardController
|
||||
{
|
||||
/** Nombre d'éléments détaillés par bloc : un aperçu, pas une liste complète.
|
||||
* Chaque bloc renvoie vers l'écran dédié pour la suite. */
|
||||
private const APERCU_MAX = 5;
|
||||
private const QUOTA_APERCU_MAX = 8;
|
||||
|
||||
@@ -47,19 +45,6 @@ final class DashboardController
|
||||
$this->vacationModel = new VacationModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dashboard_summary.php — données du tableau de bord.
|
||||
*
|
||||
* Deux portées :
|
||||
* - scope=badges : uniquement le compteur de plannings en attente. Cette
|
||||
* variante est appelée depuis TOUTES les pages pour alimenter la pastille
|
||||
* de navigation ; elle doit rester à une seule requête SQL ;
|
||||
* - portée complète : les quatre blocs de l'écran, ce qui représente
|
||||
* plusieurs dizaines de requêtes et n'est acceptable que sur le tableau
|
||||
* de bord lui-même.
|
||||
*
|
||||
* Paramètres GET : scope (badges|absent), week, year (facultatifs).
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -279,12 +264,6 @@ final class DashboardController
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante.
|
||||
*
|
||||
* Contrairement aux endpoints de saisie, une semaine illisible n'est pas une
|
||||
* erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer
|
||||
* la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC.
|
||||
*
|
||||
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
|
||||
*/
|
||||
private function resolveWeek(mixed $value): array
|
||||
@@ -315,9 +294,6 @@ final class DashboardController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée.
|
||||
*/
|
||||
private function resolveYear(mixed $value, int $fallback): int
|
||||
{
|
||||
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
|
||||
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
|
||||
* acces_role_page ne lui ouvre qu'une seule page.
|
||||
*
|
||||
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
|
||||
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
|
||||
* (création, régénération, génération en lot). Aucune lecture ne le
|
||||
* retourne, parce que la base ne l'a plus ;
|
||||
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
|
||||
* purge du journal — et sans jamais recopier le jeton dans la trace.
|
||||
*/
|
||||
final class DsiController
|
||||
{
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
private JournalModel $journal;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
$this->journal = new JournalModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_comptes.php — comptes d'accès et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role, actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
// Tant qu'un compte porte encore le jeton public de la migration,
|
||||
// l'interface doit le crier : c'est une porte ouverte documentée.
|
||||
'jeton_installation_actif' => $this->comptes->installationEnCours(),
|
||||
'roles' => $this->rolesDisponibles(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$libelle = trim((string) ($payload['libelle'] ?? ''));
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
$libelle,
|
||||
$commentaire !== '' ? $commentaire : null
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $resultat['compte'];
|
||||
Journal::administration('COMPTE_CREE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => (int) $compte['id_compte'],
|
||||
'description' => sprintf(
|
||||
'Création du compte d’accès « %s » (%s).',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
|
||||
'compte' => $compte,
|
||||
'jeton' => $resultat['jeton'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_jeton.php — régénère le jeton d'un compte.
|
||||
*
|
||||
* L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à
|
||||
* suivre pour un jeton perdu comme pour un jeton compromis.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouver($compteId) ?? [];
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » : l’ancien jeton est révoqué.',
|
||||
(string) ($compte['identite_libelle'] ?? 'compte inconnu')
|
||||
),
|
||||
'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $compte,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte
|
||||
* privilégié.
|
||||
*
|
||||
* La DSI ne REMPLACE pas le mot de passe, elle le RETIRE : le titulaire en
|
||||
* choisira un nouveau à sa prochaine connexion, avec son jeton. C'est ce qui
|
||||
* garantit que personne d'autre que lui ne l'a jamais connu — y compris la
|
||||
* personne qui vient de le réinitialiser.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$compte = $this->comptes->reinitialiserMotDePasse($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('MOT_DE_PASSE_REINITIALISE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf(
|
||||
'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_statut.php — active, désactive ou supprime un compte.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL).
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
|
||||
|
||||
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Action inconnue.']);
|
||||
}
|
||||
|
||||
// La fiche est lue AVANT l'opération : après une suppression, il n'y
|
||||
// aurait plus de libellé à écrire dans le journal.
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se retirer soi-même son propre accès en pleine session est une erreur
|
||||
// de manipulation, jamais une intention : on la refuse.
|
||||
if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') {
|
||||
JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($action === 'SUPPRIMER') {
|
||||
$this->comptes->supprimer($compteId);
|
||||
} else {
|
||||
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
|
||||
}
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$libelles = [
|
||||
'ACTIVER' => 'Réactivation',
|
||||
'DESACTIVER' => 'Désactivation',
|
||||
'SUPPRIMER' => 'Suppression',
|
||||
];
|
||||
|
||||
Journal::administration('COMPTE_' . $action, [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'%s du compte d’accès « %s » (%s).',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités
|
||||
* actives sans compte.
|
||||
*
|
||||
* C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni
|
||||
* responsable n'a de jeton, donc personne ne peut entrer. La réponse porte
|
||||
* tous les jetons produits, en clair — c'est la seule occasion de les lire,
|
||||
* et elle est faite pour être imprimée ou copiée avant de refermer l'écran.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
try {
|
||||
$produits = $this->comptes->genererManquants();
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('JETONS_GENERES_EN_LOT', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Génération de %d jeton(s) pour les identités sans compte.', count($produits)),
|
||||
'contexte' => ['nombre' => count($produits)],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Toutes les identités actives disposent déjà d’un compte.'
|
||||
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
|
||||
'jetons' => $produits,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_journal.php — recherche paginée dans le journal.
|
||||
*
|
||||
* Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page,
|
||||
* taille.
|
||||
*/
|
||||
public function journal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$resultat = $this->journal->rechercher([
|
||||
'categorie' => (string) ($_GET['categorie'] ?? ''),
|
||||
'resultat' => (string) ($_GET['resultat'] ?? ''),
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'id_compte' => (int) ($_GET['id_compte'] ?? 0),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
'du' => (string) ($_GET['du'] ?? ''),
|
||||
'au' => (string) ($_GET['au'] ?? ''),
|
||||
'page' => (int) ($_GET['page'] ?? 1),
|
||||
'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, array_merge($resultat, [
|
||||
'statistiques' => $this->journal->statistiques(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes.
|
||||
*
|
||||
* La purge est elle-même journalisée, et l'est APRÈS coup : sa propre trace
|
||||
* ne peut donc pas être emportée par la purge qu'elle décrit.
|
||||
*
|
||||
* Charge utile : jours (minimum 30), csrf_token.
|
||||
*/
|
||||
public function purgerJournal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
if ($jours < 30) {
|
||||
JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']);
|
||||
}
|
||||
|
||||
$supprimees = $this->journal->purger($jours);
|
||||
|
||||
Journal::administration('JOURNAL_PURGE', [
|
||||
'cible_table' => 'journal_activite',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours),
|
||||
'contexte' => ['jours' => $jours, 'supprimees' => $supprimees],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees),
|
||||
'supprimees' => $supprimees,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création d'un compte, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDisponibles(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'],
|
||||
['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,27 +15,6 @@ use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Rendu de toutes les pages HTML de l'application.
|
||||
*
|
||||
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
|
||||
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
|
||||
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
|
||||
* de $activePage qui surligne l'onglet courant.
|
||||
*
|
||||
* Ajouter une page suppose donc, ensemble :
|
||||
* 1. un fichier mince à la racine ;
|
||||
* 2. une méthode ici ;
|
||||
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
|
||||
* 4. un module JS ;
|
||||
* 5. l'inscription de la clé dans $navigation / $navigationGroups
|
||||
* (Views/layouts/header.php) ;
|
||||
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
|
||||
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
|
||||
*
|
||||
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
|
||||
* injecte et page-bootstrap.js appelle init() sur chaque module.
|
||||
*/
|
||||
final class PageController extends Controller
|
||||
{
|
||||
private Access $access;
|
||||
@@ -45,10 +24,6 @@ final class PageController extends Controller
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
|
||||
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
|
||||
*/
|
||||
public function dashboard(): void
|
||||
{
|
||||
$this->access->requirePage('dashboard');
|
||||
@@ -63,20 +38,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de saisie — cœur de l'application.
|
||||
*
|
||||
* Le lieu est VERROUILLÉ pour un responsable de structure
|
||||
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
|
||||
* qu'un confort d'interface, le serveur retranche de toute façon.
|
||||
*
|
||||
* Les scripts de semaine type ne sont chargés que si la permission
|
||||
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
|
||||
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
|
||||
* (éditeur de semaine type / saisie semaine par semaine, voir
|
||||
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
|
||||
* les paramètres des liens profonds venus des autres écrans.
|
||||
*/
|
||||
public function planning(): void
|
||||
{
|
||||
$this->access->requirePage('planning');
|
||||
@@ -86,14 +47,10 @@ final class PageController extends Controller
|
||||
$modules = ['planning'];
|
||||
if (!empty($permissions['can_use_templates'])) {
|
||||
$scripts[] = 'week-templates.js';
|
||||
$scripts[] = 'week-template-editor.js';
|
||||
$modules[] = 'weekTemplates';
|
||||
$modules[] = 'weekTemplateEditor';
|
||||
}
|
||||
$this->renderPage('planning', 'Saisie du planning', 'planning', [
|
||||
'pageSubtitle' => !empty($permissions['can_use_templates'])
|
||||
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
|
||||
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||
'pageSubtitle' => 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||
'structures' => $this->loadStructures(),
|
||||
'motifs' => $this->loadMotifs(),
|
||||
'currentWeek' => $this->currentWeek(),
|
||||
@@ -110,7 +67,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** File des plannings en attente de validation (Service Enfance). */
|
||||
public function pending(): void
|
||||
{
|
||||
$this->access->requirePage('pending');
|
||||
@@ -121,7 +77,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
|
||||
public function coverage(): void
|
||||
{
|
||||
$this->access->requirePage('coverage');
|
||||
@@ -139,7 +94,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Planning d'un lieu sur une semaine, en lecture seule. */
|
||||
public function structures(): void
|
||||
{
|
||||
$this->access->requirePage('structures');
|
||||
@@ -157,12 +111,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning d'un agent, mois par mois et tous lieux confondus.
|
||||
*
|
||||
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
|
||||
* sur « Mon planning » : la page sert alors de vue personnelle.
|
||||
*/
|
||||
public function agents(): void
|
||||
{
|
||||
$this->access->requirePage('agents');
|
||||
@@ -184,7 +132,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
|
||||
public function annual(): void
|
||||
{
|
||||
$this->access->requirePage('annual');
|
||||
@@ -200,12 +147,11 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
|
||||
public function pta(): void
|
||||
{
|
||||
$this->access->requirePage('pta');
|
||||
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [
|
||||
'pageSubtitle' => 'Le PTA détaillé d\'un agent : souhaits de lieux, contraintes et répartition des heures.',
|
||||
'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.',
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['pta.js'],
|
||||
@@ -217,15 +163,12 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Composition des équipes sur les mercredis et les vacances. */
|
||||
public function teams(): void
|
||||
{
|
||||
$this->access->requirePage('teams');
|
||||
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
|
||||
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
|
||||
// Liste d'AFFECTATION : un agent dont l'accès a été coupé n'est plus
|
||||
// proposé à la composition d'une équipe (V7.29).
|
||||
'agents' => (new AgentModel($this->pdo))->allSelectable(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['teams.js'],
|
||||
'modules' => ['teams'],
|
||||
@@ -236,11 +179,6 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires : elles déterminent le contexte
|
||||
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
|
||||
* l'API data.education.gouv.fr (SchoolHolidayApiService).
|
||||
*/
|
||||
public function vacations(): void
|
||||
{
|
||||
$this->access->requirePage('vacations');
|
||||
@@ -251,99 +189,16 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Référentiels : agents, lieux, postes, règles de couverture. */
|
||||
public function administration(): void
|
||||
{
|
||||
$this->access->requirePage('administration');
|
||||
$this->renderPage('administration', 'Administration', 'administration', [
|
||||
'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.',
|
||||
// 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.
|
||||
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
|
||||
// Le contenu de la section vient entièrement de l'API — le HTML
|
||||
// servi ne porte aucune information sur les comptes.
|
||||
// `comptes-acces.js` porte la fabrique de l'écran des comptes,
|
||||
// partagée avec l'espace DSI : il doit précéder `access-tokens.js`,
|
||||
// qui l'appelle dès son chargement.
|
||||
'scripts' => ['coverage.js', 'comptes-acces.js', 'access-tokens.js'],
|
||||
'modules' => ['coverage', 'accessTokens'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Agents (V7.14) : création, liste et fiche des agents.
|
||||
*
|
||||
* Ce référentiel partageait l'écran « Administration » avec celui des lieux
|
||||
* et les horaires de couverture, trois objets sans rapport. Chacun a
|
||||
* désormais son onglet, réunis sous le groupe « Management » de la barre de
|
||||
* navigation.
|
||||
*
|
||||
* NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la
|
||||
* méthode EST la clé de page passée à runPage() et inscrite dans
|
||||
* acces_role_page, où les clés sont en minuscules. Les garder identiques
|
||||
* prime sur l'uniformité de style — voir l'en-tête de cette classe.
|
||||
*
|
||||
* La liste des lieux est nécessaire ici aussi : le formulaire de création
|
||||
* demande le lieu principal de l'agent.
|
||||
*/
|
||||
public function manage_agents(): void
|
||||
{
|
||||
$this->access->requirePage('manage_agents');
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
$this->renderPage('manage_agents', 'Agents', 'manage_agents', [
|
||||
'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.',
|
||||
'agents' => $agentModel->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => $agentModel->allPosts(),
|
||||
// V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe
|
||||
// ou rétablit l'accès de l'agent. Il n'ouvre aucun droit nouveau —
|
||||
// il réutilise les endpoints délégués de la V7.27.
|
||||
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js', 'agent-access.js'],
|
||||
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat', 'agentAccess'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Lieux d'affectation (V7.14) : création et paramètres.
|
||||
*
|
||||
* Les horaires de couverture d'un lieu restent sur « Administration » : ils
|
||||
* ne décrivent pas le lieu mais une exigence de présence.
|
||||
*/
|
||||
public function manage_structures(): void
|
||||
{
|
||||
$this->access->requirePage('manage_structures');
|
||||
$this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [
|
||||
'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.',
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['administration.js'],
|
||||
'modules' => ['administration'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* Page d'administration TECHNIQUE, ouverte au seul rôle DSI par
|
||||
* acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien
|
||||
* à connaître des agents ni des plannings ; les seules listes qu'elle
|
||||
* manipule (identités sans compte) sont servies par l'API.
|
||||
*/
|
||||
public function dsi(): void
|
||||
{
|
||||
$this->access->requirePage('dsi');
|
||||
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
||||
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
||||
// Même fabrique que l'onglet Administration, et même contrainte
|
||||
// d'ordre : elle doit être chargée avant `dsi.js`.
|
||||
'scripts' => ['comptes-acces.js', 'dsi.js'],
|
||||
'modules' => ['dsi'],
|
||||
'pageConfig' => [
|
||||
// Lien profond « journal de ce compte » : filtre d'emblée le
|
||||
// journal sur le compte désigné.
|
||||
'compteId' => $this->positiveInt($_GET['compte_id'] ?? null),
|
||||
],
|
||||
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
||||
'scripts' => ['administration.js', 'coverage.js'],
|
||||
'modules' => ['administration', 'coverage'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -356,26 +211,11 @@ final class PageController extends Controller
|
||||
public function faq(): void
|
||||
{
|
||||
$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.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu.
|
||||
*
|
||||
* Pose les clés attendues par header.php et footer.php avec des valeurs par
|
||||
* défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra
|
||||
* donc correctement au lieu de lever une notice sur variable indéfinie.
|
||||
*
|
||||
* Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la
|
||||
* CSP à nonce, et $cspNonce est ce que chaque <script> inline d'une vue doit
|
||||
* porter pour être exécuté.
|
||||
*
|
||||
* @param string $view Vue de pages/, sans extension.
|
||||
* @param string $activePage Clé de l'onglet à surligner.
|
||||
* @param array<string, mixed> $data Surcharges spécifiques à la page.
|
||||
*/
|
||||
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
|
||||
{
|
||||
Security::sendHtmlHeaders();
|
||||
@@ -399,15 +239,6 @@ final class PageController extends Controller
|
||||
], $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
|
||||
*
|
||||
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
|
||||
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
|
||||
* inaffichable dans la liste déroulante.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructures(): array
|
||||
{
|
||||
$model = new StructureModel($this->pdo);
|
||||
@@ -422,15 +253,6 @@ final class PageController extends Controller
|
||||
return $structure ? [$structure] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante pour l'éditeur de la fiche agent.
|
||||
*
|
||||
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
|
||||
* Enfance a donc besoin de la liste complète même s'il consulte un agent
|
||||
* rattaché ailleurs.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructuresForAgentEditor(): array
|
||||
{
|
||||
if ($this->access->role() === Access::ROLE_SERVICE) {
|
||||
@@ -439,13 +261,6 @@ final class PageController extends Controller
|
||||
return $this->loadStructures();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
|
||||
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
|
||||
* inclut les agents rattachés ailleurs mais planifiés sur place.)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadAgents(): array
|
||||
{
|
||||
$model = new AgentModel($this->pdo);
|
||||
@@ -460,53 +275,28 @@ final class PageController extends Controller
|
||||
return $structureId ? $model->allVisibleForStructure($structureId) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
|
||||
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
|
||||
* refuse (PlanningController::assertMotifsAllowedForAgent()).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadMotifs(): array
|
||||
{
|
||||
return (new MotifModel($this->pdo))->allActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Semaine courante au format « 2026-W08 ».
|
||||
*
|
||||
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
|
||||
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
|
||||
* quand la semaine ISO appartient à l'année civile voisine.
|
||||
* Fuseau explicite : le conteneur PHP tourne en UTC.
|
||||
*/
|
||||
private function currentWeek(): string
|
||||
{
|
||||
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Assainissement des paramètres d'URL. Ces valeurs partent dans
|
||||
// window.PTA_CONFIG et pilotent les filtres appliqués par
|
||||
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
|
||||
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Identifiant d'entité : strictement positif, ou null. */
|
||||
private function positiveInt(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
|
||||
private function weekValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function dateValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
@@ -514,14 +304,12 @@ final class PageController extends Controller
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : null;
|
||||
}
|
||||
|
||||
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
|
||||
private function yearValue(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
|
||||
private function monthValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\MotifCategories;
|
||||
use App\Core\Request;
|
||||
@@ -21,26 +20,6 @@ use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Saisie, contrôle et validation des plannings hebdomadaires.
|
||||
*
|
||||
* C'est le contrôleur central de l'application : un `planning` = un agent × une
|
||||
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
|
||||
* `motif_planning`.
|
||||
*
|
||||
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
|
||||
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
|
||||
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
|
||||
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
|
||||
* le SQL correspondant est du patrimoine, pas la source de vérité.
|
||||
*
|
||||
* Forme commune des contrôles renvoyés au client, partout dans l'application :
|
||||
* {niveau: ERREUR|ALERTE|INFO, code, message}
|
||||
* — ERREUR bloque (HTTP 422) ;
|
||||
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
|
||||
* force_quota: true) ;
|
||||
* — INFO est purement informatif.
|
||||
*/
|
||||
final class PlanningController
|
||||
{
|
||||
private PlanningModel $planningModel;
|
||||
@@ -51,10 +30,6 @@ final class PlanningController
|
||||
private VacationModel $vacationModel;
|
||||
private Access $access;
|
||||
|
||||
/**
|
||||
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
|
||||
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
|
||||
*/
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->planningModel = new PlanningModel($pdo);
|
||||
@@ -66,16 +41,6 @@ final class PlanningController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/planning.php — état complet d'une semaine de saisie.
|
||||
*
|
||||
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
|
||||
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
|
||||
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
|
||||
* lecture seule pour rendre les chevauchements visibles avant l'échec.
|
||||
*
|
||||
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -114,23 +79,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST save_planning.php — enregistre le brouillon d'une semaine.
|
||||
*
|
||||
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
|
||||
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
|
||||
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
|
||||
*
|
||||
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
|
||||
* explicite au plus générique :
|
||||
* 1. droits et périmètre ;
|
||||
* 2. motifs fermés à l'agent (message nommant le motif) ;
|
||||
* 3. normalisation/validation ligne à ligne ;
|
||||
* 4. chevauchements internes à la saisie ;
|
||||
* 5. chevauchements avec les autres lieux.
|
||||
*
|
||||
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -165,7 +113,6 @@ final class PlanningController
|
||||
// affectable à un agent sans droit à l'enveloppe de formation.
|
||||
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
|
||||
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
|
||||
$this->assertDatesWithinContract($agent, array_column($entries, 'date'));
|
||||
$this->assertNoInternalOverlap($entries);
|
||||
$this->assertNoOtherLocationOverlap(
|
||||
$entries,
|
||||
@@ -183,26 +130,12 @@ final class PlanningController
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
|
||||
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
|
||||
// référence documentée — vaut « inactif » par défaut. Le détail
|
||||
// technique de l'exception peut donc remonter au navigateur sur une
|
||||
// installation qui ne définit pas la variable. Le motif est répété
|
||||
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Erreur lors de l’enregistrement du planning.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ENREGISTRE',
|
||||
'planning',
|
||||
$planningId,
|
||||
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'planning_id' => $planningId,
|
||||
@@ -214,17 +147,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
|
||||
*
|
||||
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
|
||||
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
|
||||
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
|
||||
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
|
||||
* validation intacte.
|
||||
*
|
||||
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
|
||||
*/
|
||||
public function updateAgentEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -261,20 +183,13 @@ final class PlanningController
|
||||
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
|
||||
}
|
||||
|
||||
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
|
||||
// sans lui, l'heure courante serait injectée et la comparaison de
|
||||
// contrôle ci-dessous laisserait passer des dates approximatives.
|
||||
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
|
||||
// La saisie est contrainte au quart d'heure : c'est la granularité de
|
||||
// toute l'application (éditeur, cumuls, PDF).
|
||||
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
|
||||
|
||||
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
|
||||
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
|
||||
}
|
||||
$this->assertDatesWithinContract($entryAgent, [$date]);
|
||||
|
||||
try {
|
||||
$result = $this->planningModel->updateEntryFromAgentView(
|
||||
@@ -295,14 +210,6 @@ final class PlanningController
|
||||
}
|
||||
|
||||
$reopened = count($result['reopened_planning_ids']) > 0;
|
||||
Journal::ecriture(
|
||||
'CRENEAU_MODIFIE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $reopened
|
||||
@@ -314,16 +221,6 @@ final class PlanningController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
|
||||
*
|
||||
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
|
||||
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
|
||||
* l'interface de le signaler, mais l'enregistrement reste et conserve son
|
||||
* historique.
|
||||
*
|
||||
* Charge utile : entry_id, csrf_token.
|
||||
*/
|
||||
public function deleteAgentEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -356,14 +253,6 @@ final class PlanningController
|
||||
$message .= ' Le planning validé concerné a été repassé en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'CRENEAU_SUPPRIME',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -374,16 +263,6 @@ final class PlanningController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
|
||||
*
|
||||
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
|
||||
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
|
||||
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
|
||||
* l'indique sans alarmer.
|
||||
*
|
||||
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
|
||||
*/
|
||||
public function moveOrDuplicateEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -410,12 +289,6 @@ final class PlanningController
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5) {
|
||||
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
|
||||
}
|
||||
|
||||
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
|
||||
if ($entryAgent === null) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||||
}
|
||||
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
|
||||
try {
|
||||
$result = $this->planningModel->moveOrDuplicateEntry(
|
||||
(int) $entryId,
|
||||
@@ -443,14 +316,6 @@ final class PlanningController
|
||||
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'action' => $result['action'],
|
||||
@@ -460,18 +325,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
|
||||
*
|
||||
* Réservé au Service Enfance (requireService) et non aux responsables : la
|
||||
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
|
||||
* déborde donc par nature du périmètre d'un responsable de structure.
|
||||
*
|
||||
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
|
||||
* `replace` les remplace.
|
||||
*
|
||||
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
|
||||
*/
|
||||
public function copyAgentWeek(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -489,15 +342,9 @@ final class PlanningController
|
||||
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
|
||||
}
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
// La copie ne connaît pas d'avance les jours réellement recopiés : on
|
||||
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
|
||||
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
|
||||
// entièrement hors contrat.
|
||||
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
|
||||
|
||||
try {
|
||||
$result = $this->planningModel->copyAgentWeek(
|
||||
@@ -526,25 +373,6 @@ final class PlanningController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_COPIEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
|
||||
(int) $result['copied_entries'],
|
||||
$sourceWeekValue,
|
||||
$targetWeekValue,
|
||||
(int) $agentId
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'semaine_source' => $sourceWeekValue,
|
||||
'semaine_cible' => $targetWeekValue,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -556,14 +384,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/pending_plannings.php — file d'attente de validation.
|
||||
*
|
||||
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
|
||||
* (`total_minutes`) des minutes réellement décomptées du quota
|
||||
* (`counted_minutes`) : congés et absences non décomptées figurent au
|
||||
* planning sans peser sur le quota annuel.
|
||||
*/
|
||||
public function pendingValidation(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -587,35 +407,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST validate_planning.php — contrôle puis valide un planning.
|
||||
*
|
||||
* C'est ICI que vivent les règles de validation réellement appliquées (et
|
||||
* non dans sp_valider_planning côté SQL, jamais appelée).
|
||||
*
|
||||
* Quatre contrôles bloquants (ERREUR → 422, aucune validation) :
|
||||
* - AUCUN_CRENEAU : planning vide ;
|
||||
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
|
||||
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
|
||||
* - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou
|
||||
* postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en
|
||||
* créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat
|
||||
* raccourci a rendues illégitimes ;
|
||||
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
|
||||
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
|
||||
* un brouillon concurrent peut encore être corrigé, le bloquer
|
||||
* empêcherait toute validation dans un ordre donné.
|
||||
*
|
||||
* Un contrôle non bloquant (ALERTE → 409) :
|
||||
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
|
||||
* possible, mais exige un rappel explicite avec force_quota: true. Le
|
||||
* dépassement est une décision de gestion, pas une anomalie technique.
|
||||
*
|
||||
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
|
||||
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
|
||||
*
|
||||
* Charge utile : planning_id, force_quota (bool), csrf_token.
|
||||
*/
|
||||
public function validate(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -639,19 +430,6 @@ final class PlanningController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
@@ -726,25 +504,6 @@ final class PlanningController
|
||||
$this->planningModel->markValidated((int) $planningId);
|
||||
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
|
||||
|
||||
// Journalisé APRÈS le passage en base : une validation refusée par les
|
||||
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
|
||||
Journal::ecriture(
|
||||
'PLANNING_VALIDE',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning validé pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $planning['id_agent'],
|
||||
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
|
||||
'date_debut' => (string) $planning['date_debut'],
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -754,17 +513,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
|
||||
*
|
||||
* Réouverture volontaire, à distinguer de la réouverture automatique
|
||||
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
|
||||
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
|
||||
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
|
||||
* trompeur.
|
||||
*
|
||||
* Charge utile : planning_id, csrf_token.
|
||||
*/
|
||||
public function reopen(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -789,18 +537,6 @@ final class PlanningController
|
||||
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ROUVERT',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'BROUILLON',
|
||||
@@ -809,14 +545,6 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
|
||||
*
|
||||
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
|
||||
* comptent bien une semaine 53.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
@@ -874,22 +602,6 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et met en forme les créneaux reçus du client.
|
||||
*
|
||||
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
|
||||
* motif appartenant à la liste ouverte à CET agent, horaires au quart
|
||||
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
|
||||
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
|
||||
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
|
||||
*
|
||||
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
|
||||
*
|
||||
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
|
||||
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
|
||||
*
|
||||
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
|
||||
*/
|
||||
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
@@ -924,15 +636,6 @@ final class PlanningController
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
|
||||
*
|
||||
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
|
||||
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
|
||||
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
|
||||
*
|
||||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||||
*/
|
||||
private function assertNoInternalOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
@@ -956,20 +659,6 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
|
||||
*
|
||||
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
|
||||
* un agent ne peut pas être à deux endroits à la fois, même en cours de
|
||||
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
|
||||
* puisse nommer le lieu et l'horaire en cause.
|
||||
*
|
||||
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
|
||||
* être comparables aux créneaux normalisés.
|
||||
*
|
||||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||||
* @param array<int, array<string, mixed>> $existingEntries
|
||||
*/
|
||||
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
|
||||
{
|
||||
foreach ($entries as $entry) {
|
||||
@@ -1000,58 +689,6 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empile un contrôle au format commun {niveau, code, message}.
|
||||
*
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de
|
||||
* contrat : le planifier avant son arrivée ou après son départ produit des
|
||||
* heures que personne ne fera, et fausse les cumuls annuels comme les
|
||||
* contrôles de couverture. La règle s'applique à TOUS les motifs, congés et
|
||||
* absences compris — hors contrat, il n'y a rien à décompter.
|
||||
*
|
||||
* Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie
|
||||
* l'ensemble des jours refusés, et non le premier.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent.
|
||||
* @param array<int, string> $dates Dates AAAA-MM-JJ visées par l'écriture.
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ.
|
||||
* Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à
|
||||
* être contrôlés, aucun créneau ne peut y être posé.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function workingDaysOfWeek(int $year, int $weekNumber): array
|
||||
{
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
|
||||
return array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
);
|
||||
}
|
||||
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AgentModel;
|
||||
@@ -16,21 +15,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* PTA détaillé d'un agent : contraintes prioritaires et souhaits de lieux.
|
||||
*
|
||||
* Ces deux référentiels décrivent ce qui DOIT ou NE DOIT PAS être planifié pour
|
||||
* un agent. Ils sont consultatifs : rien ici n'empêche techniquement une saisie
|
||||
* contraire, l'arbitrage reste à l'agent de planification.
|
||||
*
|
||||
* Les deux endpoints d'écriture sont à double action, pilotée par le champ
|
||||
* `action` de la charge utile (« SAVE » par défaut, ou « DELETE ») : c'est un
|
||||
* écart au reste de l'API, où un endpoint = une opération. La branche DELETE
|
||||
* sort par JsonResponse::send(), qui termine le script — le code de SAVE qui
|
||||
* suit n'est donc jamais atteint dans ce cas.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class PtaController
|
||||
{
|
||||
private PtaModel $ptaModel;
|
||||
@@ -46,15 +30,6 @@ final class PtaController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/pta_summary.php — synthèse du PTA d'un agent à une date donnée.
|
||||
*
|
||||
* La date sert de point d'observation : elle détermine la période de quota
|
||||
* retenue et les contraintes en vigueur ce jour-là. Elle retombe sur
|
||||
* aujourd'hui si elle n'est pas fournie.
|
||||
*
|
||||
* Paramètres GET : agent_id, date (facultative, « AAAA-MM-JJ »).
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -75,24 +50,6 @@ final class PtaController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/pta_constraint.php — enregistre ou supprime une contrainte.
|
||||
*
|
||||
* Deux types seulement, volontairement fermés : MEDICALE et
|
||||
* TEMPS_PARTIEL_THERAPEUTIQUE. Une date de fin vide signifie « sans terme
|
||||
* connu », cas courant d'un aménagement médical en cours.
|
||||
*
|
||||
* Le commentaire est OBLIGATOIRE : une contrainte restreint la planification
|
||||
* d'un agent, elle doit rester justifiable a posteriori.
|
||||
*
|
||||
* Les maximums arrivent en HEURES depuis le formulaire et sont convertis en
|
||||
* MINUTES pour la base, unité de tous les cumuls de l'application.
|
||||
*
|
||||
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
|
||||
* l'action constraint_id, ou type_contrainte / date_debut / date_fin /
|
||||
* commentaire / quotite_temporaire / maximum_heures_jour /
|
||||
* maximum_heures_semaine / interdit_matin|midi|soir.
|
||||
*/
|
||||
public function constraint(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -112,15 +69,6 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Contrainte invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_SUPPRIMEE',
|
||||
'agent_contrainte',
|
||||
(int) $constraintId,
|
||||
sprintf('Contrainte prioritaire #%d supprimée pour l’agent #%d.', (int) $constraintId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.',
|
||||
]);
|
||||
@@ -165,35 +113,12 @@ final class PtaController
|
||||
'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0,
|
||||
'commentaire' => $comment,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_ENREGISTREE',
|
||||
'agent_contrainte',
|
||||
(int) $id,
|
||||
sprintf('Contrainte « %s » enregistrée pour l’agent #%d à partir du %s.', $type, (int) $agentId, $start),
|
||||
['id_agent' => (int) $agentId, 'type_contrainte' => $type, 'date_debut' => $start, 'date_fin' => $end]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
|
||||
// navigateur SANS AUCUNE condition de debug, contrairement au reste
|
||||
// du projet. Idem dans wish(). À aligner sur
|
||||
// Security::debugEnabled() — voir la note de PlanningController::save().
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/pta_wish.php — enregistre ou supprime un souhait de lieu.
|
||||
*
|
||||
* Deux types : PREFERENCE (l'agent souhaite y être affecté) et INTERDICTION
|
||||
* (il ne doit pas l'être). La priorité, de 1 à 5, permet d'ordonner
|
||||
* plusieurs préférences entre elles ; la distance en kilomètres sert à
|
||||
* arbitrer les affectations éloignées.
|
||||
*
|
||||
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
|
||||
* l'action wish_id, ou structure_id / type_souhait / priorite /
|
||||
* distance_km / commentaire.
|
||||
*/
|
||||
public function wish(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -213,15 +138,6 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Préférence invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_SUPPRIME',
|
||||
'agent_structure_souhait',
|
||||
(int) $wishId,
|
||||
sprintf('Préférence de lieu #%d supprimée pour l’agent #%d.', (int) $wishId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.',
|
||||
]);
|
||||
@@ -250,13 +166,6 @@ final class PtaController
|
||||
'distance_km' => $distance,
|
||||
'commentaire' => $comment !== '' ? $comment : null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_ENREGISTRE',
|
||||
'agent_structure_souhait',
|
||||
(int) $id,
|
||||
sprintf('Préférence « %s » enregistrée pour l’agent #%d sur le lieu #%d.', $type, (int) $agentId, (int) $structureId),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'type_souhait' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]);
|
||||
|
||||
@@ -7,438 +7,61 @@ namespace App\Controllers;
|
||||
use App\Core\Access;
|
||||
use App\Core\Controller;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\Security;
|
||||
use App\Core\Session;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Connexion — point d'entrée de l'application (role.php).
|
||||
*
|
||||
* JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son
|
||||
* rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une
|
||||
* session ne peut naître que de la présentation d'un JETON valide, distribué par
|
||||
* la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours
|
||||
* les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur.
|
||||
*
|
||||
* La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application
|
||||
* — Service Enfance (qui valide les plannings de toute la collectivité) et DSI
|
||||
* (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets
|
||||
* sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la
|
||||
* DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire
|
||||
* à sa première connexion et n'est connu que de lui. La DSI ne peut que
|
||||
* l'effacer, jamais le lire ni le remplacer.
|
||||
*
|
||||
* La connexion se fait donc en deux étapes, matérialisées par $etape :
|
||||
* « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ;
|
||||
* « definition » — premier accès d'un compte privilégié : choisir son mot de passe ;
|
||||
* « verification » — accès suivants : le saisir ;
|
||||
* « changement » — session ouverte, le titulaire remplace son mot de passe.
|
||||
*
|
||||
* Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente
|
||||
* horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé
|
||||
* sans mot de passe ne donne accès à aucune page.
|
||||
*
|
||||
* Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT
|
||||
* tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un.
|
||||
*/
|
||||
final class RoleController extends Controller
|
||||
{
|
||||
/** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */
|
||||
private const FENETRE_MINUTES = 15;
|
||||
private const ECHECS_MAX = 10;
|
||||
|
||||
/** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */
|
||||
private const ATTENTE_KEY = 'pta_auth_attente';
|
||||
private const ATTENTE_SECONDES = 300;
|
||||
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche l'écran de connexion et traite ses chemins, faute de routeur :
|
||||
* - GET ?action=reset : déconnexion, puis retour à cet écran ;
|
||||
* - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ;
|
||||
* - GET ?token=… : première étape par lien direct ;
|
||||
* - POST etape=mot_de_passe : seconde étape (définition ou vérification) ;
|
||||
* - POST : première étape par le champ de saisie.
|
||||
*
|
||||
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
|
||||
* données arrivent en formulaire classique et un échec doit produire une page
|
||||
* HTML, pas une réponse JSON.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$access = new Access($this->pdo);
|
||||
|
||||
// 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') {
|
||||
$this->deconnecter($access);
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$notice = '';
|
||||
$etape = 'jeton';
|
||||
$methode = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
|
||||
// Changement volontaire du mot de passe : le seul chemin qui exige une
|
||||
// session DÉJÀ ouverte.
|
||||
if (($_GET['action'] ?? '') === 'mot_de_passe' || ($_POST['etape'] ?? '') === 'changement') {
|
||||
[$etape, $error, $notice] = $this->changerMotDePasse($access, $methode);
|
||||
} elseif ($methode === 'POST') {
|
||||
if (!$this->csrfValide()) {
|
||||
$error = 'La session a expiré. Rechargez la page et recommencez.';
|
||||
$etape = $this->attente() !== null ? $this->etapeAttendue() : 'jeton';
|
||||
} elseif (($_POST['etape'] ?? '') === 'mot_de_passe') {
|
||||
[$etape, $error] = $this->traiterMotDePasse($access);
|
||||
} else {
|
||||
[$etape, $error] = $this->connecter($access, (string) ($_POST['token'] ?? ''), 'FORMULAIRE');
|
||||
}
|
||||
} elseif (isset($_GET['token'])) {
|
||||
[$etape, $error] = $this->connecter($access, (string) $_GET['token'], 'LIEN');
|
||||
} elseif ($this->attente() !== null) {
|
||||
// Rechargement de la page au milieu de la connexion : on reste sur la
|
||||
// seconde étape plutôt que de renvoyer l'utilisateur à son jeton.
|
||||
$etape = $this->etapeAttendue();
|
||||
}
|
||||
|
||||
$attente = $this->attente();
|
||||
|
||||
Security::sendHtmlHeaders();
|
||||
$this->render('pages/role', [
|
||||
'pageTitle' => 'Connexion',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'error' => $error,
|
||||
'notice' => $notice,
|
||||
'etape' => $etape,
|
||||
'titulaire' => (string) ($attente['libelle'] ?? ''),
|
||||
'longueurMinimale' => CompteModel::MOT_DE_PASSE_MINIMUM,
|
||||
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
|
||||
'homeUrl' => $access->isConfigured() ? $access->homeUrl() : 'role.php',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Première étape : vérifie le jeton.
|
||||
*
|
||||
* Ne revient QUE si la connexion n'est pas allée à son terme — une connexion
|
||||
* complète redirige et termine le script.
|
||||
*
|
||||
* @param string $origine « FORMULAIRE » ou « LIEN », conservé au journal :
|
||||
* c'est ce qui permet à la DSI de voir si les liens
|
||||
* directs circulent.
|
||||
*
|
||||
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
|
||||
*/
|
||||
private function connecter(Access $access, string $jetonBrut, string $origine): array
|
||||
{
|
||||
$jeton = CompteModel::normaliser($jetonBrut);
|
||||
if ($jeton === '') {
|
||||
return ['jeton', 'Saisissez votre jeton d’accès.'];
|
||||
}
|
||||
|
||||
$blocage = $this->blocageEnCours();
|
||||
if ($blocage !== '') {
|
||||
return ['jeton', $blocage];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->authentifier($jeton);
|
||||
if ($compte === null) {
|
||||
// Ni le jeton présenté ni aucun fragment ne sont journalisés : une
|
||||
// trace du secret dans le journal en ferait un secret de moins.
|
||||
Journal::connexion('JETON_REFUSE', [
|
||||
'resultat' => 'REFUS',
|
||||
'description' => 'Jeton d’accès inconnu, révoqué ou porté par une identité inactive.',
|
||||
'contexte' => ['origine' => $origine],
|
||||
]);
|
||||
|
||||
return ['jeton', 'Ce jeton d’accès est inconnu ou n’est plus valide. Rapprochez-vous de la DSI.'];
|
||||
}
|
||||
|
||||
// Agents et responsables : le jeton suffit, la session s'ouvre ici.
|
||||
if (!CompteModel::exigeMotDePasse($compte)) {
|
||||
$this->ouvrirSession($access, $compte, ['jeton'], $origine);
|
||||
}
|
||||
|
||||
// Comptes privilégiés : on retient l'attente et on demande le second
|
||||
// facteur. Rien n'est ouvert tant qu'il n'est pas fourni.
|
||||
$this->poserAttente($compte, $origine);
|
||||
|
||||
Journal::connexion('JETON_ACCEPTE_ATTENTE_MDP', [
|
||||
'id_compte' => (int) $compte['id_compte'],
|
||||
'role' => (string) $compte['role'],
|
||||
'acteur' => (string) $compte['identite_libelle'],
|
||||
'description' => 'Jeton accepté ; mot de passe attendu pour ouvrir la session.',
|
||||
'contexte' => ['origine' => $origine],
|
||||
]);
|
||||
|
||||
return [$this->etapeAttendue(), ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconde étape : définition ou vérification du mot de passe.
|
||||
*
|
||||
* Le compte est RELU EN BASE plutôt que repris de la session : entre les deux
|
||||
* étapes, la DSI a pu le désactiver, ou l'agent qui le porte être fermé.
|
||||
*
|
||||
* @return array{0: string, 1: string} [étape à afficher, message d'erreur]
|
||||
*/
|
||||
private function traiterMotDePasse(Access $access): array
|
||||
{
|
||||
$attente = $this->attente();
|
||||
if ($attente === null) {
|
||||
return ['jeton', 'La connexion a expiré. Saisissez de nouveau votre jeton d’accès.'];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouverPourAuthentification((int) $attente['compte_id']);
|
||||
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
|
||||
$this->oublierAttente();
|
||||
|
||||
return ['jeton', 'Ce compte n’est plus accessible. Rapprochez-vous de la DSI.'];
|
||||
}
|
||||
|
||||
$motDePasse = (string) ($_POST['mot_de_passe'] ?? '');
|
||||
|
||||
// Premier accès : le titulaire choisit son mot de passe. Personne ne le
|
||||
// lui a transmis, et personne ne pourra le lire ensuite.
|
||||
if (!CompteModel::motDePasseDefini($compte)) {
|
||||
try {
|
||||
$this->comptes->definirMotDePasse(
|
||||
(int) $compte['id_compte'],
|
||||
$motDePasse,
|
||||
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
return ['definition', $e->getMessage()];
|
||||
}
|
||||
|
||||
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine'], true);
|
||||
}
|
||||
|
||||
$blocage = $this->blocageEnCours();
|
||||
if ($blocage !== '') {
|
||||
return ['verification', $blocage];
|
||||
}
|
||||
|
||||
if (!$this->comptes->verifierMotDePasse($compte, $motDePasse)) {
|
||||
Journal::connexion('MOT_DE_PASSE_REFUSE', [
|
||||
'resultat' => 'REFUS',
|
||||
'id_compte' => (int) $compte['id_compte'],
|
||||
'role' => (string) $compte['role'],
|
||||
'acteur' => (string) $compte['identite_libelle'],
|
||||
'description' => 'Mot de passe incorrect après un jeton valide.',
|
||||
]);
|
||||
|
||||
return ['verification', 'Mot de passe incorrect.'];
|
||||
}
|
||||
|
||||
$this->ouvrirSession($access, $compte, ['jeton', 'mot_de_passe'], (string) $attente['origine']);
|
||||
|
||||
return ['jeton', '']; // Inatteignable : ouvrirSession() redirige.
|
||||
}
|
||||
|
||||
/**
|
||||
* Changement de mot de passe par son titulaire, session ouverte.
|
||||
*
|
||||
* L'ancien mot de passe est exigé : sans lui, un poste laissé déverrouillé
|
||||
* suffirait à confisquer un compte.
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: string} [étape, erreur, message de succès]
|
||||
*/
|
||||
private function changerMotDePasse(Access $access, string $methode): array
|
||||
{
|
||||
if (!$access->isConfigured()) {
|
||||
return ['jeton', 'Connectez-vous avant de changer votre mot de passe.', ''];
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouverPourAuthentification((int) $access->accountId());
|
||||
if ($compte === null || !CompteModel::exigeMotDePasse($compte)) {
|
||||
return ['jeton', 'Votre type de compte n’utilise pas de mot de passe.', ''];
|
||||
}
|
||||
|
||||
if ($methode !== 'POST') {
|
||||
return ['changement', '', ''];
|
||||
}
|
||||
if (!$this->csrfValide()) {
|
||||
return ['changement', 'La session a expiré. Rechargez la page et recommencez.', ''];
|
||||
}
|
||||
|
||||
if (!$this->comptes->verifierMotDePasse($compte, (string) ($_POST['mot_de_passe_actuel'] ?? ''))) {
|
||||
Journal::refus('MOT_DE_PASSE_REFUSE', 'Mot de passe actuel incorrect lors d’un changement.');
|
||||
|
||||
return ['changement', 'Le mot de passe actuel est incorrect.', ''];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->comptes->definirMotDePasse(
|
||||
(int) $compte['id_compte'],
|
||||
(string) ($_POST['mot_de_passe'] ?? ''),
|
||||
(string) ($_POST['mot_de_passe_confirmation'] ?? '')
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
return ['changement', $e->getMessage(), ''];
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'MOT_DE_PASSE_MODIFIE',
|
||||
'compte_acces',
|
||||
(int) $compte['id_compte'],
|
||||
'Mot de passe changé par son titulaire.'
|
||||
);
|
||||
|
||||
return ['changement', '', 'Votre mot de passe a été modifié.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre la session et redirige : cette méthode NE REVIENT JAMAIS.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
* @param array<int, string> $facteurs Facteurs réellement présentés.
|
||||
* @param bool $premier Vrai si le mot de passe vient d'être défini.
|
||||
*/
|
||||
private function ouvrirSession(Access $access, array $compte, array $facteurs, string $origine, bool $premier = false): never
|
||||
{
|
||||
$this->oublierAttente();
|
||||
$access->openSession($compte);
|
||||
$this->comptes->enregistrerConnexion((int) $compte['id_compte']);
|
||||
|
||||
if ($premier) {
|
||||
// Journalisé APRÈS l'ouverture de session, pour que la trace porte le
|
||||
// nom du titulaire plutôt que d'être anonyme.
|
||||
Journal::ecriture(
|
||||
'MOT_DE_PASSE_DEFINI',
|
||||
'compte_acces',
|
||||
(int) $compte['id_compte'],
|
||||
'Mot de passe défini par son titulaire à sa première connexion.'
|
||||
);
|
||||
}
|
||||
|
||||
Journal::connexion('CONNEXION_REUSSIE', [
|
||||
'description' => sprintf(
|
||||
'%s s’est connecté (%s).',
|
||||
(string) ($compte['identite_libelle'] ?? $compte['libelle']),
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['origine' => $origine, 'facteurs' => $facteurs],
|
||||
]);
|
||||
|
||||
// La redirection efface le jeton de la barre d'adresse quand il est
|
||||
// arrivé par un lien direct.
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Déconnexion : trace d'abord, vide la session ensuite, redirige enfin. */
|
||||
private function deconnecter(Access $access): never
|
||||
{
|
||||
$provided = (string) ($_GET['csrf_token'] ?? '');
|
||||
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
|
||||
if ($access->isConfigured()) {
|
||||
// L'ordre compte : après clear(), la session ne dit plus qui
|
||||
// vient de partir et la trace serait anonyme.
|
||||
Journal::connexion('DECONNEXION', ['description' => 'Fin de session à la demande de l’utilisateur.']);
|
||||
}
|
||||
$this->oublierAttente();
|
||||
$access->clear();
|
||||
}
|
||||
|
||||
header('Location: role.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus
|
||||
* aucun secret n'est même comparé.
|
||||
*
|
||||
* @return string Message à afficher, vide si la voie est libre.
|
||||
*/
|
||||
private function blocageEnCours(): string
|
||||
{
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES);
|
||||
if ($echecs < self::ECHECS_MAX) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Le blocage est tracé pour que la DSI voie une attaque en cours plutôt
|
||||
// que d'avoir à la déduire.
|
||||
Journal::connexion('CONNEXION_BLOQUEE', [
|
||||
'resultat' => 'REFUS',
|
||||
'description' => sprintf(
|
||||
'Trop de secrets refusés depuis %s : connexion bloquée.',
|
||||
$ip !== '' ? $ip : 'une adresse inconnue'
|
||||
),
|
||||
'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES],
|
||||
]);
|
||||
|
||||
return sprintf(
|
||||
'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.',
|
||||
self::FENETRE_MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémorise le compte reconnu par son jeton, en attendant son mot de passe.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function poserAttente(array $compte, string $origine): void
|
||||
{
|
||||
Session::start();
|
||||
// Regénération de l'identifiant AVANT de poser l'attente : un identifiant
|
||||
// de session imposé par un tiers ne doit pas porter cette étape.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::ATTENTE_KEY] = [
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
'libelle' => (string) $compte['identite_libelle'],
|
||||
'definition' => !CompteModel::motDePasseDefini($compte),
|
||||
'origine' => $origine,
|
||||
'expire' => time() + self::ATTENTE_SECONDES,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attente en cours, ou null si elle est absente ou périmée.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function attente(): ?array
|
||||
{
|
||||
Session::start();
|
||||
$attente = $_SESSION[self::ATTENTE_KEY] ?? null;
|
||||
if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) {
|
||||
$this->oublierAttente();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attente;
|
||||
}
|
||||
|
||||
private function oublierAttente(): void
|
||||
{
|
||||
Session::start();
|
||||
unset($_SESSION[self::ATTENTE_KEY]);
|
||||
}
|
||||
|
||||
/** « definition » au premier accès, « verification » ensuite. */
|
||||
private function etapeAttendue(): string
|
||||
{
|
||||
return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification';
|
||||
}
|
||||
|
||||
/** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */
|
||||
private function csrfValide(): bool
|
||||
{
|
||||
$error = '';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$expected = Csrf::ensureToken();
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$error = 'La session a expiré. Rechargez la page et recommencez.';
|
||||
} else {
|
||||
try {
|
||||
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
|
||||
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
} catch (\DomainException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided);
|
||||
Security::sendHtmlHeaders();
|
||||
$this->render('pages/role', [
|
||||
'pageTitle' => 'Choisir votre accès',
|
||||
'cspNonce' => Security::nonce(),
|
||||
'csrfToken' => Csrf::ensureToken(),
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'error' => $error,
|
||||
'currentProfile' => $access->isConfigured() ? $access->profile() : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
@@ -18,15 +17,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
|
||||
*
|
||||
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
|
||||
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
|
||||
*
|
||||
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
|
||||
* modification réservées au Service Enfance.
|
||||
*/
|
||||
final class StructureController
|
||||
{
|
||||
private StructureModel $structureModel;
|
||||
@@ -42,19 +32,6 @@ final class StructureController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
|
||||
*
|
||||
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
|
||||
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
|
||||
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
|
||||
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
|
||||
*
|
||||
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
|
||||
* agent : une seule lecture, puis regroupement en mémoire.
|
||||
*
|
||||
* Paramètres GET : structure_id, week.
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||||
@@ -102,16 +79,6 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_planning_pdf.php — même vue, au format PDF.
|
||||
*
|
||||
* Reprend volontairement le calcul de overview() plutôt que de le
|
||||
* factoriser : les deux méthodes doivent rester alignées, toute évolution
|
||||
* de l'une appelle la même dans l'autre.
|
||||
*
|
||||
* Endpoint PDF : les erreurs sortent en texte brut avec un code HTTP, jamais
|
||||
* en JSON (voir AgentController).
|
||||
*/
|
||||
public function pdf(): void
|
||||
{
|
||||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||||
@@ -164,20 +131,6 @@ final class StructureController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/create_structure.php — création d'un lieu d'affectation.
|
||||
*
|
||||
* Le code est normalisé avant contrôle : espaces remplacés par des
|
||||
* underscores, puis passage en majuscules. Il sert d'identifiant lisible
|
||||
* (nom de fichier PDF, exports), d'où le format restreint à
|
||||
* [A-Z0-9_-]{2,50}. Un doublon ressort en 409, pas en 422 : c'est un conflit
|
||||
* avec l'existant.
|
||||
*
|
||||
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) détermine le contexte
|
||||
* par défaut du lieu ; il reste croisé avec le calendrier des vacances.
|
||||
*
|
||||
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -216,28 +169,12 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'STRUCTURE_CREEE',
|
||||
'structure',
|
||||
isset($structure['id_structure']) ? (int) $structure['id_structure'] : null,
|
||||
sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code),
|
||||
['code' => $code, 'type_affectation' => $typeAffectation]
|
||||
);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name),
|
||||
'structure' => $structure,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_structure.php — change le type d'un lieu.
|
||||
*
|
||||
* Seul le type est modifiable : le code sert de référence stable, et le nom
|
||||
* n'est pas exposé à l'édition dans cette version.
|
||||
*
|
||||
* Charge utile : structure_id, type_affectation, csrf_token.
|
||||
*/
|
||||
public function updateType(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -277,11 +214,6 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AgentModel;
|
||||
@@ -16,21 +15,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Équipes des mercredis et des vacances.
|
||||
*
|
||||
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
|
||||
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
|
||||
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
|
||||
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
|
||||
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
|
||||
* pouvant évoluer.
|
||||
*
|
||||
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
|
||||
* BROUILLON → A_CONTROLER → VALIDEE.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class TeamController
|
||||
{
|
||||
private TeamModel $teamModel;
|
||||
@@ -46,21 +30,6 @@ final class TeamController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
|
||||
*
|
||||
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
|
||||
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
|
||||
* actives sur une période.
|
||||
*
|
||||
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
|
||||
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
|
||||
* ratio à zéro provoquerait une division par zéro au calcul du taux
|
||||
* d'encadrement.
|
||||
*
|
||||
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
|
||||
* date_fin, effectifs et ratios facultatifs, csrf_token.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -115,33 +84,12 @@ final class TeamController
|
||||
'pourcentage_diplomes_min' => $qualified,
|
||||
'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_CREEE',
|
||||
'equipe_pta',
|
||||
(int) $id,
|
||||
sprintf('Création de l’équipe « %s » sur le lieu #%d.', $label, (int) $structureId),
|
||||
['id_structure' => (int) $structureId, 'type_equipe' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/team_members.php — ajoute ou retire un agent d'une équipe.
|
||||
*
|
||||
* `action` vaut ADD (défaut) ou REMOVE. Trois fonctions possibles :
|
||||
* RESPONSABLE, ANIMATION, RESTAURATION_ENTRETIEN — elles conditionnent le
|
||||
* calcul du taux d'encadrement.
|
||||
*
|
||||
* L'exception d'ajout ressort en 422 avec SON message : le modèle y place
|
||||
* les refus métier lisibles (agent déjà membre, par exemple), destinés à
|
||||
* l'utilisateur.
|
||||
*
|
||||
* Charge utile : action, team_id, agent_id, fonction_equipe, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function member(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -173,28 +121,12 @@ final class TeamController
|
||||
$function,
|
||||
trim((string) ($payload['commentaire'] ?? '')) ?: null
|
||||
);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_MEMBRE_AJOUTE',
|
||||
'equipe_pta_membre',
|
||||
(int) $teamId,
|
||||
sprintf('Agent #%d ajouté à l’équipe #%d (%s).', (int) $agentId, (int) $teamId, $function),
|
||||
['id_agent' => (int) $agentId, 'fonction_equipe' => $function]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/team_status.php — fait avancer une équipe dans son cycle de vie.
|
||||
*
|
||||
* Aucune transition n'est imposée : tout statut valide est acceptable depuis
|
||||
* n'importe quel autre, y compris un retour en arrière. Le suivi est
|
||||
* indicatif, pas un verrou.
|
||||
*
|
||||
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
|
||||
*/
|
||||
public function status(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -207,13 +139,6 @@ final class TeamController
|
||||
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
|
||||
}
|
||||
$this->teamModel->setStatus((int) $teamId, $status);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_STATUT_MODIFIE',
|
||||
'equipe_pta',
|
||||
(int) $teamId,
|
||||
sprintf('Équipe #%d passée au statut %s.', (int) $teamId, $status),
|
||||
['statut' => $status]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\VacationModel;
|
||||
@@ -15,22 +14,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires.
|
||||
*
|
||||
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
|
||||
* journée, qui conditionne à son tour les motifs pertinents et l'application
|
||||
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
|
||||
* simple calendrier d'affichage.
|
||||
*
|
||||
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
|
||||
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
|
||||
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
|
||||
* humain l'ait relue — les libellés et découpages de l'API varient d'une
|
||||
* académie à l'autre.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class VacationController
|
||||
{
|
||||
private VacationModel $vacationModel;
|
||||
@@ -42,10 +25,6 @@ final class VacationController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/vacation_periods.php — périodes enregistrées.
|
||||
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -59,19 +38,6 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/preview_school_holidays.php — propositions de l'API officielle,
|
||||
* SANS aucune écriture en base.
|
||||
*
|
||||
* Le 502 (et non 500) en cas d'échec dit ce qui s'est passé : c'est le
|
||||
* service distant qui n'a pas répondu, pas PTA qui a échoué. Le conteneur
|
||||
* php a besoin d'un accès réseau sortant pour cet appel.
|
||||
*
|
||||
* Les `warnings` signalent les périodes que le service n'a pas su
|
||||
* interpréter ; elles sont montrées à l'utilisateur plutôt que tues.
|
||||
*
|
||||
* Paramètres GET : academy, year (année civile).
|
||||
*/
|
||||
public function preview(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -112,21 +78,6 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_vacation_periods.php — enregistre les périodes retenues.
|
||||
*
|
||||
* Le plafond de 50 périodes correspond à un large excédent d'une année
|
||||
* scolaire complète ; il borne le coût d'un envoi malformé ou répété.
|
||||
*
|
||||
* Les libellés, académies et zones sont tronqués avec les fonctions mb_* aux
|
||||
* longueurs des colonnes : une troncature en octets couperait un caractère
|
||||
* accentué en deux et produirait de l'UTF-8 invalide en base.
|
||||
*
|
||||
* `source` distingue MANUEL de DATA_GOUV pour tracer l'origine ; une valeur
|
||||
* inconnue retombe sur MANUEL au lieu de faire échouer l'enregistrement.
|
||||
*
|
||||
* Charge utile : periods[], csrf_token.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -178,13 +129,6 @@ final class VacationController
|
||||
}
|
||||
|
||||
$count = $this->vacationModel->saveMany($periods);
|
||||
Journal::ecriture(
|
||||
'VACANCES_ENREGISTREES',
|
||||
'periode_vacance',
|
||||
null,
|
||||
sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count),
|
||||
['nombre' => $count]
|
||||
);
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'count' => $count,
|
||||
@@ -192,15 +136,6 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/delete_vacation_period.php — supprime une période.
|
||||
*
|
||||
* Aucun contrôle d'usage : supprimer une période change le contexte
|
||||
* scolaire/extrascolaire des journées concernées, mais ne touche aucun
|
||||
* créneau déjà planifié.
|
||||
*
|
||||
* Charge utile : period_id, csrf_token.
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -216,27 +151,15 @@ final class VacationController
|
||||
JsonResponse::send(404, ['error' => 'Période introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'VACANCES_SUPPRIMEES',
|
||||
'periode_vacance',
|
||||
(int) $id,
|
||||
sprintf('Période de vacances #%d supprimée.', (int) $id)
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function isDate(string $value): bool
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
return $date !== false && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
|
||||
* immédiatement la première : « 2026-2028 » est refusé.
|
||||
*/
|
||||
private function isSchoolYear(string $value): bool
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,42 +6,16 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
use App\Models\QuotaModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Models\WeekTemplateModel;
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
|
||||
*
|
||||
* Une semaine type n'est PAS un planning : elle décrit une organisation
|
||||
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
|
||||
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
|
||||
*
|
||||
* Deux origines pour une semaine type, qui convergent vers
|
||||
* WeekTemplateModel::persistTemplate() :
|
||||
* - store() : capture d'une semaine réellement planifiée ;
|
||||
* - storeEntries() : composition directe dans l'éditeur.
|
||||
*
|
||||
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
|
||||
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
|
||||
*
|
||||
* Convention de codes d'erreur propre à ce contrôleur :
|
||||
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
|
||||
* alors de le remplacer en renvoyant replace: true) ;
|
||||
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
|
||||
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
|
||||
* reformulation de ce message dans WeekTemplateModel casse la distinction.
|
||||
*/
|
||||
final class WeekTemplateController
|
||||
{
|
||||
private WeekTemplateModel $templateModel;
|
||||
@@ -57,12 +31,6 @@ final class WeekTemplateController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/week_templates.php — liste des semaines types d'un agent.
|
||||
*
|
||||
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
|
||||
* show() sert à ouvrir.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -87,152 +55,6 @@ final class WeekTemplateController
|
||||
JsonResponse::send(200, ['templates' => $templates]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/week_template.php — contenu détaillé d'une semaine type.
|
||||
*
|
||||
* Le modèle est retrouvé par (template_id, agent_id) et non par le seul
|
||||
* identifiant : c'est ce qui empêche de lire le modèle d'un autre agent en
|
||||
* changeant l'identifiant dans l'URL.
|
||||
*
|
||||
* Les drapeaux `structure_active` et `motif_actif` sont conservés bien que
|
||||
* toujours vrais à la création : un lieu ou un motif peut être désactivé
|
||||
* APRÈS l'enregistrement du modèle, et l'éditeur doit le signaler plutôt que
|
||||
* d'échouer au moment de l'application.
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
|
||||
|
||||
if (!$agentId || !$templateId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
|
||||
}
|
||||
|
||||
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
|
||||
if ($template === null) {
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
|
||||
}
|
||||
|
||||
$entries = array_map(static function (array $entry): array {
|
||||
return [
|
||||
'jour_semaine' => (int) $entry['jour_semaine'],
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
// L'éditeur manipule des heures au format HH:MM.
|
||||
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
|
||||
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
|
||||
'structure_nom' => (string) $entry['structure_nom'],
|
||||
'structure_active' => (int) $entry['structure_actif'] === 1,
|
||||
'motif_libelle' => (string) $entry['motif_libelle'],
|
||||
'motif_actif' => (int) $entry['motif_actif'] === 1,
|
||||
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
|
||||
];
|
||||
}, $this->templateModel->entries((int) $templateId));
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'updated_at' => (string) $template['date_modification'],
|
||||
],
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_week_template_entries.php — enregistre un modèle composé
|
||||
* directement dans l'éditeur (V7.10).
|
||||
*
|
||||
* Les listes de motifs et de lieux autorisés sont calculées ICI puis
|
||||
* transmises au modèle : celui-ci ne connaît pas les règles d'accès et se
|
||||
* contente de rejeter ce qui n'est pas dans les listes reçues.
|
||||
*
|
||||
* Charge utile : agent_id, name, entries[], replace (bool), csrf_token.
|
||||
*/
|
||||
public function storeEntries(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$name = (string) ($payload['name'] ?? '');
|
||||
$entries = $payload['entries'] ?? null;
|
||||
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$agentId || !is_array($entries)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
|
||||
}
|
||||
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
|
||||
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
|
||||
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
|
||||
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
|
||||
$allowedStructureIds = array_map(
|
||||
static fn (array $structure): int => (int) $structure['id_structure'],
|
||||
(new StructureModel($this->pdo))->allActive()
|
||||
);
|
||||
|
||||
try {
|
||||
$template = $this->templateModel->saveFromEntries(
|
||||
(int) $agentId,
|
||||
$name,
|
||||
$entries,
|
||||
$allowedMotifIds,
|
||||
$allowedStructureIds,
|
||||
$replace
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
|
||||
JsonResponse::send($status, [
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible d’enregistrer la semaine type.',
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
(int) $template['id_modele'],
|
||||
sprintf('Semaine type « %s » enregistrée depuis l’éditeur pour l’agent #%d.', (string) $template['nom'], (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'entry_count' => (int) $template['nombre_creneaux'],
|
||||
],
|
||||
'message' => sprintf(
|
||||
'La semaine type « %s » a été enregistrée avec %d plage%s.',
|
||||
$template['nom'],
|
||||
$template['nombre_creneaux'],
|
||||
$template['nombre_creneaux'] > 1 ? 's' : ''
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_week_template.php — capture une semaine planifiée en modèle.
|
||||
*
|
||||
* Point de départ historique : l'utilisateur saisit une semaine « normale »,
|
||||
* puis la fige comme modèle réutilisable.
|
||||
*
|
||||
* Charge utile : agent_id, week, name, replace (bool), csrf_token.
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -268,14 +90,6 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
isset($template['id_modele']) ? (int) $template['id_modele'] : null,
|
||||
sprintf('Semaine type « %s » enregistrée depuis une semaine planifiée pour l’agent #%d.', $name, (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => $template,
|
||||
@@ -283,15 +97,6 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
|
||||
*
|
||||
* Deux modes : `merge` complète les créneaux existants, `replace` les
|
||||
* remplace. Appliquer sur une semaine validée la repasse en brouillon
|
||||
* (`requires_revalidation`).
|
||||
*
|
||||
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
|
||||
*/
|
||||
public function apply(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -307,18 +112,12 @@ final class WeekTemplateController
|
||||
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
|
||||
}
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
[$year, $weekNumber] = $week;
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
$this->assertDatesWithinContract($agent, array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
));
|
||||
|
||||
try {
|
||||
[$year, $weekNumber] = $week;
|
||||
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
@@ -341,27 +140,6 @@ final class WeekTemplateController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type « %s » appliquée à l’agent #%d sur la semaine %d de %d : %d créneau(x).',
|
||||
(string) $result['template']['nom'],
|
||||
(int) $agentId,
|
||||
$weekNumber,
|
||||
$year,
|
||||
(int) $result['inserted']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'annee' => $year,
|
||||
'semaine' => $weekNumber,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -373,21 +151,6 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/preview_week_template_period.php — simulation SANS écriture.
|
||||
*
|
||||
* Étape obligatoire dans l'interface avant applyPeriod() : une application
|
||||
* sur période touche des dizaines de semaines et peut rouvrir des plannings
|
||||
* validés. L'utilisateur doit voir le volume avant de confirmer.
|
||||
*
|
||||
* `period_filter` restreint le périmètre au calendrier scolaire :
|
||||
* all → toutes les semaines de l'intervalle ;
|
||||
* school → semaines scolaires seulement (périscolaire) ;
|
||||
* extra → vacances scolaires seulement (extrascolaire).
|
||||
*
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode,
|
||||
* period_filter, csrf_token.
|
||||
*/
|
||||
public function previewPeriod(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -412,11 +175,9 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$preview = $this->templateModel->previewPeriod(
|
||||
@@ -445,18 +206,6 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/apply_week_template_period.php — applique un modèle sur une période.
|
||||
*
|
||||
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
|
||||
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
|
||||
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
|
||||
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
|
||||
*
|
||||
* Les quotas sont renvoyés indexés par date de début de période : une
|
||||
* application à cheval sur deux années civiles touche DEUX quotas annuels
|
||||
* distincts, et l'interface doit rafraîchir les deux.
|
||||
*/
|
||||
public function applyPeriod(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -481,11 +230,9 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$result = $this->templateModel->applyToPeriod(
|
||||
@@ -547,27 +294,6 @@ final class WeekTemplateController
|
||||
$quotas[(string) $quota['date_debut_periode']] = $quota;
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE_PERIODE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type #%d appliquée à l’agent #%d du %s au %s.',
|
||||
(int) $templateId,
|
||||
(int) $agentId,
|
||||
$startDate,
|
||||
$endDate
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
'mode' => $mode,
|
||||
'filtre_periode' => $periodFilter,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -577,14 +303,6 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/delete_week_template.php — supprime une semaine type.
|
||||
*
|
||||
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
|
||||
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
|
||||
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
|
||||
* patron, pas un lien vivant vers les plannings produits.
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -602,67 +320,9 @@ final class WeekTemplateController
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_SUPPRIMEE',
|
||||
'modele_semaine_agent',
|
||||
(int) $templateId,
|
||||
sprintf('Semaine type #%d supprimée pour l’agent #%d.', (int) $templateId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Copie assumée de PlanningController::assertDatesWithinContract() : les deux
|
||||
* contrôleurs n'ont pas de base commune, et la règle doit s'appliquer des
|
||||
* deux côtés. Le MESSAGE, lui, est construit une seule fois par AgentModel —
|
||||
* c'est lui qui doit rester unique, pas la garde.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, string> $dates
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Même règle, pour une PÉRIODE entière.
|
||||
*
|
||||
* Seules les bornes sont contrôlées, et c'est suffisant : le contrat est un
|
||||
* intervalle continu, donc une période dont les deux extrémités y tombent y
|
||||
* est entièrement contenue. Contrôler chacun des jours d'une duplication
|
||||
* annuelle coûterait 365 comparaisons pour la même réponse.
|
||||
*
|
||||
* Le refus porte volontairement sur la période DEMANDÉE et non sur les seuls
|
||||
* jours réellement écrits : personne ne doit avoir à deviner qu'une
|
||||
* duplication « du 1er janvier au 31 décembre » n'a rien produit en novembre
|
||||
* parce que le contrat s'arrêtait en octobre.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
*/
|
||||
private function assertPeriodWithinContract(array $agent, string $startDate, string $endDate): void
|
||||
{
|
||||
$this->assertDatesWithinContract($agent, [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -16,90 +16,43 @@ use PDO;
|
||||
*/
|
||||
final class Access
|
||||
{
|
||||
/** Lecture seule sur son propre planning. */
|
||||
public const ROLE_AGENT = 'AGENT';
|
||||
/** Édition des brouillons de son seul lieu d'affectation. */
|
||||
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
|
||||
/** Périmètre complet ; seul rôle habilité à valider un planning. */
|
||||
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
|
||||
/**
|
||||
* Administration technique (V7.11) : comptes d'accès, jetons et journal.
|
||||
* Aucun droit métier — la DSI ne voit ni les plannings, ni les agents.
|
||||
*/
|
||||
public const ROLE_DSI = 'DSI';
|
||||
|
||||
private const SESSION_KEY = 'pta_access';
|
||||
|
||||
/** @var array<int, string> Les quatre rôles connus de l'application. */
|
||||
private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI];
|
||||
|
||||
/**
|
||||
* Caches par requête : les vues d'accès sont interrogées une seule fois par
|
||||
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
|
||||
* qu'une modification SQL des droits s'applique sans redéploiement.
|
||||
*
|
||||
* @var array<string, array<string, bool>>
|
||||
*/
|
||||
/** @var array<string, array<string, bool>> */
|
||||
private array $permissionCache = [];
|
||||
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $pageCache = [];
|
||||
|
||||
/**
|
||||
* Compte d'accès de la session, relu UNE FOIS par requête.
|
||||
*
|
||||
* Il est relu à chaque requête et non conservé en session : c'est ce qui
|
||||
* fait qu'un jeton désactivé par la DSI coupe l'accès à la requête suivante,
|
||||
* sans attendre l'expiration de la session.
|
||||
*
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $accountCache = null;
|
||||
|
||||
private bool $accountLoaded = false;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
Session::start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la sélection en session est complète et pointe encore vers une
|
||||
* identité active. Un agent ou un lieu désactivé en base fait donc retomber
|
||||
* l'utilisateur sur role.php à la requête suivante.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$role = $this->role();
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Depuis la V7.11, toute session est adossée à un compte d'accès actif :
|
||||
// pas de compte vivant, pas de session. Un jeton révoqué, un agent
|
||||
// désactivé ou un lieu fermé referment donc l'accès immédiatement.
|
||||
$compte = $this->accountRecord();
|
||||
if ($compte === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
return $this->agentId() !== null && $this->agentRecord() !== null;
|
||||
}
|
||||
if ($role === self::ROLE_RESPONSABLE) {
|
||||
return $this->structureId() !== null && $this->structureRecord() !== null;
|
||||
}
|
||||
return true;
|
||||
return $role === self::ROLE_SERVICE;
|
||||
}
|
||||
|
||||
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
|
||||
public function role(): ?string
|
||||
{
|
||||
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
|
||||
return in_array($role, self::ROLES, true) ? $role : null;
|
||||
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
|
||||
? $role
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
|
||||
public function agentId(): ?int
|
||||
{
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
@@ -108,13 +61,6 @@ final class Access
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu de référence de la session.
|
||||
*
|
||||
* Pour un AGENT, il n'est pas stocké en session mais dérivé de son
|
||||
* rattachement en base : l'agent ne le choisit pas, et le déplacer d'un lieu
|
||||
* à l'autre doit prendre effet immédiatement.
|
||||
*/
|
||||
public function structureId(): ?int
|
||||
{
|
||||
if ($this->role() === self::ROLE_AGENT) {
|
||||
@@ -130,98 +76,40 @@ final class Access
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
|
||||
{
|
||||
// Regénération de l'identifiant de session à chaque ouverture : parade
|
||||
// classique à la fixation de session.
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = [
|
||||
'role' => (string) $compte['role'],
|
||||
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
|
||||
? (int) $compte['id_agent']
|
||||
: null,
|
||||
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null
|
||||
? (int) $compte['id_structure']
|
||||
: null,
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
// Libellé figé pour le journal : il évite une jointure à chaque trace.
|
||||
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''),
|
||||
];
|
||||
|
||||
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle
|
||||
// connexion invalide le jeton précédent.
|
||||
unset($_SESSION['csrf_token']);
|
||||
$this->accountCache = null;
|
||||
$this->accountLoaded = false;
|
||||
$role = strtoupper(trim($role));
|
||||
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
|
||||
throw new \DomainException('Rôle invalide.');
|
||||
}
|
||||
|
||||
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
if (!$agentId || !$this->activeAgentExists($agentId)) {
|
||||
throw new \DomainException('Sélectionnez un agent actif.');
|
||||
}
|
||||
$selection['agent_id'] = $agentId;
|
||||
} elseif ($role === self::ROLE_RESPONSABLE) {
|
||||
if (!$structureId || !$this->activeStructureExists($structureId)) {
|
||||
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
|
||||
}
|
||||
$selection['structure_id'] = $structureId;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::SESSION_KEY] = $selection;
|
||||
// Le jeton CSRF suit le cycle de vie de la session : un changement de
|
||||
// rôle invalide le jeton précédent.
|
||||
unset($_SESSION['csrf_token']);
|
||||
}
|
||||
|
||||
/** Ferme la session de travail (déconnexion). */
|
||||
public function clear(): void
|
||||
{
|
||||
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
|
||||
session_regenerate_id(true);
|
||||
$this->accountCache = null;
|
||||
$this->accountLoaded = false;
|
||||
}
|
||||
|
||||
/** Identifiant du compte d'accès de la session, ou null. */
|
||||
public function accountId(): ?int
|
||||
{
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['compte_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantané de session destiné au JOURNAL, lisible sans instancier Access
|
||||
* ni ouvrir de connexion.
|
||||
*
|
||||
* Il expose la clé de session privée sous une forme stable : Journal (et lui
|
||||
* seul) a besoin de connaître l'acteur courant depuis un contexte statique,
|
||||
* sans pouvoir dépendre d'une instance.
|
||||
*
|
||||
* @return array{id_compte: int|null, role: string|null, acteur: string|null}
|
||||
*/
|
||||
public static function sessionSnapshot(): array
|
||||
{
|
||||
$selection = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$compteId = filter_var($selection['compte_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
$role = strtoupper(trim((string) ($selection['role'] ?? '')));
|
||||
$acteur = trim((string) ($selection['acteur'] ?? ''));
|
||||
|
||||
return [
|
||||
'id_compte' => $compteId === false ? null : (int) $compteId,
|
||||
'role' => in_array($role, self::ROLES, true) ? $role : null,
|
||||
'acteur' => $acteur === '' ? null : $acteur,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil affichable de la session, publié tel quel dans window.PTA_CONFIG
|
||||
* par layouts/footer.php et réutilisé par les en-têtes de PDF.
|
||||
*
|
||||
* @return array<string, mixed> role, role_label, label, agent_id,
|
||||
* structure_id, permissions, et selon le rôle
|
||||
* matricule / structure_name / structure_code.
|
||||
*/
|
||||
public function profile(): array
|
||||
{
|
||||
$role = $this->role();
|
||||
@@ -232,7 +120,6 @@ final class Access
|
||||
'label' => $this->roleLabel(),
|
||||
'agent_id' => $this->agentId(),
|
||||
'structure_id' => $this->structureId(),
|
||||
'account_id' => $this->accountId(),
|
||||
'permissions' => $permissions,
|
||||
];
|
||||
|
||||
@@ -251,14 +138,6 @@ final class Access
|
||||
$profile['structure_name'] = (string) $structure['nom'];
|
||||
$profile['structure_code'] = (string) $structure['code'];
|
||||
}
|
||||
} else {
|
||||
// Service Enfance et DSI ne portent aucune identité métier : le
|
||||
// libellé affiché est celui du compte (« Direction des systèmes
|
||||
// d'information », « Service Enfance — Camille »…).
|
||||
$compte = $this->accountRecord();
|
||||
if ($compte !== null) {
|
||||
$profile['label'] = (string) $compte['identite_libelle'];
|
||||
}
|
||||
}
|
||||
|
||||
return $profile;
|
||||
@@ -293,7 +172,6 @@ final class Access
|
||||
return $this->permissionCache[$role];
|
||||
}
|
||||
|
||||
/** Raccourci de lecture d'une permission ; absente = refusée. */
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
return $this->permissions()[$permission] ?? false;
|
||||
@@ -322,10 +200,6 @@ final class Access
|
||||
return $this->pageCache[$role];
|
||||
}
|
||||
|
||||
/**
|
||||
* $page est la clé logique de la page (« planning », « dashboard »…), telle
|
||||
* qu'inscrite dans acces_role_page et passée à runPage().
|
||||
*/
|
||||
public function canAccessPage(string $page): bool
|
||||
{
|
||||
return in_array($page, $this->allowedPages(), true);
|
||||
@@ -346,19 +220,10 @@ final class Access
|
||||
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
|
||||
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
|
||||
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
|
||||
// La DSI n'a qu'une page ; le repli sur role.php évite la boucle de
|
||||
// redirection si la migration V7.11 n'est pas jouée.
|
||||
self::ROLE_DSI => $this->canAccessPage('dsi') ? 'dsi.php' : 'role.php',
|
||||
default => 'role.php',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde des pages HTML : redirige au lieu de renvoyer une erreur, puis
|
||||
* TERMINE le script. Sans rôle configuré on renvoie vers le sélecteur ;
|
||||
* page interdite, on renvoie vers l'accueil du rôle avec access_denied=1
|
||||
* pour que l'interface puisse expliquer le refus.
|
||||
*/
|
||||
public function requirePage(string $page): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
@@ -366,108 +231,47 @@ final class Access
|
||||
exit;
|
||||
}
|
||||
if (!$this->canAccessPage($page)) {
|
||||
Journal::refus(
|
||||
'PAGE_REFUSEE',
|
||||
sprintf('Accès refusé à la page « %s ».', $page),
|
||||
['contexte' => ['page' => $page]]
|
||||
);
|
||||
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde des endpoints JSON : émet un 401/403 JSON et TERMINE le script.
|
||||
* Toutes les gardes require*() ci-dessous en dérivent.
|
||||
*
|
||||
* @param array<int, string> $roles Rôles acceptés.
|
||||
*/
|
||||
public function requireRoles(array $roles): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
$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']);
|
||||
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
}
|
||||
if (!in_array($this->role(), $roles, true)) {
|
||||
$this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [
|
||||
'endpoint' => $this->endpointCourant(),
|
||||
'roles_attendus' => array_values($roles),
|
||||
]);
|
||||
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise un refus d'API. Méthode privée volontairement minuscule : elle
|
||||
* n'existe que pour que les gardes ci-dessous tiennent en deux lignes.
|
||||
*
|
||||
* @param array<string, mixed> $contexte
|
||||
*/
|
||||
private function refuser(int $status, string $action, string $description, array $contexte = []): void
|
||||
{
|
||||
Journal::refus($action, $description, [
|
||||
'contexte' => array_merge(['statut_http' => $status], $contexte),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */
|
||||
private function endpointCourant(): string
|
||||
{
|
||||
$chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
|
||||
return $chemin !== '' ? basename($chemin) : 'inconnu';
|
||||
}
|
||||
|
||||
/** Réservé au Service Enfance : validation, référentiels, administration. */
|
||||
public function requireService(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réservé à la DSI : gestion des comptes d'accès et lecture du journal.
|
||||
*
|
||||
* Cette garde double le contrôle de page : la page « dsi » n'est autorisée
|
||||
* qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même
|
||||
* si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL.
|
||||
*/
|
||||
public function requireDsi(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_DSI]);
|
||||
}
|
||||
|
||||
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
|
||||
public function requireDraftEditor(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
/** Consultation d'un lieu : hors Service Enfance, seul le lieu de rattachement. */
|
||||
public function requireStructureView(int $structureId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Édition dans un lieu : un responsable est confiné au sien. */
|
||||
public function requireStructureEdit(int $structureId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consultation d'un agent. Un responsable y a droit dès que l'agent est
|
||||
* planifié chez lui, même s'il y est rattaché ailleurs : un agent peut
|
||||
* travailler sur plusieurs lieux la même semaine (v_acces_agent_par_structure).
|
||||
*/
|
||||
public function requireAgentView(int $agentId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
@@ -480,14 +284,9 @@ final class Access
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
|
||||
return;
|
||||
}
|
||||
$this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Édition d'un créneau, dont le périmètre est déduit du planning porteur.
|
||||
* Un identifiant inconnu donne un 404 avant tout contrôle de rôle plus fin.
|
||||
*/
|
||||
public function requireEntryEdit(int $entryId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
@@ -505,7 +304,6 @@ final class Access
|
||||
$this->requireStructureEdit((int) $structureId);
|
||||
}
|
||||
|
||||
/** Édition d'un planning identifié, périmètre déduit de son lieu. */
|
||||
public function requirePlanningEdit(int $planningId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
@@ -523,74 +321,35 @@ final class Access
|
||||
$this->requireStructureEdit((int) $structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu à pré-sélectionner et à verrouiller dans l'interface, ou null quand
|
||||
* l'utilisateur peut choisir librement (Service Enfance).
|
||||
*/
|
||||
public function editableStructureId(): ?int
|
||||
{
|
||||
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
|
||||
}
|
||||
|
||||
/** Libellé lisible du rôle, affiché dans l'en-tête et sur les PDF. */
|
||||
private function roleLabel(): string
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => 'Agent',
|
||||
self::ROLE_RESPONSABLE => 'Responsable de structure',
|
||||
self::ROLE_SERVICE => 'Service Enfance',
|
||||
self::ROLE_DSI => 'DSI',
|
||||
default => 'Rôle non sélectionné',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private function activeAgentExists(int $agentId): bool
|
||||
{
|
||||
if ($this->accountLoaded) {
|
||||
return $this->accountCache;
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
$this->accountLoaded = true;
|
||||
$this->accountCache = null;
|
||||
|
||||
$compteId = $this->accountId();
|
||||
if ($compteId === null) {
|
||||
return null;
|
||||
private function activeStructureExists(int $structureId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle
|
||||
FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
// Le rôle de la session doit toujours correspondre à celui du compte :
|
||||
// une session forgée ou survivant à un changement de rôle du compte est
|
||||
// refusée plutôt que réparée.
|
||||
if ($compte !== false && (string) $compte['role'] === $this->role()) {
|
||||
$this->accountCache = $compte;
|
||||
}
|
||||
|
||||
return $this->accountCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche de l'agent en session.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function agentRecord(): ?array
|
||||
{
|
||||
$agentId = $this->agentId();
|
||||
@@ -606,11 +365,6 @@ final class Access
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche du lieu en session.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function structureRecord(): ?array
|
||||
{
|
||||
$structureId = $this->structureId();
|
||||
@@ -626,10 +380,6 @@ final class Access
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'agent apparaît dans le périmètre du responsable : rattachement
|
||||
* par défaut OU présence d'un planning dans son lieu.
|
||||
*/
|
||||
private function responsibleCanViewAgent(int $agentId): bool
|
||||
{
|
||||
$structureId = $this->structureId();
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Contrôle de FORME des coordonnées d'un agent : adresse e-mail et numéro de
|
||||
* téléphone. Source de vérité UNIQUE côté serveur — create() et update() de
|
||||
* AgentController s'y réfèrent toutes les deux, et toute nouvelle voie
|
||||
* d'écriture d'un contact doit faire de même plutôt que recopier une regex.
|
||||
*
|
||||
* Une copie de ces règles vit dans assets/js/field-format.js pour le retour
|
||||
* immédiat à la saisie. C'est l'application habituelle du principe du projet :
|
||||
* l'interface prévient, le serveur tranche. Modifier une règle ici impose donc
|
||||
* de reporter la modification dans le module JS — l'inverse n'est jamais vrai.
|
||||
*
|
||||
* PORTÉE VOLONTAIREMENT LIMITÉE : ces deux champs sont FACULTATIFS et purement
|
||||
* informatifs (ils n'ouvrent aucune session, n'envoient aucun message). Le
|
||||
* contrôle attrape la faute de frappe, il ne prétend pas prouver qu'une boîte
|
||||
* ou une ligne existe. Une valeur vide reste toujours acceptée.
|
||||
*/
|
||||
final class ContactFormat
|
||||
{
|
||||
/**
|
||||
* Adresse e-mail. Regex délibérément plus stricte que la RFC 5322, qui
|
||||
* autorise des formes que personne ne saisit dans un annuaire d'agents
|
||||
* (commentaires, guillemets, adresses IP littérales). Elle est DOUBLÉE de
|
||||
* FILTER_VALIDATE_EMAIL : la regex écarte ce que le filtre tolère (domaine
|
||||
* sans point, par exemple « agent@mairie »), le filtre écarte ce que la
|
||||
* regex laisserait passer.
|
||||
*/
|
||||
private const EMAIL_PATTERN = '/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/';
|
||||
|
||||
/**
|
||||
* Numéro français, une fois les séparateurs retirés : 10 chiffres commençant
|
||||
* par 0, ou la même chose en forme internationale (+33 / 0033). Couvre le
|
||||
* fixe comme le mobile, métropole et outre-mer.
|
||||
*/
|
||||
private const PHONE_FR_PATTERN = '/^(?:\+33|0033|0)[1-9]\d{8}$/';
|
||||
|
||||
/**
|
||||
* Repli international E.164 pour un agent joignable à l'étranger — refuser
|
||||
* un numéro valide serait pire que d'en accepter un exotique, le champ
|
||||
* n'étant qu'informatif.
|
||||
*/
|
||||
private const PHONE_INTERNATIONAL_PATTERN = '/^\+[1-9]\d{7,14}$/';
|
||||
|
||||
/** Séparateurs tolérés à la saisie : espaces (insécable compris), . - / ( ). */
|
||||
private const PHONE_SEPARATORS = '/[\s.\-\/()\x{00A0}\x{202F}]/u';
|
||||
|
||||
/** Vrai si l'adresse e-mail est exploitable. La chaîne vide est refusée : c'est à l'appelant de traiter « non renseigné » avant. */
|
||||
public static function isEmail(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match(self::EMAIL_PATTERN, $value) === 1
|
||||
&& filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/** Vrai si le numéro de téléphone est exploitable, séparateurs de saisie ignorés. */
|
||||
public static function isPhone(string $value): bool
|
||||
{
|
||||
$compact = self::compactPhone($value);
|
||||
if ($compact === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match(self::PHONE_FR_PATTERN, $compact) === 1
|
||||
|| preg_match(self::PHONE_INTERNATIONAL_PATTERN, $compact) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numéro débarrassé de ses séparateurs, seule forme sur laquelle les regex
|
||||
* travaillent. « 06 12 34 56 78 », « 06.12.34.56.78 » et « 0612345678 »
|
||||
* décrivent la même ligne : les distinguer n'apporterait rien.
|
||||
*/
|
||||
public static function compactPhone(string $value): string
|
||||
{
|
||||
return (string) preg_replace(self::PHONE_SEPARATORS, '', trim($value));
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Base des contrôleurs qui rendent une page HTML.
|
||||
*
|
||||
* Les contrôleurs d'API n'en héritent pas : ils écrivent directement leur
|
||||
* réponse via JsonResponse::send() et n'ont donc aucune vue à rendre.
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
/**
|
||||
* Rend une vue de app/Views/ en lui exposant $data comme variables locales.
|
||||
*
|
||||
* @param string $view Chemin relatif sans extension, ex. « pages/planning ».
|
||||
* @param array<string, mixed> $data Variables mises à disposition de la vue.
|
||||
*
|
||||
* @throws \RuntimeException Si le fichier de vue n'existe pas.
|
||||
*/
|
||||
protected function render(string $view, array $data = []): void
|
||||
{
|
||||
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
|
||||
@@ -27,8 +13,6 @@ abstract class Controller
|
||||
throw new \RuntimeException('Vue introuvable : ' . $view);
|
||||
}
|
||||
|
||||
// EXTR_SKIP : une clé de $data ne peut pas écraser $view ni $viewFile,
|
||||
// ce qui rendrait le require imprévisible.
|
||||
extract($data, EXTR_SKIP);
|
||||
require $viewFile;
|
||||
}
|
||||
|
||||
@@ -4,21 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Jeton anti-CSRF, transporté dans le corps JSON et non dans un en-tête.
|
||||
*
|
||||
* Le jeton est publié dans window.PTA_CONFIG.csrfToken par layouts/footer.php ;
|
||||
* le JS le recopie dans chaque charge utile mutante sous la clé « csrf_token ».
|
||||
* Ce choix évite d'avoir à gérer une requête préliminaire CORS et garde les
|
||||
* appels fetch() identiques d'un module à l'autre.
|
||||
*/
|
||||
final class Csrf
|
||||
{
|
||||
/**
|
||||
* Renvoie le jeton de la session courante, en le créant au premier appel.
|
||||
* Le jeton vit aussi longtemps que la session : il n'est pas renouvelé à
|
||||
* chaque requête, sans quoi deux onglets ouverts s'invalideraient l'un l'autre.
|
||||
*/
|
||||
public static function ensureToken(): string
|
||||
{
|
||||
Session::start();
|
||||
@@ -30,27 +17,12 @@ final class Csrf
|
||||
return (string) $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le jeton d'une charge utile et interrompt la requête si besoin.
|
||||
*
|
||||
* À appeler en tête de TOUT endpoint mutant, sans exception. La comparaison
|
||||
* passe par hash_equals() pour ne pas être sensible au temps d'exécution.
|
||||
*
|
||||
* @param array<string, mixed> $payload Corps JSON déjà décodé.
|
||||
*/
|
||||
public static function assertPayload(array $payload): void
|
||||
{
|
||||
$expected = self::ensureToken();
|
||||
$provided = (string) ($payload['csrf_token'] ?? '');
|
||||
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
// Un jeton CSRF absent ou faux est le plus souvent une session
|
||||
// expirée, parfois une tentative réelle : dans les deux cas la trace
|
||||
// a sa place dans le journal (V7.11).
|
||||
Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [
|
||||
'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))],
|
||||
]);
|
||||
// send() termine le script : rien ne s'exécute après ce point.
|
||||
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
|
||||
*
|
||||
* Trois familles d'événements sont tracées, et seulement celles-là :
|
||||
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
|
||||
* - ECRITURE : toute modification en base, avec la table et l'identifiant
|
||||
* visés ;
|
||||
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
|
||||
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
|
||||
* et SYSTEME, réservé aux incidents techniques.
|
||||
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
|
||||
* volume pour une valeur d'audit très faible.
|
||||
*
|
||||
* DEUX RÈGLES ABSOLUES :
|
||||
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
|
||||
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
|
||||
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
|
||||
* planification des agents.
|
||||
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
|
||||
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
|
||||
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
|
||||
*
|
||||
* La connexion PDO est posée une fois par app/bootstrap.php
|
||||
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
|
||||
* comme Csrf — n'ont ainsi rien à transporter.
|
||||
*/
|
||||
final class Journal
|
||||
{
|
||||
public const CONNEXION = 'CONNEXION';
|
||||
public const ECRITURE = 'ECRITURE';
|
||||
public const ACCES_REFUSE = 'ACCES_REFUSE';
|
||||
public const ADMINISTRATION = 'ADMINISTRATION';
|
||||
public const SYSTEME = 'SYSTEME';
|
||||
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
/**
|
||||
* Enregistre la connexion utilisée pour écrire les traces.
|
||||
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
|
||||
*/
|
||||
public static function useConnection(PDO $pdo): void
|
||||
{
|
||||
self::$pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une entrée, une sortie ou un échec d'authentification.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function connexion(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::CONNEXION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une modification en base.
|
||||
*
|
||||
* @param string $action Code stable, ex. « PLANNING_VALIDE ».
|
||||
* @param string $cibleTable Table principale touchée.
|
||||
* @param int|null $cibleId Identifiant de la ligne visée.
|
||||
* @param string $description Phrase lisible dans la page DSI.
|
||||
* @param array<string, mixed> $contexte Détail structuré (semaine, lieu…).
|
||||
*/
|
||||
public static function ecriture(
|
||||
string $action,
|
||||
string $cibleTable,
|
||||
?int $cibleId,
|
||||
string $description,
|
||||
array $contexte = []
|
||||
): void {
|
||||
self::enregistrer(self::ECRITURE, $action, [
|
||||
'cible_table' => $cibleTable,
|
||||
'cible_id' => $cibleId,
|
||||
'description' => $description,
|
||||
'contexte' => $contexte,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function refus(string $action, string $description, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [
|
||||
'description' => $description,
|
||||
'resultat' => 'REFUS',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une action d'administration technique (comptes, jetons, journal).
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function administration(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ADMINISTRATION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit une ligne de journal. Ne lève jamais d'exception.
|
||||
*
|
||||
* L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est
|
||||
* indispensable à la connexion, où la trace est écrite AVANT que la session
|
||||
* porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session).
|
||||
*
|
||||
* @param string $categorie Une des constantes de classe.
|
||||
* @param string $action Code stable de l'action.
|
||||
* @param array<string, mixed> $options resultat, cible_table, cible_id,
|
||||
* description, contexte, id_compte,
|
||||
* role, acteur.
|
||||
*/
|
||||
public static function enregistrer(string $categorie, string $action, array $options = []): void
|
||||
{
|
||||
if (self::$pdo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = Access::sessionSnapshot();
|
||||
$contexte = $options['contexte'] ?? [];
|
||||
|
||||
$stmt = self::$pdo->prepare(
|
||||
'INSERT INTO journal_activite
|
||||
(categorie, action, resultat, id_compte, role, acteur,
|
||||
cible_table, cible_id, description, contexte,
|
||||
adresse_ip, agent_utilisateur)
|
||||
VALUES
|
||||
(:categorie, :action, :resultat, :id_compte, :role, :acteur,
|
||||
:cible_table, :cible_id, :description, :contexte,
|
||||
:adresse_ip, :agent_utilisateur)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
'categorie' => $categorie,
|
||||
'action' => mb_substr($action, 0, 60),
|
||||
'resultat' => $options['resultat'] ?? 'SUCCES',
|
||||
'id_compte' => $options['id_compte'] ?? $session['id_compte'],
|
||||
'role' => $options['role'] ?? $session['role'],
|
||||
'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180),
|
||||
'cible_table' => self::tronquer($options['cible_table'] ?? null, 64),
|
||||
'cible_id' => $options['cible_id'] ?? null,
|
||||
'description' => self::tronquer($options['description'] ?? null, 500),
|
||||
// JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit
|
||||
// pas faire échouer l'encodage, donc la trace tout entière.
|
||||
'contexte' => $contexte === []
|
||||
? null
|
||||
: json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
// REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par
|
||||
// le client et falsifiable. Derrière un reverse proxy, c'est au
|
||||
// proxy de réécrire REMOTE_ADDR.
|
||||
'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45),
|
||||
'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Règle 1 : le journal ne casse jamais l'action journalisée.
|
||||
error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tronque à la longueur de la colonne, en préservant les caractères accentués. */
|
||||
private static function tronquer(?string $valeur, int $longueur): ?string
|
||||
{
|
||||
if ($valeur === null) {
|
||||
return null;
|
||||
}
|
||||
$valeur = trim($valeur);
|
||||
return $valeur === '' ? null : mb_substr($valeur, 0, $longueur);
|
||||
}
|
||||
}
|
||||
@@ -4,35 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Sortie JSON des endpoints d'API.
|
||||
*
|
||||
* Le JavaScript de PTA refuse toute réponse qui n'est pas du JSON : une notice
|
||||
* PHP ou une page d'erreur HTML se traduirait côté client par un message
|
||||
* incompréhensible. bootstrap() garantit donc que même un warning ou une
|
||||
* exception non rattrapée ressort en JSON.
|
||||
*
|
||||
* Les endpoints PDF sont la seule exception : ils émettent des octets binaires
|
||||
* et n'appellent volontairement pas bootstrap() (voir PdfResponse).
|
||||
*/
|
||||
final class JsonResponse
|
||||
{
|
||||
/**
|
||||
* Installe les gestionnaires d'erreur/exception qui convertissent tout
|
||||
* incident en réponse JSON 500. À appeler en toute première instruction du
|
||||
* point d'entrée, avant même l'ouverture de la connexion PDO.
|
||||
*/
|
||||
public static function bootstrap(): void
|
||||
{
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Les warnings/notices deviennent des ErrorException : elles remontent
|
||||
// ainsi au gestionnaire d'exception ci-dessous au lieu de polluer la
|
||||
// sortie avec du HTML qui casserait le JSON.
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
// Respecte @ et error_reporting() : on laisse alors PHP gérer.
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
@@ -54,27 +34,15 @@ final class JsonResponse
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Émet la réponse JSON et TERMINE le script.
|
||||
*
|
||||
* Ce exit final est ce qui permet aux gardes (Access, Csrf, Request) de
|
||||
* s'écrire en une ligne sans que l'appelant ait à tester leur retour.
|
||||
*
|
||||
* @param int $status Code HTTP (200, 403, 409, 422, 500…).
|
||||
* @param array<string, mixed> $payload Corps sérialisé en JSON.
|
||||
*/
|
||||
public static function send(int $status, array $payload): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
// Les réponses dépendent du rôle en session : jamais de cache partagé.
|
||||
header('Cache-Control: no-store');
|
||||
}
|
||||
|
||||
http_response_code($status);
|
||||
// UNESCAPED_UNICODE garde les accents lisibles ; INVALID_UTF8_SUBSTITUTE
|
||||
// évite qu'une donnée mal encodée en base fasse échouer tout l'encodage.
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -4,24 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Émission d'un PDF généré par les services de app/Services/.
|
||||
*
|
||||
* Les points d'entrée PDF n'appellent PAS JsonResponse::bootstrap() : ils
|
||||
* produisent un flux binaire, et un gestionnaire d'erreur qui écrirait du JSON
|
||||
* au milieu corromprait le document.
|
||||
*/
|
||||
final class PdfResponse
|
||||
{
|
||||
/**
|
||||
* Envoie le document en affichage direct dans le navigateur, puis TERMINE
|
||||
* le script.
|
||||
*
|
||||
* @param string $content Octets du PDF déjà assemblé.
|
||||
* @param string $filename Nom proposé ; il est assaini avant d'atterrir dans
|
||||
* l'en-tête Content-Disposition, où un guillemet ou
|
||||
* un retour ligne permettrait une injection d'en-tête.
|
||||
*/
|
||||
public static function inline(string $content, string $filename): void
|
||||
{
|
||||
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
|
||||
@@ -33,8 +17,6 @@ final class PdfResponse
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . strlen($content));
|
||||
// Un planning peut être revalidé d'une minute à l'autre : on interdit
|
||||
// au navigateur de resservir une version périmée.
|
||||
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||
header('Pragma: public');
|
||||
}
|
||||
|
||||
@@ -4,19 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Lecture de la requête entrante pour les endpoints JSON.
|
||||
*
|
||||
* Les deux méthodes coupent l'exécution en cas de non-conformité plutôt que de
|
||||
* lever une exception : l'appelant peut donc enchaîner sans bloc try/catch.
|
||||
*/
|
||||
final class Request
|
||||
{
|
||||
/**
|
||||
* Décode le corps JSON de la requête.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function json(): array
|
||||
{
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
@@ -27,9 +16,6 @@ final class Request
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Impose le verbe HTTP attendu (typiquement « POST » pour un endpoint mutant).
|
||||
*/
|
||||
public static function requireMethod(string $method): void
|
||||
{
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {
|
||||
|
||||
@@ -8,19 +8,6 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Référentiel des agents et lecture de leurs créneaux, tous lieux confondus.
|
||||
*
|
||||
* Deux notions à ne pas confondre :
|
||||
* - `agent.id_structure` est le lieu de rattachement PAR DÉFAUT. Il ne limite
|
||||
* pas où l'agent peut être planifié ;
|
||||
* - `agent_structure` est l'HISTORIQUE des rattachements, avec date de début et
|
||||
* de fin. Il est tenu à jour par updateProfile() et updateAssignment().
|
||||
*
|
||||
* Créer un agent ne se limite pas à insérer une ligne : createWithDefaults()
|
||||
* ouvre aussi son quota annuel et son PTA. Un agent sans quota afficherait un
|
||||
* dépassement dès sa première heure planifiée.
|
||||
*/
|
||||
final class AgentModel extends BaseModel
|
||||
{
|
||||
/** Enveloppe annuelle complémentaire de formation : 14 h. */
|
||||
@@ -46,161 +33,6 @@ final class AgentModel extends BaseModel
|
||||
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
|
||||
*
|
||||
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
|
||||
* manquent :
|
||||
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
|
||||
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
|
||||
* - pas de date de fin : contrat à durée indéterminée, la borne haute
|
||||
* n'existe pas.
|
||||
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
|
||||
* findActive() ou allActive().
|
||||
*
|
||||
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
|
||||
*/
|
||||
public static function contractRange(array $agent): array
|
||||
{
|
||||
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
|
||||
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
|
||||
|
||||
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la date tombe dans les bornes du contrat.
|
||||
*
|
||||
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
|
||||
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
|
||||
* de construire deux objets DateTime par créneau contrôlé.
|
||||
*/
|
||||
public static function contractCoversDate(array $agent, string $date): bool
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
|
||||
if ($debut !== null && $date < $debut) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !($fin !== null && $date > $fin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
|
||||
*
|
||||
* @param array<int, string> $dates
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function datesOutsideContract(array $agent, array $dates): array
|
||||
{
|
||||
$hors = [];
|
||||
foreach ($dates as $date) {
|
||||
$date = trim((string) $date);
|
||||
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
|
||||
$hors[$date] = true;
|
||||
}
|
||||
}
|
||||
$hors = array_keys($hors);
|
||||
sort($hors);
|
||||
|
||||
return $hors;
|
||||
}
|
||||
|
||||
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
|
||||
public static function contractPeriodLabel(array $agent): string
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
|
||||
if ($debut !== null && $fin !== null) {
|
||||
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
|
||||
}
|
||||
if ($debut !== null) {
|
||||
return sprintf('à partir du %s', $lisible($debut));
|
||||
}
|
||||
if ($fin !== null) {
|
||||
return sprintf('jusqu’au %s', $lisible($fin));
|
||||
}
|
||||
|
||||
return 'sans date de contrat renseignée';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message de refus commun à tous les points d'écriture, pour que
|
||||
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
|
||||
*
|
||||
* @param array<int, string> $datesHorsContrat
|
||||
*/
|
||||
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
|
||||
{
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
|
||||
if (count($datesHorsContrat) > 3) {
|
||||
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
|
||||
(string) ($agent['prenom'] ?? ''),
|
||||
(string) ($agent['nom'] ?? ''),
|
||||
self::contractPeriodLabel($agent),
|
||||
implode(', ', $exemples)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
|
||||
*
|
||||
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
|
||||
* pas laisser derrière lui des affectations orphelines. Interroge donc les
|
||||
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
|
||||
*
|
||||
* @return array{nombre: int, premiere: string|null, derniere: string|null}
|
||||
*/
|
||||
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
|
||||
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
|
||||
);
|
||||
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
|
||||
// émulation, un même nom ne peut pas apparaître deux fois.
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'debut' => $debut,
|
||||
'debut_compare' => $debut,
|
||||
'fin' => $fin,
|
||||
'fin_compare' => $fin,
|
||||
]);
|
||||
$ligne = $stmt->fetch() ?: [];
|
||||
|
||||
return [
|
||||
'nombre' => (int) ($ligne['nombre'] ?? 0),
|
||||
'premiere' => $ligne['premiere'] ?? null,
|
||||
'derniere' => $ligne['derniere'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection commune à toutes les lectures d'agent, complétée par la clause
|
||||
* WHERE de chaque appelant.
|
||||
*
|
||||
* Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de
|
||||
* rattachement, et doit rester listé dans ce cas.
|
||||
*
|
||||
* `structure_nom` et `structure_principale_nom` (idem pour le type) sont le
|
||||
* MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus
|
||||
* récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et
|
||||
* les modules JS.
|
||||
*/
|
||||
private const AGENT_SELECT = <<<'SQL'
|
||||
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||
@@ -220,41 +52,6 @@ final class AgentModel extends BaseModel
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
SQL;
|
||||
|
||||
/**
|
||||
* Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est
|
||||
* désactivé (V7.29).
|
||||
*
|
||||
* Trois précisions, toutes portées par le `NOT EXISTS` :
|
||||
*
|
||||
* - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu
|
||||
* son jeton, ce qui est l'état normal avant distribution : le filtrer
|
||||
* viderait les listes d'une installation neuve ;
|
||||
* - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui
|
||||
* est déjà exigé par ailleurs. Ce sont deux décisions distinctes :
|
||||
* fermer une fiche d'agent et couper un accès ;
|
||||
* - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de
|
||||
* consultation (planning par agent, vue annuelle, PTA détaillé) et le
|
||||
* référentiel « Agents » continuent de montrer l'agent — sans quoi on ne
|
||||
* pourrait plus ni lire son année passée, ni lui rétablir son accès.
|
||||
*
|
||||
* La table compte_acces est garantie présente : SchemaModel::assertCurrent()
|
||||
* refuse de démarrer si les vues de comptes manquent.
|
||||
*/
|
||||
private const SANS_COMPTE_COUPE = <<<'SQL'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM compte_acces ca
|
||||
WHERE ca.id_agent = a.id_agent AND ca.actif = 0
|
||||
)
|
||||
SQL;
|
||||
|
||||
/**
|
||||
* Tous les agents actifs, y compris ceux dont l'accès est coupé.
|
||||
*
|
||||
* Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte,
|
||||
* voir allSelectable().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -262,28 +59,6 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents proposables dans une liste d'affectation : actifs ET dont le compte
|
||||
* d'accès n'a pas été coupé.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allSelectable(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY a.nom, a.prenom"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
|
||||
* y ont un planning, quel que soit leur rattachement.
|
||||
*
|
||||
* C'est le périmètre d'un responsable de structure, aligné sur la vue
|
||||
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allVisibleForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -307,14 +82,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
|
||||
*
|
||||
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
|
||||
* répartition sur l'année civile entière.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allEligibleForPta(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -326,15 +93,6 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Référentiel des postes.
|
||||
*
|
||||
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
|
||||
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
|
||||
* alimentent les calculs de PtaModel.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allPosts(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -347,19 +105,6 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents du sélecteur de l'éditeur de planning.
|
||||
*
|
||||
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
|
||||
* planifié ailleurs que sur son rattachement. Le tri fait simplement
|
||||
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
|
||||
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
|
||||
* visuellement.
|
||||
*
|
||||
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listForLocation(int $selectedLocation = 0): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -371,18 +116,13 @@ final class AgentModel extends BaseModel
|
||||
FROM agent a
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . "
|
||||
WHERE a.actif = TRUE
|
||||
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute(['selected_location' => $selectedLocation]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche complète d'un agent actif.
|
||||
*
|
||||
* @return array<string, mixed>|null null si inexistant OU désactivé.
|
||||
*/
|
||||
public function findActive(int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -392,10 +132,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
|
||||
* rapprochement avec les outils RH, elle reste unique en base.
|
||||
*/
|
||||
public function matriculeExists(string $matricule): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
|
||||
@@ -403,25 +139,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
|
||||
* 1. la fiche `agent` ;
|
||||
* 2. sa première ligne d'historique `agent_structure` ;
|
||||
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
|
||||
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
|
||||
*
|
||||
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
|
||||
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
|
||||
* civile, jamais la date anniversaire du contrat.
|
||||
*
|
||||
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
|
||||
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
|
||||
* capturé plus haut dans $agentId.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
|
||||
*/
|
||||
public function createWithDefaults(array $data): array
|
||||
{
|
||||
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
|
||||
@@ -543,32 +260,6 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la fiche d'un agent et, si son rattachement change, son
|
||||
* historique `agent_structure`.
|
||||
*
|
||||
* L'historique n'est touché QUE si le lieu change réellement, sans quoi
|
||||
* chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne,
|
||||
* rendant l'historique illisible.
|
||||
*
|
||||
* La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont
|
||||
* la date de début est dans le FUTUR : on referme alors sur cette date de
|
||||
* début, pour ne pas produire une période qui finit avant de commencer.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le
|
||||
* passé : la ligne existante est réactivée plutôt que dupliquée.
|
||||
*
|
||||
* bindValue() explicite ici (et non un tableau passé à execute()) car
|
||||
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
|
||||
*
|
||||
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
|
||||
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
|
||||
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @throws \RuntimeException Si l'agent n'existe pas.
|
||||
*/
|
||||
public function updateProfile(int $agentId, array $data): void
|
||||
{
|
||||
try {
|
||||
@@ -585,9 +276,7 @@ final class AgentModel extends BaseModel
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE agent
|
||||
SET nom = :nom,
|
||||
prenom = :prenom,
|
||||
email = :email,
|
||||
SET email = :email,
|
||||
telephone = :telephone,
|
||||
adresse = :adresse,
|
||||
est_diplome = :est_diplome,
|
||||
@@ -602,8 +291,6 @@ final class AgentModel extends BaseModel
|
||||
WHERE id_agent = :agent_id'
|
||||
);
|
||||
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
|
||||
$update->bindValue(':nom', $data['nom']);
|
||||
$update->bindValue(':prenom', $data['prenom']);
|
||||
$update->bindValue(':email', $data['email']);
|
||||
$update->bindValue(':telephone', $data['telephone']);
|
||||
$update->bindValue(':adresse', $data['adresse']);
|
||||
@@ -653,15 +340,6 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le seul rattachement principal, sans toucher au reste de la fiche.
|
||||
*
|
||||
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
|
||||
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
|
||||
*
|
||||
* @param int|null $structureId null pour détacher l'agent de tout lieu par
|
||||
* défaut — il reste planifiable ponctuellement.
|
||||
*/
|
||||
public function updateAssignment(int $agentId, ?int $structureId): void
|
||||
{
|
||||
try {
|
||||
@@ -705,14 +383,6 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
|
||||
*
|
||||
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
|
||||
* telle que l'utilisateur la conçoit.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForWeek(int $agentId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -737,15 +407,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
|
||||
*
|
||||
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
|
||||
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
|
||||
* bornes de semaine.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -772,18 +433,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
|
||||
*
|
||||
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
|
||||
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
|
||||
* un motif en deux lignes.
|
||||
*
|
||||
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
|
||||
* est ce que le lecteur cherche en premier.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -809,11 +458,6 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul par motif sur une année civile complète.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForYear(int $agentId, int $year): array
|
||||
{
|
||||
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
|
||||
|
||||
@@ -9,42 +9,10 @@ use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Construction du calendrier annuel d'un agent, du 1er janvier au 31 décembre.
|
||||
*
|
||||
* Produit une structure entièrement prête à l'affichage : les 365 jours sont
|
||||
* calculés côté serveur, avec leurs libellés, classes CSS et infobulles. Le
|
||||
* JavaScript ne fait que rendre le tableau — c'est ce qui garantit que le
|
||||
* calendrier reste identique à l'écran, au PDF et à l'impression.
|
||||
*
|
||||
* MODÈLE DES QUATRE COLONNES par jour (constante CELL_KEYS) :
|
||||
* AP = Animation Périscolaire PP = Préparation Périscolaire
|
||||
* AE = Animation Extrascolaire PE = Préparation Extrascolaire
|
||||
*
|
||||
* Le classement d'un créneau dans l'une des quatre cases dépend de son motif et,
|
||||
* pour les motifs génériques, du caractère scolaire ou non de la journée
|
||||
* (voir cellForEntry()).
|
||||
*/
|
||||
final class AnnualOverviewModel extends BaseModel
|
||||
{
|
||||
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
|
||||
|
||||
/**
|
||||
* Calendrier annuel complet et ses totaux.
|
||||
*
|
||||
* Les créneaux sont lus EN UNE FOIS pour toute l'année puis indexés par date
|
||||
* en mémoire : 365 requêtes seraient inenvisageables.
|
||||
*
|
||||
* Trois familles de totaux cumulées en parallèle : par case (AP/PP/AE/PE),
|
||||
* par statut (validé / brouillon) et par motif particulier (congé,
|
||||
* formation, absence…). Elles répondent à trois questions différentes et ne
|
||||
* se déduisent pas les unes des autres.
|
||||
*
|
||||
* @return array<string, mixed> agent, year, quota_periods, calendar_totals,
|
||||
* months (12 entrées) et legend.
|
||||
*
|
||||
* @throws RuntimeException Si l'agent est introuvable ou inactif.
|
||||
*/
|
||||
public function overview(int $agentId, int $year): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
@@ -132,27 +100,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit UNE journée du calendrier à partir de ses créneaux.
|
||||
*
|
||||
* Les quatre cases sont toujours présentes, même vides : la grille HTML doit
|
||||
* garder un nombre de colonnes constant.
|
||||
*
|
||||
* Une case peut recevoir plusieurs créneaux ; elle prend alors la classe CSS
|
||||
* du motif le plus « fort » (voir strongestClass()), et son contenu affiche
|
||||
* les codes courts plutôt que la durée dès qu'un motif particulier est
|
||||
* présent — « CA » est plus parlant que « 7h00 » pour un jour de congé.
|
||||
*
|
||||
* `structure_id` retient le lieu du PREMIER créneau du jour, ou à défaut le
|
||||
* rattachement de l'agent : il sert à construire les liens profonds vers
|
||||
* l'écran de saisie.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Créneaux de la journée.
|
||||
* @param array<string, mixed>|null $vacation Période de vacances, ou null.
|
||||
* @param array<string, mixed> $agent Fiche de l'agent.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
|
||||
{
|
||||
$cells = [];
|
||||
@@ -239,18 +186,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Périodes de quota à afficher pour l'année.
|
||||
*
|
||||
* Trois dates de contexte sont interrogées (1er janvier, 31 décembre, et le
|
||||
* début de contrat s'il tombe dans l'année), puis dédoublonnées par leurs
|
||||
* bornes. Depuis que le quota suit l'année civile, elles donnent toutes la
|
||||
* même période : le résultat ne contient donc en pratique qu'une entrée. La
|
||||
* mécanique est conservée parce qu'elle reste juste si une règle de
|
||||
* proratisation en cours d'année est réintroduite.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
|
||||
{
|
||||
$quotaModel = new QuotaModel($this->pdo);
|
||||
@@ -275,14 +210,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return array_values($periods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif.
|
||||
*
|
||||
* Un motif non reconnu bascule sur le contexte de la journée : animation
|
||||
* extrascolaire pendant les vacances, périscolaire sinon. C'est le
|
||||
* comportement le moins surprenant pour un motif ajouté en base après coup,
|
||||
* et il évite qu'un temps disparaisse du calendrier.
|
||||
*/
|
||||
private function cellForEntry(string $code, bool $isVacation): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -295,12 +222,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Abréviation affichée dans la case pour les motifs PARTICULIERS.
|
||||
*
|
||||
* @return string|null null pour les motifs de travail ordinaires, qui
|
||||
* affichent alors leur durée plutôt qu'un code.
|
||||
*/
|
||||
private function shortCode(string $code): ?string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -315,10 +236,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe CSS d'un créneau, à partir de son motif et, à défaut, de sa case.
|
||||
* Les classes correspondantes sont définies dans assets/style.css.
|
||||
*/
|
||||
private function classForCode(string $code, string $cellKey): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -334,17 +251,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitre la classe d'une case contenant plusieurs motifs.
|
||||
*
|
||||
* Une seule couleur par case : c'est la plus « forte » qui l'emporte, selon
|
||||
* une priorité croissante allant du travail ordinaire à la journée non
|
||||
* travaillée. Une demi-journée de congé doit rester visible à côté d'une
|
||||
* demi-journée travaillée ; l'inverse ferait disparaître l'information.
|
||||
*
|
||||
* Le `>=` fait gagner le dernier créneau à priorité égale, ce qui est sans
|
||||
* conséquence puisque la classe est alors la même.
|
||||
*/
|
||||
private function strongestClass(string $current, string $candidate): string
|
||||
{
|
||||
$priority = [
|
||||
@@ -362,12 +268,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu textuel d'une case : rien si vide, les codes courts s'il y en a,
|
||||
* la durée compacte sinon.
|
||||
*
|
||||
* @param array<int, string> $codes
|
||||
*/
|
||||
private function cellDisplay(int $minutes, array $codes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
@@ -379,15 +279,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $this->formatCompactMinutes($minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de vacances contenant cette date, ou null.
|
||||
* Copie locale de VacationModel::vacationForDate() : les périodes sont déjà
|
||||
* chargées en mémoire pour toute l'année, inutile de repasser par le modèle.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
@@ -398,7 +289,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Durée en minutes entre deux HH:MM, plancher à 0. */
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
|
||||
@@ -406,14 +296,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décline chaque total en trois formes : minutes brutes, durée lisible
|
||||
* (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites.
|
||||
*
|
||||
* @param array<string, int> $totals
|
||||
*
|
||||
* @return array<string, array{minutes: int, duration: string, compact: string}>
|
||||
*/
|
||||
private function formatTotals(array $totals): array
|
||||
{
|
||||
$formatted = [];
|
||||
@@ -427,7 +309,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
@@ -435,7 +316,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
/** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */
|
||||
private function formatCompactMinutes(int $minutes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
@@ -446,7 +326,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
|
||||
}
|
||||
|
||||
/** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */
|
||||
private function monthName(int $month): string
|
||||
{
|
||||
return [
|
||||
@@ -456,7 +335,6 @@ final class AnnualOverviewModel extends BaseModel
|
||||
][$month];
|
||||
}
|
||||
|
||||
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
|
||||
private function weekdayShort(int $weekday): string
|
||||
{
|
||||
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user