Ajout des commentaires dans le code
This commit is contained in:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user