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

294 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\AnnualOverviewModel;
use App\Models\PlanningModel;
use App\Models\QuotaModel;
use PDO;
use Throwable;
/**
* Vue annuelle d'un agent et validation de l'année en une opération.
*
* L'année de référence est l'année CIVILE (01/01 → 31/12), jamais l'année
* scolaire ni la date d'anniversaire du contrat : c'est la période sur laquelle
* s'apprécie le quota annualisé (voir QuotaModel).
*
* Réservé au Service Enfance : valider une année couvre par nature tous les
* lieux où l'agent est intervenu.
*/
final class AnnualOverviewController
{
public function __construct(private PDO $pdo)
{
}
/**
* GET api/annual_overview.php — répartition de l'année d'un agent.
*
* L'année est facultative et retombe sur l'année en cours.
* Paramètres GET : agent_id, year (facultatif, borné à [2000, 2100]).
*/
public function overview(): void
{
(new Access($this->pdo))->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
$year ??= (int) date('Y');
if (!$agentId || $year < 2000 || $year > 2100) {
JsonResponse::send(422, ['error' => 'Agent ou année invalide.']);
}
try {
JsonResponse::send(200, (new AnnualOverviewModel($this->pdo))->overview((int) $agentId, (int) $year));
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de générer la vue annuelle.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
/**
* POST api/annual_validate.php — contrôle puis validation de toute l'année.
*
* Contrôle puis validation en une seule opération de tous les plannings
* en brouillon de l'agent pour l'année civile affichée.
*
* Aucune validation partielle : si une erreur est trouvée, aucun planning
* n'est validé. Les semaines concernées sont retournées au front afin de
* pouvoir les ouvrir dans un nouvel onglet et les corriger. Un succès
* partiel laisserait l'utilisateur devant une année à moitié validée, sans
* moyen simple de savoir ce qui reste.
*
* Trois issues, alignées sur PlanningController::validate() :
* - 422 + status « errors » : au moins une ERREUR, rien n'est validé ;
* - 409 + status « warning » : quota annuel dépassé, confirmation attendue
* par un rappel avec force_quota: true ;
* - 200 + status « validated » ou « nothing_to_validate ».
*
* Contrairement à validate() unitaire, le quota est ici évalué à l'échelle
* de l'ANNÉE (annualForApi) et non de la semaine.
*
* Charge utile : agent_id, year, force_quota (bool), csrf_token.
*/
public function validateYear(): void
{
(new Access($this->pdo))->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$year = filter_var($payload['year'] ?? null, FILTER_VALIDATE_INT);
$forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !$year || $year < 2000 || $year > 2100) {
JsonResponse::send(422, ['error' => 'Agent ou année invalide.']);
}
try {
$planningModel = new PlanningModel($this->pdo);
$quotaModel = new QuotaModel($this->pdo);
$plannings = $planningModel->pendingValidationForAgentYear((int) $agentId, (int) $year);
if ($plannings === []) {
JsonResponse::send(200, [
'success' => true,
'status' => 'nothing_to_validate',
'message' => 'Aucun planning en brouillon avec des créneaux n’est à valider pour cette année.',
'validated_count' => 0,
'plannings' => [],
'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year),
]);
}
$results = [];
$hasErrors = false;
foreach ($plannings as $planning) {
$controls = $this->controlsForPlanning($planningModel, $planning);
$errors = array_values(array_filter(
$controls,
static fn(array $row): bool => $row['niveau'] === 'ERREUR'
));
$hasErrors = $hasErrors || $errors !== [];
$results[] = [
'id_planning' => (int) $planning['id_planning'],
'id_structure' => (int) $planning['id_structure'],
'structure_nom' => (string) $planning['structure_nom'],
'annee' => (int) $planning['annee'],
'numero_semaine' => (int) $planning['numero_semaine'],
'date_debut' => (string) $planning['date_debut'],
'date_fin' => (string) $planning['date_fin'],
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
'total_minutes' => (int) $planning['total_minutes'],
'controls' => $controls,
'has_errors' => $errors !== [],
];
}
$quota = $quotaModel->annualForApi((int) $agentId, (int) $year);
$quotaWarning = (int) $quota['minutes_restantes'] < 0;
if ($hasErrors) {
JsonResponse::send(422, [
'success' => false,
'status' => 'errors',
'error' => 'Certaines semaines contiennent des erreurs. Aucune semaine n’a été validée.',
'plannings' => $results,
'quota' => $quota,
]);
}
if ($quotaWarning && !$forceQuota) {
JsonResponse::send(409, [
'success' => false,
'status' => 'warning',
'message' => sprintf(
'Le quota annuel est dépassé de %s. Confirmez pour valider malgré cet avertissement.',
$quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle']
),
'plannings' => $results,
'quota' => $quota,
]);
}
$ids = array_map(static fn(array $planning): int => (int) $planning['id_planning'], $plannings);
$validatedCount = $planningModel->markValidatedMany($ids);
Journal::ecriture(
'ANNEE_VALIDEE',
'planning',
null,
sprintf('%d planning(s) validés d’un coup pour l’agent #%d, année %d.', $validatedCount, (int) $agentId, (int) $year),
[
'id_agent' => (int) $agentId,
'annee' => (int) $year,
'id_plannings' => $ids,
'quota_force' => $forceQuota,
]
);
JsonResponse::send(200, [
'success' => true,
'status' => 'validated',
'validated_count' => $validatedCount,
'message' => sprintf(
'%d planning(s) ont été validés pour l’année %d.',
$validatedCount,
(int) $year
),
'plannings' => $results,
'quota' => $quotaModel->annualForApi((int) $agentId, (int) $year),
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de contrôler ou valider l’année complète.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
}
/**
* Contrôles bloquants d'un planning, dans la variante « année ».
*
* DIFFÉRENCE VOULUE avec PlanningController::validate() : les chevauchements
* inter-lieux sont recherchés via externalOverlaps() (tous statuts) et non
* validatedExternalOverlaps() (validés seulement). Une validation annuelle
* traite en un bloc l'ensemble des brouillons de l'agent : ils vont tous
* devenir opposables, deux brouillons qui se recouvrent doivent donc être
* signalés dès maintenant.
*
* Un contrôle INFO est ajouté quand rien n'a été trouvé, pour que
* l'interface affiche « aucune anomalie » plutôt qu'une liste vide
* indistincte d'une absence de contrôle.
*
* @param array<string, mixed> $planning
*
* @return array<int, array{niveau: string, code: string, message: string}>
*/
private function controlsForPlanning(PlanningModel $model, array $planning): array
{
$planningId = (int) $planning['id_planning'];
$controls = [];
if ($model->countEntries($planningId) === 0) {
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
}
foreach ($model->entriesOutsideContract($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'HORS_CONTRAT',
sprintf(
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
$row['id_creneau'],
$row['date_jour']
)
);
}
foreach ($model->entriesOutsideWeek($planningId, (string) $planning['date_debut'], (string) $planning['date_fin']) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CRENEAU_HORS_SEMAINE',
sprintf('L’affectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour'])
);
}
foreach ($model->internalOverlaps($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_INTERNE',
sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour'])
);
}
foreach ($model->externalOverlaps($planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_AUTRE_STRUCTURE',
sprintf(
'L’affectation #%d chevauche l’affectation #%d sur le lieu « %s » le %s (%s).',
$row['c1'],
$row['c2'],
$row['structure_nom'],
$row['date_jour'],
strtolower((string) $row['autre_statut'])
)
);
}
if ($controls === []) {
$this->addControl($controls, 'INFO', 'OK', 'Aucune anomalie bloquante détectée pour cette semaine.');
}
return $controls;
}
/**
* Empile un contrôle au format commun {niveau, code, message}.
* Copie de PlanningController::addControl(), les deux contrôleurs n'ayant
* pas de base commune.
*
* @param array<int, array{niveau: string, code: string, message: string}> $controls
*/
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}