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