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, ]); } 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; } }