Ajout des commentaires dans le code
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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]]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 ? '-' : '';
|
||||||
|
|||||||
@@ -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 ? '-' : '';
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user