Files
pta/app/Models/WeekTemplateModel.php
Loic Masi 5fbf76868f pour prod
2026-08-07 16:13:43 +02:00

862 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use 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']
));
}
}
}
}
}