Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -11,15 +11,56 @@ use DateTimeImmutable;
use PDO;
use RuntimeException;
/**
* PTA annuel d'un agent : répartition des temps et contrôles réglementaires.
*
* C'est le modèle qui porte le plus de RÈGLES DE DROIT DU TRAVAIL de
* l'application. Les seuils sont exprimés en minutes et écrits en dur ; ils sont
* rappelés ci-dessous pour qu'une évolution réglementaire soit repérable d'un
* coup d'œil :
*
* 600 min = 10 h — durée quotidienne maximale de travail effectif ;
* 720 min = 12 h — amplitude quotidienne maximale (du premier début à la
* dernière fin, pauses comprises) ;
* 360 min = 6 h — travail continu maximal sans pause d'au moins 20 minutes ;
* 660 min = 11 h — repos minimal entre deux journées ;
* 2880 min = 48 h — durée hebdomadaire maximale ;
* 2640 min = 44 h — moyenne hebdomadaire maximale sur 12 semaines glissantes ;
* 420 min = 7 h — durée attendue d'un ménage de fond ;
* 25 jours de congés annuels et 2 journées de fractionnement attendus.
*
* Les contrôles suivent la forme commune {niveau, code, message}, ERREUR pour
* un dépassement réglementaire, ALERTE pour un écart aux règles métier locales,
* INFO pour un simple constat. À la différence de PlanningController::validate(),
* RIEN N'EST BLOQUÉ ici : le PTA est un écran d'analyse, l'arbitrage revient à
* l'agent de planification.
*/
final class PtaModel extends BaseModel
{
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
private const WORK_CODES = MotifCategories::WORK_CODES;
/** Motifs de préparation, interdits aux agents de restauration/entretien. */
private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
];
/**
* Synthèse complète du PTA d'un agent à une date d'observation.
*
* La période analysée n'est PAS choisie par l'appelant : c'est celle du
* quota (année civile), déduite de $contextDate.
*
* Les contrôles proviennent de quatre sources indépendantes — éligibilité,
* analyse des créneaux, contraintes individuelles et ménage de fond — puis
* sont triés ERREUR, ALERTE, INFO : l'utilisateur doit voir les blocages en
* premier.
*
* @return array<string, mixed> agent, pta, quota, categories, counters,
* controls, constraints, wishes, vacations.
*
* @throws RuntimeException Si l'agent est introuvable ou inactif.
*/
public function summary(int $agentId, string $contextDate): array
{
$agentModel = new AgentModel($this->pdo);
@@ -72,6 +113,17 @@ final class PtaModel extends BaseModel
];
}
/**
* Insère une contrainte individuelle.
*
* $data est passé TEL QUEL à execute() : ses clés doivent correspondre
* exactement aux paramètres nommés de la requête. La validation métier est
* entièrement faite par PtaController::constraint().
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé.
*/
public function saveConstraint(array $data): int
{
$stmt = $this->pdo->prepare(
@@ -89,6 +141,11 @@ final class PtaModel extends BaseModel
return (int) $this->pdo->lastInsertId();
}
/**
* Supprime une contrainte, à condition qu'elle appartienne bien à l'agent.
*
* @return bool false si elle n'existe pas ou relève d'un autre agent.
*/
public function deleteConstraint(int $constraintId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
@@ -98,6 +155,18 @@ final class PtaModel extends BaseModel
return $stmt->rowCount() > 0;
}
/**
* Enregistre un souhait de lieu, en création ou en mise à jour.
*
* Le ON DUPLICATE KEY UPDATE s'appuie sur l'unicité (agent, lieu) : un agent
* n'a qu'un seul avis par lieu, réémettre un souhait le remplace au lieu de
* l'empiler. Il RÉACTIVE aussi un souhait précédemment retiré.
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé, ou 0 en cas de mise à jour (lastInsertId()
* ne renvoie rien d'exploitable dans ce cas).
*/
public function saveWish(array $data): int
{
$stmt = $this->pdo->prepare(
@@ -117,6 +186,11 @@ final class PtaModel extends BaseModel
return (int) ($this->pdo->lastInsertId() ?: 0);
}
/**
* Supprime un souhait, à condition qu'il appartienne bien à l'agent.
*
* @return bool false si absent ou relevant d'un autre agent.
*/
public function deleteWish(int $wishId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
@@ -126,6 +200,20 @@ final class PtaModel extends BaseModel
return $stmt->rowCount() > 0;
}
/**
* Retourne le PTA de la période, en le CRÉANT s'il n'existe pas.
*
* Création paresseuse volontaire : un agent créé avant la mise en place du
* PTA, ou dont on consulte une année non encore ouverte, obtient son
* enregistrement à la première consultation plutôt qu'une page en erreur.
*
* Le PTA est identifié par (agent, date_debut, date_fin) et non par la seule
* année : c'est la période de quota qui fait foi.
*
* Effet de bord assumé : une simple lecture peut écrire en base.
*
* @return array<string, mixed>
*/
private function ensurePta(array $agent, array $quota): array
{
$stmt = $this->pdo->prepare(
@@ -174,6 +262,40 @@ final class PtaModel extends BaseModel
return $stmt->fetch() ?: [];
}
/**
* Analyse tous les créneaux de la période — méthode centrale du modèle.
*
* Déroulé :
* 1. VENTILATION de chaque créneau dans une catégorie métier
* (categoryForEntry()), avec cumul des minutes et des jours distincts ;
* 2. AGRÉGATS par jour (durée, amplitude, créneaux triés) et par semaine ;
* 3. CONTRÔLES RÉGLEMENTAIRES, dans cet ordre :
* - durée quotidienne (> 10 h) et amplitude (> 12 h) ;
* - travail continu > 6 h sans coupure d'au moins 20 minutes. Deux
* créneaux séparés de moins de 20 minutes sont considérés comme
* continus, la pause étant trop courte pour compter ;
* - repos entre deux journées CONSÉCUTIVEMENT PLANIFIÉES : les dates
* sans créneau sont ignorées, un week-end n'a pas à être contrôlé ;
* - durée hebdomadaire > 48 h ;
* - moyenne > 44 h sur 12 semaines glissantes. Le contrôle s'arrête à la
* PREMIÈRE fenêtre fautive : les suivantes se recouvrent et
* produiraient des dizaines de messages redondants ;
* 4. CONTRÔLES MÉTIER LOCAUX : durée attendue des mercredis et de
* l'extrascolaire selon le poste, semaines de vacances incomplètes pour
* la famille ANIMATION, interdiction de préparation pour la famille
* RESTAURATION_ENTRETIEN, enveloppe de formation, et objectifs de
* 25 jours de congés / 2 journées de fractionnement.
*
* Un contrôle INFO est ajouté si rien n'a été trouvé, pour que l'interface
* affiche « aucune anomalie » plutôt qu'une liste vide ambiguë.
*
* @param array<string, mixed> $agent
* @param array<int, array<string, mixed>> $entries
* @param array<int, array<string, mixed>> $vacations
* @param array<string, mixed> $quota
*
* @return array{categories: array<int, array<string, mixed>>, counters: array<string, mixed>, controls: array<int, array<string, string>>, daily: array<string, mixed>, weekly: array<string, int>}
*/
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
{
$categories = [
@@ -449,6 +571,15 @@ final class PtaModel extends BaseModel
];
}
/**
* Vérifie que l'agent relève bien du dispositif PTA : contrat permanent et
* poste renseigné. Sans poste, aucune des règles de durée attendue ne peut
* s'appliquer.
*
* @param array<string, mixed> $agent
*
* @return array<int, array<string, string>>
*/
private function eligibilityControls(array $agent): array
{
$controls = [];
@@ -463,6 +594,24 @@ final class PtaModel extends BaseModel
return $controls;
}
/**
* Confronte les journées planifiées aux contraintes individuelles.
*
* Chaque contrainte produit d'abord une ALERTE de rappel, visible même sans
* dépassement : l'agent de planification doit savoir qu'elle existe. Un
* dépassement du maximum quotidien produit ensuite une ERREUR.
*
* Une contrainte sans date de fin est réputée toujours en vigueur.
*
* LIMITE CONNUE : `maximum_minutes_semaine`, `quotite_temporaire` et les
* interdictions matin/midi/soir sont enregistrés et affichés, mais AUCUN
* contrôle automatique ne les exploite encore.
*
* @param array<string, mixed> $analysis Sortie d'analyseEntries().
* @param array<int, array<string, mixed>> $constraints
*
* @return array<int, array<string, string>>
*/
private function constraintControls(array $analysis, array $constraints): array
{
$controls = [];
@@ -495,6 +644,18 @@ final class PtaModel extends BaseModel
return $controls;
}
/**
* Contrôle des ménages de fond : au moins DEUX agents ensemble, et 7 h par
* agent.
*
* La sous-requête EXISTS restreint l'analyse aux (date, lieu) où CET agent
* est lui-même sur un ménage de fond ; l'agrégat compte alors tous les
* agents présents sur ce même ménage, y compris ceux qui ne sont pas
* l'agent consulté. Sans elle, la requête balaierait tous les ménages de
* fond de la collectivité.
*
* @return array<int, array<string, string>>
*/
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
@@ -543,6 +704,15 @@ final class PtaModel extends BaseModel
return $controls;
}
/**
* Contraintes actives qui INTERSECTENT la période.
*
* Le COALESCE(date_fin, '9999-12-31') traite une contrainte sans terme comme
* courant indéfiniment ; un simple `date_fin >= :date_debut` l'écarterait,
* NULL n'étant comparable à rien.
*
* @return array<int, array<string, mixed>>
*/
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
@@ -560,6 +730,14 @@ final class PtaModel extends BaseModel
return $stmt->fetchAll();
}
/**
* Souhaits de lieux de l'agent.
*
* Le FIELD() force les INTERDICTIONS en tête, avant les préférences : une
* interdiction est une contrainte, une préférence un simple souhait.
*
* @return array<int, array<string, mixed>>
*/
private function wishesForAgent(int $agentId): array
{
$stmt = $this->pdo->prepare(
@@ -574,6 +752,21 @@ final class PtaModel extends BaseModel
return $stmt->fetchAll();
}
/**
* Classe un créneau dans une catégorie métier du PTA.
*
* Le motif générique `TRAVAIL` est ventilé selon le CONTEXTE : extrascolaire
* pendant les vacances, mercredi périscolaire le mercredi (weekday 3),
* périscolaire sinon. C'est l'héritage des saisies antérieures aux motifs
* détaillés, qui doivent rester correctement réparties.
*
* Un motif inconnu suit son drapeau `compte_dans_quota` : périscolaire s'il
* compte, non décompté sinon — le comportement le moins surprenant pour un
* motif ajouté en base après coup.
*
* @param int $weekday 1 (lundi) à 7 (dimanche).
* @param bool $counts Valeur de compte_dans_quota du motif.
*/
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
{
return match ($code) {
@@ -592,6 +785,7 @@ final class PtaModel extends BaseModel
};
}
/** Libellé affichable d'une catégorie ; le code brut sert de repli. */
private function categoryLabel(string $code): string
{
return match ($code) {
@@ -609,6 +803,15 @@ final class PtaModel extends BaseModel
};
}
/**
* Période de vacances contenant cette date, ou null.
* Copie locale : les périodes sont déjà chargées en mémoire pour toute
* l'année analysée.
*
* @param array<int, array<string, mixed>> $vacations
*
* @return array<string, mixed>|null
*/
private function vacationForDate(string $date, array $vacations): ?array
{
foreach ($vacations as $vacation) {
@@ -619,6 +822,13 @@ final class PtaModel extends BaseModel
return null;
}
/**
* Durée en minutes entre deux horaires.
*
* SANS plancher à zéro, contrairement à ses homonymes des autres modèles :
* le calcul de la pause continue a besoin des valeurs négatives pour
* détecter deux créneaux qui se recouvrent.
*/
private function minutesBetween(string $start, string $end): int
{
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
@@ -626,6 +836,7 @@ final class PtaModel extends BaseModel
return ($eh * 60 + $em) - ($sh * 60 + $sm);
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
@@ -633,11 +844,19 @@ final class PtaModel extends BaseModel
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
/** « 2026-02-17 » → « 17/02/2026 », format des messages utilisateur. */
private function formatDate(string $date): string
{
return (new DateTimeImmutable($date))->format('d/m/Y');
}
/**
* Fabrique un contrôle au format commun {niveau, code, message}.
*
* @param string $level ERREUR, ALERTE ou INFO.
*
* @return array{niveau: string, code: string, message: string}
*/
private function control(string $level, string $code, string $message): array
{
return ['niveau' => $level, 'code' => $code, 'message' => $message];