Compare commits
7 Commits
worktree-u
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e8a06ca0 | ||
|
|
1a67080268 | ||
|
|
44730eefca | ||
|
|
b3e53f83b8 | ||
|
|
94e7f09eb4 | ||
|
|
5d8c0993a6 | ||
|
|
4201894e28 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,6 +19,9 @@ 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,6 +28,7 @@ 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
|
||||
@@ -52,7 +53,9 @@ Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fi
|
||||
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
|
||||
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_…`.
|
||||
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. C'est pourquoi la
|
||||
migration de la V7.11 s'appelle `migration_v7_9e_dsi_comptes_journal.sql` : le préfixe suit la
|
||||
dernière migration en date, pas le numéro de version fonctionnelle.
|
||||
|
||||
`PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau
|
||||
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
|
||||
@@ -65,7 +68,8 @@ Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'h
|
||||
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
|
||||
|
||||
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
|
||||
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
|
||||
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `manage_agents.php`,
|
||||
`manage_structures.php`, `administration.php`, `dsi.php` :
|
||||
|
||||
```php
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
@@ -118,19 +122,44 @@ l'application entière refuse de démarrer.
|
||||
|
||||
### Rôles et périmètres (`app/Core/Access.php`)
|
||||
|
||||
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).
|
||||
Quatre rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
|
||||
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout le métier, seul habilité à valider) et
|
||||
`DSI` (administration technique : comptes d'accès et journal, aucun droit métier).
|
||||
|
||||
`role.php` 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.
|
||||
`role.php` n'est plus un sélecteur depuis la V7.11 : c'est une **authentification par jeton** (voir
|
||||
`README_V7_11.md`). Une session ne peut naître que de `Access::openSession()`, appelée après
|
||||
vérification d'un jeton par `CompteModel::authentifier()`. Le rôle, l'agent et le lieu sont **lus sur
|
||||
le compte**, jamais déclarés par l'utilisateur. `Access::select()` a disparu — ne pas le
|
||||
réintroduire.
|
||||
|
||||
Les comptes `SERVICE_ENFANCE` et `DSI` exigent un **second facteur** depuis la V7.12 (voir
|
||||
`README_V7_12.md`) : après le jeton, un mot de passe choisi par le titulaire à sa première connexion
|
||||
et haché par `password_hash()`. La règle « qui doit avoir un mot de passe » est portée par la vue
|
||||
(`v_acces_compte.mot_de_passe_requis`), pas par du PHP. Entre les deux étapes, la session ne porte
|
||||
qu'une attente horodatée de cinq minutes : **un jeton validé sans mot de passe n'ouvre rien**. La DSI
|
||||
peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde.
|
||||
|
||||
Depuis la V7.27 (`README_V7_27.md`), le Service Enfance distribue lui-même les jetons des rôles
|
||||
`AGENT` et `RESPONSABLE_STRUCTURE`, depuis l'onglet « Administration »
|
||||
(`AccessAccountController`, `api/acces_*.php`). **La délégation s'arrête à ces deux rôles** : un
|
||||
compte `SERVICE_ENFANCE` créé par le Service Enfance serait une auto-promotion, un compte `DSI` lui
|
||||
ouvrirait le journal de ses propres actions. La borne est posée des deux côtés —
|
||||
`AccessAccountController::ROLES_DELEGUES` en PHP, `can_manage_field_access` en base — et ne doit être
|
||||
élargie ni d'un côté ni de l'autre. La DSI reste seule sur les comptes privilégiés, les mots de passe
|
||||
et le journal.
|
||||
|
||||
`Access` relit le compte à **chaque requête** (`v_acces_compte`, filtrée sur `actif` et
|
||||
`identite_active`) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé
|
||||
referment la session en cours. Ne pas mettre ce résultat en cache entre deux requêtes.
|
||||
|
||||
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
|
||||
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
|
||||
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
|
||||
la base vaut « refusé ». `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.
|
||||
la base vaut « refusé » (les lignes du rôle DSI viennent, elles, de
|
||||
`migration_v7_9e_dsi_comptes_journal.sql`). `SchemaModel` vérifie au démarrage la présence des sept
|
||||
vues de droits et des trois vues de comptes/journal, et fait échouer l'application si une migration
|
||||
manque — c'est volontairement fail-closed, et `docker/db/apply-migrations.sh` refait le même contrôle
|
||||
au déploiement.
|
||||
|
||||
`dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
|
||||
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
|
||||
@@ -138,14 +167,37 @@ jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.
|
||||
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
|
||||
|
||||
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
|
||||
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
|
||||
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
|
||||
commence par un garde `requireService()`, `requireDsi()`, `requireDraftEditor()`,
|
||||
`requireStructureEdit($id)`, `requireAgentView($id)`, `requireEntryEdit($id)`,
|
||||
`requirePlanningEdit($id)`… Ces gardes émettent
|
||||
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
|
||||
garde ni sans `Csrf::assertPayload($payload)`.**
|
||||
|
||||
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
|
||||
`csrf_token: window.PTA_CONFIG.csrfToken`.
|
||||
|
||||
### Journalisation (V7.11)
|
||||
|
||||
`App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par
|
||||
`app/bootstrap.php`, les appelants n'ont donc rien à transporter :
|
||||
|
||||
```php
|
||||
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
|
||||
```
|
||||
|
||||
Trois règles :
|
||||
|
||||
- **tout endpoint mutant doit journaliser son succès**, après l'écriture en base et avant le
|
||||
`JsonResponse::send()` — au même titre qu'il porte un garde et un `Csrf::assertPayload()` ;
|
||||
- **les lectures ne sont jamais journalisées** (choix de volume) ; les refus le sont, et `Access`
|
||||
comme `Csrf` s'en chargent déjà — inutile de les doubler dans les contrôleurs ;
|
||||
- **le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas
|
||||
entourer un appel de logique qui pourrait lever.
|
||||
|
||||
Le journal sert aussi de compteur au frein anti-force-brute de la connexion
|
||||
(`JournalModel::echecsRecents()`) : renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE`
|
||||
désarmerait ce frein silencieusement.
|
||||
|
||||
### Contraintes de sécurité à respecter
|
||||
|
||||
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
|
||||
@@ -174,7 +226,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.9` et `assets/js/*.js?v=7.9` dans
|
||||
Les assets sont versionnés à la main : `assets/style.css?v=7.23` et `assets/js/*.js?v=7.23` dans
|
||||
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
|
||||
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.
|
||||
@@ -183,14 +235,43 @@ les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
|
||||
|
||||
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
|
||||
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
|
||||
|
||||
@@ -214,5 +295,8 @@ message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation exp
|
||||
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
|
||||
- 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
75
README.md
@@ -1,75 +0,0 @@
|
||||
# PTA V6 — application PHP MVC multipage
|
||||
|
||||
PTA n’est plus une SPA. Chaque fonction principale correspond à une page PHP distincte :
|
||||
|
||||
- `planning.php` : saisie, brouillon, validation et semaines types ;
|
||||
- `pending.php` : plannings en attente de validation ;
|
||||
- `coverage.php` : contrôle des créneaux sans agent ;
|
||||
- `structures.php` : planning hebdomadaire par lieu ;
|
||||
- `agents.php` : planning mensuel et bilan par agent ;
|
||||
- `vacations.php` : aperçu et enregistrement des vacances scolaires ;
|
||||
- `administration.php` : lieux, agents et règles de couverture.
|
||||
|
||||
`index.php` redirige vers le choix de rôle lors de la première visite, puis vers la page d’accueil autorisée pour le profil sélectionné.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
page PHP
|
||||
→ PageController
|
||||
→ Models / Services
|
||||
→ View dédiée
|
||||
|
||||
View
|
||||
→ API JSON uniquement pour les actions asynchrones utiles
|
||||
```
|
||||
|
||||
Les contrôleurs métier, modèles, services PDF et endpoints API de la V5.20 sont conservés. La différence principale porte sur l’interface : plus de routeur JavaScript, plus de vues cachées et plus de chargement de tous les modules sur une seule page.
|
||||
|
||||
## JavaScript chargé par page
|
||||
|
||||
- Saisie : `planning-editor.js` et `week-templates.js` ;
|
||||
- À valider : `pending-validation.js` ;
|
||||
- Couverture : `coverage.js` ;
|
||||
- Par lieu : `structure-overview.js` ;
|
||||
- Par agent : `agent-overview.js` ;
|
||||
- Vacances : `vacation-calendar.js` ;
|
||||
- Administration : `administration.js` et la partie configuration de `coverage.js`.
|
||||
|
||||
`assets/js/page-bootstrap.js` initialise uniquement les modules déclarés par la page et applique les filtres transmis dans l’URL.
|
||||
|
||||
## Liens directs
|
||||
|
||||
Une page de contrôle peut ouvrir directement le planning concerné :
|
||||
|
||||
```text
|
||||
planning.php?structure_id=2&agent_id=7&week=2026-W31&focus=validation
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Configurer `config.php`.
|
||||
2. Utiliser le schéma SQL correspondant à la V5.20.
|
||||
3. Placer le dossier sur le serveur PHP.
|
||||
4. Ouvrir `index.php` ou `planning.php`.
|
||||
|
||||
Aucune migration SQL supplémentaire n’est nécessaire pour passer de la V5.20 à la V6.
|
||||
|
||||
## Version 7
|
||||
|
||||
Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis/vacances et les contrôles métier associés.
|
||||
|
||||
## V7.2 — Vue annuelle
|
||||
|
||||
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
|
||||
|
||||
|
||||
## V7.8 — Sécurité et droits en base
|
||||
|
||||
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
|
||||
vues SQL sont décrits dans `README_V7_8.md`. La migration
|
||||
`database/migration_v7_8_securite_acces.sql` est obligatoire.
|
||||
|
||||
## V7.4 — Rôles et droits d’accès
|
||||
|
||||
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.
|
||||
101
README_DOCKER.md
101
README_DOCKER.md
@@ -1,101 +0,0 @@
|
||||
# PTA - Docker / Nginx / PHP / MariaDB / phpMyAdmin
|
||||
|
||||
## Installation
|
||||
|
||||
Copier les fichiers de ce kit à la racine du projet PTA :
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
- `docker/nginx/default.conf`
|
||||
- `docker/php/php.ini`
|
||||
- `database/pta_prod_schema_mariadb.sql`
|
||||
|
||||
Puis :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Changer impérativement `DB_PASSWORD` et `DB_ROOT_PASSWORD`.
|
||||
|
||||
Démarrer :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Application :
|
||||
|
||||
```text
|
||||
http://IP_DU_SERVEUR:8080
|
||||
```
|
||||
|
||||
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
|
||||
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
|
||||
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
|
||||
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
|
||||
atteint le port.
|
||||
|
||||
Sur le serveur lui-même :
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Pour y accéder depuis un poste distant, le plus sûr est un tunnel SSH :
|
||||
|
||||
```bash
|
||||
ssh -L 8081:127.0.0.1:8081 utilisateur@serveur
|
||||
```
|
||||
|
||||
Puis ouvrir localement :
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
## Base de données
|
||||
|
||||
Le schéma `database/pta_prod_schema_mariadb.sql` est exécuté automatiquement uniquement
|
||||
lorsque le volume MariaDB est créé pour la première fois.
|
||||
|
||||
Il ne sera PAS rejoué à chaque démarrage du conteneur.
|
||||
|
||||
Si la base existe déjà, ne supprimez pas le volume pour appliquer une migration. Exécutez
|
||||
les migrations explicitement sur la base existante.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
# Etat
|
||||
docker compose ps
|
||||
|
||||
# Logs
|
||||
docker compose logs -f nginx php db
|
||||
|
||||
# Rebuild après modification du PHP / JS / CSS
|
||||
docker compose up -d --build php nginx
|
||||
|
||||
# Arrêt
|
||||
docker compose down
|
||||
|
||||
# Arrêt SANS supprimer les données
|
||||
docker compose down
|
||||
|
||||
# ATTENTION : supprime la base MariaDB persistée
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Connexion de l'application
|
||||
|
||||
Le `config.php` PTA utilise déjà les variables :
|
||||
|
||||
- `PTA_DB_HOST`
|
||||
- `PTA_DB_PORT`
|
||||
- `PTA_DB_NAME`
|
||||
- `PTA_DB_USER`
|
||||
- `PTA_DB_PASS`
|
||||
|
||||
Le compose les fournit automatiquement au conteneur PHP.
|
||||
@@ -2,5 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
runPage('administration');
|
||||
|
||||
@@ -2,5 +2,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Planning d'un agent mois par mois, tous lieux confondus.
|
||||
* Un rôle AGENT y est verrouillé sur lui-même et l'écran devient « Mon planning ».
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/page_bootstrap.php';
|
||||
runPage('agents');
|
||||
|
||||
@@ -2,5 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vue annuelle d'un agent et validation de l'année entière.
|
||||
*
|
||||
* Point d'entrée de page. Le nom passé à runPage() est à la fois la méthode de
|
||||
* PageController à appeler et la clé de page contrôlée dans acces_role_page :
|
||||
* sans la ligne correspondante en base, la page est refusée.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/page_bootstrap.php';
|
||||
runPage('annual');
|
||||
|
||||
20
api/acces_compte_creer.php
Normal file
20
api/acces_compte_creer.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un compte de terrain et renvoie son jeton, affiché une seule fois.
|
||||
* Charge utile : role (AGENT | RESPONSABLE_STRUCTURE), agent_id, structure_id,
|
||||
* commentaire, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->creerCompte();
|
||||
19
api/acces_compte_jeton.php
Normal file
19
api/acces_compte_jeton.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — régénère le jeton d'un compte de terrain ; l'ancien est révoqué.
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->regenererJeton();
|
||||
19
api/acces_compte_statut.php
Normal file
19
api/acces_compte_statut.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — active, désactive ou supprime un compte de terrain.
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->changerStatutCompte();
|
||||
20
api/acces_comptes.php
Normal file
20
api/acces_comptes.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — comptes d'accès des agents et des responsables de lieu (Service Enfance).
|
||||
* Les comptes Service Enfance et DSI n'y figurent jamais.
|
||||
* Paramètres : role, actif, q.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->comptes();
|
||||
19
api/acces_comptes_manquants.php
Normal file
19
api/acces_comptes_manquants.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — génère un jeton pour chaque agent et chaque lieu actif qui n'en a pas.
|
||||
* Charge utile : csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AccessAccountController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AccessAccountController($pdo))->genererJetonsManquants();
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — liste des agents (Service Enfance).
|
||||
* POST — change le lieu de rattachement principal d'un agent.
|
||||
* Charge utile POST : agent_id, structure_id (nullable), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — contrat horaire annuel d'un agent, au format PDF.
|
||||
* Paramètres : agent_id, puis date OU year. Sortie binaire : pas de bootstrap JSON.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — grille mensuelle d'un agent, tous lieux confondus.
|
||||
* Paramètres : agent_id, month (« 2026-02 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaine d'un agent, tous lieux confondus.
|
||||
* Paramètres : agent_id, week (« 2026-W08 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — planning d'un agent sur 6 ou 7 semaines, au format PDF.
|
||||
* Paramètres : agent_id, week (départ), weeks (6|7). Sortie binaire.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — bilan annuel d'heures d'un agent, au format PDF.
|
||||
* Paramètres : agent_id, puis date OU year. Sortie binaire.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — agents sélectionnables dans l'éditeur de planning.
|
||||
* Paramètre : structure_id (0 ou absent = tous les lieux, Service Enfance).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — calendrier annuel d'un agent (Service Enfance).
|
||||
* Paramètres : agent_id, year (facultatif, année en cours par défaut).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AnnualOverviewController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — contrôle puis valide TOUTE l'année d'un agent (Service Enfance).
|
||||
* Tout ou rien : une seule erreur et aucune semaine n'est validée.
|
||||
* Charge utile : agent_id, year, force_quota, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AnnualOverviewController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — applique une semaine type à UNE semaine (Service Enfance).
|
||||
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — applique une semaine type sur une période (Service Enfance).
|
||||
* Opération massive : passer d'abord par preview_week_template_period.php.
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — recopie une semaine entière d'un agent (Service Enfance).
|
||||
* Porte sur tous les lieux de la semaine source.
|
||||
* Charge utile : agent_id, source_week, target_week, mode (merge|replace), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — plages sous-effectif d'une semaine (responsable ou Service Enfance).
|
||||
* Un responsable est forcé sur son propre lieu.
|
||||
* Paramètres : week, structure_id (facultatif).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un agent, son quota annuel et son PTA (Service Enfance).
|
||||
* Charge utile : matricule, nom, prenom, structure_id, id_poste, type_contrat,
|
||||
* quotite_travail, date_debut_contrat, csrf_token, et champs facultatifs.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un lieu d'affectation (Service Enfance).
|
||||
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ 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,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime un créneau depuis la fiche agent.
|
||||
* Peut rouvrir un planning validé. Charge utile : entry_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime une période de vacances (Service Enfance).
|
||||
* Charge utile : period_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — supprime une semaine type (Service Enfance).
|
||||
* Les créneaux déjà appliqués ne sont pas touchés.
|
||||
* Charge utile : template_id, agent_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
20
api/dsi_compte_creer.php
Normal file
20
api/dsi_compte_creer.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — crée un compte d'accès et renvoie SON JETON EN CLAIR (DSI).
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire, csrf_token.
|
||||
* Le jeton n'est lisible que dans cette réponse : la base n'en garde que l'empreinte.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->creerCompte();
|
||||
19
api/dsi_compte_jeton.php
Normal file
19
api/dsi_compte_jeton.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — régénère le jeton d'un compte, révoquant l'ancien (DSI).
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->regenererJeton();
|
||||
22
api/dsi_compte_mot_de_passe.php
Normal file
22
api/dsi_compte_mot_de_passe.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — efface le mot de passe d'un compte Service Enfance ou DSI (DSI).
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*
|
||||
* La DSI ne peut que RETIRER un mot de passe, jamais en poser un : le titulaire
|
||||
* en choisit un nouveau à sa connexion suivante, avec son jeton inchangé.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->reinitialiserMotDePasse();
|
||||
19
api/dsi_compte_statut.php
Normal file
19
api/dsi_compte_statut.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — active, désactive ou supprime un compte d'accès (DSI).
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->changerStatutCompte();
|
||||
19
api/dsi_comptes.php
Normal file
19
api/dsi_comptes.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — comptes d'accès, identités sans compte et rôles proposés (DSI).
|
||||
* Paramètres : role, actif, q.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->comptes();
|
||||
20
api/dsi_comptes_manquants.php
Normal file
20
api/dsi_comptes_manquants.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — génère un jeton pour chaque agent et chaque lieu actif sans compte (DSI).
|
||||
* Charge utile : csrf_token. Outil de mise en service : la réponse porte tous
|
||||
* les jetons produits, en clair, et une seule fois.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->genererJetonsManquants();
|
||||
19
api/dsi_journal.php
Normal file
19
api/dsi_journal.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — recherche paginée dans le journal d'activité (DSI).
|
||||
* Paramètres : categorie, resultat, role, id_compte, q, du, au, page, taille.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->journal();
|
||||
19
api/dsi_journal_purge.php
Normal file
19
api/dsi_journal_purge.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — purge les entrées du journal antérieures à N jours (DSI, minimum 30).
|
||||
* Charge utile : jours, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\DsiController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new DsiController($pdo))->purgerJournal();
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — déplace ou duplique un créneau (glisser-déposer).
|
||||
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — file des plannings en attente de validation (Service Enfance).
|
||||
* Triée du plus ancien au plus récent, brouillons vides exclus.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — état complet d'une semaine de saisie.
|
||||
* Inclut les créneaux de l'agent sur les AUTRES lieux, en lecture seule.
|
||||
* Paramètres : structure_id, agent_id, week.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — propositions de vacances de l'API data.education.gouv.fr, SANS écriture.
|
||||
* Un échec du service distant ressort en 502.
|
||||
* Paramètres : academy, year (année civile).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — SIMULE l'application d'une semaine type sur une période, sans écrire.
|
||||
* Étape imposée avant apply_week_template_period.php.
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode, period_filter, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre ou supprime une contrainte prioritaire (Service Enfance).
|
||||
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
|
||||
* Charge utile : action, agent_id, csrf_token, puis constraint_id ou les champs de la contrainte.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — synthèse du PTA d'un agent à une date de référence (Service Enfance).
|
||||
* Paramètres : agent_id, date (facultative, aujourd'hui par défaut).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre ou supprime un souhait de lieu (Service Enfance).
|
||||
* Double action selon le champ `action` (SAVE par défaut, ou DELETE).
|
||||
* Charge utile : action, agent_id, csrf_token, puis wish_id ou les champs du souhait.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PtaController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — remplace INTÉGRALEMENT les règles de couverture d'un lieu (Service Enfance).
|
||||
* Une liste vide supprime toute règle.
|
||||
* Charge utile : structure_id, rules[], csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre les périodes de vacances retenues (Service Enfance).
|
||||
* Plafonné à 50 périodes par envoi.
|
||||
* Charge utile : periods[], csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre la semaine planifiée comme semaine type (Service Enfance).
|
||||
* Charge utile : agent_id, week, name, replace, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
19
api/save_week_template_entries.php
Normal file
19
api/save_week_template_entries.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — enregistre une semaine type composée dans l'éditeur (Service Enfance).
|
||||
* Charge utile : agent_id, name, entries[], replace, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new WeekTemplateController($pdo))->storeEntries();
|
||||
20
api/search_address.php
Normal file
20
api/search_address.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — propositions d'adresses de la Base Adresse Nationale, SANS écriture.
|
||||
* Un échec du service distant ressort en 502.
|
||||
* Paramètres : q (saisie en cours), limit (facultatif).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AddressController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new AddressController($pdo))->search();
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — règles de couverture d'un lieu (Service Enfance).
|
||||
* Paramètre : structure_id.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\CoverageController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaine d'un lieu, agent par agent.
|
||||
* Inclut les agents ponctuellement planifiés, pas seulement les rattachés.
|
||||
* Paramètres : structure_id, week.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — planning hebdomadaire d'un lieu, au format PDF.
|
||||
* Paramètres : structure_id, week. Sortie binaire : pas de bootstrap JSON.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — ajoute ou retire un agent d'une équipe (Service Enfance).
|
||||
* Charge utile : action (ADD|REMOVE), team_id, agent_id, fonction_equipe, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — change le statut d'une équipe (Service Enfance).
|
||||
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — liste des équipes, filtrable par date_debut / date_fin.
|
||||
* POST — crée une équipe (Service Enfance).
|
||||
* Charge utile POST : structure_id, type_equipe, libelle, date_debut, date_fin, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\TeamController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — modifie la fiche d'un agent (Service Enfance).
|
||||
* Ni le matricule ni la quotité de travail ne sont modifiables ici.
|
||||
* Charge utile : agent_id, champs de la fiche, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\AgentController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — modifie un créneau depuis la fiche agent.
|
||||
* Peut changer de jour, de semaine et de lieu ; peut rouvrir un planning validé.
|
||||
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\PlanningController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* POST — change le type d'un lieu d'affectation (Service Enfance).
|
||||
* Charge utile : structure_id, type_affectation, csrf_token.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\StructureController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — périodes de vacances enregistrées (Service Enfance).
|
||||
* Paramètre : school_year (facultatif, « 2026-2027 »).
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\VacationController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
19
api/week_template.php
Normal file
19
api/week_template.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — contenu détaillé d'une semaine type (Service Enfance).
|
||||
* Paramètres : agent_id, template_id — le couple sert de contrôle de périmètre.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
JsonResponse::bootstrap();
|
||||
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||
(new WeekTemplateController($pdo))->show();
|
||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/autoload.php';
|
||||
|
||||
/**
|
||||
* GET — semaines types d'un agent, avec leurs compteurs (Service Enfance).
|
||||
* Paramètre : agent_id.
|
||||
*
|
||||
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
|
||||
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
|
||||
*/
|
||||
use App\Controllers\WeekTemplateController;
|
||||
use App\Core\JsonResponse;
|
||||
|
||||
|
||||
366
app/Controllers/AccessAccountController.php
Normal file
366
app/Controllers/AccessAccountController.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CompteModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Jetons d'accès de terrain (V7.27) : le Service Enfance équipe ses agents et
|
||||
* ses responsables de lieu.
|
||||
*
|
||||
* POURQUOI CE CONTRÔLEUR EXISTE À CÔTÉ DE DsiController : c'est le Service
|
||||
* Enfance qui sait qu'un agent est arrivé, qu'un responsable a perdu son jeton
|
||||
* ou qu'un remplaçant s'en va. Passer par la DSI pour chaque mouvement ajoutait
|
||||
* un délai sans ajouter de contrôle. La DSI conserve tout : ses propres comptes,
|
||||
* ceux du Service Enfance, le journal, la purge.
|
||||
*
|
||||
* LA FRONTIÈRE DE PRIVILÈGE, à ne jamais élargir :
|
||||
*
|
||||
* - self::ROLES_DELEGUES ne contient QUE AGENT et RESPONSABLE_STRUCTURE.
|
||||
* Y ajouter SERVICE_ENFANCE ferait du rôle son propre distributeur — une
|
||||
* auto-promotion sans contrôle externe ; y ajouter DSI lui ouvrirait le
|
||||
* journal, c'est-à-dire la trace de ses propres actions ;
|
||||
* - toute méthode qui désigne un compte existant passe par
|
||||
* compteDelegue() AVANT d'agir. Un identifiant de compte privilégié est
|
||||
* refusé en 403, jamais silencieusement ignoré ;
|
||||
* - la liste blanche est doublée en base
|
||||
* (database/migration_v7_9h_jetons_service_enfance.sql) : une frontière de
|
||||
* privilège ne doit pas dépendre d'un seul des deux côtés.
|
||||
*
|
||||
* Les deux règles de DsiController restent valables ici :
|
||||
* 1. un jeton en clair ne sort qu'AU MOMENT où il vient d'être produit ;
|
||||
* 2. toute action est journalisée en catégorie ADMINISTRATION, sans jamais
|
||||
* recopier le jeton dans la trace.
|
||||
*/
|
||||
final class AccessAccountController
|
||||
{
|
||||
/**
|
||||
* Les seuls rôles dont le Service Enfance distribue les jetons.
|
||||
* Lire l'en-tête de classe avant d'y toucher.
|
||||
*/
|
||||
private const ROLES_DELEGUES = [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE];
|
||||
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/acces_comptes.php — comptes de terrain et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role (dans la liste déléguée), actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'roles' => self::ROLES_DELEGUES,
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
// v_acces_compte_manquant ne produit que des identités AGENT et
|
||||
// RESPONSABLE_STRUCTURE : elle est déjà dans le périmètre délégué.
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
'roles' => $this->rolesDelegues(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_creer.php — crée un compte de terrain et renvoie son
|
||||
* jeton.
|
||||
*
|
||||
* Le libellé n'est pas saisi : pour ces deux rôles il est TOUJOURS déduit de
|
||||
* l'identité (nom de l'agent, nom du lieu), par CompteModel. Un titulaire ne
|
||||
* peut donc pas être maquillé sous un autre nom dans la liste.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, commentaire, csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$this->assertRoleDelegue($role);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
'',
|
||||
$commentaire !== '' ? $commentaire : null
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $resultat['compte'];
|
||||
Journal::administration('COMPTE_CREE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => (int) $compte['id_compte'],
|
||||
'description' => sprintf(
|
||||
'Création du compte d’accès « %s » (%s) par le Service Enfance.',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $compte['role'],
|
||||
'prefixe' => $compte['jeton_prefixe'],
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
|
||||
'compte' => $compte,
|
||||
'jeton' => $resultat['jeton'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_jeton.php — régénère le jeton d'un compte de terrain.
|
||||
*
|
||||
* C'est la procédure du jeton perdu comme du jeton compromis : l'ancien
|
||||
* cesse immédiatement de fonctionner.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$compte = $this->compteDelegue($compteId);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$apres = $this->comptes->trouver($compteId) ?? $compte;
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » par le Service Enfance : l’ancien jeton est révoqué.',
|
||||
(string) $apres['identite_libelle']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $apres['role'],
|
||||
'prefixe' => $apres['jeton_prefixe'] ?? null,
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $apres,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_compte_statut.php — active, désactive ou supprime un compte
|
||||
* de terrain.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur.
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
|
||||
|
||||
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Action inconnue.']);
|
||||
}
|
||||
|
||||
// La fiche est lue AVANT l'opération, et sa lecture vaut contrôle de
|
||||
// périmètre : après une suppression, il n'y aurait plus ni rôle à
|
||||
// vérifier ni libellé à écrire dans le journal. Le compte de la session
|
||||
// en cours est hors périmètre par construction (c'est un compte
|
||||
// SERVICE_ENFANCE), il n'y a donc pas d'auto-désactivation à empêcher.
|
||||
$compte = $this->compteDelegue($compteId);
|
||||
|
||||
try {
|
||||
if ($action === 'SUPPRIMER') {
|
||||
$this->comptes->supprimer($compteId);
|
||||
} else {
|
||||
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
|
||||
}
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$libelles = [
|
||||
'ACTIVER' => 'Réactivation',
|
||||
'DESACTIVER' => 'Désactivation',
|
||||
'SUPPRIMER' => 'Suppression',
|
||||
];
|
||||
|
||||
Journal::administration('COMPTE_' . $action, [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'%s du compte d’accès « %s » (%s) par le Service Enfance.',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => [
|
||||
'role' => $compte['role'],
|
||||
'prefixe' => $compte['jeton_prefixe'],
|
||||
'canal' => 'DELEGATION_SERVICE_ENFANCE',
|
||||
],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/acces_comptes_manquants.php — équipe d'un coup toutes les
|
||||
* identités actives sans compte.
|
||||
*
|
||||
* Sans danger d'élargissement : v_acces_compte_manquant ne connaît que des
|
||||
* agents et des lieux, jamais un compte privilégié. La réponse porte tous
|
||||
* les jetons produits, en clair — seule occasion de les lire.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
try {
|
||||
$produits = $this->comptes->genererManquants();
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('JETONS_GENERES_EN_LOT', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf(
|
||||
'Génération de %d jeton(s) de terrain par le Service Enfance.',
|
||||
count($produits)
|
||||
),
|
||||
'contexte' => ['nombre' => count($produits), 'canal' => 'DELEGATION_SERVICE_ENFANCE'],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Tous les agents et tous les lieux actifs disposent déjà d’un compte.'
|
||||
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
|
||||
'jetons' => $produits,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit un compte et REFUSE tout ce qui sort du périmètre délégué.
|
||||
*
|
||||
* Le refus est un 403 explicite et non un 404 : masquer l'existence du
|
||||
* compte n'apporterait rien (le Service Enfance sait parfaitement que la DSI
|
||||
* existe) et rendrait le message incompréhensible à l'utilisateur de bonne
|
||||
* foi qui a suivi un lien périmé.
|
||||
*
|
||||
* @return array<string, mixed> Le compte, garanti dans le périmètre.
|
||||
*/
|
||||
private function compteDelegue(int $compteId): array
|
||||
{
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
}
|
||||
|
||||
if (!in_array((string) $compte['role'], self::ROLES_DELEGUES, true)) {
|
||||
Journal::refus('COMPTE_HORS_PERIMETRE', 'Tentative de gestion d’un compte privilégié hors délégation.', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'contexte' => ['role_cible' => $compte['role'], 'statut_http' => 403],
|
||||
]);
|
||||
JsonResponse::send(403, [
|
||||
'error' => 'Les comptes Service Enfance et DSI relèvent de la seule DSI.',
|
||||
'code' => 'ACCESS_DENIED',
|
||||
]);
|
||||
}
|
||||
|
||||
return $compte;
|
||||
}
|
||||
|
||||
/** Refuse un rôle demandé hors périmètre délégué, avant toute écriture. */
|
||||
private function assertRoleDelegue(string $role): void
|
||||
{
|
||||
if (in_array($role, self::ROLES_DELEGUES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::refus('ROLE_HORS_PERIMETRE', 'Tentative de création d’un compte hors délégation.', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'contexte' => ['role_demande' => $role, 'statut_http' => 403],
|
||||
]);
|
||||
JsonResponse::send(403, [
|
||||
'error' => 'Vous ne pouvez créer que des comptes Agent et Responsable de structure. Les comptes Service Enfance et DSI relèvent de la DSI.',
|
||||
'code' => 'ACCESS_DENIED',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDelegues(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Controllers/AddressController.php
Normal file
78
app/Controllers/AddressController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Services\AddressApiService;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Recherche d'adresses postales (V7.25), relais vers la Base Adresse Nationale.
|
||||
*
|
||||
* Sert l'autocomplétion du champ « Adresse personnelle » de la page Agents. Le
|
||||
* relais est indispensable : la CSP interdit au navigateur d'appeler un domaine
|
||||
* tiers (`connect-src 'self'`, voir App\Core\Security).
|
||||
*
|
||||
* LECTURE PURE : aucune écriture en base, donc ni jeton CSRF ni journalisation
|
||||
* (CLAUDE.md — « les lectures ne sont jamais journalisées »). Le garde reste
|
||||
* celui de l'écriture des fiches agent, `requireService()` : c'est le seul rôle
|
||||
* qui saisit une adresse.
|
||||
*
|
||||
* Un échec du service distant ressort en 502 et non en 500 : c'est la BAN qui
|
||||
* n'a pas répondu, pas PTA qui a échoué. Côté interface, cet échec n'est jamais
|
||||
* bloquant — l'adresse reste saisissable à la main.
|
||||
*/
|
||||
final class AddressController
|
||||
{
|
||||
private Access $access;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/search_address.php — propositions d'adresses.
|
||||
* Paramètres GET : q (saisie en cours), limit (facultatif, 1 à 8).
|
||||
*/
|
||||
public function search(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
|
||||
if ($length < AddressApiService::MIN_QUERY_LENGTH) {
|
||||
JsonResponse::send(422, [
|
||||
'error' => sprintf('Saisissez au moins %d caractères.', AddressApiService::MIN_QUERY_LENGTH),
|
||||
]);
|
||||
}
|
||||
// Plafond de saisie : la BAN n'exploite pas au-delà, et cela borne le
|
||||
// coût d'une requête forgée.
|
||||
if ($length > 120) {
|
||||
JsonResponse::send(422, ['error' => 'Saisie trop longue pour une recherche d’adresse.']);
|
||||
}
|
||||
|
||||
$limit = filter_var($_GET['limit'] ?? 5, FILTER_VALIDATE_INT);
|
||||
if ($limit === false) {
|
||||
$limit = 5;
|
||||
}
|
||||
|
||||
try {
|
||||
$suggestions = (new AddressApiService())->search($query, $limit);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(502, [
|
||||
'error' => 'Le service national des adresses est momentanément indisponible. Vous pouvez saisir l’adresse manuellement.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '0') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'query' => $query,
|
||||
'suggestions' => $suggestions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ 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;
|
||||
@@ -20,6 +22,18 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Fiche agent : consultation, référentiel des agents et documents PDF.
|
||||
*
|
||||
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
|
||||
* DIFFÉRENTES et qu'il ne faut pas mélanger :
|
||||
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
|
||||
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
|
||||
* un flux binaire. Leurs points d'entrée n'appellent PAS
|
||||
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
|
||||
* http_response_code() suivi d'un `return` et d'un message en texte brut.
|
||||
* Y écrire du JSON corromprait le document téléchargé.
|
||||
*/
|
||||
final class AgentController
|
||||
{
|
||||
private AgentModel $agentModel;
|
||||
@@ -35,6 +49,13 @@ final class AgentController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
|
||||
*
|
||||
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
|
||||
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
|
||||
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
|
||||
*/
|
||||
public function listForPlanning(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -45,6 +66,15 @@ final class AgentController
|
||||
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
|
||||
*
|
||||
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
|
||||
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
|
||||
* endroits. En lecture seule pour le rôle AGENT.
|
||||
*
|
||||
* Paramètres GET : agent_id, week (« 2026-W08 »).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -73,6 +103,21 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
|
||||
*
|
||||
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
|
||||
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
|
||||
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
|
||||
* `in_month` de chaque jour permet à l'interface de griser les débordements.
|
||||
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
|
||||
* hebdomadaires affichés seraient faux.
|
||||
*
|
||||
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
|
||||
* compris, pour que les totaux de semaine restent justes.
|
||||
*
|
||||
* Paramètres GET : agent_id, month (« 2026-02 »).
|
||||
*/
|
||||
public function monthOverview(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -149,6 +194,16 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
|
||||
*
|
||||
* La période couverte n'est pas choisie par l'appelant : elle est celle
|
||||
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
|
||||
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
|
||||
* n'est qu'un raccourci vers le 1er janvier correspondant.
|
||||
*
|
||||
* Paramètres GET : agent_id, puis date OU year.
|
||||
*/
|
||||
public function timeSummaryPdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -194,6 +249,15 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
|
||||
*
|
||||
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
|
||||
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
|
||||
* sur page suivante.
|
||||
*
|
||||
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
|
||||
*/
|
||||
public function pdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -257,6 +321,14 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
|
||||
*
|
||||
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
|
||||
* détail des créneaux planifiés.
|
||||
*
|
||||
* Paramètres GET : agent_id, puis date OU year.
|
||||
*/
|
||||
public function hoursContractPdf(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -296,6 +368,19 @@ final class AgentController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* api/agent_assignments.php — rattachement principal d'un agent.
|
||||
*
|
||||
* Endpoint à double verbe, exception au reste de l'API : GET liste les
|
||||
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
|
||||
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
|
||||
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
|
||||
*
|
||||
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
|
||||
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
|
||||
*
|
||||
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
|
||||
*/
|
||||
public function assignments(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -331,6 +416,16 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_RATTACHEMENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$structureId === null
|
||||
? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId)
|
||||
: sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId),
|
||||
['id_structure' => $structureId !== null ? (int) $structureId : null]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $structureId === null
|
||||
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
|
||||
@@ -338,6 +433,19 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/create_agent.php — création d'un agent.
|
||||
*
|
||||
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
|
||||
* poste existant en base, type de contrat dans l'énumération, dates
|
||||
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
|
||||
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
|
||||
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
|
||||
* avec l'existant, pas une saisie malformée).
|
||||
*
|
||||
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
|
||||
* l'insère dans sa liste sans avoir à recharger toute la page.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -377,8 +485,15 @@ final class AgentController
|
||||
if ($rate === false || $rate <= 0 || $rate > 100) {
|
||||
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
|
||||
}
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
|
||||
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
|
||||
// Les règles de forme vivent dans ContactFormat, communes à create() et
|
||||
// update(), et recopiées dans assets/js/field-format.js pour le retour
|
||||
// immédiat à la saisie.
|
||||
if ($email !== '' && !ContactFormat::isEmail($email)) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
|
||||
}
|
||||
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
|
||||
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
|
||||
}
|
||||
if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|
||||
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
@@ -411,6 +526,10 @@ 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,
|
||||
@@ -418,6 +537,14 @@ 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, [
|
||||
@@ -444,6 +571,18 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_agent.php — modification de la fiche d'un agent.
|
||||
*
|
||||
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
|
||||
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
|
||||
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
|
||||
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
|
||||
*
|
||||
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
|
||||
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
|
||||
* rétroactivement tous les suivis.
|
||||
*/
|
||||
public function updateProfile(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -452,6 +591,8 @@ final class AgentController
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$nom = trim((string) ($payload['nom'] ?? ''));
|
||||
$prenom = trim((string) ($payload['prenom'] ?? ''));
|
||||
$email = trim((string) ($payload['email'] ?? ''));
|
||||
$telephone = trim((string) ($payload['telephone'] ?? ''));
|
||||
$adresse = trim((string) ($payload['adresse'] ?? ''));
|
||||
@@ -476,27 +617,68 @@ final class AgentController
|
||||
if (!$agentId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
// La fiche AVANT modification est conservée : elle sert à repérer un
|
||||
// renommage et à le nommer explicitement dans le journal.
|
||||
$agentAvant = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agentAvant === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
|
||||
}
|
||||
if ($nom === '' || $prenom === '') {
|
||||
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
|
||||
}
|
||||
if (!$postId || !in_array((int) $postId, $validPostIds, true)
|
||||
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|
||||
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|
||||
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
|
||||
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
|
||||
}
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
|
||||
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
|
||||
// Les règles de forme vivent dans ContactFormat, communes à create() et
|
||||
// update(), et recopiées dans assets/js/field-format.js pour le retour
|
||||
// immédiat à la saisie.
|
||||
if ($email !== '' && !ContactFormat::isEmail($email)) {
|
||||
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
|
||||
}
|
||||
if (strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
|
||||
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
|
||||
}
|
||||
if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30
|
||||
|| strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
|
||||
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
|
||||
}
|
||||
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
|
||||
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
|
||||
}
|
||||
|
||||
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
|
||||
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
|
||||
// contrat sous des affectations existantes. Le refus nomme la période
|
||||
// concernée, pour que l'utilisateur sache quoi retirer avant de
|
||||
// recommencer.
|
||||
$orphelins = $this->agentModel->entriesOutsideContract(
|
||||
(int) $agentId,
|
||||
$contractStart,
|
||||
$contractEnd !== '' ? $contractEnd : null
|
||||
);
|
||||
if ($orphelins['nombre'] > 0) {
|
||||
$lisible = static fn(?string $date): string => $date === null
|
||||
? '—'
|
||||
: implode('/', array_reverse(explode('-', $date)));
|
||||
JsonResponse::send(422, [
|
||||
'error' => sprintf(
|
||||
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
|
||||
$orphelins['nombre'],
|
||||
$lisible($orphelins['premiere']),
|
||||
$lisible($orphelins['derniere'])
|
||||
),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->agentModel->updateProfile((int) $agentId, [
|
||||
'nom' => $nom,
|
||||
'prenom' => $prenom,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'telephone' => $telephone !== '' ? $telephone : null,
|
||||
'adresse' => $adresse !== '' ? $adresse : null,
|
||||
@@ -517,12 +699,42 @@ final class AgentController
|
||||
]);
|
||||
}
|
||||
|
||||
// La fiche est relue une seule fois : elle sert à la fois au libellé de
|
||||
// la trace et à la réponse.
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
|
||||
$identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']);
|
||||
$identiteApres = trim($prenom . ' ' . $nom);
|
||||
$renomme = $identiteAvant !== $identiteApres;
|
||||
|
||||
Journal::ecriture(
|
||||
'AGENT_MODIFIE',
|
||||
'agent',
|
||||
(int) $agentId,
|
||||
$renomme
|
||||
// Un renommage doit se lire dans le journal sans avoir à
|
||||
// comparer deux lignes : l'ancienne identité y figure en clair.
|
||||
? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant)
|
||||
: sprintf('Modification de la fiche de l’agent %s.', $identiteApres),
|
||||
[
|
||||
'id_structure' => $structureId !== null ? (int) $structureId : null,
|
||||
'type_contrat' => $contractType,
|
||||
'identite_precedente' => $renomme ? $identiteAvant : null,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les informations de l’agent ont été mises à jour.',
|
||||
'agent' => $this->agentModel->findActive((int) $agentId),
|
||||
'agent' => $agent,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
|
||||
* pas de base commune ; toute correction doit être reportée partout.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AnnualOverviewModel;
|
||||
@@ -14,12 +15,28 @@ use App\Models\QuotaModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Vue annuelle d'un agent et validation de l'année en une opération.
|
||||
*
|
||||
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
|
||||
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
|
||||
* s'apprécie le quota annualisé (voir QuotaModel).
|
||||
*
|
||||
* Réservé au Service Enfance : valider une année couvre par nature tous les
|
||||
* lieux où l'agent est intervenu.
|
||||
*/
|
||||
final class AnnualOverviewController
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/annual_overview.php — répartition de l'année d'un agent.
|
||||
*
|
||||
* L'année est facultative et retombe sur l'année en cours.
|
||||
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
(new Access($this->pdo))->requireService();
|
||||
@@ -42,12 +59,27 @@ final class AnnualOverviewController
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/annual_validate.php — contrôle puis validation de toute l'année.
|
||||
*
|
||||
* Contrôle puis validation en une seule opération de tous les plannings
|
||||
* en brouillon de l'agent pour l'année civile affichée.
|
||||
*
|
||||
* Aucune validation partielle : si une erreur est trouvée, aucun planning
|
||||
* n'est validé. Les semaines concernées sont retournées au front afin de
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger.
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger. Un succès
|
||||
* partiel laisserait l'utilisateur devant une année à moitié validée, sans
|
||||
* moyen simple de savoir ce qui reste.
|
||||
*
|
||||
* Trois issues, alignées sur PlanningController::validate() :
|
||||
* - 422 + status « errors » : au moins une ERREUR, rien n'est validé ;
|
||||
* - 409 + status « warning » : quota annuel dépassé, confirmation attendue
|
||||
* par un rappel avec force_quota: true ;
|
||||
* - 200 + status « validated » ou « nothing_to_validate ».
|
||||
*
|
||||
* Contrairement à validate() unitaire, le quota est ici évalué à l'échelle
|
||||
* de l'ANNÉE (annualForApi) et non de la semaine.
|
||||
*
|
||||
* Charge utile : agent_id, year, force_quota (bool), csrf_token.
|
||||
*/
|
||||
public function validateYear(): void
|
||||
{
|
||||
@@ -133,6 +165,19 @@ final class AnnualOverviewController
|
||||
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
|
||||
$validatedCount = $planningModel->markValidatedMany($ids);
|
||||
|
||||
Journal::ecriture(
|
||||
'ANNEE_VALIDEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'annee' => (int) $year,
|
||||
'id_plannings' => $ids,
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -153,6 +198,24 @@ final class AnnualOverviewController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôles bloquants d'un planning, dans la variante « année ».
|
||||
*
|
||||
* DIFFÉRENCE VOULUE avec PlanningController::validate() : les chevauchements
|
||||
* inter-lieux sont recherchés via externalOverlaps() (tous statuts) et non
|
||||
* validatedExternalOverlaps() (validés seulement). Une validation annuelle
|
||||
* traite en un bloc l'ensemble des brouillons de l'agent : ils vont tous
|
||||
* devenir opposables, deux brouillons qui se recouvrent doivent donc être
|
||||
* signalés dès maintenant.
|
||||
*
|
||||
* Un contrôle INFO est ajouté quand rien n'a été trouvé, pour que
|
||||
* l'interface affiche « aucune anomalie » plutôt qu'une liste vide
|
||||
* indistincte d'une absence de contrôle.
|
||||
*
|
||||
* @param array<string, mixed> $planning
|
||||
*
|
||||
* @return array<int, array{niveau: string, code: string, message: string}>
|
||||
*/
|
||||
private function controlsForPlanning(PlanningModel $model, array $planning): array
|
||||
{
|
||||
$planningId = (int) $planning['id_planning'];
|
||||
@@ -162,6 +225,19 @@ final class AnnualOverviewController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideContract($planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
@@ -203,6 +279,13 @@ final class AnnualOverviewController
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empile un contrôle au format commun {niveau, code, message}.
|
||||
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
|
||||
* pas de base commune.
|
||||
*
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
*/
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CoverageModel;
|
||||
@@ -13,6 +14,19 @@ use App\Models\StructureModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contrôle de couverture : effectif planifié comparé au minimum attendu.
|
||||
*
|
||||
* Chaque lieu peut déclarer des règles de couverture (jour, plage horaire,
|
||||
* nombre minimum d'agents). Ce contrôleur confronte ces règles aux plannings
|
||||
* réels et remonte les « trous ».
|
||||
*
|
||||
* Deux niveaux de droits distincts :
|
||||
* - alerts() : responsable et Service Enfance, un responsable étant forcé sur
|
||||
* son propre lieu quoi qu'il demande ;
|
||||
* - rules() / saveRules() : Service Enfance seul, les règles étant un
|
||||
* référentiel et non une donnée de saisie.
|
||||
*/
|
||||
final class CoverageController
|
||||
{
|
||||
private CoverageModel $coverageModel;
|
||||
@@ -26,6 +40,22 @@ final class CoverageController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/coverage_alerts.php — trous de couverture d'une semaine.
|
||||
*
|
||||
* Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé
|
||||
* est ignoré pour un responsable, écrasé par son propre lieu : un simple 403
|
||||
* l'empêcherait de consulter son écran avec une URL partagée.
|
||||
*
|
||||
* Trois familles de retours, à ne pas confondre :
|
||||
* - `alerts` : plages réellement sous-couvertes ;
|
||||
* - `validation_warnings` : couverture assurée par des plannings encore en
|
||||
* brouillon, donc pas encore fiable ;
|
||||
* - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas
|
||||
* produire d'alerte, et les taire les ferait passer pour conformes.
|
||||
*
|
||||
* Paramètres GET : week, structure_id (facultatif).
|
||||
*/
|
||||
public function alerts(): void
|
||||
{
|
||||
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
|
||||
@@ -100,6 +130,9 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_coverage_rules.php — règles de couverture d'un lieu.
|
||||
*/
|
||||
public function rules(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -119,6 +152,19 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_structure_coverage_rules.php — remplace les règles d'un lieu.
|
||||
*
|
||||
* Remplacement INTÉGRAL : la liste reçue devient l'unique jeu de règles du
|
||||
* lieu, et une liste vide supprime toute règle (le lieu redevient « non
|
||||
* configuré »). Il n'y a pas d'ajout ni de suppression unitaire.
|
||||
*
|
||||
* Les règles sont validées ici — jour ouvré, horaires au quart d'heure, fin
|
||||
* après début, minimum dans [1, 50], puis absence de chevauchement — avant
|
||||
* d'être confiées au modèle, qui n'effectue aucun contrôle métier.
|
||||
*
|
||||
* Charge utile : structure_id, rules[], csrf_token.
|
||||
*/
|
||||
public function saveRules(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -176,12 +222,29 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'COUVERTURE_ENREGISTREE',
|
||||
'structure_couverture_horaire',
|
||||
(int) $structureId,
|
||||
sprintf('Horaires de couverture du lieu #%d enregistrés : %d plage(s).', (int) $structureId, count($rules)),
|
||||
['id_structure' => (int) $structureId, 'nombre_plages' => count($rules)]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Les horaires de couverture du lieu ont été enregistrés.',
|
||||
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse deux plages de couverture qui se recouvrent le même jour.
|
||||
*
|
||||
* Les règles sont regroupées par jour puis triées : il suffit alors de
|
||||
* comparer chaque début à la fin précédente, en O(n log n) et non en O(n²).
|
||||
* Des plages jointives sont acceptées (une fin égale à un début).
|
||||
*
|
||||
* @param array<int, array{jour_semaine: int, heure_debut: string, heure_fin: string}> $rules
|
||||
*/
|
||||
private function assertNoOverlap(array $rules): void
|
||||
{
|
||||
$byDay = [];
|
||||
@@ -203,6 +266,7 @@ final class CoverageController
|
||||
}
|
||||
}
|
||||
|
||||
/** HH:MM au quart d'heure, entre 00:00 et 23:45. */
|
||||
private function validQuarterHour(string $time): bool
|
||||
{
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
|
||||
@@ -211,17 +275,24 @@ final class CoverageController
|
||||
return true;
|
||||
}
|
||||
|
||||
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
|
||||
private function timeToMinutes(string $time): int
|
||||
{
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
/** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */
|
||||
private function durationMinutes(string $start, string $end): int
|
||||
{
|
||||
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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;
|
||||
|
||||
@@ -45,6 +47,19 @@ 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();
|
||||
@@ -264,6 +279,12 @@ final class DashboardController
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante.
|
||||
*
|
||||
* Contrairement aux endpoints de saisie, une semaine illisible n'est pas une
|
||||
* erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer
|
||||
* la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC.
|
||||
*
|
||||
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
|
||||
*/
|
||||
private function resolveWeek(mixed $value): array
|
||||
@@ -294,6 +315,9 @@ final class DashboardController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée.
|
||||
*/
|
||||
private function resolveYear(mixed $value, int $fallback): int
|
||||
{
|
||||
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
|
||||
394
app/Controllers/DsiController.php
Normal file
394
app/Controllers/DsiController.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
|
||||
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
|
||||
* acces_role_page ne lui ouvre qu'une seule page.
|
||||
*
|
||||
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
|
||||
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
|
||||
* (création, régénération, génération en lot). Aucune lecture ne le
|
||||
* retourne, parce que la base ne l'a plus ;
|
||||
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
|
||||
* purge du journal — et sans jamais recopier le jeton dans la trace.
|
||||
*/
|
||||
final class DsiController
|
||||
{
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
private JournalModel $journal;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
$this->journal = new JournalModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_comptes.php — comptes d'accès et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role, actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
// Tant qu'un compte porte encore le jeton public de la migration,
|
||||
// l'interface doit le crier : c'est une porte ouverte documentée.
|
||||
'jeton_installation_actif' => $this->comptes->installationEnCours(),
|
||||
'roles' => $this->rolesDisponibles(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$libelle = trim((string) ($payload['libelle'] ?? ''));
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
$libelle,
|
||||
$commentaire !== '' ? $commentaire : null
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $resultat['compte'];
|
||||
Journal::administration('COMPTE_CREE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => (int) $compte['id_compte'],
|
||||
'description' => sprintf(
|
||||
'Création du compte d’accès « %s » (%s).',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
|
||||
'compte' => $compte,
|
||||
'jeton' => $resultat['jeton'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_jeton.php — régénère le jeton d'un compte.
|
||||
*
|
||||
* L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à
|
||||
* suivre pour un jeton perdu comme pour un jeton compromis.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouver($compteId) ?? [];
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » : l’ancien jeton est révoqué.',
|
||||
(string) ($compte['identite_libelle'] ?? 'compte inconnu')
|
||||
),
|
||||
'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $compte,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte
|
||||
* privilégié.
|
||||
*
|
||||
* La DSI ne REMPLACE pas le mot de passe, elle le RETIRE : le titulaire en
|
||||
* choisira un nouveau à sa prochaine connexion, avec son jeton. C'est ce qui
|
||||
* garantit que personne d'autre que lui ne l'a jamais connu — y compris la
|
||||
* personne qui vient de le réinitialiser.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$compte = $this->comptes->reinitialiserMotDePasse($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('MOT_DE_PASSE_REINITIALISE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf(
|
||||
'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_statut.php — active, désactive ou supprime un compte.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL).
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
|
||||
|
||||
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Action inconnue.']);
|
||||
}
|
||||
|
||||
// La fiche est lue AVANT l'opération : après une suppression, il n'y
|
||||
// aurait plus de libellé à écrire dans le journal.
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se retirer soi-même son propre accès en pleine session est une erreur
|
||||
// de manipulation, jamais une intention : on la refuse.
|
||||
if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') {
|
||||
JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($action === 'SUPPRIMER') {
|
||||
$this->comptes->supprimer($compteId);
|
||||
} else {
|
||||
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
|
||||
}
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$libelles = [
|
||||
'ACTIVER' => 'Réactivation',
|
||||
'DESACTIVER' => 'Désactivation',
|
||||
'SUPPRIMER' => 'Suppression',
|
||||
];
|
||||
|
||||
Journal::administration('COMPTE_' . $action, [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'%s du compte d’accès « %s » (%s).',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités
|
||||
* actives sans compte.
|
||||
*
|
||||
* C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni
|
||||
* responsable n'a de jeton, donc personne ne peut entrer. La réponse porte
|
||||
* tous les jetons produits, en clair — c'est la seule occasion de les lire,
|
||||
* et elle est faite pour être imprimée ou copiée avant de refermer l'écran.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
try {
|
||||
$produits = $this->comptes->genererManquants();
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('JETONS_GENERES_EN_LOT', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Génération de %d jeton(s) pour les identités sans compte.', count($produits)),
|
||||
'contexte' => ['nombre' => count($produits)],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Toutes les identités actives disposent déjà d’un compte.'
|
||||
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
|
||||
'jetons' => $produits,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_journal.php — recherche paginée dans le journal.
|
||||
*
|
||||
* Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page,
|
||||
* taille.
|
||||
*/
|
||||
public function journal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$resultat = $this->journal->rechercher([
|
||||
'categorie' => (string) ($_GET['categorie'] ?? ''),
|
||||
'resultat' => (string) ($_GET['resultat'] ?? ''),
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'id_compte' => (int) ($_GET['id_compte'] ?? 0),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
'du' => (string) ($_GET['du'] ?? ''),
|
||||
'au' => (string) ($_GET['au'] ?? ''),
|
||||
'page' => (int) ($_GET['page'] ?? 1),
|
||||
'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, array_merge($resultat, [
|
||||
'statistiques' => $this->journal->statistiques(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes.
|
||||
*
|
||||
* La purge est elle-même journalisée, et l'est APRÈS coup : sa propre trace
|
||||
* ne peut donc pas être emportée par la purge qu'elle décrit.
|
||||
*
|
||||
* Charge utile : jours (minimum 30), csrf_token.
|
||||
*/
|
||||
public function purgerJournal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
if ($jours < 30) {
|
||||
JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']);
|
||||
}
|
||||
|
||||
$supprimees = $this->journal->purger($jours);
|
||||
|
||||
Journal::administration('JOURNAL_PURGE', [
|
||||
'cible_table' => 'journal_activite',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours),
|
||||
'contexte' => ['jours' => $jours, 'supprimees' => $supprimees],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees),
|
||||
'supprimees' => $supprimees,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création d'un compte, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDisponibles(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'],
|
||||
['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,27 @@ use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Rendu de toutes les pages HTML de l'application.
|
||||
*
|
||||
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
|
||||
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
|
||||
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
|
||||
* de $activePage qui surligne l'onglet courant.
|
||||
*
|
||||
* Ajouter une page suppose donc, ensemble :
|
||||
* 1. un fichier mince à la racine ;
|
||||
* 2. une méthode ici ;
|
||||
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
|
||||
* 4. un module JS ;
|
||||
* 5. l'inscription de la clé dans $navigation / $navigationGroups
|
||||
* (Views/layouts/header.php) ;
|
||||
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
|
||||
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
|
||||
*
|
||||
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
|
||||
* injecte et page-bootstrap.js appelle init() sur chaque module.
|
||||
*/
|
||||
final class PageController extends Controller
|
||||
{
|
||||
private Access $access;
|
||||
@@ -24,6 +45,10 @@ 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');
|
||||
@@ -38,6 +63,20 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de saisie — cœur de l'application.
|
||||
*
|
||||
* Le lieu est VERROUILLÉ pour un responsable de structure
|
||||
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
|
||||
* qu'un confort d'interface, le serveur retranche de toute façon.
|
||||
*
|
||||
* Les scripts de semaine type ne sont chargés que si la permission
|
||||
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
|
||||
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
|
||||
* (éditeur de semaine type / saisie semaine par semaine, voir
|
||||
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
|
||||
* les paramètres des liens profonds venus des autres écrans.
|
||||
*/
|
||||
public function planning(): void
|
||||
{
|
||||
$this->access->requirePage('planning');
|
||||
@@ -47,10 +86,14 @@ final class PageController extends Controller
|
||||
$modules = ['planning'];
|
||||
if (!empty($permissions['can_use_templates'])) {
|
||||
$scripts[] = 'week-templates.js';
|
||||
$scripts[] = 'week-template-editor.js';
|
||||
$modules[] = 'weekTemplates';
|
||||
$modules[] = 'weekTemplateEditor';
|
||||
}
|
||||
$this->renderPage('planning', 'Saisie du planning', 'planning', [
|
||||
'pageSubtitle' => 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||
'pageSubtitle' => !empty($permissions['can_use_templates'])
|
||||
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
|
||||
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
|
||||
'structures' => $this->loadStructures(),
|
||||
'motifs' => $this->loadMotifs(),
|
||||
'currentWeek' => $this->currentWeek(),
|
||||
@@ -67,6 +110,7 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** File des plannings en attente de validation (Service Enfance). */
|
||||
public function pending(): void
|
||||
{
|
||||
$this->access->requirePage('pending');
|
||||
@@ -77,6 +121,7 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */
|
||||
public function coverage(): void
|
||||
{
|
||||
$this->access->requirePage('coverage');
|
||||
@@ -94,6 +139,7 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Planning d'un lieu sur une semaine, en lecture seule. */
|
||||
public function structures(): void
|
||||
{
|
||||
$this->access->requirePage('structures');
|
||||
@@ -111,6 +157,12 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning d'un agent, mois par mois et tous lieux confondus.
|
||||
*
|
||||
* Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule
|
||||
* sur « Mon planning » : la page sert alors de vue personnelle.
|
||||
*/
|
||||
public function agents(): void
|
||||
{
|
||||
$this->access->requirePage('agents');
|
||||
@@ -132,6 +184,7 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Vue annuelle (janvier -> décembre) et validation de l'année entière. */
|
||||
public function annual(): void
|
||||
{
|
||||
$this->access->requirePage('annual');
|
||||
@@ -147,11 +200,12 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */
|
||||
public function pta(): void
|
||||
{
|
||||
$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'],
|
||||
@@ -163,12 +217,15 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Composition des équipes sur les mercredis et les vacances. */
|
||||
public function teams(): void
|
||||
{
|
||||
$this->access->requirePage('teams');
|
||||
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
|
||||
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
// Liste d'AFFECTATION : un agent dont l'accès a été coupé n'est plus
|
||||
// proposé à la composition d'une équipe (V7.29).
|
||||
'agents' => (new AgentModel($this->pdo))->allSelectable(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['teams.js'],
|
||||
'modules' => ['teams'],
|
||||
@@ -179,6 +236,11 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires : elles déterminent le contexte
|
||||
* périscolaire / extrascolaire de chaque journée, et sont importables depuis
|
||||
* l'API data.education.gouv.fr (SchoolHolidayApiService).
|
||||
*/
|
||||
public function vacations(): void
|
||||
{
|
||||
$this->access->requirePage('vacations');
|
||||
@@ -189,16 +251,99 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** Référentiels : agents, lieux, postes, règles de couverture. */
|
||||
public function administration(): void
|
||||
{
|
||||
$this->access->requirePage('administration');
|
||||
$this->renderPage('administration', 'Administration', 'administration', [
|
||||
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
|
||||
'agents' => (new AgentModel($this->pdo))->allActive(),
|
||||
'pageSubtitle' => 'Les horaires de couverture attendus de chaque lieu, et les jetons d\'accès des agents et des responsables.',
|
||||
// Depuis la V7.14, cet écran ne porte plus les agents ni les lieux :
|
||||
// ils ont leur propre page (management). Seule la liste des lieux
|
||||
// reste nécessaire, pour son sélecteur.
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => (new AgentModel($this->pdo))->allPosts(),
|
||||
'scripts' => ['administration.js', 'coverage.js'],
|
||||
'modules' => ['administration', 'coverage'],
|
||||
// V7.27 : la distribution des jetons de terrain rejoint cet écran.
|
||||
// Le contenu de la section vient entièrement de l'API — le HTML
|
||||
// servi ne porte aucune information sur les comptes.
|
||||
// `comptes-acces.js` porte la fabrique de l'écran des comptes,
|
||||
// partagée avec l'espace DSI : il doit précéder `access-tokens.js`,
|
||||
// qui l'appelle dès son chargement.
|
||||
'scripts' => ['coverage.js', 'comptes-acces.js', 'access-tokens.js'],
|
||||
'modules' => ['coverage', 'accessTokens'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Agents (V7.14) : création, liste et fiche des agents.
|
||||
*
|
||||
* Ce référentiel partageait l'écran « Administration » avec celui des lieux
|
||||
* et les horaires de couverture, trois objets sans rapport. Chacun a
|
||||
* désormais son onglet, réunis sous le groupe « Management » de la barre de
|
||||
* navigation.
|
||||
*
|
||||
* NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la
|
||||
* méthode EST la clé de page passée à runPage() et inscrite dans
|
||||
* acces_role_page, où les clés sont en minuscules. Les garder identiques
|
||||
* prime sur l'uniformité de style — voir l'en-tête de cette classe.
|
||||
*
|
||||
* La liste des lieux est nécessaire ici aussi : le formulaire de création
|
||||
* demande le lieu principal de l'agent.
|
||||
*/
|
||||
public function manage_agents(): void
|
||||
{
|
||||
$this->access->requirePage('manage_agents');
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
$this->renderPage('manage_agents', 'Agents', 'manage_agents', [
|
||||
'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.',
|
||||
'agents' => $agentModel->allActive(),
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'posts' => $agentModel->allPosts(),
|
||||
// V7.28 : `agentAccess` ajoute sur chaque ligne le bouton qui coupe
|
||||
// ou rétablit l'accès de l'agent. Il n'ouvre aucun droit nouveau —
|
||||
// il réutilise les endpoints délégués de la V7.27.
|
||||
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js', 'agent-access.js'],
|
||||
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat', 'agentAccess'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Management — Lieux d'affectation (V7.14) : création et paramètres.
|
||||
*
|
||||
* Les horaires de couverture d'un lieu restent sur « Administration » : ils
|
||||
* ne décrivent pas le lieu mais une exigence de présence.
|
||||
*/
|
||||
public function manage_structures(): void
|
||||
{
|
||||
$this->access->requirePage('manage_structures');
|
||||
$this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [
|
||||
'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.',
|
||||
'structures' => (new StructureModel($this->pdo))->allActive(),
|
||||
'scripts' => ['administration.js'],
|
||||
'modules' => ['administration'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* Page d'administration TECHNIQUE, ouverte au seul rôle DSI par
|
||||
* acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien
|
||||
* à connaître des agents ni des plannings ; les seules listes qu'elle
|
||||
* manipule (identités sans compte) sont servies par l'API.
|
||||
*/
|
||||
public function dsi(): void
|
||||
{
|
||||
$this->access->requirePage('dsi');
|
||||
$this->renderPage('dsi', 'Espace DSI', 'dsi', [
|
||||
'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.',
|
||||
// Même fabrique que l'onglet Administration, et même contrainte
|
||||
// d'ordre : elle doit être chargée avant `dsi.js`.
|
||||
'scripts' => ['comptes-acces.js', 'dsi.js'],
|
||||
'modules' => ['dsi'],
|
||||
'pageConfig' => [
|
||||
// Lien profond « journal de ce compte » : filtre d'emblée le
|
||||
// journal sur le compte désigné.
|
||||
'compteId' => $this->positiveInt($_GET['compte_id'] ?? null),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -211,11 +356,26 @@ 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();
|
||||
@@ -239,6 +399,15 @@ final class PageController extends Controller
|
||||
], $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
|
||||
*
|
||||
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
|
||||
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
|
||||
* inaffichable dans la liste déroulante.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructures(): array
|
||||
{
|
||||
$model = new StructureModel($this->pdo);
|
||||
@@ -253,6 +422,15 @@ final class PageController extends Controller
|
||||
return $structure ? [$structure] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante pour l'éditeur de la fiche agent.
|
||||
*
|
||||
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
|
||||
* Enfance a donc besoin de la liste complète même s'il consulte un agent
|
||||
* rattaché ailleurs.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructuresForAgentEditor(): array
|
||||
{
|
||||
if ($this->access->role() === Access::ROLE_SERVICE) {
|
||||
@@ -261,6 +439,13 @@ final class PageController extends Controller
|
||||
return $this->loadStructures();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
|
||||
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
|
||||
* inclut les agents rattachés ailleurs mais planifiés sur place.)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadAgents(): array
|
||||
{
|
||||
$model = new AgentModel($this->pdo);
|
||||
@@ -275,28 +460,53 @@ final class PageController extends Controller
|
||||
return $structureId ? $model->allVisibleForStructure($structureId) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
|
||||
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
|
||||
* refuse (PlanningController::assertMotifsAllowedForAgent()).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadMotifs(): array
|
||||
{
|
||||
return (new MotifModel($this->pdo))->allActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Semaine courante au format « 2026-W08 ».
|
||||
*
|
||||
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
|
||||
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
|
||||
* quand la semaine ISO appartient à l'année civile voisine.
|
||||
* Fuseau explicite : le conteneur PHP tourne en UTC.
|
||||
*/
|
||||
private function currentWeek(): string
|
||||
{
|
||||
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Assainissement des paramètres d'URL. Ces valeurs partent dans
|
||||
// window.PTA_CONFIG et pilotent les filtres appliqués par
|
||||
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
|
||||
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Identifiant d'entité : strictement positif, ou null. */
|
||||
private function positiveInt(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
|
||||
private function weekValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function dateValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
@@ -304,12 +514,14 @@ final class PageController extends Controller
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : null;
|
||||
}
|
||||
|
||||
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
|
||||
private function yearValue(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
|
||||
private function monthValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\MotifCategories;
|
||||
use App\Core\Request;
|
||||
@@ -20,6 +21,26 @@ use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Saisie, contrôle et validation des plannings hebdomadaires.
|
||||
*
|
||||
* C'est le contrôleur central de l'application : un `planning` = un agent × une
|
||||
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
|
||||
* `motif_planning`.
|
||||
*
|
||||
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
|
||||
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
|
||||
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
|
||||
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
|
||||
* le SQL correspondant est du patrimoine, pas la source de vérité.
|
||||
*
|
||||
* Forme commune des contrôles renvoyés au client, partout dans l'application :
|
||||
* {niveau: ERREUR|ALERTE|INFO, code, message}
|
||||
* — ERREUR bloque (HTTP 422) ;
|
||||
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
|
||||
* force_quota: true) ;
|
||||
* — INFO est purement informatif.
|
||||
*/
|
||||
final class PlanningController
|
||||
{
|
||||
private PlanningModel $planningModel;
|
||||
@@ -30,6 +51,10 @@ final class PlanningController
|
||||
private VacationModel $vacationModel;
|
||||
private Access $access;
|
||||
|
||||
/**
|
||||
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
|
||||
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
|
||||
*/
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->planningModel = new PlanningModel($pdo);
|
||||
@@ -41,6 +66,16 @@ final class PlanningController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/planning.php — état complet d'une semaine de saisie.
|
||||
*
|
||||
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
|
||||
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
|
||||
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
|
||||
* lecture seule pour rendre les chevauchements visibles avant l'échec.
|
||||
*
|
||||
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -79,6 +114,23 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST save_planning.php — enregistre le brouillon d'une semaine.
|
||||
*
|
||||
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
|
||||
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
|
||||
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
|
||||
*
|
||||
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
|
||||
* explicite au plus générique :
|
||||
* 1. droits et périmètre ;
|
||||
* 2. motifs fermés à l'agent (message nommant le motif) ;
|
||||
* 3. normalisation/validation ligne à ligne ;
|
||||
* 4. chevauchements internes à la saisie ;
|
||||
* 5. chevauchements avec les autres lieux.
|
||||
*
|
||||
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -113,6 +165,7 @@ 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,
|
||||
@@ -130,12 +183,26 @@ final class PlanningController
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
|
||||
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
|
||||
// référence documentée — vaut « inactif » par défaut. Le détail
|
||||
// technique de l'exception peut donc remonter au navigateur sur une
|
||||
// installation qui ne définit pas la variable. Le motif est répété
|
||||
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Erreur lors de l’enregistrement du planning.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ENREGISTRE',
|
||||
'planning',
|
||||
$planningId,
|
||||
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'planning_id' => $planningId,
|
||||
@@ -147,6 +214,17 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
|
||||
*
|
||||
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
|
||||
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
|
||||
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
|
||||
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
|
||||
* validation intacte.
|
||||
*
|
||||
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
|
||||
*/
|
||||
public function updateAgentEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -183,13 +261,20 @@ 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(
|
||||
@@ -210,6 +295,14 @@ final class PlanningController
|
||||
}
|
||||
|
||||
$reopened = count($result['reopened_planning_ids']) > 0;
|
||||
Journal::ecriture(
|
||||
'CRENEAU_MODIFIE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $reopened
|
||||
@@ -221,6 +314,16 @@ final class PlanningController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
|
||||
*
|
||||
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
|
||||
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
|
||||
* l'interface de le signaler, mais l'enregistrement reste et conserve son
|
||||
* historique.
|
||||
*
|
||||
* Charge utile : entry_id, csrf_token.
|
||||
*/
|
||||
public function deleteAgentEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -253,6 +356,14 @@ final class PlanningController
|
||||
$message .= ' Le planning validé concerné a été repassé en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'CRENEAU_SUPPRIME',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -263,6 +374,16 @@ final class PlanningController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
|
||||
*
|
||||
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
|
||||
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
|
||||
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
|
||||
* l'indique sans alarmer.
|
||||
*
|
||||
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
|
||||
*/
|
||||
public function moveOrDuplicateEntry(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -289,6 +410,12 @@ final class PlanningController
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5) {
|
||||
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
|
||||
}
|
||||
|
||||
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
|
||||
if ($entryAgent === null) {
|
||||
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||||
}
|
||||
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
|
||||
try {
|
||||
$result = $this->planningModel->moveOrDuplicateEntry(
|
||||
(int) $entryId,
|
||||
@@ -316,6 +443,14 @@ final class PlanningController
|
||||
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
|
||||
'creneau_horaire',
|
||||
(int) $entryId,
|
||||
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
|
||||
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'action' => $result['action'],
|
||||
@@ -325,6 +460,18 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
|
||||
*
|
||||
* Réservé au Service Enfance (requireService) et non aux responsables : la
|
||||
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
|
||||
* déborde donc par nature du périmètre d'un responsable de structure.
|
||||
*
|
||||
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
|
||||
* `replace` les remplace.
|
||||
*
|
||||
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
|
||||
*/
|
||||
public function copyAgentWeek(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -342,9 +489,15 @@ final class PlanningController
|
||||
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
// La copie ne connaît pas d'avance les jours réellement recopiés : on
|
||||
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
|
||||
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
|
||||
// entièrement hors contrat.
|
||||
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
|
||||
|
||||
try {
|
||||
$result = $this->planningModel->copyAgentWeek(
|
||||
@@ -373,6 +526,25 @@ final class PlanningController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_COPIEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
|
||||
(int) $result['copied_entries'],
|
||||
$sourceWeekValue,
|
||||
$targetWeekValue,
|
||||
(int) $agentId
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'semaine_source' => $sourceWeekValue,
|
||||
'semaine_cible' => $targetWeekValue,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -384,6 +556,14 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/pending_plannings.php — file d'attente de validation.
|
||||
*
|
||||
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
|
||||
* (`total_minutes`) des minutes réellement décomptées du quota
|
||||
* (`counted_minutes`) : congés et absences non décomptées figurent au
|
||||
* planning sans peser sur le quota annuel.
|
||||
*/
|
||||
public function pendingValidation(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -407,6 +587,35 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST validate_planning.php — contrôle puis valide un planning.
|
||||
*
|
||||
* C'est ICI que vivent les règles de validation réellement appliquées (et
|
||||
* non dans sp_valider_planning côté SQL, jamais appelée).
|
||||
*
|
||||
* Quatre contrôles bloquants (ERREUR → 422, aucune validation) :
|
||||
* - AUCUN_CRENEAU : planning vide ;
|
||||
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
|
||||
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
|
||||
* - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou
|
||||
* postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en
|
||||
* créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat
|
||||
* raccourci a rendues illégitimes ;
|
||||
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
|
||||
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
|
||||
* un brouillon concurrent peut encore être corrigé, le bloquer
|
||||
* empêcherait toute validation dans un ordre donné.
|
||||
*
|
||||
* Un contrôle non bloquant (ALERTE → 409) :
|
||||
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
|
||||
* possible, mais exige un rappel explicite avec force_quota: true. Le
|
||||
* dépassement est une décision de gestion, pas une anomalie technique.
|
||||
*
|
||||
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
|
||||
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
|
||||
*
|
||||
* Charge utile : planning_id, force_quota (bool), csrf_token.
|
||||
*/
|
||||
public function validate(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -430,6 +639,19 @@ final class PlanningController
|
||||
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideContract((int) $planningId) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
'ERREUR',
|
||||
'HORS_CONTRAT',
|
||||
sprintf(
|
||||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||||
$row['id_creneau'],
|
||||
$row['date_jour']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
|
||||
$this->addControl(
|
||||
$controls,
|
||||
@@ -504,6 +726,25 @@ final class PlanningController
|
||||
$this->planningModel->markValidated((int) $planningId);
|
||||
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
|
||||
|
||||
// Journalisé APRÈS le passage en base : une validation refusée par les
|
||||
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
|
||||
Journal::ecriture(
|
||||
'PLANNING_VALIDE',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning validé pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $planning['id_agent'],
|
||||
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
|
||||
'date_debut' => (string) $planning['date_debut'],
|
||||
'quota_force' => $forceQuota,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'validated',
|
||||
@@ -513,6 +754,17 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
|
||||
*
|
||||
* Réouverture volontaire, à distinguer de la réouverture automatique
|
||||
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
|
||||
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
|
||||
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
|
||||
* trompeur.
|
||||
*
|
||||
* Charge utile : planning_id, csrf_token.
|
||||
*/
|
||||
public function reopen(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -537,6 +789,18 @@ final class PlanningController
|
||||
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'PLANNING_ROUVERT',
|
||||
'planning',
|
||||
(int) $planningId,
|
||||
sprintf(
|
||||
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
|
||||
(int) $planning['id_agent'],
|
||||
(string) $planning['date_debut']
|
||||
),
|
||||
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'status' => 'BROUILLON',
|
||||
@@ -545,6 +809,14 @@ final class PlanningController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
|
||||
*
|
||||
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
|
||||
* comptent bien une semaine 53.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
@@ -602,6 +874,22 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et met en forme les créneaux reçus du client.
|
||||
*
|
||||
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
|
||||
* motif appartenant à la liste ouverte à CET agent, horaires au quart
|
||||
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
|
||||
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
|
||||
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
|
||||
*
|
||||
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
|
||||
*
|
||||
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
|
||||
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
|
||||
*
|
||||
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
|
||||
*/
|
||||
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
@@ -636,6 +924,15 @@ final class PlanningController
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
|
||||
*
|
||||
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
|
||||
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
|
||||
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
|
||||
*
|
||||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||||
*/
|
||||
private function assertNoInternalOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
@@ -659,6 +956,20 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
|
||||
*
|
||||
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
|
||||
* un agent ne peut pas être à deux endroits à la fois, même en cours de
|
||||
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
|
||||
* puisse nommer le lieu et l'horaire en cause.
|
||||
*
|
||||
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
|
||||
* être comparables aux créneaux normalisés.
|
||||
*
|
||||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||||
* @param array<int, array<string, mixed>> $existingEntries
|
||||
*/
|
||||
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
|
||||
{
|
||||
foreach ($entries as $entry) {
|
||||
@@ -689,6 +1000,58 @@ final class PlanningController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empile un contrôle au format commun {niveau, code, message}.
|
||||
*
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de
|
||||
* contrat : le planifier avant son arrivée ou après son départ produit des
|
||||
* heures que personne ne fera, et fausse les cumuls annuels comme les
|
||||
* contrôles de couverture. La règle s'applique à TOUS les motifs, congés et
|
||||
* absences compris — hors contrat, il n'y a rien à décompter.
|
||||
*
|
||||
* Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie
|
||||
* l'ensemble des jours refusés, et non le premier.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent.
|
||||
* @param array<int, string> $dates Dates AAAA-MM-JJ visées par l'écriture.
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ.
|
||||
* Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à
|
||||
* être contrôlés, aucun créneau ne peut y être posé.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function workingDaysOfWeek(int $year, int $weekNumber): array
|
||||
{
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
|
||||
return array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
);
|
||||
}
|
||||
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AgentModel;
|
||||
@@ -15,6 +16,21 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* PTA détaillé d'un agent : contraintes prioritaires et souhaits de lieux.
|
||||
*
|
||||
* Ces deux référentiels décrivent ce qui DOIT ou NE DOIT PAS être planifié pour
|
||||
* un agent. Ils sont consultatifs : rien ici n'empêche techniquement une saisie
|
||||
* contraire, l'arbitrage reste à l'agent de planification.
|
||||
*
|
||||
* Les deux endpoints d'écriture sont à double action, pilotée par le champ
|
||||
* `action` de la charge utile (« SAVE » par défaut, ou « DELETE ») : c'est un
|
||||
* écart au reste de l'API, où un endpoint = une opération. La branche DELETE
|
||||
* sort par JsonResponse::send(), qui termine le script — le code de SAVE qui
|
||||
* suit n'est donc jamais atteint dans ce cas.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class PtaController
|
||||
{
|
||||
private PtaModel $ptaModel;
|
||||
@@ -30,6 +46,15 @@ final class PtaController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/pta_summary.php — synthèse du PTA d'un agent à une date donnée.
|
||||
*
|
||||
* La date sert de point d'observation : elle détermine la période de quota
|
||||
* retenue et les contraintes en vigueur ce jour-là. Elle retombe sur
|
||||
* aujourd'hui si elle n'est pas fournie.
|
||||
*
|
||||
* Paramètres GET : agent_id, date (facultative, « AAAA-MM-JJ »).
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -50,6 +75,24 @@ final class PtaController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/pta_constraint.php — enregistre ou supprime une contrainte.
|
||||
*
|
||||
* Deux types seulement, volontairement fermés : MEDICALE et
|
||||
* TEMPS_PARTIEL_THERAPEUTIQUE. Une date de fin vide signifie « sans terme
|
||||
* connu », cas courant d'un aménagement médical en cours.
|
||||
*
|
||||
* Le commentaire est OBLIGATOIRE : une contrainte restreint la planification
|
||||
* d'un agent, elle doit rester justifiable a posteriori.
|
||||
*
|
||||
* Les maximums arrivent en HEURES depuis le formulaire et sont convertis en
|
||||
* MINUTES pour la base, unité de tous les cumuls de l'application.
|
||||
*
|
||||
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
|
||||
* l'action constraint_id, ou type_contrainte / date_debut / date_fin /
|
||||
* commentaire / quotite_temporaire / maximum_heures_jour /
|
||||
* maximum_heures_semaine / interdit_matin|midi|soir.
|
||||
*/
|
||||
public function constraint(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -69,6 +112,15 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Contrainte invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_SUPPRIMEE',
|
||||
'agent_contrainte',
|
||||
(int) $constraintId,
|
||||
sprintf('Contrainte prioritaire #%d supprimée pour l’agent #%d.', (int) $constraintId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.',
|
||||
]);
|
||||
@@ -113,12 +165,35 @@ final class PtaController
|
||||
'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0,
|
||||
'commentaire' => $comment,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'CONTRAINTE_ENREGISTREE',
|
||||
'agent_contrainte',
|
||||
(int) $id,
|
||||
sprintf('Contrainte « %s » enregistrée pour l’agent #%d à partir du %s.', $type, (int) $agentId, $start),
|
||||
['id_agent' => (int) $agentId, 'type_contrainte' => $type, 'date_debut' => $start, 'date_fin' => $end]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
|
||||
// navigateur SANS AUCUNE condition de debug, contrairement au reste
|
||||
// du projet. Idem dans wish(). À aligner sur
|
||||
// Security::debugEnabled() — voir la note de PlanningController::save().
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/pta_wish.php — enregistre ou supprime un souhait de lieu.
|
||||
*
|
||||
* Deux types : PREFERENCE (l'agent souhaite y être affecté) et INTERDICTION
|
||||
* (il ne doit pas l'être). La priorité, de 1 à 5, permet d'ordonner
|
||||
* plusieurs préférences entre elles ; la distance en kilomètres sert à
|
||||
* arbitrer les affectations éloignées.
|
||||
*
|
||||
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
|
||||
* l'action wish_id, ou structure_id / type_souhait / priorite /
|
||||
* distance_km / commentaire.
|
||||
*/
|
||||
public function wish(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -138,6 +213,15 @@ final class PtaController
|
||||
JsonResponse::send(422, ['error' => 'Préférence invalide.']);
|
||||
}
|
||||
$deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId);
|
||||
if ($deleted) {
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_SUPPRIME',
|
||||
'agent_structure_souhait',
|
||||
(int) $wishId,
|
||||
sprintf('Préférence de lieu #%d supprimée pour l’agent #%d.', (int) $wishId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
}
|
||||
JsonResponse::send($deleted ? 200 : 404, [
|
||||
'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.',
|
||||
]);
|
||||
@@ -166,6 +250,13 @@ final class PtaController
|
||||
'distance_km' => $distance,
|
||||
'commentaire' => $comment !== '' ? $comment : null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'SOUHAIT_ENREGISTRE',
|
||||
'agent_structure_souhait',
|
||||
(int) $id,
|
||||
sprintf('Préférence « %s » enregistrée pour l’agent #%d sur le lieu #%d.', $type, (int) $agentId, (int) $structureId),
|
||||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'type_souhait' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]);
|
||||
|
||||
@@ -7,61 +7,438 @@ 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\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Core\Session;
|
||||
use App\Models\CompteModel;
|
||||
use App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Connexion — point d'entrée de l'application (role.php).
|
||||
*
|
||||
* JUSQU'À LA V7.10, CET ÉCRAN ÉTAIT UN SÉLECTEUR LIBRE : chacun choisissait son
|
||||
* rôle et son identité dans une liste, sans rien prouver. Depuis la V7.11, une
|
||||
* session ne peut naître que de la présentation d'un JETON valide, distribué par
|
||||
* la DSI. Le reste de la mécanique de droits n'a pas bougé : Access lit toujours
|
||||
* les mêmes vues SQL, simplement le rôle n'est plus déclaré par l'utilisateur.
|
||||
*
|
||||
* La V7.12 ajoute un SECOND FACTEUR pour les comptes qui engagent l'application
|
||||
* — Service Enfance (qui valide les plannings de toute la collectivité) et DSI
|
||||
* (qui distribue les accès) : après le jeton, un mot de passe. Les deux secrets
|
||||
* sont de nature différente, et c'est tout l'intérêt : le jeton est remis par la
|
||||
* DSI et peut circuler par courriel, le mot de passe est choisi par le titulaire
|
||||
* à sa première connexion et n'est connu que de lui. La DSI ne peut que
|
||||
* l'effacer, jamais le lire ni le remplacer.
|
||||
*
|
||||
* La connexion se fait donc en deux étapes, matérialisées par $etape :
|
||||
* « jeton » — saisie du jeton (ou arrivée par role.php?token=…) ;
|
||||
* « definition » — premier accès d'un compte privilégié : choisir son mot de passe ;
|
||||
* « verification » — accès suivants : le saisir ;
|
||||
* « changement » — session ouverte, le titulaire remplace son mot de passe.
|
||||
*
|
||||
* Entre les deux étapes, RIEN N'EST OUVERT : la session ne porte qu'une attente
|
||||
* horodatée (ATTENTE_KEY), qui périme au bout de cinq minutes. Un jeton validé
|
||||
* sans mot de passe ne donne accès à aucune page.
|
||||
*
|
||||
* Comme avant, ce contrôleur n'est pas appelé par runPage() : il s'exécute AVANT
|
||||
* tout contrôle d'accès, puisqu'il est ce qui permet d'en avoir un.
|
||||
*/
|
||||
final class RoleController extends Controller
|
||||
{
|
||||
/** Fenêtre et plafond du frein anti-force-brute, par adresse IP. */
|
||||
private const FENETRE_MINUTES = 15;
|
||||
private const ECHECS_MAX = 10;
|
||||
|
||||
/** Attente entre les deux étapes : le jeton est reconnu, le mot de passe manque. */
|
||||
private const ATTENTE_KEY = 'pta_auth_attente';
|
||||
private const ATTENTE_SECONDES = 300;
|
||||
|
||||
private CompteModel $comptes;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche l'écran de connexion et traite ses chemins, faute de routeur :
|
||||
* - GET ?action=reset : déconnexion, puis retour à cet écran ;
|
||||
* - GET ?action=mot_de_passe : changement de mot de passe, session ouverte ;
|
||||
* - GET ?token=… : première étape par lien direct ;
|
||||
* - POST etape=mot_de_passe : seconde étape (définition ou vérification) ;
|
||||
* - POST : première étape par le champ de saisie.
|
||||
*
|
||||
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
|
||||
* données arrivent en formulaire classique et un échec doit produire une page
|
||||
* HTML, pas une réponse JSON.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$access = new Access($this->pdo);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$expected = Csrf::ensureToken();
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$error = 'La session a expiré. Rechargez la page et recommencez.';
|
||||
} else {
|
||||
try {
|
||||
$role = strtoupper(trim((string) ($_POST['role'] ?? '')));
|
||||
$agentId = filter_var($_POST['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($_POST['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$access->select($role, $agentId ? (int) $agentId : null, $structureId ? (int) $structureId : null);
|
||||
header('Location: ' . $access->homeUrl(), true, 302);
|
||||
exit;
|
||||
} catch (\DomainException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Frein anti-force-brute, commun aux deux étapes : au-delà du plafond, plus
|
||||
* aucun secret n'est même comparé.
|
||||
*
|
||||
* @return string Message à afficher, vide si la voie est libre.
|
||||
*/
|
||||
private function blocageEnCours(): string
|
||||
{
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$echecs = (new JournalModel($this->pdo))->echecsRecents($ip, self::FENETRE_MINUTES);
|
||||
if ($echecs < self::ECHECS_MAX) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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,
|
||||
// Le blocage est tracé pour que la DSI voie une attaque en cours plutôt
|
||||
// que d'avoir à la déduire.
|
||||
Journal::connexion('CONNEXION_BLOQUEE', [
|
||||
'resultat' => 'REFUS',
|
||||
'description' => sprintf(
|
||||
'Trop de secrets refusés depuis %s : connexion bloquée.',
|
||||
$ip !== '' ? $ip : 'une adresse inconnue'
|
||||
),
|
||||
'contexte' => ['echecs' => $echecs, 'fenetre_minutes' => self::FENETRE_MINUTES],
|
||||
]);
|
||||
|
||||
return sprintf(
|
||||
'Trop de tentatives infructueuses. Réessayez dans %d minutes ou contactez la DSI.',
|
||||
self::FENETRE_MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mémorise le compte reconnu par son jeton, en attendant son mot de passe.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function poserAttente(array $compte, string $origine): void
|
||||
{
|
||||
Session::start();
|
||||
// Regénération de l'identifiant AVANT de poser l'attente : un identifiant
|
||||
// de session imposé par un tiers ne doit pas porter cette étape.
|
||||
session_regenerate_id(true);
|
||||
$_SESSION[self::ATTENTE_KEY] = [
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
'libelle' => (string) $compte['identite_libelle'],
|
||||
'definition' => !CompteModel::motDePasseDefini($compte),
|
||||
'origine' => $origine,
|
||||
'expire' => time() + self::ATTENTE_SECONDES,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attente en cours, ou null si elle est absente ou périmée.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function attente(): ?array
|
||||
{
|
||||
Session::start();
|
||||
$attente = $_SESSION[self::ATTENTE_KEY] ?? null;
|
||||
if (!is_array($attente) || (int) ($attente['expire'] ?? 0) < time()) {
|
||||
$this->oublierAttente();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attente;
|
||||
}
|
||||
|
||||
private function oublierAttente(): void
|
||||
{
|
||||
Session::start();
|
||||
unset($_SESSION[self::ATTENTE_KEY]);
|
||||
}
|
||||
|
||||
/** « definition » au premier accès, « verification » ensuite. */
|
||||
private function etapeAttendue(): string
|
||||
{
|
||||
return ($this->attente()['definition'] ?? false) ? 'definition' : 'verification';
|
||||
}
|
||||
|
||||
/** Jeton CSRF du formulaire, vérifié à la main (données non JSON). */
|
||||
private function csrfValide(): bool
|
||||
{
|
||||
$provided = (string) ($_POST['csrf_token'] ?? '');
|
||||
|
||||
return $provided !== '' && hash_equals(Csrf::ensureToken(), $provided);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\PdfResponse;
|
||||
use App\Core\Request;
|
||||
@@ -17,6 +18,15 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
|
||||
*
|
||||
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
|
||||
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
|
||||
*
|
||||
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
|
||||
* modification réservées au Service Enfance.
|
||||
*/
|
||||
final class StructureController
|
||||
{
|
||||
private StructureModel $structureModel;
|
||||
@@ -32,6 +42,19 @@ final class StructureController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
|
||||
*
|
||||
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
|
||||
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
|
||||
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
|
||||
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
|
||||
*
|
||||
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
|
||||
* agent : une seule lecture, puis regroupement en mémoire.
|
||||
*
|
||||
* Paramètres GET : structure_id, week.
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||||
@@ -79,6 +102,16 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_planning_pdf.php — même vue, au format PDF.
|
||||
*
|
||||
* Reprend volontairement le calcul de overview() plutôt que de le
|
||||
* factoriser : les deux méthodes doivent rester alignées, toute évolution
|
||||
* de l'une appelle la même dans l'autre.
|
||||
*
|
||||
* Endpoint PDF : les erreurs sortent en texte brut avec un code HTTP, jamais
|
||||
* en JSON (voir AgentController).
|
||||
*/
|
||||
public function pdf(): void
|
||||
{
|
||||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||||
@@ -131,6 +164,20 @@ final class StructureController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/create_structure.php — création d'un lieu d'affectation.
|
||||
*
|
||||
* Le code est normalisé avant contrôle : espaces remplacés par des
|
||||
* underscores, puis passage en majuscules. Il sert d'identifiant lisible
|
||||
* (nom de fichier PDF, exports), d'où le format restreint à
|
||||
* [A-Z0-9_-]{2,50}. Un doublon ressort en 409, pas en 422 : c'est un conflit
|
||||
* avec l'existant.
|
||||
*
|
||||
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) détermine le contexte
|
||||
* par défaut du lieu ; il reste croisé avec le calendrier des vacances.
|
||||
*
|
||||
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -169,12 +216,28 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'STRUCTURE_CREEE',
|
||||
'structure',
|
||||
isset($structure['id_structure']) ? (int) $structure['id_structure'] : null,
|
||||
sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code),
|
||||
['code' => $code, 'type_affectation' => $typeAffectation]
|
||||
);
|
||||
|
||||
JsonResponse::send(201, [
|
||||
'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name),
|
||||
'structure' => $structure,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/update_structure.php — change le type d'un lieu.
|
||||
*
|
||||
* Seul le type est modifiable : le code sert de référence stable, et le nom
|
||||
* n'est pas exposé à l'édition dans cette version.
|
||||
*
|
||||
* Charge utile : structure_id, type_affectation, csrf_token.
|
||||
*/
|
||||
public function updateType(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -214,6 +277,11 @@ final class StructureController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\AgentModel;
|
||||
@@ -15,6 +16,21 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Équipes des mercredis et des vacances.
|
||||
*
|
||||
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
|
||||
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
|
||||
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
|
||||
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
|
||||
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
|
||||
* pouvant évoluer.
|
||||
*
|
||||
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
|
||||
* BROUILLON → A_CONTROLER → VALIDEE.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class TeamController
|
||||
{
|
||||
private TeamModel $teamModel;
|
||||
@@ -30,6 +46,21 @@ final class TeamController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
|
||||
*
|
||||
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
|
||||
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
|
||||
* actives sur une période.
|
||||
*
|
||||
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
|
||||
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
|
||||
* ratio à zéro provoquerait une division par zéro au calcul du taux
|
||||
* d'encadrement.
|
||||
*
|
||||
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
|
||||
* date_fin, effectifs et ratios facultatifs, csrf_token.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -84,12 +115,33 @@ final class TeamController
|
||||
'pourcentage_diplomes_min' => $qualified,
|
||||
'commentaire' => trim((string) ($payload['commentaire'] ?? '')) ?: null,
|
||||
]);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_CREEE',
|
||||
'equipe_pta',
|
||||
(int) $id,
|
||||
sprintf('Création de l’équipe « %s » sur le lieu #%d.', $label, (int) $structureId),
|
||||
['id_structure' => (int) $structureId, 'type_equipe' => $type]
|
||||
);
|
||||
JsonResponse::send(201, ['message' => 'L’équipe a été créée.', 'id_equipe' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, ['error' => 'Impossible de créer l’équipe.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/team_members.php — ajoute ou retire un agent d'une équipe.
|
||||
*
|
||||
* `action` vaut ADD (défaut) ou REMOVE. Trois fonctions possibles :
|
||||
* RESPONSABLE, ANIMATION, RESTAURATION_ENTRETIEN — elles conditionnent le
|
||||
* calcul du taux d'encadrement.
|
||||
*
|
||||
* L'exception d'ajout ressort en 422 avec SON message : le modèle y place
|
||||
* les refus métier lisibles (agent déjà membre, par exemple), destinés à
|
||||
* l'utilisateur.
|
||||
*
|
||||
* Charge utile : action, team_id, agent_id, fonction_equipe, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function member(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -121,12 +173,28 @@ final class TeamController
|
||||
$function,
|
||||
trim((string) ($payload['commentaire'] ?? '')) ?: null
|
||||
);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_MEMBRE_AJOUTE',
|
||||
'equipe_pta_membre',
|
||||
(int) $teamId,
|
||||
sprintf('Agent #%d ajouté à l’équipe #%d (%s).', (int) $agentId, (int) $teamId, $function),
|
||||
['id_agent' => (int) $agentId, 'fonction_equipe' => $function]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'L’agent a été ajouté à l’équipe.']);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/team_status.php — fait avancer une équipe dans son cycle de vie.
|
||||
*
|
||||
* Aucune transition n'est imposée : tout statut valide est acceptable depuis
|
||||
* n'importe quel autre, y compris un retour en arrière. Le suivi est
|
||||
* indicatif, pas un verrou.
|
||||
*
|
||||
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
|
||||
*/
|
||||
public function status(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -139,6 +207,13 @@ final class TeamController
|
||||
JsonResponse::send(422, ['error' => 'Équipe ou statut invalide.']);
|
||||
}
|
||||
$this->teamModel->setStatus((int) $teamId, $status);
|
||||
Journal::ecriture(
|
||||
'EQUIPE_STATUT_MODIFIE',
|
||||
'equipe_pta',
|
||||
(int) $teamId,
|
||||
sprintf('Équipe #%d passée au statut %s.', (int) $teamId, $status),
|
||||
['statut' => $status]
|
||||
);
|
||||
JsonResponse::send(200, ['message' => 'Le statut de l’équipe a été mis à jour.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Models\VacationModel;
|
||||
@@ -14,6 +15,22 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires.
|
||||
*
|
||||
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
|
||||
* journée, qui conditionne à son tour les motifs pertinents et l'application
|
||||
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
|
||||
* simple calendrier d'affichage.
|
||||
*
|
||||
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
|
||||
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
|
||||
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
|
||||
* humain l'ait relue — les libellés et découpages de l'API varient d'une
|
||||
* académie à l'autre.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class VacationController
|
||||
{
|
||||
private VacationModel $vacationModel;
|
||||
@@ -25,6 +42,10 @@ final class VacationController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/vacation_periods.php — périodes enregistrées.
|
||||
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -38,6 +59,19 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/preview_school_holidays.php — propositions de l'API officielle,
|
||||
* SANS aucune écriture en base.
|
||||
*
|
||||
* Le 502 (et non 500) en cas d'échec dit ce qui s'est passé : c'est le
|
||||
* service distant qui n'a pas répondu, pas PTA qui a échoué. Le conteneur
|
||||
* php a besoin d'un accès réseau sortant pour cet appel.
|
||||
*
|
||||
* Les `warnings` signalent les périodes que le service n'a pas su
|
||||
* interpréter ; elles sont montrées à l'utilisateur plutôt que tues.
|
||||
*
|
||||
* Paramètres GET : academy, year (année civile).
|
||||
*/
|
||||
public function preview(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -78,6 +112,21 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_vacation_periods.php — enregistre les périodes retenues.
|
||||
*
|
||||
* Le plafond de 50 périodes correspond à un large excédent d'une année
|
||||
* scolaire complète ; il borne le coût d'un envoi malformé ou répété.
|
||||
*
|
||||
* Les libellés, académies et zones sont tronqués avec les fonctions mb_* aux
|
||||
* longueurs des colonnes : une troncature en octets couperait un caractère
|
||||
* accentué en deux et produirait de l'UTF-8 invalide en base.
|
||||
*
|
||||
* `source` distingue MANUEL de DATA_GOUV pour tracer l'origine ; une valeur
|
||||
* inconnue retombe sur MANUEL au lieu de faire échouer l'enregistrement.
|
||||
*
|
||||
* Charge utile : periods[], csrf_token.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -129,6 +178,13 @@ final class VacationController
|
||||
}
|
||||
|
||||
$count = $this->vacationModel->saveMany($periods);
|
||||
Journal::ecriture(
|
||||
'VACANCES_ENREGISTREES',
|
||||
'periode_vacance',
|
||||
null,
|
||||
sprintf('%d période(s) de vacances enregistrée(s) ou mise(s) à jour.', $count),
|
||||
['nombre' => $count]
|
||||
);
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'count' => $count,
|
||||
@@ -136,6 +192,15 @@ final class VacationController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/delete_vacation_period.php — supprime une période.
|
||||
*
|
||||
* Aucun contrôle d'usage : supprimer une période change le contexte
|
||||
* scolaire/extrascolaire des journées concernées, mais ne touche aucun
|
||||
* créneau déjà planifié.
|
||||
*
|
||||
* Charge utile : period_id, csrf_token.
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -151,15 +216,27 @@ final class VacationController
|
||||
JsonResponse::send(404, ['error' => 'Période introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'VACANCES_SUPPRIMEES',
|
||||
'periode_vacance',
|
||||
(int) $id,
|
||||
sprintf('Période de vacances #%d supprimée.', (int) $id)
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function isDate(string $value): bool
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
return $date !== false && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
|
||||
* immédiatement la première : « 2026-2028 » est refusé.
|
||||
*/
|
||||
private function isSchoolYear(string $value): bool
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
|
||||
|
||||
@@ -6,16 +6,42 @@ namespace App\Controllers;
|
||||
|
||||
use App\Core\Access;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Journal;
|
||||
use App\Core\JsonResponse;
|
||||
use App\Core\Request;
|
||||
use App\Core\Security;
|
||||
use App\Models\AgentModel;
|
||||
use App\Models\MotifModel;
|
||||
use App\Models\QuotaModel;
|
||||
use App\Models\StructureModel;
|
||||
use App\Models\WeekTemplateModel;
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
|
||||
*
|
||||
* Une semaine type n'est PAS un planning : elle décrit une organisation
|
||||
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
|
||||
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
|
||||
*
|
||||
* Deux origines pour une semaine type, qui convergent vers
|
||||
* WeekTemplateModel::persistTemplate() :
|
||||
* - store() : capture d'une semaine réellement planifiée ;
|
||||
* - storeEntries() : composition directe dans l'éditeur.
|
||||
*
|
||||
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
|
||||
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
|
||||
*
|
||||
* Convention de codes d'erreur propre à ce contrôleur :
|
||||
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
|
||||
* alors de le remplacer en renvoyant replace: true) ;
|
||||
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
|
||||
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
|
||||
* reformulation de ce message dans WeekTemplateModel casse la distinction.
|
||||
*/
|
||||
final class WeekTemplateController
|
||||
{
|
||||
private WeekTemplateModel $templateModel;
|
||||
@@ -31,6 +57,12 @@ final class WeekTemplateController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/week_templates.php — liste des semaines types d'un agent.
|
||||
*
|
||||
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
|
||||
* show() sert à ouvrir.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -55,6 +87,152 @@ final class WeekTemplateController
|
||||
JsonResponse::send(200, ['templates' => $templates]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/week_template.php — contenu détaillé d'une semaine type.
|
||||
*
|
||||
* Le modèle est retrouvé par (template_id, agent_id) et non par le seul
|
||||
* identifiant : c'est ce qui empêche de lire le modèle d'un autre agent en
|
||||
* changeant l'identifiant dans l'URL.
|
||||
*
|
||||
* Les drapeaux `structure_active` et `motif_actif` sont conservés bien que
|
||||
* toujours vrais à la création : un lieu ou un motif peut être désactivé
|
||||
* APRÈS l'enregistrement du modèle, et l'éditeur doit le signaler plutôt que
|
||||
* d'échouer au moment de l'application.
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
|
||||
|
||||
if (!$agentId || !$templateId) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
|
||||
}
|
||||
|
||||
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
|
||||
if ($template === null) {
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
|
||||
}
|
||||
|
||||
$entries = array_map(static function (array $entry): array {
|
||||
return [
|
||||
'jour_semaine' => (int) $entry['jour_semaine'],
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
// L'éditeur manipule des heures au format HH:MM.
|
||||
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
|
||||
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
|
||||
'structure_nom' => (string) $entry['structure_nom'],
|
||||
'structure_active' => (int) $entry['structure_actif'] === 1,
|
||||
'motif_libelle' => (string) $entry['motif_libelle'],
|
||||
'motif_actif' => (int) $entry['motif_actif'] === 1,
|
||||
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
|
||||
];
|
||||
}, $this->templateModel->entries((int) $templateId));
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'updated_at' => (string) $template['date_modification'],
|
||||
],
|
||||
'entries' => $entries,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_week_template_entries.php — enregistre un modèle composé
|
||||
* directement dans l'éditeur (V7.10).
|
||||
*
|
||||
* Les listes de motifs et de lieux autorisés sont calculées ICI puis
|
||||
* transmises au modèle : celui-ci ne connaît pas les règles d'accès et se
|
||||
* contente de rejeter ce qui n'est pas dans les listes reçues.
|
||||
*
|
||||
* Charge utile : agent_id, name, entries[], replace (bool), csrf_token.
|
||||
*/
|
||||
public function storeEntries(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$name = (string) ($payload['name'] ?? '');
|
||||
$entries = $payload['entries'] ?? null;
|
||||
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$agentId || !is_array($entries)) {
|
||||
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
|
||||
}
|
||||
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
|
||||
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
|
||||
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
|
||||
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
|
||||
$allowedStructureIds = array_map(
|
||||
static fn (array $structure): int => (int) $structure['id_structure'],
|
||||
(new StructureModel($this->pdo))->allActive()
|
||||
);
|
||||
|
||||
try {
|
||||
$template = $this->templateModel->saveFromEntries(
|
||||
(int) $agentId,
|
||||
$name,
|
||||
$entries,
|
||||
$allowedMotifIds,
|
||||
$allowedStructureIds,
|
||||
$replace
|
||||
);
|
||||
} catch (DomainException $e) {
|
||||
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
|
||||
JsonResponse::send($status, [
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible d’enregistrer la semaine type.',
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
(int) $template['id_modele'],
|
||||
sprintf('Semaine type « %s » enregistrée depuis l’éditeur pour l’agent #%d.', (string) $template['nom'], (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
'entry_count' => (int) $template['nombre_creneaux'],
|
||||
],
|
||||
'message' => sprintf(
|
||||
'La semaine type « %s » a été enregistrée avec %d plage%s.',
|
||||
$template['nom'],
|
||||
$template['nombre_creneaux'],
|
||||
$template['nombre_creneaux'] > 1 ? 's' : ''
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/save_week_template.php — capture une semaine planifiée en modèle.
|
||||
*
|
||||
* Point de départ historique : l'utilisateur saisit une semaine « normale »,
|
||||
* puis la fige comme modèle réutilisable.
|
||||
*
|
||||
* Charge utile : agent_id, week, name, replace (bool), csrf_token.
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -90,6 +268,14 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_ENREGISTREE',
|
||||
'modele_semaine_agent',
|
||||
isset($template['id_modele']) ? (int) $template['id_modele'] : null,
|
||||
sprintf('Semaine type « %s » enregistrée depuis une semaine planifiée pour l’agent #%d.', $name, (int) $agentId),
|
||||
['id_agent' => (int) $agentId, 'nombre_creneaux' => (int) $template['nombre_creneaux'], 'remplacement' => $replace]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'template' => $template,
|
||||
@@ -97,6 +283,15 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
|
||||
*
|
||||
* Deux modes : `merge` complète les créneaux existants, `replace` les
|
||||
* remplace. Appliquer sur une semaine validée la repasse en brouillon
|
||||
* (`requires_revalidation`).
|
||||
*
|
||||
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
|
||||
*/
|
||||
public function apply(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -112,12 +307,18 @@ final class WeekTemplateController
|
||||
if (!$templateId || !$agentId || $week === null || !in_array($mode, ['merge', 'replace'], true)) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, semaine ou mode d’import invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
[$year, $weekNumber] = $week;
|
||||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||||
$this->assertDatesWithinContract($agent, array_map(
|
||||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||||
range(0, 4)
|
||||
));
|
||||
|
||||
try {
|
||||
[$year, $weekNumber] = $week;
|
||||
$result = $this->templateModel->applyToWeek((int) $templateId, (int) $agentId, $year, $weekNumber, $mode);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
@@ -140,6 +341,27 @@ final class WeekTemplateController
|
||||
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type « %s » appliquée à l’agent #%d sur la semaine %d de %d : %d créneau(x).',
|
||||
(string) $result['template']['nom'],
|
||||
(int) $agentId,
|
||||
$weekNumber,
|
||||
$year,
|
||||
(int) $result['inserted']
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'annee' => $year,
|
||||
'semaine' => $weekNumber,
|
||||
'mode' => $mode,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -151,6 +373,21 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/preview_week_template_period.php — simulation SANS écriture.
|
||||
*
|
||||
* Étape obligatoire dans l'interface avant applyPeriod() : une application
|
||||
* sur période touche des dizaines de semaines et peut rouvrir des plannings
|
||||
* validés. L'utilisateur doit voir le volume avant de confirmer.
|
||||
*
|
||||
* `period_filter` restreint le périmètre au calendrier scolaire :
|
||||
* all → toutes les semaines de l'intervalle ;
|
||||
* school → semaines scolaires seulement (périscolaire) ;
|
||||
* extra → vacances scolaires seulement (extrascolaire).
|
||||
*
|
||||
* Charge utile : template_id, agent_id, start_date, end_date, mode,
|
||||
* period_filter, csrf_token.
|
||||
*/
|
||||
public function previewPeriod(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -175,9 +412,11 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$preview = $this->templateModel->previewPeriod(
|
||||
@@ -206,6 +445,18 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/apply_week_template_period.php — applique un modèle sur une période.
|
||||
*
|
||||
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
|
||||
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
|
||||
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
|
||||
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
|
||||
*
|
||||
* Les quotas sont renvoyés indexés par date de début de période : une
|
||||
* application à cheval sur deux années civiles touche DEUX quotas annuels
|
||||
* distincts, et l'interface doit rafraîchir les deux.
|
||||
*/
|
||||
public function applyPeriod(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -230,9 +481,11 @@ final class WeekTemplateController
|
||||
) {
|
||||
JsonResponse::send(422, ['error' => 'Semaine type, agent, période, filtre de calendrier ou mode d’application invalide.']);
|
||||
}
|
||||
if ($this->agentModel->findActive((int) $agentId) === null) {
|
||||
$agent = $this->agentModel->findActive((int) $agentId);
|
||||
if ($agent === null) {
|
||||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||||
}
|
||||
$this->assertPeriodWithinContract($agent, $startDate, $endDate);
|
||||
|
||||
try {
|
||||
$result = $this->templateModel->applyToPeriod(
|
||||
@@ -294,6 +547,27 @@ final class WeekTemplateController
|
||||
$quotas[(string) $quota['date_debut_periode']] = $quota;
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_APPLIQUEE_PERIODE',
|
||||
'planning',
|
||||
null,
|
||||
sprintf(
|
||||
'Semaine type #%d appliquée à l’agent #%d du %s au %s.',
|
||||
(int) $templateId,
|
||||
(int) $agentId,
|
||||
$startDate,
|
||||
$endDate
|
||||
),
|
||||
[
|
||||
'id_agent' => (int) $agentId,
|
||||
'id_modele' => (int) $templateId,
|
||||
'date_debut' => $startDate,
|
||||
'date_fin' => $endDate,
|
||||
'mode' => $mode,
|
||||
'filtre_periode' => $periodFilter,
|
||||
]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
@@ -303,6 +577,14 @@ final class WeekTemplateController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/delete_week_template.php — supprime une semaine type.
|
||||
*
|
||||
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
|
||||
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
|
||||
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
|
||||
* patron, pas un lien vivant vers les plannings produits.
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -320,9 +602,67 @@ final class WeekTemplateController
|
||||
JsonResponse::send(404, ['error' => 'Semaine type introuvable.']);
|
||||
}
|
||||
|
||||
Journal::ecriture(
|
||||
'SEMAINE_TYPE_SUPPRIMEE',
|
||||
'modele_semaine_agent',
|
||||
(int) $templateId,
|
||||
sprintf('Semaine type #%d supprimée pour l’agent #%d.', (int) $templateId, (int) $agentId),
|
||||
['id_agent' => (int) $agentId]
|
||||
);
|
||||
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
/**
|
||||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||||
*
|
||||
* Copie assumée de PlanningController::assertDatesWithinContract() : les deux
|
||||
* contrôleurs n'ont pas de base commune, et la règle doit s'appliquer des
|
||||
* deux côtés. Le MESSAGE, lui, est construit une seule fois par AgentModel —
|
||||
* c'est lui qui doit rester unique, pas la garde.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, string> $dates
|
||||
*/
|
||||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||||
{
|
||||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||||
if ($hors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonResponse::send(422, [
|
||||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||||
'code' => 'HORS_CONTRAT',
|
||||
'dates' => $hors,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Même règle, pour une PÉRIODE entière.
|
||||
*
|
||||
* Seules les bornes sont contrôlées, et c'est suffisant : le contrat est un
|
||||
* intervalle continu, donc une période dont les deux extrémités y tombent y
|
||||
* est entièrement contenue. Contrôler chacun des jours d'une duplication
|
||||
* annuelle coûterait 365 comparaisons pour la même réponse.
|
||||
*
|
||||
* Le refus porte volontairement sur la période DEMANDÉE et non sur les seuls
|
||||
* jours réellement écrits : personne ne doit avoir à deviner qu'une
|
||||
* duplication « du 1er janvier au 31 décembre » n'a rien produit en novembre
|
||||
* parce que le contrat s'arrêtait en octobre.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
*/
|
||||
private function assertPeriodWithinContract(array $agent, string $startDate, string $endDate): void
|
||||
{
|
||||
$this->assertDatesWithinContract($agent, [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -16,43 +16,90 @@ 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<string, array<string, bool>> */
|
||||
/** @var array<int, string> Les quatre rôles connus de l'application. */
|
||||
private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI];
|
||||
|
||||
/**
|
||||
* Caches par requête : les vues d'accès sont interrogées une seule fois par
|
||||
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
|
||||
* qu'une modification SQL des droits s'applique sans redéploiement.
|
||||
*
|
||||
* @var array<string, array<string, bool>>
|
||||
*/
|
||||
private array $permissionCache = [];
|
||||
|
||||
/** @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 $role === self::ROLE_SERVICE;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
|
||||
public function role(): ?string
|
||||
{
|
||||
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
|
||||
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
|
||||
? $role
|
||||
: null;
|
||||
return in_array($role, self::ROLES, true) ? $role : null;
|
||||
}
|
||||
|
||||
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
|
||||
public function agentId(): ?int
|
||||
{
|
||||
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
|
||||
@@ -61,6 +108,13 @@ final class Access
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu de référence de la session.
|
||||
*
|
||||
* Pour un AGENT, il n'est pas stocké en session mais dérivé de son
|
||||
* rattachement en base : l'agent ne le choisit pas, et le déplacer d'un lieu
|
||||
* à l'autre doit prendre effet immédiatement.
|
||||
*/
|
||||
public function structureId(): ?int
|
||||
{
|
||||
if ($this->role() === self::ROLE_AGENT) {
|
||||
@@ -76,40 +130,98 @@ final class Access
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
|
||||
/**
|
||||
* Ouvre la session de travail à partir d'un compte d'accès authentifié.
|
||||
*
|
||||
* DEPUIS LA V7.11, C'EST LE SEUL CHEMIN D'ENTRÉE : l'ancien sélecteur libre
|
||||
* (select()) a disparu, une session ne peut plus naître que de la
|
||||
* présentation d'un jeton valide. Le rôle, l'agent et le lieu ne sont plus
|
||||
* choisis par l'utilisateur mais LUS SUR LE COMPTE — c'est ce qui rend la
|
||||
* mécanique de droits opposable.
|
||||
*
|
||||
* La vérification du jeton elle-même appartient à CompteModel : cette
|
||||
* méthode reçoit un compte déjà authentifié.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public function openSession(array $compte): void
|
||||
{
|
||||
$role = strtoupper(trim($role));
|
||||
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
|
||||
throw new \DomainException('Rôle invalide.');
|
||||
}
|
||||
|
||||
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
|
||||
|
||||
if ($role === self::ROLE_AGENT) {
|
||||
if (!$agentId || !$this->activeAgentExists($agentId)) {
|
||||
throw new \DomainException('Sélectionnez un agent actif.');
|
||||
}
|
||||
$selection['agent_id'] = $agentId;
|
||||
} elseif ($role === self::ROLE_RESPONSABLE) {
|
||||
if (!$structureId || !$this->activeStructureExists($structureId)) {
|
||||
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
|
||||
}
|
||||
$selection['structure_id'] = $structureId;
|
||||
}
|
||||
|
||||
// Regénération de l'identifiant de session à chaque ouverture : parade
|
||||
// classique à la fixation de session.
|
||||
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.
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = [
|
||||
'role' => (string) $compte['role'],
|
||||
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
|
||||
? (int) $compte['id_agent']
|
||||
: null,
|
||||
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null
|
||||
? (int) $compte['id_structure']
|
||||
: null,
|
||||
'compte_id' => (int) $compte['id_compte'],
|
||||
// Libellé figé pour le journal : il évite une jointure à chaque trace.
|
||||
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''),
|
||||
];
|
||||
|
||||
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle
|
||||
// connexion invalide le jeton précédent.
|
||||
unset($_SESSION['csrf_token']);
|
||||
$this->accountCache = null;
|
||||
$this->accountLoaded = false;
|
||||
}
|
||||
|
||||
/** Ferme la session de travail (déconnexion). */
|
||||
public function clear(): void
|
||||
{
|
||||
unset($_SESSION[self::SESSION_KEY], $_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();
|
||||
@@ -120,6 +232,7 @@ final class Access
|
||||
'label' => $this->roleLabel(),
|
||||
'agent_id' => $this->agentId(),
|
||||
'structure_id' => $this->structureId(),
|
||||
'account_id' => $this->accountId(),
|
||||
'permissions' => $permissions,
|
||||
];
|
||||
|
||||
@@ -138,6 +251,14 @@ 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;
|
||||
@@ -172,6 +293,7 @@ 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;
|
||||
@@ -200,6 +322,10 @@ 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);
|
||||
@@ -220,10 +346,19 @@ 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()) {
|
||||
@@ -231,47 +366,108 @@ final class Access
|
||||
exit;
|
||||
}
|
||||
if (!$this->canAccessPage($page)) {
|
||||
Journal::refus(
|
||||
'PAGE_REFUSEE',
|
||||
sprintf('Accès refusé à la page « %s ».', $page),
|
||||
['contexte' => ['page' => $page]]
|
||||
);
|
||||
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde des endpoints JSON : émet un 401/403 JSON et TERMINE le script.
|
||||
* Toutes les gardes require*() ci-dessous en dérivent.
|
||||
*
|
||||
* @param array<int, string> $roles Rôles acceptés.
|
||||
*/
|
||||
public function requireRoles(array $roles): void
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
$this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [
|
||||
'endpoint' => $this->endpointCourant(),
|
||||
]);
|
||||
JsonResponse::send(401, ['error' => 'Connectez-vous avec votre jeton d’accès avant de continuer.', 'code' => 'ROLE_REQUIRED']);
|
||||
}
|
||||
if (!in_array($this->role(), $roles, true)) {
|
||||
$this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [
|
||||
'endpoint' => $this->endpointCourant(),
|
||||
'roles_attendus' => array_values($roles),
|
||||
]);
|
||||
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise un refus d'API. Méthode privée volontairement minuscule : elle
|
||||
* n'existe que pour que les gardes ci-dessous tiennent en deux lignes.
|
||||
*
|
||||
* @param array<string, mixed> $contexte
|
||||
*/
|
||||
private function refuser(int $status, string $action, string $description, array $contexte = []): void
|
||||
{
|
||||
Journal::refus($action, $description, [
|
||||
'contexte' => array_merge(['statut_http' => $status], $contexte),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */
|
||||
private function endpointCourant(): string
|
||||
{
|
||||
$chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
|
||||
return $chemin !== '' ? basename($chemin) : 'inconnu';
|
||||
}
|
||||
|
||||
/** Réservé au Service Enfance : validation, référentiels, administration. */
|
||||
public function requireService(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réservé à la DSI : gestion des comptes d'accès et lecture du journal.
|
||||
*
|
||||
* Cette garde double le contrôle de page : la page « dsi » n'est autorisée
|
||||
* qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même
|
||||
* si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL.
|
||||
*/
|
||||
public function requireDsi(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_DSI]);
|
||||
}
|
||||
|
||||
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
|
||||
public function requireDraftEditor(): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
}
|
||||
|
||||
/** Consultation d'un lieu : hors Service Enfance, seul le lieu de rattachement. */
|
||||
public function requireStructureView(int $structureId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Édition dans un lieu : un responsable est confiné au sien. */
|
||||
public function requireStructureEdit(int $structureId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
|
||||
$this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consultation d'un agent. Un responsable y a droit dès que l'agent est
|
||||
* planifié chez lui, même s'il y est rattaché ailleurs : un agent peut
|
||||
* travailler sur plusieurs lieux la même semaine (v_acces_agent_par_structure).
|
||||
*/
|
||||
public function requireAgentView(int $agentId): void
|
||||
{
|
||||
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
|
||||
@@ -284,9 +480,14 @@ final class Access
|
||||
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
|
||||
return;
|
||||
}
|
||||
$this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]);
|
||||
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Édition d'un créneau, dont le périmètre est déduit du planning porteur.
|
||||
* Un identifiant inconnu donne un 404 avant tout contrôle de rôle plus fin.
|
||||
*/
|
||||
public function requireEntryEdit(int $entryId): void
|
||||
{
|
||||
$this->requireDraftEditor();
|
||||
@@ -304,6 +505,7 @@ 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();
|
||||
@@ -321,35 +523,74 @@ final class Access
|
||||
$this->requireStructureEdit((int) $structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu à pré-sélectionner et à verrouiller dans l'interface, ou null quand
|
||||
* l'utilisateur peut choisir librement (Service Enfance).
|
||||
*/
|
||||
public function editableStructureId(): ?int
|
||||
{
|
||||
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
|
||||
}
|
||||
|
||||
/** Libellé lisible du rôle, affiché dans l'en-tête et sur les PDF. */
|
||||
private function roleLabel(): string
|
||||
{
|
||||
return match ($this->role()) {
|
||||
self::ROLE_AGENT => 'Agent',
|
||||
self::ROLE_RESPONSABLE => 'Responsable de structure',
|
||||
self::ROLE_SERVICE => 'Service Enfance',
|
||||
self::ROLE_DSI => 'DSI',
|
||||
default => 'Rôle non sélectionné',
|
||||
};
|
||||
}
|
||||
|
||||
private function activeAgentExists(int $agentId): bool
|
||||
/**
|
||||
* Compte d'accès de la session, relu une fois par requête.
|
||||
*
|
||||
* La vue ne renvoie le compte que s'il est actif ET que l'identité qu'il
|
||||
* porte l'est aussi (`identite_active`) : désactiver un agent suffit donc à
|
||||
* lui couper l'accès, sans avoir à toucher à son jeton.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function accountRecord(): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
if ($this->accountLoaded) {
|
||||
return $this->accountCache;
|
||||
}
|
||||
|
||||
private function activeStructureExists(int $structureId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
$this->accountLoaded = true;
|
||||
$this->accountCache = null;
|
||||
|
||||
$compteId = $this->accountId();
|
||||
if ($compteId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle
|
||||
FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
// Le rôle de la session doit toujours correspondre à celui du compte :
|
||||
// une session forgée ou survivant à un changement de rôle du compte est
|
||||
// refusée plutôt que réparée.
|
||||
if ($compte !== false && (string) $compte['role'] === $this->role()) {
|
||||
$this->accountCache = $compte;
|
||||
}
|
||||
|
||||
return $this->accountCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche de l'agent en session.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function agentRecord(): ?array
|
||||
{
|
||||
$agentId = $this->agentId();
|
||||
@@ -365,6 +606,11 @@ final class Access
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche du lieu en session.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function structureRecord(): ?array
|
||||
{
|
||||
$structureId = $this->structureId();
|
||||
@@ -380,6 +626,10 @@ 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();
|
||||
|
||||
85
app/Core/ContactFormat.php
Normal file
85
app/Core/ContactFormat.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Contrôle de FORME des coordonnées d'un agent : adresse e-mail et numéro de
|
||||
* téléphone. Source de vérité UNIQUE côté serveur — create() et update() de
|
||||
* AgentController s'y réfèrent toutes les deux, et toute nouvelle voie
|
||||
* d'écriture d'un contact doit faire de même plutôt que recopier une regex.
|
||||
*
|
||||
* Une copie de ces règles vit dans assets/js/field-format.js pour le retour
|
||||
* immédiat à la saisie. C'est l'application habituelle du principe du projet :
|
||||
* l'interface prévient, le serveur tranche. Modifier une règle ici impose donc
|
||||
* de reporter la modification dans le module JS — l'inverse n'est jamais vrai.
|
||||
*
|
||||
* PORTÉE VOLONTAIREMENT LIMITÉE : ces deux champs sont FACULTATIFS et purement
|
||||
* informatifs (ils n'ouvrent aucune session, n'envoient aucun message). Le
|
||||
* contrôle attrape la faute de frappe, il ne prétend pas prouver qu'une boîte
|
||||
* ou une ligne existe. Une valeur vide reste toujours acceptée.
|
||||
*/
|
||||
final class ContactFormat
|
||||
{
|
||||
/**
|
||||
* Adresse e-mail. Regex délibérément plus stricte que la RFC 5322, qui
|
||||
* autorise des formes que personne ne saisit dans un annuaire d'agents
|
||||
* (commentaires, guillemets, adresses IP littérales). Elle est DOUBLÉE de
|
||||
* FILTER_VALIDATE_EMAIL : la regex écarte ce que le filtre tolère (domaine
|
||||
* sans point, par exemple « agent@mairie »), le filtre écarte ce que la
|
||||
* regex laisserait passer.
|
||||
*/
|
||||
private const EMAIL_PATTERN = '/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/';
|
||||
|
||||
/**
|
||||
* Numéro français, une fois les séparateurs retirés : 10 chiffres commençant
|
||||
* par 0, ou la même chose en forme internationale (+33 / 0033). Couvre le
|
||||
* fixe comme le mobile, métropole et outre-mer.
|
||||
*/
|
||||
private const PHONE_FR_PATTERN = '/^(?:\+33|0033|0)[1-9]\d{8}$/';
|
||||
|
||||
/**
|
||||
* Repli international E.164 pour un agent joignable à l'étranger — refuser
|
||||
* un numéro valide serait pire que d'en accepter un exotique, le champ
|
||||
* n'étant qu'informatif.
|
||||
*/
|
||||
private const PHONE_INTERNATIONAL_PATTERN = '/^\+[1-9]\d{7,14}$/';
|
||||
|
||||
/** Séparateurs tolérés à la saisie : espaces (insécable compris), . - / ( ). */
|
||||
private const PHONE_SEPARATORS = '/[\s.\-\/()\x{00A0}\x{202F}]/u';
|
||||
|
||||
/** Vrai si l'adresse e-mail est exploitable. La chaîne vide est refusée : c'est à l'appelant de traiter « non renseigné » avant. */
|
||||
public static function isEmail(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match(self::EMAIL_PATTERN, $value) === 1
|
||||
&& filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/** Vrai si le numéro de téléphone est exploitable, séparateurs de saisie ignorés. */
|
||||
public static function isPhone(string $value): bool
|
||||
{
|
||||
$compact = self::compactPhone($value);
|
||||
if ($compact === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match(self::PHONE_FR_PATTERN, $compact) === 1
|
||||
|| preg_match(self::PHONE_INTERNATIONAL_PATTERN, $compact) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numéro débarrassé de ses séparateurs, seule forme sur laquelle les regex
|
||||
* travaillent. « 06 12 34 56 78 », « 06.12.34.56.78 » et « 0612345678 »
|
||||
* décrivent la même ligne : les distinguer n'apporterait rien.
|
||||
*/
|
||||
public static function compactPhone(string $value): string
|
||||
{
|
||||
return (string) preg_replace(self::PHONE_SEPARATORS, '', trim($value));
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Base des contrôleurs qui rendent une page HTML.
|
||||
*
|
||||
* Les contrôleurs d'API n'en héritent pas : ils écrivent directement leur
|
||||
* réponse via JsonResponse::send() et n'ont donc aucune vue à rendre.
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
/**
|
||||
* Rend une vue de app/Views/ en lui exposant $data comme variables locales.
|
||||
*
|
||||
* @param string $view Chemin relatif sans extension, ex. « pages/planning ».
|
||||
* @param array<string, mixed> $data Variables mises à disposition de la vue.
|
||||
*
|
||||
* @throws \RuntimeException Si le fichier de vue n'existe pas.
|
||||
*/
|
||||
protected function render(string $view, array $data = []): void
|
||||
{
|
||||
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
|
||||
@@ -13,6 +27,8 @@ abstract class Controller
|
||||
throw new \RuntimeException('Vue introuvable : ' . $view);
|
||||
}
|
||||
|
||||
// EXTR_SKIP : une clé de $data ne peut pas écraser $view ni $viewFile,
|
||||
// ce qui rendrait le require imprévisible.
|
||||
extract($data, EXTR_SKIP);
|
||||
require $viewFile;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@ 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();
|
||||
@@ -17,12 +30,27 @@ final class Csrf
|
||||
return (string) $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le jeton d'une charge utile et interrompt la requête si besoin.
|
||||
*
|
||||
* À appeler en tête de TOUT endpoint mutant, sans exception. La comparaison
|
||||
* passe par hash_equals() pour ne pas être sensible au temps d'exécution.
|
||||
*
|
||||
* @param array<string, mixed> $payload Corps JSON déjà décodé.
|
||||
*/
|
||||
public static function assertPayload(array $payload): void
|
||||
{
|
||||
$expected = self::ensureToken();
|
||||
$provided = (string) ($payload['csrf_token'] ?? '');
|
||||
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
// Un jeton CSRF absent ou faux est le plus souvent une session
|
||||
// expirée, parfois une tentative réelle : dans les deux cas la trace
|
||||
// a sa place dans le journal (V7.11).
|
||||
Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [
|
||||
'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))],
|
||||
]);
|
||||
// send() termine le script : rien ne s'exécute après ce point.
|
||||
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
|
||||
}
|
||||
}
|
||||
|
||||
182
app/Core/Journal.php
Normal file
182
app/Core/Journal.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
|
||||
*
|
||||
* Trois familles d'événements sont tracées, et seulement celles-là :
|
||||
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
|
||||
* - ECRITURE : toute modification en base, avec la table et l'identifiant
|
||||
* visés ;
|
||||
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
|
||||
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
|
||||
* et SYSTEME, réservé aux incidents techniques.
|
||||
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
|
||||
* volume pour une valeur d'audit très faible.
|
||||
*
|
||||
* DEUX RÈGLES ABSOLUES :
|
||||
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
|
||||
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
|
||||
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
|
||||
* planification des agents.
|
||||
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
|
||||
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
|
||||
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
|
||||
*
|
||||
* La connexion PDO est posée une fois par app/bootstrap.php
|
||||
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
|
||||
* comme Csrf — n'ont ainsi rien à transporter.
|
||||
*/
|
||||
final class Journal
|
||||
{
|
||||
public const CONNEXION = 'CONNEXION';
|
||||
public const ECRITURE = 'ECRITURE';
|
||||
public const ACCES_REFUSE = 'ACCES_REFUSE';
|
||||
public const ADMINISTRATION = 'ADMINISTRATION';
|
||||
public const SYSTEME = 'SYSTEME';
|
||||
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
/**
|
||||
* Enregistre la connexion utilisée pour écrire les traces.
|
||||
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
|
||||
*/
|
||||
public static function useConnection(PDO $pdo): void
|
||||
{
|
||||
self::$pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une entrée, une sortie ou un échec d'authentification.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function connexion(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::CONNEXION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une modification en base.
|
||||
*
|
||||
* @param string $action Code stable, ex. « PLANNING_VALIDE ».
|
||||
* @param string $cibleTable Table principale touchée.
|
||||
* @param int|null $cibleId Identifiant de la ligne visée.
|
||||
* @param string $description Phrase lisible dans la page DSI.
|
||||
* @param array<string, mixed> $contexte Détail structuré (semaine, lieu…).
|
||||
*/
|
||||
public static function ecriture(
|
||||
string $action,
|
||||
string $cibleTable,
|
||||
?int $cibleId,
|
||||
string $description,
|
||||
array $contexte = []
|
||||
): void {
|
||||
self::enregistrer(self::ECRITURE, $action, [
|
||||
'cible_table' => $cibleTable,
|
||||
'cible_id' => $cibleId,
|
||||
'description' => $description,
|
||||
'contexte' => $contexte,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES.
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function refus(string $action, string $description, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [
|
||||
'description' => $description,
|
||||
'resultat' => 'REFUS',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace une action d'administration technique (comptes, jetons, journal).
|
||||
*
|
||||
* @param array<string, mixed> $options Voir enregistrer().
|
||||
*/
|
||||
public static function administration(string $action, array $options = []): void
|
||||
{
|
||||
self::enregistrer(self::ADMINISTRATION, $action, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit une ligne de journal. Ne lève jamais d'exception.
|
||||
*
|
||||
* L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est
|
||||
* indispensable à la connexion, où la trace est écrite AVANT que la session
|
||||
* porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session).
|
||||
*
|
||||
* @param string $categorie Une des constantes de classe.
|
||||
* @param string $action Code stable de l'action.
|
||||
* @param array<string, mixed> $options resultat, cible_table, cible_id,
|
||||
* description, contexte, id_compte,
|
||||
* role, acteur.
|
||||
*/
|
||||
public static function enregistrer(string $categorie, string $action, array $options = []): void
|
||||
{
|
||||
if (self::$pdo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = Access::sessionSnapshot();
|
||||
$contexte = $options['contexte'] ?? [];
|
||||
|
||||
$stmt = self::$pdo->prepare(
|
||||
'INSERT INTO journal_activite
|
||||
(categorie, action, resultat, id_compte, role, acteur,
|
||||
cible_table, cible_id, description, contexte,
|
||||
adresse_ip, agent_utilisateur)
|
||||
VALUES
|
||||
(:categorie, :action, :resultat, :id_compte, :role, :acteur,
|
||||
:cible_table, :cible_id, :description, :contexte,
|
||||
:adresse_ip, :agent_utilisateur)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
'categorie' => $categorie,
|
||||
'action' => mb_substr($action, 0, 60),
|
||||
'resultat' => $options['resultat'] ?? 'SUCCES',
|
||||
'id_compte' => $options['id_compte'] ?? $session['id_compte'],
|
||||
'role' => $options['role'] ?? $session['role'],
|
||||
'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180),
|
||||
'cible_table' => self::tronquer($options['cible_table'] ?? null, 64),
|
||||
'cible_id' => $options['cible_id'] ?? null,
|
||||
'description' => self::tronquer($options['description'] ?? null, 500),
|
||||
// JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit
|
||||
// pas faire échouer l'encodage, donc la trace tout entière.
|
||||
'contexte' => $contexte === []
|
||||
? null
|
||||
: json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
// REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par
|
||||
// le client et falsifiable. Derrière un reverse proxy, c'est au
|
||||
// proxy de réécrire REMOTE_ADDR.
|
||||
'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45),
|
||||
'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Règle 1 : le journal ne casse jamais l'action journalisée.
|
||||
error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tronque à la longueur de la colonne, en préservant les caractères accentués. */
|
||||
private static function tronquer(?string $valeur, int $longueur): ?string
|
||||
{
|
||||
if ($valeur === null) {
|
||||
return null;
|
||||
}
|
||||
$valeur = trim($valeur);
|
||||
return $valeur === '' ? null : mb_substr($valeur, 0, $longueur);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Sortie JSON des endpoints d'API.
|
||||
*
|
||||
* Le JavaScript de PTA refuse toute réponse qui n'est pas du JSON : une notice
|
||||
* PHP ou une page d'erreur HTML se traduirait côté client par un message
|
||||
* incompréhensible. bootstrap() garantit donc que même un warning ou une
|
||||
* exception non rattrapée ressort en JSON.
|
||||
*
|
||||
* Les endpoints PDF sont la seule exception : ils émettent des octets binaires
|
||||
* et n'appellent volontairement pas bootstrap() (voir PdfResponse).
|
||||
*/
|
||||
final class JsonResponse
|
||||
{
|
||||
/**
|
||||
* Installe les gestionnaires d'erreur/exception qui convertissent tout
|
||||
* incident en réponse JSON 500. À appeler en toute première instruction du
|
||||
* point d'entrée, avant même l'ouverture de la connexion PDO.
|
||||
*/
|
||||
public static function bootstrap(): void
|
||||
{
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Les warnings/notices deviennent des ErrorException : elles remontent
|
||||
// ainsi au gestionnaire d'exception ci-dessous au lieu de polluer la
|
||||
// sortie avec du HTML qui casserait le JSON.
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
// Respecte @ et error_reporting() : on laisse alors PHP gérer.
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
@@ -34,15 +54,27 @@ final class JsonResponse
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Émet la réponse JSON et TERMINE le script.
|
||||
*
|
||||
* Ce exit final est ce qui permet aux gardes (Access, Csrf, Request) de
|
||||
* s'écrire en une ligne sans que l'appelant ait à tester leur retour.
|
||||
*
|
||||
* @param int $status Code HTTP (200, 403, 409, 422, 500…).
|
||||
* @param array<string, mixed> $payload Corps sérialisé en JSON.
|
||||
*/
|
||||
public static function send(int $status, array $payload): void
|
||||
{
|
||||
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,8 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Émission d'un PDF généré par les services de app/Services/.
|
||||
*
|
||||
* Les points d'entrée PDF n'appellent PAS JsonResponse::bootstrap() : ils
|
||||
* produisent un flux binaire, et un gestionnaire d'erreur qui écrirait du JSON
|
||||
* au milieu corromprait le document.
|
||||
*/
|
||||
final class PdfResponse
|
||||
{
|
||||
/**
|
||||
* Envoie le document en affichage direct dans le navigateur, puis TERMINE
|
||||
* le script.
|
||||
*
|
||||
* @param string $content Octets du PDF déjà assemblé.
|
||||
* @param string $filename Nom proposé ; il est assaini avant d'atterrir dans
|
||||
* l'en-tête Content-Disposition, où un guillemet ou
|
||||
* un retour ligne permettrait une injection d'en-tête.
|
||||
*/
|
||||
public static function inline(string $content, string $filename): void
|
||||
{
|
||||
$safeFilename = preg_replace('/[^A-Za-z0-9._-]+/', '_', $filename) ?: 'planning.pdf';
|
||||
@@ -17,6 +33,8 @@ final class PdfResponse
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . strlen($content));
|
||||
// Un planning peut être revalidé d'une minute à l'autre : on interdit
|
||||
// au navigateur de resservir une version périmée.
|
||||
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||
header('Pragma: public');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Lecture de la requête entrante pour les endpoints JSON.
|
||||
*
|
||||
* Les deux méthodes coupent l'exécution en cas de non-conformité plutôt que de
|
||||
* lever une exception : l'appelant peut donc enchaîner sans bloc try/catch.
|
||||
*/
|
||||
final class Request
|
||||
{
|
||||
/**
|
||||
* Décode le corps JSON de la requête.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function json(): array
|
||||
{
|
||||
$payload = json_decode((string) file_get_contents('php://input'), true);
|
||||
@@ -16,6 +27,9 @@ final class Request
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Impose le verbe HTTP attendu (typiquement « POST » pour un endpoint mutant).
|
||||
*/
|
||||
public static function requireMethod(string $method): void
|
||||
{
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== strtoupper($method)) {
|
||||
|
||||
@@ -8,6 +8,19 @@ 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. */
|
||||
@@ -33,6 +46,161 @@ 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,
|
||||
@@ -52,6 +220,41 @@ final class AgentModel extends BaseModel
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
SQL;
|
||||
|
||||
/**
|
||||
* Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est
|
||||
* désactivé (V7.29).
|
||||
*
|
||||
* Trois précisions, toutes portées par le `NOT EXISTS` :
|
||||
*
|
||||
* - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu
|
||||
* son jeton, ce qui est l'état normal avant distribution : le filtrer
|
||||
* viderait les listes d'une installation neuve ;
|
||||
* - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui
|
||||
* est déjà exigé par ailleurs. Ce sont deux décisions distinctes :
|
||||
* fermer une fiche d'agent et couper un accès ;
|
||||
* - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de
|
||||
* consultation (planning par agent, vue annuelle, PTA détaillé) et le
|
||||
* référentiel « Agents » continuent de montrer l'agent — sans quoi on ne
|
||||
* pourrait plus ni lire son année passée, ni lui rétablir son accès.
|
||||
*
|
||||
* La table compte_acces est garantie présente : SchemaModel::assertCurrent()
|
||||
* refuse de démarrer si les vues de comptes manquent.
|
||||
*/
|
||||
private const SANS_COMPTE_COUPE = <<<'SQL'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM compte_acces ca
|
||||
WHERE ca.id_agent = a.id_agent AND ca.actif = 0
|
||||
)
|
||||
SQL;
|
||||
|
||||
/**
|
||||
* Tous les agents actifs, y compris ceux dont l'accès est coupé.
|
||||
*
|
||||
* Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte,
|
||||
* voir allSelectable().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -59,6 +262,28 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents proposables dans une liste d'affectation : actifs ET dont le compte
|
||||
* d'accès n'a pas été coupé.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allSelectable(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " ORDER BY a.nom, a.prenom"
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
|
||||
* y ont un planning, quel que soit leur rattachement.
|
||||
*
|
||||
* C'est le périmètre d'un responsable de structure, aligné sur la vue
|
||||
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allVisibleForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -82,6 +307,14 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
|
||||
*
|
||||
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
|
||||
* répartition sur l'année civile entière.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allEligibleForPta(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -93,6 +326,15 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Référentiel des postes.
|
||||
*
|
||||
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
|
||||
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
|
||||
* alimentent les calculs de PtaModel.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allPosts(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -105,6 +347,19 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents du sélecteur de l'éditeur de planning.
|
||||
*
|
||||
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
|
||||
* planifié ailleurs que sur son rattachement. Le tri fait simplement
|
||||
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
|
||||
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
|
||||
* visuellement.
|
||||
*
|
||||
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listForLocation(int $selectedLocation = 0): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -116,13 +371,18 @@ final class AgentModel extends BaseModel
|
||||
FROM agent a
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
WHERE a.actif = TRUE
|
||||
WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . "
|
||||
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute(['selected_location' => $selectedLocation]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche complète d'un agent actif.
|
||||
*
|
||||
* @return array<string, mixed>|null null si inexistant OU désactivé.
|
||||
*/
|
||||
public function findActive(int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -132,6 +392,10 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
|
||||
* rapprochement avec les outils RH, elle reste unique en base.
|
||||
*/
|
||||
public function matriculeExists(string $matricule): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
|
||||
@@ -139,6 +403,25 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
|
||||
* 1. la fiche `agent` ;
|
||||
* 2. sa première ligne d'historique `agent_structure` ;
|
||||
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
|
||||
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
|
||||
*
|
||||
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
|
||||
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
|
||||
* civile, jamais la date anniversaire du contrat.
|
||||
*
|
||||
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
|
||||
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
|
||||
* capturé plus haut dans $agentId.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
|
||||
*/
|
||||
public function createWithDefaults(array $data): array
|
||||
{
|
||||
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
|
||||
@@ -260,6 +543,32 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la fiche d'un agent et, si son rattachement change, son
|
||||
* historique `agent_structure`.
|
||||
*
|
||||
* L'historique n'est touché QUE si le lieu change réellement, sans quoi
|
||||
* chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne,
|
||||
* rendant l'historique illisible.
|
||||
*
|
||||
* La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont
|
||||
* la date de début est dans le FUTUR : on referme alors sur cette date de
|
||||
* début, pour ne pas produire une période qui finit avant de commencer.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le
|
||||
* passé : la ligne existante est réactivée plutôt que dupliquée.
|
||||
*
|
||||
* bindValue() explicite ici (et non un tableau passé à execute()) car
|
||||
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
|
||||
*
|
||||
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
|
||||
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
|
||||
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @throws \RuntimeException Si l'agent n'existe pas.
|
||||
*/
|
||||
public function updateProfile(int $agentId, array $data): void
|
||||
{
|
||||
try {
|
||||
@@ -276,7 +585,9 @@ final class AgentModel extends BaseModel
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE agent
|
||||
SET email = :email,
|
||||
SET nom = :nom,
|
||||
prenom = :prenom,
|
||||
email = :email,
|
||||
telephone = :telephone,
|
||||
adresse = :adresse,
|
||||
est_diplome = :est_diplome,
|
||||
@@ -291,6 +602,8 @@ 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']);
|
||||
@@ -340,6 +653,15 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le seul rattachement principal, sans toucher au reste de la fiche.
|
||||
*
|
||||
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
|
||||
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
|
||||
*
|
||||
* @param int|null $structureId null pour détacher l'agent de tout lieu par
|
||||
* défaut — il reste planifiable ponctuellement.
|
||||
*/
|
||||
public function updateAssignment(int $agentId, ?int $structureId): void
|
||||
{
|
||||
try {
|
||||
@@ -383,6 +705,14 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
|
||||
*
|
||||
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
|
||||
* telle que l'utilisateur la conçoit.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForWeek(int $agentId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -407,6 +737,15 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
|
||||
*
|
||||
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
|
||||
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
|
||||
* bornes de semaine.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -433,6 +772,18 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
|
||||
*
|
||||
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
|
||||
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
|
||||
* un motif en deux lignes.
|
||||
*
|
||||
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
|
||||
* est ce que le lecteur cherche en premier.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -458,6 +809,11 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul par motif sur une année civile complète.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForYear(int $agentId, int $year): array
|
||||
{
|
||||
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
|
||||
|
||||
@@ -9,10 +9,42 @@ use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Construction du calendrier annuel d'un agent, du 1er janvier au 31 décembre.
|
||||
*
|
||||
* Produit une structure entièrement prête à l'affichage : les 365 jours sont
|
||||
* calculés côté serveur, avec leurs libellés, classes CSS et infobulles. Le
|
||||
* JavaScript ne fait que rendre le tableau — c'est ce qui garantit que le
|
||||
* calendrier reste identique à l'écran, au PDF et à l'impression.
|
||||
*
|
||||
* MODÈLE DES QUATRE COLONNES par jour (constante CELL_KEYS) :
|
||||
* AP = Animation Périscolaire PP = Préparation Périscolaire
|
||||
* AE = Animation Extrascolaire PE = Préparation Extrascolaire
|
||||
*
|
||||
* Le classement d'un créneau dans l'une des quatre cases dépend de son motif et,
|
||||
* pour les motifs génériques, du caractère scolaire ou non de la journée
|
||||
* (voir cellForEntry()).
|
||||
*/
|
||||
final class AnnualOverviewModel extends BaseModel
|
||||
{
|
||||
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
|
||||
|
||||
/**
|
||||
* Calendrier annuel complet et ses totaux.
|
||||
*
|
||||
* Les créneaux sont lus EN UNE FOIS pour toute l'année puis indexés par date
|
||||
* en mémoire : 365 requêtes seraient inenvisageables.
|
||||
*
|
||||
* Trois familles de totaux cumulées en parallèle : par case (AP/PP/AE/PE),
|
||||
* par statut (validé / brouillon) et par motif particulier (congé,
|
||||
* formation, absence…). Elles répondent à trois questions différentes et ne
|
||||
* se déduisent pas les unes des autres.
|
||||
*
|
||||
* @return array<string, mixed> agent, year, quota_periods, calendar_totals,
|
||||
* months (12 entrées) et legend.
|
||||
*
|
||||
* @throws RuntimeException Si l'agent est introuvable ou inactif.
|
||||
*/
|
||||
public function overview(int $agentId, int $year): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
@@ -100,6 +132,27 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit UNE journée du calendrier à partir de ses créneaux.
|
||||
*
|
||||
* Les quatre cases sont toujours présentes, même vides : la grille HTML doit
|
||||
* garder un nombre de colonnes constant.
|
||||
*
|
||||
* Une case peut recevoir plusieurs créneaux ; elle prend alors la classe CSS
|
||||
* du motif le plus « fort » (voir strongestClass()), et son contenu affiche
|
||||
* les codes courts plutôt que la durée dès qu'un motif particulier est
|
||||
* présent — « CA » est plus parlant que « 7h00 » pour un jour de congé.
|
||||
*
|
||||
* `structure_id` retient le lieu du PREMIER créneau du jour, ou à défaut le
|
||||
* rattachement de l'agent : il sert à construire les liens profonds vers
|
||||
* l'écran de saisie.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Créneaux de la journée.
|
||||
* @param array<string, mixed>|null $vacation Période de vacances, ou null.
|
||||
* @param array<string, mixed> $agent Fiche de l'agent.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
|
||||
{
|
||||
$cells = [];
|
||||
@@ -186,6 +239,18 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Périodes de quota à afficher pour l'année.
|
||||
*
|
||||
* Trois dates de contexte sont interrogées (1er janvier, 31 décembre, et le
|
||||
* début de contrat s'il tombe dans l'année), puis dédoublonnées par leurs
|
||||
* bornes. Depuis que le quota suit l'année civile, elles donnent toutes la
|
||||
* même période : le résultat ne contient donc en pratique qu'une entrée. La
|
||||
* mécanique est conservée parce qu'elle reste juste si une règle de
|
||||
* proratisation en cours d'année est réintroduite.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
|
||||
{
|
||||
$quotaModel = new QuotaModel($this->pdo);
|
||||
@@ -210,6 +275,14 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return array_values($periods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif.
|
||||
*
|
||||
* Un motif non reconnu bascule sur le contexte de la journée : animation
|
||||
* extrascolaire pendant les vacances, périscolaire sinon. C'est le
|
||||
* comportement le moins surprenant pour un motif ajouté en base après coup,
|
||||
* et il évite qu'un temps disparaisse du calendrier.
|
||||
*/
|
||||
private function cellForEntry(string $code, bool $isVacation): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -222,6 +295,12 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Abréviation affichée dans la case pour les motifs PARTICULIERS.
|
||||
*
|
||||
* @return string|null null pour les motifs de travail ordinaires, qui
|
||||
* affichent alors leur durée plutôt qu'un code.
|
||||
*/
|
||||
private function shortCode(string $code): ?string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -236,6 +315,10 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe CSS d'un créneau, à partir de son motif et, à défaut, de sa case.
|
||||
* Les classes correspondantes sont définies dans assets/style.css.
|
||||
*/
|
||||
private function classForCode(string $code, string $cellKey): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -251,6 +334,17 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitre la classe d'une case contenant plusieurs motifs.
|
||||
*
|
||||
* Une seule couleur par case : c'est la plus « forte » qui l'emporte, selon
|
||||
* une priorité croissante allant du travail ordinaire à la journée non
|
||||
* travaillée. Une demi-journée de congé doit rester visible à côté d'une
|
||||
* demi-journée travaillée ; l'inverse ferait disparaître l'information.
|
||||
*
|
||||
* Le `>=` fait gagner le dernier créneau à priorité égale, ce qui est sans
|
||||
* conséquence puisque la classe est alors la même.
|
||||
*/
|
||||
private function strongestClass(string $current, string $candidate): string
|
||||
{
|
||||
$priority = [
|
||||
@@ -268,6 +362,12 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu textuel d'une case : rien si vide, les codes courts s'il y en a,
|
||||
* la durée compacte sinon.
|
||||
*
|
||||
* @param array<int, string> $codes
|
||||
*/
|
||||
private function cellDisplay(int $minutes, array $codes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
@@ -279,6 +379,15 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $this->formatCompactMinutes($minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de vacances contenant cette date, ou null.
|
||||
* Copie locale de VacationModel::vacationForDate() : les périodes sont déjà
|
||||
* chargées en mémoire pour toute l'année, inutile de repasser par le modèle.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
@@ -289,6 +398,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Durée en minutes entre deux HH:MM, plancher à 0. */
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
|
||||
@@ -296,6 +406,14 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décline chaque total en trois formes : minutes brutes, durée lisible
|
||||
* (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites.
|
||||
*
|
||||
* @param array<string, int> $totals
|
||||
*
|
||||
* @return array<string, array{minutes: int, duration: string, compact: string}>
|
||||
*/
|
||||
private function formatTotals(array $totals): array
|
||||
{
|
||||
$formatted = [];
|
||||
@@ -309,6 +427,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
@@ -316,6 +435,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
/** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */
|
||||
private function formatCompactMinutes(int $minutes): string
|
||||
{
|
||||
if ($minutes <= 0) {
|
||||
@@ -326,6 +446,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
|
||||
}
|
||||
|
||||
/** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */
|
||||
private function monthName(int $month): string
|
||||
{
|
||||
return [
|
||||
@@ -335,6 +456,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
][$month];
|
||||
}
|
||||
|
||||
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
|
||||
private function weekdayShort(int $weekday): string
|
||||
{
|
||||
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
|
||||
|
||||
@@ -6,6 +6,13 @@ namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Base commune des modèles : porte la connexion PDO partagée.
|
||||
*
|
||||
* Les modèles ne contiennent que du SQL et la mise en forme de ses résultats ;
|
||||
* les règles d'accès sont appliquées en amont par les contrôleurs
|
||||
* (voir App\Core\Access). Un modèle ne filtre donc jamais par rôle de lui-même.
|
||||
*/
|
||||
abstract class BaseModel
|
||||
{
|
||||
public function __construct(protected PDO $pdo)
|
||||
|
||||
706
app/Models/CompteModel.php
Normal file
706
app/Models/CompteModel.php
Normal file
@@ -0,0 +1,706 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Access;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Comptes d'accès et jetons (V7.11).
|
||||
*
|
||||
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
|
||||
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
|
||||
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
|
||||
* un post-it.
|
||||
*
|
||||
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
|
||||
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
|
||||
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
|
||||
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
|
||||
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
|
||||
*
|
||||
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
|
||||
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
|
||||
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
|
||||
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
|
||||
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
|
||||
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
|
||||
*/
|
||||
final class CompteModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
|
||||
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
|
||||
* caractères qu'on confond à la lecture en sont exclus.
|
||||
*/
|
||||
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
|
||||
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
|
||||
private const GROUPES = 4;
|
||||
private const TAILLE_GROUPE = 6;
|
||||
|
||||
/**
|
||||
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
|
||||
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MINIMUM = 12;
|
||||
|
||||
/**
|
||||
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
|
||||
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
|
||||
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
|
||||
* qu'un préfixe de 72.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MAXIMUM = 72;
|
||||
|
||||
/**
|
||||
* Empreinte du jeton d'installation posé par la migration V7.11.
|
||||
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
|
||||
* tant que le compte DSI initial n'a pas été renouvelé.
|
||||
*/
|
||||
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
|
||||
|
||||
/**
|
||||
* Vérifie un jeton présenté à la connexion.
|
||||
*
|
||||
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
|
||||
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
|
||||
* jeton techniquement valide.
|
||||
*
|
||||
* @return array<string, mixed>|null Le compte, ou null si le jeton est
|
||||
* inconnu, révoqué ou porté par une
|
||||
* identité inactive.
|
||||
*/
|
||||
public function authentifier(string $jeton): ?array
|
||||
{
|
||||
$jeton = self::normaliser($jeton);
|
||||
if ($jeton === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::empreinte($jeton)]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit un compte par son identifiant, pour la SECONDE étape de connexion.
|
||||
*
|
||||
* Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif
|
||||
* et identité active : entre la saisie du jeton et celle du mot de passe, la
|
||||
* DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage,
|
||||
* là où trouver() la met en forme pour l'API et le retire.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouverPourAuthentification(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Horodate une connexion réussie et incrémente son compteur. */
|
||||
public function enregistrerConnexion(int $compteId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET date_derniere_connexion = NOW(),
|
||||
nombre_connexions = nombre_connexions + 1
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des comptes pour la page DSI et pour la page « Jetons d'accès » du
|
||||
* Service Enfance.
|
||||
*
|
||||
* `roles` BORNE la liste à un ensemble de rôles. C'est ce qui permet à la
|
||||
* délégation V7.27 de ne jamais laisser voir un compte privilégié : le
|
||||
* contrôleur passe sa liste blanche, et aucun filtre d'interface ne peut
|
||||
* l'élargir — `role` ne fait que restreindre à l'intérieur de `roles`.
|
||||
*
|
||||
* @param array<string, mixed> $filtres role, actif (0|1), q (texte libre),
|
||||
* roles (array<int, string> facultatif).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function lister(array $filtres = []): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
$rolesAutorises = array_values(array_filter(array_map(
|
||||
static fn (mixed $role): string => strtoupper(trim((string) $role)),
|
||||
(array) ($filtres['roles'] ?? [])
|
||||
)));
|
||||
if ($rolesAutorises !== []) {
|
||||
$marqueurs = [];
|
||||
foreach ($rolesAutorises as $index => $roleAutorise) {
|
||||
$marqueurs[] = ':role_autorise_' . $index;
|
||||
$params['role_autorise_' . $index] = $roleAutorise;
|
||||
}
|
||||
$conditions[] = 'role IN (' . implode(', ', $marqueurs) . ')';
|
||||
}
|
||||
|
||||
$role = (string) ($filtres['role'] ?? '');
|
||||
// Le filtre d'interface ne peut que restreindre : un rôle hors de la
|
||||
// liste blanche ne rend rien, il ne la contourne pas.
|
||||
if ($role !== '' && ($rolesAutorises === [] || in_array(strtoupper($role), $rolesAutorises, true))) {
|
||||
$conditions[] = 'role = :role';
|
||||
$params['role'] = $role;
|
||||
} elseif ($role !== '') {
|
||||
$conditions[] = '1 = 0';
|
||||
}
|
||||
|
||||
if (isset($filtres['actif']) && $filtres['actif'] !== '') {
|
||||
$conditions[] = 'actif = :actif';
|
||||
$params['actif'] = (int) $filtres['actif'];
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
// UN MARQUEUR PAR COLONNE, et non un seul répété quatre fois : PDO
|
||||
// tourne ici sans émulation de requêtes préparées (ATTR_EMULATE_
|
||||
// PREPARES = 0), et MariaDB refuse alors un même paramètre nommé
|
||||
// employé plusieurs fois — « Invalid parameter number ». Répéter
|
||||
// « :q » faisait échouer toute recherche en 500.
|
||||
$colonnes = ['identite_libelle', 'libelle', 'matricule', 'jeton_prefixe'];
|
||||
$comparaisons = [];
|
||||
foreach ($colonnes as $index => $colonne) {
|
||||
$comparaisons[] = $colonne . ' LIKE :q_' . $index;
|
||||
$params['q_' . $index] = '%' . $recherche . '%';
|
||||
}
|
||||
$conditions[] = '(' . implode(' OR ', $comparaisons) . ')';
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure,
|
||||
matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire,
|
||||
date_creation, date_rotation, date_derniere_connexion, nombre_connexions,
|
||||
mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe
|
||||
FROM v_acces_compte' . $where . '
|
||||
ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
return array_map(
|
||||
fn (array $ligne): array => $this->presenter($ligne),
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte par son identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouver(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
return $compte === false ? null : $this->presenter($compte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identités actives encore dépourvues de compte.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function manquants(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle'
|
||||
);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire.
|
||||
*
|
||||
* @return array{compte: array<string, mixed>, jeton: string}
|
||||
*
|
||||
* @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas.
|
||||
*/
|
||||
public function creer(
|
||||
string $role,
|
||||
?int $agentId,
|
||||
?int $structureId,
|
||||
string $libelle,
|
||||
?string $commentaire = null
|
||||
): array {
|
||||
$role = strtoupper(trim($role));
|
||||
$libelle = trim($libelle);
|
||||
|
||||
if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) {
|
||||
throw new DomainException('Rôle inconnu.');
|
||||
}
|
||||
|
||||
// Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son
|
||||
// identité : sans identité, la session n'aurait aucun lieu ni aucun
|
||||
// agent à borner. Les deux autres rôles n'ont pas de périmètre à borner.
|
||||
if ($role === Access::ROLE_AGENT) {
|
||||
$structureId = null;
|
||||
if ($agentId === null) {
|
||||
throw new DomainException('Choisissez l’agent titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleAgent($agentId);
|
||||
} elseif ($role === Access::ROLE_RESPONSABLE) {
|
||||
$agentId = null;
|
||||
if ($structureId === null) {
|
||||
throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleStructure($structureId);
|
||||
} else {
|
||||
$agentId = null;
|
||||
$structureId = null;
|
||||
if ($libelle === '') {
|
||||
throw new DomainException('Indiquez le nom du titulaire du jeton.');
|
||||
}
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire)
|
||||
VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)'
|
||||
);
|
||||
|
||||
try {
|
||||
$stmt->execute([
|
||||
'role' => $role,
|
||||
'libelle' => mb_substr($libelle, 0, 180),
|
||||
'id_agent' => $agentId,
|
||||
'id_structure' => $structureId,
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'commentaire' => $commentaire !== null && trim($commentaire) !== ''
|
||||
? mb_substr(trim($commentaire), 0, 255)
|
||||
: null,
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
// 23000 = violation de contrainte : les index uniques sur id_agent et
|
||||
// id_structure garantissent un seul compte par identité.
|
||||
if ($e->getCode() === '23000') {
|
||||
throw new DomainException('Cette identité possède déjà un compte d’accès.');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$compte = $this->trouver((int) $this->pdo->lastInsertId());
|
||||
|
||||
return ['compte' => $compte ?? [], 'jeton' => $jeton];
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénère le jeton d'un compte : l'ancien cesse immédiatement de
|
||||
* fonctionner, y compris pour les sessions ouvertes — Access relit le compte
|
||||
* à chaque requête, mais c'est l'empreinte qui a changé, pas le compte.
|
||||
*
|
||||
* @return string Le nouveau jeton, en clair, à transmettre au titulaire.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas.
|
||||
*/
|
||||
public function regenererJeton(int $compteId): string
|
||||
{
|
||||
if ($this->trouver($compteId) === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET jeton_empreinte = :empreinte,
|
||||
jeton_prefixe = :prefixe,
|
||||
date_rotation = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'id' => $compteId,
|
||||
]);
|
||||
|
||||
return $jeton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active ou désactive un compte.
|
||||
*
|
||||
* @throws DomainException Si l'opération laisserait l'application sans
|
||||
* aucune DSI en état de se connecter.
|
||||
*/
|
||||
public function definirActif(int $compteId, bool $actif): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
if (!$actif) {
|
||||
$this->assurerDerniereDsi($compte);
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id');
|
||||
$stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement un compte. Les traces qu'il a laissées dans le
|
||||
* journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le
|
||||
* libellé de l'acteur y est figé.
|
||||
*
|
||||
* @throws DomainException Si c'est le dernier accès DSI actif.
|
||||
*/
|
||||
public function supprimer(int $compteId): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$this->assurerDerniereDsi($compte);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère en une passe un jeton pour chaque agent et chaque lieu actif qui
|
||||
* n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer
|
||||
* les comptes un par un avant que quiconque puisse se connecter.
|
||||
*
|
||||
* @return array<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
|
||||
* Les jetons produits, en clair. Cette liste est la seule occasion
|
||||
* de les lire : elle doit être distribuée puis refermée.
|
||||
*/
|
||||
public function genererManquants(): array
|
||||
{
|
||||
$produits = [];
|
||||
|
||||
foreach ($this->manquants() as $identite) {
|
||||
$role = (string) $identite['role'];
|
||||
$agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null;
|
||||
$structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null;
|
||||
|
||||
$resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']);
|
||||
$produits[] = [
|
||||
'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0),
|
||||
'role' => $role,
|
||||
'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']),
|
||||
'jeton' => $resultat['jeton'],
|
||||
];
|
||||
}
|
||||
|
||||
return $produits;
|
||||
}
|
||||
|
||||
/** Nombre de comptes DSI actifs, hors compte $exclu. */
|
||||
public function compterDsiActives(?int $exclu = null): int
|
||||
{
|
||||
// Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées,
|
||||
// un même paramètre nommé ne peut donc pas être répété dans la requête,
|
||||
// et aucun compte ne porte l'identifiant 0.
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM v_acces_compte
|
||||
WHERE role = :role AND actif = 1 AND identite_active = 1
|
||||
AND id_compte <> :exclu'
|
||||
);
|
||||
$stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai tant qu'un compte actif porte encore le jeton d'installation, dont
|
||||
* l'empreinte est écrite en clair dans la migration versionnée — donc
|
||||
* publique. L'interface DSI s'en sert pour alerter en rouge.
|
||||
*
|
||||
* La question est posée à la base plutôt qu'à la liste affichée : un filtre
|
||||
* de recherche ne doit pas faire disparaître l'alerte.
|
||||
*/
|
||||
public function installationEnCours(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1 FROM compte_acces
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce compte réclame-t-il un mot de passe en plus de son jeton ?
|
||||
*
|
||||
* La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des
|
||||
* rôles concernés se modifie en SQL, comme le reste des droits depuis la
|
||||
* V7.8.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function exigeMotDePasse(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le titulaire a-t-il déjà choisi son mot de passe ?
|
||||
* Faux à la création du compte comme après une réinitialisation par la DSI :
|
||||
* la connexion suivante impose alors d'en définir un.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function motDePasseDefini(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a
|
||||
* changé d'algorithme par défaut depuis sa création.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte, avec son hachage.
|
||||
*/
|
||||
public function verifierMotDePasse(array $compte, string $motDePasse): bool
|
||||
{
|
||||
$hash = (string) ($compte['mot_de_passe_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($motDePasse, $hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le mot de passe choisi par le titulaire.
|
||||
*
|
||||
* @throws DomainException Si la politique n'est pas respectée.
|
||||
*/
|
||||
public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void
|
||||
{
|
||||
if ($motDePasse !== $confirmation) {
|
||||
throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.');
|
||||
}
|
||||
|
||||
$this->assurerPolitique($motDePasse);
|
||||
$this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le
|
||||
* RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec
|
||||
* son jeton — la DSI ne connaît donc jamais le mot de passe de personne.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(int $compteId): array
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
if (!$compte['mot_de_passe_requis']) {
|
||||
throw new DomainException('Ce type de compte n’utilise pas de mot de passe.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $compte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Politique de mot de passe : la longueur, et rien d'autre.
|
||||
*
|
||||
* @throws DomainException Si le mot de passe est trop court, trop long ou
|
||||
* trop proche du jeton.
|
||||
*/
|
||||
private function assurerPolitique(string $motDePasse): void
|
||||
{
|
||||
// Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et
|
||||
// un accent compte pour deux.
|
||||
$longueur = strlen($motDePasse);
|
||||
|
||||
if ($longueur < self::MOT_DE_PASSE_MINIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe doit contenir au moins %d caractères.',
|
||||
self::MOT_DE_PASSE_MINIMUM
|
||||
));
|
||||
}
|
||||
if ($longueur > self::MOT_DE_PASSE_MAXIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe ne peut pas dépasser %d caractères.',
|
||||
self::MOT_DE_PASSE_MAXIMUM
|
||||
));
|
||||
}
|
||||
if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) {
|
||||
// Recopier son jeton dans le champ mot de passe ramènerait les deux
|
||||
// facteurs à un seul secret.
|
||||
throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Écrit une empreinte de mot de passe et horodate le changement. */
|
||||
private function enregistrerHash(int $compteId, string $hash): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['hash' => $hash, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */
|
||||
public static function genererJeton(): string
|
||||
{
|
||||
$groupes = [];
|
||||
for ($groupe = 0; $groupe < self::GROUPES; $groupe++) {
|
||||
$caracteres = '';
|
||||
for ($index = 0; $index < self::TAILLE_GROUPE; $index++) {
|
||||
// random_int() et non rand() : le tirage doit être imprévisible.
|
||||
$caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
||||
}
|
||||
$groupes[] = $caracteres;
|
||||
}
|
||||
|
||||
return 'PTA-' . implode('-', $groupes);
|
||||
}
|
||||
|
||||
/** Empreinte stockée en base. */
|
||||
public static function empreinte(string $jeton): string
|
||||
{
|
||||
return hash('sha256', self::normaliser($jeton));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un jeton saisi à la main : espaces, casse et espaces insécables
|
||||
* collés par un copier-coller depuis un courriel.
|
||||
*/
|
||||
public static function normaliser(string $jeton): string
|
||||
{
|
||||
$jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton);
|
||||
|
||||
return strtoupper(trim($jeton));
|
||||
}
|
||||
|
||||
/** Début du jeton, conservé en base pour l'identifier sans le révéler. */
|
||||
private static function prefixe(string $jeton): string
|
||||
{
|
||||
return mb_substr(self::normaliser($jeton), 0, 11);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une ligne de v_acces_compte pour l'API : types corrects,
|
||||
* empreinte remplacée par le seul drapeau qui intéresse l'interface.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
return [
|
||||
'id_compte' => (int) $ligne['id_compte'],
|
||||
'role' => (string) $ligne['role'],
|
||||
'libelle' => (string) $ligne['libelle'],
|
||||
'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']),
|
||||
'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1,
|
||||
'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null,
|
||||
'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null,
|
||||
'matricule' => $ligne['matricule'] ?? null,
|
||||
'structure_nom' => $ligne['structure_nom'] ?? null,
|
||||
'jeton_prefixe' => (string) $ligne['jeton_prefixe'],
|
||||
// Le jeton d'installation est public : l'interface doit pouvoir
|
||||
// crier tant qu'il n'a pas été remplacé.
|
||||
'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION,
|
||||
'actif' => (int) $ligne['actif'] === 1,
|
||||
// Le hachage ne sort jamais du serveur : l'interface n'a besoin que
|
||||
// de savoir si un mot de passe est exigé et s'il a été choisi.
|
||||
'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1,
|
||||
'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1,
|
||||
'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null,
|
||||
'commentaire' => $ligne['commentaire'] ?? null,
|
||||
'date_creation' => $ligne['date_creation'],
|
||||
'date_rotation' => $ligne['date_rotation'],
|
||||
'date_derniere_connexion' => $ligne['date_derniere_connexion'],
|
||||
'nombre_connexions' => (int) $ligne['nombre_connexions'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interdit de se couper soi-même la branche : le dernier accès DSI actif ne
|
||||
* peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer
|
||||
* la migration pour récupérer la main.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function assurerDerniereDsi(array $compte): void
|
||||
{
|
||||
if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->compterDsiActives((int) $compte['id_compte']) === 0) {
|
||||
throw new DomainException(
|
||||
'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */
|
||||
private function libelleAgent(int $agentId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Agent introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
|
||||
/** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */
|
||||
private function libelleStructure(int $structureId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Lieu d’affectation introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,23 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Règles de couverture d'un lieu et détection des plages sous-effectif.
|
||||
*
|
||||
* PRINCIPE DU CALCUL : chaque règle (jour, plage, minimum d'agents) est
|
||||
* découpée en créneaux de 15 MINUTES, l'effectif présent est compté sur chacun,
|
||||
* puis les créneaux consécutifs identiques sont refusionnés en intervalles
|
||||
* lisibles. Ce détour par le quart d'heure est ce qui permet de traiter
|
||||
* correctement des créneaux qui se recouvrent partiellement — un simple
|
||||
* comptage par plage donnerait un effectif faux dès que deux agents ont des
|
||||
* horaires décalés. Le pas de 15 minutes est aussi la granularité de saisie de
|
||||
* toute l'application.
|
||||
*
|
||||
* Deux niveaux de signalement :
|
||||
* - `gaps` : effectif PLANIFIÉ insuffisant — un vrai manque ;
|
||||
* - `validation_warnings` : effectif atteint, mais pas encore VALIDÉ. La
|
||||
* couverture n'est donc pas garantie, sans être en défaut.
|
||||
*/
|
||||
final class CoverageModel extends BaseModel
|
||||
{
|
||||
private const DAY_LABELS = [
|
||||
@@ -18,6 +35,11 @@ final class CoverageModel extends BaseModel
|
||||
5 => 'Vendredi',
|
||||
];
|
||||
|
||||
/**
|
||||
* Règles de couverture d'un lieu, triées par jour puis par horaire.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function rulesForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -33,6 +55,16 @@ final class CoverageModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace INTÉGRALEMENT les règles d'un lieu, en une transaction.
|
||||
*
|
||||
* Une liste vide supprime toute règle : le lieu redevient « non configuré »
|
||||
* et cesse de produire des alertes. Il n'y a pas d'ajout ni de suppression
|
||||
* unitaire, la validation métier (chevauchements compris) étant faite en
|
||||
* amont par CoverageController::saveRules().
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rules Règles déjà validées.
|
||||
*/
|
||||
public function replaceRules(int $structureId, array $rules): void
|
||||
{
|
||||
try {
|
||||
@@ -71,6 +103,18 @@ final class CoverageModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rapport de couverture d'un lieu sur une semaine.
|
||||
*
|
||||
* Un lieu sans règle renvoie `configured: false` et AUCUNE alerte : il n'y a
|
||||
* pas d'attendu, donc pas de manque. L'appelant doit le signaler à part —
|
||||
* taire ces lieux les ferait passer pour conformes.
|
||||
*
|
||||
* L'analyse s'arrête au VENDREDI (weekStart + 4 jours), conformément au
|
||||
* modèle métier.
|
||||
*
|
||||
* @return array{configured: bool, rules: array<int, array<string, mixed>>, gaps: array<int, array<string, mixed>>, validation_warnings: array<int, array<string, mixed>>, summary: array{gap_count: int, gap_minutes: int, validation_warning_count: int}}
|
||||
*/
|
||||
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
|
||||
{
|
||||
$rules = $this->rulesForStructure($structureId);
|
||||
@@ -172,6 +216,21 @@ final class CoverageModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux comptant pour la couverture d'un lieu.
|
||||
*
|
||||
* ATTENTION : le filtre porte sur le seul motif `TRAVAIL`. Les motifs de
|
||||
* travail plus fins (ALP_MATIN, RESTAURATION, ALP_SOIR, EXTRASCOLAIRE…),
|
||||
* pourtant reconnus comme du travail effectif par
|
||||
* App\Core\MotifCategories::WORK_CODES, ne sont PAS comptés ici. Un lieu
|
||||
* planifié exclusivement avec ces motifs apparaîtra donc entièrement
|
||||
* découvert. À revoir si les alertes remontent des manques inexpliqués.
|
||||
*
|
||||
* Les agents désactivés sont exclus (jointure sur `a.actif = TRUE`) : ils ne
|
||||
* couvrent plus rien.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -195,6 +254,21 @@ final class CoverageModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un créneau de 15 minutes, en le FUSIONNANT avec le précédent quand
|
||||
* c'est le même jour, qu'il enchaîne exactement (fin = début) et que tous
|
||||
* les compteurs comparés sont identiques.
|
||||
*
|
||||
* Sans cette fusion, une matinée découverte produirait seize alertes de
|
||||
* quinze minutes au lieu d'une seule ligne de 8h00 à 12h00.
|
||||
*
|
||||
* Ne regarde que le DERNIER élément : les créneaux sont produits dans
|
||||
* l'ordre chronologique, une fusion plus lointaine n'a pas lieu d'être.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $target Liste enrichie sur place.
|
||||
* @param array<string, mixed> $slot Créneau candidat.
|
||||
* @param array<int, string> $comparisonKeys Compteurs devant coïncider.
|
||||
*/
|
||||
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
|
||||
{
|
||||
$lastIndex = count($target) - 1;
|
||||
@@ -212,12 +286,14 @@ final class CoverageModel extends BaseModel
|
||||
$target[] = $slot;
|
||||
}
|
||||
|
||||
/** « 09:30 » → 570. Tronque à HH:MM, les horaires arrivant parfois en HH:MM:SS. */
|
||||
private function timeToMinutes(string $time): int
|
||||
{
|
||||
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
/** 570 → « 09:30 ». Opération inverse de timeToMinutes(). */
|
||||
private function minutesToTime(int $minutes): string
|
||||
{
|
||||
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
|
||||
|
||||
265
app/Models/JournalModel.php
Normal file
265
app/Models/JournalModel.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Lecture du journal d'activité pour la page DSI (V7.11).
|
||||
*
|
||||
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
|
||||
* n'est jamais loin d'une action métier. La seule exception est la purge, une
|
||||
* opération d'exploitation déclenchée à la main par la DSI.
|
||||
*
|
||||
* Le volume est la contrainte de conception : un journal se consulte par
|
||||
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
|
||||
* total est demandé séparément plutôt que ramené ligne par ligne.
|
||||
*/
|
||||
final class JournalModel extends BaseModel
|
||||
{
|
||||
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
|
||||
public const TAILLE_PAGE = 50;
|
||||
|
||||
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
|
||||
private const TAILLE_PAGE_MAX = 200;
|
||||
|
||||
/**
|
||||
* Recherche paginée dans le journal.
|
||||
*
|
||||
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
|
||||
* q (texte libre), du, au (AAAA-MM-JJ),
|
||||
* page, taille.
|
||||
*
|
||||
* @return array{entrees: array<int, array<string, mixed>>, total: int, page: int, taille: int, pages: int}
|
||||
*/
|
||||
public function rechercher(array $filtres = []): array
|
||||
{
|
||||
[$where, $params] = $this->conditions($filtres);
|
||||
|
||||
$taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE);
|
||||
$taille = max(10, min(self::TAILLE_PAGE_MAX, $taille));
|
||||
$page = max(1, (int) ($filtres['page'] ?? 1));
|
||||
|
||||
$total = $this->compter($where, $params);
|
||||
$pages = $total === 0 ? 1 : (int) ceil($total / $taille);
|
||||
// Une page vidée par un filtre plus restrictif ne doit pas renvoyer un
|
||||
// écran blanc : on se replie sur la dernière page réellement peuplée.
|
||||
$page = min($page, $pages);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role,
|
||||
acteur, cible_table, cible_id, description, contexte, adresse_ip,
|
||||
agent_utilisateur, jeton_prefixe
|
||||
FROM v_journal_activite' . $where . '
|
||||
ORDER BY date_evenement DESC, id_journal DESC
|
||||
LIMIT :limite OFFSET :decalage'
|
||||
);
|
||||
|
||||
foreach ($params as $nom => $valeur) {
|
||||
$stmt->bindValue(':' . $nom, $valeur);
|
||||
}
|
||||
// LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de
|
||||
// requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50'
|
||||
// que MariaDB refuse.
|
||||
$stmt->bindValue(':limite', $taille, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return [
|
||||
'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'taille' => $taille,
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quelques chiffres de tête pour le bandeau de la page DSI.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function statistiques(): array
|
||||
{
|
||||
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn();
|
||||
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT categorie, COUNT(*) AS nombre
|
||||
FROM journal_activite
|
||||
WHERE date_evenement >= NOW() - INTERVAL 7 DAY
|
||||
GROUP BY categorie'
|
||||
);
|
||||
$parCategorie = [];
|
||||
foreach ($stmt->fetchAll() as $ligne) {
|
||||
$parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre'];
|
||||
}
|
||||
|
||||
$refus24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$connexions24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\'
|
||||
AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$plusAncienne = $this->pdo->query(
|
||||
'SELECT MIN(date_evenement) FROM journal_activite'
|
||||
)->fetchColumn();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'sept_jours' => $parCategorie,
|
||||
'refus_24h' => $refus24h,
|
||||
'connexions_24h' => $connexions24h,
|
||||
'plus_ancienne' => $plusAncienne ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de secrets refusés pour une adresse IP sur les dernières minutes.
|
||||
*
|
||||
* C'est la mesure qui alimente le frein anti-force-brute de la page de
|
||||
* connexion : le journal sert ici de compteur, sans table supplémentaire.
|
||||
* Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et
|
||||
* mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton
|
||||
* volé doit buter sur le même mur qu'essayer mille jetons.
|
||||
*/
|
||||
public function echecsRecents(string $adresseIp, int $minutes): int
|
||||
{
|
||||
if ($adresseIp === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\'
|
||||
AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\')
|
||||
AND adresse_ip = :ip
|
||||
AND date_evenement >= NOW() - INTERVAL :minutes MINUTE'
|
||||
);
|
||||
$stmt->bindValue(':ip', $adresseIp);
|
||||
$stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entrées antérieures à $jours jours.
|
||||
*
|
||||
* Le plancher de 30 jours est volontaire : une purge trop courte reviendrait
|
||||
* à effacer les traces d'un incident avant même qu'il soit constaté.
|
||||
*
|
||||
* @return int Nombre de lignes supprimées.
|
||||
*/
|
||||
public function purger(int $jours): int
|
||||
{
|
||||
$jours = max(30, $jours);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY'
|
||||
);
|
||||
$stmt->bindValue(':jours', $jours, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la clause WHERE commune à la recherche et au comptage.
|
||||
*
|
||||
* @param array<string, mixed> $filtres
|
||||
*
|
||||
* @return array{0: string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function conditions(array $filtres): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) {
|
||||
$valeur = trim((string) ($filtres[$filtre] ?? ''));
|
||||
if ($valeur !== '') {
|
||||
$conditions[] = $colonne . ' = :' . $filtre;
|
||||
$params[$filtre] = $valeur;
|
||||
}
|
||||
}
|
||||
|
||||
$compteId = (int) ($filtres['id_compte'] ?? 0);
|
||||
if ($compteId > 0) {
|
||||
$conditions[] = 'id_compte = :id_compte';
|
||||
$params['id_compte'] = $compteId;
|
||||
}
|
||||
|
||||
$du = trim((string) ($filtres['du'] ?? ''));
|
||||
if ($du !== '') {
|
||||
$conditions[] = 'date_evenement >= :du';
|
||||
$params['du'] = $du . ' 00:00:00';
|
||||
}
|
||||
|
||||
$au = trim((string) ($filtres['au'] ?? ''));
|
||||
if ($au !== '') {
|
||||
$conditions[] = 'date_evenement <= :au';
|
||||
$params['au'] = $au . ' 23:59:59';
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
$conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)';
|
||||
$params['q'] = '%' . $recherche . '%';
|
||||
}
|
||||
|
||||
return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function compter(string $where, array $params): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une entrée pour l'API. `contexte` est décodé ici : le client
|
||||
* reçoit un objet, pas une chaîne JSON à réanalyser.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
$contexte = null;
|
||||
if (!empty($ligne['contexte'])) {
|
||||
$decode = json_decode((string) $ligne['contexte'], true);
|
||||
$contexte = is_array($decode) ? $decode : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id_journal' => (int) $ligne['id_journal'],
|
||||
'date_evenement' => (string) $ligne['date_evenement'],
|
||||
'categorie' => (string) $ligne['categorie'],
|
||||
'action' => (string) $ligne['action'],
|
||||
'resultat' => (string) $ligne['resultat'],
|
||||
'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null,
|
||||
'role' => $ligne['role'],
|
||||
'acteur' => $ligne['acteur'],
|
||||
'cible_table' => $ligne['cible_table'],
|
||||
'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null,
|
||||
'description' => $ligne['description'],
|
||||
'contexte' => $contexte,
|
||||
'adresse_ip' => $ligne['adresse_ip'],
|
||||
'agent_utilisateur' => $ligne['agent_utilisateur'],
|
||||
'jeton_prefixe' => $ligne['jeton_prefixe'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,27 @@ namespace App\Models;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
|
||||
/**
|
||||
* Référentiel des motifs de créneau (`motif_planning`).
|
||||
*
|
||||
* Le motif porte le drapeau `compte_dans_quota`, qui décide si le temps saisi
|
||||
* s'impute ou non sur le quota annuel de l'agent. C'est le seul point de
|
||||
* bascule : CONGE, AUTRE_ABSENCE et JNT ne comptent pas (voir
|
||||
* QuotaModel::REFERENCE_MINUTES pour le pourquoi).
|
||||
*
|
||||
* Les motifs sont classés fonctionnellement par App\Core\MotifCategories, qui
|
||||
* distingue travail effectif, formation, congé et absence.
|
||||
*/
|
||||
final class MotifModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Tous les motifs actifs, dans l'ordre d'affichage du référentiel.
|
||||
*
|
||||
* Liste NON filtrée par agent : voir allActiveForAgent() pour la variante
|
||||
* qui applique les droits.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -18,6 +37,11 @@ final class MotifModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des motifs actifs.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function activeIds(): array
|
||||
{
|
||||
return array_map(
|
||||
@@ -29,6 +53,14 @@ final class MotifModel extends BaseModel
|
||||
/**
|
||||
* Motifs réellement affectables à un agent : « Formation » disparaît de la
|
||||
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
|
||||
*
|
||||
* C'est la liste de référence des contrôles de saisie : tout motif absent
|
||||
* d'ici est refusé côté serveur, quelle que soit l'interface utilisée.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent (doit porter
|
||||
* droit_enveloppe_formation).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActiveForAgent(array $agent): array
|
||||
{
|
||||
|
||||
@@ -7,8 +7,40 @@ namespace App\Models;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Accès aux plannings hebdomadaires et à leurs créneaux.
|
||||
*
|
||||
* Modèle de données : `planning` (agent × semaine × lieu) porte des
|
||||
* `creneau_horaire` typés par un `motif_planning`. La table `semaine` est un
|
||||
* référentiel créé À LA DEMANDE (voir ensureWeek()) : une semaine n'existe en
|
||||
* base qu'à partir du moment où quelque chose y est planifié.
|
||||
*
|
||||
* Trois conventions valent pour tout ce fichier :
|
||||
*
|
||||
* 1. TRANSACTIONS — toute méthode qui écrit plusieurs lignes ouvre une
|
||||
* transaction et la déroule intégralement en cas d'échec. Le `catch` réémet
|
||||
* l'exception : c'est au contrôleur de la traduire en réponse HTTP.
|
||||
*
|
||||
* 2. VERROUS — les lectures qui précèdent une écriture portent `FOR UPDATE`.
|
||||
* Deux utilisateurs peuvent travailler sur le même agent en même temps ;
|
||||
* sans verrou, un contrôle d'antériorité serait fait sur un état déjà périmé.
|
||||
*
|
||||
* 3. RÉOUVERTURE AUTOMATIQUE — modifier le contenu d'un planning VALIDÉ le
|
||||
* repasse en BROUILLON, et les identifiants concernés remontent dans
|
||||
* `reopened_planning_ids`. Une validation ne doit jamais survivre à la
|
||||
* modification de ce qu'elle attestait.
|
||||
*
|
||||
* Test de chevauchement, identique partout : `debut1 < fin2 && fin1 > debut2`.
|
||||
* Deux créneaux jointifs (10:00-12:00 et 12:00-14:00) ne se chevauchent pas.
|
||||
*/
|
||||
final class PlanningModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Retrouve le planning d'un triplet (lieu, agent, semaine) — la clé
|
||||
* fonctionnelle d'un planning.
|
||||
*
|
||||
* @return array<string, mixed>|null null si rien n'a encore été saisi.
|
||||
*/
|
||||
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -30,6 +62,15 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un planning, motif joint.
|
||||
*
|
||||
* Les heures sortent en HH:MM (TIME_FORMAT) et non en HH:MM:SS : c'est le
|
||||
* format attendu par les champs de saisie du navigateur. Les écritures, à
|
||||
* l'inverse, passent en HH:MM:SS.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entries(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -47,6 +88,15 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux de l'agent sur les AUTRES lieux, la même semaine.
|
||||
*
|
||||
* Affichés en lecture seule dans l'éditeur : un agent pouvant être planifié
|
||||
* sur plusieurs lieux, c'est le seul moyen de voir un conflit avant de
|
||||
* l'enregistrer.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -78,6 +128,31 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le brouillon d'une semaine : REMPLACEMENT INTÉGRAL.
|
||||
*
|
||||
* Les créneaux existants du planning sont supprimés puis réinsérés. C'est
|
||||
* plus simple et plus sûr qu'une réconciliation ligne à ligne, au prix de la
|
||||
* perte des identifiants de créneaux — aucune donnée n'y étant rattachée,
|
||||
* c'est sans conséquence.
|
||||
*
|
||||
* Un planning VALIDÉ est refusé : il faut le rouvrir explicitement d'abord.
|
||||
* C'est la différence avec les modifications unitaires, qui rouvrent d'elles-
|
||||
* mêmes ; ici l'utilisateur écrase toute une semaine, le geste doit être
|
||||
* conscient.
|
||||
*
|
||||
* La semaine est créée si besoin par un INSERT … ON DUPLICATE KEY UPDATE
|
||||
* avec LAST_INSERT_ID(id_semaine) : cette astuce MariaDB fait remonter
|
||||
* l'identifiant EXISTANT via lastInsertId() plutôt qu'un zéro. Le repli par
|
||||
* SELECT couvre les configurations où l'astuce ne joue pas.
|
||||
*
|
||||
* @param array<int, array{date: string, motif_id: int, start: string, end: string}> $entries
|
||||
* Créneaux déjà normalisés par PlanningController::normalizeEntries().
|
||||
*
|
||||
* @return int Identifiant du planning créé ou mis à jour.
|
||||
*
|
||||
* @throws \DomainException Si le planning est déjà validé.
|
||||
*/
|
||||
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
@@ -168,6 +243,22 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tous les brouillons NON VIDES, toutes structures confondues.
|
||||
*
|
||||
* Le `HAVING COUNT(...) > 0` écarte les plannings sans créneau : ils
|
||||
* existent (un planning vidé n'est jamais supprimé) mais n'ont rien à
|
||||
* valider, et pollueraient la file de travail.
|
||||
*
|
||||
* Deux totaux distincts : `total_minutes` (tout le temps saisi) et
|
||||
* `minutes_decomptees` (seulement ce qui pèse sur le quota).
|
||||
* `date_fin_ouvrable` est le vendredi, la semaine s'arrêtant au jour ouvré.
|
||||
*
|
||||
* Tri par semaine croissante : les premiers éléments sont les plus anciens,
|
||||
* donc les plus urgents — DashboardController s'appuie sur cet ordre.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function pendingValidation(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
@@ -211,6 +302,12 @@ final class PlanningModel extends BaseModel
|
||||
/**
|
||||
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
|
||||
* dans l'année civile demandée.
|
||||
*
|
||||
* Le filtrage se fait sur la DATE DES CRÉNEAUX (sous-requête EXISTS) et non
|
||||
* sur l'année de la semaine : une semaine à cheval sur deux années civiles
|
||||
* doit être reprise par la validation annuelle des deux années.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function pendingValidationForAgentYear(int $agentId, int $year): array
|
||||
{
|
||||
@@ -254,6 +351,13 @@ final class PlanningModel extends BaseModel
|
||||
/**
|
||||
* Chevauchements avec n'importe quel autre planning de l'agent,
|
||||
* qu'il soit déjà validé ou encore en brouillon.
|
||||
*
|
||||
* Variante LARGE, utilisée par la validation ANNUELLE : tous les brouillons
|
||||
* de l'agent y sont validés d'un bloc, deux brouillons qui se recouvrent
|
||||
* doivent donc être signalés. La validation unitaire utilise au contraire
|
||||
* validatedExternalOverlaps(), plus permissive.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Paires de créneaux en conflit.
|
||||
*/
|
||||
public function externalOverlaps(int $planningId): array
|
||||
{
|
||||
@@ -277,6 +381,11 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning et bornes de sa semaine, par identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findDetailed(int $planningId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -291,6 +400,7 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Nombre de créneaux ; zéro déclenche le contrôle bloquant AUCUN_CRENEAU. */
|
||||
public function countEntries(int $planningId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
|
||||
@@ -298,6 +408,15 @@ final class PlanningModel extends BaseModel
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux sortis des bornes de leur semaine.
|
||||
*
|
||||
* Ne devrait jamais rien renvoyer : la saisie contrôle déjà l'appartenance.
|
||||
* Ce filet rattrape les données produites par un import, une reprise ou une
|
||||
* version antérieure du code.
|
||||
*
|
||||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||||
*/
|
||||
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -310,6 +429,42 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevauchements entre créneaux d'un MÊME planning.
|
||||
*
|
||||
* `c2.id_creneau > c1.id_creneau` évite de sortir chaque paire deux fois et
|
||||
* empêche un créneau de se comparer à lui-même.
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string}>
|
||||
*/
|
||||
/**
|
||||
* Créneaux du planning tombant hors des dates de contrat de leur agent.
|
||||
*
|
||||
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
|
||||
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
|
||||
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
|
||||
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
|
||||
* agent jointe : c'est elle qui fait foi au moment du contrôle.
|
||||
*
|
||||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||||
*/
|
||||
public function entriesOutsideContract(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT c.id_creneau, c.date_jour
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
JOIN agent a ON a.id_agent = p.id_agent
|
||||
WHERE c.id_planning = :id_planning
|
||||
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
|
||||
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
|
||||
ORDER BY c.date_jour'
|
||||
);
|
||||
$stmt->execute(['id_planning' => $planningId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function internalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -327,6 +482,16 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevauchements avec les seuls plannings DÉJÀ VALIDÉS d'un autre lieu.
|
||||
*
|
||||
* Variante RESTREINTE, utilisée par la validation unitaire : seul un
|
||||
* planning validé est opposable. Opposer les brouillons rendrait toute
|
||||
* validation impossible dès que deux brouillons concurrents existent, sans
|
||||
* ordre de résolution évident.
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string, structure_nom: string}>
|
||||
*/
|
||||
public function validatedExternalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -349,6 +514,11 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneau enrichi de son planning et de sa semaine.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findEntryDetailed(int $entryId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -371,6 +541,25 @@ final class PlanningModel extends BaseModel
|
||||
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
|
||||
* Le planning cible est créé si nécessaire et tout planning validé modifié
|
||||
* repasse automatiquement en brouillon.
|
||||
*
|
||||
* C'est la méthode la plus délicate du modèle : la nouvelle date peut
|
||||
* relever d'une autre SEMAINE et le nouveau lieu d'un autre PLANNING. Le
|
||||
* créneau est alors RATTACHÉ à un autre planning (mise à jour de
|
||||
* `id_planning`) plutôt que supprimé puis recréé, ce qui préserve son
|
||||
* identifiant.
|
||||
*
|
||||
* Jusqu'à DEUX plannings peuvent être rouverts : celui d'origine et celui de
|
||||
* destination.
|
||||
*
|
||||
* La recherche de conflit couvre TOUS les lieux de l'agent, pas seulement le
|
||||
* lieu visé : c'est l'agent qui ne peut pas être à deux endroits à la fois.
|
||||
*
|
||||
* @param string $start Heure de début en HH:MM:SS.
|
||||
* @param string $end Heure de fin en HH:MM:SS.
|
||||
*
|
||||
* @return array{entry_id: int, agent_id: int, year: int, week: int, source_planning_id: int, target_planning_id: int, reopened_planning_ids: int[]}
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
|
||||
*/
|
||||
public function updateEntryFromAgentView(
|
||||
int $entryId,
|
||||
@@ -501,6 +690,18 @@ final class PlanningModel extends BaseModel
|
||||
* Si le planning était validé, il repasse en brouillon afin de signaler
|
||||
* que son contenu a été modifié. Le planning est conservé même s'il ne
|
||||
* contient plus aucun créneau.
|
||||
*
|
||||
* Le contrôle `rowCount() !== 1` après le DELETE détecte la suppression
|
||||
* concurrente : le créneau était encore là au SELECT … FOR UPDATE mais plus
|
||||
* au DELETE. On préfère annuler et demander un rechargement plutôt que de
|
||||
* confirmer une suppression qui n'a rien fait.
|
||||
*
|
||||
* `remaining_entries` permet à l'interface d'annoncer un planning devenu
|
||||
* vide, cas nominal et non erreur.
|
||||
*
|
||||
* @return array{entry_id: int, planning_id: int, agent_id: int, year: int, week: int, date: string, reopened: bool, remaining_entries: int}
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable ou déjà supprimé.
|
||||
*/
|
||||
public function deleteEntryFromAgentView(int $entryId): array
|
||||
{
|
||||
@@ -573,6 +774,23 @@ final class PlanningModel extends BaseModel
|
||||
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
|
||||
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
|
||||
* changement de semaine.
|
||||
*
|
||||
* Sert le glisser-déposer de l'éditeur. Trois particularités :
|
||||
*
|
||||
* - Déposer sur le jour d'origine en mode déplacement est un NON-ÉVÉNEMENT :
|
||||
* on rend « action: noop » sans rien écrire, plutôt qu'une erreur pour un
|
||||
* geste de souris sans conséquence.
|
||||
* - En DUPLICATION, le créneau source est inclus dans la recherche de
|
||||
* conflit ($excludeEntryId = 0) : la copie entrerait en collision avec lui
|
||||
* si elle atterrissait au même endroit. En DÉPLACEMENT il en est exclu,
|
||||
* puisqu'il libère sa place.
|
||||
* - Une duplication ne rouvre PAS le planning source : celui-ci n'est pas
|
||||
* modifié, seule la destination l'est.
|
||||
*
|
||||
* @return array{entry_id: int, created_entry_id: int|null, agent_id: int, year: int, week: int, planning_id: int, target_planning_id: int, reopened_planning_ids: int[], action: string}
|
||||
* `action` vaut « move », « duplicate » ou « noop ».
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
|
||||
*/
|
||||
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
|
||||
{
|
||||
@@ -720,6 +938,31 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recopie tous les créneaux d'un agent d'une semaine vers une autre.
|
||||
*
|
||||
* Le report se fait par JOUR DE LA SEMAINE, pas par date : un créneau du
|
||||
* mardi source atterrit sur le mardi cible. `WEEKDAY() + 1` donne 1 (lundi)
|
||||
* à 5 (vendredi) ; le filtre `BETWEEN 0 AND 4` écarte le week-end, qui ne
|
||||
* devrait pas exister mais peut subsister d'une reprise de données.
|
||||
*
|
||||
* La copie couvre TOUS les lieux de la semaine source, d'où la réservation
|
||||
* de l'opération au Service Enfance.
|
||||
*
|
||||
* Deux modes :
|
||||
* - `replace` : les créneaux existants de la semaine cible sont supprimés,
|
||||
* tous lieux confondus ;
|
||||
* - `merge` : ils sont conservés, et le moindre chevauchement fait
|
||||
* ÉCHOUER l'opération entière. Une copie partielle laisserait un état
|
||||
* difficile à interpréter — mieux vaut ne rien faire et le dire.
|
||||
*
|
||||
* @param string $mode « merge » ou « replace ».
|
||||
*
|
||||
* @return array{agent_id: int, year: int, week: int, copied_entries: int, reopened_planning_ids: int[], mode: string}
|
||||
*
|
||||
* @throws \DomainException Mode invalide, semaines identiques, source vide,
|
||||
* ou conflit en mode merge.
|
||||
*/
|
||||
public function copyAgentWeek(
|
||||
int $agentId,
|
||||
int $sourceYear,
|
||||
@@ -874,6 +1117,17 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de la semaine, en la créant si elle n'existe pas.
|
||||
*
|
||||
* La table `semaine` est peuplée à la demande : aucune pré-génération de
|
||||
* calendrier. LAST_INSERT_ID(id_semaine) dans le ON DUPLICATE KEY UPDATE
|
||||
* fait remonter l'identifiant EXISTANT par lastInsertId(), ce qui évite un
|
||||
* SELECT supplémentaire dans le cas courant ; le repli couvre les
|
||||
* configurations où l'astuce ne joue pas.
|
||||
*
|
||||
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
|
||||
*/
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -901,7 +1155,17 @@ final class PlanningModel extends BaseModel
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
/** @return array{id_planning:int,statut:string} */
|
||||
/**
|
||||
* Retourne le planning du triplet (agent, semaine, lieu), en le créant au
|
||||
* besoin en BROUILLON.
|
||||
*
|
||||
* Le statut est renvoyé avec l'identifiant, car l'appelant en a besoin pour
|
||||
* décider s'il doit rouvrir le planning après modification.
|
||||
*
|
||||
* À N'APPELER QUE DANS UNE TRANSACTION : le SELECT porte un FOR UPDATE.
|
||||
*
|
||||
* @return array{id_planning:int,statut:string}
|
||||
*/
|
||||
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -932,7 +1196,17 @@ final class PlanningModel extends BaseModel
|
||||
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
/**
|
||||
* Repasse en BROUILLON les plannings listés et efface leur date de validation.
|
||||
*
|
||||
* Les identifiants sont dédoublonnés : un même planning peut être signalé
|
||||
* deux fois (source et destination confondues). La liste est renvoyée telle
|
||||
* qu'appliquée, pour remonter au client dans `reopened_planning_ids`.
|
||||
*
|
||||
* @param array<int, int|string> $planningIds
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function reopenPlanningIds(array $planningIds): array
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
|
||||
@@ -948,7 +1222,17 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
|
||||
|
||||
/** @param int[] $planningIds */
|
||||
/**
|
||||
* Valide un lot de plannings.
|
||||
*
|
||||
* La clause `statut = 'BROUILLON'` rend l'opération sûre en concurrence :
|
||||
* un planning validé entre-temps n'est pas revalidé, et rowCount() renvoie
|
||||
* le nombre RÉELLEMENT traité — c'est lui qui est annoncé à l'utilisateur.
|
||||
*
|
||||
* @param int[] $planningIds
|
||||
*
|
||||
* @return int Nombre de plannings effectivement validés.
|
||||
*/
|
||||
public function markValidatedMany(array $planningIds): int
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
|
||||
@@ -965,6 +1249,13 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un planning unitaire.
|
||||
*
|
||||
* Sans condition de statut, contrairement à markValidatedMany() : l'appelant
|
||||
* (PlanningController::validate()) a déjà lu le planning et joué tous ses
|
||||
* contrôles.
|
||||
*/
|
||||
public function markValidated(int $planningId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -973,6 +1264,13 @@ final class PlanningModel extends BaseModel
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repasse un planning VALIDÉ en BROUILLON, sur demande explicite.
|
||||
*
|
||||
* @return bool false si le planning n'était plus validé — un autre
|
||||
* utilisateur est passé entre-temps, et l'appelant doit
|
||||
* répondre 409 plutôt que d'annoncer un succès.
|
||||
*/
|
||||
public function reopen(int $planningId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -11,15 +11,56 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* PTA annuel d'un agent : répartition des temps et contrôles réglementaires.
|
||||
*
|
||||
* C'est le modèle qui porte le plus de RÈGLES DE DROIT DU TRAVAIL de
|
||||
* l'application. Les seuils sont exprimés en minutes et écrits en dur ; ils sont
|
||||
* rappelés ci-dessous pour qu'une évolution réglementaire soit repérable d'un
|
||||
* coup d'œil :
|
||||
*
|
||||
* 600 min = 10 h — durée quotidienne maximale de travail effectif ;
|
||||
* 720 min = 12 h — amplitude quotidienne maximale (du premier début à la
|
||||
* dernière fin, pauses comprises) ;
|
||||
* 360 min = 6 h — travail continu maximal sans pause d'au moins 20 minutes ;
|
||||
* 660 min = 11 h — repos minimal entre deux journées ;
|
||||
* 2880 min = 48 h — durée hebdomadaire maximale ;
|
||||
* 2640 min = 44 h — moyenne hebdomadaire maximale sur 12 semaines glissantes ;
|
||||
* 420 min = 7 h — durée attendue d'un ménage de fond ;
|
||||
* 25 jours de congés annuels et 2 journées de fractionnement attendus.
|
||||
*
|
||||
* Les contrôles suivent la forme commune {niveau, code, message}, ERREUR pour
|
||||
* un dépassement réglementaire, ALERTE pour un écart aux règles métier locales,
|
||||
* INFO pour un simple constat. À la différence de PlanningController::validate(),
|
||||
* RIEN N'EST BLOQUÉ ici : le PTA est un écran d'analyse, l'arbitrage revient à
|
||||
* l'agent de planification.
|
||||
*/
|
||||
final class PtaModel extends BaseModel
|
||||
{
|
||||
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
|
||||
private const WORK_CODES = MotifCategories::WORK_CODES;
|
||||
|
||||
/** Motifs de préparation, interdits aux agents de restauration/entretien. */
|
||||
private const PREPARATION_CODES = [
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||
];
|
||||
|
||||
/**
|
||||
* Synthèse complète du PTA d'un agent à une date d'observation.
|
||||
*
|
||||
* La période analysée n'est PAS choisie par l'appelant : c'est celle du
|
||||
* quota (année civile), déduite de $contextDate.
|
||||
*
|
||||
* Les contrôles proviennent de quatre sources indépendantes — éligibilité,
|
||||
* analyse des créneaux, contraintes individuelles et ménage de fond — puis
|
||||
* sont triés ERREUR, ALERTE, INFO : l'utilisateur doit voir les blocages en
|
||||
* premier.
|
||||
*
|
||||
* @return array<string, mixed> agent, pta, quota, categories, counters,
|
||||
* controls, constraints, wishes, vacations.
|
||||
*
|
||||
* @throws RuntimeException Si l'agent est introuvable ou inactif.
|
||||
*/
|
||||
public function summary(int $agentId, string $contextDate): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
@@ -72,6 +113,17 @@ final class PtaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insère une contrainte individuelle.
|
||||
*
|
||||
* $data est passé TEL QUEL à execute() : ses clés doivent correspondre
|
||||
* exactement aux paramètres nommés de la requête. La validation métier est
|
||||
* entièrement faite par PtaController::constraint().
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé.
|
||||
*/
|
||||
public function saveConstraint(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -89,6 +141,11 @@ final class PtaModel extends BaseModel
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une contrainte, à condition qu'elle appartienne bien à l'agent.
|
||||
*
|
||||
* @return bool false si elle n'existe pas ou relève d'un autre agent.
|
||||
*/
|
||||
public function deleteConstraint(int $constraintId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -98,6 +155,18 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un souhait de lieu, en création ou en mise à jour.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE s'appuie sur l'unicité (agent, lieu) : un agent
|
||||
* n'a qu'un seul avis par lieu, réémettre un souhait le remplace au lieu de
|
||||
* l'empiler. Il RÉACTIVE aussi un souhait précédemment retiré.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé, ou 0 en cas de mise à jour (lastInsertId()
|
||||
* ne renvoie rien d'exploitable dans ce cas).
|
||||
*/
|
||||
public function saveWish(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -117,6 +186,11 @@ final class PtaModel extends BaseModel
|
||||
return (int) ($this->pdo->lastInsertId() ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un souhait, à condition qu'il appartienne bien à l'agent.
|
||||
*
|
||||
* @return bool false si absent ou relevant d'un autre agent.
|
||||
*/
|
||||
public function deleteWish(int $wishId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -126,6 +200,20 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le PTA de la période, en le CRÉANT s'il n'existe pas.
|
||||
*
|
||||
* Création paresseuse volontaire : un agent créé avant la mise en place du
|
||||
* PTA, ou dont on consulte une année non encore ouverte, obtient son
|
||||
* enregistrement à la première consultation plutôt qu'une page en erreur.
|
||||
*
|
||||
* Le PTA est identifié par (agent, date_debut, date_fin) et non par la seule
|
||||
* année : c'est la période de quota qui fait foi.
|
||||
*
|
||||
* Effet de bord assumé : une simple lecture peut écrire en base.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function ensurePta(array $agent, array $quota): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -174,6 +262,40 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetch() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse tous les créneaux de la période — méthode centrale du modèle.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. VENTILATION de chaque créneau dans une catégorie métier
|
||||
* (categoryForEntry()), avec cumul des minutes et des jours distincts ;
|
||||
* 2. AGRÉGATS par jour (durée, amplitude, créneaux triés) et par semaine ;
|
||||
* 3. CONTRÔLES RÉGLEMENTAIRES, dans cet ordre :
|
||||
* - durée quotidienne (> 10 h) et amplitude (> 12 h) ;
|
||||
* - travail continu > 6 h sans coupure d'au moins 20 minutes. Deux
|
||||
* créneaux séparés de moins de 20 minutes sont considérés comme
|
||||
* continus, la pause étant trop courte pour compter ;
|
||||
* - repos entre deux journées CONSÉCUTIVEMENT PLANIFIÉES : les dates
|
||||
* sans créneau sont ignorées, un week-end n'a pas à être contrôlé ;
|
||||
* - durée hebdomadaire > 48 h ;
|
||||
* - moyenne > 44 h sur 12 semaines glissantes. Le contrôle s'arrête à la
|
||||
* PREMIÈRE fenêtre fautive : les suivantes se recouvrent et
|
||||
* produiraient des dizaines de messages redondants ;
|
||||
* 4. CONTRÔLES MÉTIER LOCAUX : durée attendue des mercredis et de
|
||||
* l'extrascolaire selon le poste, semaines de vacances incomplètes pour
|
||||
* la famille ANIMATION, interdiction de préparation pour la famille
|
||||
* RESTAURATION_ENTRETIEN, enveloppe de formation, et objectifs de
|
||||
* 25 jours de congés / 2 journées de fractionnement.
|
||||
*
|
||||
* Un contrôle INFO est ajouté si rien n'a été trouvé, pour que l'interface
|
||||
* affiche « aucune anomalie » plutôt qu'une liste vide ambiguë.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
* @param array<string, mixed> $quota
|
||||
*
|
||||
* @return array{categories: array<int, array<string, mixed>>, counters: array<string, mixed>, controls: array<int, array<string, string>>, daily: array<string, mixed>, weekly: array<string, int>}
|
||||
*/
|
||||
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
|
||||
{
|
||||
$categories = [
|
||||
@@ -449,6 +571,15 @@ final class PtaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que l'agent relève bien du dispositif PTA : contrat permanent et
|
||||
* poste renseigné. Sans poste, aucune des règles de durée attendue ne peut
|
||||
* s'appliquer.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function eligibilityControls(array $agent): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -463,6 +594,24 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confronte les journées planifiées aux contraintes individuelles.
|
||||
*
|
||||
* Chaque contrainte produit d'abord une ALERTE de rappel, visible même sans
|
||||
* dépassement : l'agent de planification doit savoir qu'elle existe. Un
|
||||
* dépassement du maximum quotidien produit ensuite une ERREUR.
|
||||
*
|
||||
* Une contrainte sans date de fin est réputée toujours en vigueur.
|
||||
*
|
||||
* LIMITE CONNUE : `maximum_minutes_semaine`, `quotite_temporaire` et les
|
||||
* interdictions matin/midi/soir sont enregistrés et affichés, mais AUCUN
|
||||
* contrôle automatique ne les exploite encore.
|
||||
*
|
||||
* @param array<string, mixed> $analysis Sortie d'analyseEntries().
|
||||
* @param array<int, array<string, mixed>> $constraints
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function constraintControls(array $analysis, array $constraints): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -495,6 +644,18 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle des ménages de fond : au moins DEUX agents ensemble, et 7 h par
|
||||
* agent.
|
||||
*
|
||||
* La sous-requête EXISTS restreint l'analyse aux (date, lieu) où CET agent
|
||||
* est lui-même sur un ménage de fond ; l'agrégat compte alors tous les
|
||||
* agents présents sur ce même ménage, y compris ceux qui ne sont pas
|
||||
* l'agent consulté. Sans elle, la requête balaierait tous les ménages de
|
||||
* fond de la collectivité.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -543,6 +704,15 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contraintes actives qui INTERSECTENT la période.
|
||||
*
|
||||
* Le COALESCE(date_fin, '9999-12-31') traite une contrainte sans terme comme
|
||||
* courant indéfiniment ; un simple `date_fin >= :date_debut` l'écarterait,
|
||||
* NULL n'étant comparable à rien.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -560,6 +730,14 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Souhaits de lieux de l'agent.
|
||||
*
|
||||
* Le FIELD() force les INTERDICTIONS en tête, avant les préférences : une
|
||||
* interdiction est une contrainte, une préférence un simple souhait.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function wishesForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -574,6 +752,21 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe un créneau dans une catégorie métier du PTA.
|
||||
*
|
||||
* Le motif générique `TRAVAIL` est ventilé selon le CONTEXTE : extrascolaire
|
||||
* pendant les vacances, mercredi périscolaire le mercredi (weekday 3),
|
||||
* périscolaire sinon. C'est l'héritage des saisies antérieures aux motifs
|
||||
* détaillés, qui doivent rester correctement réparties.
|
||||
*
|
||||
* Un motif inconnu suit son drapeau `compte_dans_quota` : périscolaire s'il
|
||||
* compte, non décompté sinon — le comportement le moins surprenant pour un
|
||||
* motif ajouté en base après coup.
|
||||
*
|
||||
* @param int $weekday 1 (lundi) à 7 (dimanche).
|
||||
* @param bool $counts Valeur de compte_dans_quota du motif.
|
||||
*/
|
||||
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -592,6 +785,7 @@ final class PtaModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/** Libellé affichable d'une catégorie ; le code brut sert de repli. */
|
||||
private function categoryLabel(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -609,6 +803,15 @@ final class PtaModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de vacances contenant cette date, ou null.
|
||||
* Copie locale : les périodes sont déjà chargées en mémoire pour toute
|
||||
* l'année analysée.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
@@ -619,6 +822,13 @@ final class PtaModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée en minutes entre deux horaires.
|
||||
*
|
||||
* SANS plancher à zéro, contrairement à ses homonymes des autres modèles :
|
||||
* le calcul de la pause continue a besoin des valeurs négatives pour
|
||||
* détecter deux créneaux qui se recouvrent.
|
||||
*/
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
|
||||
@@ -626,6 +836,7 @@ final class PtaModel extends BaseModel
|
||||
return ($eh * 60 + $em) - ($sh * 60 + $sm);
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
@@ -633,11 +844,19 @@ final class PtaModel extends BaseModel
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
/** « 2026-02-17 » → « 17/02/2026 », format des messages utilisateur. */
|
||||
private function formatDate(string $date): string
|
||||
{
|
||||
return (new DateTimeImmutable($date))->format('d/m/Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique un contrôle au format commun {niveau, code, message}.
|
||||
*
|
||||
* @param string $level ERREUR, ALERTE ou INFO.
|
||||
*
|
||||
* @return array{niveau: string, code: string, message: string}
|
||||
*/
|
||||
private function control(string $level, string $code, string $message): array
|
||||
{
|
||||
return ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -16,8 +16,28 @@ use InvalidArgumentException;
|
||||
*/
|
||||
final class QuotaModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
|
||||
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
|
||||
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
|
||||
* les 7 h de la journée de solidarité).
|
||||
*
|
||||
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
|
||||
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
|
||||
* travail effectif alimente le compteur, via `compte_dans_quota`.
|
||||
*/
|
||||
public const REFERENCE_MINUTES = 1607 * 60;
|
||||
|
||||
/**
|
||||
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
|
||||
*
|
||||
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
|
||||
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
|
||||
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
|
||||
* fausserait un suivi déjà entamé.
|
||||
*
|
||||
* @param float $rate Quotité en pourcentage (100 = temps plein).
|
||||
*/
|
||||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||
{
|
||||
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
|
||||
@@ -43,6 +63,19 @@ final class QuotaModel extends BaseModel
|
||||
*
|
||||
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
|
||||
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
|
||||
*
|
||||
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
|
||||
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
|
||||
* un brouillon consomme déjà le quota du point de vue de la planification,
|
||||
* la distinction ne sert qu'à indiquer ce qui est acquis.
|
||||
*
|
||||
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
|
||||
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
|
||||
*
|
||||
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
|
||||
* ALERTE à la validation, jamais bloqué.
|
||||
*
|
||||
* @return array<string, mixed> Période de référence enrichie des compteurs.
|
||||
*/
|
||||
public function forDate(int $agentId, string $contextDate): array
|
||||
{
|
||||
@@ -90,16 +123,33 @@ final class QuotaModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idem forDate(), avec les libellés lisibles ajoutés par enrich().
|
||||
* C'est cette variante que consomment tous les endpoints JSON.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forDateForApi(int $agentId, string $contextDate): array
|
||||
{
|
||||
return $this->enrich($this->forDate($agentId, $contextDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
|
||||
* la période étant de toute façon l'année civile complète.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function annual(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* Idem annual(), enrichi des libellés.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function annualForApi(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
|
||||
@@ -108,6 +158,16 @@ final class QuotaModel extends BaseModel
|
||||
/**
|
||||
* Période de référence PTA : toujours l'année civile.
|
||||
* La date de contrat est retournée uniquement à titre informatif.
|
||||
*
|
||||
* Les clés `periode_contractuelle`, `periode_civile` et
|
||||
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
|
||||
* pouvait être ancrée sur la date de contrat. Elles sont figées
|
||||
* (false / true / false) et conservées pour ne pas casser les écrans qui les
|
||||
* lisent encore. Ne pas construire de nouvelle logique dessus.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws InvalidArgumentException Si la date de contexte est malformée.
|
||||
*/
|
||||
public function referencePeriod(int $agentId, string $contextDate): array
|
||||
{
|
||||
@@ -141,6 +201,16 @@ final class QuotaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute les versions lisibles des compteurs à un quota brut.
|
||||
*
|
||||
* Les clés en minutes restent présentes : le JavaScript calcule sur les
|
||||
* minutes et n'affiche que les libellés.
|
||||
*
|
||||
* @param array<string, mixed> $quota
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function enrich(array $quota): array
|
||||
{
|
||||
return array_merge($quota, [
|
||||
@@ -152,6 +222,16 @@ final class QuotaModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une durée en minutes sous ses trois représentations.
|
||||
*
|
||||
* Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait
|
||||
* avant la division, sans quoi intdiv() et le modulo produiraient un libellé
|
||||
* incohérent du type « -1 h -30 ».
|
||||
*
|
||||
* @return array{minutes: int, heures_decimales: float, libelle: string}
|
||||
* Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ».
|
||||
*/
|
||||
public function formatMinutes(int $minutes): array
|
||||
{
|
||||
$sign = $minutes < 0 ? -1 : 1;
|
||||
@@ -166,6 +246,24 @@ final class QuotaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve la ligne de quota la plus pertinente pour une année donnée.
|
||||
*
|
||||
* Le tri du ORDER BY applique une règle de repli en trois temps :
|
||||
* 1. l'année demandée exactement ;
|
||||
* 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus
|
||||
* récente reste la meilleure estimation) ;
|
||||
* 3. sinon l'année POSTÉRIEURE la plus proche.
|
||||
*
|
||||
* Sans aucune ligne — agent créé avant la mise en place des quotas — on
|
||||
* retombe sur un temps plein théorique plutôt que sur un quota nul, qui
|
||||
* afficherait un dépassement à la première heure planifiée.
|
||||
*
|
||||
* `quota_source_annee` indique quelle année a réellement servi, pour que
|
||||
* l'interface puisse signaler un quota emprunté à une autre année.
|
||||
*
|
||||
* @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int}
|
||||
*/
|
||||
private function quotaProfile(int $agentId, int $year): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -4,8 +4,36 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Contrôle de conformité du schéma, joué à CHAQUE requête par app/bootstrap.php.
|
||||
*
|
||||
* Ce garde-fou existe parce que les migrations sont des fichiers SQL appliqués
|
||||
* par un service séparé : rien ne garantit que la base en face du code soit à
|
||||
* jour. Plutôt que de laisser l'application échouer plus tard sur une erreur SQL
|
||||
* cryptique au milieu d'un écran, on refuse de démarrer avec un message qui
|
||||
* nomme précisément ce qui manque et quel fichier jouer.
|
||||
*
|
||||
* CONSÉQUENCE POUR LE DÉVELOPPEUR : ajouter une table ou une colonne obligatoire
|
||||
* impose de compléter les listes ci-dessous EN MÊME TEMPS que la migration.
|
||||
* Oublier de le faire ne casse rien tout de suite — c'est justement le piège :
|
||||
* l'application démarrera sur une base incomplète.
|
||||
*
|
||||
* Le coût est de trois requêtes sur information_schema par requête HTTP,
|
||||
* accepté au regard du volume d'utilisateurs de l'application.
|
||||
*/
|
||||
final class SchemaModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Vérifie tables, colonnes puis vues d'accès.
|
||||
*
|
||||
* Seules les colonnes AJOUTÉES PAR MIGRATION sont listées, pas l'intégralité
|
||||
* du schéma : ce sont les seules susceptibles de manquer sur une base créée
|
||||
* à partir de database/pta_prod_schema_mariadb.sql, qui ne contient que le
|
||||
* socle métier.
|
||||
*
|
||||
* @throws \RuntimeException Au premier manque constaté, avec le détail et
|
||||
* le fichier SQL à exécuter.
|
||||
*/
|
||||
public function assertCurrent(): void
|
||||
{
|
||||
$requiredTables = [
|
||||
@@ -28,6 +56,8 @@ final class SchemaModel extends BaseModel
|
||||
'equipe_pta',
|
||||
'equipe_pta_membre',
|
||||
'structure_creneau_reference',
|
||||
'compte_acces',
|
||||
'journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||
@@ -54,6 +84,7 @@ final class SchemaModel extends BaseModel
|
||||
'planning' => ['date_validation'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
|
||||
'structure' => ['type_affectation'],
|
||||
'compte_acces' => ['mot_de_passe_hash'],
|
||||
];
|
||||
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
@@ -80,6 +111,7 @@ final class SchemaModel extends BaseModel
|
||||
}
|
||||
|
||||
$this->assertAccessViews();
|
||||
$this->assertJetonViews();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,4 +149,39 @@ final class SchemaModel extends BaseModel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vues des comptes à jeton et du journal (V7.11).
|
||||
*
|
||||
* Même logique fail-closed que pour les vues de droits : sans elles,
|
||||
* personne ne peut plus s'authentifier et aucune action ne serait tracée.
|
||||
* Mieux vaut un message explicite au démarrage qu'une application ouverte
|
||||
* sans journal.
|
||||
*/
|
||||
private function assertJetonViews(): void
|
||||
{
|
||||
$requiredViews = [
|
||||
'v_acces_compte',
|
||||
'v_acces_compte_manquant',
|
||||
'v_journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT TABLE_NAME
|
||||
FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($requiredViews);
|
||||
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||
|
||||
if ($missing) {
|
||||
throw new \RuntimeException(
|
||||
'Comptes d’accès indisponibles : vue(s) manquante(s) : '
|
||||
. implode(', ', $missing)
|
||||
. '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Référentiel des lieux d'affectation (`structure`).
|
||||
*
|
||||
* Toutes les lectures filtrent sur `actif = TRUE` : un lieu désactivé disparaît
|
||||
* des listes et ne peut plus être choisi, mais ses plannings passés restent
|
||||
* intacts. Aucune méthode de désactivation n'est exposée dans cette version, la
|
||||
* bascule se fait en SQL.
|
||||
*
|
||||
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) donne le contexte par défaut
|
||||
* du lieu ; il reste croisé avec le calendrier des vacances (VacationModel).
|
||||
*/
|
||||
final class StructureModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Tous les lieux actifs, triés par nom.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -13,6 +29,12 @@ final class StructureModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu actif par identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null null si inexistant OU désactivé — les
|
||||
* appelants traitent les deux cas de la même façon.
|
||||
*/
|
||||
public function findActive(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -22,6 +44,10 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le code est déjà pris, DÉSACTIVÉS COMPRIS : le code reste unique
|
||||
* en base, réutiliser celui d'un lieu fermé violerait la contrainte.
|
||||
*/
|
||||
public function codeExists(string $code): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
|
||||
@@ -29,6 +55,14 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un lieu actif et renvoie sa fiche complète.
|
||||
*
|
||||
* La fiche est reconstituée en PHP plutôt que relue en base : l'appelant en
|
||||
* a besoin pour l'insérer dans la liste affichée sans recharger la page.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function create(string $code, string $name, ?string $address, string $typeAffectation): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -51,6 +85,10 @@ final class StructureModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le type d'un lieu. La valeur est contrôlée par le contrôleur, ce
|
||||
* modèle ne fait qu'écrire.
|
||||
*/
|
||||
public function updateType(int $structureId, string $typeAffectation): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -62,6 +100,20 @@ final class StructureModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents à afficher pour un lieu et une semaine.
|
||||
*
|
||||
* Réunit DEUX populations, d'où les LEFT JOIN et le OR de la clause WHERE :
|
||||
* - les agents RATTACHÉS au lieu (a.id_structure), même sans planning ;
|
||||
* - les agents PLANIFIÉS sur le lieu cette semaine, quel que soit leur
|
||||
* rattachement (sem.id_semaine IS NOT NULL).
|
||||
*
|
||||
* Omettre la seconde donnerait une vue fausse de l'effectif présent, un
|
||||
* agent pouvant travailler sur plusieurs lieux la même semaine.
|
||||
* `id_structure_principale` permet à l'interface de les distinguer.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function agentsForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -88,6 +140,14 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un lieu sur une semaine, tous agents confondus.
|
||||
*
|
||||
* Une seule lecture, regroupée ensuite par agent en PHP par l'appelant,
|
||||
* plutôt qu'une requête par agent. Heures en HH:MM, format d'affichage.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -6,8 +6,38 @@ namespace App\Models;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Équipes des mercredis et des vacances, et leurs contrôles d'encadrement.
|
||||
*
|
||||
* Le NOMBRE D'AGENTS REQUIS est calculé en SQL, au plus contraignant des deux
|
||||
* règles :
|
||||
* GREATEST(minimum_agents,
|
||||
* CEIL(enfants_moins_6 / ratio_moins_6)
|
||||
* + CEIL(enfants_6_plus / ratio_6_plus))
|
||||
* Chaque tranche d'âge est arrondie au SUPÉRIEUR séparément avant d'être
|
||||
* additionnée : un demi-agent n'existe pas, et sommer avant d'arrondir
|
||||
* sous-estimerait l'encadrement.
|
||||
*
|
||||
* Les contrôles suivent la forme commune {niveau, code, message}. Aucun n'est
|
||||
* bloquant : ils signalent, l'arbitrage restant humain.
|
||||
*
|
||||
* Cycle de vie propre aux équipes : BROUILLON → A_CONTROLER → VALIDEE, sans
|
||||
* transition imposée.
|
||||
*/
|
||||
final class TeamModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Toutes les équipes, avec leurs membres et leurs contrôles.
|
||||
*
|
||||
* Le filtre de dates cherche les équipes qui INTERSECTENT l'intervalle
|
||||
* (fin >= début demandé ET début <= fin demandée) et non celles qui y sont
|
||||
* incluses : une équipe à cheval sur les bornes doit remonter.
|
||||
*
|
||||
* ATTENTION AU COÛT : deux requêtes supplémentaires par équipe (membres puis
|
||||
* contrôles). Acceptable sur quelques dizaines d'équipes, à revoir au-delà.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
|
||||
@@ -56,6 +86,15 @@ final class TeamModel extends BaseModel
|
||||
return $teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une équipe par identifiant.
|
||||
*
|
||||
* Passe par all() plutôt que par une requête dédiée, afin de bénéficier des
|
||||
* mêmes agrégats et contrôles. Coûteux, mais cette méthode n'est pas sur un
|
||||
* chemin critique.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(int $teamId): ?array
|
||||
{
|
||||
foreach ($this->all() as $team) {
|
||||
@@ -66,6 +105,17 @@ final class TeamModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une équipe, toujours en BROUILLON.
|
||||
*
|
||||
* $data est passé tel quel à execute() : ses clés doivent correspondre
|
||||
* exactement aux paramètres nommés. La validation est faite par
|
||||
* TeamController::index().
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé.
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -85,6 +135,20 @@ final class TeamModel extends BaseModel
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un agent à une équipe, ou met à jour sa fonction s'il y est déjà.
|
||||
*
|
||||
* Un seul refus est automatique : une INTERDICTION active de l'agent pour ce
|
||||
* lieu (agent_structure_souhait). Tout le reste — contraintes médicales,
|
||||
* temps partiel thérapeutique — est signalé dans les contrôles de l'équipe
|
||||
* et laissé à l'appréciation humaine, ces situations pouvant être compatibles
|
||||
* avec certaines affectations.
|
||||
*
|
||||
* Les RuntimeException levées ici portent un message destiné à l'utilisateur
|
||||
* final, que le contrôleur renvoie tel quel en 422.
|
||||
*
|
||||
* @throws \RuntimeException Équipe introuvable ou interdiction active.
|
||||
*/
|
||||
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
|
||||
{
|
||||
$team = $this->teamDates($teamId);
|
||||
@@ -125,6 +189,11 @@ final class TeamModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire un agent d'une équipe.
|
||||
*
|
||||
* @return bool false s'il n'en faisait pas partie.
|
||||
*/
|
||||
public function removeMember(int $teamId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -134,12 +203,27 @@ final class TeamModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le statut d'une équipe. La valeur est contrôlée par le contrôleur ;
|
||||
* aucune transition n'est imposée, y compris un retour en arrière.
|
||||
*/
|
||||
public function setStatus(int $teamId, string $status): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
|
||||
$stmt->execute(['statut' => $status, 'id' => $teamId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membres d'une équipe, enrichis de tout ce dont les contrôles ont besoin :
|
||||
* diplôme, poste, lieu de rattachement, souhait exprimé pour ce lieu, et
|
||||
* nombre de contraintes actives sur la période de l'équipe (sous-requête
|
||||
* corrélée, calculée pour la seule fenêtre de dates de l'équipe).
|
||||
*
|
||||
* Le FIELD() ordonne par fonction — responsable, animation, restauration —
|
||||
* plutôt qu'alphabétiquement : c'est l'ordre de lecture d'un trombinoscope.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function members(int $teamId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -171,6 +255,23 @@ final class TeamModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôles d'une équipe.
|
||||
*
|
||||
* Deux ERREUR (effectif insuffisant, taux de diplômés sous le seuil), deux
|
||||
* ALERTE (aucun responsable identifié, membre sous contrainte médicale) et
|
||||
* une INFO signalant qu'une préférence de l'agent est satisfaite. Un INFO
|
||||
* « conforme » est ajouté si rien n'a été relevé, pour distinguer l'absence
|
||||
* d'anomalie d'une absence de contrôle.
|
||||
*
|
||||
* Le taux de diplômés est calculé sur les agents AFFECTÉS, pas sur les
|
||||
* agents requis : c'est la composition réelle de l'équipe qui compte.
|
||||
*
|
||||
* @param array<string, mixed> $team
|
||||
* @param array<int, array<string, mixed>> $members
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function controls(array $team, array $members): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -225,6 +326,11 @@ final class TeamModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu et dates d'une équipe — lecture légère, sans les agrégats de all().
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function teamDates(int $teamId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user