302 lines
12 KiB
PHP
302 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use DateTimeImmutable;
|
|
use PDO;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Règles de couverture d'un lieu et détection des plages sous-effectif.
|
|
*
|
|
* PRINCIPE DU CALCUL : chaque règle (jour, plage, minimum d'agents) est
|
|
* découpée en créneaux de 15 MINUTES, l'effectif présent est compté sur chacun,
|
|
* puis les créneaux consécutifs identiques sont refusionnés en intervalles
|
|
* lisibles. Ce détour par le quart d'heure est ce qui permet de traiter
|
|
* correctement des créneaux qui se recouvrent partiellement — un simple
|
|
* comptage par plage donnerait un effectif faux dès que deux agents ont des
|
|
* horaires décalés. Le pas de 15 minutes est aussi la granularité de saisie de
|
|
* toute l'application.
|
|
*
|
|
* Deux niveaux de signalement :
|
|
* - `gaps` : effectif PLANIFIÉ insuffisant — un vrai manque ;
|
|
* - `validation_warnings` : effectif atteint, mais pas encore VALIDÉ. La
|
|
* couverture n'est donc pas garantie, sans être en défaut.
|
|
*/
|
|
final class CoverageModel extends BaseModel
|
|
{
|
|
private const DAY_LABELS = [
|
|
1 => 'Lundi',
|
|
2 => 'Mardi',
|
|
3 => 'Mercredi',
|
|
4 => 'Jeudi',
|
|
5 => 'Vendredi',
|
|
];
|
|
|
|
/**
|
|
* Règles de couverture d'un lieu, triées par jour puis par horaire.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function rulesForStructure(int $structureId): array
|
|
{
|
|
$stmt = $this->pdo->prepare(
|
|
"SELECT id_couverture, id_structure, jour_semaine,
|
|
TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut,
|
|
TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin,
|
|
minimum_agents
|
|
FROM structure_couverture_horaire
|
|
WHERE id_structure = :structure_id
|
|
ORDER BY jour_semaine, heure_debut, heure_fin"
|
|
);
|
|
$stmt->execute(['structure_id' => $structureId]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* Remplace INTÉGRALEMENT les règles d'un lieu, en une transaction.
|
|
*
|
|
* Une liste vide supprime toute règle : le lieu redevient « non configuré »
|
|
* et cesse de produire des alertes. Il n'y a pas d'ajout ni de suppression
|
|
* unitaire, la validation métier (chevauchements compris) étant faite en
|
|
* amont par CoverageController::saveRules().
|
|
*
|
|
* @param array<int, array<string, mixed>> $rules Règles déjà validées.
|
|
*/
|
|
public function replaceRules(int $structureId, array $rules): void
|
|
{
|
|
try {
|
|
$this->pdo->beginTransaction();
|
|
|
|
$delete = $this->pdo->prepare(
|
|
'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id'
|
|
);
|
|
$delete->execute(['structure_id' => $structureId]);
|
|
|
|
if ($rules !== []) {
|
|
$insert = $this->pdo->prepare(
|
|
'INSERT INTO structure_couverture_horaire
|
|
(id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents)
|
|
VALUES
|
|
(:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)'
|
|
);
|
|
|
|
foreach ($rules as $rule) {
|
|
$insert->execute([
|
|
'structure_id' => $structureId,
|
|
'jour_semaine' => (int) $rule['jour_semaine'],
|
|
'heure_debut' => $rule['heure_debut'],
|
|
'heure_fin' => $rule['heure_fin'],
|
|
'minimum_agents' => (int) $rule['minimum_agents'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($this->pdo->inTransaction()) {
|
|
$this->pdo->rollBack();
|
|
}
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rapport de couverture d'un lieu sur une semaine.
|
|
*
|
|
* Un lieu sans règle renvoie `configured: false` et AUCUNE alerte : il n'y a
|
|
* pas d'attendu, donc pas de manque. L'appelant doit le signaler à part —
|
|
* taire ces lieux les ferait passer pour conformes.
|
|
*
|
|
* L'analyse s'arrête au VENDREDI (weekStart + 4 jours), conformément au
|
|
* modèle métier.
|
|
*
|
|
* @return array{configured: bool, rules: array<int, array<string, mixed>>, gaps: array<int, array<string, mixed>>, validation_warnings: array<int, array<string, mixed>>, summary: array{gap_count: int, gap_minutes: int, validation_warning_count: int}}
|
|
*/
|
|
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
|
|
{
|
|
$rules = $this->rulesForStructure($structureId);
|
|
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
|
$weekEnd = $weekStart->modify('+4 days');
|
|
|
|
if ($rules === []) {
|
|
return [
|
|
'configured' => false,
|
|
'rules' => [],
|
|
'gaps' => [],
|
|
'validation_warnings' => [],
|
|
'summary' => [
|
|
'gap_count' => 0,
|
|
'gap_minutes' => 0,
|
|
'validation_warning_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$entries = $this->workEntries(
|
|
$structureId,
|
|
$weekStart->format('Y-m-d'),
|
|
$weekEnd->format('Y-m-d')
|
|
);
|
|
|
|
$entriesByDate = [];
|
|
foreach ($entries as $entry) {
|
|
$entriesByDate[$entry['date_jour']][] = $entry;
|
|
}
|
|
|
|
$gaps = [];
|
|
$validationWarnings = [];
|
|
|
|
foreach ($rules as $rule) {
|
|
$dayNumber = (int) $rule['jour_semaine'];
|
|
$date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d');
|
|
$start = $this->timeToMinutes((string) $rule['heure_debut']);
|
|
$end = $this->timeToMinutes((string) $rule['heure_fin']);
|
|
$minimum = (int) $rule['minimum_agents'];
|
|
|
|
for ($slotStart = $start; $slotStart < $end; $slotStart += 15) {
|
|
$slotEnd = min($slotStart + 15, $end);
|
|
$plannedAgents = [];
|
|
$validatedAgents = [];
|
|
|
|
foreach ($entriesByDate[$date] ?? [] as $entry) {
|
|
$entryStart = $this->timeToMinutes((string) $entry['heure_debut']);
|
|
$entryEnd = $this->timeToMinutes((string) $entry['heure_fin']);
|
|
if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) {
|
|
continue;
|
|
}
|
|
|
|
$agentId = (int) $entry['id_agent'];
|
|
$plannedAgents[$agentId] = true;
|
|
if ($entry['statut'] === 'VALIDE') {
|
|
$validatedAgents[$agentId] = true;
|
|
}
|
|
}
|
|
|
|
$plannedCount = count($plannedAgents);
|
|
$validatedCount = count($validatedAgents);
|
|
$base = [
|
|
'date' => $date,
|
|
'jour_semaine' => $dayNumber,
|
|
'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour',
|
|
'heure_debut' => $this->minutesToTime($slotStart),
|
|
'heure_fin' => $this->minutesToTime($slotEnd),
|
|
'minimum_agents' => $minimum,
|
|
'agents_planifies' => $plannedCount,
|
|
'agents_valides' => $validatedCount,
|
|
];
|
|
|
|
if ($plannedCount < $minimum) {
|
|
$base['agents_manquants'] = $minimum - $plannedCount;
|
|
$this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']);
|
|
} elseif ($validatedCount < $minimum) {
|
|
$base['validations_manquantes'] = $minimum - $validatedCount;
|
|
$this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']);
|
|
}
|
|
}
|
|
}
|
|
|
|
$gapMinutes = 0;
|
|
foreach ($gaps as $gap) {
|
|
$gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']);
|
|
}
|
|
|
|
return [
|
|
'configured' => true,
|
|
'rules' => $rules,
|
|
'gaps' => $gaps,
|
|
'validation_warnings' => $validationWarnings,
|
|
'summary' => [
|
|
'gap_count' => count($gaps),
|
|
'gap_minutes' => $gapMinutes,
|
|
'validation_warning_count' => count($validationWarnings),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Créneaux comptant pour la couverture d'un lieu.
|
|
*
|
|
* ATTENTION : le filtre porte sur le seul motif `TRAVAIL`. Les motifs de
|
|
* travail plus fins (ALP_MATIN, RESTAURATION, ALP_SOIR, EXTRASCOLAIRE…),
|
|
* pourtant reconnus comme du travail effectif par
|
|
* App\Core\MotifCategories::WORK_CODES, ne sont PAS comptés ici. Un lieu
|
|
* planifié exclusivement avec ces motifs apparaîtra donc entièrement
|
|
* découvert. À revoir si les alertes remontent des manques inexpliqués.
|
|
*
|
|
* Les agents désactivés sont exclus (jointure sur `a.actif = TRUE`) : ils ne
|
|
* couvrent plus rien.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
|
|
{
|
|
$stmt = $this->pdo->prepare(
|
|
"SELECT p.id_agent, p.statut, c.date_jour,
|
|
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
|
|
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
|
|
FROM planning p
|
|
INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE
|
|
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
|
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
|
WHERE p.id_structure = :structure_id
|
|
AND c.date_jour BETWEEN :date_start AND :date_end
|
|
AND m.code = 'TRAVAIL'
|
|
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
|
|
);
|
|
$stmt->execute([
|
|
'structure_id' => $structureId,
|
|
'date_start' => $dateStart,
|
|
'date_end' => $dateEnd,
|
|
]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* Ajoute un créneau de 15 minutes, en le FUSIONNANT avec le précédent quand
|
|
* c'est le même jour, qu'il enchaîne exactement (fin = début) et que tous
|
|
* les compteurs comparés sont identiques.
|
|
*
|
|
* Sans cette fusion, une matinée découverte produirait seize alertes de
|
|
* quinze minutes au lieu d'une seule ligne de 8h00 à 12h00.
|
|
*
|
|
* Ne regarde que le DERNIER élément : les créneaux sont produits dans
|
|
* l'ordre chronologique, une fusion plus lointaine n'a pas lieu d'être.
|
|
*
|
|
* @param array<int, array<string, mixed>> $target Liste enrichie sur place.
|
|
* @param array<string, mixed> $slot Créneau candidat.
|
|
* @param array<int, string> $comparisonKeys Compteurs devant coïncider.
|
|
*/
|
|
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
|
|
{
|
|
$lastIndex = count($target) - 1;
|
|
if ($lastIndex >= 0) {
|
|
$last = $target[$lastIndex];
|
|
$same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut'];
|
|
foreach ($comparisonKeys as $key) {
|
|
$same = $same && (int) $last[$key] === (int) $slot[$key];
|
|
}
|
|
if ($same) {
|
|
$target[$lastIndex]['heure_fin'] = $slot['heure_fin'];
|
|
return;
|
|
}
|
|
}
|
|
$target[] = $slot;
|
|
}
|
|
|
|
/** « 09:30 » → 570. Tronque à HH:MM, les horaires arrivant parfois en HH:MM:SS. */
|
|
private function timeToMinutes(string $time): int
|
|
{
|
|
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
|
|
return ($hours * 60) + $minutes;
|
|
}
|
|
|
|
/** 570 → « 09:30 ». Opération inverse de timeToMinutes(). */
|
|
private function minutesToTime(int $minutes): string
|
|
{
|
|
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
|
|
}
|
|
}
|