Files
pta/app/Models/AgentModel.php
2026-08-20 17:00:06 +02:00

779 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Référentiel des agents et lecture de leurs créneaux, tous lieux confondus.
*
* Deux notions à ne pas confondre :
* - `agent.id_structure` est le lieu de rattachement PAR DÉFAUT. Il ne limite
* pas où l'agent peut être planifié ;
* - `agent_structure` est l'HISTORIQUE des rattachements, avec date de début et
* de fin. Il est tenu à jour par updateProfile() et updateAssignment().
*
* Créer un agent ne se limite pas à insérer une ligne : createWithDefaults()
* ouvre aussi son quota annuel et son PTA. Un agent sans quota afficherait un
* dépassement dès sa première heure planifiée.
*/
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 : l'enveloppe de 14 h
* s'il y a droit, rien sinon.
*/
public static function trainingEnvelopeMinutes(array $agent): int
{
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
}
/**
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
*
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
* manquent :
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
* - pas de date de fin : contrat à durée indéterminée, la borne haute
* n'existe pas.
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
*
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
* findActive() ou allActive().
*
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
*/
public static function contractRange(array $agent): array
{
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
}
/**
* Vrai si la date tombe dans les bornes du contrat.
*
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
* de construire deux objets DateTime par créneau contrôlé.
*/
public static function contractCoversDate(array $agent, string $date): bool
{
[$debut, $fin] = self::contractRange($agent);
if ($debut !== null && $date < $debut) {
return false;
}
return !($fin !== null && $date > $fin);
}
/**
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
*
* @param array<int, string> $dates
*
* @return array<int, string>
*/
public static function datesOutsideContract(array $agent, array $dates): array
{
$hors = [];
foreach ($dates as $date) {
$date = trim((string) $date);
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
$hors[$date] = true;
}
}
$hors = array_keys($hors);
sort($hors);
return $hors;
}
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
public static function contractPeriodLabel(array $agent): string
{
[$debut, $fin] = self::contractRange($agent);
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
if ($debut !== null && $fin !== null) {
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
}
if ($debut !== null) {
return sprintf('à partir du %s', $lisible($debut));
}
if ($fin !== null) {
return sprintf('jusqu’au %s', $lisible($fin));
}
return 'sans date de contrat renseignée';
}
/**
* Message de refus commun à tous les points d'écriture, pour que
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
*
* @param array<int, string> $datesHorsContrat
*/
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
{
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
if (count($datesHorsContrat) > 3) {
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
}
return sprintf(
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? ''),
self::contractPeriodLabel($agent),
implode(', ', $exemples)
);
}
/**
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
*
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
* pas laisser derrière lui des affectations orphelines. Interroge donc les
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
*
* @return array{nombre: int, premiere: string|null, derniere: string|null}
*/
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
WHERE p.id_agent = :agent_id
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
);
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
// émulation, un même nom ne peut pas apparaître deux fois.
$stmt->execute([
'agent_id' => $agentId,
'debut' => $debut,
'debut_compare' => $debut,
'fin' => $fin,
'fin_compare' => $fin,
]);
$ligne = $stmt->fetch() ?: [];
return [
'nombre' => (int) ($ligne['nombre'] ?? 0),
'premiere' => $ligne['premiere'] ?? null,
'derniere' => $ligne['derniere'] ?? null,
];
}
/**
* Projection commune à toutes les lectures d'agent, complétée par la clause
* WHERE de chaque appelant.
*
* Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de
* rattachement, et doit rester listé dans ce cas.
*
* `structure_nom` et `structure_principale_nom` (idem pour le type) sont le
* MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus
* récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et
* les modules JS.
*/
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.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;
/**
* Tous les agents actifs.
*
* @return array<int, array<string, mixed>>
*/
public function allActive(): array
{
return $this->pdo->query(
self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom"
)->fetchAll();
}
/**
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
* y ont un planning, quel que soit leur rattachement.
*
* C'est le périmètre d'un responsable de structure, aligné sur la vue
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
*
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
* répartition sur l'année civile entière.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Référentiel des postes.
*
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
* alimentent les calculs de PtaModel.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Agents du sélecteur de l'éditeur de planning.
*
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
* planifié ailleurs que sur son rattachement. Le tri fait simplement
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
* visuellement.
*
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Fiche complète d'un agent actif.
*
* @return array<string, mixed>|null null si inexistant OU désactivé.
*/
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;
}
/**
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
* rapprochement avec les outils RH, elle reste unique en base.
*/
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;
}
/**
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
* 1. la fiche `agent` ;
* 2. sa première ligne d'historique `agent_structure` ;
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
*
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
* civile, jamais la date anniversaire du contrat.
*
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
* capturé plus haut dans $agentId.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
*/
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,
]);
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,
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,
: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'),
'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;
}
}
/**
* Met à jour la fiche d'un agent et, si son rattachement change, son
* historique `agent_structure`.
*
* L'historique n'est touché QUE si le lieu change réellement, sans quoi
* chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne,
* rendant l'historique illisible.
*
* La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont
* la date de début est dans le FUTUR : on referme alors sur cette date de
* début, pour ne pas produire une période qui finit avant de commencer.
*
* Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le
* passé : la ligne existante est réactivée plutôt que dupliquée.
*
* bindValue() explicite ici (et non un tableau passé à execute()) car
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
*
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @throws \RuntimeException Si l'agent n'existe pas.
*/
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 nom = :nom,
prenom = :prenom,
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,
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(':nom', $data['nom']);
$update->bindValue(':prenom', $data['prenom']);
$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(':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;
}
}
/**
* Change le seul rattachement principal, sans toucher au reste de la fiche.
*
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
*
* @param int|null $structureId null pour détacher l'agent de tout lieu par
* défaut — il reste planifiable ponctuellement.
*/
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;
}
}
/**
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
*
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
* telle que l'utilisateur la conçoit.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
*
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
* bornes de semaine.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
*
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
* un motif en deux lignes.
*
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
* est ce que le lecteur cherche en premier.
*
* @return array<int, array<string, mixed>>
*/
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();
}
/**
* Cumul par motif sur une année civile complète.
*
* @return array<int, array<string, mixed>>
*/
public function timeSummaryForYear(int $agentId, int $year): array
{
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
}
}