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'] )); } } } } }