Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; 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\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,13 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php'; require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — 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\Controllers\WeekTemplateController;
use App\Core\JsonResponse; use App\Core\JsonResponse;

View File

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

View File

@@ -20,6 +20,18 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController final class AgentController
{ {
private AgentModel $agentModel; private AgentModel $agentModel;
@@ -35,6 +47,13 @@ final class AgentController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void public function listForPlanning(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -45,6 +64,15 @@ final class AgentController
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]); JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
} }
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void public function overview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -73,6 +101,21 @@ final class AgentController
]); ]);
} }
/**
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
*
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
* `in_month` de chaque jour permet à l'interface de griser les débordements.
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
* hebdomadaires affichés seraient faux.
*
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
* compris, pour que les totaux de semaine restent justes.
*
* Paramètres GET : agent_id, month (« 2026-02 »).
*/
public function monthOverview(): void public function monthOverview(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -149,6 +192,16 @@ final class AgentController
]); ]);
} }
/**
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
*
* La période couverte n'est pas choisie par l'appelant : elle est celle
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
* n'est qu'un raccourci vers le 1er janvier correspondant.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function timeSummaryPdf(): void public function timeSummaryPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -194,6 +247,15 @@ final class AgentController
); );
} }
/**
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
*
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
* sur page suivante.
*
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
*/
public function pdf(): void public function pdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -257,6 +319,14 @@ final class AgentController
); );
} }
/**
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
*
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
* détail des créneaux planifiés.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function hoursContractPdf(): void public function hoursContractPdf(): void
{ {
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT); $agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
@@ -296,6 +366,19 @@ final class AgentController
); );
} }
/**
* api/agent_assignments.php — rattachement principal d'un agent.
*
* Endpoint à double verbe, exception au reste de l'API : GET liste les
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
*
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
*
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
*/
public function assignments(): void public function assignments(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -338,6 +421,19 @@ final class AgentController
]); ]);
} }
/**
* POST api/create_agent.php — création d'un agent.
*
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
* poste existant en base, type de contrat dans l'énumération, dates
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
* avec l'existant, pas une saisie malformée).
*
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
* l'insère dans sa liste sans avoir à recharger toute la page.
*/
public function create(): void public function create(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -411,6 +507,10 @@ final class AgentController
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.', 'error' => 'Impossible de créer l’agent.',
'details' => Security::debugEnabled() ? $e->getMessage() : null, 'details' => Security::debugEnabled() ? $e->getMessage() : null,
@@ -444,6 +544,16 @@ 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.
*
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
* rétroactivement tous les suivis.
*/
public function updateProfile(): void public function updateProfile(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -523,6 +633,12 @@ final class AgentController
]); ]);
} }
/**
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
* pas de base commune ; toute correction doit être reportée partout.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,26 @@ use DomainException;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController final class PlanningController
{ {
private PlanningModel $planningModel; private PlanningModel $planningModel;
@@ -30,6 +50,10 @@ final class PlanningController
private VacationModel $vacationModel; private VacationModel $vacationModel;
private Access $access; private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
$this->planningModel = new PlanningModel($pdo); $this->planningModel = new PlanningModel($pdo);
@@ -41,6 +65,16 @@ final class PlanningController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/planning.php — état complet d'une semaine de saisie.
*
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
* lecture seule pour rendre les chevauchements visibles avant l'échec.
*
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
*/
public function show(): void public function show(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -79,6 +113,23 @@ final class PlanningController
]); ]);
} }
/**
* POST save_planning.php — enregistre le brouillon d'une semaine.
*
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
*
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
* explicite au plus générique :
* 1. droits et périmètre ;
* 2. motifs fermés à l'agent (message nommant le motif) ;
* 3. normalisation/validation ligne à ligne ;
* 4. chevauchements internes à la saisie ;
* 5. chevauchements avec les autres lieux.
*
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
*/
public function save(): void public function save(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -130,6 +181,12 @@ final class PlanningController
} catch (DomainException $e) { } catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]); JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) { } catch (Throwable $e) {
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
// référence documentée — vaut « inactif » par défaut. Le détail
// technique de l'exception peut donc remonter au navigateur sur une
// installation qui ne définit pas la variable. Le motif est répété
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Erreur lors de l’enregistrement du planning.', 'error' => 'Erreur lors de l’enregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, 'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
@@ -147,6 +204,17 @@ final class PlanningController
]); ]);
} }
/**
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
*
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
* validation intacte.
*
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
*/
public function updateAgentEntry(): void public function updateAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -183,10 +251,16 @@ final class PlanningController
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
} }
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
// sans lui, l'heure courante serait injectée et la comparaison de
// contrôle ci-dessous laisserait passer des dates approximatives.
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date); $dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date; $validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
// La saisie est contrainte au quart d'heure : c'est la granularité de
// toute l'application (éditeur, cumuls, PDF).
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time); $quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) { if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']); JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
} }
@@ -221,6 +295,16 @@ final class PlanningController
} }
/**
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
*
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
* l'interface de le signaler, mais l'enregistrement reste et conserve son
* historique.
*
* Charge utile : entry_id, csrf_token.
*/
public function deleteAgentEntry(): void public function deleteAgentEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -263,6 +347,16 @@ final class PlanningController
} }
/**
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
*
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
* l'indique sans alarmer.
*
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
*/
public function moveOrDuplicateEntry(): void public function moveOrDuplicateEntry(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -325,6 +419,18 @@ final class PlanningController
]); ]);
} }
/**
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
*
* Réservé au Service Enfance (requireService) et non aux responsables : la
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
* déborde donc par nature du périmètre d'un responsable de structure.
*
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
* `replace` les remplace.
*
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
*/
public function copyAgentWeek(): void public function copyAgentWeek(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -384,6 +490,14 @@ final class PlanningController
]); ]);
} }
/**
* GET api/pending_plannings.php — file d'attente de validation.
*
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
* (`total_minutes`) des minutes réellement décomptées du quota
* (`counted_minutes`) : congés et absences non décomptées figurent au
* planning sans peser sur le quota annuel.
*/
public function pendingValidation(): void public function pendingValidation(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -407,6 +521,31 @@ 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 ;
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
* un brouillon concurrent peut encore être corrigé, le bloquer
* empêcherait toute validation dans un ordre donné.
*
* Un contrôle non bloquant (ALERTE → 409) :
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
* possible, mais exige un rappel explicite avec force_quota: true. Le
* dépassement est une décision de gestion, pas une anomalie technique.
*
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
*
* Charge utile : planning_id, force_quota (bool), csrf_token.
*/
public function validate(): void public function validate(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -513,6 +652,17 @@ final class PlanningController
]); ]);
} }
/**
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
*
* Réouverture volontaire, à distinguer de la réouverture automatique
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
* trompeur.
*
* Charge utile : planning_id, csrf_token.
*/
public function reopen(): void public function reopen(): void
{ {
$this->access->requireDraftEditor(); $this->access->requireDraftEditor();
@@ -545,6 +695,14 @@ final class PlanningController
]); ]);
} }
/**
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
*
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
* comptent bien une semaine 53.
*
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
@@ -602,6 +760,22 @@ final class PlanningController
} }
} }
/**
* Valide et met en forme les créneaux reçus du client.
*
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
* motif appartenant à la liste ouverte à CET agent, horaires au quart
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
*
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
*
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -636,6 +810,15 @@ final class PlanningController
return $normalized; return $normalized;
} }
/**
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
*
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void private function assertNoInternalOverlap(array $entries): void
{ {
for ($i = 0, $count = count($entries); $i < $count; $i++) { for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -659,6 +842,20 @@ final class PlanningController
} }
} }
/**
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
*
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
* un agent ne peut pas être à deux endroits à la fois, même en cours de
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
* puisse nommer le lieu et l'horaire en cause.
*
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
* être comparables aux créneaux normalisés.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
* @param array<int, array<string, mixed>> $existingEntries
*/
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{ {
foreach ($entries as $entry) { foreach ($entries as $entry) {
@@ -689,6 +886,12 @@ 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.
*/
private function addControl(array &$controls, string $level, string $code, string $message): void private function addControl(array &$controls, string $level, string $code, string $message): void
{ {
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];

View File

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

View File

@@ -12,12 +12,41 @@ use App\Models\AgentModel;
use App\Models\StructureModel; use App\Models\StructureModel;
use PDO; use PDO;
/**
* Sélection du rôle et de l'identité de travail.
*
* ATTENTION : ce n'est PAS une authentification. L'utilisateur choisit
* librement qui il est, sans mot de passe ni vérification. C'est la faille
* ouverte la plus importante du projet, assumée pour cette version. Un passage
* en production doit remplacer cet écran par une authentification réelle — le
* reste de la mécanique de droits (Access, vues SQL) n'a pas à bouger, il
* suffit que la session soit renseignée par un compte vérifié.
*
* Contrairement aux autres pages, ce contrôleur n'est pas appelé par runPage() :
* il s'exécute AVANT tout contrôle d'accès, puisqu'il est ce qui permet d'en
* avoir un.
*/
final class RoleController extends Controller final class RoleController extends Controller
{ {
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
} }
/**
* Affiche le sélecteur et traite sa soumission.
*
* Trois chemins dans une seule méthode, faute de routeur :
* - GET ?action=reset : abandon du rôle courant, puis redirection ;
* - POST : tentative de sélection, puis redirection vers
* l'accueil du rôle ou réaffichage avec $error ;
* - GET simple : affichage du formulaire.
*
* Le CSRF est vérifié à la main plutôt que par Csrf::assertPayload() : les
* données arrivent ici en formulaire classique ($_POST / $_GET) et non en
* JSON, et un échec doit produire une page HTML, pas une réponse JSON.
* Un jeton invalide sur le reset est ignoré EN SILENCE : l'utilisateur est
* simplement renvoyé au sélecteur, sans message d'échec inutile.
*/
public function index(): void public function index(): void
{ {
$access = new Access($this->pdo); $access = new Access($this->pdo);

View File

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

View File

@@ -15,6 +15,21 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Équipes des mercredis et des vacances.
*
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
* pouvant évoluer.
*
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
* BROUILLON → A_CONTROLER → VALIDEE.
*
* Réservé au Service Enfance.
*/
final class TeamController final class TeamController
{ {
private TeamModel $teamModel; private TeamModel $teamModel;
@@ -30,6 +45,21 @@ final class TeamController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
*
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
* actives sur une période.
*
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
* ratio à zéro provoquerait une division par zéro au calcul du taux
* d'encadrement.
*
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
* date_fin, effectifs et ratios facultatifs, csrf_token.
*/
public function index(): void public function index(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -90,6 +120,20 @@ final class TeamController
} }
} }
/**
* POST api/team_members.php — ajoute ou retire un agent d'une équipe.
*
* `action` vaut ADD (défaut) ou REMOVE. Trois fonctions possibles :
* RESPONSABLE, ANIMATION, RESTAURATION_ENTRETIEN — elles conditionnent le
* calcul du taux d'encadrement.
*
* L'exception d'ajout ressort en 422 avec SON message : le modèle y place
* les refus métier lisibles (agent déjà membre, par exemple), destinés à
* l'utilisateur.
*
* Charge utile : action, team_id, agent_id, fonction_equipe, commentaire,
* csrf_token.
*/
public function member(): void public function member(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -127,6 +171,15 @@ final class TeamController
} }
} }
/**
* POST api/team_status.php — fait avancer une équipe dans son cycle de vie.
*
* Aucune transition n'est imposée : tout statut valide est acceptable depuis
* n'importe quel autre, y compris un retour en arrière. Le suivi est
* indicatif, pas un verrou.
*
* Charge utile : team_id, statut (BROUILLON|A_CONTROLER|VALIDEE), csrf_token.
*/
public function status(): void public function status(): void
{ {
$this->access->requireService(); $this->access->requireService();

View File

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

View File

@@ -19,6 +19,28 @@ use DomainException;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
*
* Une semaine type n'est PAS un planning : elle décrit une organisation
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
*
* Deux origines pour une semaine type, qui convergent vers
* WeekTemplateModel::persistTemplate() :
* - store() : capture d'une semaine réellement planifiée ;
* - storeEntries() : composition directe dans l'éditeur.
*
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
*
* Convention de codes d'erreur propre à ce contrôleur :
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
* alors de le remplacer en renvoyant replace: true) ;
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
* reformulation de ce message dans WeekTemplateModel casse la distinction.
*/
final class WeekTemplateController final class WeekTemplateController
{ {
private WeekTemplateModel $templateModel; private WeekTemplateModel $templateModel;
@@ -34,6 +56,12 @@ final class WeekTemplateController
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
/**
* GET api/week_templates.php — liste des semaines types d'un agent.
*
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
* show() sert à ouvrir.
*/
public function index(): void public function index(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -59,7 +87,16 @@ final class WeekTemplateController
} }
/** /**
* Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur. * 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 public function show(): void
{ {
@@ -103,7 +140,14 @@ final class WeekTemplateController
} }
/** /**
* Enregistre une semaine type composée dans l'éditeur. * 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 public function storeEntries(): void
{ {
@@ -172,6 +216,14 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/save_week_template.php — capture une semaine planifiée en modèle.
*
* Point de départ historique : l'utilisateur saisit une semaine « normale »,
* puis la fige comme modèle réutilisable.
*
* Charge utile : agent_id, week, name, replace (bool), csrf_token.
*/
public function store(): void public function store(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -214,6 +266,15 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/apply_week_template.php — applique un modèle à UNE semaine.
*
* Deux modes : `merge` complète les créneaux existants, `replace` les
* remplace. Appliquer sur une semaine validée la repasse en brouillon
* (`requires_revalidation`).
*
* Charge utile : template_id, agent_id, week, mode (merge|replace), csrf_token.
*/
public function apply(): void public function apply(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -268,6 +329,21 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/preview_week_template_period.php — simulation SANS écriture.
*
* Étape obligatoire dans l'interface avant applyPeriod() : une application
* sur période touche des dizaines de semaines et peut rouvrir des plannings
* validés. L'utilisateur doit voir le volume avant de confirmer.
*
* `period_filter` restreint le périmètre au calendrier scolaire :
* all → toutes les semaines de l'intervalle ;
* school → semaines scolaires seulement (périscolaire) ;
* extra → vacances scolaires seulement (extrascolaire).
*
* Charge utile : template_id, agent_id, start_date, end_date, mode,
* period_filter, csrf_token.
*/
public function previewPeriod(): void public function previewPeriod(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -323,6 +399,18 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/apply_week_template_period.php — applique un modèle sur une période.
*
* Mêmes paramètres que previewPeriod(), mais avec écriture. Le message de
* retour est volontairement détaillé (créés / ignorés / remplacés / rouverts) :
* l'opération est massive, difficile à annuler à la main, et l'utilisateur
* doit pouvoir vérifier a posteriori qu'elle a fait ce qu'il attendait.
*
* Les quotas sont renvoyés indexés par date de début de période : une
* application à cheval sur deux années civiles touche DEUX quotas annuels
* distincts, et l'interface doit rafraîchir les deux.
*/
public function applyPeriod(): void public function applyPeriod(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -420,6 +508,14 @@ final class WeekTemplateController
]); ]);
} }
/**
* POST api/delete_week_template.php — supprime une semaine type.
*
* La suppression est filtrée par agent_id ; un modèle appartenant à un autre
* agent ressort en 404 plutôt qu'en 403, pour ne pas révéler son existence.
* Les créneaux DÉJÀ appliqués ne sont pas touchés : le modèle n'est qu'un
* patron, pas un lien vivant vers les plannings produits.
*/
public function destroy(): void public function destroy(): void
{ {
$this->access->requireService(); $this->access->requireService();
@@ -440,6 +536,11 @@ final class WeekTemplateController
JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']); JsonResponse::send(200, ['success' => true, 'message' => 'Semaine type supprimée.']);
} }
/**
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array private function parseWeek(string $value): ?array
{ {
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) { if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {

View File

@@ -16,13 +16,22 @@ use PDO;
*/ */
final class Access final class Access
{ {
/** Lecture seule sur son propre planning. */
public const ROLE_AGENT = 'AGENT'; public const ROLE_AGENT = 'AGENT';
/** Édition des brouillons de son seul lieu d'affectation. */
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE'; public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
/** Périmètre complet ; seul rôle habilité à valider un planning. */
public const ROLE_SERVICE = 'SERVICE_ENFANCE'; public const ROLE_SERVICE = 'SERVICE_ENFANCE';
private const SESSION_KEY = 'pta_access'; private const SESSION_KEY = 'pta_access';
/** @var array<string, array<string, bool>> */ /**
* Caches par requête : les vues d'accès sont interrogées une seule fois par
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
* qu'une modification SQL des droits s'applique sans redéploiement.
*
* @var array<string, array<string, bool>>
*/
private array $permissionCache = []; private array $permissionCache = [];
/** @var array<string, array<int, string>> */ /** @var array<string, array<int, string>> */
@@ -33,6 +42,11 @@ final class Access
Session::start(); Session::start();
} }
/**
* Vrai si la sélection en session est complète et pointe encore vers une
* identité active. Un agent ou un lieu désactivé en base fait donc retomber
* l'utilisateur sur role.php à la requête suivante.
*/
public function isConfigured(): bool public function isConfigured(): bool
{ {
$role = $this->role(); $role = $this->role();
@@ -45,6 +59,7 @@ final class Access
return $role === self::ROLE_SERVICE; return $role === self::ROLE_SERVICE;
} }
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
public function role(): ?string public function role(): ?string
{ {
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? ''))); $role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
@@ -53,6 +68,7 @@ final class Access
: null; : null;
} }
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
public function agentId(): ?int public function agentId(): ?int
{ {
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [ $value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
@@ -61,6 +77,13 @@ final class Access
return $value === false ? null : (int) $value; return $value === false ? null : (int) $value;
} }
/**
* Lieu de référence de la session.
*
* Pour un AGENT, il n'est pas stocké en session mais dérivé de son
* rattachement en base : l'agent ne le choisit pas, et le déplacer d'un lieu
* à l'autre doit prendre effet immédiatement.
*/
public function structureId(): ?int public function structureId(): ?int
{ {
if ($this->role() === self::ROLE_AGENT) { if ($this->role() === self::ROLE_AGENT) {
@@ -76,6 +99,16 @@ final class Access
return $value === false ? null : (int) $value; return $value === false ? null : (int) $value;
} }
/**
* Enregistre la sélection de rôle en session.
*
* ATTENTION : ceci est un sélecteur, PAS une authentification — l'utilisateur
* choisit librement son identité. C'est la faille ouverte la plus importante
* du projet ; un passage en production doit remplacer cet appel par une
* authentification réelle, sans toucher au reste de la mécanique de droits.
*
* @throws \DomainException Si le rôle est inconnu ou l'identité inactive.
*/
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
{ {
$role = strtoupper(trim($role)); $role = strtoupper(trim($role));
@@ -97,6 +130,8 @@ final class Access
$selection['structure_id'] = $structureId; $selection['structure_id'] = $structureId;
} }
// Regénération de l'identifiant de session à chaque changement de rôle :
// parade classique à la fixation de session.
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $selection; $_SESSION[self::SESSION_KEY] = $selection;
// Le jeton CSRF suit le cycle de vie de la session : un changement de // Le jeton CSRF suit le cycle de vie de la session : un changement de
@@ -104,12 +139,21 @@ final class Access
unset($_SESSION['csrf_token']); unset($_SESSION['csrf_token']);
} }
/** Abandonne la sélection courante (« changer de rôle »). */
public function clear(): void public function clear(): void
{ {
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']); unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true); session_regenerate_id(true);
} }
/**
* Profil affichable de la session, publié tel quel dans window.PTA_CONFIG
* par layouts/footer.php et réutilisé par les en-têtes de PDF.
*
* @return array<string, mixed> role, role_label, label, agent_id,
* structure_id, permissions, et selon le rôle
* matricule / structure_name / structure_code.
*/
public function profile(): array public function profile(): array
{ {
$role = $this->role(); $role = $this->role();
@@ -172,6 +216,7 @@ final class Access
return $this->permissionCache[$role]; return $this->permissionCache[$role];
} }
/** Raccourci de lecture d'une permission ; absente = refusée. */
public function hasPermission(string $permission): bool public function hasPermission(string $permission): bool
{ {
return $this->permissions()[$permission] ?? false; return $this->permissions()[$permission] ?? false;
@@ -200,6 +245,10 @@ final class Access
return $this->pageCache[$role]; return $this->pageCache[$role];
} }
/**
* $page est la clé logique de la page (« planning », « dashboard »…), telle
* qu'inscrite dans acces_role_page et passée à runPage().
*/
public function canAccessPage(string $page): bool public function canAccessPage(string $page): bool
{ {
return in_array($page, $this->allowedPages(), true); return in_array($page, $this->allowedPages(), true);
@@ -224,6 +273,12 @@ final class Access
}; };
} }
/**
* Garde des pages HTML : redirige au lieu de renvoyer une erreur, puis
* TERMINE le script. Sans rôle configuré on renvoie vers le sélecteur ;
* page interdite, on renvoie vers l'accueil du rôle avec access_denied=1
* pour que l'interface puisse expliquer le refus.
*/
public function requirePage(string $page): void public function requirePage(string $page): void
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
@@ -236,6 +291,12 @@ final class Access
} }
} }
/**
* Garde des endpoints JSON : émet un 401/403 JSON et TERMINE le script.
* Toutes les gardes require*() ci-dessous en dérivent.
*
* @param array<int, string> $roles Rôles acceptés.
*/
public function requireRoles(array $roles): void public function requireRoles(array $roles): void
{ {
if (!$this->isConfigured()) { if (!$this->isConfigured()) {
@@ -246,16 +307,19 @@ final class Access
} }
} }
/** Réservé au Service Enfance : validation, référentiels, administration. */
public function requireService(): void public function requireService(): void
{ {
$this->requireRoles([self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_SERVICE]);
} }
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
public function requireDraftEditor(): void public function requireDraftEditor(): void
{ {
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
} }
/** Consultation d'un lieu : hors Service Enfance, seul le lieu de rattachement. */
public function requireStructureView(int $structureId): void public function requireStructureView(int $structureId): void
{ {
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
@@ -264,6 +328,7 @@ final class Access
} }
} }
/** Édition dans un lieu : un responsable est confiné au sien. */
public function requireStructureEdit(int $structureId): void public function requireStructureEdit(int $structureId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
@@ -272,6 +337,11 @@ final class Access
} }
} }
/**
* Consultation d'un agent. Un responsable y a droit dès que l'agent est
* planifié chez lui, même s'il y est rattaché ailleurs : un agent peut
* travailler sur plusieurs lieux la même semaine (v_acces_agent_par_structure).
*/
public function requireAgentView(int $agentId): void public function requireAgentView(int $agentId): void
{ {
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]); $this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
@@ -287,6 +357,10 @@ final class Access
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']); JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
} }
/**
* Édition d'un créneau, dont le périmètre est déduit du planning porteur.
* Un identifiant inconnu donne un 404 avant tout contrôle de rôle plus fin.
*/
public function requireEntryEdit(int $entryId): void public function requireEntryEdit(int $entryId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
@@ -304,6 +378,7 @@ final class Access
$this->requireStructureEdit((int) $structureId); $this->requireStructureEdit((int) $structureId);
} }
/** Édition d'un planning identifié, périmètre déduit de son lieu. */
public function requirePlanningEdit(int $planningId): void public function requirePlanningEdit(int $planningId): void
{ {
$this->requireDraftEditor(); $this->requireDraftEditor();
@@ -321,11 +396,16 @@ final class Access
$this->requireStructureEdit((int) $structureId); $this->requireStructureEdit((int) $structureId);
} }
/**
* Lieu à pré-sélectionner et à verrouiller dans l'interface, ou null quand
* l'utilisateur peut choisir librement (Service Enfance).
*/
public function editableStructureId(): ?int public function editableStructureId(): ?int
{ {
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null; return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
} }
/** Libellé lisible du rôle, affiché dans l'en-tête et sur les PDF. */
private function roleLabel(): string private function roleLabel(): string
{ {
return match ($this->role()) { return match ($this->role()) {
@@ -336,6 +416,7 @@ final class Access
}; };
} }
/** Les vues v_acces_identite_* ne listent que les identités ACTIVES. */
private function activeAgentExists(int $agentId): bool private function activeAgentExists(int $agentId): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
@@ -350,6 +431,11 @@ final class Access
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
/**
* Fiche de l'agent en session.
*
* @return array<string, mixed>|null
*/
private function agentRecord(): ?array private function agentRecord(): ?array
{ {
$agentId = $this->agentId(); $agentId = $this->agentId();
@@ -365,6 +451,11 @@ final class Access
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Fiche du lieu en session.
*
* @return array<string, mixed>|null
*/
private function structureRecord(): ?array private function structureRecord(): ?array
{ {
$structureId = $this->structureId(); $structureId = $this->structureId();
@@ -380,6 +471,10 @@ final class Access
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Vrai si l'agent apparaît dans le périmètre du responsable : rattachement
* par défaut OU présence d'un planning dans son lieu.
*/
private function responsibleCanViewAgent(int $agentId): bool private function responsibleCanViewAgent(int $agentId): bool
{ {
$structureId = $this->structureId(); $structureId = $this->structureId();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,19 @@ use DateTimeImmutable;
use PDO; use PDO;
use Throwable; use Throwable;
/**
* Référentiel des agents et lecture de leurs créneaux, tous lieux confondus.
*
* Deux notions à ne pas confondre :
* - `agent.id_structure` est le lieu de rattachement PAR DÉFAUT. Il ne limite
* pas où l'agent peut être planifié ;
* - `agent_structure` est l'HISTORIQUE des rattachements, avec date de début et
* de fin. Il est tenu à jour par updateProfile() et updateAssignment().
*
* Créer un agent ne se limite pas à insérer une ligne : createWithDefaults()
* ouvre aussi son quota annuel et son PTA. Un agent sans quota afficherait un
* dépassement dès sa première heure planifiée.
*/
final class AgentModel extends BaseModel final class AgentModel extends BaseModel
{ {
/** Enveloppe annuelle complémentaire de formation : 14 h. */ /** Enveloppe annuelle complémentaire de formation : 14 h. */
@@ -33,6 +46,18 @@ final class AgentModel extends BaseModel
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0; return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
} }
/**
* Projection commune à toutes les lectures d'agent, complétée par la clause
* WHERE de chaque appelant.
*
* Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de
* rattachement, et doit rester listé dans ce cas.
*
* `structure_nom` et `structure_principale_nom` (idem pour le type) sont le
* MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus
* récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et
* les modules JS.
*/
private const AGENT_SELECT = <<<'SQL' private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
@@ -52,6 +77,11 @@ final class AgentModel extends BaseModel
LEFT JOIN structure s ON s.id_structure = a.id_structure LEFT JOIN structure s ON s.id_structure = a.id_structure
SQL; SQL;
/**
* Tous les agents actifs.
*
* @return array<int, array<string, mixed>>
*/
public function allActive(): array public function allActive(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -59,6 +89,15 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
* y ont un planning, quel que soit leur rattachement.
*
* C'est le périmètre d'un responsable de structure, aligné sur la vue
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
*
* @return array<int, array<string, mixed>>
*/
public function allVisibleForStructure(int $structureId): array public function allVisibleForStructure(int $structureId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -82,6 +121,14 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
*
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
* répartition sur l'année civile entière.
*
* @return array<int, array<string, mixed>>
*/
public function allEligibleForPta(): array public function allEligibleForPta(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -93,6 +140,15 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Référentiel des postes.
*
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
* alimentent les calculs de PtaModel.
*
* @return array<int, array<string, mixed>>
*/
public function allPosts(): array public function allPosts(): array
{ {
return $this->pdo->query( return $this->pdo->query(
@@ -105,6 +161,19 @@ final class AgentModel extends BaseModel
)->fetchAll(); )->fetchAll();
} }
/**
* Agents du sélecteur de l'éditeur de planning.
*
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
* planifié ailleurs que sur son rattachement. Le tri fait simplement
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
* visuellement.
*
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
*
* @return array<int, array<string, mixed>>
*/
public function listForLocation(int $selectedLocation = 0): array public function listForLocation(int $selectedLocation = 0): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -123,6 +192,11 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Fiche complète d'un agent actif.
*
* @return array<string, mixed>|null null si inexistant OU désactivé.
*/
public function findActive(int $agentId): ?array public function findActive(int $agentId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -132,6 +206,10 @@ final class AgentModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
* rapprochement avec les outils RH, elle reste unique en base.
*/
public function matriculeExists(string $matricule): bool public function matriculeExists(string $matricule): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
@@ -139,6 +217,25 @@ final class AgentModel extends BaseModel
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
/**
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
* 1. la fiche `agent` ;
* 2. sa première ligne d'historique `agent_structure` ;
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
*
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
* civile, jamais la date anniversaire du contrat.
*
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
* capturé plus haut dans $agentId.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
*/
public function createWithDefaults(array $data): array public function createWithDefaults(array $data): array
{ {
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']); $contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
@@ -260,6 +357,28 @@ 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.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @throws \RuntimeException Si l'agent n'existe pas.
*/
public function updateProfile(int $agentId, array $data): void public function updateProfile(int $agentId, array $data): void
{ {
try { try {
@@ -340,6 +459,15 @@ final class AgentModel extends BaseModel
} }
} }
/**
* Change le seul rattachement principal, sans toucher au reste de la fiche.
*
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
*
* @param int|null $structureId null pour détacher l'agent de tout lieu par
* défaut — il reste planifiable ponctuellement.
*/
public function updateAssignment(int $agentId, ?int $structureId): void public function updateAssignment(int $agentId, ?int $structureId): void
{ {
try { try {
@@ -383,6 +511,14 @@ final class AgentModel extends BaseModel
} }
} }
/**
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
*
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
* telle que l'utilisateur la conçoit.
*
* @return array<int, array<string, mixed>>
*/
public function entriesForWeek(int $agentId, int $year, int $week): array public function entriesForWeek(int $agentId, int $year, int $week): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -407,6 +543,15 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
*
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
* bornes de semaine.
*
* @return array<int, array<string, mixed>>
*/
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -433,6 +578,18 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
*
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
* un motif en deux lignes.
*
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
* est ce que le lecteur cherche en premier.
*
* @return array<int, array<string, mixed>>
*/
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -458,6 +615,11 @@ final class AgentModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Cumul par motif sur une année civile complète.
*
* @return array<int, array<string, mixed>>
*/
public function timeSummaryForYear(int $agentId, int $year): array public function timeSummaryForYear(int $agentId, int $year): array
{ {
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)); return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,36 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
/**
* Contrôle de conformité du schéma, joué à CHAQUE requête par app/bootstrap.php.
*
* Ce garde-fou existe parce que les migrations sont des fichiers SQL appliqués
* par un service séparé : rien ne garantit que la base en face du code soit à
* jour. Plutôt que de laisser l'application échouer plus tard sur une erreur SQL
* cryptique au milieu d'un écran, on refuse de démarrer avec un message qui
* nomme précisément ce qui manque et quel fichier jouer.
*
* CONSÉQUENCE POUR LE DÉVELOPPEUR : ajouter une table ou une colonne obligatoire
* impose de compléter les listes ci-dessous EN MÊME TEMPS que la migration.
* Oublier de le faire ne casse rien tout de suite — c'est justement le piège :
* l'application démarrera sur une base incomplète.
*
* Le coût est de trois requêtes sur information_schema par requête HTTP,
* accepté au regard du volume d'utilisateurs de l'application.
*/
final class SchemaModel extends BaseModel final class SchemaModel extends BaseModel
{ {
/**
* Vérifie tables, colonnes puis vues d'accès.
*
* Seules les colonnes AJOUTÉES PAR MIGRATION sont listées, pas l'intégralité
* du schéma : ce sont les seules susceptibles de manquer sur une base créée
* à partir de database/pta_prod_schema_mariadb.sql, qui ne contient que le
* socle métier.
*
* @throws \RuntimeException Au premier manque constaté, avec le détail et
* le fichier SQL à exécuter.
*/
public function assertCurrent(): void public function assertCurrent(): void
{ {
$requiredTables = [ $requiredTables = [

View File

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

View File

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

View File

@@ -7,8 +7,26 @@ namespace App\Models;
use DateTimeImmutable; use DateTimeImmutable;
use PDO; use PDO;
/**
* Périodes de vacances scolaires (`periode_vacance`).
*
* Ces périodes déterminent le contexte PÉRISCOLAIRE / EXTRASCOLAIRE de chaque
* journée : une date incluse dans une période active est extrascolaire, sinon
* elle est périscolaire. C'est cette qualification qui pilote le filtrage des
* semaines types (WeekTemplateModel) et le contexte affiché dans l'éditeur.
*
* Les périodes peuvent être saisies à la main ou importées depuis
* data.education.gouv.fr, la colonne `source` conservant l'origine.
*/
final class VacationModel extends BaseModel final class VacationModel extends BaseModel
{ {
/**
* Périodes actives, éventuellement restreintes à une année scolaire.
*
* @param string|null $schoolYear Ex. « 2026-2027 », ou null pour tout.
*
* @return array<int, array<string, mixed>>
*/
public function all(?string $schoolYear = null): array public function all(?string $schoolYear = null): array
{ {
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire, $sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
@@ -28,6 +46,22 @@ final class VacationModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Enregistre un lot de périodes, en INSERT ou mise à jour.
*
* Le ON DUPLICATE KEY UPDATE s'appuie sur la clé d'unicité du schéma : un
* réimport de la même période met à jour son libellé et sa zone au lieu de
* la dupliquer, et la RÉACTIVE (`actif = TRUE`) si elle avait été retirée.
* Les DATES ne sont volontairement pas mises à jour : ce sont elles qui
* identifient la période, les modifier reviendrait à en créer une autre.
*
* Le lot est transactionnel : soit toutes les périodes sélectionnées entrent,
* soit aucune.
*
* @param array<int, array<string, mixed>> $periods Déjà validées par le contrôleur.
*
* @return int Nombre de périodes traitées.
*/
public function saveMany(array $periods): int public function saveMany(array $periods): int
{ {
if ($periods === []) { if ($periods === []) {
@@ -69,6 +103,11 @@ final class VacationModel extends BaseModel
return count($periods); return count($periods);
} }
/**
* Supprime définitivement une période (et non une désactivation).
*
* @return bool false si la période n'existait pas.
*/
public function delete(int $id): bool public function delete(int $id): bool
{ {
$stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id'); $stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id');
@@ -76,6 +115,16 @@ final class VacationModel extends BaseModel
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Périodes actives qui INTERSECTENT l'intervalle demandé.
*
* Le test `debut <= fin_demandee AND fin >= debut_demande` est un
* recouvrement, pas une inclusion : une période à cheval sur les bornes doit
* remonter, sans quoi les jours de vacances en début ou fin d'intervalle
* seraient qualifiés à tort de périscolaires.
*
* @return array<int, array<string, mixed>>
*/
public function periodsBetween(string $startDate, string $endDate): array public function periodsBetween(string $startDate, string $endDate): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -97,6 +146,13 @@ final class VacationModel extends BaseModel
* Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO. * Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO.
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE, * Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
* sinon elle est PERISCOLAIRE. * sinon elle est PERISCOLAIRE.
*
* Toujours cinq entrées, lundi à vendredi : le modèle métier s'arrête au
* vendredi. Une semaine peut être MIXTE (vacances commençant en cours de
* semaine), c'est à l'appelant d'en tirer les conséquences —
* DashboardController::weekPayload() par exemple.
*
* @return array<int, array{date: string, type_affectation: string, vacances: array<string, mixed>|null}>
*/ */
public function typesForIsoWeek(int $year, int $week): array public function typesForIsoWeek(int $year, int $week): array
{ {

View File

@@ -8,8 +8,42 @@ use DateTimeImmutable;
use DomainException; use DomainException;
use Throwable; use Throwable;
/**
* Semaines types : modèles lundi→vendredi rattachés à un agent.
*
* Deux tables : `modele_semaine_agent` (l'en-tête, nom + agent) et
* `modele_semaine_creneau` (les plages, repérées par un JOUR DE LA SEMAINE de 1
* à 5 et non par une date). Un modèle ne produit aucun créneau tant qu'il n'est
* pas appliqué.
*
* Deux voies d'entrée qui convergent sur persistTemplate() :
* saveFromWeek() ← capture d'une semaine réellement planifiée ;
* saveFromEntries() ← composition directe dans l'éditeur (V7.10).
*
* Deux voies de sortie :
* applyToWeek() ← une semaine précise ;
* applyToPeriod() ← toutes les semaines d'un intervalle, avec filtrage sur le
* calendrier scolaire ; previewPeriod() en est la simulation
* sans écriture, les deux partageant buildPeriodPlan().
*
* Conventions communes à tout le fichier :
* - les heures sont manipulées en HH:MM:SS, format des colonnes TIME (l'éditeur
* les tronque à HH:MM pour l'affichage seulement) ;
* - les erreurs métier sont des DomainException dont le MESSAGE est destiné à
* l'utilisateur final et affiché tel quel ;
* - toute écriture est transactionnelle, et un planning validé touché repasse
* en BROUILLON.
*/
final class WeekTemplateModel extends BaseModel final class WeekTemplateModel extends BaseModel
{ {
/**
* Modèles d'un agent, avec leurs compteurs de plages et de lieux.
*
* LEFT JOIN volontaire : un modèle vidé de ses plages reste listé, avec un
* compteur à zéro, plutôt que de disparaître silencieusement.
*
* @return array<int, array<string, mixed>>
*/
public function listForAgent(int $agentId): array public function listForAgent(int $agentId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -26,6 +60,15 @@ final class WeekTemplateModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Retrouve un modèle par (identifiant, agent).
*
* Le double critère est le contrôle de périmètre : il rend impossible la
* lecture du modèle d'un autre agent en changeant l'identifiant dans l'URL.
* Toutes les méthodes publiques passent par ici avant d'agir.
*
* @return array<string, mixed>|null
*/
public function findForAgent(int $templateId, int $agentId): ?array public function findForAgent(int $templateId, int $agentId): ?array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -38,6 +81,15 @@ final class WeekTemplateModel extends BaseModel
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
} }
/**
* Plages d'un modèle, enrichies du lieu et du motif.
*
* `structure_actif` et `motif_actif` sont remontés parce qu'un lieu ou un
* motif peut être désactivé APRÈS l'enregistrement du modèle : l'application
* refuse alors le modèle, avec un message qui nomme l'élément fautif.
*
* @return array<int, array<string, mixed>>
*/
public function entries(int $templateId): array public function entries(int $templateId): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -60,6 +112,20 @@ final class WeekTemplateModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Enregistre une semaine réellement planifiée comme modèle réutilisable.
*
* Les dates de la semaine source sont converties en jours de la semaine :
* c'est ce qui rend le modèle rejouable n'importe quand.
*
* @param bool $replaceExisting true pour écraser un modèle de même nom
* (confirmation donnée par l'utilisateur après
* un premier refus en 409).
*
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
*
* @throws DomainException Nom invalide, semaine source vide, ou nom déjà pris.
*/
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
{ {
// La colonne `nom` fait 120 caractères : la borne annoncée à // La colonne `nom` fait 120 caractères : la borne annoncée à
@@ -85,6 +151,11 @@ final class WeekTemplateModel extends BaseModel
* @param array<int, mixed> $rawEntries * @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds motifs affectables à cet agent * @param int[] $allowedMotifIds motifs affectables à cet agent
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur * @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
*
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
*
* @throws DomainException Nom invalide, modèle vide, plage refusée,
* chevauchement, ou nom déjà pris.
*/ */
public function saveFromEntries( public function saveFromEntries(
int $agentId, int $agentId,
@@ -104,6 +175,18 @@ final class WeekTemplateModel extends BaseModel
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting); return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
} }
/**
* Assainit et contrôle le nom d'un modèle.
*
* Les suites d'espaces sont réduites à un seul (motif /u, donc sûr en
* UTF-8), afin que « Semaine type » et « Semaine type » ne coexistent pas
* comme deux modèles distincts. La borne de 120 caractères est celle de la
* colonne : contrôlée ici, elle produit un message clair au lieu d'une
* troncature silencieuse de MariaDB. mb_strlen() compte des CARACTÈRES, pas
* des octets.
*
* @throws DomainException Si le nom est vide ou trop long.
*/
private function normalizeName(string $name): string private function normalizeName(string $name): string
{ {
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? ''); $name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
@@ -114,10 +197,24 @@ final class WeekTemplateModel extends BaseModel
} }
/** /**
* Valide les plages saisies dans l'éditeur et les met en forme.
*
* Chaque champ est revalidé côté serveur, y compris l'appartenance du motif
* et du lieu aux listes autorisées calculées par le contrôleur. Les messages
* d'erreur nomment le JOUR concerné : c'est le seul repère dont dispose
* l'utilisateur pour retrouver la plage fautive dans la grille.
*
* Le tri final (jour, puis heure de début) rend l'ordre d'affichage stable
* quel que soit l'ordre d'envoi du navigateur.
*
* @param array<int, mixed> $rawEntries * @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds * @param int[] $allowedMotifIds
* @param int[] $allowedStructureIds * @param int[] $allowedStructureIds
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*
* @throws DomainException Plage illisible, jour hors lundi-vendredi,
* horaires invalides, motif ou lieu non autorisé,
* ou chevauchement.
*/ */
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
{ {
@@ -182,6 +279,16 @@ final class WeekTemplateModel extends BaseModel
return $entries; return $entries;
} }
/**
* Contrôle un horaire au quart d'heure et le ramène en HH:MM:SS.
*
* Accepte HH:MM comme HH:MM:SS en entrée (le navigateur envoie l'un ou
* l'autre selon le champ) et normalise toujours vers HH:MM:SS.
*
* @param string $dayLabel Jour en toutes lettres, pour le message d'erreur.
*
* @throws DomainException Si l'horaire n'est pas un quart d'heure valide.
*/
private function normalizeQuarterHour(mixed $value, string $dayLabel): string private function normalizeQuarterHour(mixed $value, string $dayLabel): string
{ {
$time = trim((string) $value); $time = trim((string) $value);
@@ -198,8 +305,13 @@ final class WeekTemplateModel extends BaseModel
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux * Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
* lieux différents : un agent n'est jamais à deux endroits à la fois. * lieux différents : un agent n'est jamais à deux endroits à la fois.
* *
* Comparaison en O(n²), assumée : un modèle plafonne à quelques dizaines de
* plages.
*
* @param array<int, array<string, mixed>> $entries * @param array<int, array<string, mixed>> $entries
* @param array<int, string> $jours * @param array<int, string> $jours Libellés indexés par numéro de jour.
*
* @throws DomainException Au premier chevauchement rencontré.
*/ */
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
{ {
@@ -227,7 +339,23 @@ final class WeekTemplateModel extends BaseModel
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine : * Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
* une semaine réellement planifiée ou l'éditeur de semaine type. * une semaine réellement planifiée ou l'éditeur de semaine type.
* *
* L'unicité porte sur le couple (agent, nom). Le SELECT … FOR UPDATE ferme
* la fenêtre entre le contrôle et l'insertion, où deux enregistrements
* simultanés créeraient deux modèles homonymes.
*
* En remplacement, les plages sont SUPPRIMÉES puis réinsérées et l'en-tête
* conserve son identifiant : les écrans qui référencent le modèle continuent
* de le retrouver.
*
* Le message « Une semaine type porte déjà ce nom » est reconnu par
* WeekTemplateController pour distinguer un 409 d'un 422 — le reformuler
* casse cette distinction.
*
* @param array<int, array<string, mixed>> $rows * @param array<int, array<string, mixed>> $rows
*
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
*
* @throws DomainException Si le nom est déjà pris et $replaceExisting est faux.
*/ */
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
{ {
@@ -290,6 +418,32 @@ final class WeekTemplateModel extends BaseModel
} }
} }
/**
* Applique un modèle à UNE semaine précise.
*
* Les jours de la semaine du modèle sont projetés sur les dates réelles de
* la semaine cible (jour 1 = lundi de cette semaine).
*
* Deux modes :
* - `replace` : tous les créneaux de l'agent sur cette semaine sont
* supprimés, TOUS LIEUX CONFONDUS — un modèle décrit une semaine
* complète, pas le complément d'un lieu ;
* - `merge` : les créneaux existants sont conservés et le moindre
* chevauchement fait échouer l'opération entière.
*
* Le cache $planningByStructure évite de rechercher le planning à chaque
* plage. Il est manipulé par référence (&$planningInfo) pour que la remise
* en brouillon d'un planning soit visible aux itérations suivantes et ne
* déclenche pas plusieurs UPDATE ; le `unset` en fin de boucle est
* indispensable, une référence PHP survivant à l'itération.
*
* @param string $mode « merge » ou « replace ».
*
* @return array{template: array<string, mixed>, inserted: int, mode: string, reopened_planning_ids: int[]}
*
* @throws DomainException Mode invalide, modèle introuvable ou vide, lieu ou
* motif désactivé depuis, ou conflit en mode merge.
*/
public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array
{ {
if (!in_array($mode, ['merge', 'replace'], true)) { if (!in_array($mode, ['merge', 'replace'], true)) {
@@ -450,6 +604,16 @@ final class WeekTemplateModel extends BaseModel
} }
} }
/**
* Simule l'application sur une période, SANS RIEN ÉCRIRE.
*
* Étape imposée par l'interface avant applyToPeriod() : l'opération touche
* des dizaines de semaines et peut rouvrir des plannings validés.
*
* @param string $periodFilter all | school | extra.
*
* @return array<string, mixed> Voir buildPeriodPlan() pour le détail.
*/
public function previewPeriod( public function previewPeriod(
int $templateId, int $templateId,
int $agentId, int $agentId,
@@ -461,6 +625,26 @@ final class WeekTemplateModel extends BaseModel
return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true); return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true);
} }
/**
* Applique un modèle à toutes les semaines d'une période.
*
* Reprend le plan calculé par buildPeriodPlan(), puis écrit. Deux modes :
* - `merge` : échec au moindre conflit, avec un message qui décrit le
* PREMIER conflit rencontré et oriente vers l'aperçu ou le mode remplacement ;
* - `replace` : les créneaux existants sont supprimés sur les SEULES DATES
* RÉELLEMENT VISÉES ($targetDateLookup). Une date ignorée par le filtre
* de calendrier n'est donc pas vidée — sans quoi appliquer un modèle
* « périodes scolaires » effacerait aussi les vacances.
*
* Le tout dans UNE transaction : sur des centaines de créneaux, un échec à
* mi-parcours laisserait un état ininterprétable.
*
* @return array<string, mixed> Compteurs détaillés (inserted, deleted,
* weeks_touched, days_touched, skipped_*, reopened_planning_ids…),
* repris tels quels dans le message rendu à l'utilisateur.
*
* @throws DomainException Aucune date éligible, ou conflit en mode merge.
*/
public function applyToPeriod( public function applyToPeriod(
int $templateId, int $templateId,
int $agentId, int $agentId,
@@ -646,6 +830,14 @@ final class WeekTemplateModel extends BaseModel
} }
} }
/**
* Supprime un modèle et, par cascade SQL, ses plages.
*
* Les créneaux DÉJÀ APPLIQUÉS ne sont pas touchés : le modèle est un patron,
* pas un lien vivant vers les plannings qu'il a produits.
*
* @return bool false si le modèle n'existe pas ou appartient à un autre agent.
*/
public function delete(int $templateId, int $agentId): bool public function delete(int $templateId, int $agentId): bool
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -655,6 +847,16 @@ final class WeekTemplateModel extends BaseModel
return $stmt->rowCount() === 1; return $stmt->rowCount() === 1;
} }
/**
* Créneaux d'une semaine réelle, convertis en jours de la semaine.
*
* `WEEKDAY() + 1` donne 1 (lundi) à 5 (vendredi) — à ne pas confondre avec
* DAYOFWEEK(), qui commence au dimanche. Le filtre `BETWEEN 0 AND 4` écarte
* le week-end, et la contrainte sur les bornes de la semaine écarte un
* créneau égaré hors de sa semaine.
*
* @return array<int, array<string, mixed>>
*/
private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -680,6 +882,39 @@ final class WeekTemplateModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Calcule le plan d'application sur une période — cœur de previewPeriod() et
* d'applyToPeriod(), qui partagent ainsi exactement le même calcul.
*
* Déroulé :
* 1. contrôles d'entrée (mode, filtre, dates, plafond de deux ans — borne
* de sécurité contre une saisie erronée qui produirait des dizaines de
* milliers de créneaux) ;
* 2. lecture du modèle, avec refus si un lieu ou un motif a été désactivé
* depuis son enregistrement ;
* 3. parcours JOUR PAR JOUR de la période, week-ends exclus. Chaque jour
* est qualifié de PERISCOLAIRE ou EXTRASCOLAIRE selon qu'il tombe ou non
* dans une période de vacances, puis retenu ou écarté selon
* $periodFilter ;
* 4. contrôle de cohérence interne du plan produit ;
* 5. si $includeExistingAnalysis, confrontation aux créneaux déjà en base
* pour dénombrer les conflits.
*
* Les plages écartées sont CONSERVÉES dans `skipped_entries` avec leur motif
* d'exclusion : l'utilisateur doit pouvoir vérifier que le filtre a fait ce
* qu'il attendait, un simple compteur ne le permettrait pas.
*
* @param bool $includeExistingAnalysis true en aperçu (on veut voir les
* conflits sans verrouiller) ; false en application, où
* applyToPeriod() refait l'analyse dans sa transaction, sous verrou.
*
* @return array<string, mixed> template, target_entries, skipped_entries,
* conflicts, days, vacation_periods et summary.
*
* @throws DomainException Mode ou filtre invalide, dates incohérentes,
* période trop longue, modèle introuvable ou vide,
* lieu ou motif désactivé, chevauchement interne.
*/
private function buildPeriodPlan( private function buildPeriodPlan(
int $templateId, int $templateId,
int $agentId, int $agentId,
@@ -861,6 +1096,17 @@ final class WeekTemplateModel extends BaseModel
]; ];
} }
/**
* Décode une date « AAAA-MM-JJ » en refusant tout report implicite.
*
* getLastErrors() est indispensable : sans ce contrôle, PHP accepterait
* « 2026-02-31 » en le reportant au 3 mars. Le « ! » remet à zéro les champs
* non fournis, sinon l'heure courante serait injectée.
*
* @param string $label Libellé du champ, repris dans le message d'erreur.
*
* @throws DomainException Si la date est invalide.
*/
private function parseStrictDate(string $value, string $label): DateTimeImmutable private function parseStrictDate(string $value, string $label): DateTimeImmutable
{ {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
@@ -872,6 +1118,16 @@ final class WeekTemplateModel extends BaseModel
return $date; return $date;
} }
/**
* Période de vacances contenant cette date, ou null (jour scolaire).
*
* Comparaison de chaînes : le format ISO AAAA-MM-JJ se trie
* chronologiquement, aucune conversion en objet n'est nécessaire.
*
* @param array<int, array<string, mixed>> $periods
*
* @return array<string, mixed>|null
*/
private function vacationForDate(string $date, array $periods): ?array private function vacationForDate(string $date, array $periods): ?array
{ {
foreach ($periods as $period) { foreach ($periods as $period) {
@@ -882,6 +1138,16 @@ final class WeekTemplateModel extends BaseModel
return null; return null;
} }
/**
* Créneaux existants de l'agent sur une période, TOUS LIEUX confondus.
*
* @param bool $forUpdate true pour verrouiller les lignes ; à n'utiliser
* que dans une transaction, et donc uniquement à l'application.
* L'aperçu lit sans verrou, pour ne pas bloquer les autres
* utilisateurs pendant une simple simulation.
*
* @return array<int, array<string, mixed>>
*/
private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array
{ {
$sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour, $sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour,
@@ -907,6 +1173,17 @@ final class WeekTemplateModel extends BaseModel
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/**
* Confronte le plan aux créneaux existants et liste TOUS les conflits.
*
* Contrairement aux assert*(), ne lève pas : l'aperçu doit présenter
* l'ensemble des conflits, à charge de l'appelant de décider quoi en faire.
*
* @param array<int, array<string, mixed>> $targetEntries
* @param array<int, array<string, mixed>> $existingEntries
*
* @return array<int, array<string, mixed>> Un élément par paire en conflit.
*/
private function findConflicts(array $targetEntries, array $existingEntries): array private function findConflicts(array $targetEntries, array $existingEntries): array
{ {
$conflicts = []; $conflicts = [];
@@ -932,6 +1209,15 @@ final class WeekTemplateModel extends BaseModel
return $conflicts; return $conflicts;
} }
/**
* Retourne l'identifiant de la semaine, en la créant si besoin.
*
* COPIE de PlanningModel::ensureWeek(), les deux modèles n'ayant pas de base
* commune. Toute correction ici doit être reportée là-bas, et
* réciproquement.
*
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
*/
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
@@ -959,6 +1245,18 @@ final class WeekTemplateModel extends BaseModel
return $weekId; return $weekId;
} }
/**
* Refuse un plan dont deux créneaux se chevauchent le même jour.
*
* Contrôle du plan PROJETÉ (dates réelles), là où
* assertEditorEntriesNoOverlap() porte sur le modèle abstrait (jours de la
* semaine). Les deux sont nécessaires : un modèle sain peut produire un plan
* fautif si deux modèles sont appliqués sur la même période.
*
* @param array<int, array<string, mixed>> $entries
*
* @throws DomainException Au premier chevauchement rencontré.
*/
private function assertTemplateNoOverlap(array $entries): void private function assertTemplateNoOverlap(array $entries): void
{ {
for ($i = 0, $count = count($entries); $i < $count; $i++) { for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -983,6 +1281,16 @@ final class WeekTemplateModel extends BaseModel
} }
} }
/**
* Refuse un chevauchement avec les créneaux DÉJÀ en base sur la semaine.
*
* Utilisé par applyToWeek() en mode « merge ». La lecture couvre tous les
* lieux de l'agent sur la semaine, et non le seul lieu visé.
*
* @param array<int, array<string, mixed>> $entries
*
* @throws DomainException Au premier chevauchement rencontré.
*/
private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void
{ {
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(

View File

@@ -10,13 +10,36 @@ use DateTimeZone;
/** /**
* Génère le document annuel de validation du quota horaire d'un agent. * Génère le document annuel de validation du quota horaire d'un agent.
* Le PDF est autonome et ne nécessite aucune dépendance Composer. * Le PDF est autonome et ne nécessite aucune dépendance Composer.
*
* Document CONTRACTUEL, destiné à être signé : il n'affiche que le volume
* annualisé dû sur la période et la clause de validation, jamais le détail des
* créneaux planifiés.
*
* Seul PDF de l'application en A4 PORTRAIT (les plannings sont en paysage) :
* c'est le format attendu d'une pièce à signer et à classer.
*
* Contrairement aux PDF de planning, les libellés portent ici leurs ACCENTS :
* la valeur juridique du document prime, et iconv est présent dans le conteneur
* fourni. À vérifier si l'application est déployée sur un PHP sans iconv.
*/ */
final class AgentHoursContractPdfService final class AgentHoursContractPdfService
{ {
/** A4 PORTRAIT en points PDF, contrairement aux plannings en paysage. */
private const PAGE_WIDTH = 595.28; private const PAGE_WIDTH = 595.28;
private const PAGE_HEIGHT = 841.89; private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0; private const MARGIN = 42.0;
/**
* Assemble le contrat horaire annuel.
*
* La période affichée est celle du quota (année civile) et non l'année de
* contrat ; le libellé est repris tel quel de QuotaModel.
*
* @param array<string, mixed> $agent
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
*
* @return string Octets du PDF.
*/
public function annualContract(array $agent, array $quota): string public function annualContract(array $agent, array $quota): string
{ {
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT); $pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
@@ -39,6 +62,7 @@ final class AgentHoursContractPdfService
return $pdf->output(); return $pdf->output();
} }
/** Titre, période de référence, date d'édition et identité de l'agent. */
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{ {
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true); $pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
@@ -56,6 +80,7 @@ final class AgentHoursContractPdfService
); );
} }
/** Bloc d'identité : matricule, poste, contrat, lieu, quotité. */
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -90,6 +115,7 @@ final class AgentHoursContractPdfService
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5); $pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
} }
/** Volume annualisé dû : référence, quotité appliquée, quota cible. */
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -127,6 +153,11 @@ final class AgentHoursContractPdfService
} }
} }
/**
* Clause de validation : le texte que les parties reconnaissent en signant.
* Toute modification a une portée juridique — à ne pas retoucher pour des
* raisons de mise en page.
*/
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -157,6 +188,7 @@ final class AgentHoursContractPdfService
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8); $pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
} }
/** Cartouches de signature, laissés vides pour un remplissage manuscrit. */
private function drawSignatures(SimplePdf $pdf): void private function drawSignatures(SimplePdf $pdf): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -184,6 +216,7 @@ final class AgentHoursContractPdfService
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5); $pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
} }
/** Libellé du type de lieu d'affectation. */
private function typeLabel(string $type): string private function typeLabel(string $type): string
{ {
return match (strtoupper($type)) { return match (strtoupper($type)) {
@@ -193,12 +226,14 @@ final class AgentHoursContractPdfService
}; };
} }
/** Quotité en pourcentage, telle qu'elle figure au contrat. */
private function percentLabel(float $rate): string private function percentLabel(float $rate): string
{ {
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ','); $formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
return $formatted . ' %'; return $formatted . ' %';
} }
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
private function minutesLabel(int $minutes): string private function minutesLabel(int $minutes): string
{ {
$sign = $minutes < 0 ? '-' : ''; $sign = $minutes < 0 ? '-' : '';

View File

@@ -10,6 +10,13 @@ use DateTimeZone;
/** /**
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif. * Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
*
* Complément du contrat horaire : là où celui-ci fixe le volume DÛ, ce bilan
* montre le volume RÉELLEMENT planifié et sa ventilation. A4 portrait également.
*
* La ventilation s'appuie sur App\Core\MotifCategories, partagé avec l'écran et
* les autres PDF : tous les motifs de travail effectif alimentent une seule
* ligne « Heures travaillées », le détail par motif figurant en dessous.
*/ */
final class AgentTimeSummaryPdfService final class AgentTimeSummaryPdfService
{ {
@@ -17,6 +24,16 @@ final class AgentTimeSummaryPdfService
private const PAGE_HEIGHT = 841.89; private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0; private const MARGIN = 42.0;
/**
* Assemble le bilan d'heures annuel.
*
* @param array<string, mixed> $agent
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
* @param array<int, array<string, mixed>> $rows Cumuls par motif issus de
* AgentModel::timeSummaryForRange().
*
* @return string Octets du PDF.
*/
public function annualSummary(array $agent, array $quota, array $rows): string public function annualSummary(array $agent, array $quota, array $rows): string
{ {
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT); $pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
@@ -33,6 +50,7 @@ final class AgentTimeSummaryPdfService
return $pdf->output(); return $pdf->output();
} }
/** Titre, période couverte et date d'édition. */
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{ {
$pdf->text(self::MARGIN, 34, 'PTA', 10, true); $pdf->text(self::MARGIN, 34, 'PTA', 10, true);
@@ -42,6 +60,7 @@ final class AgentTimeSummaryPdfService
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72); $pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
} }
/** Bloc d'identité de l'agent. */
private function drawIdentity(SimplePdf $pdf, array $agent): void private function drawIdentity(SimplePdf $pdf, array $agent): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -58,6 +77,10 @@ final class AgentTimeSummaryPdfService
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2); $pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
} }
/**
* Tableau de ventilation : une ligne par catégorie, chacune avec sa couleur,
* puis le détail des motifs de travail sous la ligne « Heures travaillées ».
*/
private function drawBreakdown(SimplePdf $pdf, array $totals): void private function drawBreakdown(SimplePdf $pdf, array $totals): void
{ {
$x = self::MARGIN; $x = self::MARGIN;
@@ -109,6 +132,14 @@ final class AgentTimeSummaryPdfService
* @param array<string, int> $detail * @param array<string, int> $detail
* @return string[] * @return string[]
*/ */
/**
* Met en forme le détail des motifs de travail pour tenir dans la largeur
* disponible.
*
* @param array<string, int> $detail Minutes indexées par libellé de motif.
*
* @return string[]
*/
private function detailLines(SimplePdf $pdf, array $detail, float $width): array private function detailLines(SimplePdf $pdf, array $detail, float $width): array
{ {
arsort($detail); arsort($detail);
@@ -143,6 +174,10 @@ final class AgentTimeSummaryPdfService
return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7); return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7);
} }
/**
* Bloc de conclusion : quota cible, temps décompté, et écart.
* C'est la seule partie du document qui confronte le réalisé à l'attendu.
*/
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
{ {
// Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel : // Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel :
@@ -182,6 +217,7 @@ final class AgentTimeSummaryPdfService
} }
} }
/** Pied de page : origine du document, matricule, horodatage. */
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
{ {
$pdf->text( $pdf->text(
@@ -197,6 +233,15 @@ final class AgentTimeSummaryPdfService
* (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire, * (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire,
* préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas * préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas
* seulement le motif générique TRAVAIL. * seulement le motif générique TRAVAIL.
*
* Le total « DECOMPTE » est calculé indépendamment des catégories, sur le
* seul drapeau `compte_dans_quota` : c'est lui qui se compare au quota
* cible, et il ne se déduit pas de la somme des catégories.
*
* @param array<int, array<string, mixed>> $rows
*
* @return array<string, mixed> Une clé par catégorie, plus « DECOMPTE » et
* « DETAIL_TRAVAIL » (minutes par libellé de motif).
*/ */
private function normalizeTotals(array $rows): array private function normalizeTotals(array $rows): array
{ {
@@ -230,7 +275,11 @@ final class AgentTimeSummaryPdfService
return $totals; return $totals;
} }
/** @return array{accent:string} */ /**
* Couleur d'accent d'une catégorie, alignée sur celle des PDF de planning.
*
* @return array{accent:string}
*/
private function palette(string $code): array private function palette(string $code): array
{ {
return match (strtoupper($code)) { return match (strtoupper($code)) {
@@ -242,6 +291,7 @@ final class AgentTimeSummaryPdfService
}; };
} }
/** 450 → « 7 h 30 ». Gère les valeurs négatives (écart de quota). */
private function minutesLabel(int $minutes): string private function minutesLabel(int $minutes): string
{ {
$sign = $minutes < 0 ? '-' : ''; $sign = $minutes < 0 ? '-' : '';

View File

@@ -7,13 +7,35 @@ namespace App\Services;
use App\Core\MotifCategories; use App\Core\MotifCategories;
use DateTimeImmutable; use DateTimeImmutable;
/**
* Mise en page des plannings au format PDF, par agent ou par lieu.
*
* Deux documents, tous deux en A4 PAYSAGE (SimplePdf par défaut) :
* - agentWeeks() : un agent sur 1 à 7 semaines, en blocs compacts ;
* - structureWeek() : un lieu sur une semaine, en tableau agents × jours, avec
* saut de page géré à la main.
*
* CONTRAINTE TRANSVERSE — les libellés sont écrits SANS ACCENT dans tout ce
* fichier (« Document genere », « Periscolaire »). Ce n'est pas une négligence :
* SimplePdf convertit en Windows-1252 avec translittération, et cette écriture
* garantit un rendu identique même quand iconv n'est pas disponible. Les données
* venant de la base, elles, gardent leurs accents.
*
* Toutes les coordonnées sont en POINTS PDF, origine en HAUT à gauche (voir
* SimplePdf). Les valeurs numériques éparpillées dans les méthodes de dessin
* sont des positions absolues ajustées visuellement : les modifier suppose de
* regénérer un document pour vérifier.
*/
final class PlanningPdfService final class PlanningPdfService
{ {
/** Marge extérieure commune à tous les documents. */
private const MARGIN = 28.0; private const MARGIN = 28.0;
private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi']; private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
/** /**
* Compatibilité avec l'ancien export d'une seule semaine. * Compatibilité avec l'ancien export d'une seule semaine.
* Conservée pour d'éventuels appels externes ; le code actuel passe
* directement par agentWeeks().
*/ */
public function agentWeek(array $agent, array $entries, array $week, array $quota): string public function agentWeek(array $agent, array $entries, array $week, array $quota): string
{ {
@@ -23,6 +45,24 @@ final class PlanningPdfService
/** /**
* Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu * Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu
* tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines). * tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines).
*
* Le découpage en pages est fait par array_chunk(..., 4) : QUATRE SEMAINES
* PAR PAGE, chaque bloc occupant 112 points de haut à partir de y = 96. Ce
* plafond de quatre est une contrainte physique, SimplePdf ne sachant pas
* reporter un tableau sur la page suivante. Passer à cinq exigerait de
* réduire la hauteur des blocs.
*
* Au-delà de sept semaines, la liste est silencieusement TRONQUÉE : le
* contrôleur n'accepte de toute façon que 6 ou 7.
*
* @param array<string, mixed> $agent
* @param array<int, array<string, mixed>> $entries Créneaux de toute la période.
* @param array<int, array<string, mixed>> $weeks Semaines à rendre.
* @param array<string, mixed> $quota
*
* @return string Octets du PDF.
*
* @throws \InvalidArgumentException Si aucune semaine n'est fournie.
*/ */
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
{ {
@@ -60,6 +100,24 @@ final class PlanningPdfService
return $pdf->output(); return $pdf->output();
} }
/**
* Planning d'un lieu sur une semaine : un tableau agents × cinq jours.
*
* Contrairement à agentWeeks(), la hauteur des lignes n'est PAS connue à
* l'avance — elle dépend du nombre de créneaux de chaque agent et du
* découpage des libellés. La méthode mesure donc chaque ligne avant de la
* tracer, et déclenche une nouvelle page dès que $bottomLimit serait
* dépassé. La closure $drawHeader existe pour cette raison : l'en-tête du
* tableau doit être redessiné en tête de CHAQUE page, sans quoi les colonnes
* ne seraient plus identifiables.
*
* @param array<string, mixed> $structure
* @param array<int, array<string, mixed>> $agents Chaque agent porte ses
* propres clés `entries` et `quota`.
* @param array<string, mixed> $week
*
* @return string Octets du PDF.
*/
public function structureWeek(array $structure, array $agents, array $week): string public function structureWeek(array $structure, array $agents, array $week): string
{ {
$pdf = new SimplePdf(); $pdf = new SimplePdf();
@@ -201,6 +259,10 @@ final class PlanningPdfService
return $pdf->output(); return $pdf->output();
} }
/**
* En-tête du planning agent : identité, lieu principal, période couverte,
* quota restant, et pagination si le document tient sur plusieurs pages.
*/
private function drawAgentMultiWeekHeader( private function drawAgentMultiWeekHeader(
SimplePdf $pdf, SimplePdf $pdf,
array $agent, array $agent,
@@ -228,7 +290,19 @@ final class PlanningPdfService
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72); $pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
} }
/** @param array<string, array<int, array>> $entriesByDate */ /**
* Dessine UN bloc de semaine : bandeau de titre, en-têtes de jours, puis les
* créneaux de chaque journée dans une colonne.
*
* La hauteur du corps est FIXE (77 points) pour que les blocs s'empilent à
* pas régulier. Les créneaux qui n'y tiennent pas ne sont pas rendus — le
* compteur $rendered plus bas s'en charge. Une journée très chargée peut
* donc apparaître tronquée : c'est le compromis retenu pour garantir sept
* semaines sur deux pages.
*
* @param array<string, array<int, array>> $entriesByDate Créneaux indexés par date.
* @param float $y Ordonnée du haut du bloc.
*/
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
{ {
$weekDays = $this->weekDays($week); $weekDays = $this->weekDays($week);
@@ -287,7 +361,13 @@ final class PlanningPdfService
} }
} }
/** @return string[] */ /**
* Lignes d'un créneau dans la vue agent : horaires, motif si informatif,
* lieu, et la mention « Brouillon » quand le planning n'est pas validé —
* un document imprimé doit dire s'il est définitif.
*
* @return string[]
*/
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
{ {
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])]; $lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
@@ -310,7 +390,12 @@ final class PlanningPdfService
return $lines; return $lines;
} }
/** @return string[] */ /**
* Lignes d'un créneau dans la vue lieu : plus condensées que dans la vue
* agent, le lieu étant déjà celui du document.
*
* @return string[]
*/
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
{ {
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']); $first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
@@ -333,7 +418,11 @@ final class PlanningPdfService
: (string) ($entry['motif_libelle'] ?? 'Affectation'); : (string) ($entry['motif_libelle'] ?? 'Affectation');
} }
/** @return array{accent:string,background:string,label:string} */ /**
* Palette d'un créneau, déduite de son motif.
*
* @return array{accent:string,background:string,label:string}
*/
private function entryPalette(array $entry): array private function entryPalette(array $entry): array
{ {
return $this->motifPalette( return $this->motifPalette(
@@ -342,7 +431,15 @@ final class PlanningPdfService
); );
} }
/** @return array{accent:string,background:string,label:string} */ /**
* Couleurs par CATÉGORIE de motif — travail en bleu, formation en violet,
* congé en vert, absence en rouge, reste en gris.
*
* Le classement passe par App\Core\MotifCategories, partagé avec l'écran et
* les autres PDF : une même catégorie garde partout la même couleur.
*
* @return array{accent:string,background:string,label:string}
*/
private function motifPalette(string $code, bool $countsInQuota = false): array private function motifPalette(string $code, bool $countsInQuota = false): array
{ {
return match (MotifCategories::category($code, $countsInQuota)) { return match (MotifCategories::category($code, $countsInQuota)) {
@@ -354,6 +451,12 @@ final class PlanningPdfService
}; };
} }
/**
* Légende des couleurs, en une ligne au pied du document.
*
* Les entrées sont espacées selon la LARGEUR ESTIMÉE de leur libellé et non
* à pas fixe : la première est trop longue pour une colonne régulière.
*/
private function drawLegend(SimplePdf $pdf, float $x, float $y): void private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{ {
$items = [ $items = [
@@ -374,7 +477,15 @@ final class PlanningPdfService
} }
} }
/** @return array<string, array<int, array>> */ /**
* Indexe les créneaux par date et les trie par heure de début.
*
* Le tri est refait ici bien que le SQL trie déjà : les créneaux peuvent
* avoir été fusionnés depuis plusieurs sources (plusieurs lieux, plusieurs
* plannings) avant d'arriver.
*
* @return array<string, array<int, array>>
*/
private function entriesByDate(array $entries): array private function entriesByDate(array $entries): array
{ {
$grouped = []; $grouped = [];
@@ -388,6 +499,7 @@ final class PlanningPdfService
return $grouped; return $grouped;
} }
/** En-tête commun du planning par lieu : titre, sous-titre, semaine, filet. */
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
{ {
$pdf->text(self::MARGIN, 34, 'PTA', 10, true); $pdf->text(self::MARGIN, 34, 'PTA', 10, true);
@@ -397,7 +509,12 @@ final class PlanningPdfService
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72); $pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
} }
/** @return array<int, array{date:string,label:string,display:string}> */ /**
* Les cinq jours ouvrés d'une semaine, à partir de sa date de début.
* S'arrête au vendredi, le week-end n'étant pas planifiable.
*
* @return array<int, array{date:string,label:string,display:string}>
*/
private function weekDays(array $week): array private function weekDays(array $week): array
{ {
$start = new DateTimeImmutable((string) $week['date_debut']); $start = new DateTimeImmutable((string) $week['date_debut']);
@@ -413,6 +530,7 @@ final class PlanningPdfService
return $days; return $days;
} }
/** « Semaine du 16/02/2026 au 20/02/2026 » — bornée au vendredi. */
private function weekLabel(array $week): string private function weekLabel(array $week): string
{ {
$start = new DateTimeImmutable((string) $week['date_debut']); $start = new DateTimeImmutable((string) $week['date_debut']);
@@ -420,6 +538,7 @@ final class PlanningPdfService
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y')); return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
} }
/** Abrège le nom du jour pour les colonnes étroites : « Lundi » → « Lun. ». */
private function shortDayLabel(string $label): string private function shortDayLabel(string $label): string
{ {
return match ($label) { return match ($label) {
@@ -432,6 +551,7 @@ final class PlanningPdfService
}; };
} }
/** Libellé du type de lieu, sans accent (contrainte d'encodage du PDF). */
private function typeLabel(string $type): string private function typeLabel(string $type): string
{ {
return match (strtoupper($type)) { return match (strtoupper($type)) {

View File

@@ -8,8 +8,26 @@ use DateTimeImmutable;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
/**
* Import des vacances scolaires depuis l'API publique data.education.gouv.fr.
*
* Ce service ne fait que PROPOSER : il ne touche jamais la base. L'écriture
* relève de VacationModel, après validation manuelle par un utilisateur. Ce
* découpage est délibéré — les libellés et découpages publiés varient d'une
* académie et d'une année à l'autre, et rien de tout cela n'entre en base sans
* relecture humaine.
*
* TOLÉRANCE AUX PANNES, principe directeur de tout le fichier. Le portail public
* change de version d'API, de graphie d'académie et de format de réponse sans
* préavis. Chaque étape est donc doublée d'un repli, et un échec partiel produit
* un AVERTISSEMENT plutôt qu'une erreur : mieux vaut proposer les dates
* disponibles que rien du tout.
*
* Nécessite un accès réseau SORTANT depuis le conteneur php.
*/
final class SchoolHolidayApiService final class SchoolHolidayApiService
{ {
/** Jeu de données public du calendrier scolaire. */
private const DATASET = 'fr-en-calendrier-scolaire'; private const DATASET = 'fr-en-calendrier-scolaire';
/** /**
@@ -24,35 +42,24 @@ final class SchoolHolidayApiService
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/'; private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
/**
* Zone de vacances (A, B ou C) de chaque académie métropolitaine.
*
* Table locale servant de REPLI quand l'API ne publie pas de ligne propre à
* l'académie mais seulement une ligne commune à sa zone. À mettre à jour si
* le découpage officiel évolue — il a déjà changé par le passé, et les
* académies d'outre-mer n'y figurent pas.
*/
private const ACADEMY_ZONES = [ private const ACADEMY_ZONES = [
'aix-marseille' => 'Zone B', 'dijon' => 'Zone A'
'amiens' => 'Zone B',
'besancon' => 'Zone A',
'bordeaux' => 'Zone A',
'clermont-ferrand' => 'Zone A',
'creteil' => 'Zone C',
'dijon' => 'Zone A',
'grenoble' => 'Zone A',
'lille' => 'Zone B',
'limoges' => 'Zone A',
'lyon' => 'Zone A',
'montpellier' => 'Zone C',
'nancy-metz' => 'Zone B',
'nantes' => 'Zone B',
'nice' => 'Zone B',
'normandie' => 'Zone B',
'orleans-tours' => 'Zone B',
'paris' => 'Zone C',
'poitiers' => 'Zone A',
'reims' => 'Zone B',
'rennes' => 'Zone B',
'strasbourg' => 'Zone B',
'toulouse' => 'Zone C',
'versailles' => 'Zone C',
]; ];
/** /**
* Compatibilité avec le code existant : retourne uniquement les périodes. * Compatibilité avec le code existant : retourne uniquement les périodes,
* sans les avertissements. Le code actuel appelle plutôt la variante
* détaillée, qui permet d'afficher ce qui n'a pas pu être chargé.
*
* @return array<int, array<string, mixed>>
*/ */
public function previewCalendarYear(string $academy, int $calendarYear): array public function previewCalendarYear(string $academy, int $calendarYear): array
{ {
@@ -65,7 +72,19 @@ final class SchoolHolidayApiService
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus * scolaires. L'absence de publication de l'une d'elles ne doit donc plus
* empêcher l'affichage des dates déjà disponibles. * empêcher l'affichage des dates déjà disponibles.
* *
* Une année civile couvre la FIN d'une année scolaire et le DÉBUT de la
* suivante : les deux sont donc interrogées, puis les périodes fusionnées et
* dédoublonnées sur (libellé, début, fin). Ne sont conservées que celles qui
* INTERSECTENT l'année civile demandée.
*
* L'exception n'est levée que si AUCUNE des deux requêtes n'a abouti : un
* seul succès suffit à rendre un résultat exploitable, l'échec de l'autre
* partant en avertissement.
*
* @return array{periods: array, warnings: array, school_years: array} * @return array{periods: array, warnings: array, school_years: array}
*
* @throws RuntimeException Année ou académie invalide, ou API totalement
* injoignable.
*/ */
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
{ {
@@ -135,13 +154,28 @@ final class SchoolHolidayApiService
} }
/** /**
* Compatibilité avec l'ancienne API interne V5.17. * Compatibilité avec l'ancienne API interne V5.17, qui raisonnait en année
* scolaire et non en année civile.
*
* @return array<int, array<string, mixed>>
*/ */
public function preview(string $academy, string $schoolYear): array public function preview(string $academy, string $schoolYear): array
{ {
return $this->previewSchoolYear($academy, $schoolYear); return $this->previewSchoolYear($academy, $schoolYear);
} }
/**
* Interroge l'API pour UNE année scolaire, avec trois stratégies en cascade.
*
* On s'arrête au PREMIER résultat non vide ; les erreurs intermédiaires sont
* accumulées et seule la dernière est remontée si tout échoue. Un résultat
* vide sans erreur n'est pas une anomalie : l'académie peut simplement ne
* rien avoir publié.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si toutes les stratégies ont échoué en erreur.
*/
private function previewSchoolYear(string $academy, string $schoolYear): array private function previewSchoolYear(string $academy, string $schoolYear): array
{ {
$errors = []; $errors = [];
@@ -212,6 +246,16 @@ final class SchoolHolidayApiService
return []; return [];
} }
/**
* Interroge l'API Explore, en essayant successivement chaque version
* d'endpoint jusqu'à obtenir une réponse exploitable.
*
* @param array<string, mixed> $parameters Paramètres de requête Explore.
*
* @return array<int, mixed> Contenu brut de la clé `results`.
*
* @throws RuntimeException Si aucun endpoint n'a répondu correctement.
*/
private function fetchExploreRecords(array $parameters): array private function fetchExploreRecords(array $parameters): array
{ {
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
@@ -233,6 +277,17 @@ final class SchoolHolidayApiService
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.'); throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
} }
/**
* Dernier repli : l'ancienne API Opendatasoft 1.0.
*
* Son format diffère — les champs utiles sont imbriqués sous `fields` — et
* sont remis à plat ici pour que normalize() traite les deux sources de la
* même façon.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si la réponse n'a pas la forme attendue.
*/
private function fetchLegacyRecords(string $academy, string $schoolYear): array private function fetchLegacyRecords(string $academy, string $schoolYear): array
{ {
$query = http_build_query([ $query = http_build_query([
@@ -257,6 +312,30 @@ final class SchoolHolidayApiService
))); )));
} }
/**
* Convertit les enregistrements bruts de l'API en périodes exploitables.
*
* Trois filtres successifs :
* 1. la POPULATION doit concerner les élèves — un enregistrement réservé
* aux enseignants ne fait pas de vacances pour l'accueil de loisirs ;
* 2. hors repli par zone, l'ACADÉMIE doit correspondre à celle demandée,
* comparée après normalisation (accents et tirets neutralisés) ;
* 3. la DESCRIPTION doit ressembler à des vacances (voir
* isVacationDescription()), afin d'écarter rentrées et pré-rentrées.
*
* CONVERSION DE DATE ESSENTIELLE : l'API publie la DATE DE REPRISE des
* cours, alors que PTA raisonne en bornes INCLUSIVES. Le dernier jour de
* vacances est donc la veille de la reprise. Sans cette correction, chaque
* période gagnerait un jour et le premier jour de classe serait qualifié
* d'extrascolaire.
*
* @param array<int, mixed> $records
* @param bool $zoneFallback true quand les données viennent du repli par
* zone : le filtre sur l'académie est alors levé, et l'académie
* demandée est conservée dans le résultat.
*
* @return array<int, array<string, mixed>> Triées par date de début.
*/
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
{ {
$periods = []; $periods = [];
@@ -319,6 +398,18 @@ final class SchoolHolidayApiService
return $periods; return $periods;
} }
/**
* Reconnaît une période de vacances à partir de sa description.
*
* Filtrage par MOTS-CLÉS faute de champ de type dans le jeu de données. Les
* rentrées et pré-rentrées sont exclues explicitement : ce sont des
* événements ponctuels, pas des périodes. « été austral » et « hiver
* austral » couvrent les académies d'outre-mer.
*
* Une description non reconnue est SILENCIEUSEMENT ignorée : un libellé
* inédit peut donc faire disparaître une période, à surveiller si des
* vacances manquent à l'appel.
*/
private function isVacationDescription(string $description): bool private function isVacationDescription(string $description): bool
{ {
$normalized = $this->normalizeText($description); $normalized = $this->normalizeText($description);
@@ -337,6 +428,17 @@ final class SchoolHolidayApiService
return false; return false;
} }
/**
* Graphies alternatives d'un nom d'académie à essayer successivement.
*
* Le portail n'est pas constant : « Nancy-Metz », « Nancy Metz »,
* « Orléans-Tours » ou « Orleans-Tours » selon les enregistrements, avec
* parfois un tiret cadratin plutôt qu'un trait d'union. On essaie donc
* l'original, les tirets uniformisés, la version sans accents, et les
* variantes à espaces.
*
* @return array<int, string> Sans doublon, dans l'ordre d'essai.
*/
private function academyVariants(string $academy): array private function academyVariants(string $academy): array
{ {
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy); $academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
@@ -349,12 +451,35 @@ final class SchoolHolidayApiService
return array_values(array_unique(array_filter(array_map('trim', $variants)))); return array_values(array_unique(array_filter(array_map('trim', $variants))));
} }
/**
* Zone de vacances d'une académie, ou null si elle est inconnue de la table
* (outre-mer, ou nom mal orthographié).
*/
private function zoneForAcademy(string $academy): ?string private function zoneForAcademy(string $academy): ?string
{ {
$key = $this->normalizeAcademy($academy); $key = $this->normalizeAcademy($academy);
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null; return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
} }
/**
* Effectue la requête HTTP et décode le JSON.
*
* Deux implémentations : cURL quand l'extension est présente, sinon
* file_get_contents() avec un contexte de flux. Ce second chemin ne
* fonctionne que si `allow_url_fopen` est actif, d'où le message explicite
* en cas d'échec — c'est la panne la plus fréquente sur un hébergement
* mutualisé.
*
* Délais volontairement courts (8 s de connexion, 20 s au total) : l'appel
* est SYNCHRONE dans une requête web, l'utilisateur attend devant son écran.
*
* `ignore_errors` et CURLOPT_RETURNTRANSFER permettent de lire le corps même
* sur un code d'erreur, mais tout code >= 400 est traité comme un échec.
*
* @return array<mixed> Charge utile décodée.
*
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
*/
private function fetchJson(string $url): array private function fetchJson(string $url): array
{ {
$body = false; $body = false;
@@ -412,11 +537,23 @@ final class SchoolHolidayApiService
return $data; return $data;
} }
/**
* Échappe une valeur pour la clause `where` de l'API Explore, dont la
* syntaxe est proche du SQL : les guillemets et les barres obliques
* inverses doivent être protégés pour ne pas altérer la requête distante.
*/
private function quoteWhereValue(string $value): string private function quoteWhereValue(string $value): string
{ {
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
} }
/**
* Extrait la partie date d'un horodatage ISO.
* L'API renvoie tantôt « 2026-02-21 », tantôt
* « 2026-02-21T23:00:00+00:00 » ; seule la date nous intéresse.
*
* @return string|null null si aucune date n'est reconnaissable.
*/
private function dateOnly(string $value): ?string private function dateOnly(string $value): ?string
{ {
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) { if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
@@ -425,6 +562,11 @@ final class SchoolHolidayApiService
return $matches[1]; return $matches[1];
} }
/**
* Ramène une valeur d'API à une chaîne.
* Certains champs (zones, population) sont tantôt scalaires, tantôt des
* tableaux selon la version de l'API ; les tableaux sont aplatis.
*/
private function stringValue(mixed $value): string private function stringValue(mixed $value): string
{ {
if (is_array($value)) { if (is_array($value)) {
@@ -434,6 +576,13 @@ final class SchoolHolidayApiService
} }
/**
* Vrai si l'enregistrement concerne les élèves.
*
* Acceptent : la valeur vide, « - », « Élèves », « Tous », « Tout public ».
* Les vacances communes sont en effet publiées avec une population « - » ;
* ne retenir que « Élèves » les faisait disparaître du résultat.
*/
private function isStudentPopulation(mixed $value): bool private function isStudentPopulation(mixed $value): bool
{ {
$values = is_array($value) ? $value : [$value]; $values = is_array($value) ? $value : [$value];
@@ -446,12 +595,21 @@ final class SchoolHolidayApiService
return false; return false;
} }
/**
* Forme canonique d'un nom d'académie pour comparaison : tirets et
* soulignés deviennent des espaces, accents et casse neutralisés.
* « Nancy-Metz », « nancy metz » et « NANCY_METZ » se rejoignent ainsi.
*/
private function normalizeAcademy(string $value): string private function normalizeAcademy(string $value): string
{ {
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value); $value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
return $this->normalizeText($value); return $this->normalizeText($value);
} }
/**
* Forme canonique d'un texte : sans accent, en minuscules, réduit aux
* lettres, chiffres et tirets, espaces compactés.
*/
private function normalizeText(string $value): string private function normalizeText(string $value): string
{ {
$value = $this->stripAccents($value); $value = $this->stripAccents($value);
@@ -462,6 +620,14 @@ final class SchoolHolidayApiService
return trim($value); return trim($value);
} }
/**
* Retire les accents par une table de correspondance explicite.
*
* Table écrite à la main plutôt qu'iconv ou intl : ces extensions ne sont
* pas garanties partout, et leur comportement de translittération varie
* selon la locale du système. Une table donne un résultat identique
* quel que soit l'hébergement.
*/
private function stripAccents(string $value): string private function stripAccents(string $value): string
{ {
return strtr($value, [ return strtr($value, [

View File

@@ -8,9 +8,27 @@ namespace App\Services;
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings. * Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert * Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
* aucune dépendance Composer. * aucune dépendance Composer.
*
* SYSTÈME DE COORDONNÉES — le point le plus important à comprendre ici. Le
* format PDF place l'origine EN BAS À GAUCHE, l'axe Y montant. Cette classe
* expose au contraire une origine EN HAUT À GAUCHE, Y descendant, comme en CSS :
* c'est ainsi qu'on raisonne pour composer une page. La conversion
* (`height - y`) est faite dans CHAQUE méthode de dessin. Toute nouvelle
* primitive doit l'appliquer aussi, sinon elle dessinera à l'envers.
*
* Ce que cette classe ne sait PAS faire, et qui contraint les appelants :
* - aucune image, aucune police embarquée (Helvetica et Helvetica-Bold seules) ;
* - aucun retour à la ligne automatique : voir wrap(), qui ESTIME la largeur ;
* - aucun saut de page automatique : l'appelant appelle addPage() lui-même et
* doit surveiller ses hauteurs. C'est pourquoi le PDF de planning agent est
* plafonné à 6 ou 7 semaines.
*
* Le texte est converti en Windows-1252 (voir escapeText()) : les caractères
* hors de ce jeu, émoji compris, sont translittérés ou remplacés.
*/ */
final class SimplePdf final class SimplePdf
{ {
/** Dimensions A4 paysage en POINTS PDF (1 pt = 1/72 pouce). */
public const A4_LANDSCAPE_WIDTH = 841.89; public const A4_LANDSCAPE_WIDTH = 841.89;
public const A4_LANDSCAPE_HEIGHT = 595.28; public const A4_LANDSCAPE_HEIGHT = 595.28;
@@ -35,6 +53,12 @@ final class SimplePdf
return $this->height; return $this->height;
} }
/**
* Ouvre une nouvelle page.
*
* La page en cours est empilée si elle contient quelque chose. Appeler
* addPage() deux fois de suite ne produit donc pas de page blanche.
*/
public function addPage(): void public function addPage(): void
{ {
if ($this->currentPage !== '') { if ($this->currentPage !== '') {
@@ -43,6 +67,13 @@ final class SimplePdf
$this->currentPage = ''; $this->currentPage = '';
} }
/**
* Écrit du texte en noir. ($x, $y) est le point de BASE (« baseline ») du
* premier caractère, mesuré depuis le HAUT de la page.
*
* @param float $size Corps en points.
* @param bool $bold true pour Helvetica-Bold.
*/
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
{ {
$font = $bold ? 'F2' : 'F1'; $font = $bold ? 'F2' : 'F1';
@@ -58,6 +89,11 @@ final class SimplePdf
); );
} }
/**
* Trace un trait.
*
* @param float $gray Niveau de gris, 0 = noir, 1 = blanc.
*/
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
{ {
$this->currentPage .= sprintf( $this->currentPage .= sprintf(
@@ -70,6 +106,10 @@ final class SimplePdf
); );
} }
/**
* Trace le CONTOUR d'un rectangle. ($x, $y) est son coin supérieur gauche ;
* la conversion vers le coin inférieur gauche attendu par PDF est faite ici.
*/
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
{ {
$this->currentPage .= sprintf( $this->currentPage .= sprintf(
@@ -82,6 +122,7 @@ final class SimplePdf
); );
} }
/** Rectangle PLEIN en niveau de gris (fonds de cellules, bandeaux). */
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
{ {
$this->currentPage .= sprintf( $this->currentPage .= sprintf(
@@ -94,6 +135,12 @@ final class SimplePdf
); );
} }
/**
* Rectangle plein en couleur.
*
* @param string $hex Couleur « #RRGGBB » ou « #RGB » ; une valeur invalide
* retombe sur le noir plutôt que d'échouer.
*/
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
{ {
[$r, $g, $b] = $this->hexToRgb($hex); [$r, $g, $b] = $this->hexToRgb($hex);
@@ -109,6 +156,7 @@ final class SimplePdf
); );
} }
/** Contour de rectangle en couleur, d'épaisseur réglable. */
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
{ {
[$r, $g, $b] = $this->hexToRgb($hex); [$r, $g, $b] = $this->hexToRgb($hex);
@@ -125,6 +173,7 @@ final class SimplePdf
); );
} }
/** Texte en couleur. Mêmes conventions que text(). */
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
{ {
[$r, $g, $b] = $this->hexToRgb($hex); [$r, $g, $b] = $this->hexToRgb($hex);
@@ -145,7 +194,19 @@ final class SimplePdf
} }
/** /**
* @return string[] * Découpe un texte en lignes tenant dans une largeur donnée.
*
* APPROXIMATION ASSUMÉE : la largeur réelle d'un caractère Helvetica varie
* (un « i » et un « W » n'occupent pas la même place). Mesurer exactement
* exigerait d'embarquer les métriques AFM de la police, ce qui va à
* l'encontre du parti pris « aucune dépendance ». On retient donc une
* moyenne de 0,52 em par caractère, volontairement CONSERVATRICE : le texte
* peut être coupé un peu tôt, mais ne déborde jamais de sa cellule.
*
* Un mot plus long que la largeur disponible est coupé de force, sans quoi
* il déborderait à coup sûr.
*
* @return string[] Toujours au moins une ligne, éventuellement vide.
*/ */
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
{ {
@@ -186,6 +247,21 @@ final class SimplePdf
return $lines ?: ['']; return $lines ?: [''];
} }
/**
* Assemble le document et retourne les octets du PDF.
*
* Structure produite, conforme à un PDF 1.4 minimal :
* objet 1 = catalogue, 2 = arbre des pages, 3/4 = les deux polices,
* puis, pour chaque page, un objet /Page et un objet de contenu.
*
* Vient ensuite la table de références croisées (« xref »), qui donne le
* DÉCALAGE EN OCTETS de chaque objet depuis le début du fichier — d'où les
* strlen() successifs. Le tout se termine par le trailer et startxref, qui
* indique où trouver cette table.
*
* À ne appeler qu'UNE FOIS : la méthode consomme la page courante en la
* poussant dans la pile. Un second appel ajouterait une page vide.
*/
public function output(): string public function output(): string
{ {
if ($this->currentPage !== '' || $this->pages === []) { if ($this->currentPage !== '' || $this->pages === []) {
@@ -242,11 +318,17 @@ final class SimplePdf
return $pdf; return $pdf;
} }
/**
* Longueur en CARACTÈRES, avec repli sur les octets si mbstring est absent.
* Le repli surestime la longueur d'un texte accentué, ce qui ne fait que
* couper un peu plus tôt — jamais déborder.
*/
private function stringLength(string $value): int private function stringLength(string $value): int
{ {
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value); return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
} }
/** Extraction sûre en UTF-8, avec repli sur substr() sans mbstring. */
private function stringSlice(string $value, int $start, ?int $length = null): string private function stringSlice(string $value, int $start, ?int $length = null): string
{ {
if (function_exists('mb_substr')) { if (function_exists('mb_substr')) {
@@ -255,7 +337,13 @@ final class SimplePdf
return $length === null ? substr($value, $start) : substr($value, $start, $length); return $length === null ? substr($value, $start) : substr($value, $start, $length);
} }
/** @return array{0:float,1:float,2:float} */ /**
* Convertit « #RRGGBB » ou « #RGB » en composantes RVB de 0 à 1, échelle
* attendue par les opérateurs PDF. Une valeur invalide donne du noir plutôt
* qu'une erreur : un PDF illisible serait pire qu'une couleur fausse.
*
* @return array{0:float,1:float,2:float}
*/
private function hexToRgb(string $hex): array private function hexToRgb(string $hex): array
{ {
$hex = ltrim(trim($hex), '#'); $hex = ltrim(trim($hex), '#');
@@ -273,6 +361,20 @@ final class SimplePdf
]; ];
} }
/**
* Prépare une chaîne pour l'insertion dans un flux PDF.
*
* Deux opérations distinctes :
* 1. CONVERSION en Windows-1252, encodage déclaré des polices. //TRANSLIT
* approche les caractères absents (« œ » → « oe »), //IGNORE écarte le
* reste. Sans iconv, tout ce qui n'est pas ASCII imprimable devient « ? ».
* 2. ÉCHAPPEMENT des caractères qui ont un sens dans la syntaxe PDF : la
* barre oblique inverse et les parenthèses, qui délimitent les chaînes.
* Un texte contenant une parenthèse non échappée corromprait le document.
*
* Les retours à la ligne sont supprimés ou remplacés par un espace : le
* découpage en lignes relève de wrap(), pas du contenu.
*/
private function escapeText(string $text): string private function escapeText(string $text): string
{ {
$converted = function_exists('iconv') $converted = function_exists('iconv')

View File

@@ -1,4 +1,27 @@
<?php <?php
/**
* Pied de page commun : publication de la configuration JavaScript et injection
* des scripts.
*
* C'EST LE SEUL PONT ENTRE PHP ET LE JAVASCRIPT. Deux objets globaux sont
* publiés dans un <script> inline :
* - window.PTA_CONFIG : jeton CSRF, profil d'accès, permissions et
* référentiel des motifs — données valables pour toute l'application ;
* - window.PTA_PAGE_CONFIG : modules à initialiser et paramètres d'URL déjà
* assainis — données propres à la page courante.
* core.js recopie le premier dans `PTA.config` ; page-bootstrap.js consomme le
* second.
*
* Ce <script> porte le nonce de la CSP : sans lui, il serait bloqué et TOUTE
* l'interface cesserait de fonctionner. Tout script inline ajouté à une vue doit
* faire de même (voir App\Core\Security).
*
* ORDRE DE CHARGEMENT IMPOSÉ : core.js d'abord (il publie window.PTA), puis les
* modules de la page, puis page-bootstrap.js en dernier (il appelle leurs init).
*
* VERSION DES ASSETS : incrémenter le `?v=` ici, dans layouts/header.php ET
* dans Views/pages/role.php après toute modification de JS ou de CSS.
*/
/** @var string $csrfToken */ /** @var string $csrfToken */
/** @var array $motifs */ /** @var array $motifs */
/** @var array $scripts */ /** @var array $scripts */

View File

@@ -1,4 +1,21 @@
<?php <?php
/**
* En-tête HTML commun à toutes les pages : <head>, bandeau et barre de navigation.
*
* LA NAVIGATION EST DÉFINIE À DEUX ENDROITS, à tenir cohérents :
* - $navigation ci-dessous, qui associe une clé de page à son URL et son libellé ;
* - acces_role_page en base, qui décide des pages RÉELLEMENT autorisées.
* Une page absente de $navigation n'apparaîtra jamais, même autorisée en base ;
* une page absente de la base n'apparaîtra pas non plus, même déclarée ici.
*
* $navigationGroups n'est qu'un regroupement d'affichage : une page oubliée
* dans les groupes reste atteignable via le groupe « Autres » (voir plus bas).
*
* VERSION DES ASSETS : le `?v=` de la feuille de style est à incrémenter après
* toute modification de CSS ou de JS, ICI, dans layouts/footer.php ET dans
* Views/pages/role.php, qui porte son propre <head>. Sans cela, nginx
* (expires 7d) et les navigateurs continuent de servir l'ancienne version.
*/
/** @var string $pageTitle */ /** @var string $pageTitle */
/** @var string $pageSubtitle */ /** @var string $pageSubtitle */
/** @var string $activePage */ /** @var string $activePage */

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/administration.php'; require dirname(__DIR__) . '/partials/administration.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/agent_overview.php'; require dirname(__DIR__) . '/partials/agent_overview.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/annual_overview.php'; require dirname(__DIR__) . '/partials/annual_overview.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/coverage_alerts.php'; require dirname(__DIR__) . '/partials/coverage_alerts.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/dashboard.php'; require dirname(__DIR__) . '/partials/dashboard.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/faq.php'; require dirname(__DIR__) . '/partials/faq.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/pending_validation.php'; require dirname(__DIR__) . '/partials/pending_validation.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/planning_editor.php'; require dirname(__DIR__) . '/partials/planning_editor.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/pta.php'; require dirname(__DIR__) . '/partials/pta.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,15 @@
<?php <?php
/**
* Sélecteur de rôle — SEULE vue à ne pas passer par layouts/header.php.
*
* Elle s'affiche avant qu'un rôle soit choisi, donc avant tout contrôle
* d'accès, et ne peut pas afficher la barre de navigation qui en dépend. Elle
* porte donc son propre <head> : c'est le TROISIÈME endroit où incrémenter le
* `?v=` des assets, avec layouts/header.php et layouts/footer.php.
*
* Rappel : sélection n'est pas authentification. L'utilisateur choisit
* librement son identité (voir App\Core\Access::select()).
*/
/** @var string $csrfToken */ /** @var string $csrfToken */
/** @var array $agents */ /** @var array $agents */
/** @var array $structures */ /** @var array $structures */

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/structure_overview.php'; require dirname(__DIR__) . '/partials/structure_overview.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/teams.php'; require dirname(__DIR__) . '/partials/teams.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,4 +1,9 @@
<?php <?php
/**
* Vue de page : assemble l'en-tête commun, le partiel de contenu et le pied.
* Toutes les variables utilisées viennent de PageController::renderPage(), qui
* les a extraites dans la portée de ce fichier.
*/
require dirname(__DIR__) . '/layouts/header.php'; require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/vacation_calendar.php'; require dirname(__DIR__) . '/partials/vacation_calendar.php';
require dirname(__DIR__) . '/layouts/footer.php'; require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -1,3 +1,13 @@
<?php
/**
* Référentiels : agents, lieux d'affectation, postes, horaires de couverture.
* Modules JS : `administration` et `coverage`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*/
?>
<section id="assignment-view" class="app-view page-content"> <section id="assignment-view" class="app-view page-content">
<div class="admin-grid"> <div class="admin-grid">
<section class="card admin-card"> <section class="card admin-card">

View File

@@ -1,3 +1,13 @@
<?php
/**
* Planning mensuel d'un agent, tous lieux confondus.
* Module JS : `agentOverview`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*/
?>
<section id="agent-view" class="app-view page-content"> <section id="agent-view" class="app-view page-content">
<section class="card"> <section class="card">
<div class="section-heading"> <div class="section-heading">

View File

@@ -1,4 +1,14 @@
<?php <?php
/**
* Calendrier annuel d'un agent et validation de l'année entière.
* Le calendrier est intégralement construit côté serveur (AnnualOverviewModel) ;
* ce partiel ne fournit que les conteneurs vides.
* Module JS : `annualOverview`.
*
* Partiel de contenu inclus par la vue de page homonyme. Les identifiants HTML
* posés ici sont les points d'ancrage du module JS : les renommer casse le
* module sans aucune erreur visible.
*/
$currentYear = (int) date('Y'); $currentYear = (int) date('Y');
?> ?>
<section class="page-content annual-overview-page"> <section class="page-content annual-overview-page">

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