Files
pta/app/Models/AnnualOverviewModel.php
2026-08-20 13:22:49 +02:00

465 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use RuntimeException;
/**
* Construction du calendrier annuel d'un agent, du 1er janvier au 31 décembre.
*
* Produit une structure entièrement prête à l'affichage : les 365 jours sont
* calculés côté serveur, avec leurs libellés, classes CSS et infobulles. Le
* JavaScript ne fait que rendre le tableau — c'est ce qui garantit que le
* calendrier reste identique à l'écran, au PDF et à l'impression.
*
* MODÈLE DES QUATRE COLONNES par jour (constante CELL_KEYS) :
* AP = Animation Périscolaire PP = Préparation Périscolaire
* AE = Animation Extrascolaire PE = Préparation Extrascolaire
*
* Le classement d'un créneau dans l'une des quatre cases dépend de son motif et,
* pour les motifs génériques, du caractère scolaire ou non de la journée
* (voir cellForEntry()).
*/
final class AnnualOverviewModel extends BaseModel
{
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
/**
* Calendrier annuel complet et ses totaux.
*
* Les créneaux sont lus EN UNE FOIS pour toute l'année puis indexés par date
* en mémoire : 365 requêtes seraient inenvisageables.
*
* Trois familles de totaux cumulées en parallèle : par case (AP/PP/AE/PE),
* par statut (validé / brouillon) et par motif particulier (congé,
* formation, absence…). Elles répondent à trois questions différentes et ne
* se déduisent pas les unes des autres.
*
* @return array<string, mixed> agent, year, quota_periods, calendar_totals,
* months (12 entrées) et legend.
*
* @throws RuntimeException Si l'agent est introuvable ou inactif.
*/
public function overview(int $agentId, int $year): array
{
$agentModel = new AgentModel($this->pdo);
$agent = $agentModel->findActive($agentId);
if ($agent === null) {
throw new RuntimeException('Agent introuvable.');
}
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$entries = $agentModel->entriesForDateRange($agentId, $start, $end);
$vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end);
$quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent);
$entriesByDate = [];
foreach ($entries as $entry) {
$entriesByDate[(string) $entry['date_jour']][] = $entry;
}
$months = [];
$yearTotals = array_fill_keys(self::CELL_KEYS, 0);
$statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialTotals = [];
for ($month = 1; $month <= 12; $month++) {
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$monthEnd = $monthStart->modify('last day of this month');
$days = [];
$monthTotals = array_fill_keys(self::CELL_KEYS, 0);
$period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day'));
foreach ($period as $date) {
$dateValue = $date->format('Y-m-d');
$vacation = $this->vacationForDate($dateValue, $vacations);
$day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent);
$days[] = $day;
foreach (self::CELL_KEYS as $key) {
$minutes = (int) $day['cells'][$key]['minutes'];
$monthTotals[$key] += $minutes;
$yearTotals[$key] += $minutes;
}
foreach ($day['status_minutes'] as $status => $minutes) {
$statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes;
}
foreach ($day['special_minutes'] as $code => $minutes) {
$specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes;
}
}
$months[] = [
'number' => $month,
'name' => $this->monthName($month),
'days' => $days,
'totals' => $this->formatTotals($monthTotals),
];
}
$calendarMinutes = array_sum($yearTotals);
return [
'agent' => $agent,
'year' => $year,
'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
'quota_periods' => $quotaPeriods,
'calendar_totals' => [
'minutes' => $calendarMinutes,
'duration' => $this->formatMinutes($calendarMinutes),
'cells' => $this->formatTotals($yearTotals),
'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)),
'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)),
'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals),
],
'months' => $months,
'legend' => [
['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'],
['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'],
['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'],
['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'],
['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'],
['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'],
['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'],
['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'],
],
];
}
/**
* Construit UNE journée du calendrier à partir de ses créneaux.
*
* Les quatre cases sont toujours présentes, même vides : la grille HTML doit
* garder un nombre de colonnes constant.
*
* Une case peut recevoir plusieurs créneaux ; elle prend alors la classe CSS
* du motif le plus « fort » (voir strongestClass()), et son contenu affiche
* les codes courts plutôt que la durée dès qu'un motif particulier est
* présent — « CA » est plus parlant que « 7h00 » pour un jour de congé.
*
* `structure_id` retient le lieu du PREMIER créneau du jour, ou à défaut le
* rattachement de l'agent : il sert à construire les liens profonds vers
* l'écran de saisie.
*
* @param array<int, array<string, mixed>> $entries Créneaux de la journée.
* @param array<string, mixed>|null $vacation Période de vacances, ou null.
* @param array<string, mixed> $agent Fiche de l'agent.
*
* @return array<string, mixed>
*/
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
{
$cells = [];
foreach (self::CELL_KEYS as $key) {
$cells[$key] = [
'minutes' => 0,
'display' => '',
'details' => [],
'codes' => [],
'class' => '',
'structure_id' => null,
];
}
$statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialMinutes = [];
$hasDraft = false;
$firstStructureId = null;
foreach ($entries as $entry) {
$minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']);
if ($minutes <= 0) {
continue;
}
$code = strtoupper((string) $entry['motif_code']);
$cellKey = $this->cellForEntry($code, $vacation !== null);
$shortCode = $this->shortCode($code);
$status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON';
$statusMinutes[$status] += $minutes;
$hasDraft = $hasDraft || $status === 'BROUILLON';
$firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['minutes'] += $minutes;
$cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['details'][] = sprintf(
'%s–%s · %s · %s%s',
(string) $entry['heure_debut'],
(string) $entry['heure_fin'],
(string) $entry['motif_libelle'],
(string) $entry['structure_nom'],
$status === 'BROUILLON' ? ' · Brouillon' : ''
);
if ($shortCode !== null) {
$cells[$cellKey]['codes'][$shortCode] = true;
$specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes;
}
$cells[$cellKey]['class'] = $this->strongestClass(
(string) $cells[$cellKey]['class'],
$this->classForCode($code, $cellKey)
);
}
foreach (self::CELL_KEYS as $key) {
$codes = array_keys($cells[$key]['codes']);
$cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes);
$cells[$key]['title'] = implode("\n", $cells[$key]['details']);
unset($cells[$key]['details'], $cells[$key]['codes']);
}
$weekday = (int) $date->format('N');
$week = (int) $date->format('W');
$principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null
? (int) $agent['id_structure']
: null;
return [
'date' => $date->format('Y-m-d'),
'day' => (int) $date->format('j'),
'weekday' => $weekday,
'weekday_short' => $this->weekdayShort($weekday),
'week' => $week,
'week_year' => (int) $date->format('o'),
'show_week' => $weekday === 1 || (int) $date->format('j') === 1,
'is_weekend' => $weekday >= 6,
'is_vacation' => $vacation !== null,
'vacation_label' => $vacation['libelle'] ?? null,
'has_entries' => $entries !== [],
'has_draft' => $hasDraft,
'structure_id' => $firstStructureId ?? $principalStructure,
'cells' => $cells,
'status_minutes' => $statusMinutes,
'special_minutes' => $specialMinutes,
];
}
/**
* Périodes de quota à afficher pour l'année.
*
* Trois dates de contexte sont interrogées (1er janvier, 31 décembre, et le
* début de contrat s'il tombe dans l'année), puis dédoublonnées par leurs
* bornes. Depuis que le quota suit l'année civile, elles donnent toutes la
* même période : le résultat ne contient donc en pratique qu'une entrée. La
* mécanique est conservée parce qu'elle reste juste si une règle de
* proratisation en cours d'année est réintroduite.
*
* @return array<int, array<string, mixed>>
*/
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
{
$quotaModel = new QuotaModel($this->pdo);
$contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)];
$contractStart = trim((string) ($agent['date_debut_contrat'] ?? ''));
if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) {
$contexts[] = $contractStart;
}
$periods = [];
foreach ($contexts as $context) {
$quota = $quotaModel->forDateForApi($agentId, $context);
$key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode'];
$periods[$key] = $quota;
}
uasort($periods, static fn(array $a, array $b): int => strcmp(
(string) $a['date_debut_periode'],
(string) $b['date_debut_periode']
));
return array_values($periods);
}
/**
* Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif.
*
* Un motif non reconnu bascule sur le contexte de la journée : animation
* extrascolaire pendant les vacances, périscolaire sinon. C'est le
* comportement le moins surprenant pour un motif ajouté en base après coup,
* et il évite qu'un temps disparaisse du calendrier.
*/
private function cellForEntry(string $code, bool $isVacation): string
{
return match ($code) {
'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP',
'PREPARATION_EXTRASCOLAIRE' => 'PE',
'EXTRASCOLAIRE' => 'AE',
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP',
'JOUR_FRACTIONNEMENT' => 'AP',
default => $isVacation ? 'AE' : 'AP',
};
}
/**
* Abréviation affichée dans la case pour les motifs PARTICULIERS.
*
* @return string|null null pour les motifs de travail ordinaires, qui
* affichent alors leur durée plutôt qu'un code.
*/
private function shortCode(string $code): ?string
{
return match ($code) {
'FORMATION' => 'FOR',
'CONGE' => 'CA',
'ABSENCE', 'MALADIE' => 'ABS',
'AUTRE_ABSENCE' => 'A.ND',
'JNT' => 'JNT',
'JOUR_FRACTIONNEMENT' => 'JF',
'MENAGE_FOND' => 'MF',
default => null,
};
}
/**
* Classe CSS d'un créneau, à partir de son motif et, à défaut, de sa case.
* Les classes correspondantes sont définies dans assets/style.css.
*/
private function classForCode(string $code, string $cellKey): string
{
return match ($code) {
'FORMATION' => 'annual-training',
'CONGE' => 'annual-leave',
'ABSENCE', 'MALADIE' => 'annual-absence',
'AUTRE_ABSENCE' => 'annual-other-absence',
'JNT' => 'annual-jnt',
'JOUR_FRACTIONNEMENT' => 'annual-fraction',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation',
'EXTRASCOLAIRE' => 'annual-extra',
default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work',
};
}
/**
* Arbitre la classe d'une case contenant plusieurs motifs.
*
* Une seule couleur par case : c'est la plus « forte » qui l'emporte, selon
* une priorité croissante allant du travail ordinaire à la journée non
* travaillée. Une demi-journée de congé doit rester visible à côté d'une
* demi-journée travaillée ; l'inverse ferait disparaître l'information.
*
* Le `>=` fait gagner le dernier créneau à priorité égale, ce qui est sans
* conséquence puisque la classe est alors la même.
*/
private function strongestClass(string $current, string $candidate): string
{
$priority = [
'' => 0,
'annual-work' => 1,
'annual-extra' => 2,
'annual-preparation' => 3,
'annual-fraction' => 4,
'annual-training' => 5,
'annual-leave' => 6,
'annual-other-absence' => 7,
'annual-absence' => 8,
'annual-jnt' => 9,
];
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
}
/**
* Contenu textuel d'une case : rien si vide, les codes courts s'il y en a,
* la durée compacte sinon.
*
* @param array<int, string> $codes
*/
private function cellDisplay(int $minutes, array $codes): string
{
if ($minutes <= 0) {
return '';
}
if ($codes !== []) {
return implode('/', $codes);
}
return $this->formatCompactMinutes($minutes);
}
/**
* Période de vacances contenant cette date, ou null.
* Copie locale de VacationModel::vacationForDate() : les périodes sont déjà
* chargées en mémoire pour toute l'année, inutile de repasser par le modèle.
*
* @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 >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) {
return $vacation;
}
}
return null;
}
/** Durée en minutes entre deux HH:MM, plancher à 0. */
private function minutesBetween(string $start, string $end): int
{
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
}
/**
* Décline chaque total en trois formes : minutes brutes, durée lisible
* (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites.
*
* @param array<string, int> $totals
*
* @return array<string, array{minutes: int, duration: string, compact: string}>
*/
private function formatTotals(array $totals): array
{
$formatted = [];
foreach ($totals as $key => $minutes) {
$formatted[$key] = [
'minutes' => (int) $minutes,
'duration' => $this->formatMinutes((int) $minutes),
'compact' => $this->formatCompactMinutes((int) $minutes),
];
}
return $formatted;
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
/** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */
private function formatCompactMinutes(int $minutes): string
{
if ($minutes <= 0) {
return '';
}
$hours = intdiv($minutes, 60);
$mins = $minutes % 60;
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
}
/** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */
private function monthName(int $month): string
{
return [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
][$month];
}
/** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */
private function weekdayShort(int $weekday): string
{
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
}
}