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 + +