From 30cb50031dc1e4e59b66fbbd97391d70a9fde1ce Mon Sep 17 00:00:00 2001 From: PTA Date: Tue, 11 Aug 2026 15:30:43 +0200 Subject: [PATCH] =?UTF-8?q?Droit=20=C3=A0=20l'enveloppe=20de=20formation?= =?UTF-8?q?=20par=20agent,=20et=20motif=20=C2=AB=20Formation=20=C2=BB=20fi?= =?UTF-8?q?ltr=C3=A9=20=C3=A0=20la=20saisie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit : - ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de planning ni dans le modal de la vue individuelle ; - se voit refuser toute affectation de ce motif par le serveur, avec un message qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ; - a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning. À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà : celle-ci dit que la formation est déjà comptée dans la répartition annuelle — l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(), qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués. Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours d'édition : le retirer de la liste changerait silencieusement le motif d'une saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent alors de corriger la plage. La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ». Co-Authored-By: Claude Opus 5 --- app/Controllers/AgentController.php | 9 +++ app/Controllers/PlanningController.php | 58 ++++++++++++++++++- app/Core/MotifCategories.php | 10 ++++ app/Models/AgentModel.php | 47 +++++++++++++-- app/Models/MotifModel.php | 27 +++++++++ app/Models/PtaModel.php | 10 +++- app/Models/SchemaModel.php | 2 +- app/Views/layouts/footer.php | 6 +- app/Views/layouts/header.php | 2 +- app/Views/pages/role.php | 2 +- app/Views/partials/administration.php | 9 +++ assets/js/administration.js | 4 ++ assets/js/agent-overview.js | 23 ++++++++ assets/js/core.js | 1 + assets/js/planning-editor.js | 24 ++++++++ ...ration_v7_9b_droit_enveloppe_formation.sql | 25 ++++++++ 16 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 database/migration_v7_9b_droit_enveloppe_formation.sql diff --git a/app/Controllers/AgentController.php b/app/Controllers/AgentController.php index 86843e2..26e725f 100644 --- a/app/Controllers/AgentController.php +++ b/app/Controllers/AgentController.php @@ -362,6 +362,9 @@ final class AgentController $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); + // Le droit à l'enveloppe de formation est acquis par défaut : un + // formulaire ancien ou une intégration tierce ne doit pas le retirer. + $trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); @@ -406,6 +409,7 @@ final class AgentController 'id_poste' => (int) $postId, 'type_contrat' => $contractType, 'formation_repartition_annuelle' => $trainingPlanned, + 'droit_enveloppe_formation' => $trainingRight, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, ]); } catch (Throwable $e) { @@ -437,6 +441,7 @@ final class AgentController 'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''), 'type_contrat' => $contractType, 'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0, + 'droit_enveloppe_formation' => $trainingRight ? 1 : 0, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, ]), ]); @@ -462,6 +467,9 @@ final class AgentController $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); + // Le droit à l'enveloppe de formation est acquis par défaut : un + // formulaire ancien ou une intégration tierce ne doit pas le retirer. + $trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); $rawStructure = $payload['structure_id'] ?? null; @@ -503,6 +511,7 @@ final class AgentController 'id_poste' => (int) $postId, 'type_contrat' => $contractType, 'formation_repartition_annuelle' => $trainingPlanned, + 'droit_enveloppe_formation' => $trainingRight, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'structure_id' => $structureId !== null ? (int) $structureId : null, ]); diff --git a/app/Controllers/PlanningController.php b/app/Controllers/PlanningController.php index 4754d62..070db90 100644 --- a/app/Controllers/PlanningController.php +++ b/app/Controllers/PlanningController.php @@ -7,6 +7,7 @@ namespace App\Controllers; use App\Core\Access; use App\Core\Csrf; use App\Core\JsonResponse; +use App\Core\MotifCategories; use App\Core\Request; use App\Models\AgentModel; use App\Models\MotifModel; @@ -98,7 +99,8 @@ final class PlanningController if (!is_array($rawEntries)) { JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']); } - if ($this->agentModel->findActive((int) $agentId) === null) { + $agent = $this->agentModel->findActive((int) $agentId); + if ($agent === null) { JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); } if ($this->structureModel->findActive((int) $structureId) === null) { @@ -107,7 +109,10 @@ final class PlanningController [$year, $weekNumber] = $week; $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); - $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds()); + // Les motifs autorisés dépendent de l'agent : « Formation » n'est pas + // affectable à un agent sans droit à l'enveloppe de formation. + $this->assertMotifsAllowedForAgent($rawEntries, $agent); + $entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent)); $this->assertNoInternalOverlap($entries); $this->assertNoOtherLocationOverlap( $entries, @@ -169,7 +174,12 @@ final class PlanningController if ($this->structureModel->findActive((int) $structureId) === null) { JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); } - if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) { + $entryAgent = $this->agentModel->findActive((int) $entry['id_agent']); + if ($entryAgent === null) { + JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); + } + $this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent); + if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) { JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); } @@ -550,6 +560,48 @@ final class PlanningController return [$year, $week]; } + /** + * Refuse les affectations portant un motif fermé à l'agent, avec un message + * qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais + * sans dire pourquoi. L'interface masque déjà ces motifs — reste les + * plannings saisis avant le retrait du droit, et les appels directs. + * + * @param array $rawEntries + */ + private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void + { + if (AgentModel::hasTrainingRight($agent)) { + return; + } + + $restricted = []; + foreach ($this->motifModel->allActive() as $motif) { + if (MotifCategories::requiresTrainingRight((string) $motif['code'])) { + $restricted[(int) $motif['id_motif']] = (string) $motif['libelle']; + } + } + if ($restricted === []) { + return; + } + + foreach ($rawEntries as $entry) { + if (!is_array($entry)) { + continue; + } + $motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT); + if (isset($restricted[$motifId])) { + JsonResponse::send(422, [ + 'error' => sprintf( + 'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.', + $restricted[$motifId], + (string) ($agent['prenom'] ?? ''), + (string) ($agent['nom'] ?? '') + ), + ]); + } + } + } + private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array { $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); diff --git a/app/Core/MotifCategories.php b/app/Core/MotifCategories.php index 6c9870d..0249d0a 100644 --- a/app/Core/MotifCategories.php +++ b/app/Core/MotifCategories.php @@ -47,6 +47,16 @@ final class MotifCategories return strtoupper(trim((string) $code)); } + /** + * Motif dont l'affectation dépend d'un droit porté par l'agent : la formation + * n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h + * (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()). + */ + public static function requiresTrainingRight(?string $code): bool + { + return self::normalizeCode($code) === self::FORMATION; + } + /** * Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il * décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté diff --git a/app/Models/AgentModel.php b/app/Models/AgentModel.php index bf890f3..bad3b33 100644 --- a/app/Models/AgentModel.php +++ b/app/Models/AgentModel.php @@ -10,11 +10,40 @@ use Throwable; final class AgentModel extends BaseModel { + /** Enveloppe annuelle complémentaire de formation : 14 h. */ + public const ENVELOPPE_FORMATION_MINUTES = 840; + + /** + * Un agent sans droit à l'enveloppe de formation ne peut pas se voir + * affecter le motif « Formation ». Le droit vaut par défaut lorsque + * l'information n'est pas projetée : ne jamais retirer un droit sur une + * donnée absente. + */ + public static function hasTrainingRight(array $agent): bool + { + return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1; + } + + /** + * Minutes de formation ouvertes à l'agent sur l'année : rien s'il n'y a pas + * droit, rien non plus si sa formation est déjà comptée dans la répartition + * annuelle, l'enveloppe complémentaire sinon. + */ + public static function trainingEnvelopeMinutes(array $agent): int + { + if (!self::hasTrainingRight($agent) || !empty($agent['formation_repartition_annuelle'])) { + return 0; + } + + return self::ENVELOPPE_FORMATION_MINUTES; + } + private const AGENT_SELECT = <<<'SQL' SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.date_debut_contrat, a.date_fin_contrat, a.type_contrat, - a.formation_repartition_annuelle, a.commentaire_pta, + a.formation_repartition_annuelle, a.droit_enveloppe_formation, + a.commentaire_pta, a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle, po.famille AS poste_famille, po.heures_mercredi_minutes, po.heures_extrascolaire_minutes, po.autorise_preparation, @@ -129,7 +158,10 @@ final class AgentModel extends BaseModel $periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year)); $quotaReference = QuotaModel::REFERENCE_MINUTES; $quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100)); - $trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840; + $trainingEnvelope = self::trainingEnvelopeMinutes([ + 'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0, + 'formation_repartition_annuelle' => $data['formation_repartition_annuelle'], + ]); try { $this->pdo->beginTransaction(); @@ -139,14 +171,14 @@ final class AgentModel extends BaseModel matricule, nom, prenom, email, telephone, adresse, est_diplome, diplome_libelle, date_debut_contrat, id_poste, type_contrat, date_fin_contrat, - formation_repartition_annuelle, commentaire_pta, - id_structure, actif + formation_repartition_annuelle, droit_enveloppe_formation, + commentaire_pta, id_structure, actif ) VALUES ( :matricule, :nom, :prenom, :email, :telephone, :adresse, :est_diplome, :diplome_libelle, :date_debut_contrat, :id_poste, :type_contrat, :date_fin_contrat, - :formation_repartition_annuelle, :commentaire_pta, - :structure_id, TRUE + :formation_repartition_annuelle, :droit_enveloppe_formation, + :commentaire_pta, :structure_id, TRUE )' ); $insertAgent->execute([ @@ -163,6 +195,7 @@ final class AgentModel extends BaseModel 'type_contrat' => $data['type_contrat'], 'date_fin_contrat' => $contractEnd?->format('Y-m-d'), 'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0, + 'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0, 'commentaire_pta' => $data['commentaire_pta'], 'structure_id' => $data['structure_id'], ]); @@ -261,6 +294,7 @@ final class AgentModel extends BaseModel type_contrat = :type_contrat, date_fin_contrat = :date_fin_contrat, formation_repartition_annuelle = :formation_repartition_annuelle, + droit_enveloppe_formation = :droit_enveloppe_formation, commentaire_pta = :commentaire_pta, id_structure = :structure_id WHERE id_agent = :agent_id' @@ -276,6 +310,7 @@ final class AgentModel extends BaseModel $update->bindValue(':type_contrat', $data['type_contrat']); $update->bindValue(':date_fin_contrat', $data['date_fin_contrat']); $update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT); + $update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT); $update->bindValue(':commentaire_pta', $data['commentaire_pta']); if ($newStructureId === null) { $update->bindValue(':structure_id', null, PDO::PARAM_NULL); diff --git a/app/Models/MotifModel.php b/app/Models/MotifModel.php index be6add5..93eaf40 100644 --- a/app/Models/MotifModel.php +++ b/app/Models/MotifModel.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Models; +use App\Core\MotifCategories; + final class MotifModel extends BaseModel { public function allActive(): array @@ -23,4 +25,29 @@ final class MotifModel extends BaseModel $this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN) ); } + + /** + * Motifs réellement affectables à un agent : « Formation » disparaît de la + * liste dès lors que l'agent n'a pas droit à l'enveloppe de formation. + */ + public function allActiveForAgent(array $agent): array + { + if (AgentModel::hasTrainingRight($agent)) { + return $this->allActive(); + } + + return array_values(array_filter( + $this->allActive(), + static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code']) + )); + } + + /** @return int[] */ + public function activeIdsForAgent(array $agent): array + { + return array_map( + static fn(array $motif): int => (int) $motif['id_motif'], + $this->allActiveForAgent($agent) + ); + } } diff --git a/app/Models/PtaModel.php b/app/Models/PtaModel.php index 55f0d5e..4b20a57 100644 --- a/app/Models/PtaModel.php +++ b/app/Models/PtaModel.php @@ -145,7 +145,7 @@ final class PtaModel extends BaseModel return $pta; } - $training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; + $training = AgentModel::trainingEnvelopeMinutes($agent); $insert = $this->pdo->prepare( "INSERT INTO pta_annuel ( id_agent, date_debut, date_fin, quotite_travail, @@ -378,7 +378,13 @@ final class PtaModel extends BaseModel } $formationMinutes = $categories['FORMATION']; - $formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; + $formationTarget = AgentModel::trainingEnvelopeMinutes($agent); + if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) { + $controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf( + 'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.', + $this->formatMinutes($formationMinutes) + )); + } if ($formationTarget > 0 && $formationMinutes > $formationTarget) { $controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf( 'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.', diff --git a/app/Models/SchemaModel.php b/app/Models/SchemaModel.php index 06b0677..ebb640f 100644 --- a/app/Models/SchemaModel.php +++ b/app/Models/SchemaModel.php @@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel $requiredColumns = [ 'creneau_horaire' => ['id_motif'], 'planning' => ['date_validation'], - 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'], + 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'droit_enveloppe_formation', 'commentaire_pta'], 'structure' => ['type_affectation'], ]; diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index 568fdd5..797d563 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index 482a4f6..f0b743b 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -67,7 +67,7 @@ $showGroupLabels = $visiblePageCount > 4; <?= htmlspecialchars($pageTitle) ?> — PTA - +
diff --git a/app/Views/pages/role.php b/app/Views/pages/role.php index 0d63bba..71dfeda 100644 --- a/app/Views/pages/role.php +++ b/app/Views/pages/role.php @@ -12,7 +12,7 @@ Choisir votre accès — PTA - +
diff --git a/app/Views/partials/administration.php b/app/Views/partials/administration.php index 15d4c0f..1df14c5 100644 --- a/app/Views/partials/administration.php +++ b/app/Views/partials/administration.php @@ -122,6 +122,10 @@ Date de fin du contrat + +