Files
pta/app/Controllers/CoverageController.php
2026-08-20 13:22:49 +02:00

296 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\CoverageModel;
use App\Models\StructureModel;
use PDO;
use Throwable;
/**
* Contrôle de couverture : effectif planifié comparé au minimum attendu.
*
* Chaque lieu peut déclarer des règles de couverture (jour, plage horaire,
* nombre minimum d'agents). Ce contrôleur confronte ces règles aux plannings
* réels et remonte les « trous ».
*
* Deux niveaux de droits distincts :
* - alerts() : responsable et Service Enfance, un responsable étant forcé sur
* son propre lieu quoi qu'il demande ;
* - rules() / saveRules() : Service Enfance seul, les règles étant un
* référentiel et non une donnée de saisie.
*/
final class CoverageController
{
private CoverageModel $coverageModel;
private StructureModel $structureModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->coverageModel = new CoverageModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->access = new Access($pdo);
}
/**
* GET api/coverage_alerts.php — trous de couverture d'une semaine.
*
* Sans structure_id, balaie TOUS les lieux actifs. Le structure_id demandé
* est ignoré pour un responsable, écrasé par son propre lieu : un simple 403
* l'empêcherait de consulter son écran avec une URL partagée.
*
* Trois familles de retours, à ne pas confondre :
* - `alerts` : plages réellement sous-couvertes ;
* - `validation_warnings` : couverture assurée par des plannings encore en
* brouillon, donc pas encore fiable ;
* - `unconfigured_structures` : lieux sans aucune règle. Ils ne peuvent pas
* produire d'alerte, et les taire les ferait passer pour conformes.
*
* Paramètres GET : week, structure_id (facultatif).
*/
public function alerts(): void
{
$this->access->requireRoles([Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE]);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
if ($this->access->role() === Access::ROLE_RESPONSABLE) {
$structureId = $this->access->structureId();
}
if ($week === null) {
JsonResponse::send(400, ['error' => 'Semaine invalide.']);
}
[$year, $weekNumber] = $week;
$structures = $this->structureModel->allActive();
if ($structureId) {
$structures = array_values(array_filter(
$structures,
static fn (array $structure): bool => (int) $structure['id_structure'] === (int) $structureId
));
}
$alerts = [];
$validationWarnings = [];
$unconfigured = [];
$affectedStructures = [];
$gapMinutes = 0;
foreach ($structures as $structure) {
$id = (int) $structure['id_structure'];
$report = $this->coverageModel->reportForStructure($id, $year, $weekNumber);
if (!$report['configured']) {
$unconfigured[] = [
'id_structure' => $id,
'nom' => $structure['nom'],
'code' => $structure['code'],
];
continue;
}
foreach ($report['gaps'] as $gap) {
$gap['id_structure'] = $id;
$gap['structure_nom'] = $structure['nom'];
$gap['structure_code'] = $structure['code'];
$gap['structure_type_affectation'] = $structure['type_affectation'];
$alerts[] = $gap;
$affectedStructures[$id] = true;
$gapMinutes += $this->durationMinutes($gap['heure_debut'], $gap['heure_fin']);
}
foreach ($report['validation_warnings'] as $warning) {
$warning['id_structure'] = $id;
$warning['structure_nom'] = $structure['nom'];
$validationWarnings[] = $warning;
}
}
JsonResponse::send(200, [
'week' => $weekValue,
'alerts' => $alerts,
'validation_warnings' => $validationWarnings,
'unconfigured_structures' => $unconfigured,
'summary' => [
'gap_count' => count($alerts),
'affected_structure_count' => count($affectedStructures),
'gap_minutes' => $gapMinutes,
'validation_warning_count' => count($validationWarnings),
'unconfigured_structure_count' => count($unconfigured),
],
]);
}
/**
* GET api/structure_coverage_rules.php — règles de couverture d'un lieu.
*/
public function rules(): void
{
$this->access->requireService();
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
if (!$structureId) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide.']);
}
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']);
}
JsonResponse::send(200, [
'structure' => $structure,
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
]);
}
/**
* POST api/save_structure_coverage_rules.php — remplace les règles d'un lieu.
*
* Remplacement INTÉGRAL : la liste reçue devient l'unique jeu de règles du
* lieu, et une liste vide supprime toute règle (le lieu redevient « non
* configuré »). Il n'y a pas d'ajout ni de suppression unitaire.
*
* Les règles sont validées ici — jour ouvré, horaires au quart d'heure, fin
* après début, minimum dans [1, 50], puis absence de chevauchement — avant
* d'être confiées au modèle, qui n'effectue aucun contrôle métier.
*
* Charge utile : structure_id, rules[], csrf_token.
*/
public function saveRules(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$rawRules = $payload['rules'] ?? null;
if (!$structureId || !is_array($rawRules)) {
JsonResponse::send(422, ['error' => 'Paramètres de couverture invalides.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']);
}
$rules = [];
foreach ($rawRules as $index => $rawRule) {
if (!is_array($rawRule)) {
JsonResponse::send(422, ['error' => 'Une règle de couverture est invalide.']);
}
$day = filter_var($rawRule['jour_semaine'] ?? null, FILTER_VALIDATE_INT);
$start = trim((string) ($rawRule['heure_debut'] ?? ''));
$end = trim((string) ($rawRule['heure_fin'] ?? ''));
$minimum = filter_var($rawRule['minimum_agents'] ?? 1, FILTER_VALIDATE_INT);
if (!$day || $day < 1 || $day > 5 || !$this->validQuarterHour($start) || !$this->validQuarterHour($end)) {
JsonResponse::send(422, ['error' => sprintf('La règle de couverture n°%d contient un jour ou un horaire invalide.', $index + 1)]);
}
if ($this->timeToMinutes($end) <= $this->timeToMinutes($start)) {
JsonResponse::send(422, ['error' => sprintf('La fin doit être postérieure au début pour la règle n°%d.', $index + 1)]);
}
if (!$minimum || $minimum < 1 || $minimum > 50) {
JsonResponse::send(422, ['error' => 'Le nombre minimum d’agents doit être compris entre 1 et 50.']);
}
$rules[] = [
'jour_semaine' => (int) $day,
'heure_debut' => $start,
'heure_fin' => $end,
'minimum_agents' => (int) $minimum,
];
}
$this->assertNoOverlap($rules);
try {
$this->coverageModel->replaceRules((int) $structureId, $rules);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer les horaires de couverture.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'message' => 'Les horaires de couverture du lieu ont été enregistrés.',
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
]);
}
/**
* Refuse deux plages de couverture qui se recouvrent le même jour.
*
* Les règles sont regroupées par jour puis triées : il suffit alors de
* comparer chaque début à la fin précédente, en O(n log n) et non en O(n²).
* Des plages jointives sont acceptées (une fin égale à un début).
*
* @param array<int, array{jour_semaine: int, heure_debut: string, heure_fin: string}> $rules
*/
private function assertNoOverlap(array $rules): void
{
$byDay = [];
foreach ($rules as $rule) {
$byDay[(int) $rule['jour_semaine']][] = $rule;
}
foreach ($byDay as $day => $dayRules) {
usort($dayRules, fn (array $a, array $b): int => $this->timeToMinutes($a['heure_debut']) <=> $this->timeToMinutes($b['heure_debut']));
$previousEnd = null;
foreach ($dayRules as $rule) {
$start = $this->timeToMinutes($rule['heure_debut']);
$end = $this->timeToMinutes($rule['heure_fin']);
if ($previousEnd !== null && $start < $previousEnd) {
JsonResponse::send(422, ['error' => sprintf('Deux plages de couverture se chevauchent pour le jour %d.', $day)]);
}
$previousEnd = $end;
}
}
}
/** HH:MM au quart d'heure, entre 00:00 et 23:45. */
private function validQuarterHour(string $time): bool
{
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
return false;
}
return true;
}
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
/** Durée en minutes, plancher à 0 : jamais de total négatif dans les cumuls. */
private function durationMinutes(string $start, string $end): int
{
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
}
/**
* Décode « AAAA-Www ». Copie locale, comme dans les autres contrôleurs.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null;
}
}