822 lines
34 KiB
PHP
822 lines
34 KiB
PHP
<?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;
|
||
|
||
/**
|
||
* Retire des listes D'AFFECTATION les agents dont le COMPTE D'ACCÈS est
|
||
* désactivé (V7.29).
|
||
*
|
||
* Trois précisions, toutes portées par le `NOT EXISTS` :
|
||
*
|
||
* - un agent SANS compte reste proposé. Il n'a simplement pas encore reçu
|
||
* son jeton, ce qui est l'état normal avant distribution : le filtrer
|
||
* viderait les listes d'une installation neuve ;
|
||
* - le filtre porte sur `compte_acces.actif`, PAS sur `agent.actif`, qui
|
||
* est déjà exigé par ailleurs. Ce sont deux décisions distinctes :
|
||
* fermer une fiche d'agent et couper un accès ;
|
||
* - il ne s'applique QU'AUX listes où l'on affecte. Les écrans de
|
||
* consultation (planning par agent, vue annuelle, PTA détaillé) et le
|
||
* référentiel « Agents » continuent de montrer l'agent — sans quoi on ne
|
||
* pourrait plus ni lire son année passée, ni lui rétablir son accès.
|
||
*
|
||
* La table compte_acces est garantie présente : SchemaModel::assertCurrent()
|
||
* refuse de démarrer si les vues de comptes manquent.
|
||
*/
|
||
private const SANS_COMPTE_COUPE = <<<'SQL'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM compte_acces ca
|
||
WHERE ca.id_agent = a.id_agent AND ca.actif = 0
|
||
)
|
||
SQL;
|
||
|
||
/**
|
||
* Tous les agents actifs, y compris ceux dont l'accès est coupé.
|
||
*
|
||
* Liste de RÉFÉRENTIEL et de CONSULTATION. Pour une liste où l'on affecte,
|
||
* voir allSelectable().
|
||
*
|
||
* @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 proposables dans une liste d'affectation : actifs ET dont le compte
|
||
* d'accès n'a pas été coupé.
|
||
*
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public function allSelectable(): array
|
||
{
|
||
return $this->pdo->query(
|
||
self::AGENT_SELECT . " WHERE a.actif = TRUE" . self::SANS_COMPTE_COUPE . " 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" . self::SANS_COMPTE_COUPE . "
|
||
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));
|
||
}
|
||
}
|