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>
646 lines
28 KiB
PHP
646 lines
28 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 = 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];
|
||
}
|
||
}
|