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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,16 @@ final class QuotaModel extends BaseModel
*/
public const REFERENCE_MINUTES = 1607 * 60;
/**
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
*
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
* fausserait un suivi déjà entamé.
*
* @param float $rate Quotité en pourcentage (100 = temps plein).
*/
public function createForAgent(int $agentId, int $year, float $rate): void
{
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
@@ -53,6 +63,19 @@ final class QuotaModel extends BaseModel
*
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
*
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
* un brouillon consomme déjà le quota du point de vue de la planification,
* la distinction ne sert qu'à indiquer ce qui est acquis.
*
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
*
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
* ALERTE à la validation, jamais bloqué.
*
* @return array<string, mixed> Période de référence enrichie des compteurs.
*/
public function forDate(int $agentId, string $contextDate): array
{
@@ -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
{
return $this->enrich($this->forDate($agentId, $contextDate));
}
/**
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
* la période étant de toute façon l'année civile complète.
*
* @return array<string, mixed>
*/
public function annual(int $agentId, int $year): array
{
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
}
/**
* Idem annual(), enrichi des libellés.
*
* @return array<string, mixed>
*/
public function annualForApi(int $agentId, int $year): array
{
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
@@ -118,6 +158,16 @@ final class QuotaModel extends BaseModel
/**
* Période de référence PTA : toujours l'année civile.
* La date de contrat est retournée uniquement à titre informatif.
*
* Les clés `periode_contractuelle`, `periode_civile` et
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
* pouvait être ancrée sur la date de contrat. Elles sont figées
* (false / true / false) et conservées pour ne pas casser les écrans qui les
* lisent encore. Ne pas construire de nouvelle logique dessus.
*
* @return array<string, mixed>
*
* @throws InvalidArgumentException Si la date de contexte est malformée.
*/
public function referencePeriod(int $agentId, string $contextDate): array
{
@@ -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
{
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
{
$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
{
$stmt = $this->pdo->prepare(

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,26 @@ namespace App\Models;
use DateTimeImmutable;
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
{
/**
* 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
{
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
@@ -28,6 +46,22 @@ final class VacationModel extends BaseModel
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
{
if ($periods === []) {
@@ -69,6 +103,11 @@ final class VacationModel extends BaseModel
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
{
$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;
}
/**
* 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
{
$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.
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
* 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
{

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(