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

@@ -20,6 +20,26 @@ use DomainException;
use PDO;
use Throwable;
/**
* Saisie, contrôle et validation des plannings hebdomadaires.
*
* C'est le contrôleur central de l'application : un `planning` = un agent × une
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
* `motif_planning`.
*
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
* le SQL correspondant est du patrimoine, pas la source de vérité.
*
* Forme commune des contrôles renvoyés au client, partout dans l'application :
* {niveau: ERREUR|ALERTE|INFO, code, message}
* — ERREUR bloque (HTTP 422) ;
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
* force_quota: true) ;
* — INFO est purement informatif.
*/
final class PlanningController
{
private PlanningModel $planningModel;
@@ -30,6 +50,10 @@ final class PlanningController
private VacationModel $vacationModel;
private Access $access;
/**
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
*/
public function __construct(private PDO $pdo)
{
$this->planningModel = new PlanningModel($pdo);
@@ -41,6 +65,16 @@ final class PlanningController
$this->access = new Access($pdo);
}
/**
* GET api/planning.php — état complet d'une semaine de saisie.
*
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
* lecture seule pour rendre les chevauchements visibles avant l'échec.
*
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
*/
public function show(): void
{
$this->access->requireDraftEditor();
@@ -79,6 +113,23 @@ final class PlanningController
]);
}
/**
* POST save_planning.php — enregistre le brouillon d'une semaine.
*
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
*
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
* explicite au plus générique :
* 1. droits et périmètre ;
* 2. motifs fermés à l'agent (message nommant le motif) ;
* 3. normalisation/validation ligne à ligne ;
* 4. chevauchements internes à la saisie ;
* 5. chevauchements avec les autres lieux.
*
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
*/
public function save(): void
{
$this->access->requireDraftEditor();
@@ -130,6 +181,12 @@ final class PlanningController
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
// référence documentée — vaut « inactif » par défaut. Le détail
// technique de l'exception peut donc remonter au navigateur sur une
// installation qui ne définit pas la variable. Le motif est répété
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
JsonResponse::send(500, [
'error' => 'Erreur lors de l’enregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
@@ -147,6 +204,17 @@ final class PlanningController
]);
}
/**
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
*
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
* validation intacte.
*
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
*/
public function updateAgentEntry(): void
{
$this->access->requireDraftEditor();
@@ -183,10 +251,16 @@ final class PlanningController
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
}
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
// sans lui, l'heure courante serait injectée et la comparaison de
// contrôle ci-dessous laisserait passer des dates approximatives.
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
// La saisie est contrainte au quart d'heure : c'est la granularité de
// toute l'application (éditeur, cumuls, PDF).
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts d’heure.']);
}
@@ -221,6 +295,16 @@ final class PlanningController
}
/**
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
*
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
* l'interface de le signaler, mais l'enregistrement reste et conserve son
* historique.
*
* Charge utile : entry_id, csrf_token.
*/
public function deleteAgentEntry(): void
{
$this->access->requireDraftEditor();
@@ -263,6 +347,16 @@ final class PlanningController
}
/**
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
*
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
* l'indique sans alarmer.
*
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
*/
public function moveOrDuplicateEntry(): void
{
$this->access->requireDraftEditor();
@@ -325,6 +419,18 @@ final class PlanningController
]);
}
/**
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
*
* Réservé au Service Enfance (requireService) et non aux responsables : la
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
* déborde donc par nature du périmètre d'un responsable de structure.
*
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
* `replace` les remplace.
*
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
*/
public function copyAgentWeek(): void
{
$this->access->requireService();
@@ -384,6 +490,14 @@ final class PlanningController
]);
}
/**
* GET api/pending_plannings.php — file d'attente de validation.
*
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
* (`total_minutes`) des minutes réellement décomptées du quota
* (`counted_minutes`) : congés et absences non décomptées figurent au
* planning sans peser sur le quota annuel.
*/
public function pendingValidation(): void
{
$this->access->requireService();
@@ -407,6 +521,31 @@ final class PlanningController
]);
}
/**
* POST validate_planning.php — contrôle puis valide un planning.
*
* C'est ICI que vivent les règles de validation réellement appliquées (et
* non dans sp_valider_planning côté SQL, jamais appelée).
*
* Quatre contrôles bloquants (ERREUR → 422, aucune validation) :
* - AUCUN_CRENEAU : planning vide ;
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
* un brouillon concurrent peut encore être corrigé, le bloquer
* empêcherait toute validation dans un ordre donné.
*
* Un contrôle non bloquant (ALERTE → 409) :
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
* possible, mais exige un rappel explicite avec force_quota: true. Le
* dépassement est une décision de gestion, pas une anomalie technique.
*
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
*
* Charge utile : planning_id, force_quota (bool), csrf_token.
*/
public function validate(): void
{
$this->access->requireService();
@@ -513,6 +652,17 @@ final class PlanningController
]);
}
/**
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
*
* Réouverture volontaire, à distinguer de la réouverture automatique
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
* trompeur.
*
* Charge utile : planning_id, csrf_token.
*/
public function reopen(): void
{
$this->access->requireDraftEditor();
@@ -545,6 +695,14 @@ final class PlanningController
]);
}
/**
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
*
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
* comptent bien une semaine 53.
*
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
*/
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
@@ -602,6 +760,22 @@ final class PlanningController
}
}
/**
* Valide et met en forme les créneaux reçus du client.
*
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
* motif appartenant à la liste ouverte à CET agent, horaires au quart
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
*
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
*
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
*
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
*/
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
@@ -636,6 +810,15 @@ final class PlanningController
return $normalized;
}
/**
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
*
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
*/
private function assertNoInternalOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
@@ -659,6 +842,20 @@ final class PlanningController
}
}
/**
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
*
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
* un agent ne peut pas être à deux endroits à la fois, même en cours de
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
* puisse nommer le lieu et l'horaire en cause.
*
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
* être comparables aux créneaux normalisés.
*
* @param array<int, array{date: string, start: string, end: string}> $entries
* @param array<int, array<string, mixed>> $existingEntries
*/
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{
foreach ($entries as $entry) {
@@ -689,6 +886,12 @@ final class PlanningController
}
}
/**
* Empile un contrôle au format commun {niveau, code, message}.
*
* @param array<int, array{niveau: string, code: string, message: string}> $controls
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
*/
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];