465 lines
19 KiB
PHP
465 lines
19 KiB
PHP
<?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];
|
||
}
|
||
}
|