Files
pta/app/Models/AgentModel.php
PTA 30cb50031d 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>
2026-08-11 15:30:43 +02:00

476 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
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.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,
po.peut_assurer_animation, po.preparation_lundi_si_100,
a.id_structure,
s.nom AS structure_nom,
s.nom AS structure_principale_nom,
s.type_affectation AS structure_type_affectation,
s.type_affectation AS structure_principale_type_affectation
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
SQL;
public function allActive(): array
{
return $this->pdo->query(
self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allVisibleForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1
FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
return $stmt->fetchAll();
}
public function allEligibleForPta(): array
{
return $this->pdo->query(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND a.type_contrat = 'PERMANENT'
AND a.id_poste IS NOT NULL
ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allPosts(): array
{
return $this->pdo->query(
"SELECT id_poste, code, libelle, famille, heures_mercredi_minutes,
heures_extrascolaire_minutes, autorise_preparation,
peut_assurer_animation, preparation_lundi_si_100
FROM poste_agent
WHERE actif = TRUE
ORDER BY ordre_affichage, libelle"
)->fetchAll();
}
public function listForLocation(int $selectedLocation = 0): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
a.id_poste, po.libelle AS poste_libelle,
s.nom AS structure_nom,
s.type_affectation AS structure_type_affectation,
CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.actif = TRUE
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
);
$stmt->execute(['selected_location' => $selectedLocation]);
return $stmt->fetchAll();
}
public function findActive(int $agentId): ?array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1"
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
public function matriculeExists(string $matricule): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
$stmt->execute(['matricule' => $matricule]);
return $stmt->fetchColumn() !== false;
}
public function createWithDefaults(array $data): array
{
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
$contractEnd = !empty($data['date_fin_contrat'])
? new DateTimeImmutable((string) $data['date_fin_contrat'])
: null;
$year = (int) $contractStart->format('Y');
// Le quota PTA est suivi sur l'année civile, indépendamment de la date
// anniversaire du contrat. La date de contrat reste une donnée RH.
$periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = self::trainingEnvelopeMinutes([
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'],
]);
try {
$this->pdo->beginTransaction();
$insertAgent = $this->pdo->prepare(
'INSERT INTO agent (
matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat,
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, :droit_enveloppe_formation,
:commentaire_pta, :structure_id, TRUE
)'
);
$insertAgent->execute([
'matricule' => $data['matricule'],
'nom' => $data['nom'],
'prenom' => $data['prenom'],
'email' => $data['email'],
'telephone' => $data['telephone'],
'adresse' => $data['adresse'],
'est_diplome' => $data['est_diplome'] ? 1 : 0,
'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'id_poste' => $data['id_poste'],
'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'],
]);
$agentId = (int) $this->pdo->lastInsertId();
$history = $this->pdo->prepare(
'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)'
);
$history->execute([
'agent_id' => $agentId,
'structure_id' => $data['structure_id'],
'date_debut' => $contractStart->format('Y-m-d'),
]);
$quota = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes,
quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference,
:quota_cible, :commentaire
)'
);
$quota->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'commentaire' => 'Quota PTA de référence - année civile',
]);
$pta = $this->pdo->prepare(
'INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
quota_reference_minutes, quota_cible_minutes,
enveloppe_formation_minutes, statut
) VALUES (
:agent_id, :date_debut, :date_fin, :quotite,
:quota_reference, :quota_cible,
:formation, \'BROUILLON\'
)'
);
$pta->execute([
'agent_id' => $agentId,
'date_debut' => $periodStart->format('Y-m-d'),
'date_fin' => $periodEnd->format('Y-m-d'),
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'formation' => $trainingEnvelope,
]);
$this->pdo->commit();
return [
'id_agent' => $agentId,
'annee_quota' => $year,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'quota_cible_minutes' => $quotaTarget,
'id_pta' => (int) $this->pdo->lastInsertId(),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateProfile(int $agentId, array $data): void
{
try {
$this->pdo->beginTransaction();
$currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE');
$currentStmt->execute(['agent_id' => $agentId]);
$currentStructure = $currentStmt->fetchColumn();
if ($currentStructure === false) {
throw new \RuntimeException('Agent introuvable.');
}
$currentStructureId = $currentStructure !== null ? (int) $currentStructure : null;
$newStructureId = $data['structure_id'];
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
diplome_libelle = :diplome_libelle,
date_debut_contrat = :date_debut_contrat,
id_poste = :id_poste,
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'
);
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);
$update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null);
$update->bindValue(':date_debut_contrat', $data['date_debut_contrat']);
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
$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);
} else {
$update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT);
}
$update->execute();
if ($currentStructureId !== $newStructureId) {
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($newStructureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]);
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateAssignment(int $agentId, ?int $structureId): void
{
try {
$this->pdo->beginTransaction();
$update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id');
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
if ($structureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
} else {
$update->bindValue(':structure_id', $structureId, PDO::PARAM_INT);
}
$update->execute();
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($structureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function entriesForWeek(int $agentId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]);
return $stmt->fetchAll();
}
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT UPPER(m.code) AS motif_code,
m.libelle AS motif_libelle,
m.compte_dans_quota,
COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota
ORDER BY m.compte_dans_quota DESC, m.libelle"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForYear(int $agentId, int $year): array
{
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
}
}