Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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<int, mixed> $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);
|
||||
|
||||
Reference in New Issue
Block a user