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

1257 lines
54 KiB
PHP
Raw 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 Throwable;
/**
* Accès aux plannings hebdomadaires et à leurs créneaux.
*
* Modèle de données : `planning` (agent × semaine × lieu) porte des
* `creneau_horaire` typés par un `motif_planning`. La table `semaine` est un
* référentiel créé À LA DEMANDE (voir ensureWeek()) : une semaine n'existe en
* base qu'à partir du moment où quelque chose y est planifié.
*
* Trois conventions valent pour tout ce fichier :
*
* 1. TRANSACTIONS — toute méthode qui écrit plusieurs lignes ouvre une
* transaction et la déroule intégralement en cas d'échec. Le `catch` réémet
* l'exception : c'est au contrôleur de la traduire en réponse HTTP.
*
* 2. VERROUS — les lectures qui précèdent une écriture portent `FOR UPDATE`.
* Deux utilisateurs peuvent travailler sur le même agent en même temps ;
* sans verrou, un contrôle d'antériorité serait fait sur un état déjà périmé.
*
* 3. RÉOUVERTURE AUTOMATIQUE — modifier le contenu d'un planning VALIDÉ le
* repasse en BROUILLON, et les identifiants concernés remontent dans
* `reopened_planning_ids`. Une validation ne doit jamais survivre à la
* modification de ce qu'elle attestait.
*
* Test de chevauchement, identique partout : `debut1 < fin2 && fin1 > debut2`.
* Deux créneaux jointifs (10:00-12:00 et 12:00-14:00) ne se chevauchent pas.
*/
final class PlanningModel extends BaseModel
{
/**
* Retrouve le planning d'un triplet (lieu, agent, semaine) — la clé
* fonctionnelle d'un planning.
*
* @return array<string, mixed>|null null si rien n'a encore été saisi.
*/
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut, p.commentaire, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_structure = :structure_id
AND p.id_agent = :agent_id
AND s.annee = :annee
AND s.numero_semaine = :semaine
LIMIT 1"
);
$stmt->execute([
'structure_id' => $structureId,
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetch() ?: null;
}
/**
* Créneaux d'un planning, motif joint.
*
* Les heures sortent en HH:MM (TIME_FORMAT) et non en HH:MM:SS : c'est le
* format attendu par les champs de saisie du navigateur. Les écritures, à
* l'inverse, passent en HH:MM:SS.
*
* @return array<int, array<string, mixed>>
*/
public function entries(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM creneau_horaire c
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE c.id_planning = :planning_id
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
/**
* Créneaux de l'agent sur les AUTRES lieux, la même semaine.
*
* Affichés en lecture seule dans l'éditeur : un agent pouvant être planifié
* sur plusieurs lieux, c'est le seul moyen de voir un conflit avant de
* l'enregistrer.
*
* @return array<int, array<string, mixed>>
*/
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
st.id_structure, st.code AS structure_code, st.nom AS structure_nom,
st.type_affectation AS structure_type_affectation,
c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND p.id_structure <> :structure_id
ORDER BY c.date_jour, c.heure_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
'structure_id' => $structureId,
]);
return $stmt->fetchAll();
}
/**
* Enregistre le brouillon d'une semaine : REMPLACEMENT INTÉGRAL.
*
* Les créneaux existants du planning sont supprimés puis réinsérés. C'est
* plus simple et plus sûr qu'une réconciliation ligne à ligne, au prix de la
* perte des identifiants de créneaux — aucune donnée n'y étant rattachée,
* c'est sans conséquence.
*
* Un planning VALIDÉ est refusé : il faut le rouvrir explicitement d'abord.
* C'est la différence avec les modifications unitaires, qui rouvrent d'elles-
* mêmes ; ici l'utilisateur écrase toute une semaine, le geste doit être
* conscient.
*
* La semaine est créée si besoin par un INSERT … ON DUPLICATE KEY UPDATE
* avec LAST_INSERT_ID(id_semaine) : cette astuce MariaDB fait remonter
* l'identifiant EXISTANT via lastInsertId() plutôt qu'un zéro. Le repli par
* SELECT couvre les configurations où l'astuce ne joue pas.
*
* @param array<int, array{date: string, motif_id: int, start: string, end: string}> $entries
* Créneaux déjà normalisés par PlanningController::normalizeEntries().
*
* @return int Identifiant du planning créé ou mis à jour.
*
* @throws \DomainException Si le planning est déjà validé.
*/
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
try {
$this->pdo->beginTransaction();
$weekStmt = $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)"
);
$weekStmt->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) {
$findWeek = $this->pdo->prepare(
'SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'
);
$findWeek->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $findWeek->fetchColumn();
}
$planningStmt = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id
AND id_semaine = :semaine_id
AND id_structure = :structure_id
FOR UPDATE"
);
$planningStmt->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $planningStmt->fetch();
if ($planning && $planning['statut'] === 'VALIDE') {
throw new \DomainException('Ce planning est déjà validé et doit être rouvert avant modification.');
}
if ($planning) {
$planningId = (int) $planning['id_planning'];
$delete = $this->pdo->prepare('DELETE FROM creneau_horaire WHERE id_planning = :planning_id');
$delete->execute(['planning_id' => $planningId]);
} else {
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :semaine_id, :structure_id, 'BROUILLON')"
);
$insertPlanning->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planningId = (int) $this->pdo->lastInsertId();
}
$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, :heure_debut, :heure_fin)"
);
foreach ($entries as $entry) {
$insertEntry->execute([
'planning_id' => $planningId,
'motif_id' => $entry['motif_id'],
'date_jour' => $entry['date'],
'heure_debut' => $entry['start'],
'heure_fin' => $entry['end'],
]);
}
$this->pdo->commit();
return $planningId;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Tous les brouillons NON VIDES, toutes structures confondues.
*
* Le `HAVING COUNT(...) > 0` écarte les plannings sans créneau : ils
* existent (un planning vidé n'est jamais supprimé) mais n'ont rien à
* valider, et pollueraient la file de travail.
*
* Deux totaux distincts : `total_minutes` (tout le temps saisi) et
* `minutes_decomptees` (seulement ce qui pèse sur le quota).
* `date_fin_ouvrable` est le vendredi, la semaine s'arrêtant au jour ouvré.
*
* Tri par semaine croissante : les premiers éléments sont les plus anciens,
* donc les plus urgents — DashboardController s'appuie sur cet ordre.
*
* @return array<int, array<string, mixed>>
*/
public function pendingValidation(): array
{
$stmt = $this->pdo->query(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom AS agent_nom, a.prenom AS agent_prenom,
st.nom AS structure_nom, st.type_affectation AS structure_type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
DATE_ADD(sem.date_debut, INTERVAL 4 DAY) AS date_fin_ouvrable,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN m.compte_dans_quota = 1 THEN TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
) ELSE 0 END), 0) AS minutes_decomptees
FROM planning p
INNER JOIN agent a ON a.id_agent = p.id_agent
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
LEFT JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom, a.prenom,
st.nom, st.type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut ASC, a.nom ASC, a.prenom ASC, st.nom ASC"
);
return $stmt->fetchAll();
}
/**
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
* dans l'année civile demandée.
*
* Le filtrage se fait sur la DATE DES CRÉNEAUX (sous-requête EXISTS) et non
* sur l'année de la semaine : une semaine à cheval sur deux années civiles
* doit être reprise par la validation annuelle des deux années.
*
* @return array<int, array<string, mixed>>
*/
public function pendingValidationForAgentYear(int $agentId, int $year): array
{
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom AS structure_nom,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes
FROM planning p
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND p.statut = 'BROUILLON'
AND EXISTS (
SELECT 1
FROM creneau_horaire cy
WHERE cy.id_planning = p.id_planning
AND cy.date_jour BETWEEN :date_debut AND :date_fin
)
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom, sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $start,
'date_fin' => $end,
]);
return $stmt->fetchAll();
}
/**
* Chevauchements avec n'importe quel autre planning de l'agent,
* qu'il soit déjà validé ou encore en brouillon.
*
* Variante LARGE, utilisée par la validation ANNUELLE : tous les brouillons
* de l'agent y sont validés d'un bloc, deux brouillons qui se recouvrent
* doivent donc être signalés. La validation unitaire utilise au contraire
* validatedExternalOverlaps(), plus permissive.
*
* @return array<int, array<string, mixed>> Paires de créneaux en conflit.
*/
public function externalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2,
c1.date_jour, st.nom AS structure_nom, p2.statut AS autre_statut
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
/**
* Planning et bornes de sa semaine, par identifiant.
*
* @return array<string, mixed>|null
*/
public function findDetailed(int $planningId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
s.annee, s.numero_semaine, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_planning = :planning_id
LIMIT 1"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetch() ?: null;
}
/** Nombre de créneaux ; zéro déclenche le contrôle bloquant AUCUN_CRENEAU. */
public function countEntries(int $planningId): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
$stmt->execute(['planning_id' => $planningId]);
return (int) $stmt->fetchColumn();
}
/**
* Créneaux sortis des bornes de leur semaine.
*
* Ne devrait jamais rien renvoyer : la saisie contrôle déjà l'appartenance.
* Ce filet rattrape les données produites par un import, une reprise ou une
* version antérieure du code.
*
* @return array<int, array{id_creneau: int, date_jour: string}>
*/
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
{
$stmt = $this->pdo->prepare(
"SELECT id_creneau, date_jour
FROM creneau_horaire
WHERE id_planning = :planning_id
AND date_jour NOT BETWEEN :date_debut AND :date_fin"
);
$stmt->execute(['planning_id' => $planningId, 'date_debut' => $start, 'date_fin' => $end]);
return $stmt->fetchAll();
}
/**
* Chevauchements entre créneaux d'un MÊME planning.
*
* `c2.id_creneau > c1.id_creneau` évite de sortir chaque paire deux fois et
* empêche un créneau de se comparer à lui-même.
*
* @return array<int, array{c1: int, c2: int, date_jour: string}>
*/
public function internalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour
FROM creneau_horaire c1
INNER JOIN creneau_horaire c2
ON c2.id_planning = c1.id_planning
AND c2.date_jour = c1.date_jour
AND c2.id_creneau > c1.id_creneau
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE c1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
/**
* Chevauchements avec les seuls plannings DÉJÀ VALIDÉS d'un autre lieu.
*
* Variante RESTREINTE, utilisée par la validation unitaire : seul un
* planning validé est opposable. Opposer les brouillons rendrait toute
* validation impossible dès que deux brouillons concurrents existent, sans
* ordre de résolution évident.
*
* @return array<int, array{c1: int, c2: int, date_jour: string, structure_nom: string}>
*/
public function validatedExternalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour, st.nom AS structure_nom
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
AND p2.statut = 'VALIDE'
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
/**
* Créneau enrichi de son planning et de sa semaine.
*
* @return array<string, mixed>|null
*/
public function findEntryDetailed(int $entryId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
LIMIT 1"
);
$stmt->execute(['entry_id' => $entryId]);
return $stmt->fetch() ?: null;
}
/**
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
* Le planning cible est créé si nécessaire et tout planning validé modifié
* repasse automatiquement en brouillon.
*
* C'est la méthode la plus délicate du modèle : la nouvelle date peut
* relever d'une autre SEMAINE et le nouveau lieu d'un autre PLANNING. Le
* créneau est alors RATTACHÉ à un autre planning (mise à jour de
* `id_planning`) plutôt que supprimé puis recréé, ce qui préserve son
* identifiant.
*
* Jusqu'à DEUX plannings peuvent être rouverts : celui d'origine et celui de
* destination.
*
* La recherche de conflit couvre TOUS les lieux de l'agent, pas seulement le
* lieu visé : c'est l'agent qui ne peut pas être à deux endroits à la fois.
*
* @param string $start Heure de début en HH:MM:SS.
* @param string $end Heure de fin en HH:MM:SS.
*
* @return array{entry_id: int, agent_id: int, year: int, week: int, source_planning_id: int, target_planning_id: int, reopened_planning_ids: int[]}
*
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
*/
public function updateEntryFromAgentView(
int $entryId,
int $targetStructureId,
int $motifId,
string $date,
string $start,
string $end
): array {
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à modifier est introuvable.');
}
$targetDate = new DateTimeImmutable($date);
if ((int) $targetDate->format('N') > 5) {
throw new \DomainException('Le créneau doit être positionné entre le lundi et le vendredi.');
}
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND c.id_creneau <> :entry_id
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'entry_id' => $entryId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Double affectation impossible : un autre créneau existe déjà sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDate->format('o');
$targetWeekNumber = (int) $targetDate->format('W');
$targetWeekStart = $targetDate->modify('-' . ((int) $targetDate->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetWeekStart, $targetWeekStart->modify('+6 days'));
$sourcePlanningId = (int) $entry['id_planning'];
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
$targetStructureId
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$updateEntry = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :planning_id,
id_motif = :motif_id,
date_jour = :date_jour,
heure_debut = :heure_debut,
heure_fin = :heure_fin
WHERE id_creneau = :entry_id"
);
$updateEntry->execute([
'planning_id' => $targetPlanningId,
'motif_id' => $motifId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
'entry_id' => $entryId,
]);
$reopenedPlanningIds = [];
if ((string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'source_planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Supprime un créneau depuis la vue par agent.
* Si le planning était validé, il repasse en brouillon afin de signaler
* que son contenu a été modifié. Le planning est conservé même s'il ne
* contient plus aucun créneau.
*
* Le contrôle `rowCount() !== 1` après le DELETE détecte la suppression
* concurrente : le créneau était encore là au SELECT … FOR UPDATE mais plus
* au DELETE. On préfère annuler et demander un rechargement plutôt que de
* confirmer une suppression qui n'a rien fait.
*
* `remaining_entries` permet à l'interface d'annoncer un planning devenu
* vide, cas nominal et non erreur.
*
* @return array{entry_id: int, planning_id: int, agent_id: int, year: int, week: int, date: string, reopened: bool, remaining_entries: int}
*
* @throws \DomainException Créneau introuvable ou déjà supprimé.
*/
public function deleteEntryFromAgentView(int $entryId): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.date_jour,
p.id_agent, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à supprimer est introuvable.');
}
$deleteStmt = $this->pdo->prepare(
'DELETE FROM creneau_horaire WHERE id_creneau = :entry_id'
);
$deleteStmt->execute(['entry_id' => $entryId]);
if ($deleteStmt->rowCount() !== 1) {
throw new \DomainException('Le créneau a déjà été supprimé ou modifié. Rechargez la page.');
}
$reopened = (string) $entry['statut'] === 'VALIDE';
if ($reopened) {
$reopenStmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON'
WHERE id_planning = :planning_id"
);
$reopenStmt->execute(['planning_id' => (int) $entry['id_planning']]);
}
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'
);
$countStmt->execute(['planning_id' => (int) $entry['id_planning']]);
$remainingEntries = (int) $countStmt->fetchColumn();
$this->pdo->commit();
return [
'entry_id' => $entryId,
'planning_id' => (int) $entry['id_planning'],
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'date' => (string) $entry['date_jour'],
'reopened' => $reopened,
'remaining_entries' => $remainingEntries,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
* changement de semaine.
*
* Sert le glisser-déposer de l'éditeur. Trois particularités :
*
* - Déposer sur le jour d'origine en mode déplacement est un NON-ÉVÉNEMENT :
* on rend « action: noop » sans rien écrire, plutôt qu'une erreur pour un
* geste de souris sans conséquence.
* - En DUPLICATION, le créneau source est inclus dans la recherche de
* conflit ($excludeEntryId = 0) : la copie entrerait en collision avec lui
* si elle atterrissait au même endroit. En DÉPLACEMENT il en est exclu,
* puisqu'il libère sa place.
* - Une duplication ne rouvre PAS le planning source : celui-ci n'est pas
* modifié, seule la destination l'est.
*
* @return array{entry_id: int, created_entry_id: int|null, agent_id: int, year: int, week: int, planning_id: int, target_planning_id: int, reopened_planning_ids: int[], action: string}
* `action` vaut « move », « duplicate » ou « noop ».
*
* @throws \DomainException Créneau introuvable, jour non ouvré, ou conflit.
*/
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à déplacer est introuvable.');
}
$targetDateObject = new DateTimeImmutable($targetDate);
if ((int) $targetDateObject->format('N') > 5) {
throw new \DomainException('Le jour cible doit être compris entre le lundi et le vendredi.');
}
if (!$duplicate && $targetDate === (string) $entry['date_jour']) {
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => null,
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'planning_id' => (int) $entry['id_planning'],
'target_planning_id' => (int) $entry['id_planning'],
'reopened_planning_ids' => [],
'action' => 'noop',
];
}
$excludeEntryId = $duplicate ? 0 : $entryId;
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND (:exclude_entry_id = 0 OR c.id_creneau <> :exclude_entry_id_compare)
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'exclude_entry_id' => $excludeEntryId,
'exclude_entry_id_compare' => $excludeEntryId,
'date_jour' => $targetDate,
'heure_fin' => $entry['heure_fin'],
'heure_debut' => $entry['heure_debut'],
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Action impossible : l’agent possède déjà un créneau sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDateObject->format('o');
$targetWeekNumber = (int) $targetDateObject->format('W');
$targetMonday = $targetDateObject->modify('-' . ((int) $targetDateObject->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
(int) $entry['id_structure']
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$sourcePlanningId = (int) $entry['id_planning'];
$createdEntryId = null;
if ($duplicate) {
$insertStmt = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
);
$insertStmt->execute([
'planning_id' => $targetPlanningId,
'motif_id' => (int) $entry['id_motif'],
'date_jour' => $targetDate,
'heure_debut' => $entry['heure_debut'],
'heure_fin' => $entry['heure_fin'],
]);
$createdEntryId = (int) $this->pdo->lastInsertId();
} else {
$moveStmt = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :target_planning_id,
date_jour = :date_jour
WHERE id_creneau = :entry_id"
);
$moveStmt->execute([
'target_planning_id' => $targetPlanningId,
'date_jour' => $targetDate,
'entry_id' => $entryId,
]);
}
$reopenedPlanningIds = [];
if (!$duplicate && (string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => $createdEntryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
'action' => $duplicate ? 'duplicate' : 'move',
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Recopie tous les créneaux d'un agent d'une semaine vers une autre.
*
* Le report se fait par JOUR DE LA SEMAINE, pas par date : un créneau du
* mardi source atterrit sur le mardi cible. `WEEKDAY() + 1` donne 1 (lundi)
* à 5 (vendredi) ; le filtre `BETWEEN 0 AND 4` écarte le week-end, qui ne
* devrait pas exister mais peut subsister d'une reprise de données.
*
* La copie couvre TOUS les lieux de la semaine source, d'où la réservation
* de l'opération au Service Enfance.
*
* Deux modes :
* - `replace` : les créneaux existants de la semaine cible sont supprimés,
* tous lieux confondus ;
* - `merge` : ils sont conservés, et le moindre chevauchement fait
* ÉCHOUER l'opération entière. Une copie partielle laisserait un état
* difficile à interpréter — mieux vaut ne rien faire et le dire.
*
* @param string $mode « merge » ou « replace ».
*
* @return array{agent_id: int, year: int, week: int, copied_entries: int, reopened_planning_ids: int[], mode: string}
*
* @throws \DomainException Mode invalide, semaines identiques, source vide,
* ou conflit en mode merge.
*/
public function copyAgentWeek(
int $agentId,
int $sourceYear,
int $sourceWeekNumber,
int $targetYear,
int $targetWeekNumber,
string $mode
): array {
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new \DomainException('Mode de duplication invalide.');
}
if ($sourceYear === $targetYear && $sourceWeekNumber === $targetWeekNumber) {
throw new \DomainException('La semaine cible doit être différente de la semaine source.');
}
$sourceStmt = $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 sem ON sem.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
ORDER BY c.date_jour, c.heure_debut"
);
$sourceStmt->execute([
'agent_id' => $agentId,
'annee' => $sourceYear,
'semaine' => $sourceWeekNumber,
]);
$sourceEntries = $sourceStmt->fetchAll();
if (!$sourceEntries) {
throw new \DomainException('La semaine source ne contient aucun créneau à dupliquer.');
}
$targetMonday = (new DateTimeImmutable())->setISODate($targetYear, $targetWeekNumber, 1)->setTime(0, 0);
$targetEntries = array_map(static function (array $entry) use ($targetMonday): array {
return [
'date_jour' => $targetMonday->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'),
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => (string) $entry['heure_debut'],
'heure_fin' => (string) $entry['heure_fin'],
];
}, $sourceEntries);
try {
$this->pdo->beginTransaction();
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$existingPlanningStmt = $this->pdo->prepare(
"SELECT id_planning, id_structure, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id
FOR UPDATE"
);
$existingPlanningStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingPlannings = $existingPlanningStmt->fetchAll();
$reopened = [];
if ($mode === 'replace') {
if ($existingPlannings) {
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
foreach ($existingPlannings as $planning) {
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
$delete->execute($ids);
}
} else {
$existingEntriesStmt = $this->pdo->prepare(
"SELECT c.date_jour, c.heure_debut, c.heure_fin, st.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
FOR UPDATE"
);
$existingEntriesStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingEntries = $existingEntriesStmt->fetchAll();
foreach ($targetEntries as $candidate) {
foreach ($existingEntries as $existing) {
if ($candidate['date_jour'] === $existing['date_jour']
&& $candidate['heure_debut'] < $existing['heure_fin']
&& $candidate['heure_fin'] > $existing['heure_debut']) {
throw new \DomainException(sprintf(
'Duplication impossible : un créneau existe déjà le %s de %s à %s sur « %s ».',
$candidate['date_jour'],
substr((string) $existing['heure_debut'], 0, 5),
substr((string) $existing['heure_fin'], 0, 5),
$existing['structure_nom']
));
}
}
}
}
$planningCache = [];
foreach ($existingPlannings as $planning) {
$planningCache[(int) $planning['id_structure']] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
}
$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, :heure_debut, :heure_fin)"
);
foreach ($targetEntries as $candidate) {
$structureId = (int) $candidate['id_structure'];
if (!isset($planningCache[$structureId])) {
$planningCache[$structureId] = $this->findOrCreatePlanning($agentId, $targetWeekId, $structureId);
}
$planning = $planningCache[$structureId];
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
$insertEntry->execute([
'planning_id' => (int) $planning['id_planning'],
'motif_id' => (int) $candidate['id_motif'],
'date_jour' => $candidate['date_jour'],
'heure_debut' => $candidate['heure_debut'],
'heure_fin' => $candidate['heure_fin'],
]);
}
$reopened = $this->reopenPlanningIds($reopened);
$this->pdo->commit();
return [
'agent_id' => $agentId,
'year' => $targetYear,
'week' => $targetWeekNumber,
'copied_entries' => count($targetEntries),
'reopened_planning_ids' => $reopened,
'mode' => $mode,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Retourne l'identifiant de la semaine, en la créant si elle n'existe pas.
*
* La table `semaine` est peuplée à la demande : aucune pré-génération de
* calendrier. LAST_INSERT_ID(id_semaine) dans le ON DUPLICATE KEY UPDATE
* fait remonter l'identifiant EXISTANT par lastInsertId(), ce qui évite un
* SELECT supplémentaire dans le cas courant ; le repli couvre les
* configurations où l'astuce ne joue pas.
*
* @throws \RuntimeException Si la semaine n'a pu être ni créée ni retrouvée.
*/
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
{
$stmt = $this->pdo->prepare(
"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;
}
/**
* Retourne le planning du triplet (agent, semaine, lieu), en le créant au
* besoin en BROUILLON.
*
* Le statut est renvoyé avec l'identifiant, car l'appelant en a besoin pour
* décider s'il doit rouvrir le planning après modification.
*
* À N'APPELER QUE DANS UNE TRANSACTION : le SELECT porte un FOR UPDATE.
*
* @return array{id_planning:int,statut:string}
*/
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
FOR UPDATE"
);
$stmt->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $stmt->fetch();
if ($planning) {
return ['id_planning' => (int) $planning['id_planning'], 'statut' => (string) $planning['statut']];
}
$insert = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$insert->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
}
/**
* Repasse en BROUILLON les plannings listés et efface leur date de validation.
*
* Les identifiants sont dédoublonnés : un même planning peut être signalé
* deux fois (source et destination confondues). La liste est renvoyée telle
* qu'appliquée, pour remonter au client dans `reopened_planning_ids`.
*
* @param array<int, int|string> $planningIds
*
* @return int[]
*/
private function reopenPlanningIds(array $planningIds): array
{
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
if (!$planningIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $planningIds;
}
/**
* Valide un lot de plannings.
*
* La clause `statut = 'BROUILLON'` rend l'opération sûre en concurrence :
* un planning validé entre-temps n'est pas revalidé, et rowCount() renvoie
* le nombre RÉELLEMENT traité — c'est lui qui est annoncé à l'utilisateur.
*
* @param int[] $planningIds
*
* @return int Nombre de plannings effectivement validés.
*/
public function markValidatedMany(array $planningIds): int
{
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
if ($planningIds === []) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP
WHERE statut = 'BROUILLON' AND id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $stmt->rowCount();
}
/**
* Valide un planning unitaire.
*
* Sans condition de statut, contrairement à markValidatedMany() : l'appelant
* (PlanningController::validate()) a déjà lu le planning et joué tous ses
* contrôles.
*/
public function markValidated(int $planningId): void
{
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP WHERE id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
}
/**
* Repasse un planning VALIDÉ en BROUILLON, sur demande explicite.
*
* @return bool false si le planning n'était plus validé — un autre
* utilisateur est passé entre-temps, et l'appelant doit
* répondre 409 plutôt que d'annoncer un succès.
*/
public function reopen(int $planningId): bool
{
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON', date_validation = NULL
WHERE id_planning = :planning_id AND statut = 'VALIDE'"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->rowCount() === 1;
}
}