Ajout des commentaires dans le code

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

View File

@@ -8,8 +8,42 @@ use DateTimeImmutable;
use DomainException;
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
{
/**
* 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
{
$stmt = $this->pdo->prepare(
@@ -26,6 +60,15 @@ final class WeekTemplateModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -38,6 +81,15 @@ final class WeekTemplateModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -60,6 +112,20 @@ final class WeekTemplateModel extends BaseModel
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
{
// 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 int[] $allowedMotifIds motifs affectables à cet agent
* @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(
int $agentId,
@@ -104,6 +175,18 @@ final class WeekTemplateModel extends BaseModel
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
{
$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 int[] $allowedMotifIds
* @param int[] $allowedStructureIds
* @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
{
@@ -182,6 +279,16 @@ final class WeekTemplateModel extends BaseModel
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
{
$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
* 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, 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
{
@@ -227,7 +339,23 @@ final class WeekTemplateModel extends BaseModel
* É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.
*
* 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
*
* @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
{
@@ -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
{
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(
int $templateId,
int $agentId,
@@ -461,6 +625,26 @@ final class WeekTemplateModel extends BaseModel
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(
int $templateId,
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
{
$stmt = $this->pdo->prepare(
@@ -655,6 +847,16 @@ final class WeekTemplateModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -680,6 +882,39 @@ final class WeekTemplateModel extends BaseModel
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(
int $templateId,
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
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
@@ -872,6 +1118,16 @@ final class WeekTemplateModel extends BaseModel
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
{
foreach ($periods as $period) {
@@ -882,6 +1138,16 @@ final class WeekTemplateModel extends BaseModel
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
{
$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();
}
/**
* 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
{
$conflicts = [];
@@ -932,6 +1209,15 @@ final class WeekTemplateModel extends BaseModel
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
{
$stmt = $this->pdo->prepare(
@@ -959,6 +1245,18 @@ final class WeekTemplateModel extends BaseModel
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
{
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
{
$stmt = $this->pdo->prepare(