862 lines
37 KiB
PHP
862 lines
37 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use DateTimeImmutable;
|
||
use DomainException;
|
||
use Throwable;
|
||
|
||
final class WeekTemplateModel extends BaseModel
|
||
{
|
||
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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||
{
|
||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||
if ($name === '' || strlen($name) > 240) {
|
||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||
}
|
||
|
||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||
if (!$sourceEntries) {
|
||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||
}
|
||
|
||
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 ($sourceEntries 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($sourceEntries),
|
||
];
|
||
} catch (Throwable $e) {
|
||
if ($this->pdo->inTransaction()) {
|
||
$this->pdo->rollBack();
|
||
}
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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),
|
||
],
|
||
];
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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']
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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']
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|