294 lines
12 KiB
PHP
294 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Core\Access;
|
||
use App\Core\Csrf;
|
||
use App\Core\Journal;
|
||
use App\Core\JsonResponse;
|
||
use App\Core\PdfResponse;
|
||
use App\Core\Request;
|
||
use App\Models\CoverageModel;
|
||
use App\Models\QuotaModel;
|
||
use App\Models\StructureModel;
|
||
use App\Services\PlanningPdfService;
|
||
use DateTimeImmutable;
|
||
use PDO;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Lieux d'affectation : planning hebdomadaire par lieu et référentiel.
|
||
*
|
||
* Vue complémentaire de celle par agent : ici on regarde QUI est présent sur un
|
||
* lieu, là on regarde OÙ va un agent. Les deux lisent les mêmes créneaux.
|
||
*
|
||
* Consultation ouverte aux trois rôles dans leur périmètre ; création et
|
||
* modification réservées au Service Enfance.
|
||
*/
|
||
final class StructureController
|
||
{
|
||
private StructureModel $structureModel;
|
||
private QuotaModel $quotaModel;
|
||
private CoverageModel $coverageModel;
|
||
private Access $access;
|
||
|
||
public function __construct(private PDO $pdo)
|
||
{
|
||
$this->structureModel = new StructureModel($pdo);
|
||
$this->quotaModel = new QuotaModel($pdo);
|
||
$this->coverageModel = new CoverageModel($pdo);
|
||
$this->access = new Access($pdo);
|
||
}
|
||
|
||
/**
|
||
* GET api/structure_overview.php — semaine d'un lieu, agent par agent.
|
||
*
|
||
* La liste d'agents ne se limite PAS aux agents rattachés au lieu : elle
|
||
* inclut ceux qui y sont ponctuellement planifiés, distingués par le drapeau
|
||
* `lieu_principal`. Un agent peut travailler sur plusieurs lieux la même
|
||
* semaine, et l'omettre donnerait une vue fausse de l'effectif présent.
|
||
*
|
||
* Les créneaux sont indexés par agent en PHP plutôt que par une requête par
|
||
* agent : une seule lecture, puis regroupement en mémoire.
|
||
*
|
||
* Paramètres GET : structure_id, week.
|
||
*/
|
||
public function overview(): void
|
||
{
|
||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||
$weekValue = trim((string) ($_GET['week'] ?? ''));
|
||
$week = $this->parseWeek($weekValue);
|
||
|
||
if (!$structureId || $week === null) {
|
||
JsonResponse::send(400, ['error' => 'Lieu d’affectation ou semaine invalide.']);
|
||
}
|
||
$this->access->requireStructureView((int) $structureId);
|
||
|
||
$structure = $this->structureModel->findActive((int) $structureId);
|
||
if ($structure === null) {
|
||
JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']);
|
||
}
|
||
|
||
[$year, $weekNumber] = $week;
|
||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
|
||
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
|
||
$entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber);
|
||
$entriesByAgent = [];
|
||
foreach ($entries as $entry) {
|
||
$entriesByAgent[(int) $entry['id_agent']][] = $entry;
|
||
}
|
||
|
||
$agents = [];
|
||
foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) {
|
||
$agentId = (int) $agent['id_agent'];
|
||
$agents[] = [
|
||
'id_agent' => $agentId,
|
||
'matricule' => $agent['matricule'],
|
||
'nom' => $agent['nom'],
|
||
'prenom' => $agent['prenom'],
|
||
'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId,
|
||
'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart),
|
||
'entries' => $entriesByAgent[$agentId] ?? [],
|
||
];
|
||
}
|
||
|
||
JsonResponse::send(200, [
|
||
'structure' => $structure,
|
||
'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd],
|
||
'agents' => $agents,
|
||
'coverage' => $this->coverageModel->reportForStructure((int) $structureId, $year, $weekNumber),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* GET api/structure_planning_pdf.php — même vue, au format PDF.
|
||
*
|
||
* Reprend volontairement le calcul de overview() plutôt que de le
|
||
* factoriser : les deux méthodes doivent rester alignées, toute évolution
|
||
* de l'une appelle la même dans l'autre.
|
||
*
|
||
* Endpoint PDF : les erreurs sortent en texte brut avec un code HTTP, jamais
|
||
* en JSON (voir AgentController).
|
||
*/
|
||
public function pdf(): void
|
||
{
|
||
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
|
||
$weekValue = trim((string) ($_GET['week'] ?? ''));
|
||
$week = $this->parseWeek($weekValue);
|
||
|
||
if (!$structureId || $week === null) {
|
||
http_response_code(400);
|
||
echo 'Lieu d’affectation ou semaine invalide.';
|
||
return;
|
||
}
|
||
$this->access->requireStructureView((int) $structureId);
|
||
|
||
$structure = $this->structureModel->findActive((int) $structureId);
|
||
if ($structure === null) {
|
||
http_response_code(404);
|
||
echo 'Lieu d’affectation introuvable.';
|
||
return;
|
||
}
|
||
|
||
[$year, $weekNumber] = $week;
|
||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
|
||
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
|
||
$weekData = ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd];
|
||
|
||
$entries = $this->structureModel->entriesForOverview((int) $structureId, $year, $weekNumber);
|
||
$entriesByAgent = [];
|
||
foreach ($entries as $entry) {
|
||
$entriesByAgent[(int) $entry['id_agent']][] = $entry;
|
||
}
|
||
|
||
$agents = [];
|
||
foreach ($this->structureModel->agentsForOverview((int) $structureId, $year, $weekNumber) as $agent) {
|
||
$agentId = (int) $agent['id_agent'];
|
||
$agents[] = [
|
||
'id_agent' => $agentId,
|
||
'matricule' => $agent['matricule'],
|
||
'nom' => $agent['nom'],
|
||
'prenom' => $agent['prenom'],
|
||
'lieu_principal' => (int) ($agent['id_structure_principale'] ?? 0) === (int) $structureId,
|
||
'quota' => $this->quotaModel->forDateForApi($agentId, $weekStart),
|
||
'entries' => $entriesByAgent[$agentId] ?? [],
|
||
];
|
||
}
|
||
|
||
$pdf = (new PlanningPdfService())->structureWeek($structure, $agents, $weekData);
|
||
PdfResponse::inline(
|
||
$pdf,
|
||
sprintf('planning_lieu_%s_%s.pdf', $structure['code'] ?: $structure['id_structure'], str_replace('-W', '_S', $weekValue))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* POST api/create_structure.php — création d'un lieu d'affectation.
|
||
*
|
||
* Le code est normalisé avant contrôle : espaces remplacés par des
|
||
* underscores, puis passage en majuscules. Il sert d'identifiant lisible
|
||
* (nom de fichier PDF, exports), d'où le format restreint à
|
||
* [A-Z0-9_-]{2,50}. Un doublon ressort en 409, pas en 422 : c'est un conflit
|
||
* avec l'existant.
|
||
*
|
||
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) détermine le contexte
|
||
* par défaut du lieu ; il reste croisé avec le calendrier des vacances.
|
||
*
|
||
* Charge utile : code, nom, adresse, type_affectation, csrf_token.
|
||
*/
|
||
public function create(): void
|
||
{
|
||
$this->access->requireService();
|
||
Request::requireMethod('POST');
|
||
$payload = Request::json();
|
||
Csrf::assertPayload($payload);
|
||
|
||
$rawCode = trim((string) ($payload['code'] ?? ''));
|
||
$code = strtoupper((string) preg_replace('/\s+/', '_', $rawCode));
|
||
$name = trim((string) ($payload['nom'] ?? ''));
|
||
$address = trim((string) ($payload['adresse'] ?? ''));
|
||
$typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? '')));
|
||
|
||
if ($code === '' || $name === '') {
|
||
JsonResponse::send(422, ['error' => 'Le code et le nom du lieu d’affectation sont obligatoires.']);
|
||
}
|
||
if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) {
|
||
JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']);
|
||
}
|
||
if (!preg_match('/^[A-Z0-9_-]{2,50}$/', $code)) {
|
||
JsonResponse::send(422, ['error' => 'Le code doit contenir uniquement des lettres, chiffres, tirets ou underscores.']);
|
||
}
|
||
if (strlen($name) > 150 || strlen($address) > 255) {
|
||
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
|
||
}
|
||
if ($this->structureModel->codeExists($code)) {
|
||
JsonResponse::send(409, ['error' => 'Un lieu d’affectation utilise déjà ce code.']);
|
||
}
|
||
|
||
try {
|
||
$structure = $this->structureModel->create($code, $name, $address !== '' ? $address : null, $typeAffectation);
|
||
} catch (Throwable $e) {
|
||
JsonResponse::send(500, [
|
||
'error' => 'Impossible de créer le lieu d’affectation.',
|
||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||
]);
|
||
}
|
||
|
||
Journal::ecriture(
|
||
'STRUCTURE_CREEE',
|
||
'structure',
|
||
isset($structure['id_structure']) ? (int) $structure['id_structure'] : null,
|
||
sprintf('Création du lieu d’affectation « %s » (%s).', $name, $code),
|
||
['code' => $code, 'type_affectation' => $typeAffectation]
|
||
);
|
||
|
||
JsonResponse::send(201, [
|
||
'message' => sprintf('Le lieu d’affectation « %s » a été ajouté.', $name),
|
||
'structure' => $structure,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST api/update_structure.php — change le type d'un lieu.
|
||
*
|
||
* Seul le type est modifiable : le code sert de référence stable, et le nom
|
||
* n'est pas exposé à l'édition dans cette version.
|
||
*
|
||
* Charge utile : structure_id, type_affectation, csrf_token.
|
||
*/
|
||
public function updateType(): void
|
||
{
|
||
$this->access->requireService();
|
||
Request::requireMethod('POST');
|
||
$payload = Request::json();
|
||
Csrf::assertPayload($payload);
|
||
|
||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
|
||
$typeAffectation = strtoupper(trim((string) ($payload['type_affectation'] ?? '')));
|
||
|
||
if (!$structureId) {
|
||
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide.']);
|
||
}
|
||
if (!in_array($typeAffectation, ['PERISCOLAIRE', 'EXTRASCOLAIRE'], true)) {
|
||
JsonResponse::send(422, ['error' => 'Le type du lieu doit être Périscolaire ou Extrascolaire.']);
|
||
}
|
||
$structure = $this->structureModel->findActive((int) $structureId);
|
||
if ($structure === null) {
|
||
JsonResponse::send(404, ['error' => 'Lieu d’affectation introuvable.']);
|
||
}
|
||
|
||
try {
|
||
$this->structureModel->updateType((int) $structureId, $typeAffectation);
|
||
} catch (Throwable $e) {
|
||
JsonResponse::send(500, [
|
||
'error' => 'Impossible de modifier le type du lieu d’affectation.',
|
||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||
]);
|
||
}
|
||
|
||
JsonResponse::send(200, [
|
||
'message' => sprintf('Le type du lieu « %s » a été mis à jour.', $structure['nom']),
|
||
'structure' => [
|
||
'id_structure' => (int) $structureId,
|
||
'type_affectation' => $typeAffectation,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|