Ajout des commentaires dans le code
This commit is contained in:
@@ -20,6 +20,18 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Fiche agent : consultation, référentiel des agents et documents PDF.
|
||||
*
|
||||
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
|
||||
* DIFFÉRENTES et qu'il ne faut pas mélanger :
|
||||
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
|
||||
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
|
||||
* un flux binaire. Leurs points d'entrée n'appellent PAS
|
||||
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
|
||||
* http_response_code() suivi d'un `return` et d'un message en texte brut.
|
||||
* Y écrire du JSON corromprait le document téléchargé.
|
||||
*/
|
||||
final class AgentController
|
||||
{
|
||||
private AgentModel $agentModel;
|
||||
@@ -35,6 +47,13 @@ final class AgentController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
|
||||
*
|
||||
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
|
||||
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
|
||||
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
|
||||
*/
|
||||
public function listForPlanning(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -45,6 +64,15 @@ final class AgentController
|
||||
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
|
||||
*
|
||||
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
|
||||
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
|
||||
* endroits. En lecture seule pour le rôle AGENT.
|
||||
*
|
||||
* Paramètres GET : agent_id, week (« 2026-W08 »).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
|
||||
@@ -73,6 +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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -411,6 +507,10 @@ final class AgentController
|
||||
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Seul endroit du projet qui utilise correctement
|
||||
// Security::debugEnabled() (fermé par défaut). Les autres blocs
|
||||
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
|
||||
// OUVERT — voir la note dans PlanningController::save().
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Impossible de créer l’agent.',
|
||||
'details' => Security::debugEnabled() ? $e->getMessage() : null,
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -14,12 +14,28 @@ use App\Models\QuotaModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Vue annuelle d'un agent et validation de l'année en une opération.
|
||||
*
|
||||
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
|
||||
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
|
||||
* s'apprécie le quota annualisé (voir QuotaModel).
|
||||
*
|
||||
* Réservé au Service Enfance : valider une année couvre par nature tous les
|
||||
* lieux où l'agent est intervenu.
|
||||
*/
|
||||
final class AnnualOverviewController
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/annual_overview.php — répartition de l'année d'un agent.
|
||||
*
|
||||
* L'année est facultative et retombe sur l'année en cours.
|
||||
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
(new Access($this->pdo))->requireService();
|
||||
@@ -42,12 +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
|
||||
* en brouillon de l'agent pour l'année civile affichée.
|
||||
*
|
||||
* Aucune validation partielle : si une erreur est trouvée, aucun planning
|
||||
* n'est validé. Les semaines concernées sont retournées au front afin de
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger.
|
||||
* pouvoir les ouvrir dans un nouvel onglet et les corriger. Un succès
|
||||
* partiel laisserait l'utilisateur devant une année à moitié validée, sans
|
||||
* moyen simple de savoir ce qui reste.
|
||||
*
|
||||
* Trois issues, alignées sur PlanningController::validate() :
|
||||
* - 422 + status « errors » : au moins une ERREUR, rien n'est validé ;
|
||||
* - 409 + status « warning » : quota annuel dépassé, confirmation attendue
|
||||
* par un rappel avec force_quota: true ;
|
||||
* - 200 + status « validated » ou « nothing_to_validate ».
|
||||
*
|
||||
* Contrairement à validate() unitaire, le quota est ici évalué à l'échelle
|
||||
* de l'ANNÉE (annualForApi) et non de la semaine.
|
||||
*
|
||||
* Charge utile : agent_id, year, force_quota (bool), csrf_token.
|
||||
*/
|
||||
public function validateYear(): void
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
$planningId = (int) $planning['id_planning'];
|
||||
@@ -203,6 +252,13 @@ final class AnnualOverviewController
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empile un contrôle au format commun {niveau, code, message}.
|
||||
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
|
||||
* pas de base commune.
|
||||
*
|
||||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||||
*/
|
||||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -13,6 +13,19 @@ use App\Models\StructureModel;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contrôle de couverture : effectif planifié comparé au minimum attendu.
|
||||
*
|
||||
* Chaque lieu peut déclarer des règles de couverture (jour, plage horaire,
|
||||
* nombre minimum d'agents). Ce contrôleur confronte ces règles aux plannings
|
||||
* réels et remonte les « trous ».
|
||||
*
|
||||
* Deux niveaux de droits distincts :
|
||||
* - alerts() : responsable et Service Enfance, un responsable étant forcé sur
|
||||
* son propre lieu quoi qu'il demande ;
|
||||
* - rules() / saveRules() : Service Enfance seul, les règles étant un
|
||||
* référentiel et non une donnée de saisie.
|
||||
*/
|
||||
final class CoverageController
|
||||
{
|
||||
private CoverageModel $coverageModel;
|
||||
@@ -26,6 +39,22 @@ final class CoverageController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/coverage_alerts.php — trous de couverture d'une semaine.
|
||||
*
|
||||
* Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé
|
||||
* est ignoré pour un responsable, écrasé par son propre lieu : un simple 403
|
||||
* l'empêcherait de consulter son écran avec une URL partagée.
|
||||
*
|
||||
* Trois familles de retours, à ne pas confondre :
|
||||
* - `alerts` : plages réellement sous-couvertes ;
|
||||
* - `validation_warnings` : couverture assurée par des plannings encore en
|
||||
* brouillon, donc pas encore fiable ;
|
||||
* - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas
|
||||
* produire d'alerte, et les taire les ferait passer pour conformes.
|
||||
*
|
||||
* Paramètres GET : week, structure_id (facultatif).
|
||||
*/
|
||||
public function alerts(): void
|
||||
{
|
||||
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
|
||||
@@ -100,6 +129,9 @@ final class CoverageController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_coverage_rules.php — règles de couverture d'un lieu.
|
||||
*/
|
||||
public function rules(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -119,6 +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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
|
||||
@@ -211,17 +266,24 @@ final class CoverageController
|
||||
return true;
|
||||
}
|
||||
|
||||
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
|
||||
private function timeToMinutes(string $time): int
|
||||
{
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
/** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */
|
||||
private function durationMinutes(string $start, string $end): int
|
||||
{
|
||||
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
|
||||
*
|
||||
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
|
||||
*/
|
||||
private function parseWeek(string $value): ?array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -25,6 +25,8 @@ use PDO;
|
||||
*/
|
||||
final class DashboardController
|
||||
{
|
||||
/** Nombre d'éléments détaillés par bloc : un aperçu, pas une liste complète.
|
||||
* Chaque bloc renvoie vers l'écran dédié pour la suite. */
|
||||
private const APERCU_MAX = 5;
|
||||
private const QUOTA_APERCU_MAX = 8;
|
||||
|
||||
@@ -45,6 +47,19 @@ final class DashboardController
|
||||
$this->vacationModel = new VacationModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dashboard_summary.php — données du tableau de bord.
|
||||
*
|
||||
* Deux portées :
|
||||
* - scope=badges : uniquement le compteur de plannings en attente. Cette
|
||||
* variante est appelée depuis TOUTES les pages pour alimenter la pastille
|
||||
* de navigation ; elle doit rester à une seule requête SQL ;
|
||||
* - portée complète : les quatre blocs de l'écran, ce qui représente
|
||||
* plusieurs dizaines de requêtes et n'est acceptable que sur le tableau
|
||||
* de bord lui-même.
|
||||
*
|
||||
* Paramètres GET : scope (badges|absent), week, year (facultatifs).
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -264,6 +279,12 @@ final class DashboardController
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante.
|
||||
*
|
||||
* Contrairement aux endpoints de saisie, une semaine illisible n'est pas une
|
||||
* erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer
|
||||
* la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC.
|
||||
*
|
||||
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
|
||||
*/
|
||||
private function resolveWeek(mixed $value): array
|
||||
@@ -294,6 +315,9 @@ final class DashboardController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée.
|
||||
*/
|
||||
private function resolveYear(mixed $value, int $fallback): int
|
||||
{
|
||||
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
|
||||
@@ -15,6 +15,27 @@ use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Rendu de toutes les pages HTML de l'application.
|
||||
*
|
||||
* Chaque fichier .php de la racine appelle runPage('<nom>'), qui appelle la
|
||||
* méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses
|
||||
* à la fois : il est la route, la clé de page dans acces_role_page, et la valeur
|
||||
* de $activePage qui surligne l'onglet courant.
|
||||
*
|
||||
* Ajouter une page suppose donc, ensemble :
|
||||
* 1. un fichier mince à la racine ;
|
||||
* 2. une méthode ici ;
|
||||
* 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ;
|
||||
* 4. un module JS ;
|
||||
* 5. l'inscription de la clé dans $navigation / $navigationGroups
|
||||
* (Views/layouts/header.php) ;
|
||||
* 6. une migration SQL insérant la ligne dans acces_role_page — les droits
|
||||
* sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée.
|
||||
*
|
||||
* Chaque méthode déclare les scripts et modules JS à charger ; footer.php les
|
||||
* injecte et page-bootstrap.js appelle init() sur chaque module.
|
||||
*/
|
||||
final class PageController extends Controller
|
||||
{
|
||||
private Access $access;
|
||||
@@ -24,6 +45,10 @@ final class PageController extends Controller
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle.
|
||||
* Voir Access::homeUrl() pour le repli si la migration n'est pas jouée.
|
||||
*/
|
||||
public function dashboard(): void
|
||||
{
|
||||
$this->access->requirePage('dashboard');
|
||||
@@ -38,6 +63,20 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de saisie — cœur de l'application.
|
||||
*
|
||||
* Le lieu est VERROUILLÉ pour un responsable de structure
|
||||
* (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est
|
||||
* qu'un confort d'interface, le serveur retranche de toute façon.
|
||||
*
|
||||
* Les scripts de semaine type ne sont chargés que si la permission
|
||||
* can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un
|
||||
* utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs
|
||||
* (éditeur de semaine type / saisie semaine par semaine, voir
|
||||
* README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont
|
||||
* les paramètres des liens profonds venus des autres écrans.
|
||||
*/
|
||||
public function planning(): void
|
||||
{
|
||||
$this->access->requirePage('planning');
|
||||
@@ -71,6 +110,7 @@ final class PageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/** File des plannings en attente de validation (Service Enfance). */
|
||||
public function pending(): void
|
||||
{
|
||||
$this->access->requirePage('pending');
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
Security::sendHtmlHeaders();
|
||||
@@ -243,6 +315,15 @@ final class PageController extends Controller
|
||||
], $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieux proposés dans les sélecteurs, restreints au périmètre du rôle.
|
||||
*
|
||||
* Ce filtrage est du confort d'interface, pas une sécurité : les endpoints
|
||||
* appliquent leurs propres gardes. Un lieu absent d'ici est simplement
|
||||
* inaffichable dans la liste déroulante.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructures(): array
|
||||
{
|
||||
$model = new StructureModel($this->pdo);
|
||||
@@ -257,6 +338,15 @@ final class PageController extends Controller
|
||||
return $structure ? [$structure] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante pour l'éditeur de la fiche agent.
|
||||
*
|
||||
* Un créneau d'agent peut porter sur n'importe quel lieu ; le Service
|
||||
* Enfance a donc besoin de la liste complète même s'il consulte un agent
|
||||
* rattaché ailleurs.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadStructuresForAgentEditor(): array
|
||||
{
|
||||
if ($this->access->role() === Access::ROLE_SERVICE) {
|
||||
@@ -265,6 +355,13 @@ final class PageController extends Controller
|
||||
return $this->loadStructures();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents proposés dans les sélecteurs, selon le rôle : tous (Service),
|
||||
* soi-même (Agent), ou ceux visibles depuis le lieu (Responsable — ce qui
|
||||
* inclut les agents rattachés ailleurs mais planifiés sur place.)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadAgents(): array
|
||||
{
|
||||
$model = new AgentModel($this->pdo);
|
||||
@@ -279,28 +376,53 @@ final class PageController extends Controller
|
||||
return $structureId ? $model->allVisibleForStructure($structureId) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tous les motifs actifs, sans filtrage par agent : c'est l'écran qui
|
||||
* masque « Formation » quand l'agent n'y a pas droit, et le serveur qui
|
||||
* refuse (PlanningController::assertMotifsAllowedForAgent()).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadMotifs(): array
|
||||
{
|
||||
return (new MotifModel($this->pdo))->allActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Semaine courante au format « 2026-W08 ».
|
||||
*
|
||||
* Le format 'o-\WW' associe bien l'ANNÉE ISO ('o') au numéro de semaine ISO
|
||||
* ('W'). Utiliser 'Y' donnerait un couple faux fin décembre / début janvier,
|
||||
* quand la semaine ISO appartient à l'année civile voisine.
|
||||
* Fuseau explicite : le conteneur PHP tourne en UTC.
|
||||
*/
|
||||
private function currentWeek(): string
|
||||
{
|
||||
return (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('o-\\WW');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Assainissement des paramètres d'URL. Ces valeurs partent dans
|
||||
// window.PTA_CONFIG et pilotent les filtres appliqués par
|
||||
// page-bootstrap.js ; toute valeur non conforme est ramenée à null plutôt
|
||||
// que corrigée, pour qu'un lien tronqué ouvre la page par défaut.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Identifiant d'entité : strictement positif, ou null. */
|
||||
private function positiveInt(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Semaine « AAAA-Www ». La validité du numéro est vérifiée côté API. */
|
||||
private function weekValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return preg_match('/^\\d{4}-W\\d{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function dateValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
@@ -308,12 +430,14 @@ final class PageController extends Controller
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : null;
|
||||
}
|
||||
|
||||
/** Année bornée à [2000, 2100] : garde-fou contre les valeurs absurdes. */
|
||||
private function yearValue(mixed $value): ?int
|
||||
{
|
||||
$number = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
|
||||
return $number === false ? null : (int) $number;
|
||||
}
|
||||
|
||||
/** Mois « AAAA-MM », le motif interdisant les mois 00 et 13+. */
|
||||
private function monthValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
@@ -20,6 +20,26 @@ use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Saisie, contrôle et validation des plannings hebdomadaires.
|
||||
*
|
||||
* C'est le contrôleur central de l'application : un `planning` = un agent × une
|
||||
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
|
||||
* `motif_planning`.
|
||||
*
|
||||
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
|
||||
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
|
||||
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
|
||||
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
|
||||
* le SQL correspondant est du patrimoine, pas la source de vérité.
|
||||
*
|
||||
* Forme commune des contrôles renvoyés au client, partout dans l'application :
|
||||
* {niveau: ERREUR|ALERTE|INFO, code, message}
|
||||
* — ERREUR bloque (HTTP 422) ;
|
||||
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
|
||||
* force_quota: true) ;
|
||||
* — INFO est purement informatif.
|
||||
*/
|
||||
final class PlanningController
|
||||
{
|
||||
private PlanningModel $planningModel;
|
||||
@@ -30,6 +50,10 @@ final class PlanningController
|
||||
private VacationModel $vacationModel;
|
||||
private Access $access;
|
||||
|
||||
/**
|
||||
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
|
||||
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
|
||||
*/
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->planningModel = new PlanningModel($pdo);
|
||||
@@ -41,6 +65,16 @@ final class PlanningController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/planning.php — état complet d'une semaine de saisie.
|
||||
*
|
||||
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
|
||||
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
|
||||
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
|
||||
* lecture seule pour rendre les chevauchements visibles avant l'échec.
|
||||
*
|
||||
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -79,6 +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
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -130,6 +181,12 @@ final class PlanningController
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(409, ['error' => $e->getMessage()]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
|
||||
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
|
||||
// référence documentée — vaut « inactif » par défaut. Le détail
|
||||
// technique de l'exception peut donc remonter au navigateur sur une
|
||||
// installation qui ne définit pas la variable. Le motif est répété
|
||||
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
|
||||
JsonResponse::send(500, [
|
||||
'error' => 'Erreur lors de l’enregistrement du planning.',
|
||||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||||
@@ -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
|
||||
{
|
||||
$this->access->requireDraftEditor();
|
||||
@@ -183,10 +251,16 @@ final class PlanningController
|
||||
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
|
||||
}
|
||||
|
||||
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
|
||||
// sans lui, l'heure courante serait injectée et la comparaison de
|
||||
// contrôle ci-dessous laisserait passer des dates approximatives.
|
||||
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
|
||||
// La saisie est contrainte au quart d'heure : c'est la granularité de
|
||||
// toute l'application (éditeur, cumuls, PDF).
|
||||
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
|
||||
|
||||
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
|
||||
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
|
||||
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
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
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
@@ -636,6 +810,15 @@ final class PlanningController
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
|
||||
*
|
||||
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
|
||||
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
|
||||
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
|
||||
*
|
||||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||||
*/
|
||||
private function assertNoInternalOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
@@ -659,6 +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
|
||||
{
|
||||
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
|
||||
{
|
||||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -15,6 +15,21 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* PTA détaillé d'un agent : contraintes prioritaires et souhaits de lieux.
|
||||
*
|
||||
* Ces deux référentiels décrivent ce qui DOIT ou NE DOIT PAS être planifié pour
|
||||
* un agent. Ils sont consultatifs : rien ici n'empêche techniquement une saisie
|
||||
* contraire, l'arbitrage reste à l'agent de planification.
|
||||
*
|
||||
* Les deux endpoints d'écriture sont à double action, pilotée par le champ
|
||||
* `action` de la charge utile (« SAVE » par défaut, ou « DELETE ») : c'est un
|
||||
* écart au reste de l'API, où un endpoint = une opération. La branche DELETE
|
||||
* sort par JsonResponse::send(), qui termine le script — le code de SAVE qui
|
||||
* suit n'est donc jamais atteint dans ce cas.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class PtaController
|
||||
{
|
||||
private PtaModel $ptaModel;
|
||||
@@ -30,6 +45,15 @@ final class PtaController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/pta_summary.php — synthèse du PTA d'un agent à une date donnée.
|
||||
*
|
||||
* La date sert de point d'observation : elle détermine la période de quota
|
||||
* retenue et les contraintes en vigueur ce jour-là. Elle retombe sur
|
||||
* aujourd'hui si elle n'est pas fournie.
|
||||
*
|
||||
* Paramètres GET : agent_id, date (facultative, « AAAA-MM-JJ »).
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -50,6 +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
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -115,10 +157,26 @@ final class PtaController
|
||||
]);
|
||||
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
|
||||
// navigateur SANS AUCUNE condition de debug, contrairement au reste
|
||||
// du projet. Idem dans wish(). À aligner sur
|
||||
// Security::debugEnabled() — voir la note de PlanningController::save().
|
||||
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/pta_wish.php — enregistre ou supprime un souhait de lieu.
|
||||
*
|
||||
* Deux types : PREFERENCE (l'agent souhaite y être affecté) et INTERDICTION
|
||||
* (il ne doit pas l'être). La priorité, de 1 à 5, permet d'ordonner
|
||||
* plusieurs préférences entre elles ; la distance en kilomètres sert à
|
||||
* arbitrer les affectations éloignées.
|
||||
*
|
||||
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
|
||||
* l'action wish_id, ou structure_id / type_souhait / priorite /
|
||||
* distance_km / commentaire.
|
||||
*/
|
||||
public function wish(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
@@ -12,12 +12,41 @@ use App\Models\AgentModel;
|
||||
use App\Models\StructureModel;
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$access = new Access($this->pdo);
|
||||
|
||||
@@ -17,6 +17,15 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
|
||||
*
|
||||
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
|
||||
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
|
||||
*
|
||||
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
|
||||
* modification réservées au Service Enfance.
|
||||
*/
|
||||
final class StructureController
|
||||
{
|
||||
private StructureModel $structureModel;
|
||||
@@ -32,6 +41,19 @@ final class StructureController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
|
||||
*
|
||||
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
|
||||
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
|
||||
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
|
||||
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
|
||||
*
|
||||
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
|
||||
* agent : une seule lecture, puis regroupement en mémoire.
|
||||
*
|
||||
* Paramètres GET : structure_id, week.
|
||||
*/
|
||||
public function overview(): void
|
||||
{
|
||||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||||
@@ -79,6 +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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
@@ -15,6 +15,21 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Équipes des mercredis et des vacances.
|
||||
*
|
||||
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
|
||||
* d'encadrement propres à l'accueil collectif de mineurs : effectifs d'enfants
|
||||
* par tranche d'âge, taux d'encadrement (1 agent pour 8 enfants de moins de
|
||||
* 6 ans, 1 pour 12 au-delà, par défaut) et proportion minimale d'agents
|
||||
* diplômés. Ces paramètres sont réglables par équipe, les seuils réglementaires
|
||||
* pouvant évoluer.
|
||||
*
|
||||
* Une équipe suit son propre cycle de vie, distinct de celui des plannings :
|
||||
* BROUILLON → A_CONTROLER → VALIDEE.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class TeamController
|
||||
{
|
||||
private TeamModel $teamModel;
|
||||
@@ -30,6 +45,21 @@ final class TeamController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* api/teams.php — liste (GET) ou création (POST) d'une équipe.
|
||||
*
|
||||
* Endpoint à double verbe, comme AgentController::assignments(). Le GET
|
||||
* accepte un intervalle de dates facultatif pour ne montrer que les équipes
|
||||
* actives sur une période.
|
||||
*
|
||||
* Les valeurs numériques sont bornées par max() plutôt que rejetées : un
|
||||
* effectif négatif n'a pas de sens mais ne doit pas bloquer la création, un
|
||||
* ratio à zéro provoquerait une division par zéro au calcul du taux
|
||||
* d'encadrement.
|
||||
*
|
||||
* Charge utile (POST) : structure_id, type_equipe, libelle, date_debut,
|
||||
* date_fin, effectifs et ratios facultatifs, csrf_token.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->access->requireService();
|
||||
|
||||
@@ -14,6 +14,22 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires.
|
||||
*
|
||||
* Ces périodes déterminent le contexte PÉRISCOLAIRE ou EXTRASCOLAIRE de chaque
|
||||
* journée, qui conditionne à son tour les motifs pertinents et l'application
|
||||
* filtrée des semaines types. C'est donc un référentiel structurant, pas un
|
||||
* simple calendrier d'affichage.
|
||||
*
|
||||
* L'import depuis l'API publique data.education.gouv.fr se fait en DEUX temps
|
||||
* volontairement séparés : preview() propose sans rien écrire, save() enregistre
|
||||
* après validation manuelle. Aucune donnée externe n'entre en base sans qu'un
|
||||
* humain l'ait relue — les libellés et découpages de l'API varient d'une
|
||||
* académie à l'autre.
|
||||
*
|
||||
* Réservé au Service Enfance.
|
||||
*/
|
||||
final class VacationController
|
||||
{
|
||||
private VacationModel $vacationModel;
|
||||
@@ -25,6 +41,10 @@ final class VacationController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/vacation_periods.php — périodes enregistrées.
|
||||
* Paramètre GET : school_year (facultatif, « 2026-2027 »).
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -38,6 +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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -154,12 +211,17 @@ final class VacationController
|
||||
JsonResponse::send(200, ['success' => true, 'message' => 'Période supprimée.']);
|
||||
}
|
||||
|
||||
/** Date « AAAA-MM-JJ » réellement existante (le 31/02 est rejeté). */
|
||||
private function isDate(string $value): bool
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
return $date !== false && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Année scolaire « AAAA-AAAA », dont la seconde année doit suivre
|
||||
* immédiatement la première : « 2026-2028 » est refusé.
|
||||
*/
|
||||
private function isSchoolYear(string $value): bool
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
|
||||
|
||||
@@ -19,6 +19,28 @@ use DomainException;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Semaines types : modèles lundi→vendredi rattachés à UN agent.
|
||||
*
|
||||
* Une semaine type n'est PAS un planning : elle décrit une organisation
|
||||
* générique et ne produit aucun créneau tant qu'elle n'est pas appliquée à une
|
||||
* semaine (apply) ou dupliquée sur une période (applyPeriod). Voir README_V7_10.md.
|
||||
*
|
||||
* Deux origines pour une semaine type, qui convergent vers
|
||||
* WeekTemplateModel::persistTemplate() :
|
||||
* - store() : capture d'une semaine réellement planifiée ;
|
||||
* - storeEntries() : composition directe dans l'éditeur.
|
||||
*
|
||||
* Tout est réservé au Service Enfance : un modèle porte des créneaux sur
|
||||
* plusieurs lieux, il déborde donc du périmètre d'un responsable de structure.
|
||||
*
|
||||
* Convention de codes d'erreur propre à ce contrôleur :
|
||||
* - 409 / TEMPLATE_EXISTS : un modèle porte déjà ce nom (l'interface propose
|
||||
* alors de le remplacer en renvoyant replace: true) ;
|
||||
* - 422 / TEMPLATE_INVALID : contenu du modèle refusé.
|
||||
* Le tri se fait sur le TEXTE du message d'exception (« déjà ce nom ») : toute
|
||||
* reformulation de ce message dans WeekTemplateModel casse la distinction.
|
||||
*/
|
||||
final class WeekTemplateController
|
||||
{
|
||||
private WeekTemplateModel $templateModel;
|
||||
@@ -34,6 +56,12 @@ final class WeekTemplateController
|
||||
$this->access = new Access($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/week_templates.php — liste des semaines types d'un agent.
|
||||
*
|
||||
* Ne renvoie que des compteurs, pas le contenu : la liste sert à choisir,
|
||||
* show() sert à ouvrir.
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->access->requireService();
|
||||
@@ -440,6 +536,11 @@ final class WeekTemplateController
|
||||
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
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
|
||||
|
||||
Reference in New Issue
Block a user