pour prod
This commit is contained in:
861
app/Models/WeekTemplateModel.php
Normal file
861
app/Models/WeekTemplateModel.php
Normal 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 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']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user