pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
<?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;
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);
}
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),
],
]);
}
public function rules(): void
{
$this->access->requireService();
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
if (!$structureId) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide.']);
}
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
JsonResponse::send(200, [
'structure' => $structure,
'rules' => $this->coverageModel->rulesForStructure((int) $structureId),
]);
}
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 daffectation 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 dagents 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 denregistrer 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),
]);
}
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;
}
}
}
private function validQuarterHour(string $time): bool
{
if (!preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time)) {
return false;
}
return true;
}
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function durationMinutes(string $start, string $end): int
{
return max(0, $this->timeToMinutes($end) - $this->timeToMinutes($start));
}
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;
}
}