Ajout des commentaires dans le code
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user