267 lines
11 KiB
PHP
267 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Core\Access;
|
||
use App\Core\Csrf;
|
||
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);
|
||
|
||
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->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];
|
||
}
|
||
}
|