865 lines
38 KiB
PHP
865 lines
38 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Core\MotifCategories;
|
||
use DateInterval;
|
||
use DatePeriod;
|
||
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);
|
||
$agent = $agentModel->findActive($agentId);
|
||
if ($agent === null) {
|
||
throw new RuntimeException('Agent introuvable.');
|
||
}
|
||
|
||
$quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate);
|
||
$pta = $this->ensurePta($agent, $quota);
|
||
$entries = $agentModel->entriesForDateRange(
|
||
$agentId,
|
||
(string) $quota['date_debut_periode'],
|
||
(string) $quota['date_fin_periode']
|
||
);
|
||
$vacations = (new VacationModel($this->pdo))->periodsBetween(
|
||
(string) $quota['date_debut_periode'],
|
||
(string) $quota['date_fin_periode']
|
||
);
|
||
|
||
$analysis = $this->analyseEntries($agent, $entries, $vacations, $quota);
|
||
$constraints = $this->constraintsForAgent(
|
||
$agentId,
|
||
(string) $quota['date_debut_periode'],
|
||
(string) $quota['date_fin_periode']
|
||
);
|
||
$wishes = $this->wishesForAgent($agentId);
|
||
$controls = array_merge(
|
||
$this->eligibilityControls($agent),
|
||
$analysis['controls'],
|
||
$this->constraintControls($analysis, $constraints),
|
||
$this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'])
|
||
);
|
||
|
||
usort($controls, static function (array $a, array $b): int {
|
||
$order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2];
|
||
return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9);
|
||
});
|
||
|
||
return [
|
||
'agent' => $agent,
|
||
'pta' => $pta,
|
||
'quota' => $quota,
|
||
'categories' => $analysis['categories'],
|
||
'counters' => $analysis['counters'],
|
||
'controls' => $controls,
|
||
'constraints' => $constraints,
|
||
'wishes' => $wishes,
|
||
'vacations' => $vacations,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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(
|
||
"INSERT INTO agent_contrainte (
|
||
id_agent, type_contrainte, date_debut, date_fin,
|
||
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
|
||
interdit_matin, interdit_midi, interdit_soir, commentaire, actif
|
||
) VALUES (
|
||
:id_agent, :type_contrainte, :date_debut, :date_fin,
|
||
:quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine,
|
||
:interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE
|
||
)"
|
||
);
|
||
$stmt->execute($data);
|
||
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(
|
||
'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id'
|
||
);
|
||
$stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]);
|
||
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(
|
||
"INSERT INTO agent_structure_souhait (
|
||
id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif
|
||
) VALUES (
|
||
:id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE
|
||
)
|
||
ON DUPLICATE KEY UPDATE
|
||
type_souhait = VALUES(type_souhait),
|
||
priorite = VALUES(priorite),
|
||
distance_km = VALUES(distance_km),
|
||
commentaire = VALUES(commentaire),
|
||
actif = TRUE"
|
||
);
|
||
$stmt->execute($data);
|
||
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(
|
||
'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id'
|
||
);
|
||
$stmt->execute(['id' => $wishId, 'agent_id' => $agentId]);
|
||
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(
|
||
'SELECT * FROM pta_annuel
|
||
WHERE id_agent = :agent_id
|
||
AND date_debut = :date_debut
|
||
AND date_fin = :date_fin
|
||
LIMIT 1'
|
||
);
|
||
$stmt->execute([
|
||
'agent_id' => $agent['id_agent'],
|
||
'date_debut' => $quota['date_debut_periode'],
|
||
'date_fin' => $quota['date_fin_periode'],
|
||
]);
|
||
$pta = $stmt->fetch();
|
||
if ($pta) {
|
||
return $pta;
|
||
}
|
||
|
||
$training = AgentModel::trainingEnvelopeMinutes($agent);
|
||
$insert = $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'
|
||
)"
|
||
);
|
||
$insert->execute([
|
||
'agent_id' => $agent['id_agent'],
|
||
'date_debut' => $quota['date_debut_periode'],
|
||
'date_fin' => $quota['date_fin_periode'],
|
||
'quotite' => $quota['quotite_travail'],
|
||
'quota_reference' => $quota['quota_reference_minutes'],
|
||
'quota_cible' => $quota['quota_cible_minutes'],
|
||
'formation' => $training,
|
||
]);
|
||
|
||
$stmt->execute([
|
||
'agent_id' => $agent['id_agent'],
|
||
'date_debut' => $quota['date_debut_periode'],
|
||
'date_fin' => $quota['date_fin_periode'],
|
||
]);
|
||
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 = [
|
||
'PERISCOLAIRE' => 0,
|
||
'MERCREDI_PERISCOLAIRE' => 0,
|
||
'EXTRASCOLAIRE' => 0,
|
||
'FORMATION' => 0,
|
||
'CONGE' => 0,
|
||
'JOUR_FRACTIONNEMENT' => 0,
|
||
'PREPARATION' => 0,
|
||
'MENAGE_FOND' => 0,
|
||
'ABSENCE' => 0,
|
||
'NON_DECOMPTE' => 0,
|
||
];
|
||
$daysByCategory = [];
|
||
$daily = [];
|
||
$weekly = [];
|
||
$controls = [];
|
||
|
||
foreach ($entries as $entry) {
|
||
$date = (string) $entry['date_jour'];
|
||
$start = (string) $entry['heure_debut'];
|
||
$end = (string) $entry['heure_fin'];
|
||
$minutes = $this->minutesBetween($start, $end);
|
||
$code = strtoupper((string) $entry['motif_code']);
|
||
$isVacation = $this->vacationForDate($date, $vacations) !== null;
|
||
$weekday = (int) (new DateTimeImmutable($date))->format('N');
|
||
$category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']);
|
||
|
||
$categories[$category] = ($categories[$category] ?? 0) + $minutes;
|
||
$daysByCategory[$category][$date] = true;
|
||
|
||
$daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []];
|
||
$daily[$date]['minutes'] += $minutes;
|
||
$daily[$date]['starts'][] = $start;
|
||
$daily[$date]['ends'][] = $end;
|
||
$daily[$date]['entries'][] = [
|
||
'start' => $start,
|
||
'end' => $end,
|
||
'minutes' => $minutes,
|
||
'code' => $code,
|
||
'category' => $category,
|
||
];
|
||
|
||
$monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
|
||
$weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes;
|
||
}
|
||
|
||
foreach ($daily as $date => &$day) {
|
||
sort($day['starts']);
|
||
sort($day['ends']);
|
||
usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start']));
|
||
$first = min($day['starts']);
|
||
$last = max($day['ends']);
|
||
$day['amplitude_minutes'] = $this->minutesBetween($first, $last);
|
||
$day['first'] = $first;
|
||
$day['last'] = $last;
|
||
}
|
||
unset($day);
|
||
|
||
foreach ($daily as $date => $day) {
|
||
if ($day['minutes'] > 600) {
|
||
$controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf(
|
||
'%s : %s planifiées, au-delà de 10 h de travail quotidien.',
|
||
$this->formatDate($date), $this->formatMinutes($day['minutes'])
|
||
));
|
||
}
|
||
if ($day['amplitude_minutes'] > 720) {
|
||
$controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf(
|
||
'%s : amplitude de %s, au-delà de 12 h.',
|
||
$this->formatDate($date), $this->formatMinutes($day['amplitude_minutes'])
|
||
));
|
||
}
|
||
|
||
$continuous = 0;
|
||
$previousEnd = null;
|
||
foreach ($day['entries'] as $entry) {
|
||
if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) {
|
||
$continuous = $entry['minutes'];
|
||
} else {
|
||
$continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end']));
|
||
}
|
||
$previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd;
|
||
if ($continuous > 360) {
|
||
$controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf(
|
||
'%s : plus de 6 h consécutives sans coupure d’au moins 20 minutes.',
|
||
$this->formatDate($date)
|
||
));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$dates = array_keys($daily);
|
||
sort($dates);
|
||
for ($i = 1, $count = count($dates); $i < $count; $i++) {
|
||
$previousDate = $dates[$i - 1];
|
||
$currentDate = $dates[$i];
|
||
$previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']);
|
||
$currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']);
|
||
$restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60);
|
||
if ($restMinutes < 660) {
|
||
$controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf(
|
||
'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.',
|
||
$this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes)
|
||
));
|
||
}
|
||
}
|
||
|
||
foreach ($weekly as $monday => $minutes) {
|
||
if ($minutes > 2880) {
|
||
$controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf(
|
||
'Semaine du %s : %s planifiées, au-delà de 48 h.',
|
||
$this->formatDate($monday), $this->formatMinutes($minutes)
|
||
));
|
||
}
|
||
}
|
||
|
||
// Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives.
|
||
$periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']);
|
||
$periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']);
|
||
$firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days');
|
||
$lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days');
|
||
$weekSeries = [];
|
||
for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) {
|
||
$key = $monday->format('Y-m-d');
|
||
$weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)];
|
||
}
|
||
for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) {
|
||
$window = array_slice($weekSeries, $index, 12);
|
||
$average = (int) round(array_sum(array_column($window, 'minutes')) / 12);
|
||
if ($average > 2640) {
|
||
$controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf(
|
||
'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.',
|
||
$this->formatDate($window[0]['date']),
|
||
$this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')),
|
||
$this->formatMinutes($average)
|
||
));
|
||
break;
|
||
}
|
||
}
|
||
|
||
$expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0);
|
||
$expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0);
|
||
$vacationWeeks = [];
|
||
foreach ($daily as $date => $day) {
|
||
$dateObject = new DateTimeImmutable($date);
|
||
$weekday = (int) $dateObject->format('N');
|
||
$vacation = $this->vacationForDate($date, $vacations);
|
||
$workingMinutes = 0;
|
||
foreach ($day['entries'] as $entry) {
|
||
if (in_array($entry['code'], self::WORK_CODES, true)) {
|
||
$workingMinutes += $entry['minutes'];
|
||
}
|
||
}
|
||
|
||
if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) {
|
||
$controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf(
|
||
'%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.',
|
||
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday)
|
||
));
|
||
}
|
||
if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) {
|
||
$controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf(
|
||
'%s : %s de travail extrascolaire, contre %s attendues pour le poste.',
|
||
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra)
|
||
));
|
||
}
|
||
if ($vacation !== null && $workingMinutes > 0) {
|
||
$weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
|
||
$vacationWeeks[$weekKey][$date] = true;
|
||
}
|
||
}
|
||
|
||
if (($agent['poste_famille'] ?? null) === 'ANIMATION') {
|
||
foreach ($vacationWeeks as $weekStart => $workedDays) {
|
||
$count = count($workedDays);
|
||
if ($count > 0 && $count < 5) {
|
||
$controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf(
|
||
'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.',
|
||
$this->formatDate($weekStart), $count
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') {
|
||
foreach (self::PREPARATION_CODES as $code) {
|
||
$hasPreparation = false;
|
||
foreach ($entries as $entry) {
|
||
if (strtoupper((string) $entry['motif_code']) === $code) {
|
||
$hasPreparation = true;
|
||
break;
|
||
}
|
||
}
|
||
if ($hasPreparation) {
|
||
$controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE',
|
||
'Un agent de restauration collective et d’entretien ne doit pas recevoir de temps de préparation.');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$formationMinutes = $categories['FORMATION'];
|
||
$formationTarget = AgentModel::trainingEnvelopeMinutes($agent);
|
||
if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) {
|
||
$controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf(
|
||
'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.',
|
||
$this->formatMinutes($formationMinutes)
|
||
));
|
||
}
|
||
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
|
||
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
||
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
||
$this->formatMinutes($formationMinutes)
|
||
));
|
||
}
|
||
if ($formationTarget > 0 && $formationMinutes < $formationTarget) {
|
||
$controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf(
|
||
'Il reste %s dans l’enveloppe de formation CNFPT de 14 h.',
|
||
$this->formatMinutes($formationTarget - $formationMinutes)
|
||
));
|
||
}
|
||
|
||
$caDays = count($daysByCategory['CONGE'] ?? []);
|
||
$jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []);
|
||
if ($caDays !== 25) {
|
||
$controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf(
|
||
'%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.',
|
||
$caDays
|
||
));
|
||
}
|
||
if ($jfDays !== 2) {
|
||
$controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf(
|
||
'%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.',
|
||
$jfDays
|
||
));
|
||
}
|
||
|
||
if ($controls === []) {
|
||
$controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.');
|
||
}
|
||
|
||
$formattedCategories = [];
|
||
foreach ($categories as $code => $minutes) {
|
||
$formattedCategories[] = [
|
||
'code' => $code,
|
||
'minutes' => $minutes,
|
||
'libelle' => $this->categoryLabel($code),
|
||
'duree' => $this->formatMinutes($minutes),
|
||
'jours' => count($daysByCategory[$code] ?? []),
|
||
];
|
||
}
|
||
|
||
return [
|
||
'categories' => $formattedCategories,
|
||
'counters' => [
|
||
'conges_annuels_jours' => $caDays,
|
||
'conges_annuels_cible' => 25,
|
||
'jours_fractionnement' => $jfDays,
|
||
'jours_fractionnement_cible' => 2,
|
||
'formation_minutes' => $formationMinutes,
|
||
'formation_cible_minutes' => $formationTarget,
|
||
'formation_duree' => $this->formatMinutes($formationMinutes),
|
||
'formation_cible_duree' => $this->formatMinutes($formationTarget),
|
||
'quota_cible' => $quota['quota_cible'],
|
||
'heures_affectees' => $quota['affectees'],
|
||
'heures_restantes' => $quota['restantes'],
|
||
],
|
||
'controls' => $controls,
|
||
'daily' => $daily,
|
||
'weekly' => $weekly,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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 = [];
|
||
if (($agent['type_contrat'] ?? '') !== 'PERMANENT') {
|
||
$controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT',
|
||
'Le dispositif PTA est réservé ici aux agents en contrat permanent.');
|
||
}
|
||
if (empty($agent['id_poste'])) {
|
||
$controls[] = $this->control('ERREUR', 'POSTE_MANQUANT',
|
||
'Le poste de l’agent doit être renseigné pour appliquer les règles du PTA.');
|
||
}
|
||
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 = [];
|
||
foreach ($constraints as $constraint) {
|
||
$label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE'
|
||
? 'Temps partiel thérapeutique'
|
||
: 'Préconisation médicale';
|
||
$controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf(
|
||
'%s du %s au %s : %s',
|
||
$label,
|
||
$this->formatDate((string) $constraint['date_debut']),
|
||
$constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin',
|
||
(string) $constraint['commentaire']
|
||
));
|
||
|
||
foreach ($analysis['daily'] as $date => $day) {
|
||
if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) {
|
||
continue;
|
||
}
|
||
if ($constraint['maximum_minutes_jour'] !== null
|
||
&& $day['minutes'] > (int) $constraint['maximum_minutes_jour']) {
|
||
$controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf(
|
||
'%s : %s planifiées alors que la contrainte limite la journée à %s.',
|
||
$this->formatDate($date), $this->formatMinutes($day['minutes']),
|
||
$this->formatMinutes((int) $constraint['maximum_minutes_jour'])
|
||
));
|
||
}
|
||
}
|
||
}
|
||
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(
|
||
"SELECT c.date_jour, p.id_structure, s.nom AS structure_nom,
|
||
COUNT(DISTINCT p.id_agent) AS agents,
|
||
SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent
|
||
FROM creneau_horaire c
|
||
JOIN planning p ON p.id_planning = c.id_planning
|
||
JOIN motif_planning m ON m.id_motif = c.id_motif
|
||
JOIN structure s ON s.id_structure = p.id_structure
|
||
WHERE m.code = 'MENAGE_FOND'
|
||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||
AND EXISTS (
|
||
SELECT 1 FROM planning px
|
||
JOIN creneau_horaire cx ON cx.id_planning = px.id_planning
|
||
JOIN motif_planning mx ON mx.id_motif = cx.id_motif
|
||
WHERE px.id_agent = :agent_exists
|
||
AND px.id_structure = p.id_structure
|
||
AND cx.date_jour = c.date_jour
|
||
AND mx.code = 'MENAGE_FOND'
|
||
)
|
||
GROUP BY c.date_jour, p.id_structure, s.nom"
|
||
);
|
||
$stmt->execute([
|
||
'agent_id' => $agentId,
|
||
'agent_exists' => $agentId,
|
||
'date_debut' => $startDate,
|
||
'date_fin' => $endDate,
|
||
]);
|
||
$controls = [];
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
if ((int) $row['agents'] < 2) {
|
||
$controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf(
|
||
'%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.',
|
||
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents']
|
||
));
|
||
}
|
||
if ((int) $row['minutes_agent'] !== 420) {
|
||
$controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf(
|
||
'%s — %s : %s de ménage de fond pour l’agent, au lieu de 7 h.',
|
||
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'],
|
||
$this->formatMinutes((int) $row['minutes_agent'])
|
||
));
|
||
}
|
||
}
|
||
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(
|
||
"SELECT id_contrainte, type_contrainte, date_debut, date_fin,
|
||
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
|
||
interdit_matin, interdit_midi, interdit_soir, commentaire
|
||
FROM agent_contrainte
|
||
WHERE id_agent = :agent_id
|
||
AND actif = TRUE
|
||
AND date_debut <= :date_fin
|
||
AND COALESCE(date_fin, '9999-12-31') >= :date_debut
|
||
ORDER BY date_debut DESC"
|
||
);
|
||
$stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]);
|
||
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(
|
||
"SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom,
|
||
sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire
|
||
FROM agent_structure_souhait sw
|
||
JOIN structure s ON s.id_structure = sw.id_structure
|
||
WHERE sw.id_agent = :agent_id AND sw.actif = TRUE
|
||
ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom"
|
||
);
|
||
$stmt->execute(['agent_id' => $agentId]);
|
||
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) {
|
||
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE',
|
||
'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE',
|
||
'EXTRASCOLAIRE' => 'EXTRASCOLAIRE',
|
||
'FORMATION' => 'FORMATION',
|
||
'CONGE' => 'CONGE',
|
||
'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT',
|
||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION',
|
||
'MENAGE_FOND' => 'MENAGE_FOND',
|
||
'ABSENCE' => 'ABSENCE',
|
||
'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE',
|
||
'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'),
|
||
default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE',
|
||
};
|
||
}
|
||
|
||
/** Libellé affichable d'une catégorie ; le code brut sert de repli. */
|
||
private function categoryLabel(string $code): string
|
||
{
|
||
return match ($code) {
|
||
'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi',
|
||
'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires',
|
||
'EXTRASCOLAIRE' => 'Temps extrascolaire',
|
||
'FORMATION' => 'Formation',
|
||
'CONGE' => 'Congés annuels non décomptés',
|
||
'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement',
|
||
'PREPARATION' => 'Temps de préparation',
|
||
'MENAGE_FOND' => 'Ménages de fond',
|
||
'ABSENCE' => 'Absences décomptées',
|
||
'NON_DECOMPTE' => 'JNT et absences non décomptées',
|
||
default => $code,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) {
|
||
return $vacation;
|
||
}
|
||
}
|
||
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)));
|
||
[$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5)));
|
||
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 ? '-' : '';
|
||
$minutes = abs($minutes);
|
||
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];
|
||
}
|
||
}
|