Files
pta/app/Models/WeekTemplateModel.php
2026-08-20 13:22:49 +02:00

1329 lines
58 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use 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(
"SELECT m.id_modele, m.nom, m.date_creation, m.date_modification,
COUNT(c.id_modele_creneau) AS nombre_creneaux,
COUNT(DISTINCT c.id_structure) AS nombre_lieux
FROM modele_semaine_agent m
LEFT JOIN modele_semaine_creneau c ON c.id_modele = m.id_modele
WHERE m.id_agent = :agent_id
GROUP BY m.id_modele, m.nom, m.date_creation, m.date_modification
ORDER BY m.nom"
);
$stmt->execute(['agent_id' => $agentId]);
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(
"SELECT id_modele, id_agent, nom, date_creation, date_modification
FROM modele_semaine_agent
WHERE id_modele = :template_id AND id_agent = :agent_id
LIMIT 1"
);
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
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(
"SELECT c.id_modele_creneau, c.jour_semaine, c.id_structure, c.id_motif,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom,
s.type_affectation AS structure_type_affectation,
s.actif AS structure_actif,
m.libelle AS motif_libelle,
m.compte_dans_quota,
m.actif AS motif_actif
FROM modele_semaine_creneau c
INNER JOIN structure s ON s.id_structure = c.id_structure
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE c.id_modele = :template_id
ORDER BY c.jour_semaine, c.heure_debut, c.heure_fin, s.nom"
);
$stmt->execute(['template_id' => $templateId]);
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 à
// l'utilisateur et celle réellement appliquée doivent coïncider.
$name = $this->normalizeName($name);
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
if (!$sourceEntries) {
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
}
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
}
/**
* Enregistre une semaine type composée directement dans l'éditeur, sans
* partir d'une semaine réellement planifiée.
*
* Les plages arrivent du navigateur : elles sont intégralement revalidées
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
* le serveur reste seul juge de ce qui entre en base.
*
* @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,
string $name,
array $rawEntries,
array $allowedMotifIds,
array $allowedStructureIds,
bool $replaceExisting = false
): array {
$name = $this->normalizeName($name);
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
if ($entries === []) {
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
}
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) ?? '');
if ($name === '' || mb_strlen($name) > 120) {
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
}
return $name;
}
/**
* 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
{
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
$entries = [];
foreach ($rawEntries as $rawEntry) {
if (!is_array($rawEntry)) {
throw new DomainException('Une plage de la semaine type est illisible.');
}
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 5],
]);
if ($weekday === false) {
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
}
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
if ($end <= $start) {
throw new DomainException(sprintf(
'Le %s, la fin (%s) doit être postérieure au début (%s).',
$jours[$weekday],
substr($end, 0, 5),
substr($start, 0, 5)
));
}
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
}
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
throw new DomainException(sprintf(
'Le motif choisi le %s n’est pas affectable à cet agent.',
$jours[$weekday]
));
}
$entries[] = [
'jour_semaine' => (int) $weekday,
'id_structure' => (int) $structureId,
'id_motif' => (int) $motifId,
'heure_debut' => $start,
'heure_fin' => $end,
];
}
$this->assertEditorEntriesNoOverlap($entries, $jours);
usort($entries, static function (array $a, array $b): int {
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
});
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);
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
throw new DomainException(sprintf(
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
$dayLabel
));
}
return substr($time, 0, 5) . ':00';
}
/**
* 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 Libellés indexés par numéro de jour.
*
* @throws DomainException Au premier chevauchement rencontré.
*/
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['jour_semaine'] === $second['jour_semaine']
&& $first['heure_debut'] < $second['heure_fin']
&& $first['heure_fin'] > $second['heure_debut']) {
throw new DomainException(sprintf(
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
$jours[$first['jour_semaine']] ?? 'jour',
substr($first['heure_debut'], 0, 5),
substr($first['heure_fin'], 0, 5),
substr($second['heure_debut'], 0, 5),
substr($second['heure_fin'], 0, 5)
));
}
}
}
}
/**
* É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
{
try {
$this->pdo->beginTransaction();
$existingStmt = $this->pdo->prepare(
'SELECT id_modele FROM modele_semaine_agent WHERE id_agent = :agent_id AND nom = :nom FOR UPDATE'
);
$existingStmt->execute(['agent_id' => $agentId, 'nom' => $name]);
$existingId = (int) ($existingStmt->fetchColumn() ?: 0);
if ($existingId > 0 && !$replaceExisting) {
throw new DomainException('Une semaine type porte déjà ce nom pour cet agent.');
}
if ($existingId > 0) {
$templateId = $existingId;
$delete = $this->pdo->prepare('DELETE FROM modele_semaine_creneau WHERE id_modele = :template_id');
$delete->execute(['template_id' => $templateId]);
$touch = $this->pdo->prepare('UPDATE modele_semaine_agent SET date_modification = CURRENT_TIMESTAMP WHERE id_modele = :template_id');
$touch->execute(['template_id' => $templateId]);
} else {
$insertTemplate = $this->pdo->prepare(
'INSERT INTO modele_semaine_agent (id_agent, nom) VALUES (:agent_id, :nom)'
);
$insertTemplate->execute(['agent_id' => $agentId, 'nom' => $name]);
$templateId = (int) $this->pdo->lastInsertId();
}
$insertEntry = $this->pdo->prepare(
"INSERT INTO modele_semaine_creneau
(id_modele, jour_semaine, id_structure, id_motif, heure_debut, heure_fin)
VALUES
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
);
foreach ($rows as $entry) {
$insertEntry->execute([
'template_id' => $templateId,
'weekday' => (int) $entry['jour_semaine'],
'structure_id' => (int) $entry['id_structure'],
'motif_id' => (int) $entry['id_motif'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
}
$this->pdo->commit();
return [
'id_modele' => $templateId,
'nom' => $name,
'nombre_creneaux' => count($rows),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* 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)) {
throw new DomainException('Mode d’import invalide.');
}
$template = $this->findForAgent($templateId, $agentId);
if ($template === null) {
throw new DomainException('Semaine type introuvable pour cet agent.');
}
$templateEntries = $this->entries($templateId);
if (!$templateEntries) {
throw new DomainException('Cette semaine type ne contient aucun créneau.');
}
foreach ($templateEntries as $entry) {
if (!(bool) $entry['structure_actif']) {
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
}
if (!(bool) $entry['motif_actif']) {
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
}
}
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
$targetEntries = array_map(static function (array $entry) use ($weekStart): array {
$date = $weekStart->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d');
return [
'date' => $date,
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => $entry['heure_debut'],
'heure_fin' => $entry['heure_fin'],
'structure_nom' => $entry['structure_nom'],
];
}, $templateEntries);
$this->assertTemplateNoOverlap($targetEntries);
try {
$this->pdo->beginTransaction();
$weekId = $this->ensureWeek($year, $weekNumber, $weekStart, $weekEnd);
$existingPlanningsStmt = $this->pdo->prepare(
"SELECT id_planning, id_structure, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id
FOR UPDATE"
);
$existingPlanningsStmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
$existingPlannings = $existingPlanningsStmt->fetchAll();
$reopened = [];
if ($mode === 'replace') {
foreach ($existingPlannings as $planning) {
if ($planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
}
if ($existingPlannings) {
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
$delete->execute($ids);
$reopen = $this->pdo->prepare("UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)");
$reopen->execute($ids);
}
} else {
$this->assertNoExistingOverlap($agentId, $weekId, $targetEntries);
}
$planningByStructure = [];
foreach ($existingPlannings as $planning) {
$planningByStructure[(int) $planning['id_structure']] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
}
$selectPlanning = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$reopenPlanning = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
);
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
);
$inserted = 0;
foreach ($targetEntries as $entry) {
$structureId = (int) $entry['id_structure'];
if (!isset($planningByStructure[$structureId])) {
$selectPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $selectPlanning->fetch();
if ($planning) {
$planningByStructure[$structureId] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
} else {
$insertPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planningByStructure[$structureId] = [
'id_planning' => (int) $this->pdo->lastInsertId(),
'statut' => 'BROUILLON',
];
}
}
$planningInfo = &$planningByStructure[$structureId];
if ($planningInfo['statut'] === 'VALIDE') {
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
$reopened[] = $planningInfo['id_planning'];
$planningInfo['statut'] = 'BROUILLON';
}
$insertEntry->execute([
'planning_id' => $planningInfo['id_planning'],
'motif_id' => $entry['id_motif'],
'date_jour' => $entry['date'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
$inserted++;
unset($planningInfo);
}
$this->pdo->commit();
return [
'template' => $template,
'inserted' => $inserted,
'mode' => $mode,
'reopened_planning_ids' => array_values(array_unique($reopened)),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* 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,
string $startDate,
string $endDate,
string $mode,
string $periodFilter = 'all'
): array {
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,
string $startDate,
string $endDate,
string $mode,
string $periodFilter = 'all'
): array {
$plan = $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, false);
$targetEntries = $plan['target_entries'];
if ($targetEntries === []) {
throw new DomainException(
'Aucun créneau de cette semaine type ne correspond au filtre de calendrier sélectionné sur cette période. Vérifiez les dates et les vacances enregistrées dans PTA.'
);
}
$targetDates = array_values(array_unique(array_column($targetEntries, 'date')));
$targetDateLookup = array_fill_keys($targetDates, true);
$reopened = [];
$deleted = 0;
try {
$this->pdo->beginTransaction();
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate, true);
if ($mode === 'merge') {
$conflicts = $this->findConflicts($targetEntries, $existing);
if ($conflicts !== []) {
$first = $conflicts[0];
throw new DomainException(sprintf(
'Application impossible en mode « Compléter » : %d conflit%s détecté%s. Premier conflit le %s entre %s-%s et un créneau existant %s-%s à « %s ». Utilisez l’aperçu puis le mode « Remplacer les jours concernés » ou corrigez le planning existant.',
count($conflicts),
count($conflicts) > 1 ? 's' : '',
count($conflicts) > 1 ? 's' : '',
$first['date'],
substr($first['template_start'], 0, 5),
substr($first['template_end'], 0, 5),
substr($first['existing_start'], 0, 5),
substr($first['existing_end'], 0, 5),
$first['existing_structure']
));
}
} else {
$entriesToDelete = array_values(array_filter(
$existing,
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
));
if ($entriesToDelete !== []) {
$entryIds = array_map(static fn(array $entry): int => (int) $entry['id_creneau'], $entriesToDelete);
$planningIds = [];
foreach ($entriesToDelete as $entry) {
if ((string) $entry['statut'] === 'VALIDE') {
$planningIds[(int) $entry['id_planning']] = true;
}
}
$placeholders = implode(',', array_fill(0, count($entryIds), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_creneau IN ($placeholders)");
$delete->execute($entryIds);
$deleted = count($entryIds);
if ($planningIds !== []) {
$ids = array_keys($planningIds);
$planningPlaceholders = implode(',', array_fill(0, count($ids), '?'));
$reopen = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($planningPlaceholders)"
);
$reopen->execute($ids);
$reopened = array_merge($reopened, $ids);
}
}
}
$weekIds = [];
$planningCache = [];
$selectPlanning = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$reopenPlanning = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
);
$insertEntry = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
);
$inserted = 0;
foreach ($targetEntries as $entry) {
$date = new DateTimeImmutable($entry['date']);
$isoYear = (int) $date->format('o');
$weekNumber = (int) $date->format('W');
$weekKey = sprintf('%04d-W%02d', $isoYear, $weekNumber);
if (!isset($weekIds[$weekKey])) {
$weekStart = (new DateTimeImmutable())->setISODate($isoYear, $weekNumber, 1)->setTime(0, 0);
$weekIds[$weekKey] = $this->ensureWeek(
$isoYear,
$weekNumber,
$weekStart,
$weekStart->modify('+6 days')
);
}
$weekId = $weekIds[$weekKey];
$structureId = (int) $entry['id_structure'];
$planningKey = $weekId . ':' . $structureId;
if (!isset($planningCache[$planningKey])) {
$selectPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $selectPlanning->fetch();
if ($planning) {
$planningCache[$planningKey] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
} else {
$insertPlanning->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planningCache[$planningKey] = [
'id_planning' => (int) $this->pdo->lastInsertId(),
'statut' => 'BROUILLON',
];
}
}
$planningInfo = &$planningCache[$planningKey];
if ($planningInfo['statut'] === 'VALIDE') {
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
$reopened[] = $planningInfo['id_planning'];
$planningInfo['statut'] = 'BROUILLON';
}
$insertEntry->execute([
'planning_id' => $planningInfo['id_planning'],
'motif_id' => (int) $entry['id_motif'],
'date_jour' => $entry['date'],
'start' => $entry['heure_debut'],
'end' => $entry['heure_fin'],
]);
$inserted++;
unset($planningInfo);
}
$this->pdo->commit();
return [
'template' => $plan['template'],
'inserted' => $inserted,
'deleted' => $deleted,
'mode' => $mode,
'period_filter' => $plan['period_filter'],
'start_date' => $startDate,
'end_date' => $endDate,
'weeks_touched' => count($weekIds),
'days_touched' => count($targetDates),
'skipped_count' => $plan['summary']['skipped_count'],
'skipped_vacation_count' => $plan['summary']['skipped_vacation_count'],
'skipped_school_count' => $plan['summary']['skipped_school_count'],
'reopened_planning_ids' => array_values(array_unique(array_map('intval', $reopened))),
'years' => $plan['summary']['years'],
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* 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(
'DELETE FROM modele_semaine_agent WHERE id_modele = :template_id AND id_agent = :agent_id'
);
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
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(
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
p.id_structure, c.id_motif,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND s.annee = :annee
AND s.numero_semaine = :week_number
AND c.date_jour BETWEEN s.date_debut AND s.date_fin
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
ORDER BY c.date_jour, c.heure_debut, p.id_structure"
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'week_number' => $weekNumber,
]);
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,
string $startDate,
string $endDate,
string $mode,
string $periodFilter,
bool $includeExistingAnalysis
): array {
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new DomainException('Mode d’application invalide.');
}
if (!in_array($periodFilter, ['all', 'school', 'extra'], true)) {
throw new DomainException('Filtre de calendrier invalide.');
}
$start = $this->parseStrictDate($startDate, 'date de début');
$end = $this->parseStrictDate($endDate, 'date de fin');
if ($end < $start) {
throw new DomainException('La date de fin doit être postérieure ou égale à la date de début.');
}
if ((int) $start->diff($end)->days > 730) {
throw new DomainException('La période ne peut pas dépasser deux ans.');
}
$template = $this->findForAgent($templateId, $agentId);
if ($template === null) {
throw new DomainException('Semaine type introuvable pour cet agent.');
}
$templateEntries = $this->entries($templateId);
if ($templateEntries === []) {
throw new DomainException('Cette semaine type ne contient aucun créneau.');
}
foreach ($templateEntries as $entry) {
if (!(bool) $entry['structure_actif']) {
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
}
if (!(bool) $entry['motif_actif']) {
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
}
}
$entriesByWeekday = [];
foreach ($templateEntries as $entry) {
$entriesByWeekday[(int) $entry['jour_semaine']][] = $entry;
}
$vacationPeriods = (new VacationModel($this->pdo))->periodsBetween($startDate, $endDate);
$targetEntries = [];
$skippedEntries = [];
$days = [];
$weeks = [];
$years = [];
for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) {
$weekday = (int) $date->format('N');
if ($weekday > 5) {
continue;
}
$dateValue = $date->format('Y-m-d');
$vacation = $this->vacationForDate($dateValue, $vacationPeriods);
$periodType = $vacation ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE';
$dayEntries = $entriesByWeekday[$weekday] ?? [];
$eligibleCount = 0;
$skippedCount = 0;
foreach ($dayEntries as $entry) {
$dateIsEligible = match ($periodFilter) {
'school' => $periodType === 'PERISCOLAIRE',
'extra' => $periodType === 'EXTRASCOLAIRE',
default => true,
};
if (!$dateIsEligible) {
$skippedCount++;
$skippedEntries[] = [
'date' => $dateValue,
'reason' => $periodType === 'EXTRASCOLAIRE' ? 'VACANCES' : 'HORS_VACANCES',
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
'structure_name' => (string) $entry['structure_nom'],
'start' => (string) $entry['heure_debut'],
'end' => (string) $entry['heure_fin'],
];
continue;
}
$eligibleCount++;
$targetEntries[] = [
'date' => $dateValue,
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => (string) $entry['heure_debut'],
'heure_fin' => (string) $entry['heure_fin'],
'structure_nom' => (string) $entry['structure_nom'],
'motif_libelle' => (string) $entry['motif_libelle'],
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
];
$weeks[$date->format('o-W')] = true;
$years[(int) $date->format('Y')] = true;
}
if ($dayEntries !== []) {
$days[$dateValue] = [
'date' => $dateValue,
'weekday' => $weekday,
'period_type' => $periodType,
'vacation_label' => $vacation['libelle'] ?? null,
'template_entry_count' => count($dayEntries),
'eligible_count' => $eligibleCount,
'skipped_count' => $skippedCount,
'conflict_count' => 0,
];
}
}
$this->assertTemplateNoOverlap($targetEntries);
$existing = [];
$conflicts = [];
$existingOnTargetDates = [];
if ($includeExistingAnalysis && $targetEntries !== []) {
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate);
$conflicts = $this->findConflicts($targetEntries, $existing);
$targetDateLookup = array_fill_keys(array_unique(array_column($targetEntries, 'date')), true);
$existingOnTargetDates = array_values(array_filter(
$existing,
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
));
foreach ($conflicts as $conflict) {
if (isset($days[$conflict['date']])) {
$days[$conflict['date']]['conflict_count']++;
}
}
}
$skippedVacationCount = count(array_filter(
$skippedEntries,
static fn(array $entry): bool => $entry['reason'] === 'VACANCES'
));
$skippedSchoolCount = count($skippedEntries) - $skippedVacationCount;
return [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
],
'start_date' => $startDate,
'end_date' => $endDate,
'mode' => $mode,
'period_filter' => $periodFilter,
'target_entries' => $targetEntries,
'skipped_entries' => $skippedEntries,
'conflicts' => $conflicts,
'days' => array_values($days),
'vacation_periods' => array_map(static fn(array $period): array => [
'id' => (int) $period['id_periode'],
'label' => (string) $period['libelle'],
'start_date' => (string) $period['date_debut'],
'end_date' => (string) $period['date_fin'],
'school_year' => (string) $period['annee_scolaire'],
], $vacationPeriods),
'summary' => [
'entry_count' => count($targetEntries),
'target_day_count' => count(array_unique(array_column($targetEntries, 'date'))),
'week_count' => count($weeks),
'skipped_count' => count($skippedEntries),
'skipped_vacation_count' => $skippedVacationCount,
'skipped_school_count' => $skippedSchoolCount,
'conflict_count' => count($conflicts),
'existing_entry_count_on_target_dates' => count($existingOnTargetDates),
'years' => array_values(array_map('intval', array_keys($years))),
'vacation_period_count' => count($vacationPeriods),
],
];
}
/**
* 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);
$errors = DateTimeImmutable::getLastErrors();
$hasErrors = is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0);
if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) {
throw new DomainException(sprintf('La %s est invalide.', $label));
}
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) {
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
return $period;
}
}
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,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :start_date AND :end_date
ORDER BY c.date_jour, c.heure_debut";
if ($forUpdate) {
$sql .= ' FOR UPDATE';
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'agent_id' => $agentId,
'start_date' => $startDate,
'end_date' => $endDate,
]);
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 = [];
foreach ($targetEntries as $target) {
foreach ($existingEntries as $existing) {
if ($target['date'] !== $existing['date_jour']) {
continue;
}
if ($target['heure_debut'] < $existing['heure_fin'] && $target['heure_fin'] > $existing['heure_debut']) {
$conflicts[] = [
'date' => $target['date'],
'template_start' => $target['heure_debut'],
'template_end' => $target['heure_fin'],
'template_structure' => $target['structure_nom'],
'existing_entry_id' => (int) $existing['id_creneau'],
'existing_start' => $existing['heure_debut'],
'existing_end' => $existing['heure_fin'],
'existing_structure' => $existing['structure_nom'],
];
}
}
}
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(
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
);
$stmt->execute([
'annee' => $year,
'numero' => $weekNumber,
'date_debut' => $weekStart->format('Y-m-d'),
'date_fin' => $weekEnd->format('Y-m-d'),
]);
$weekId = (int) $this->pdo->lastInsertId();
if ($weekId > 0) {
return $weekId;
}
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $find->fetchColumn();
if ($weekId <= 0) {
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
}
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++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['date'] === $second['date']
&& $first['heure_debut'] < $second['heure_fin']
&& $first['heure_fin'] > $second['heure_debut']) {
throw new DomainException(sprintf(
'La semaine type contient un chevauchement le %s entre %s-%s (%s) et %s-%s (%s).',
$first['date'],
substr($first['heure_debut'], 0, 5),
substr($first['heure_fin'], 0, 5),
$first['structure_nom'],
substr($second['heure_debut'], 0, 5),
substr($second['heure_fin'], 0, 5),
$second['structure_nom']
));
}
}
}
}
/**
* 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(
"SELECT c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
s.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
ORDER BY c.date_jour, c.heure_debut"
);
$stmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
$existing = $stmt->fetchAll();
foreach ($entries as $entry) {
foreach ($existing as $current) {
if ($entry['date'] === $current['date_jour']
&& $entry['heure_debut'] < $current['heure_fin']
&& $entry['heure_fin'] > $current['heure_debut']) {
throw new DomainException(sprintf(
'Import impossible en mode « compléter » : le modèle %s-%s chevauche un créneau existant %s-%s à « %s » le %s.',
substr($entry['heure_debut'], 0, 5),
substr($entry['heure_fin'], 0, 5),
substr($current['heure_debut'], 0, 5),
substr($current['heure_fin'], 0, 5),
$current['structure_nom'],
$entry['date']
));
}
}
}
}
}