Ajout des commentaires dans le code
This commit is contained in:
@@ -8,6 +8,19 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Référentiel des agents et lecture de leurs créneaux, tous lieux confondus.
|
||||
*
|
||||
* Deux notions à ne pas confondre :
|
||||
* - `agent.id_structure` est le lieu de rattachement PAR DÉFAUT. Il ne limite
|
||||
* pas où l'agent peut être planifié ;
|
||||
* - `agent_structure` est l'HISTORIQUE des rattachements, avec date de début et
|
||||
* de fin. Il est tenu à jour par updateProfile() et updateAssignment().
|
||||
*
|
||||
* Créer un agent ne se limite pas à insérer une ligne : createWithDefaults()
|
||||
* ouvre aussi son quota annuel et son PTA. Un agent sans quota afficherait un
|
||||
* dépassement dès sa première heure planifiée.
|
||||
*/
|
||||
final class AgentModel extends BaseModel
|
||||
{
|
||||
/** Enveloppe annuelle complémentaire de formation : 14 h. */
|
||||
@@ -33,6 +46,18 @@ final class AgentModel extends BaseModel
|
||||
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection commune à toutes les lectures d'agent, complétée par la clause
|
||||
* WHERE de chaque appelant.
|
||||
*
|
||||
* Les LEFT JOIN sont volontaires : un agent peut n'avoir ni poste ni lieu de
|
||||
* rattachement, et doit rester listé dans ce cas.
|
||||
*
|
||||
* `structure_nom` et `structure_principale_nom` (idem pour le type) sont le
|
||||
* MÊME champ sous deux noms : des écrans plus anciens lisent l'un, les plus
|
||||
* récents l'autre. Ne pas retirer l'un des deux sans vérifier les vues et
|
||||
* les modules JS.
|
||||
*/
|
||||
private const AGENT_SELECT = <<<'SQL'
|
||||
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
|
||||
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
|
||||
@@ -52,6 +77,11 @@ final class AgentModel extends BaseModel
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
SQL;
|
||||
|
||||
/**
|
||||
* Tous les agents actifs.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -59,6 +89,15 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents visibles depuis un lieu : ceux qui y sont RATTACHÉS, plus ceux qui
|
||||
* y ont un planning, quel que soit leur rattachement.
|
||||
*
|
||||
* C'est le périmètre d'un responsable de structure, aligné sur la vue
|
||||
* v_acces_agent_par_structure utilisée par Access::requireAgentView().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allVisibleForStructure(int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -82,6 +121,14 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents éligibles au PTA annuel : permanents et pourvus d'un poste.
|
||||
*
|
||||
* Les contrats temporaires en sont exclus, le PTA étant un engagement de
|
||||
* répartition sur l'année civile entière.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allEligibleForPta(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -93,6 +140,15 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Référentiel des postes.
|
||||
*
|
||||
* Le poste porte les volumes forfaitaires de l'agent (heures des mercredis,
|
||||
* de l'extrascolaire) et ses autorisations (préparation, animation), qui
|
||||
* alimentent les calculs de PtaModel.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allPosts(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -105,6 +161,19 @@ final class AgentModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents du sélecteur de l'éditeur de planning.
|
||||
*
|
||||
* Renvoie TOUS les agents actifs, quel que soit le lieu : un agent peut être
|
||||
* planifié ailleurs que sur son rattachement. Le tri fait simplement
|
||||
* REMONTER EN TÊTE ceux rattachés au lieu sélectionné, sans masquer les
|
||||
* autres. `lieu_principal_selectionne` permet à l'interface de les distinguer
|
||||
* visuellement.
|
||||
*
|
||||
* @param int $selectedLocation 0 pour ne privilégier aucun lieu.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listForLocation(int $selectedLocation = 0): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -123,6 +192,11 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche complète d'un agent actif.
|
||||
*
|
||||
* @return array<string, mixed>|null null si inexistant OU désactivé.
|
||||
*/
|
||||
public function findActive(int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -132,6 +206,10 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le matricule est déjà pris, DÉSACTIVÉS COMPRIS : c'est la clé de
|
||||
* rapprochement avec les outils RH, elle reste unique en base.
|
||||
*/
|
||||
public function matriculeExists(string $matricule): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
|
||||
@@ -139,6 +217,25 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un agent et TOUT ce qui doit l'accompagner, en une transaction :
|
||||
* 1. la fiche `agent` ;
|
||||
* 2. sa première ligne d'historique `agent_structure` ;
|
||||
* 3. son quota annuel `quota_agent_annuel`, proratisé par sa quotité ;
|
||||
* 4. son PTA annuel `pta_annuel`, en BROUILLON.
|
||||
*
|
||||
* L'année retenue pour le quota et le PTA est celle du DÉBUT DE CONTRAT,
|
||||
* mais la période court du 1er janvier au 31 décembre : le PTA suit l'année
|
||||
* civile, jamais la date anniversaire du contrat.
|
||||
*
|
||||
* ATTENTION : `id_pta` du retour vaut lastInsertId() APRÈS l'insertion du
|
||||
* PTA, donc bien l'identifiant du PTA — et non celui de l'agent, qui a été
|
||||
* capturé plus haut dans $agentId.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @return array{id_agent: int, annee_quota: int, date_debut_contrat: string, date_fin_contrat: string|null, quota_cible_minutes: int, id_pta: int}
|
||||
*/
|
||||
public function createWithDefaults(array $data): array
|
||||
{
|
||||
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
|
||||
@@ -260,6 +357,28 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la fiche d'un agent et, si son rattachement change, son
|
||||
* historique `agent_structure`.
|
||||
*
|
||||
* L'historique n'est touché QUE si le lieu change réellement, sans quoi
|
||||
* chaque enregistrement de la fiche clôturerait puis rouvrirait une ligne,
|
||||
* rendant l'historique illisible.
|
||||
*
|
||||
* La clôture pose `date_fin` à aujourd'hui, sauf pour un rattachement dont
|
||||
* la date de début est dans le FUTUR : on referme alors sur cette date de
|
||||
* début, pour ne pas produire une période qui finit avant de commencer.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE gère le retour sur un lieu déjà occupé par le
|
||||
* passé : la ligne existante est réactivée plutôt que dupliquée.
|
||||
*
|
||||
* bindValue() explicite ici (et non un tableau passé à execute()) car
|
||||
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @throws \RuntimeException Si l'agent n'existe pas.
|
||||
*/
|
||||
public function updateProfile(int $agentId, array $data): void
|
||||
{
|
||||
try {
|
||||
@@ -340,6 +459,15 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le seul rattachement principal, sans toucher au reste de la fiche.
|
||||
*
|
||||
* Même gestion d'historique qu'updateProfile(), à ceci près que l'historique
|
||||
* est réécrit INCONDITIONNELLEMENT, y compris si le lieu ne change pas.
|
||||
*
|
||||
* @param int|null $structureId null pour détacher l'agent de tout lieu par
|
||||
* défaut — il reste planifiable ponctuellement.
|
||||
*/
|
||||
public function updateAssignment(int $agentId, ?int $structureId): void
|
||||
{
|
||||
try {
|
||||
@@ -383,6 +511,14 @@ final class AgentModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur une semaine ISO, TOUS LIEUX confondus.
|
||||
*
|
||||
* Filtre sur le NUMÉRO DE SEMAINE du planning : c'est la vue « semaine »
|
||||
* telle que l'utilisateur la conçoit.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForWeek(int $agentId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -407,6 +543,15 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un agent sur un intervalle de DATES, tous lieux confondus.
|
||||
*
|
||||
* Filtre sur `c.date_jour` et non sur la semaine du planning : indispensable
|
||||
* pour les grilles mensuelles et les PDF multi-semaines, qui débordent des
|
||||
* bornes de semaine.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -433,6 +578,18 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul des minutes par motif sur une période — base du bilan d'heures PDF.
|
||||
*
|
||||
* Trois totaux par motif : global, validé, brouillon. Le regroupement porte
|
||||
* sur `UPPER(m.code)` pour qu'une variation de casse en base ne scinde pas
|
||||
* un motif en deux lignes.
|
||||
*
|
||||
* Le tri place `compte_dans_quota` en tête : le temps qui pèse sur le quota
|
||||
* est ce que le lecteur cherche en premier.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -458,6 +615,11 @@ final class AgentModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumul par motif sur une année civile complète.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function timeSummaryForYear(int $agentId, int $year): array
|
||||
{
|
||||
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
|
||||
|
||||
@@ -9,10 +9,42 @@ 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);
|
||||
@@ -100,6 +132,27 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
@@ -186,6 +239,18 @@ final class AnnualOverviewModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -210,6 +275,14 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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) {
|
||||
@@ -222,6 +295,12 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -236,6 +315,10 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -251,6 +334,17 @@ final class AnnualOverviewModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [
|
||||
@@ -268,6 +362,12 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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) {
|
||||
@@ -279,6 +379,15 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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) {
|
||||
@@ -289,6 +398,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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));
|
||||
@@ -296,6 +406,14 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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 = [];
|
||||
@@ -309,6 +427,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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 ? '-' : '';
|
||||
@@ -316,6 +435,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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) {
|
||||
@@ -326,6 +446,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
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 [
|
||||
@@ -335,6 +456,7 @@ final class AnnualOverviewModel extends BaseModel
|
||||
][$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];
|
||||
|
||||
@@ -6,6 +6,13 @@ namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Base commune des modèles : porte la connexion PDO partagée.
|
||||
*
|
||||
* Les modèles ne contiennent que du SQL et la mise en forme de ses résultats ;
|
||||
* les règles d'accès sont appliquées en amont par les contrôleurs
|
||||
* (voir App\Core\Access). Un modèle ne filtre donc jamais par rôle de lui-même.
|
||||
*/
|
||||
abstract class BaseModel
|
||||
{
|
||||
public function __construct(protected PDO $pdo)
|
||||
|
||||
@@ -8,6 +8,23 @@ 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 = [
|
||||
@@ -18,6 +35,11 @@ final class CoverageModel extends BaseModel
|
||||
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(
|
||||
@@ -33,6 +55,16 @@ final class CoverageModel extends BaseModel
|
||||
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 {
|
||||
@@ -71,6 +103,18 @@ final class CoverageModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -172,6 +216,21 @@ final class CoverageModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
@@ -195,6 +254,21 @@ final class CoverageModel extends BaseModel
|
||||
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;
|
||||
@@ -212,12 +286,14 @@ final class CoverageModel extends BaseModel
|
||||
$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);
|
||||
|
||||
@@ -6,8 +6,27 @@ namespace App\Models;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
|
||||
/**
|
||||
* Référentiel des motifs de créneau (`motif_planning`).
|
||||
*
|
||||
* Le motif porte le drapeau `compte_dans_quota`, qui décide si le temps saisi
|
||||
* s'impute ou non sur le quota annuel de l'agent. C'est le seul point de
|
||||
* bascule : CONGE, AUTRE_ABSENCE et JNT ne comptent pas (voir
|
||||
* QuotaModel::REFERENCE_MINUTES pour le pourquoi).
|
||||
*
|
||||
* Les motifs sont classés fonctionnellement par App\Core\MotifCategories, qui
|
||||
* distingue travail effectif, formation, congé et absence.
|
||||
*/
|
||||
final class MotifModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Tous les motifs actifs, dans l'ordre d'affichage du référentiel.
|
||||
*
|
||||
* Liste NON filtrée par agent : voir allActiveForAgent() pour la variante
|
||||
* qui applique les droits.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -18,6 +37,11 @@ final class MotifModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des motifs actifs.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function activeIds(): array
|
||||
{
|
||||
return array_map(
|
||||
@@ -29,6 +53,14 @@ final class MotifModel extends BaseModel
|
||||
/**
|
||||
* Motifs réellement affectables à un agent : « Formation » disparaît de la
|
||||
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
|
||||
*
|
||||
* C'est la liste de référence des contrôles de saisie : tout motif absent
|
||||
* d'ici est refusé côté serveur, quelle que soit l'interface utilisée.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent (doit porter
|
||||
* droit_enveloppe_formation).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActiveForAgent(array $agent): array
|
||||
{
|
||||
|
||||
@@ -7,8 +7,40 @@ namespace App\Models;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Accès aux plannings hebdomadaires et à leurs créneaux.
|
||||
*
|
||||
* Modèle de données : `planning` (agent × semaine × lieu) porte des
|
||||
* `creneau_horaire` typés par un `motif_planning`. La table `semaine` est un
|
||||
* référentiel créé À LA DEMANDE (voir ensureWeek()) : une semaine n'existe en
|
||||
* base qu'à partir du moment où quelque chose y est planifié.
|
||||
*
|
||||
* Trois conventions valent pour tout ce fichier :
|
||||
*
|
||||
* 1. TRANSACTIONS — toute méthode qui écrit plusieurs lignes ouvre une
|
||||
* transaction et la déroule intégralement en cas d'échec. Le `catch` réémet
|
||||
* l'exception : c'est au contrôleur de la traduire en réponse HTTP.
|
||||
*
|
||||
* 2. VERROUS — les lectures qui précèdent une écriture portent `FOR UPDATE`.
|
||||
* Deux utilisateurs peuvent travailler sur le même agent en même temps ;
|
||||
* sans verrou, un contrôle d'antériorité serait fait sur un état déjà périmé.
|
||||
*
|
||||
* 3. RÉOUVERTURE AUTOMATIQUE — modifier le contenu d'un planning VALIDÉ le
|
||||
* repasse en BROUILLON, et les identifiants concernés remontent dans
|
||||
* `reopened_planning_ids`. Une validation ne doit jamais survivre à la
|
||||
* modification de ce qu'elle attestait.
|
||||
*
|
||||
* Test de chevauchement, identique partout : `debut1 < fin2 && fin1 > debut2`.
|
||||
* Deux créneaux jointifs (10:00-12:00 et 12:00-14:00) ne se chevauchent pas.
|
||||
*/
|
||||
final class PlanningModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Retrouve le planning d'un triplet (lieu, agent, semaine) — la clé
|
||||
* fonctionnelle d'un planning.
|
||||
*
|
||||
* @return array<string, mixed>|null null si rien n'a encore été saisi.
|
||||
*/
|
||||
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -30,6 +62,15 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un planning, motif joint.
|
||||
*
|
||||
* Les heures sortent en HH:MM (TIME_FORMAT) et non en HH:MM:SS : c'est le
|
||||
* format attendu par les champs de saisie du navigateur. Les écritures, à
|
||||
* l'inverse, passent en HH:MM:SS.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entries(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -47,6 +88,15 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux de l'agent sur les AUTRES lieux, la même semaine.
|
||||
*
|
||||
* Affichés en lecture seule dans l'éditeur : un agent pouvant être planifié
|
||||
* sur plusieurs lieux, c'est le seul moyen de voir un conflit avant de
|
||||
* l'enregistrer.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -78,6 +128,31 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le brouillon d'une semaine : REMPLACEMENT INTÉGRAL.
|
||||
*
|
||||
* Les créneaux existants du planning sont supprimés puis réinsérés. C'est
|
||||
* plus simple et plus sûr qu'une réconciliation ligne à ligne, au prix de la
|
||||
* perte des identifiants de créneaux — aucune donnée n'y étant rattachée,
|
||||
* c'est sans conséquence.
|
||||
*
|
||||
* Un planning VALIDÉ est refusé : il faut le rouvrir explicitement d'abord.
|
||||
* C'est la différence avec les modifications unitaires, qui rouvrent d'elles-
|
||||
* mêmes ; ici l'utilisateur écrase toute une semaine, le geste doit être
|
||||
* conscient.
|
||||
*
|
||||
* La semaine est créée si besoin par un INSERT … ON DUPLICATE KEY UPDATE
|
||||
* avec LAST_INSERT_ID(id_semaine) : cette astuce MariaDB fait remonter
|
||||
* l'identifiant EXISTANT via lastInsertId() plutôt qu'un zéro. Le repli par
|
||||
* SELECT couvre les configurations où l'astuce ne joue pas.
|
||||
*
|
||||
* @param array<int, array{date: string, motif_id: int, start: string, end: string}> $entries
|
||||
* Créneaux déjà normalisés par PlanningController::normalizeEntries().
|
||||
*
|
||||
* @return int Identifiant du planning créé ou mis à jour.
|
||||
*
|
||||
* @throws \DomainException Si le planning est déjà validé.
|
||||
*/
|
||||
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
|
||||
{
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
@@ -168,6 +243,22 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tous les brouillons NON VIDES, toutes structures confondues.
|
||||
*
|
||||
* Le `HAVING COUNT(...) > 0` écarte les plannings sans créneau : ils
|
||||
* existent (un planning vidé n'est jamais supprimé) mais n'ont rien à
|
||||
* valider, et pollueraient la file de travail.
|
||||
*
|
||||
* Deux totaux distincts : `total_minutes` (tout le temps saisi) et
|
||||
* `minutes_decomptees` (seulement ce qui pèse sur le quota).
|
||||
* `date_fin_ouvrable` est le vendredi, la semaine s'arrêtant au jour ouvré.
|
||||
*
|
||||
* Tri par semaine croissante : les premiers éléments sont les plus anciens,
|
||||
* donc les plus urgents — DashboardController s'appuie sur cet ordre.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function pendingValidation(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
@@ -211,6 +302,12 @@ final class PlanningModel extends BaseModel
|
||||
/**
|
||||
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
|
||||
* dans l'année civile demandée.
|
||||
*
|
||||
* Le filtrage se fait sur la DATE DES CRÉNEAUX (sous-requête EXISTS) et non
|
||||
* sur l'année de la semaine : une semaine à cheval sur deux années civiles
|
||||
* doit être reprise par la validation annuelle des deux années.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function pendingValidationForAgentYear(int $agentId, int $year): array
|
||||
{
|
||||
@@ -254,6 +351,13 @@ final class PlanningModel extends BaseModel
|
||||
/**
|
||||
* Chevauchements avec n'importe quel autre planning de l'agent,
|
||||
* qu'il soit déjà validé ou encore en brouillon.
|
||||
*
|
||||
* Variante LARGE, utilisée par la validation ANNUELLE : tous les brouillons
|
||||
* de l'agent y sont validés d'un bloc, deux brouillons qui se recouvrent
|
||||
* doivent donc être signalés. La validation unitaire utilise au contraire
|
||||
* validatedExternalOverlaps(), plus permissive.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Paires de créneaux en conflit.
|
||||
*/
|
||||
public function externalOverlaps(int $planningId): array
|
||||
{
|
||||
@@ -277,6 +381,11 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning et bornes de sa semaine, par identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findDetailed(int $planningId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -291,6 +400,7 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Nombre de créneaux ; zéro déclenche le contrôle bloquant AUCUN_CRENEAU. */
|
||||
public function countEntries(int $planningId): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
|
||||
@@ -298,6 +408,15 @@ final class PlanningModel extends BaseModel
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux sortis des bornes de leur semaine.
|
||||
*
|
||||
* Ne devrait jamais rien renvoyer : la saisie contrôle déjà l'appartenance.
|
||||
* Ce filet rattrape les données produites par un import, une reprise ou une
|
||||
* version antérieure du code.
|
||||
*
|
||||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||||
*/
|
||||
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -310,6 +429,14 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevauchements entre créneaux d'un MÊME planning.
|
||||
*
|
||||
* `c2.id_creneau > c1.id_creneau` évite de sortir chaque paire deux fois et
|
||||
* empêche un créneau de se comparer à lui-même.
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string}>
|
||||
*/
|
||||
public function internalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -327,6 +454,16 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chevauchements avec les seuls plannings DÉJÀ VALIDÉS d'un autre lieu.
|
||||
*
|
||||
* Variante RESTREINTE, utilisée par la validation unitaire : seul un
|
||||
* planning validé est opposable. Opposer les brouillons rendrait toute
|
||||
* validation impossible dès que deux brouillons concurrents existent, sans
|
||||
* ordre de résolution évident.
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string, structure_nom: string}>
|
||||
*/
|
||||
public function validatedExternalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -349,6 +486,11 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneau enrichi de son planning et de sa semaine.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findEntryDetailed(int $entryId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -371,6 +513,25 @@ final class PlanningModel extends BaseModel
|
||||
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
|
||||
* Le planning cible est créé si nécessaire et tout planning validé modifié
|
||||
* repasse automatiquement en brouillon.
|
||||
*
|
||||
* C'est la méthode la plus délicate du modèle : la nouvelle date peut
|
||||
* relever d'une autre SEMAINE et le nouveau lieu d'un autre PLANNING. Le
|
||||
* créneau est alors RATTACHÉ à un autre planning (mise à jour de
|
||||
* `id_planning`) plutôt que supprimé puis recréé, ce qui préserve son
|
||||
* identifiant.
|
||||
*
|
||||
* Jusqu'à DEUX plannings peuvent être rouverts : celui d'origine et celui de
|
||||
* destination.
|
||||
*
|
||||
* La recherche de conflit couvre TOUS les lieux de l'agent, pas seulement le
|
||||
* lieu visé : c'est l'agent qui ne peut pas être à deux endroits à la fois.
|
||||
*
|
||||
* @param string $start Heure de début en HH:MM:SS.
|
||||
* @param string $end Heure de fin en HH:MM:SS.
|
||||
*
|
||||
* @return array{entry_id: int, agent_id: int, year: int, week: int, source_planning_id: int, target_planning_id: int, reopened_planning_ids: int[]}
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
|
||||
*/
|
||||
public function updateEntryFromAgentView(
|
||||
int $entryId,
|
||||
@@ -501,6 +662,18 @@ final class PlanningModel extends BaseModel
|
||||
* Si le planning était validé, il repasse en brouillon afin de signaler
|
||||
* que son contenu a été modifié. Le planning est conservé même s'il ne
|
||||
* contient plus aucun créneau.
|
||||
*
|
||||
* Le contrôle `rowCount() !== 1` après le DELETE détecte la suppression
|
||||
* concurrente : le créneau était encore là au SELECT … FOR UPDATE mais plus
|
||||
* au DELETE. On préfère annuler et demander un rechargement plutôt que de
|
||||
* confirmer une suppression qui n'a rien fait.
|
||||
*
|
||||
* `remaining_entries` permet à l'interface d'annoncer un planning devenu
|
||||
* vide, cas nominal et non erreur.
|
||||
*
|
||||
* @return array{entry_id: int, planning_id: int, agent_id: int, year: int, week: int, date: string, reopened: bool, remaining_entries: int}
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable ou déjà supprimé.
|
||||
*/
|
||||
public function deleteEntryFromAgentView(int $entryId): array
|
||||
{
|
||||
@@ -573,6 +746,23 @@ final class PlanningModel extends BaseModel
|
||||
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
|
||||
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
|
||||
* changement de semaine.
|
||||
*
|
||||
* Sert le glisser-déposer de l'éditeur. Trois particularités :
|
||||
*
|
||||
* - Déposer sur le jour d'origine en mode déplacement est un NON-ÉVÉNEMENT :
|
||||
* on rend « action: noop » sans rien écrire, plutôt qu'une erreur pour un
|
||||
* geste de souris sans conséquence.
|
||||
* - En DUPLICATION, le créneau source est inclus dans la recherche de
|
||||
* conflit ($excludeEntryId = 0) : la copie entrerait en collision avec lui
|
||||
* si elle atterrissait au même endroit. En DÉPLACEMENT il en est exclu,
|
||||
* puisqu'il libère sa place.
|
||||
* - Une duplication ne rouvre PAS le planning source : celui-ci n'est pas
|
||||
* modifié, seule la destination l'est.
|
||||
*
|
||||
* @return array{entry_id: int, created_entry_id: int|null, agent_id: int, year: int, week: int, planning_id: int, target_planning_id: int, reopened_planning_ids: int[], action: string}
|
||||
* `action` vaut « move », « duplicate » ou « noop ».
|
||||
*
|
||||
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
|
||||
*/
|
||||
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
|
||||
{
|
||||
@@ -720,6 +910,31 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recopie tous les créneaux d'un agent d'une semaine vers une autre.
|
||||
*
|
||||
* Le report se fait par JOUR DE LA SEMAINE, pas par date : un créneau du
|
||||
* mardi source atterrit sur le mardi cible. `WEEKDAY() + 1` donne 1 (lundi)
|
||||
* à 5 (vendredi) ; le filtre `BETWEEN 0 AND 4` écarte le week-end, qui ne
|
||||
* devrait pas exister mais peut subsister d'une reprise de données.
|
||||
*
|
||||
* La copie couvre TOUS les lieux de la semaine source, d'où la réservation
|
||||
* de l'opération au Service Enfance.
|
||||
*
|
||||
* Deux modes :
|
||||
* - `replace` : les créneaux existants de la semaine cible sont supprimés,
|
||||
* tous lieux confondus ;
|
||||
* - `merge` : ils sont conservés, et le moindre chevauchement fait
|
||||
* ÉCHOUER l'opération entière. Une copie partielle laisserait un état
|
||||
* difficile à interpréter — mieux vaut ne rien faire et le dire.
|
||||
*
|
||||
* @param string $mode « merge » ou « replace ».
|
||||
*
|
||||
* @return array{agent_id: int, year: int, week: int, copied_entries: int, reopened_planning_ids: int[], mode: string}
|
||||
*
|
||||
* @throws \DomainException Mode invalide, semaines identiques, source vide,
|
||||
* ou conflit en mode merge.
|
||||
*/
|
||||
public function copyAgentWeek(
|
||||
int $agentId,
|
||||
int $sourceYear,
|
||||
@@ -874,6 +1089,17 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de la semaine, en la créant si elle n'existe pas.
|
||||
*
|
||||
* La table `semaine` est peuplée à la demande : aucune pré-génération de
|
||||
* calendrier. LAST_INSERT_ID(id_semaine) dans le ON DUPLICATE KEY UPDATE
|
||||
* fait remonter l'identifiant EXISTANT par lastInsertId(), ce qui évite un
|
||||
* SELECT supplémentaire dans le cas courant ; le repli couvre les
|
||||
* configurations où l'astuce ne joue pas.
|
||||
*
|
||||
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
|
||||
*/
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -901,7 +1127,17 @@ final class PlanningModel extends BaseModel
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
/** @return array{id_planning:int,statut:string} */
|
||||
/**
|
||||
* Retourne le planning du triplet (agent, semaine, lieu), en le créant au
|
||||
* besoin en BROUILLON.
|
||||
*
|
||||
* Le statut est renvoyé avec l'identifiant, car l'appelant en a besoin pour
|
||||
* décider s'il doit rouvrir le planning après modification.
|
||||
*
|
||||
* À N'APPELER QUE DANS UNE TRANSACTION : le SELECT porte un FOR UPDATE.
|
||||
*
|
||||
* @return array{id_planning:int,statut:string}
|
||||
*/
|
||||
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -932,7 +1168,17 @@ final class PlanningModel extends BaseModel
|
||||
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
/**
|
||||
* Repasse en BROUILLON les plannings listés et efface leur date de validation.
|
||||
*
|
||||
* Les identifiants sont dédoublonnés : un même planning peut être signalé
|
||||
* deux fois (source et destination confondues). La liste est renvoyée telle
|
||||
* qu'appliquée, pour remonter au client dans `reopened_planning_ids`.
|
||||
*
|
||||
* @param array<int, int|string> $planningIds
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function reopenPlanningIds(array $planningIds): array
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
|
||||
@@ -948,7 +1194,17 @@ final class PlanningModel extends BaseModel
|
||||
}
|
||||
|
||||
|
||||
/** @param int[] $planningIds */
|
||||
/**
|
||||
* Valide un lot de plannings.
|
||||
*
|
||||
* La clause `statut = 'BROUILLON'` rend l'opération sûre en concurrence :
|
||||
* un planning validé entre-temps n'est pas revalidé, et rowCount() renvoie
|
||||
* le nombre RÉELLEMENT traité — c'est lui qui est annoncé à l'utilisateur.
|
||||
*
|
||||
* @param int[] $planningIds
|
||||
*
|
||||
* @return int Nombre de plannings effectivement validés.
|
||||
*/
|
||||
public function markValidatedMany(array $planningIds): int
|
||||
{
|
||||
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
|
||||
@@ -965,6 +1221,13 @@ final class PlanningModel extends BaseModel
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide un planning unitaire.
|
||||
*
|
||||
* Sans condition de statut, contrairement à markValidatedMany() : l'appelant
|
||||
* (PlanningController::validate()) a déjà lu le planning et joué tous ses
|
||||
* contrôles.
|
||||
*/
|
||||
public function markValidated(int $planningId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -973,6 +1236,13 @@ final class PlanningModel extends BaseModel
|
||||
$stmt->execute(['planning_id' => $planningId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repasse un planning VALIDÉ en BROUILLON, sur demande explicite.
|
||||
*
|
||||
* @return bool false si le planning n'était plus validé — un autre
|
||||
* utilisateur est passé entre-temps, et l'appelant doit
|
||||
* répondre 409 plutôt que d'annoncer un succès.
|
||||
*/
|
||||
public function reopen(int $planningId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -11,15 +11,56 @@ use DateTimeImmutable;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* PTA annuel d'un agent : répartition des temps et contrôles réglementaires.
|
||||
*
|
||||
* C'est le modèle qui porte le plus de RÈGLES DE DROIT DU TRAVAIL de
|
||||
* l'application. Les seuils sont exprimés en minutes et écrits en dur ; ils sont
|
||||
* rappelés ci-dessous pour qu'une évolution réglementaire soit repérable d'un
|
||||
* coup d'œil :
|
||||
*
|
||||
* 600 min = 10 h — durée quotidienne maximale de travail effectif ;
|
||||
* 720 min = 12 h — amplitude quotidienne maximale (du premier début à la
|
||||
* dernière fin, pauses comprises) ;
|
||||
* 360 min = 6 h — travail continu maximal sans pause d'au moins 20 minutes ;
|
||||
* 660 min = 11 h — repos minimal entre deux journées ;
|
||||
* 2880 min = 48 h — durée hebdomadaire maximale ;
|
||||
* 2640 min = 44 h — moyenne hebdomadaire maximale sur 12 semaines glissantes ;
|
||||
* 420 min = 7 h — durée attendue d'un ménage de fond ;
|
||||
* 25 jours de congés annuels et 2 journées de fractionnement attendus.
|
||||
*
|
||||
* Les contrôles suivent la forme commune {niveau, code, message}, ERREUR pour
|
||||
* un dépassement réglementaire, ALERTE pour un écart aux règles métier locales,
|
||||
* INFO pour un simple constat. À la différence de PlanningController::validate(),
|
||||
* RIEN N'EST BLOQUÉ ici : le PTA est un écran d'analyse, l'arbitrage revient à
|
||||
* l'agent de planification.
|
||||
*/
|
||||
final class PtaModel extends BaseModel
|
||||
{
|
||||
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
|
||||
private const WORK_CODES = MotifCategories::WORK_CODES;
|
||||
|
||||
/** Motifs de préparation, interdits aux agents de restauration/entretien. */
|
||||
private const PREPARATION_CODES = [
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||
];
|
||||
|
||||
/**
|
||||
* Synthèse complète du PTA d'un agent à une date d'observation.
|
||||
*
|
||||
* La période analysée n'est PAS choisie par l'appelant : c'est celle du
|
||||
* quota (année civile), déduite de $contextDate.
|
||||
*
|
||||
* Les contrôles proviennent de quatre sources indépendantes — éligibilité,
|
||||
* analyse des créneaux, contraintes individuelles et ménage de fond — puis
|
||||
* sont triés ERREUR, ALERTE, INFO : l'utilisateur doit voir les blocages en
|
||||
* premier.
|
||||
*
|
||||
* @return array<string, mixed> agent, pta, quota, categories, counters,
|
||||
* controls, constraints, wishes, vacations.
|
||||
*
|
||||
* @throws RuntimeException Si l'agent est introuvable ou inactif.
|
||||
*/
|
||||
public function summary(int $agentId, string $contextDate): array
|
||||
{
|
||||
$agentModel = new AgentModel($this->pdo);
|
||||
@@ -72,6 +113,17 @@ final class PtaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insère une contrainte individuelle.
|
||||
*
|
||||
* $data est passé TEL QUEL à execute() : ses clés doivent correspondre
|
||||
* exactement aux paramètres nommés de la requête. La validation métier est
|
||||
* entièrement faite par PtaController::constraint().
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé.
|
||||
*/
|
||||
public function saveConstraint(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -89,6 +141,11 @@ final class PtaModel extends BaseModel
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une contrainte, à condition qu'elle appartienne bien à l'agent.
|
||||
*
|
||||
* @return bool false si elle n'existe pas ou relève d'un autre agent.
|
||||
*/
|
||||
public function deleteConstraint(int $constraintId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -98,6 +155,18 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un souhait de lieu, en création ou en mise à jour.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE s'appuie sur l'unicité (agent, lieu) : un agent
|
||||
* n'a qu'un seul avis par lieu, réémettre un souhait le remplace au lieu de
|
||||
* l'empiler. Il RÉACTIVE aussi un souhait précédemment retiré.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé, ou 0 en cas de mise à jour (lastInsertId()
|
||||
* ne renvoie rien d'exploitable dans ce cas).
|
||||
*/
|
||||
public function saveWish(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -117,6 +186,11 @@ final class PtaModel extends BaseModel
|
||||
return (int) ($this->pdo->lastInsertId() ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un souhait, à condition qu'il appartienne bien à l'agent.
|
||||
*
|
||||
* @return bool false si absent ou relevant d'un autre agent.
|
||||
*/
|
||||
public function deleteWish(int $wishId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -126,6 +200,20 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le PTA de la période, en le CRÉANT s'il n'existe pas.
|
||||
*
|
||||
* Création paresseuse volontaire : un agent créé avant la mise en place du
|
||||
* PTA, ou dont on consulte une année non encore ouverte, obtient son
|
||||
* enregistrement à la première consultation plutôt qu'une page en erreur.
|
||||
*
|
||||
* Le PTA est identifié par (agent, date_debut, date_fin) et non par la seule
|
||||
* année : c'est la période de quota qui fait foi.
|
||||
*
|
||||
* Effet de bord assumé : une simple lecture peut écrire en base.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function ensurePta(array $agent, array $quota): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -174,6 +262,40 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetch() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse tous les créneaux de la période — méthode centrale du modèle.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. VENTILATION de chaque créneau dans une catégorie métier
|
||||
* (categoryForEntry()), avec cumul des minutes et des jours distincts ;
|
||||
* 2. AGRÉGATS par jour (durée, amplitude, créneaux triés) et par semaine ;
|
||||
* 3. CONTRÔLES RÉGLEMENTAIRES, dans cet ordre :
|
||||
* - durée quotidienne (> 10 h) et amplitude (> 12 h) ;
|
||||
* - travail continu > 6 h sans coupure d'au moins 20 minutes. Deux
|
||||
* créneaux séparés de moins de 20 minutes sont considérés comme
|
||||
* continus, la pause étant trop courte pour compter ;
|
||||
* - repos entre deux journées CONSÉCUTIVEMENT PLANIFIÉES : les dates
|
||||
* sans créneau sont ignorées, un week-end n'a pas à être contrôlé ;
|
||||
* - durée hebdomadaire > 48 h ;
|
||||
* - moyenne > 44 h sur 12 semaines glissantes. Le contrôle s'arrête à la
|
||||
* PREMIÈRE fenêtre fautive : les suivantes se recouvrent et
|
||||
* produiraient des dizaines de messages redondants ;
|
||||
* 4. CONTRÔLES MÉTIER LOCAUX : durée attendue des mercredis et de
|
||||
* l'extrascolaire selon le poste, semaines de vacances incomplètes pour
|
||||
* la famille ANIMATION, interdiction de préparation pour la famille
|
||||
* RESTAURATION_ENTRETIEN, enveloppe de formation, et objectifs de
|
||||
* 25 jours de congés / 2 journées de fractionnement.
|
||||
*
|
||||
* Un contrôle INFO est ajouté si rien n'a été trouvé, pour que l'interface
|
||||
* affiche « aucune anomalie » plutôt qu'une liste vide ambiguë.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
* @param array<string, mixed> $quota
|
||||
*
|
||||
* @return array{categories: array<int, array<string, mixed>>, counters: array<string, mixed>, controls: array<int, array<string, string>>, daily: array<string, mixed>, weekly: array<string, int>}
|
||||
*/
|
||||
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
|
||||
{
|
||||
$categories = [
|
||||
@@ -449,6 +571,15 @@ final class PtaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que l'agent relève bien du dispositif PTA : contrat permanent et
|
||||
* poste renseigné. Sans poste, aucune des règles de durée attendue ne peut
|
||||
* s'appliquer.
|
||||
*
|
||||
* @param array<string, mixed> $agent
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function eligibilityControls(array $agent): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -463,6 +594,24 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confronte les journées planifiées aux contraintes individuelles.
|
||||
*
|
||||
* Chaque contrainte produit d'abord une ALERTE de rappel, visible même sans
|
||||
* dépassement : l'agent de planification doit savoir qu'elle existe. Un
|
||||
* dépassement du maximum quotidien produit ensuite une ERREUR.
|
||||
*
|
||||
* Une contrainte sans date de fin est réputée toujours en vigueur.
|
||||
*
|
||||
* LIMITE CONNUE : `maximum_minutes_semaine`, `quotite_temporaire` et les
|
||||
* interdictions matin/midi/soir sont enregistrés et affichés, mais AUCUN
|
||||
* contrôle automatique ne les exploite encore.
|
||||
*
|
||||
* @param array<string, mixed> $analysis Sortie d'analyseEntries().
|
||||
* @param array<int, array<string, mixed>> $constraints
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function constraintControls(array $analysis, array $constraints): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -495,6 +644,18 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle des ménages de fond : au moins DEUX agents ensemble, et 7 h par
|
||||
* agent.
|
||||
*
|
||||
* La sous-requête EXISTS restreint l'analyse aux (date, lieu) où CET agent
|
||||
* est lui-même sur un ménage de fond ; l'agrégat compte alors tous les
|
||||
* agents présents sur ce même ménage, y compris ceux qui ne sont pas
|
||||
* l'agent consulté. Sans elle, la requête balaierait tous les ménages de
|
||||
* fond de la collectivité.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -543,6 +704,15 @@ final class PtaModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contraintes actives qui INTERSECTENT la période.
|
||||
*
|
||||
* Le COALESCE(date_fin, '9999-12-31') traite une contrainte sans terme comme
|
||||
* courant indéfiniment ; un simple `date_fin >= :date_debut` l'écarterait,
|
||||
* NULL n'étant comparable à rien.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -560,6 +730,14 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Souhaits de lieux de l'agent.
|
||||
*
|
||||
* Le FIELD() force les INTERDICTIONS en tête, avant les préférences : une
|
||||
* interdiction est une contrainte, une préférence un simple souhait.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function wishesForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -574,6 +752,21 @@ final class PtaModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe un créneau dans une catégorie métier du PTA.
|
||||
*
|
||||
* Le motif générique `TRAVAIL` est ventilé selon le CONTEXTE : extrascolaire
|
||||
* pendant les vacances, mercredi périscolaire le mercredi (weekday 3),
|
||||
* périscolaire sinon. C'est l'héritage des saisies antérieures aux motifs
|
||||
* détaillés, qui doivent rester correctement réparties.
|
||||
*
|
||||
* Un motif inconnu suit son drapeau `compte_dans_quota` : périscolaire s'il
|
||||
* compte, non décompté sinon — le comportement le moins surprenant pour un
|
||||
* motif ajouté en base après coup.
|
||||
*
|
||||
* @param int $weekday 1 (lundi) à 7 (dimanche).
|
||||
* @param bool $counts Valeur de compte_dans_quota du motif.
|
||||
*/
|
||||
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -592,6 +785,7 @@ final class PtaModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/** Libellé affichable d'une catégorie ; le code brut sert de repli. */
|
||||
private function categoryLabel(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -609,6 +803,15 @@ final class PtaModel extends BaseModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de vacances contenant cette date, ou null.
|
||||
* Copie locale : les périodes sont déjà chargées en mémoire pour toute
|
||||
* l'année analysée.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vacations
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function vacationForDate(string $date, array $vacations): ?array
|
||||
{
|
||||
foreach ($vacations as $vacation) {
|
||||
@@ -619,6 +822,13 @@ final class PtaModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée en minutes entre deux horaires.
|
||||
*
|
||||
* SANS plancher à zéro, contrairement à ses homonymes des autres modèles :
|
||||
* le calcul de la pause continue a besoin des valeurs négatives pour
|
||||
* détecter deux créneaux qui se recouvrent.
|
||||
*/
|
||||
private function minutesBetween(string $start, string $end): int
|
||||
{
|
||||
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
|
||||
@@ -626,6 +836,7 @@ final class PtaModel extends BaseModel
|
||||
return ($eh * 60 + $em) - ($sh * 60 + $sm);
|
||||
}
|
||||
|
||||
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
@@ -633,11 +844,19 @@ final class PtaModel extends BaseModel
|
||||
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
/** « 2026-02-17 » → « 17/02/2026 », format des messages utilisateur. */
|
||||
private function formatDate(string $date): string
|
||||
{
|
||||
return (new DateTimeImmutable($date))->format('d/m/Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique un contrôle au format commun {niveau, code, message}.
|
||||
*
|
||||
* @param string $level ERREUR, ALERTE ou INFO.
|
||||
*
|
||||
* @return array{niveau: string, code: string, message: string}
|
||||
*/
|
||||
private function control(string $level, string $code, string $message): array
|
||||
{
|
||||
return ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||||
|
||||
@@ -28,6 +28,16 @@ final class QuotaModel extends BaseModel
|
||||
*/
|
||||
public const REFERENCE_MINUTES = 1607 * 60;
|
||||
|
||||
/**
|
||||
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
|
||||
*
|
||||
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
|
||||
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
|
||||
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
|
||||
* fausserait un suivi déjà entamé.
|
||||
*
|
||||
* @param float $rate Quotité en pourcentage (100 = temps plein).
|
||||
*/
|
||||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||
{
|
||||
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
|
||||
@@ -53,6 +63,19 @@ final class QuotaModel extends BaseModel
|
||||
*
|
||||
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
|
||||
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
|
||||
*
|
||||
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
|
||||
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
|
||||
* un brouillon consomme déjà le quota du point de vue de la planification,
|
||||
* la distinction ne sert qu'à indiquer ce qui est acquis.
|
||||
*
|
||||
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
|
||||
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
|
||||
*
|
||||
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
|
||||
* ALERTE à la validation, jamais bloqué.
|
||||
*
|
||||
* @return array<string, mixed> Période de référence enrichie des compteurs.
|
||||
*/
|
||||
public function forDate(int $agentId, string $contextDate): array
|
||||
{
|
||||
@@ -100,16 +123,33 @@ final class QuotaModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idem forDate(), avec les libellés lisibles ajoutés par enrich().
|
||||
* C'est cette variante que consomment tous les endpoints JSON.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forDateForApi(int $agentId, string $contextDate): array
|
||||
{
|
||||
return $this->enrich($this->forDate($agentId, $contextDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
|
||||
* la période étant de toute façon l'année civile complète.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function annual(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* Idem annual(), enrichi des libellés.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function annualForApi(int $agentId, int $year): array
|
||||
{
|
||||
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
|
||||
@@ -118,6 +158,16 @@ final class QuotaModel extends BaseModel
|
||||
/**
|
||||
* Période de référence PTA : toujours l'année civile.
|
||||
* La date de contrat est retournée uniquement à titre informatif.
|
||||
*
|
||||
* Les clés `periode_contractuelle`, `periode_civile` et
|
||||
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
|
||||
* pouvait être ancrée sur la date de contrat. Elles sont figées
|
||||
* (false / true / false) et conservées pour ne pas casser les écrans qui les
|
||||
* lisent encore. Ne pas construire de nouvelle logique dessus.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws InvalidArgumentException Si la date de contexte est malformée.
|
||||
*/
|
||||
public function referencePeriod(int $agentId, string $contextDate): array
|
||||
{
|
||||
@@ -151,6 +201,16 @@ final class QuotaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute les versions lisibles des compteurs à un quota brut.
|
||||
*
|
||||
* Les clés en minutes restent présentes : le JavaScript calcule sur les
|
||||
* minutes et n'affiche que les libellés.
|
||||
*
|
||||
* @param array<string, mixed> $quota
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function enrich(array $quota): array
|
||||
{
|
||||
return array_merge($quota, [
|
||||
@@ -162,6 +222,16 @@ final class QuotaModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une durée en minutes sous ses trois représentations.
|
||||
*
|
||||
* Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait
|
||||
* avant la division, sans quoi intdiv() et le modulo produiraient un libellé
|
||||
* incohérent du type « -1 h -30 ».
|
||||
*
|
||||
* @return array{minutes: int, heures_decimales: float, libelle: string}
|
||||
* Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ».
|
||||
*/
|
||||
public function formatMinutes(int $minutes): array
|
||||
{
|
||||
$sign = $minutes < 0 ? -1 : 1;
|
||||
@@ -176,6 +246,24 @@ final class QuotaModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve la ligne de quota la plus pertinente pour une année donnée.
|
||||
*
|
||||
* Le tri du ORDER BY applique une règle de repli en trois temps :
|
||||
* 1. l'année demandée exactement ;
|
||||
* 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus
|
||||
* récente reste la meilleure estimation) ;
|
||||
* 3. sinon l'année POSTÉRIEURE la plus proche.
|
||||
*
|
||||
* Sans aucune ligne — agent créé avant la mise en place des quotas — on
|
||||
* retombe sur un temps plein théorique plutôt que sur un quota nul, qui
|
||||
* afficherait un dépassement à la première heure planifiée.
|
||||
*
|
||||
* `quota_source_annee` indique quelle année a réellement servi, pour que
|
||||
* l'interface puisse signaler un quota emprunté à une autre année.
|
||||
*
|
||||
* @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int}
|
||||
*/
|
||||
private function quotaProfile(int $agentId, int $year): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -4,8 +4,36 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Contrôle de conformité du schéma, joué à CHAQUE requête par app/bootstrap.php.
|
||||
*
|
||||
* Ce garde-fou existe parce que les migrations sont des fichiers SQL appliqués
|
||||
* par un service séparé : rien ne garantit que la base en face du code soit à
|
||||
* jour. Plutôt que de laisser l'application échouer plus tard sur une erreur SQL
|
||||
* cryptique au milieu d'un écran, on refuse de démarrer avec un message qui
|
||||
* nomme précisément ce qui manque et quel fichier jouer.
|
||||
*
|
||||
* CONSÉQUENCE POUR LE DÉVELOPPEUR : ajouter une table ou une colonne obligatoire
|
||||
* impose de compléter les listes ci-dessous EN MÊME TEMPS que la migration.
|
||||
* Oublier de le faire ne casse rien tout de suite — c'est justement le piège :
|
||||
* l'application démarrera sur une base incomplète.
|
||||
*
|
||||
* Le coût est de trois requêtes sur information_schema par requête HTTP,
|
||||
* accepté au regard du volume d'utilisateurs de l'application.
|
||||
*/
|
||||
final class SchemaModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Vérifie tables, colonnes puis vues d'accès.
|
||||
*
|
||||
* Seules les colonnes AJOUTÉES PAR MIGRATION sont listées, pas l'intégralité
|
||||
* du schéma : ce sont les seules susceptibles de manquer sur une base créée
|
||||
* à partir de database/pta_prod_schema_mariadb.sql, qui ne contient que le
|
||||
* socle métier.
|
||||
*
|
||||
* @throws \RuntimeException Au premier manque constaté, avec le détail et
|
||||
* le fichier SQL à exécuter.
|
||||
*/
|
||||
public function assertCurrent(): void
|
||||
{
|
||||
$requiredTables = [
|
||||
|
||||
@@ -4,8 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Référentiel des lieux d'affectation (`structure`).
|
||||
*
|
||||
* Toutes les lectures filtrent sur `actif = TRUE` : un lieu désactivé disparaît
|
||||
* des listes et ne peut plus être choisi, mais ses plannings passés restent
|
||||
* intacts. Aucune méthode de désactivation n'est exposée dans cette version, la
|
||||
* bascule se fait en SQL.
|
||||
*
|
||||
* `type_affectation` (PERISCOLAIRE | EXTRASCOLAIRE) donne le contexte par défaut
|
||||
* du lieu ; il reste croisé avec le calendrier des vacances (VacationModel).
|
||||
*/
|
||||
final class StructureModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Tous les lieux actifs, triés par nom.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function allActive(): array
|
||||
{
|
||||
return $this->pdo->query(
|
||||
@@ -13,6 +29,12 @@ final class StructureModel extends BaseModel
|
||||
)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu actif par identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null null si inexistant OU désactivé — les
|
||||
* appelants traitent les deux cas de la même façon.
|
||||
*/
|
||||
public function findActive(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -22,6 +44,10 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le code est déjà pris, DÉSACTIVÉS COMPRIS : le code reste unique
|
||||
* en base, réutiliser celui d'un lieu fermé violerait la contrainte.
|
||||
*/
|
||||
public function codeExists(string $code): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
|
||||
@@ -29,6 +55,14 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un lieu actif et renvoie sa fiche complète.
|
||||
*
|
||||
* La fiche est reconstituée en PHP plutôt que relue en base : l'appelant en
|
||||
* a besoin pour l'insérer dans la liste affichée sans recharger la page.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function create(string $code, string $name, ?string $address, string $typeAffectation): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -51,6 +85,10 @@ final class StructureModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le type d'un lieu. La valeur est contrôlée par le contrôleur, ce
|
||||
* modèle ne fait qu'écrire.
|
||||
*/
|
||||
public function updateType(int $structureId, string $typeAffectation): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -62,6 +100,20 @@ final class StructureModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents à afficher pour un lieu et une semaine.
|
||||
*
|
||||
* Réunit DEUX populations, d'où les LEFT JOIN et le OR de la clause WHERE :
|
||||
* - les agents RATTACHÉS au lieu (a.id_structure), même sans planning ;
|
||||
* - les agents PLANIFIÉS sur le lieu cette semaine, quel que soit leur
|
||||
* rattachement (sem.id_semaine IS NOT NULL).
|
||||
*
|
||||
* Omettre la seconde donnerait une vue fausse de l'effectif présent, un
|
||||
* agent pouvant travailler sur plusieurs lieux la même semaine.
|
||||
* `id_structure_principale` permet à l'interface de les distinguer.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function agentsForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -88,6 +140,14 @@ final class StructureModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'un lieu sur une semaine, tous agents confondus.
|
||||
*
|
||||
* Une seule lecture, regroupée ensuite par agent en PHP par l'appelant,
|
||||
* plutôt qu'une requête par agent. Heures en HH:MM, format d'affichage.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entriesForOverview(int $structureId, int $year, int $week): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -6,8 +6,38 @@ namespace App\Models;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Équipes des mercredis et des vacances, et leurs contrôles d'encadrement.
|
||||
*
|
||||
* Le NOMBRE D'AGENTS REQUIS est calculé en SQL, au plus contraignant des deux
|
||||
* règles :
|
||||
* GREATEST(minimum_agents,
|
||||
* CEIL(enfants_moins_6 / ratio_moins_6)
|
||||
* + CEIL(enfants_6_plus / ratio_6_plus))
|
||||
* Chaque tranche d'âge est arrondie au SUPÉRIEUR séparément avant d'être
|
||||
* additionnée : un demi-agent n'existe pas, et sommer avant d'arrondir
|
||||
* sous-estimerait l'encadrement.
|
||||
*
|
||||
* Les contrôles suivent la forme commune {niveau, code, message}. Aucun n'est
|
||||
* bloquant : ils signalent, l'arbitrage restant humain.
|
||||
*
|
||||
* Cycle de vie propre aux équipes : BROUILLON → A_CONTROLER → VALIDEE, sans
|
||||
* transition imposée.
|
||||
*/
|
||||
final class TeamModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Toutes les équipes, avec leurs membres et leurs contrôles.
|
||||
*
|
||||
* Le filtre de dates cherche les équipes qui INTERSECTENT l'intervalle
|
||||
* (fin >= début demandé ET début <= fin demandée) et non celles qui y sont
|
||||
* incluses : une équipe à cheval sur les bornes doit remonter.
|
||||
*
|
||||
* ATTENTION AU COÛT : deux requêtes supplémentaires par équipe (membres puis
|
||||
* contrôles). Acceptable sur quelques dizaines d'équipes, à revoir au-delà.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
|
||||
@@ -56,6 +86,15 @@ final class TeamModel extends BaseModel
|
||||
return $teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une équipe par identifiant.
|
||||
*
|
||||
* Passe par all() plutôt que par une requête dédiée, afin de bénéficier des
|
||||
* mêmes agrégats et contrôles. Coûteux, mais cette méthode n'est pas sur un
|
||||
* chemin critique.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(int $teamId): ?array
|
||||
{
|
||||
foreach ($this->all() as $team) {
|
||||
@@ -66,6 +105,17 @@ final class TeamModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une équipe, toujours en BROUILLON.
|
||||
*
|
||||
* $data est passé tel quel à execute() : ses clés doivent correspondre
|
||||
* exactement aux paramètres nommés. La validation est faite par
|
||||
* TeamController::index().
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return int Identifiant créé.
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -85,6 +135,20 @@ final class TeamModel extends BaseModel
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un agent à une équipe, ou met à jour sa fonction s'il y est déjà.
|
||||
*
|
||||
* Un seul refus est automatique : une INTERDICTION active de l'agent pour ce
|
||||
* lieu (agent_structure_souhait). Tout le reste — contraintes médicales,
|
||||
* temps partiel thérapeutique — est signalé dans les contrôles de l'équipe
|
||||
* et laissé à l'appréciation humaine, ces situations pouvant être compatibles
|
||||
* avec certaines affectations.
|
||||
*
|
||||
* Les RuntimeException levées ici portent un message destiné à l'utilisateur
|
||||
* final, que le contrôleur renvoie tel quel en 422.
|
||||
*
|
||||
* @throws \RuntimeException Équipe introuvable ou interdiction active.
|
||||
*/
|
||||
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
|
||||
{
|
||||
$team = $this->teamDates($teamId);
|
||||
@@ -125,6 +189,11 @@ final class TeamModel extends BaseModel
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire un agent d'une équipe.
|
||||
*
|
||||
* @return bool false s'il n'en faisait pas partie.
|
||||
*/
|
||||
public function removeMember(int $teamId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -134,12 +203,27 @@ final class TeamModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le statut d'une équipe. La valeur est contrôlée par le contrôleur ;
|
||||
* aucune transition n'est imposée, y compris un retour en arrière.
|
||||
*/
|
||||
public function setStatus(int $teamId, string $status): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
|
||||
$stmt->execute(['statut' => $status, 'id' => $teamId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membres d'une équipe, enrichis de tout ce dont les contrôles ont besoin :
|
||||
* diplôme, poste, lieu de rattachement, souhait exprimé pour ce lieu, et
|
||||
* nombre de contraintes actives sur la période de l'équipe (sous-requête
|
||||
* corrélée, calculée pour la seule fenêtre de dates de l'équipe).
|
||||
*
|
||||
* Le FIELD() ordonne par fonction — responsable, animation, restauration —
|
||||
* plutôt qu'alphabétiquement : c'est l'ordre de lecture d'un trombinoscope.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function members(int $teamId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -171,6 +255,23 @@ final class TeamModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôles d'une équipe.
|
||||
*
|
||||
* Deux ERREUR (effectif insuffisant, taux de diplômés sous le seuil), deux
|
||||
* ALERTE (aucun responsable identifié, membre sous contrainte médicale) et
|
||||
* une INFO signalant qu'une préférence de l'agent est satisfaite. Un INFO
|
||||
* « conforme » est ajouté si rien n'a été relevé, pour distinguer l'absence
|
||||
* d'anomalie d'une absence de contrôle.
|
||||
*
|
||||
* Le taux de diplômés est calculé sur les agents AFFECTÉS, pas sur les
|
||||
* agents requis : c'est la composition réelle de l'équipe qui compte.
|
||||
*
|
||||
* @param array<string, mixed> $team
|
||||
* @param array<int, array<string, mixed>> $members
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function controls(array $team, array $members): array
|
||||
{
|
||||
$controls = [];
|
||||
@@ -225,6 +326,11 @@ final class TeamModel extends BaseModel
|
||||
return $controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lieu et dates d'une équipe — lecture légère, sans les agrégats de all().
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function teamDates(int $teamId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -7,8 +7,26 @@ namespace App\Models;
|
||||
use DateTimeImmutable;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Périodes de vacances scolaires (`periode_vacance`).
|
||||
*
|
||||
* Ces périodes déterminent le contexte PÉRISCOLAIRE / EXTRASCOLAIRE de chaque
|
||||
* journée : une date incluse dans une période active est extrascolaire, sinon
|
||||
* elle est périscolaire. C'est cette qualification qui pilote le filtrage des
|
||||
* semaines types (WeekTemplateModel) et le contexte affiché dans l'éditeur.
|
||||
*
|
||||
* Les périodes peuvent être saisies à la main ou importées depuis
|
||||
* data.education.gouv.fr, la colonne `source` conservant l'origine.
|
||||
*/
|
||||
final class VacationModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Périodes actives, éventuellement restreintes à une année scolaire.
|
||||
*
|
||||
* @param string|null $schoolYear Ex. « 2026-2027 », ou null pour tout.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(?string $schoolYear = null): array
|
||||
{
|
||||
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
|
||||
@@ -28,6 +46,22 @@ final class VacationModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un lot de périodes, en INSERT ou mise à jour.
|
||||
*
|
||||
* Le ON DUPLICATE KEY UPDATE s'appuie sur la clé d'unicité du schéma : un
|
||||
* réimport de la même période met à jour son libellé et sa zone au lieu de
|
||||
* la dupliquer, et la RÉACTIVE (`actif = TRUE`) si elle avait été retirée.
|
||||
* Les DATES ne sont volontairement pas mises à jour : ce sont elles qui
|
||||
* identifient la période, les modifier reviendrait à en créer une autre.
|
||||
*
|
||||
* Le lot est transactionnel : soit toutes les périodes sélectionnées entrent,
|
||||
* soit aucune.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $periods Déjà validées par le contrôleur.
|
||||
*
|
||||
* @return int Nombre de périodes traitées.
|
||||
*/
|
||||
public function saveMany(array $periods): int
|
||||
{
|
||||
if ($periods === []) {
|
||||
@@ -69,6 +103,11 @@ final class VacationModel extends BaseModel
|
||||
return count($periods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement une période (et non une désactivation).
|
||||
*
|
||||
* @return bool false si la période n'existait pas.
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id');
|
||||
@@ -76,6 +115,16 @@ final class VacationModel extends BaseModel
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Périodes actives qui INTERSECTENT l'intervalle demandé.
|
||||
*
|
||||
* Le test `debut <= fin_demandee AND fin >= debut_demande` est un
|
||||
* recouvrement, pas une inclusion : une période à cheval sur les bornes doit
|
||||
* remonter, sans quoi les jours de vacances en début ou fin d'intervalle
|
||||
* seraient qualifiés à tort de périscolaires.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function periodsBetween(string $startDate, string $endDate): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -97,6 +146,13 @@ final class VacationModel extends BaseModel
|
||||
* Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO.
|
||||
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
|
||||
* sinon elle est PERISCOLAIRE.
|
||||
*
|
||||
* Toujours cinq entrées, lundi à vendredi : le modèle métier s'arrête au
|
||||
* vendredi. Une semaine peut être MIXTE (vacances commençant en cours de
|
||||
* semaine), c'est à l'appelant d'en tirer les conséquences —
|
||||
* DashboardController::weekPayload() par exemple.
|
||||
*
|
||||
* @return array<int, array{date: string, type_affectation: string, vacances: array<string, mixed>|null}>
|
||||
*/
|
||||
public function typesForIsoWeek(int $year, int $week): array
|
||||
{
|
||||
|
||||
@@ -8,8 +8,42 @@ use DateTimeImmutable;
|
||||
use DomainException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Semaines types : modèles lundi→vendredi rattachés à un agent.
|
||||
*
|
||||
* Deux tables : `modele_semaine_agent` (l'en-tête, nom + agent) et
|
||||
* `modele_semaine_creneau` (les plages, repérées par un JOUR DE LA SEMAINE de 1
|
||||
* à 5 et non par une date). Un modèle ne produit aucun créneau tant qu'il n'est
|
||||
* pas appliqué.
|
||||
*
|
||||
* Deux voies d'entrée qui convergent sur persistTemplate() :
|
||||
* saveFromWeek() ← capture d'une semaine réellement planifiée ;
|
||||
* saveFromEntries() ← composition directe dans l'éditeur (V7.10).
|
||||
*
|
||||
* Deux voies de sortie :
|
||||
* applyToWeek() ← une semaine précise ;
|
||||
* applyToPeriod() ← toutes les semaines d'un intervalle, avec filtrage sur le
|
||||
* calendrier scolaire ; previewPeriod() en est la simulation
|
||||
* sans écriture, les deux partageant buildPeriodPlan().
|
||||
*
|
||||
* Conventions communes à tout le fichier :
|
||||
* - les heures sont manipulées en HH:MM:SS, format des colonnes TIME (l'éditeur
|
||||
* les tronque à HH:MM pour l'affichage seulement) ;
|
||||
* - les erreurs métier sont des DomainException dont le MESSAGE est destiné à
|
||||
* l'utilisateur final et affiché tel quel ;
|
||||
* - toute écriture est transactionnelle, et un planning validé touché repasse
|
||||
* en BROUILLON.
|
||||
*/
|
||||
final class WeekTemplateModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Modèles d'un agent, avec leurs compteurs de plages et de lieux.
|
||||
*
|
||||
* LEFT JOIN volontaire : un modèle vidé de ses plages reste listé, avec un
|
||||
* compteur à zéro, plutôt que de disparaître silencieusement.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -26,6 +60,15 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un modèle par (identifiant, agent).
|
||||
*
|
||||
* Le double critère est le contrôle de périmètre : il rend impossible la
|
||||
* lecture du modèle d'un autre agent en changeant l'identifiant dans l'URL.
|
||||
* Toutes les méthodes publiques passent par ici avant d'agir.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findForAgent(int $templateId, int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -38,6 +81,15 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plages d'un modèle, enrichies du lieu et du motif.
|
||||
*
|
||||
* `structure_actif` et `motif_actif` sont remontés parce qu'un lieu ou un
|
||||
* motif peut être désactivé APRÈS l'enregistrement du modèle : l'application
|
||||
* refuse alors le modèle, avec un message qui nomme l'élément fautif.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function entries(int $templateId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -60,6 +112,20 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une semaine réellement planifiée comme modèle réutilisable.
|
||||
*
|
||||
* Les dates de la semaine source sont converties en jours de la semaine :
|
||||
* c'est ce qui rend le modèle rejouable n'importe quand.
|
||||
*
|
||||
* @param bool $replaceExisting true pour écraser un modèle de même nom
|
||||
* (confirmation donnée par l'utilisateur après
|
||||
* un premier refus en 409).
|
||||
*
|
||||
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
|
||||
*
|
||||
* @throws DomainException Nom invalide, semaine source vide, ou nom déjà pris.
|
||||
*/
|
||||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||||
{
|
||||
// La colonne `nom` fait 120 caractères : la borne annoncée à
|
||||
@@ -85,6 +151,11 @@ final class WeekTemplateModel extends BaseModel
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds motifs affectables à cet agent
|
||||
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
|
||||
*
|
||||
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
|
||||
*
|
||||
* @throws DomainException Nom invalide, modèle vide, plage refusée,
|
||||
* chevauchement, ou nom déjà pris.
|
||||
*/
|
||||
public function saveFromEntries(
|
||||
int $agentId,
|
||||
@@ -104,6 +175,18 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assainit et contrôle le nom d'un modèle.
|
||||
*
|
||||
* Les suites d'espaces sont réduites à un seul (motif /u, donc sûr en
|
||||
* UTF-8), afin que « Semaine type » et « Semaine type » ne coexistent pas
|
||||
* comme deux modèles distincts. La borne de 120 caractères est celle de la
|
||||
* colonne : contrôlée ici, elle produit un message clair au lieu d'une
|
||||
* troncature silencieuse de MariaDB. mb_strlen() compte des CARACTÈRES, pas
|
||||
* des octets.
|
||||
*
|
||||
* @throws DomainException Si le nom est vide ou trop long.
|
||||
*/
|
||||
private function normalizeName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
@@ -114,10 +197,24 @@ final class WeekTemplateModel extends BaseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les plages saisies dans l'éditeur et les met en forme.
|
||||
*
|
||||
* Chaque champ est revalidé côté serveur, y compris l'appartenance du motif
|
||||
* et du lieu aux listes autorisées calculées par le contrôleur. Les messages
|
||||
* d'erreur nomment le JOUR concerné : c'est le seul repère dont dispose
|
||||
* l'utilisateur pour retrouver la plage fautive dans la grille.
|
||||
*
|
||||
* Le tri final (jour, puis heure de début) rend l'ordre d'affichage stable
|
||||
* quel que soit l'ordre d'envoi du navigateur.
|
||||
*
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds
|
||||
* @param int[] $allowedStructureIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*
|
||||
* @throws DomainException Plage illisible, jour hors lundi-vendredi,
|
||||
* horaires invalides, motif ou lieu non autorisé,
|
||||
* ou chevauchement.
|
||||
*/
|
||||
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
|
||||
{
|
||||
@@ -182,6 +279,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrôle un horaire au quart d'heure et le ramène en HH:MM:SS.
|
||||
*
|
||||
* Accepte HH:MM comme HH:MM:SS en entrée (le navigateur envoie l'un ou
|
||||
* l'autre selon le champ) et normalise toujours vers HH:MM:SS.
|
||||
*
|
||||
* @param string $dayLabel Jour en toutes lettres, pour le message d'erreur.
|
||||
*
|
||||
* @throws DomainException Si l'horaire n'est pas un quart d'heure valide.
|
||||
*/
|
||||
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
|
||||
{
|
||||
$time = trim((string) $value);
|
||||
@@ -198,8 +305,13 @@ final class WeekTemplateModel extends BaseModel
|
||||
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
|
||||
* lieux différents : un agent n'est jamais à deux endroits à la fois.
|
||||
*
|
||||
* Comparaison en O(n²), assumée : un modèle plafonne à quelques dizaines de
|
||||
* plages.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, string> $jours
|
||||
* @param array<int, string> $jours Libellés indexés par numéro de jour.
|
||||
*
|
||||
* @throws DomainException Au premier chevauchement rencontré.
|
||||
*/
|
||||
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
|
||||
{
|
||||
@@ -227,7 +339,23 @@ final class WeekTemplateModel extends BaseModel
|
||||
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
|
||||
* une semaine réellement planifiée ou l'éditeur de semaine type.
|
||||
*
|
||||
* L'unicité porte sur le couple (agent, nom). Le SELECT … FOR UPDATE ferme
|
||||
* la fenêtre entre le contrôle et l'insertion, où deux enregistrements
|
||||
* simultanés créeraient deux modèles homonymes.
|
||||
*
|
||||
* En remplacement, les plages sont SUPPRIMÉES puis réinsérées et l'en-tête
|
||||
* conserve son identifiant : les écrans qui référencent le modèle continuent
|
||||
* de le retrouver.
|
||||
*
|
||||
* Le message « Une semaine type porte déjà ce nom » est reconnu par
|
||||
* WeekTemplateController pour distinguer un 409 d'un 422 — le reformuler
|
||||
* casse cette distinction.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*
|
||||
* @return array{id_modele: int, nom: string, nombre_creneaux: int}
|
||||
*
|
||||
* @throws DomainException Si le nom est déjà pris et $replaceExisting est faux.
|
||||
*/
|
||||
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
|
||||
{
|
||||
@@ -290,6 +418,32 @@ final class WeekTemplateModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un modèle à UNE semaine précise.
|
||||
*
|
||||
* Les jours de la semaine du modèle sont projetés sur les dates réelles de
|
||||
* la semaine cible (jour 1 = lundi de cette semaine).
|
||||
*
|
||||
* Deux modes :
|
||||
* - `replace` : tous les créneaux de l'agent sur cette semaine sont
|
||||
* supprimés, TOUS LIEUX CONFONDUS — un modèle décrit une semaine
|
||||
* complète, pas le complément d'un lieu ;
|
||||
* - `merge` : les créneaux existants sont conservés et le moindre
|
||||
* chevauchement fait échouer l'opération entière.
|
||||
*
|
||||
* Le cache $planningByStructure évite de rechercher le planning à chaque
|
||||
* plage. Il est manipulé par référence (&$planningInfo) pour que la remise
|
||||
* en brouillon d'un planning soit visible aux itérations suivantes et ne
|
||||
* déclenche pas plusieurs UPDATE ; le `unset` en fin de boucle est
|
||||
* indispensable, une référence PHP survivant à l'itération.
|
||||
*
|
||||
* @param string $mode « merge » ou « replace ».
|
||||
*
|
||||
* @return array{template: array<string, mixed>, inserted: int, mode: string, reopened_planning_ids: int[]}
|
||||
*
|
||||
* @throws DomainException Mode invalide, modèle introuvable ou vide, lieu ou
|
||||
* motif désactivé depuis, ou conflit en mode merge.
|
||||
*/
|
||||
public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array
|
||||
{
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
@@ -450,6 +604,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simule l'application sur une période, SANS RIEN ÉCRIRE.
|
||||
*
|
||||
* Étape imposée par l'interface avant applyToPeriod() : l'opération touche
|
||||
* des dizaines de semaines et peut rouvrir des plannings validés.
|
||||
*
|
||||
* @param string $periodFilter all | school | extra.
|
||||
*
|
||||
* @return array<string, mixed> Voir buildPeriodPlan() pour le détail.
|
||||
*/
|
||||
public function previewPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
@@ -461,6 +625,26 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un modèle à toutes les semaines d'une période.
|
||||
*
|
||||
* Reprend le plan calculé par buildPeriodPlan(), puis écrit. Deux modes :
|
||||
* - `merge` : échec au moindre conflit, avec un message qui décrit le
|
||||
* PREMIER conflit rencontré et oriente vers l'aperçu ou le mode remplacement ;
|
||||
* - `replace` : les créneaux existants sont supprimés sur les SEULES DATES
|
||||
* RÉELLEMENT VISÉES ($targetDateLookup). Une date ignorée par le filtre
|
||||
* de calendrier n'est donc pas vidée — sans quoi appliquer un modèle
|
||||
* « périodes scolaires » effacerait aussi les vacances.
|
||||
*
|
||||
* Le tout dans UNE transaction : sur des centaines de créneaux, un échec à
|
||||
* mi-parcours laisserait un état ininterprétable.
|
||||
*
|
||||
* @return array<string, mixed> Compteurs détaillés (inserted, deleted,
|
||||
* weeks_touched, days_touched, skipped_*, reopened_planning_ids…),
|
||||
* repris tels quels dans le message rendu à l'utilisateur.
|
||||
*
|
||||
* @throws DomainException Aucune date éligible, ou conflit en mode merge.
|
||||
*/
|
||||
public function applyToPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
@@ -646,6 +830,14 @@ final class WeekTemplateModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un modèle et, par cascade SQL, ses plages.
|
||||
*
|
||||
* Les créneaux DÉJÀ APPLIQUÉS ne sont pas touchés : le modèle est un patron,
|
||||
* pas un lien vivant vers les plannings qu'il a produits.
|
||||
*
|
||||
* @return bool false si le modèle n'existe pas ou appartient à un autre agent.
|
||||
*/
|
||||
public function delete(int $templateId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -655,6 +847,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->rowCount() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux d'une semaine réelle, convertis en jours de la semaine.
|
||||
*
|
||||
* `WEEKDAY() + 1` donne 1 (lundi) à 5 (vendredi) — à ne pas confondre avec
|
||||
* DAYOFWEEK(), qui commence au dimanche. Le filtre `BETWEEN 0 AND 4` écarte
|
||||
* le week-end, et la contrainte sur les bornes de la semaine écarte un
|
||||
* créneau égaré hors de sa semaine.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -680,6 +882,39 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le plan d'application sur une période — cœur de previewPeriod() et
|
||||
* d'applyToPeriod(), qui partagent ainsi exactement le même calcul.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. contrôles d'entrée (mode, filtre, dates, plafond de deux ans — borne
|
||||
* de sécurité contre une saisie erronée qui produirait des dizaines de
|
||||
* milliers de créneaux) ;
|
||||
* 2. lecture du modèle, avec refus si un lieu ou un motif a été désactivé
|
||||
* depuis son enregistrement ;
|
||||
* 3. parcours JOUR PAR JOUR de la période, week-ends exclus. Chaque jour
|
||||
* est qualifié de PERISCOLAIRE ou EXTRASCOLAIRE selon qu'il tombe ou non
|
||||
* dans une période de vacances, puis retenu ou écarté selon
|
||||
* $periodFilter ;
|
||||
* 4. contrôle de cohérence interne du plan produit ;
|
||||
* 5. si $includeExistingAnalysis, confrontation aux créneaux déjà en base
|
||||
* pour dénombrer les conflits.
|
||||
*
|
||||
* Les plages écartées sont CONSERVÉES dans `skipped_entries` avec leur motif
|
||||
* d'exclusion : l'utilisateur doit pouvoir vérifier que le filtre a fait ce
|
||||
* qu'il attendait, un simple compteur ne le permettrait pas.
|
||||
*
|
||||
* @param bool $includeExistingAnalysis true en aperçu (on veut voir les
|
||||
* conflits sans verrouiller) ; false en application, où
|
||||
* applyToPeriod() refait l'analyse dans sa transaction, sous verrou.
|
||||
*
|
||||
* @return array<string, mixed> template, target_entries, skipped_entries,
|
||||
* conflicts, days, vacation_periods et summary.
|
||||
*
|
||||
* @throws DomainException Mode ou filtre invalide, dates incohérentes,
|
||||
* période trop longue, modèle introuvable ou vide,
|
||||
* lieu ou motif désactivé, chevauchement interne.
|
||||
*/
|
||||
private function buildPeriodPlan(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
@@ -861,6 +1096,17 @@ final class WeekTemplateModel extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode une date « AAAA-MM-JJ » en refusant tout report implicite.
|
||||
*
|
||||
* getLastErrors() est indispensable : sans ce contrôle, PHP accepterait
|
||||
* « 2026-02-31 » en le reportant au 3 mars. Le « ! » remet à zéro les champs
|
||||
* non fournis, sinon l'heure courante serait injectée.
|
||||
*
|
||||
* @param string $label Libellé du champ, repris dans le message d'erreur.
|
||||
*
|
||||
* @throws DomainException Si la date est invalide.
|
||||
*/
|
||||
private function parseStrictDate(string $value, string $label): DateTimeImmutable
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
@@ -872,6 +1118,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Période de vacances contenant cette date, ou null (jour scolaire).
|
||||
*
|
||||
* Comparaison de chaînes : le format ISO AAAA-MM-JJ se trie
|
||||
* chronologiquement, aucune conversion en objet n'est nécessaire.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $periods
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function vacationForDate(string $date, array $periods): ?array
|
||||
{
|
||||
foreach ($periods as $period) {
|
||||
@@ -882,6 +1138,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux existants de l'agent sur une période, TOUS LIEUX confondus.
|
||||
*
|
||||
* @param bool $forUpdate true pour verrouiller les lignes ; à n'utiliser
|
||||
* que dans une transaction, et donc uniquement à l'application.
|
||||
* L'aperçu lit sans verrou, pour ne pas bloquer les autres
|
||||
* utilisateurs pendant une simple simulation.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array
|
||||
{
|
||||
$sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour,
|
||||
@@ -907,6 +1173,17 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confronte le plan aux créneaux existants et liste TOUS les conflits.
|
||||
*
|
||||
* Contrairement aux assert*(), ne lève pas : l'aperçu doit présenter
|
||||
* l'ensemble des conflits, à charge de l'appelant de décider quoi en faire.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $targetEntries
|
||||
* @param array<int, array<string, mixed>> $existingEntries
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Un élément par paire en conflit.
|
||||
*/
|
||||
private function findConflicts(array $targetEntries, array $existingEntries): array
|
||||
{
|
||||
$conflicts = [];
|
||||
@@ -932,6 +1209,15 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de la semaine, en la créant si besoin.
|
||||
*
|
||||
* COPIE de PlanningModel::ensureWeek(), les deux modèles n'ayant pas de base
|
||||
* commune. Toute correction ici doit être reportée là-bas, et
|
||||
* réciproquement.
|
||||
*
|
||||
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
|
||||
*/
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
@@ -959,6 +1245,18 @@ final class WeekTemplateModel extends BaseModel
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse un plan dont deux créneaux se chevauchent le même jour.
|
||||
*
|
||||
* Contrôle du plan PROJETÉ (dates réelles), là où
|
||||
* assertEditorEntriesNoOverlap() porte sur le modèle abstrait (jours de la
|
||||
* semaine). Les deux sont nécessaires : un modèle sain peut produire un plan
|
||||
* fautif si deux modèles sont appliqués sur la même période.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
*
|
||||
* @throws DomainException Au premier chevauchement rencontré.
|
||||
*/
|
||||
private function assertTemplateNoOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
@@ -983,6 +1281,16 @@ final class WeekTemplateModel extends BaseModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse un chevauchement avec les créneaux DÉJÀ en base sur la semaine.
|
||||
*
|
||||
* Utilisé par applyToWeek() en mode « merge ». La lecture couvre tous les
|
||||
* lieux de l'agent sur la semaine, et non le seul lieu visé.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
*
|
||||
* @throws DomainException Au premier chevauchement rencontré.
|
||||
*/
|
||||
private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
Reference in New Issue
Block a user