Files
pta/app/Controllers/PtaController.php
2026-08-20 17:00:06 +02:00

266 lines
12 KiB
PHP
Raw Permalink 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\Journal;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\PtaModel;
use App\Models\StructureModel;
use DateTimeImmutable;
use PDO;
use Throwable;
/**
* PTA détaillé d'un agent : contraintes prioritaires et souhaits de lieux.
*
* Ces deux référentiels décrivent ce qui DOIT ou NE DOIT PAS être planifié pour
* un agent. Ils sont consultatifs : rien ici n'empêche techniquement une saisie
* contraire, l'arbitrage reste à l'agent de planification.
*
* Les deux endpoints d'écriture sont à double action, pilotée par le champ
* `action` de la charge utile (« SAVE » par défaut, ou « DELETE ») : c'est un
* écart au reste de l'API, où un endpoint = une opération. La branche DELETE
* sort par JsonResponse::send(), qui termine le script — le code de SAVE qui
* suit n'est donc jamais atteint dans ce cas.
*
* Réservé au Service Enfance.
*/
final class PtaController
{
private PtaModel $ptaModel;
private AgentModel $agentModel;
private StructureModel $structureModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->ptaModel = new PtaModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->access = new Access($pdo);
}
/**
* GET api/pta_summary.php — synthèse du PTA d'un agent à une date donnée.
*
* La date sert de point d'observation : elle détermine la période de quota
* retenue et les contraintes en vigueur ce jour-là. Elle retombe sur
* aujourd'hui si elle n'est pas fournie.
*
* Paramètres GET : agent_id, date (facultative, « AAAA-MM-JJ »).
*/
public function summary(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$date = trim((string) ($_GET['date'] ?? date('Y-m-d')));
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if (!$agentId || !$parsed || $parsed->format('Y-m-d') !== $date) {
JsonResponse::send(422, ['error' => 'Agent ou date de référence invalide.']);
}
try {
JsonResponse::send(200, $this->ptaModel->summary((int) $agentId, $date));
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de calculer le PTA de l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
/**
* POST api/pta_constraint.php — enregistre ou supprime une contrainte.
*
* Deux types seulement, volontairement fermés : MEDICALE et
* TEMPS_PARTIEL_THERAPEUTIQUE. Une date de fin vide signifie « sans terme
* connu », cas courant d'un aménagement médical en cours.
*
* Le commentaire est OBLIGATOIRE : une contrainte restreint la planification
* d'un agent, elle doit rester justifiable a posteriori.
*
* Les maximums arrivent en HEURES depuis le formulaire et sont convertis en
* MINUTES pour la base, unité de tous les cumuls de l'application.
*
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
* l'action constraint_id, ou type_contrainte / date_debut / date_fin /
* commentaire / quotite_temporaire / maximum_heures_jour /
* maximum_heures_semaine / interdit_matin|midi|soir.
*/
public function constraint(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE')));
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($action === 'DELETE') {
$constraintId = filter_var($payload['constraint_id'] ?? null, FILTER_VALIDATE_INT);
if (!$constraintId) {
JsonResponse::send(422, ['error' => 'Contrainte invalide.']);
}
$deleted = $this->ptaModel->deleteConstraint((int) $constraintId, (int) $agentId);
if ($deleted) {
Journal::ecriture(
'CONTRAINTE_SUPPRIMEE',
'agent_contrainte',
(int) $constraintId,
sprintf('Contrainte prioritaire #%d supprimée pour l’agent #%d.', (int) $constraintId, (int) $agentId),
['id_agent' => (int) $agentId]
);
}
JsonResponse::send($deleted ? 200 : 404, [
'message' => $deleted ? 'La contrainte a été supprimée.' : 'Contrainte introuvable.',
]);
}
$type = strtoupper(trim((string) ($payload['type_contrainte'] ?? '')));
$start = trim((string) ($payload['date_debut'] ?? ''));
$end = trim((string) ($payload['date_fin'] ?? ''));
$comment = trim((string) ($payload['commentaire'] ?? ''));
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $start);
$endDate = $end !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $end) : null;
if (!in_array($type, ['MEDICALE', 'TEMPS_PARTIEL_THERAPEUTIQUE'], true)
|| !$startDate || $startDate->format('Y-m-d') !== $start
|| ($end !== '' && (!$endDate || $endDate->format('Y-m-d') !== $end || $endDate < $startDate))
|| $comment === '') {
JsonResponse::send(422, ['error' => 'Type, dates ou commentaire de contrainte invalides.']);
}
$rate = $payload['quotite_temporaire'] ?? null;
$rate = ($rate === null || $rate === '') ? null : filter_var($rate, FILTER_VALIDATE_FLOAT);
if ($rate !== null && ($rate === false || $rate <= 0 || $rate > 100)) {
JsonResponse::send(422, ['error' => 'La quotité temporaire doit être comprise entre 1 et 100 %.']);
}
$maxDayHours = $payload['maximum_heures_jour'] ?? null;
$maxWeekHours = $payload['maximum_heures_semaine'] ?? null;
$maxDayMinutes = ($maxDayHours === null || $maxDayHours === '') ? null : (int) round((float) $maxDayHours * 60);
$maxWeekMinutes = ($maxWeekHours === null || $maxWeekHours === '') ? null : (int) round((float) $maxWeekHours * 60);
try {
$id = $this->ptaModel->saveConstraint([
'id_agent' => (int) $agentId,
'type_contrainte' => $type,
'date_debut' => $start,
'date_fin' => $end !== '' ? $end : null,
'quotite_temporaire' => $rate,
'maximum_minutes_jour' => $maxDayMinutes,
'maximum_minutes_semaine' => $maxWeekMinutes,
'interdit_matin' => !empty($payload['interdit_matin']) ? 1 : 0,
'interdit_midi' => !empty($payload['interdit_midi']) ? 1 : 0,
'interdit_soir' => !empty($payload['interdit_soir']) ? 1 : 0,
'commentaire' => $comment,
]);
Journal::ecriture(
'CONTRAINTE_ENREGISTREE',
'agent_contrainte',
(int) $id,
sprintf('Contrainte « %s » enregistrée pour l’agent #%d à partir du %s.', $type, (int) $agentId, $start),
['id_agent' => (int) $agentId, 'type_contrainte' => $type, 'date_debut' => $start, 'date_fin' => $end]
);
JsonResponse::send(201, ['message' => 'La contrainte prioritaire a été enregistrée.', 'id_contrainte' => $id]);
} catch (Throwable $e) {
// INCOHÉRENCE CONNUE : le détail de l'exception est renvoyé au
// navigateur SANS AUCUNE condition de debug, contrairement au reste
// du projet. Idem dans wish(). À aligner sur
// Security::debugEnabled() — voir la note de PlanningController::save().
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la contrainte.', 'details' => $e->getMessage()]);
}
}
/**
* POST api/pta_wish.php — enregistre ou supprime un souhait de lieu.
*
* Deux types : PREFERENCE (l'agent souhaite y être affecté) et INTERDICTION
* (il ne doit pas l'être). La priorité, de 1 à 5, permet d'ordonner
* plusieurs préférences entre elles ; la distance en kilomètres sert à
* arbitrer les affectations éloignées.
*
* Charge utile : action (SAVE|DELETE), agent_id, csrf_token, puis selon
* l'action wish_id, ou structure_id / type_souhait / priorite /
* distance_km / commentaire.
*/
public function wish(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$action = strtoupper(trim((string) ($payload['action'] ?? 'SAVE')));
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
if (!$agentId || $this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($action === 'DELETE') {
$wishId = filter_var($payload['wish_id'] ?? null, FILTER_VALIDATE_INT);
if (!$wishId) {
JsonResponse::send(422, ['error' => 'Préférence invalide.']);
}
$deleted = $this->ptaModel->deleteWish((int) $wishId, (int) $agentId);
if ($deleted) {
Journal::ecriture(
'SOUHAIT_SUPPRIME',
'agent_structure_souhait',
(int) $wishId,
sprintf('Préférence de lieu #%d supprimée pour l’agent #%d.', (int) $wishId, (int) $agentId),
['id_agent' => (int) $agentId]
);
}
JsonResponse::send($deleted ? 200 : 404, [
'message' => $deleted ? 'La préférence ou interdiction a été supprimée.' : 'Élément introuvable.',
]);
}
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$type = strtoupper(trim((string) ($payload['type_souhait'] ?? '')));
$priority = filter_var($payload['priorite'] ?? 1, FILTER_VALIDATE_INT);
$distance = $payload['distance_km'] ?? null;
$distance = ($distance === null || $distance === '') ? null : filter_var($distance, FILTER_VALIDATE_FLOAT);
$comment = trim((string) ($payload['commentaire'] ?? ''));
if (!$structureId || $this->structureModel->findActive((int) $structureId) === null
|| !in_array($type, ['PREFERENCE', 'INTERDICTION'], true)
|| !$priority || $priority < 1 || $priority > 5
|| ($distance !== null && ($distance === false || $distance < 0))) {
JsonResponse::send(422, ['error' => 'Lieu, type, priorité ou distance invalide.']);
}
try {
$id = $this->ptaModel->saveWish([
'id_agent' => (int) $agentId,
'id_structure' => (int) $structureId,
'type_souhait' => $type,
'priorite' => (int) $priority,
'distance_km' => $distance,
'commentaire' => $comment !== '' ? $comment : null,
]);
Journal::ecriture(
'SOUHAIT_ENREGISTRE',
'agent_structure_souhait',
(int) $id,
sprintf('Préférence « %s » enregistrée pour l’agent #%d sur le lieu #%d.', $type, (int) $agentId, (int) $structureId),
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'type_souhait' => $type]
);
JsonResponse::send(201, ['message' => 'La préférence de lieu a été enregistrée.', 'id_souhait' => $id]);
} catch (Throwable $e) {
JsonResponse::send(500, ['error' => 'Impossible d’enregistrer la préférence.', 'details' => $e->getMessage()]);
}
}
}