1285 lines
55 KiB
PHP
1285 lines
55 KiB
PHP
<?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}>
|
||
*/
|
||
/**
|
||
* Créneaux du planning tombant hors des dates de contrat de leur agent.
|
||
*
|
||
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
|
||
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
|
||
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
|
||
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
|
||
* agent jointe : c'est elle qui fait foi au moment du contrôle.
|
||
*
|
||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||
*/
|
||
public function entriesOutsideContract(int $planningId): array
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT c.id_creneau, c.date_jour
|
||
FROM creneau_horaire c
|
||
JOIN planning p ON p.id_planning = c.id_planning
|
||
JOIN agent a ON a.id_agent = p.id_agent
|
||
WHERE c.id_planning = :id_planning
|
||
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
|
||
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
|
||
ORDER BY c.date_jour'
|
||
);
|
||
$stmt->execute(['id_planning' => $planningId]);
|
||
|
||
return $stmt->fetchAll();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|