266 lines
12 KiB
PHP
266 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\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()]);
|
||
}
|
||
}
|
||
}
|