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 $planning * * @return array */ 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 $controls */ private function addControl(array &$controls, string $level, string $code, string $message): void { $controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message]; } }