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

@@ -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(