La liste des motifs d'une nouvelle plage s'ouvrait sur un placeholder vide « Choisir un motif », alors que le temps de travail est le cas très majoritaire : elle propose désormais « Heure de travail » (code TRAVAIL), avec repli sur le premier motif décomptant le quota si le référentiel ne contient pas ce code. Côté PDF, seul le code générique TRAVAIL était traité comme du temps travaillé. Les motifs ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire, préparations et ménage de fond tombaient donc : - dans un total invisible du bilan annuel (ligne « Heures travaillées » à 0 h 00 alors que les heures étaient bien décomptées plus bas) ; - dans la couleur grise « Autre absence » des plannings agent et lieu. La classification est centralisée dans App\Core\MotifCategories, réutilisée par PtaModel (qui portait déjà la même liste) et par les deux services PDF. Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il décompte le quota, pour qu'aucun total ne puisse disparaître d'un document. La ligne « Heures travaillées » détaille en outre les motifs regroupés, et le total décompté suit maintenant le drapeau compte_dans_quota comme QuotaModel : il additionnait auparavant les congés, hors quota, en contradiction avec sa propre note de bas de tableau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
640 lines
27 KiB
PHP
640 lines
27 KiB
PHP
<?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 = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||
$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 = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||
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];
|
||
}
|
||
}
|