Files
pta/app/Models/PtaModel.php
PTA 30cb50031d Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie
Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à
la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit :

- ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de
  planning ni dans le modal de la vue individuelle ;
- se voit refuser toute affectation de ce motif par le serveur, avec un message
  qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ;
- a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE
  FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning.

À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà :
celle-ci dit que la formation est déjà comptée dans la répartition annuelle —
l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les
deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(),
qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués.

Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours
d'édition : le retirer de la liste changerait silencieusement le motif d'une
saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent
alors de corriger la plage.

La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans
l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:30:43 +02:00

646 lines
28 KiB
PHP
Raw Permalink 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 App\Core\MotifCategories;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use PDO;
use RuntimeException;
final class PtaModel extends BaseModel
{
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
private const WORK_CODES = MotifCategories::WORK_CODES;
private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
];
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,
];
}
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();
}
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;
}
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);
}
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;
}
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() ?: [];
}
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,
];
}
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;
}
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;
}
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;
}
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();
}
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();
}
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',
};
}
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,
};
}
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;
}
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);
}
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
private function formatDate(string $date): string
{
return (new DateTimeImmutable($date))->format('d/m/Y');
}
private function control(string $level, string $code, string $message): array
{
return ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}