pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View File

@@ -0,0 +1,861 @@
<?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 dimport 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 laperç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 dapplication 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']
));
}
}
}
}
}