1329 lines
58 KiB
PHP
1329 lines
58 KiB
PHP
<?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']
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|