296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|