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

440
app/Models/AgentModel.php Normal file
View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
use Throwable;
final class AgentModel extends BaseModel
{
private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
a.formation_repartition_annuelle, a.commentaire_pta,
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation,
po.peut_assurer_animation, po.preparation_lundi_si_100,
a.id_structure,
s.nom AS structure_nom,
s.nom AS structure_principale_nom,
s.type_affectation AS structure_type_affectation,
s.type_affectation AS structure_principale_type_affectation
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
SQL;
public function allActive(): array
{
return $this->pdo->query(
self::AGENT_SELECT . " WHERE a.actif = TRUE ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allVisibleForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1
FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
return $stmt->fetchAll();
}
public function allEligibleForPta(): array
{
return $this->pdo->query(
self::AGENT_SELECT . "
WHERE a.actif = TRUE
AND a.type_contrat = 'PERMANENT'
AND a.id_poste IS NOT NULL
ORDER BY a.nom, a.prenom"
)->fetchAll();
}
public function allPosts(): array
{
return $this->pdo->query(
"SELECT id_poste, code, libelle, famille, heures_mercredi_minutes,
heures_extrascolaire_minutes, autorise_preparation,
peut_assurer_animation, preparation_lundi_si_100
FROM poste_agent
WHERE actif = TRUE
ORDER BY ordre_affichage, libelle"
)->fetchAll();
}
public function listForLocation(int $selectedLocation = 0): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
a.id_poste, po.libelle AS poste_libelle,
s.nom AS structure_nom,
s.type_affectation AS structure_type_affectation,
CASE WHEN a.id_structure = :selected_location THEN 1 ELSE 0 END AS lieu_principal_selectionne
FROM agent a
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.actif = TRUE
ORDER BY lieu_principal_selectionne DESC, a.nom, a.prenom"
);
$stmt->execute(['selected_location' => $selectedLocation]);
return $stmt->fetchAll();
}
public function findActive(int $agentId): ?array
{
$stmt = $this->pdo->prepare(
self::AGENT_SELECT . " WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1"
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
public function matriculeExists(string $matricule): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE matricule = :matricule LIMIT 1');
$stmt->execute(['matricule' => $matricule]);
return $stmt->fetchColumn() !== false;
}
public function createWithDefaults(array $data): array
{
$contractStart = new DateTimeImmutable((string) $data['date_debut_contrat']);
$contractEnd = !empty($data['date_fin_contrat'])
? new DateTimeImmutable((string) $data['date_fin_contrat'])
: null;
$year = (int) $contractStart->format('Y');
// Le quota PTA est suivi sur l'année civile, indépendamment de la date
// anniversaire du contrat. La date de contrat reste une donnée RH.
$periodStart = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840;
try {
$this->pdo->beginTransaction();
$insertAgent = $this->pdo->prepare(
'INSERT INTO agent (
matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta,
id_structure, actif
) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta,
:structure_id, TRUE
)'
);
$insertAgent->execute([
'matricule' => $data['matricule'],
'nom' => $data['nom'],
'prenom' => $data['prenom'],
'email' => $data['email'],
'telephone' => $data['telephone'],
'adresse' => $data['adresse'],
'est_diplome' => $data['est_diplome'] ? 1 : 0,
'diplome_libelle' => $data['est_diplome'] ? $data['diplome_libelle'] : null,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'id_poste' => $data['id_poste'],
'type_contrat' => $data['type_contrat'],
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0,
'commentaire_pta' => $data['commentaire_pta'],
'structure_id' => $data['structure_id'],
]);
$agentId = (int) $this->pdo->lastInsertId();
$history = $this->pdo->prepare(
'INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, :date_debut, NULL, TRUE)'
);
$history->execute([
'agent_id' => $agentId,
'structure_id' => $data['structure_id'],
'date_debut' => $contractStart->format('Y-m-d'),
]);
$quota = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes,
quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference,
:quota_cible, :commentaire
)'
);
$quota->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'commentaire' => 'Quota PTA de référence - année civile',
]);
$pta = $this->pdo->prepare(
'INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
quota_reference_minutes, quota_cible_minutes,
enveloppe_formation_minutes, statut
) VALUES (
:agent_id, :date_debut, :date_fin, :quotite,
:quota_reference, :quota_cible,
:formation, \'BROUILLON\'
)'
);
$pta->execute([
'agent_id' => $agentId,
'date_debut' => $periodStart->format('Y-m-d'),
'date_fin' => $periodEnd->format('Y-m-d'),
'quotite' => $data['quotite_travail'],
'quota_reference' => $quotaReference,
'quota_cible' => $quotaTarget,
'formation' => $trainingEnvelope,
]);
$this->pdo->commit();
return [
'id_agent' => $agentId,
'annee_quota' => $year,
'date_debut_contrat' => $contractStart->format('Y-m-d'),
'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'quota_cible_minutes' => $quotaTarget,
'id_pta' => (int) $this->pdo->lastInsertId(),
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateProfile(int $agentId, array $data): void
{
try {
$this->pdo->beginTransaction();
$currentStmt = $this->pdo->prepare('SELECT id_structure FROM agent WHERE id_agent = :agent_id FOR UPDATE');
$currentStmt->execute(['agent_id' => $agentId]);
$currentStructure = $currentStmt->fetchColumn();
if ($currentStructure === false) {
throw new \RuntimeException('Agent introuvable.');
}
$currentStructureId = $currentStructure !== null ? (int) $currentStructure : null;
$newStructureId = $data['structure_id'];
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
diplome_libelle = :diplome_libelle,
date_debut_contrat = :date_debut_contrat,
id_poste = :id_poste,
type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle,
commentaire_pta = :commentaire_pta,
id_structure = :structure_id
WHERE id_agent = :agent_id'
);
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);
$update->bindValue(':est_diplome', $data['est_diplome'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':diplome_libelle', $data['est_diplome'] ? $data['diplome_libelle'] : null);
$update->bindValue(':date_debut_contrat', $data['date_debut_contrat']);
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
$update->bindValue(':type_contrat', $data['type_contrat']);
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
if ($newStructureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
} else {
$update->bindValue(':structure_id', $newStructureId, PDO::PARAM_INT);
}
$update->execute();
if ($currentStructureId !== $newStructureId) {
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($newStructureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $newStructureId]);
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function updateAssignment(int $agentId, ?int $structureId): void
{
try {
$this->pdo->beginTransaction();
$update = $this->pdo->prepare('UPDATE agent SET id_structure = :structure_id WHERE id_agent = :agent_id');
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
if ($structureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
} else {
$update->bindValue(':structure_id', $structureId, PDO::PARAM_INT);
}
$update->execute();
$close = $this->pdo->prepare(
"UPDATE agent_structure
SET actif = FALSE,
date_fin = CASE
WHEN date_debut IS NOT NULL AND date_debut > CURRENT_DATE THEN date_debut
ELSE CURRENT_DATE
END
WHERE id_agent = :agent_id AND actif = TRUE"
);
$close->execute(['agent_id' => $agentId]);
if ($structureId !== null) {
$history = $this->pdo->prepare(
"INSERT INTO agent_structure (id_agent, id_structure, date_debut, date_fin, actif)
VALUES (:agent_id, :structure_id, CURRENT_DATE, NULL, TRUE)
ON DUPLICATE KEY UPDATE date_fin = NULL, actif = TRUE"
);
$history->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function entriesForWeek(int $agentId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute(['agent_id' => $agentId, 'annee' => $year, 'semaine' => $week]);
return $stmt->fetchAll();
}
public function entriesForDateRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
s2.id_structure, s2.nom AS structure_nom,
s2.type_affectation AS structure_type_affectation,
c.id_creneau, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
FROM planning p
INNER JOIN structure s2 ON s2.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
ORDER BY c.date_jour, c.heure_debut, s2.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForRange(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT UPPER(m.code) AS motif_code,
m.libelle AS motif_libelle,
m.compte_dans_quota,
COALESCE(SUM(TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))), 0) AS minutes_total,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
GROUP BY UPPER(m.code), m.libelle, m.compte_dans_quota
ORDER BY m.compte_dans_quota DESC, m.libelle"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
public function timeSummaryForYear(int $agentId, int $year): array
{
return $this->timeSummaryForRange($agentId, sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year));
}
}

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use RuntimeException;
final class AnnualOverviewModel extends BaseModel
{
private const CELL_KEYS = ['AP', 'PP', 'AE', 'PE'];
public function overview(int $agentId, int $year): array
{
$agentModel = new AgentModel($this->pdo);
$agent = $agentModel->findActive($agentId);
if ($agent === null) {
throw new RuntimeException('Agent introuvable.');
}
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$entries = $agentModel->entriesForDateRange($agentId, $start, $end);
$vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end);
$quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent);
$entriesByDate = [];
foreach ($entries as $entry) {
$entriesByDate[(string) $entry['date_jour']][] = $entry;
}
$months = [];
$yearTotals = array_fill_keys(self::CELL_KEYS, 0);
$statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialTotals = [];
for ($month = 1; $month <= 12; $month++) {
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$monthEnd = $monthStart->modify('last day of this month');
$days = [];
$monthTotals = array_fill_keys(self::CELL_KEYS, 0);
$period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day'));
foreach ($period as $date) {
$dateValue = $date->format('Y-m-d');
$vacation = $this->vacationForDate($dateValue, $vacations);
$day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent);
$days[] = $day;
foreach (self::CELL_KEYS as $key) {
$minutes = (int) $day['cells'][$key]['minutes'];
$monthTotals[$key] += $minutes;
$yearTotals[$key] += $minutes;
}
foreach ($day['status_minutes'] as $status => $minutes) {
$statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes;
}
foreach ($day['special_minutes'] as $code => $minutes) {
$specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes;
}
}
$months[] = [
'number' => $month,
'name' => $this->monthName($month),
'days' => $days,
'totals' => $this->formatTotals($monthTotals),
];
}
$calendarMinutes = array_sum($yearTotals);
return [
'agent' => $agent,
'year' => $year,
'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
'quota_periods' => $quotaPeriods,
'calendar_totals' => [
'minutes' => $calendarMinutes,
'duration' => $this->formatMinutes($calendarMinutes),
'cells' => $this->formatTotals($yearTotals),
'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)),
'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)),
'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals),
],
'months' => $months,
'legend' => [
['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'],
['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'],
['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'],
['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'],
['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'],
['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'],
['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'],
['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'],
],
];
}
private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array
{
$cells = [];
foreach (self::CELL_KEYS as $key) {
$cells[$key] = [
'minutes' => 0,
'display' => '',
'details' => [],
'codes' => [],
'class' => '',
'structure_id' => null,
];
}
$statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0];
$specialMinutes = [];
$hasDraft = false;
$firstStructureId = null;
foreach ($entries as $entry) {
$minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']);
if ($minutes <= 0) {
continue;
}
$code = strtoupper((string) $entry['motif_code']);
$cellKey = $this->cellForEntry($code, $vacation !== null);
$shortCode = $this->shortCode($code);
$status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON';
$statusMinutes[$status] += $minutes;
$hasDraft = $hasDraft || $status === 'BROUILLON';
$firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['minutes'] += $minutes;
$cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null;
$cells[$cellKey]['details'][] = sprintf(
'%s%s · %s · %s%s',
(string) $entry['heure_debut'],
(string) $entry['heure_fin'],
(string) $entry['motif_libelle'],
(string) $entry['structure_nom'],
$status === 'BROUILLON' ? ' · Brouillon' : ''
);
if ($shortCode !== null) {
$cells[$cellKey]['codes'][$shortCode] = true;
$specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes;
}
$cells[$cellKey]['class'] = $this->strongestClass(
(string) $cells[$cellKey]['class'],
$this->classForCode($code, $cellKey)
);
}
foreach (self::CELL_KEYS as $key) {
$codes = array_keys($cells[$key]['codes']);
$cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes);
$cells[$key]['title'] = implode("\n", $cells[$key]['details']);
unset($cells[$key]['details'], $cells[$key]['codes']);
}
$weekday = (int) $date->format('N');
$week = (int) $date->format('W');
$principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null
? (int) $agent['id_structure']
: null;
return [
'date' => $date->format('Y-m-d'),
'day' => (int) $date->format('j'),
'weekday' => $weekday,
'weekday_short' => $this->weekdayShort($weekday),
'week' => $week,
'week_year' => (int) $date->format('o'),
'show_week' => $weekday === 1 || (int) $date->format('j') === 1,
'is_weekend' => $weekday >= 6,
'is_vacation' => $vacation !== null,
'vacation_label' => $vacation['libelle'] ?? null,
'has_entries' => $entries !== [],
'has_draft' => $hasDraft,
'structure_id' => $firstStructureId ?? $principalStructure,
'cells' => $cells,
'status_minutes' => $statusMinutes,
'special_minutes' => $specialMinutes,
];
}
private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array
{
$quotaModel = new QuotaModel($this->pdo);
$contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)];
$contractStart = trim((string) ($agent['date_debut_contrat'] ?? ''));
if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) {
$contexts[] = $contractStart;
}
$periods = [];
foreach ($contexts as $context) {
$quota = $quotaModel->forDateForApi($agentId, $context);
$key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode'];
$periods[$key] = $quota;
}
uasort($periods, static fn(array $a, array $b): int => strcmp(
(string) $a['date_debut_periode'],
(string) $b['date_debut_periode']
));
return array_values($periods);
}
private function cellForEntry(string $code, bool $isVacation): string
{
return match ($code) {
'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP',
'PREPARATION_EXTRASCOLAIRE' => 'PE',
'EXTRASCOLAIRE' => 'AE',
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP',
'JOUR_FRACTIONNEMENT' => 'AP',
default => $isVacation ? 'AE' : 'AP',
};
}
private function shortCode(string $code): ?string
{
return match ($code) {
'FORMATION' => 'FOR',
'CONGE' => 'CA',
'ABSENCE', 'MALADIE' => 'ABS',
'AUTRE_ABSENCE' => 'A.ND',
'JNT' => 'JNT',
'JOUR_FRACTIONNEMENT' => 'JF',
'MENAGE_FOND' => 'MF',
default => null,
};
}
private function classForCode(string $code, string $cellKey): string
{
return match ($code) {
'FORMATION' => 'annual-training',
'CONGE' => 'annual-leave',
'ABSENCE', 'MALADIE' => 'annual-absence',
'AUTRE_ABSENCE' => 'annual-other-absence',
'JNT' => 'annual-jnt',
'JOUR_FRACTIONNEMENT' => 'annual-fraction',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation',
'EXTRASCOLAIRE' => 'annual-extra',
default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work',
};
}
private function strongestClass(string $current, string $candidate): string
{
$priority = [
'' => 0,
'annual-work' => 1,
'annual-extra' => 2,
'annual-preparation' => 3,
'annual-fraction' => 4,
'annual-training' => 5,
'annual-leave' => 6,
'annual-other-absence' => 7,
'annual-absence' => 8,
'annual-jnt' => 9,
];
return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current;
}
private function cellDisplay(int $minutes, array $codes): string
{
if ($minutes <= 0) {
return '';
}
if ($codes !== []) {
return implode('/', $codes);
}
return $this->formatCompactMinutes($minutes);
}
private function vacationForDate(string $date, array $vacations): ?array
{
foreach ($vacations as $vacation) {
if ($date >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) {
return $vacation;
}
}
return null;
}
private function minutesBetween(string $start, string $end): int
{
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute));
}
private function formatTotals(array $totals): array
{
$formatted = [];
foreach ($totals as $key => $minutes) {
$formatted[$key] = [
'minutes' => (int) $minutes,
'duration' => $this->formatMinutes((int) $minutes),
'compact' => $this->formatCompactMinutes((int) $minutes),
];
}
return $formatted;
}
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
private function formatCompactMinutes(int $minutes): string
{
if ($minutes <= 0) {
return '';
}
$hours = intdiv($minutes, 60);
$mins = $minutes % 60;
return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins);
}
private function monthName(int $month): string
{
return [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
][$month];
}
private function weekdayShort(int $weekday): string
{
return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday];
}
}

14
app/Models/BaseModel.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
abstract class BaseModel
{
public function __construct(protected PDO $pdo)
{
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
use Throwable;
final class CoverageModel extends BaseModel
{
private const DAY_LABELS = [
1 => 'Lundi',
2 => 'Mardi',
3 => 'Mercredi',
4 => 'Jeudi',
5 => 'Vendredi',
];
public function rulesForStructure(int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT id_couverture, id_structure, jour_semaine,
TIME_FORMAT(heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(heure_fin, '%H:%i') AS heure_fin,
minimum_agents
FROM structure_couverture_horaire
WHERE id_structure = :structure_id
ORDER BY jour_semaine, heure_debut, heure_fin"
);
$stmt->execute(['structure_id' => $structureId]);
return $stmt->fetchAll();
}
public function replaceRules(int $structureId, array $rules): void
{
try {
$this->pdo->beginTransaction();
$delete = $this->pdo->prepare(
'DELETE FROM structure_couverture_horaire WHERE id_structure = :structure_id'
);
$delete->execute(['structure_id' => $structureId]);
if ($rules !== []) {
$insert = $this->pdo->prepare(
'INSERT INTO structure_couverture_horaire
(id_structure, jour_semaine, heure_debut, heure_fin, minimum_agents)
VALUES
(:structure_id, :jour_semaine, :heure_debut, :heure_fin, :minimum_agents)'
);
foreach ($rules as $rule) {
$insert->execute([
'structure_id' => $structureId,
'jour_semaine' => (int) $rule['jour_semaine'],
'heure_debut' => $rule['heure_debut'],
'heure_fin' => $rule['heure_fin'],
'minimum_agents' => (int) $rule['minimum_agents'],
]);
}
}
$this->pdo->commit();
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function reportForStructure(int $structureId, int $year, int $weekNumber): array
{
$rules = $this->rulesForStructure($structureId);
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+4 days');
if ($rules === []) {
return [
'configured' => false,
'rules' => [],
'gaps' => [],
'validation_warnings' => [],
'summary' => [
'gap_count' => 0,
'gap_minutes' => 0,
'validation_warning_count' => 0,
],
];
}
$entries = $this->workEntries(
$structureId,
$weekStart->format('Y-m-d'),
$weekEnd->format('Y-m-d')
);
$entriesByDate = [];
foreach ($entries as $entry) {
$entriesByDate[$entry['date_jour']][] = $entry;
}
$gaps = [];
$validationWarnings = [];
foreach ($rules as $rule) {
$dayNumber = (int) $rule['jour_semaine'];
$date = $weekStart->modify('+' . ($dayNumber - 1) . ' days')->format('Y-m-d');
$start = $this->timeToMinutes((string) $rule['heure_debut']);
$end = $this->timeToMinutes((string) $rule['heure_fin']);
$minimum = (int) $rule['minimum_agents'];
for ($slotStart = $start; $slotStart < $end; $slotStart += 15) {
$slotEnd = min($slotStart + 15, $end);
$plannedAgents = [];
$validatedAgents = [];
foreach ($entriesByDate[$date] ?? [] as $entry) {
$entryStart = $this->timeToMinutes((string) $entry['heure_debut']);
$entryEnd = $this->timeToMinutes((string) $entry['heure_fin']);
if ($entryStart >= $slotEnd || $entryEnd <= $slotStart) {
continue;
}
$agentId = (int) $entry['id_agent'];
$plannedAgents[$agentId] = true;
if ($entry['statut'] === 'VALIDE') {
$validatedAgents[$agentId] = true;
}
}
$plannedCount = count($plannedAgents);
$validatedCount = count($validatedAgents);
$base = [
'date' => $date,
'jour_semaine' => $dayNumber,
'jour_libelle' => self::DAY_LABELS[$dayNumber] ?? 'Jour',
'heure_debut' => $this->minutesToTime($slotStart),
'heure_fin' => $this->minutesToTime($slotEnd),
'minimum_agents' => $minimum,
'agents_planifies' => $plannedCount,
'agents_valides' => $validatedCount,
];
if ($plannedCount < $minimum) {
$base['agents_manquants'] = $minimum - $plannedCount;
$this->appendMergedInterval($gaps, $base, ['minimum_agents', 'agents_planifies', 'agents_manquants']);
} elseif ($validatedCount < $minimum) {
$base['validations_manquantes'] = $minimum - $validatedCount;
$this->appendMergedInterval($validationWarnings, $base, ['minimum_agents', 'agents_valides', 'validations_manquantes']);
}
}
}
$gapMinutes = 0;
foreach ($gaps as $gap) {
$gapMinutes += $this->timeToMinutes($gap['heure_fin']) - $this->timeToMinutes($gap['heure_debut']);
}
return [
'configured' => true,
'rules' => $rules,
'gaps' => $gaps,
'validation_warnings' => $validationWarnings,
'summary' => [
'gap_count' => count($gaps),
'gap_minutes' => $gapMinutes,
'validation_warning_count' => count($validationWarnings),
],
];
}
private function workEntries(int $structureId, string $dateStart, string $dateEnd): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_agent, p.statut, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM planning p
INNER JOIN agent a ON a.id_agent = p.id_agent AND a.actif = TRUE
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_structure = :structure_id
AND c.date_jour BETWEEN :date_start AND :date_end
AND m.code = 'TRAVAIL'
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
);
$stmt->execute([
'structure_id' => $structureId,
'date_start' => $dateStart,
'date_end' => $dateEnd,
]);
return $stmt->fetchAll();
}
private function appendMergedInterval(array &$target, array $slot, array $comparisonKeys): void
{
$lastIndex = count($target) - 1;
if ($lastIndex >= 0) {
$last = $target[$lastIndex];
$same = $last['date'] === $slot['date'] && $last['heure_fin'] === $slot['heure_debut'];
foreach ($comparisonKeys as $key) {
$same = $same && (int) $last[$key] === (int) $slot[$key];
}
if ($same) {
$target[$lastIndex]['heure_fin'] = $slot['heure_fin'];
return;
}
}
$target[] = $slot;
}
private function timeToMinutes(string $time): int
{
[$hours, $minutes] = array_map('intval', explode(':', substr($time, 0, 5)));
return ($hours * 60) + $minutes;
}
private function minutesToTime(int $minutes): string
{
return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
}
}

26
app/Models/MotifModel.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class MotifModel extends BaseModel
{
public function allActive(): array
{
return $this->pdo->query(
"SELECT id_motif, code, libelle, compte_dans_quota
FROM motif_planning
WHERE actif = TRUE
ORDER BY ordre_affichage, libelle"
)->fetchAll();
}
public function activeIds(): array
{
return array_map(
'intval',
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
);
}
}

View File

@@ -0,0 +1,986 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use Throwable;
final class PlanningModel extends BaseModel
{
public function findByContext(int $structureId, int $agentId, int $year, int $week): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut, p.commentaire, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_structure = :structure_id
AND p.id_agent = :agent_id
AND s.annee = :annee
AND s.numero_semaine = :semaine
LIMIT 1"
);
$stmt->execute([
'structure_id' => $structureId,
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetch() ?: null;
}
public function entries(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM creneau_horaire c
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE c.id_planning = :planning_id
ORDER BY c.date_jour, c.heure_debut, c.heure_fin"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function otherLocationEntries(int $agentId, int $year, int $week, int $structureId): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.statut,
st.id_structure, st.code AS structure_code, st.nom AS structure_nom,
st.type_affectation AS structure_type_affectation,
c.id_creneau, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.id_motif, m.code AS motif_code, m.libelle AS motif_libelle,
m.compte_dans_quota
FROM planning p
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND p.id_structure <> :structure_id
ORDER BY c.date_jour, c.heure_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'semaine' => $week,
'structure_id' => $structureId,
]);
return $stmt->fetchAll();
}
public function saveDraft(int $structureId, int $agentId, int $year, int $weekNumber, array $entries): int
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
try {
$this->pdo->beginTransaction();
$weekStmt = $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)"
);
$weekStmt->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) {
$findWeek = $this->pdo->prepare(
'SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero'
);
$findWeek->execute(['annee' => $year, 'numero' => $weekNumber]);
$weekId = (int) $findWeek->fetchColumn();
}
$planningStmt = $this->pdo->prepare(
"SELECT id_planning, statut
FROM planning
WHERE id_agent = :agent_id
AND id_semaine = :semaine_id
AND id_structure = :structure_id
FOR UPDATE"
);
$planningStmt->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $planningStmt->fetch();
if ($planning && $planning['statut'] === 'VALIDE') {
throw new \DomainException('Ce planning est déjà validé et doit être rouvert avant modification.');
}
if ($planning) {
$planningId = (int) $planning['id_planning'];
$delete = $this->pdo->prepare('DELETE FROM creneau_horaire WHERE id_planning = :planning_id');
$delete->execute(['planning_id' => $planningId]);
} else {
$insertPlanning = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :semaine_id, :structure_id, 'BROUILLON')"
);
$insertPlanning->execute([
'agent_id' => $agentId,
'semaine_id' => $weekId,
'structure_id' => $structureId,
]);
$planningId = (int) $this->pdo->lastInsertId();
}
$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, :heure_debut, :heure_fin)"
);
foreach ($entries as $entry) {
$insertEntry->execute([
'planning_id' => $planningId,
'motif_id' => $entry['motif_id'],
'date_jour' => $entry['date'],
'heure_debut' => $entry['start'],
'heure_fin' => $entry['end'],
]);
}
$this->pdo->commit();
return $planningId;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function pendingValidation(): array
{
$stmt = $this->pdo->query(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom AS agent_nom, a.prenom AS agent_prenom,
st.nom AS structure_nom, st.type_affectation AS structure_type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
DATE_ADD(sem.date_debut, INTERVAL 4 DAY) AS date_fin_ouvrable,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes,
COALESCE(SUM(CASE WHEN m.compte_dans_quota = 1 THEN TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
) ELSE 0 END), 0) AS minutes_decomptees
FROM planning p
INNER JOIN agent a ON a.id_agent = p.id_agent
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
LEFT JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
p.date_creation, p.date_modification,
a.matricule, a.nom, a.prenom,
st.nom, st.type_affectation,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut ASC, a.nom ASC, a.prenom ASC, st.nom ASC"
);
return $stmt->fetchAll();
}
/**
* Plannings en brouillon de l'agent qui contiennent au moins un créneau
* dans l'année civile demandée.
*/
public function pendingValidationForAgentYear(int $agentId, int $year): array
{
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom AS structure_nom,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin,
COUNT(c.id_creneau) AS nombre_creneaux,
COALESCE(SUM(TIMESTAMPDIFF(
MINUTE,
CONCAT(c.date_jour, ' ', c.heure_debut),
CONCAT(c.date_jour, ' ', c.heure_fin)
)), 0) AS total_minutes
FROM planning p
INNER JOIN structure st ON st.id_structure = p.id_structure
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
LEFT JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND p.statut = 'BROUILLON'
AND EXISTS (
SELECT 1
FROM creneau_horaire cy
WHERE cy.id_planning = p.id_planning
AND cy.date_jour BETWEEN :date_debut AND :date_fin
)
GROUP BY p.id_planning, p.id_agent, p.id_structure, p.statut,
st.nom, sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
HAVING COUNT(c.id_creneau) > 0
ORDER BY sem.date_debut, st.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'date_debut' => $start,
'date_fin' => $end,
]);
return $stmt->fetchAll();
}
/**
* Chevauchements avec n'importe quel autre planning de l'agent,
* qu'il soit déjà validé ou encore en brouillon.
*/
public function externalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2,
c1.date_jour, st.nom AS structure_nom, p2.statut AS autre_statut
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function findDetailed(int $planningId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_planning, p.id_agent, p.id_structure, p.statut,
s.annee, s.numero_semaine, s.date_debut, s.date_fin
FROM planning p
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
WHERE p.id_planning = :planning_id
LIMIT 1"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetch() ?: null;
}
public function countEntries(int $planningId): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id');
$stmt->execute(['planning_id' => $planningId]);
return (int) $stmt->fetchColumn();
}
public function entriesOutsideWeek(int $planningId, string $start, string $end): array
{
$stmt = $this->pdo->prepare(
"SELECT id_creneau, date_jour
FROM creneau_horaire
WHERE id_planning = :planning_id
AND date_jour NOT BETWEEN :date_debut AND :date_fin"
);
$stmt->execute(['planning_id' => $planningId, 'date_debut' => $start, 'date_fin' => $end]);
return $stmt->fetchAll();
}
public function internalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour
FROM creneau_horaire c1
INNER JOIN creneau_horaire c2
ON c2.id_planning = c1.id_planning
AND c2.date_jour = c1.date_jour
AND c2.id_creneau > c1.id_creneau
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE c1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function validatedExternalOverlaps(int $planningId): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT c1.id_creneau AS c1, c2.id_creneau AS c2, c1.date_jour, st.nom AS structure_nom
FROM planning p1
INNER JOIN creneau_horaire c1 ON c1.id_planning = p1.id_planning
INNER JOIN planning p2
ON p2.id_agent = p1.id_agent
AND p2.id_planning <> p1.id_planning
AND p2.statut = 'VALIDE'
INNER JOIN structure st ON st.id_structure = p2.id_structure
INNER JOIN creneau_horaire c2
ON c2.id_planning = p2.id_planning
AND c2.date_jour = c1.date_jour
AND c1.heure_debut < c2.heure_fin
AND c1.heure_fin > c2.heure_debut
WHERE p1.id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->fetchAll();
}
public function findEntryDetailed(int $entryId): ?array
{
$stmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine, sem.date_debut, sem.date_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
LIMIT 1"
);
$stmt->execute(['entry_id' => $entryId]);
return $stmt->fetch() ?: null;
}
/**
* Modifie un créneau depuis la vue agent, y compris vers une autre semaine.
* Le planning cible est créé si nécessaire et tout planning validé modifié
* repasse automatiquement en brouillon.
*/
public function updateEntryFromAgentView(
int $entryId,
int $targetStructureId,
int $motifId,
string $date,
string $start,
string $end
): array {
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à modifier est introuvable.');
}
$targetDate = new DateTimeImmutable($date);
if ((int) $targetDate->format('N') > 5) {
throw new \DomainException('Le créneau doit être positionné entre le lundi et le vendredi.');
}
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND c.id_creneau <> :entry_id
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'entry_id' => $entryId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Double affectation impossible : un autre créneau existe déjà sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDate->format('o');
$targetWeekNumber = (int) $targetDate->format('W');
$targetWeekStart = $targetDate->modify('-' . ((int) $targetDate->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetWeekStart, $targetWeekStart->modify('+6 days'));
$sourcePlanningId = (int) $entry['id_planning'];
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
$targetStructureId
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$updateEntry = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :planning_id,
id_motif = :motif_id,
date_jour = :date_jour,
heure_debut = :heure_debut,
heure_fin = :heure_fin
WHERE id_creneau = :entry_id"
);
$updateEntry->execute([
'planning_id' => $targetPlanningId,
'motif_id' => $motifId,
'date_jour' => $date,
'heure_debut' => $start,
'heure_fin' => $end,
'entry_id' => $entryId,
]);
$reopenedPlanningIds = [];
if ((string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'source_planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Supprime un créneau depuis la vue par agent.
* Si le planning était validé, il repasse en brouillon afin de signaler
* que son contenu a été modifié. Le planning est conservé même s'il ne
* contient plus aucun créneau.
*/
public function deleteEntryFromAgentView(int $entryId): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.date_jour,
p.id_agent, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à supprimer est introuvable.');
}
$deleteStmt = $this->pdo->prepare(
'DELETE FROM creneau_horaire WHERE id_creneau = :entry_id'
);
$deleteStmt->execute(['entry_id' => $entryId]);
if ($deleteStmt->rowCount() !== 1) {
throw new \DomainException('Le créneau a déjà été supprimé ou modifié. Rechargez la page.');
}
$reopened = (string) $entry['statut'] === 'VALIDE';
if ($reopened) {
$reopenStmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON'
WHERE id_planning = :planning_id"
);
$reopenStmt->execute(['planning_id' => (int) $entry['id_planning']]);
}
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM creneau_horaire WHERE id_planning = :planning_id'
);
$countStmt->execute(['planning_id' => (int) $entry['id_planning']]);
$remainingEntries = (int) $countStmt->fetchColumn();
$this->pdo->commit();
return [
'entry_id' => $entryId,
'planning_id' => (int) $entry['id_planning'],
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'date' => (string) $entry['date_jour'],
'reopened' => $reopened,
'remaining_entries' => $remainingEntries,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
/**
* Déplace ou duplique un créneau vers n'importe quel jour ouvré.
* Le lieu, le motif et les horaires sont conservés, y compris lors d'un
* changement de semaine.
*/
public function moveOrDuplicateEntry(int $entryId, string $targetDate, bool $duplicate): array
{
try {
$this->pdo->beginTransaction();
$entryStmt = $this->pdo->prepare(
"SELECT c.id_creneau, c.id_planning, c.id_motif, c.date_jour, c.heure_debut, c.heure_fin,
p.id_agent, p.id_structure, p.id_semaine, p.statut,
sem.annee, sem.numero_semaine
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN semaine sem ON sem.id_semaine = p.id_semaine
WHERE c.id_creneau = :entry_id
FOR UPDATE"
);
$entryStmt->execute(['entry_id' => $entryId]);
$entry = $entryStmt->fetch();
if (!$entry) {
throw new \DomainException('Le créneau à déplacer est introuvable.');
}
$targetDateObject = new DateTimeImmutable($targetDate);
if ((int) $targetDateObject->format('N') > 5) {
throw new \DomainException('Le jour cible doit être compris entre le lundi et le vendredi.');
}
if (!$duplicate && $targetDate === (string) $entry['date_jour']) {
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => null,
'agent_id' => (int) $entry['id_agent'],
'year' => (int) $entry['annee'],
'week' => (int) $entry['numero_semaine'],
'planning_id' => (int) $entry['id_planning'],
'target_planning_id' => (int) $entry['id_planning'],
'reopened_planning_ids' => [],
'action' => 'noop',
];
}
$excludeEntryId = $duplicate ? 0 : $entryId;
$conflictStmt = $this->pdo->prepare(
"SELECT c.id_creneau, p.statut, st.nom AS structure_nom,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id
AND (:exclude_entry_id = 0 OR c.id_creneau <> :exclude_entry_id_compare)
AND c.date_jour = :date_jour
AND c.heure_debut < :heure_fin
AND c.heure_fin > :heure_debut
LIMIT 1
FOR UPDATE"
);
$conflictStmt->execute([
'agent_id' => (int) $entry['id_agent'],
'exclude_entry_id' => $excludeEntryId,
'exclude_entry_id_compare' => $excludeEntryId,
'date_jour' => $targetDate,
'heure_fin' => $entry['heure_fin'],
'heure_debut' => $entry['heure_debut'],
]);
$conflict = $conflictStmt->fetch();
if ($conflict) {
throw new \DomainException(sprintf(
'Action impossible : lagent possède déjà un créneau sur le lieu « %s » de %s à %s (%s).',
$conflict['structure_nom'],
$conflict['heure_debut'],
$conflict['heure_fin'],
strtolower((string) $conflict['statut'])
));
}
$targetYear = (int) $targetDateObject->format('o');
$targetWeekNumber = (int) $targetDateObject->format('W');
$targetMonday = $targetDateObject->modify('-' . ((int) $targetDateObject->format('N') - 1) . ' days')->setTime(0, 0);
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$targetPlanning = $this->findOrCreatePlanning(
(int) $entry['id_agent'],
$targetWeekId,
(int) $entry['id_structure']
);
$targetPlanningId = (int) $targetPlanning['id_planning'];
$sourcePlanningId = (int) $entry['id_planning'];
$createdEntryId = null;
if ($duplicate) {
$insertStmt = $this->pdo->prepare(
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
VALUES (:planning_id, :motif_id, :date_jour, :heure_debut, :heure_fin)"
);
$insertStmt->execute([
'planning_id' => $targetPlanningId,
'motif_id' => (int) $entry['id_motif'],
'date_jour' => $targetDate,
'heure_debut' => $entry['heure_debut'],
'heure_fin' => $entry['heure_fin'],
]);
$createdEntryId = (int) $this->pdo->lastInsertId();
} else {
$moveStmt = $this->pdo->prepare(
"UPDATE creneau_horaire
SET id_planning = :target_planning_id,
date_jour = :date_jour
WHERE id_creneau = :entry_id"
);
$moveStmt->execute([
'target_planning_id' => $targetPlanningId,
'date_jour' => $targetDate,
'entry_id' => $entryId,
]);
}
$reopenedPlanningIds = [];
if (!$duplicate && (string) $entry['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $sourcePlanningId;
}
if ((string) $targetPlanning['statut'] === 'VALIDE') {
$reopenedPlanningIds[] = $targetPlanningId;
}
$reopenedPlanningIds = $this->reopenPlanningIds($reopenedPlanningIds);
$this->pdo->commit();
return [
'entry_id' => $entryId,
'created_entry_id' => $createdEntryId,
'agent_id' => (int) $entry['id_agent'],
'year' => $targetYear,
'week' => $targetWeekNumber,
'planning_id' => $sourcePlanningId,
'target_planning_id' => $targetPlanningId,
'reopened_planning_ids' => $reopenedPlanningIds,
'action' => $duplicate ? 'duplicate' : 'move',
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
public function copyAgentWeek(
int $agentId,
int $sourceYear,
int $sourceWeekNumber,
int $targetYear,
int $targetWeekNumber,
string $mode
): array {
if (!in_array($mode, ['merge', 'replace'], true)) {
throw new \DomainException('Mode de duplication invalide.');
}
if ($sourceYear === $targetYear && $sourceWeekNumber === $targetWeekNumber) {
throw new \DomainException('La semaine cible doit être différente de la semaine source.');
}
$sourceStmt = $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 sem ON sem.id_semaine = p.id_semaine
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.id_agent = :agent_id
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
ORDER BY c.date_jour, c.heure_debut"
);
$sourceStmt->execute([
'agent_id' => $agentId,
'annee' => $sourceYear,
'semaine' => $sourceWeekNumber,
]);
$sourceEntries = $sourceStmt->fetchAll();
if (!$sourceEntries) {
throw new \DomainException('La semaine source ne contient aucun créneau à dupliquer.');
}
$targetMonday = (new DateTimeImmutable())->setISODate($targetYear, $targetWeekNumber, 1)->setTime(0, 0);
$targetEntries = array_map(static function (array $entry) use ($targetMonday): array {
return [
'date_jour' => $targetMonday->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d'),
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
'heure_debut' => (string) $entry['heure_debut'],
'heure_fin' => (string) $entry['heure_fin'],
];
}, $sourceEntries);
try {
$this->pdo->beginTransaction();
$targetWeekId = $this->ensureWeek($targetYear, $targetWeekNumber, $targetMonday, $targetMonday->modify('+6 days'));
$existingPlanningStmt = $this->pdo->prepare(
"SELECT id_planning, id_structure, statut
FROM planning
WHERE id_agent = :agent_id AND id_semaine = :week_id
FOR UPDATE"
);
$existingPlanningStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingPlannings = $existingPlanningStmt->fetchAll();
$reopened = [];
if ($mode === 'replace') {
if ($existingPlannings) {
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
foreach ($existingPlannings as $planning) {
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
$delete->execute($ids);
}
} else {
$existingEntriesStmt = $this->pdo->prepare(
"SELECT c.date_jour, c.heure_debut, c.heure_fin, st.nom AS structure_nom
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN structure st ON st.id_structure = p.id_structure
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
FOR UPDATE"
);
$existingEntriesStmt->execute(['agent_id' => $agentId, 'week_id' => $targetWeekId]);
$existingEntries = $existingEntriesStmt->fetchAll();
foreach ($targetEntries as $candidate) {
foreach ($existingEntries as $existing) {
if ($candidate['date_jour'] === $existing['date_jour']
&& $candidate['heure_debut'] < $existing['heure_fin']
&& $candidate['heure_fin'] > $existing['heure_debut']) {
throw new \DomainException(sprintf(
'Duplication impossible : un créneau existe déjà le %s de %s à %s sur « %s ».',
$candidate['date_jour'],
substr((string) $existing['heure_debut'], 0, 5),
substr((string) $existing['heure_fin'], 0, 5),
$existing['structure_nom']
));
}
}
}
}
$planningCache = [];
foreach ($existingPlannings as $planning) {
$planningCache[(int) $planning['id_structure']] = [
'id_planning' => (int) $planning['id_planning'],
'statut' => (string) $planning['statut'],
];
}
$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, :heure_debut, :heure_fin)"
);
foreach ($targetEntries as $candidate) {
$structureId = (int) $candidate['id_structure'];
if (!isset($planningCache[$structureId])) {
$planningCache[$structureId] = $this->findOrCreatePlanning($agentId, $targetWeekId, $structureId);
}
$planning = $planningCache[$structureId];
if ((string) $planning['statut'] === 'VALIDE') {
$reopened[] = (int) $planning['id_planning'];
}
$insertEntry->execute([
'planning_id' => (int) $planning['id_planning'],
'motif_id' => (int) $candidate['id_motif'],
'date_jour' => $candidate['date_jour'],
'heure_debut' => $candidate['heure_debut'],
'heure_fin' => $candidate['heure_fin'],
]);
}
$reopened = $this->reopenPlanningIds($reopened);
$this->pdo->commit();
return [
'agent_id' => $agentId,
'year' => $targetYear,
'week' => $targetWeekNumber,
'copied_entries' => count($targetEntries),
'reopened_planning_ids' => $reopened,
'mode' => $mode,
];
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
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;
}
/** @return array{id_planning:int,statut:string} */
private function findOrCreatePlanning(int $agentId, int $weekId, int $structureId): array
{
$stmt = $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"
);
$stmt->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
$planning = $stmt->fetch();
if ($planning) {
return ['id_planning' => (int) $planning['id_planning'], 'statut' => (string) $planning['statut']];
}
$insert = $this->pdo->prepare(
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
);
$insert->execute([
'agent_id' => $agentId,
'week_id' => $weekId,
'structure_id' => $structureId,
]);
return ['id_planning' => (int) $this->pdo->lastInsertId(), 'statut' => 'BROUILLON'];
}
/** @return int[] */
private function reopenPlanningIds(array $planningIds): array
{
$planningIds = array_values(array_unique(array_map('intval', array_filter($planningIds))));
if (!$planningIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $planningIds;
}
/** @param int[] $planningIds */
public function markValidatedMany(array $planningIds): int
{
$planningIds = array_values(array_unique(array_filter(array_map('intval', $planningIds), static fn(int $id): bool => $id > 0)));
if ($planningIds === []) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($planningIds), '?'));
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP
WHERE statut = 'BROUILLON' AND id_planning IN ($placeholders)"
);
$stmt->execute($planningIds);
return $stmt->rowCount();
}
public function markValidated(int $planningId): void
{
$stmt = $this->pdo->prepare(
"UPDATE planning SET statut = 'VALIDE', date_validation = CURRENT_TIMESTAMP WHERE id_planning = :planning_id"
);
$stmt->execute(['planning_id' => $planningId]);
}
public function reopen(int $planningId): bool
{
$stmt = $this->pdo->prepare(
"UPDATE planning
SET statut = 'BROUILLON', date_validation = NULL
WHERE id_planning = :planning_id AND statut = 'VALIDE'"
);
$stmt->execute(['planning_id' => $planningId]);
return $stmt->rowCount() === 1;
}
}

642
app/Models/PtaModel.php Normal file
View File

@@ -0,0 +1,642 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use PDO;
use RuntimeException;
final class PtaModel extends BaseModel
{
private const WORK_CODES = [
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
];
private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
];
public function summary(int $agentId, string $contextDate): array
{
$agentModel = new AgentModel($this->pdo);
$agent = $agentModel->findActive($agentId);
if ($agent === null) {
throw new RuntimeException('Agent introuvable.');
}
$quota = (new QuotaModel($this->pdo))->forDateForApi($agentId, $contextDate);
$pta = $this->ensurePta($agent, $quota);
$entries = $agentModel->entriesForDateRange(
$agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$vacations = (new VacationModel($this->pdo))->periodsBetween(
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$analysis = $this->analyseEntries($agent, $entries, $vacations, $quota);
$constraints = $this->constraintsForAgent(
$agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$wishes = $this->wishesForAgent($agentId);
$controls = array_merge(
$this->eligibilityControls($agent),
$analysis['controls'],
$this->constraintControls($analysis, $constraints),
$this->deepCleaningControls($agentId, (string) $quota['date_debut_periode'], (string) $quota['date_fin_periode'])
);
usort($controls, static function (array $a, array $b): int {
$order = ['ERREUR' => 0, 'ALERTE' => 1, 'INFO' => 2];
return ($order[$a['niveau']] ?? 9) <=> ($order[$b['niveau']] ?? 9);
});
return [
'agent' => $agent,
'pta' => $pta,
'quota' => $quota,
'categories' => $analysis['categories'],
'counters' => $analysis['counters'],
'controls' => $controls,
'constraints' => $constraints,
'wishes' => $wishes,
'vacations' => $vacations,
];
}
public function saveConstraint(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO agent_contrainte (
id_agent, type_contrainte, date_debut, date_fin,
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
interdit_matin, interdit_midi, interdit_soir, commentaire, actif
) VALUES (
:id_agent, :type_contrainte, :date_debut, :date_fin,
:quotite_temporaire, :maximum_minutes_jour, :maximum_minutes_semaine,
:interdit_matin, :interdit_midi, :interdit_soir, :commentaire, TRUE
)"
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
public function deleteConstraint(int $constraintId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM agent_contrainte WHERE id_contrainte = :id AND id_agent = :agent_id'
);
$stmt->execute(['id' => $constraintId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
public function saveWish(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO agent_structure_souhait (
id_agent, id_structure, type_souhait, priorite, distance_km, commentaire, actif
) VALUES (
:id_agent, :id_structure, :type_souhait, :priorite, :distance_km, :commentaire, TRUE
)
ON DUPLICATE KEY UPDATE
type_souhait = VALUES(type_souhait),
priorite = VALUES(priorite),
distance_km = VALUES(distance_km),
commentaire = VALUES(commentaire),
actif = TRUE"
);
$stmt->execute($data);
return (int) ($this->pdo->lastInsertId() ?: 0);
}
public function deleteWish(int $wishId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM agent_structure_souhait WHERE id_souhait = :id AND id_agent = :agent_id'
);
$stmt->execute(['id' => $wishId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
private function ensurePta(array $agent, array $quota): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM pta_annuel
WHERE id_agent = :agent_id
AND date_debut = :date_debut
AND date_fin = :date_fin
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
]);
$pta = $stmt->fetch();
if ($pta) {
return $pta;
}
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
$insert = $this->pdo->prepare(
"INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
quota_reference_minutes, quota_cible_minutes,
enveloppe_formation_minutes, statut
) VALUES (
:agent_id, :date_debut, :date_fin, :quotite,
:quota_reference, :quota_cible, :formation, 'BROUILLON'
)"
);
$insert->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
'quotite' => $quota['quotite_travail'],
'quota_reference' => $quota['quota_reference_minutes'],
'quota_cible' => $quota['quota_cible_minutes'],
'formation' => $training,
]);
$stmt->execute([
'agent_id' => $agent['id_agent'],
'date_debut' => $quota['date_debut_periode'],
'date_fin' => $quota['date_fin_periode'],
]);
return $stmt->fetch() ?: [];
}
private function analyseEntries(array $agent, array $entries, array $vacations, array $quota): array
{
$categories = [
'PERISCOLAIRE' => 0,
'MERCREDI_PERISCOLAIRE' => 0,
'EXTRASCOLAIRE' => 0,
'FORMATION' => 0,
'CONGE' => 0,
'JOUR_FRACTIONNEMENT' => 0,
'PREPARATION' => 0,
'MENAGE_FOND' => 0,
'ABSENCE' => 0,
'NON_DECOMPTE' => 0,
];
$daysByCategory = [];
$daily = [];
$weekly = [];
$controls = [];
foreach ($entries as $entry) {
$date = (string) $entry['date_jour'];
$start = (string) $entry['heure_debut'];
$end = (string) $entry['heure_fin'];
$minutes = $this->minutesBetween($start, $end);
$code = strtoupper((string) $entry['motif_code']);
$isVacation = $this->vacationForDate($date, $vacations) !== null;
$weekday = (int) (new DateTimeImmutable($date))->format('N');
$category = $this->categoryForEntry($code, $isVacation, $weekday, (bool) $entry['compte_dans_quota']);
$categories[$category] = ($categories[$category] ?? 0) + $minutes;
$daysByCategory[$category][$date] = true;
$daily[$date] ??= ['minutes' => 0, 'starts' => [], 'ends' => [], 'entries' => []];
$daily[$date]['minutes'] += $minutes;
$daily[$date]['starts'][] = $start;
$daily[$date]['ends'][] = $end;
$daily[$date]['entries'][] = [
'start' => $start,
'end' => $end,
'minutes' => $minutes,
'code' => $code,
'category' => $category,
];
$monday = (new DateTimeImmutable($date))->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
$weekly[$monday] = ($weekly[$monday] ?? 0) + $minutes;
}
foreach ($daily as $date => &$day) {
sort($day['starts']);
sort($day['ends']);
usort($day['entries'], static fn(array $a, array $b): int => strcmp($a['start'], $b['start']));
$first = min($day['starts']);
$last = max($day['ends']);
$day['amplitude_minutes'] = $this->minutesBetween($first, $last);
$day['first'] = $first;
$day['last'] = $last;
}
unset($day);
foreach ($daily as $date => $day) {
if ($day['minutes'] > 600) {
$controls[] = $this->control('ERREUR', 'DUREE_QUOTIDIENNE', sprintf(
'%s : %s planifiées, au-delà de 10 h de travail quotidien.',
$this->formatDate($date), $this->formatMinutes($day['minutes'])
));
}
if ($day['amplitude_minutes'] > 720) {
$controls[] = $this->control('ERREUR', 'AMPLITUDE_QUOTIDIENNE', sprintf(
'%s : amplitude de %s, au-delà de 12 h.',
$this->formatDate($date), $this->formatMinutes($day['amplitude_minutes'])
));
}
$continuous = 0;
$previousEnd = null;
foreach ($day['entries'] as $entry) {
if ($previousEnd === null || $this->minutesBetween($previousEnd, $entry['start']) >= 20) {
$continuous = $entry['minutes'];
} else {
$continuous += max(0, $this->minutesBetween(max($previousEnd, $entry['start']), $entry['end']));
}
$previousEnd = $previousEnd === null || $entry['end'] > $previousEnd ? $entry['end'] : $previousEnd;
if ($continuous > 360) {
$controls[] = $this->control('ERREUR', 'PAUSE_OBLIGATOIRE', sprintf(
'%s : plus de 6 h consécutives sans coupure dau moins 20 minutes.',
$this->formatDate($date)
));
break;
}
}
}
$dates = array_keys($daily);
sort($dates);
for ($i = 1, $count = count($dates); $i < $count; $i++) {
$previousDate = $dates[$i - 1];
$currentDate = $dates[$i];
$previousEnd = new DateTimeImmutable($previousDate . ' ' . $daily[$previousDate]['last']);
$currentStart = new DateTimeImmutable($currentDate . ' ' . $daily[$currentDate]['first']);
$restMinutes = (int) round(($currentStart->getTimestamp() - $previousEnd->getTimestamp()) / 60);
if ($restMinutes < 660) {
$controls[] = $this->control('ERREUR', 'REPOS_QUOTIDIEN', sprintf(
'Repos insuffisant entre le %s et le %s : %s au lieu de 11 h minimum.',
$this->formatDate($previousDate), $this->formatDate($currentDate), $this->formatMinutes($restMinutes)
));
}
}
foreach ($weekly as $monday => $minutes) {
if ($minutes > 2880) {
$controls[] = $this->control('ERREUR', 'DUREE_HEBDOMADAIRE', sprintf(
'Semaine du %s : %s planifiées, au-delà de 48 h.',
$this->formatDate($monday), $this->formatMinutes($minutes)
));
}
}
// Contrôle de la moyenne maximale de 44 h sur 12 semaines consécutives.
$periodStart = new DateTimeImmutable((string) $quota['date_debut_periode']);
$periodEnd = new DateTimeImmutable((string) $quota['date_fin_periode']);
$firstMonday = $periodStart->modify('-' . ((int) $periodStart->format('N') - 1) . ' days');
$lastMonday = $periodEnd->modify('-' . ((int) $periodEnd->format('N') - 1) . ' days');
$weekSeries = [];
for ($monday = $firstMonday; $monday <= $lastMonday; $monday = $monday->modify('+7 days')) {
$key = $monday->format('Y-m-d');
$weekSeries[] = ['date' => $key, 'minutes' => (int) ($weekly[$key] ?? 0)];
}
for ($index = 0, $totalWeeks = count($weekSeries); $index + 11 < $totalWeeks; $index++) {
$window = array_slice($weekSeries, $index, 12);
$average = (int) round(array_sum(array_column($window, 'minutes')) / 12);
if ($average > 2640) {
$controls[] = $this->control('ERREUR', 'MOYENNE_12_SEMAINES', sprintf(
'Du %s au %s : moyenne de %s par semaine sur 12 semaines, au-delà de 44 h.',
$this->formatDate($window[0]['date']),
$this->formatDate((new DateTimeImmutable($window[11]['date']))->modify('+6 days')->format('Y-m-d')),
$this->formatMinutes($average)
));
break;
}
}
$expectedWednesday = (int) ($agent['heures_mercredi_minutes'] ?? 0);
$expectedExtra = (int) ($agent['heures_extrascolaire_minutes'] ?? 0);
$vacationWeeks = [];
foreach ($daily as $date => $day) {
$dateObject = new DateTimeImmutable($date);
$weekday = (int) $dateObject->format('N');
$vacation = $this->vacationForDate($date, $vacations);
$workingMinutes = 0;
foreach ($day['entries'] as $entry) {
if (in_array($entry['code'], self::WORK_CODES, true)) {
$workingMinutes += $entry['minutes'];
}
}
if ($weekday === 3 && $vacation === null && $workingMinutes > 0 && $expectedWednesday > 0 && $workingMinutes !== $expectedWednesday) {
$controls[] = $this->control('ALERTE', 'MERCREDI_DUREE', sprintf(
'%s : %s de travail le mercredi scolaire, contre %s attendues pour le poste.',
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedWednesday)
));
}
if ($vacation !== null && $workingMinutes > 0 && $expectedExtra > 0 && $workingMinutes !== $expectedExtra) {
$controls[] = $this->control('ALERTE', 'EXTRA_DUREE', sprintf(
'%s : %s de travail extrascolaire, contre %s attendues pour le poste.',
$this->formatDate($date), $this->formatMinutes($workingMinutes), $this->formatMinutes($expectedExtra)
));
}
if ($vacation !== null && $workingMinutes > 0) {
$weekKey = $dateObject->modify('-' . ($weekday - 1) . ' days')->format('Y-m-d');
$vacationWeeks[$weekKey][$date] = true;
}
}
if (($agent['poste_famille'] ?? null) === 'ANIMATION') {
foreach ($vacationWeeks as $weekStart => $workedDays) {
$count = count($workedDays);
if ($count > 0 && $count < 5) {
$controls[] = $this->control('ALERTE', 'VACANCES_SEMAINE_INCOMPLETE', sprintf(
'Semaine de vacances du %s : seulement %d jour(s) travaillé(s). La préférence métier est une semaine complète.',
$this->formatDate($weekStart), $count
));
}
}
}
if (($agent['poste_famille'] ?? null) === 'RESTAURATION_ENTRETIEN') {
foreach (self::PREPARATION_CODES as $code) {
$hasPreparation = false;
foreach ($entries as $entry) {
if (strtoupper((string) $entry['motif_code']) === $code) {
$hasPreparation = true;
break;
}
}
if ($hasPreparation) {
$controls[] = $this->control('ERREUR', 'PREPARATION_NON_AUTORISEE',
'Un agent de restauration collective et dentretien ne doit pas recevoir de temps de préparation.');
break;
}
}
}
$formationMinutes = $categories['FORMATION'];
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
$this->formatMinutes($formationMinutes)
));
}
if ($formationTarget > 0 && $formationMinutes < $formationTarget) {
$controls[] = $this->control('INFO', 'FORMATION_RESTANTE', sprintf(
'Il reste %s dans lenveloppe de formation CNFPT de 14 h.',
$this->formatMinutes($formationTarget - $formationMinutes)
));
}
$caDays = count($daysByCategory['CONGE'] ?? []);
$jfDays = count($daysByCategory['JOUR_FRACTIONNEMENT'] ?? []);
if ($caDays !== 25) {
$controls[] = $this->control('ALERTE', 'CONGES_ANNUELS', sprintf(
'%d jour(s) de congé annuel positionné(s) sur un objectif métier de 25 jours.',
$caDays
));
}
if ($jfDays !== 2) {
$controls[] = $this->control('ALERTE', 'JOURS_FRACTIONNEMENT', sprintf(
'%d journée(s) de fractionnement positionnée(s) sur un objectif de 2.',
$jfDays
));
}
if ($controls === []) {
$controls[] = $this->control('INFO', 'OK', 'Aucune anomalie détectée sur le PTA de cette période.');
}
$formattedCategories = [];
foreach ($categories as $code => $minutes) {
$formattedCategories[] = [
'code' => $code,
'minutes' => $minutes,
'libelle' => $this->categoryLabel($code),
'duree' => $this->formatMinutes($minutes),
'jours' => count($daysByCategory[$code] ?? []),
];
}
return [
'categories' => $formattedCategories,
'counters' => [
'conges_annuels_jours' => $caDays,
'conges_annuels_cible' => 25,
'jours_fractionnement' => $jfDays,
'jours_fractionnement_cible' => 2,
'formation_minutes' => $formationMinutes,
'formation_cible_minutes' => $formationTarget,
'formation_duree' => $this->formatMinutes($formationMinutes),
'formation_cible_duree' => $this->formatMinutes($formationTarget),
'quota_cible' => $quota['quota_cible'],
'heures_affectees' => $quota['affectees'],
'heures_restantes' => $quota['restantes'],
],
'controls' => $controls,
'daily' => $daily,
'weekly' => $weekly,
];
}
private function eligibilityControls(array $agent): array
{
$controls = [];
if (($agent['type_contrat'] ?? '') !== 'PERMANENT') {
$controls[] = $this->control('ERREUR', 'CONTRAT_NON_PERMANENT',
'Le dispositif PTA est réservé ici aux agents en contrat permanent.');
}
if (empty($agent['id_poste'])) {
$controls[] = $this->control('ERREUR', 'POSTE_MANQUANT',
'Le poste de lagent doit être renseigné pour appliquer les règles du PTA.');
}
return $controls;
}
private function constraintControls(array $analysis, array $constraints): array
{
$controls = [];
foreach ($constraints as $constraint) {
$label = $constraint['type_contrainte'] === 'TEMPS_PARTIEL_THERAPEUTIQUE'
? 'Temps partiel thérapeutique'
: 'Préconisation médicale';
$controls[] = $this->control('ALERTE', 'CONTRAINTE_PRIORITAIRE', sprintf(
'%s du %s au %s : %s',
$label,
$this->formatDate((string) $constraint['date_debut']),
$constraint['date_fin'] ? $this->formatDate((string) $constraint['date_fin']) : 'sans date de fin',
(string) $constraint['commentaire']
));
foreach ($analysis['daily'] as $date => $day) {
if ($date < $constraint['date_debut'] || ($constraint['date_fin'] && $date > $constraint['date_fin'])) {
continue;
}
if ($constraint['maximum_minutes_jour'] !== null
&& $day['minutes'] > (int) $constraint['maximum_minutes_jour']) {
$controls[] = $this->control('ERREUR', 'CONTRAINTE_MAX_JOUR', sprintf(
'%s : %s planifiées alors que la contrainte limite la journée à %s.',
$this->formatDate($date), $this->formatMinutes($day['minutes']),
$this->formatMinutes((int) $constraint['maximum_minutes_jour'])
));
}
}
}
return $controls;
}
private function deepCleaningControls(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT c.date_jour, p.id_structure, s.nom AS structure_nom,
COUNT(DISTINCT p.id_agent) AS agents,
SUM(CASE WHEN p.id_agent = :agent_id THEN TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) ELSE 0 END) AS minutes_agent
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
JOIN motif_planning m ON m.id_motif = c.id_motif
JOIN structure s ON s.id_structure = p.id_structure
WHERE m.code = 'MENAGE_FOND'
AND c.date_jour BETWEEN :date_debut AND :date_fin
AND EXISTS (
SELECT 1 FROM planning px
JOIN creneau_horaire cx ON cx.id_planning = px.id_planning
JOIN motif_planning mx ON mx.id_motif = cx.id_motif
WHERE px.id_agent = :agent_exists
AND px.id_structure = p.id_structure
AND cx.date_jour = c.date_jour
AND mx.code = 'MENAGE_FOND'
)
GROUP BY c.date_jour, p.id_structure, s.nom"
);
$stmt->execute([
'agent_id' => $agentId,
'agent_exists' => $agentId,
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
$controls = [];
foreach ($stmt->fetchAll() as $row) {
if ((int) $row['agents'] < 2) {
$controls[] = $this->control('ERREUR', 'MENAGE_EQUIPE', sprintf(
'%s — %s : le ménage de fond ne compte que %d agent(s), alors que 2 sont requis.',
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'], (int) $row['agents']
));
}
if ((int) $row['minutes_agent'] !== 420) {
$controls[] = $this->control('ALERTE', 'MENAGE_DUREE', sprintf(
'%s — %s : %s de ménage de fond pour lagent, au lieu de 7 h.',
$this->formatDate((string) $row['date_jour']), (string) $row['structure_nom'],
$this->formatMinutes((int) $row['minutes_agent'])
));
}
}
return $controls;
}
private function constraintsForAgent(int $agentId, string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT id_contrainte, type_contrainte, date_debut, date_fin,
quotite_temporaire, maximum_minutes_jour, maximum_minutes_semaine,
interdit_matin, interdit_midi, interdit_soir, commentaire
FROM agent_contrainte
WHERE id_agent = :agent_id
AND actif = TRUE
AND date_debut <= :date_fin
AND COALESCE(date_fin, '9999-12-31') >= :date_debut
ORDER BY date_debut DESC"
);
$stmt->execute(['agent_id' => $agentId, 'date_debut' => $startDate, 'date_fin' => $endDate]);
return $stmt->fetchAll();
}
private function wishesForAgent(int $agentId): array
{
$stmt = $this->pdo->prepare(
"SELECT sw.id_souhait, sw.id_structure, s.nom AS structure_nom,
sw.type_souhait, sw.priorite, sw.distance_km, sw.commentaire
FROM agent_structure_souhait sw
JOIN structure s ON s.id_structure = sw.id_structure
WHERE sw.id_agent = :agent_id AND sw.actif = TRUE
ORDER BY FIELD(sw.type_souhait, 'INTERDICTION', 'PREFERENCE'), sw.priorite DESC, s.nom"
);
$stmt->execute(['agent_id' => $agentId]);
return $stmt->fetchAll();
}
private function categoryForEntry(string $code, bool $isVacation, int $weekday, bool $counts): string
{
return match ($code) {
'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR' => 'PERISCOLAIRE',
'MERCREDI_PERISCOLAIRE' => 'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE' => 'EXTRASCOLAIRE',
'FORMATION' => 'FORMATION',
'CONGE' => 'CONGE',
'JOUR_FRACTIONNEMENT' => 'JOUR_FRACTIONNEMENT',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'PREPARATION',
'MENAGE_FOND' => 'MENAGE_FOND',
'ABSENCE' => 'ABSENCE',
'AUTRE_ABSENCE', 'JNT' => 'NON_DECOMPTE',
'TRAVAIL' => $isVacation ? 'EXTRASCOLAIRE' : ($weekday === 3 ? 'MERCREDI_PERISCOLAIRE' : 'PERISCOLAIRE'),
default => $counts ? 'PERISCOLAIRE' : 'NON_DECOMPTE',
};
}
private function categoryLabel(string $code): string
{
return match ($code) {
'PERISCOLAIRE' => 'Temps périscolaire lundi, mardi, jeudi et vendredi',
'MERCREDI_PERISCOLAIRE' => 'Mercredis périscolaires',
'EXTRASCOLAIRE' => 'Temps extrascolaire',
'FORMATION' => 'Formation',
'CONGE' => 'Congés annuels non décomptés',
'JOUR_FRACTIONNEMENT' => 'Journées de fractionnement',
'PREPARATION' => 'Temps de préparation',
'MENAGE_FOND' => 'Ménages de fond',
'ABSENCE' => 'Absences décomptées',
'NON_DECOMPTE' => 'JNT et absences non décomptées',
default => $code,
};
}
private function vacationForDate(string $date, array $vacations): ?array
{
foreach ($vacations as $vacation) {
if ($date >= $vacation['date_debut'] && $date <= $vacation['date_fin']) {
return $vacation;
}
}
return null;
}
private function minutesBetween(string $start, string $end): int
{
[$sh, $sm] = array_map('intval', explode(':', substr($start, 0, 5)));
[$eh, $em] = array_map('intval', explode(':', substr($end, 0, 5)));
return ($eh * 60 + $em) - ($sh * 60 + $sm);
}
private function formatMinutes(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$minutes = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60);
}
private function formatDate(string $date): string
{
return (new DateTimeImmutable($date))->format('d/m/Y');
}
private function control(string $level, string $code, string $message): array
{
return ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}

209
app/Models/QuotaModel.php Normal file
View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Suivi du quota PTA sur l'année CIVILE.
*
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
* La date de début du contrat reste une information RH utile, mais elle ne sert
* plus d'ancre pour décaler la période annuelle du quota.
*/
final class QuotaModel extends BaseModel
{
public const REFERENCE_MINUTES = 1607 * 60;
public function createForAgent(int $agentId, int $year, float $rate): void
{
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
$stmt = $this->pdo->prepare(
'INSERT INTO quota_agent_annuel (
id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire
) VALUES (
:agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire
)'
);
$stmt->execute([
'agent_id' => $agentId,
'annee' => $year,
'quotite' => $rate,
'quota_reference' => self::REFERENCE_MINUTES,
'quota_cible' => $target,
'commentaire' => 'Quota PTA de référence pour lannée civile',
]);
}
/**
* Retourne le quota de l'année civile contenant la date fournie.
*
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
*/
public function forDate(int $agentId, string $contextDate): array
{
$period = $this->referencePeriod($agentId, $contextDate);
$quota = $this->quotaProfile($agentId, (int) $period['annee_periode']);
$usedStmt = $this->pdo->prepare(
"SELECT
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_agent = :agent_id
AND c.date_jour BETWEEN :date_debut AND :date_fin
AND m.compte_dans_quota = TRUE"
);
$usedStmt->execute([
'agent_id' => $agentId,
'date_debut' => $period['date_debut_periode'],
'date_fin' => $period['date_fin_periode'],
]);
$used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0];
$validated = (int) $used['minutes_valides'];
$draft = (int) $used['minutes_brouillon'];
$target = (int) $quota['quota_cible_minutes'];
$allocated = $validated + $draft;
return array_merge($period, [
'annee' => (int) $period['annee_periode'],
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $quota['quota_source_annee'],
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0,
]);
}
public function forDateForApi(int $agentId, string $contextDate): array
{
return $this->enrich($this->forDate($agentId, $contextDate));
}
public function annual(int $agentId, int $year): array
{
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
}
public function annualForApi(int $agentId, int $year): array
{
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
}
/**
* Période de référence PTA : toujours l'année civile.
* La date de contrat est retournée uniquement à titre informatif.
*/
public function referencePeriod(int $agentId, string $contextDate): array
{
$context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate);
if (!$context || $context->format('Y-m-d') !== $contextDate) {
throw new InvalidArgumentException('Date de référence du quota invalide.');
}
$stmt = $this->pdo->prepare(
'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1'
);
$stmt->execute(['agent_id' => $agentId]);
$contractStartValue = $stmt->fetchColumn();
$year = (int) $context->format('Y');
$start = new DateTimeImmutable(sprintf('%04d-01-01', $year));
$end = new DateTimeImmutable(sprintf('%04d-12-31', $year));
return [
'date_reference' => $context->format('Y-m-d'),
'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null,
'date_debut_periode' => $start->format('Y-m-d'),
'date_fin_periode' => $end->format('Y-m-d'),
'annee_periode' => $year,
'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')),
'periode_contractuelle' => false,
'periode_civile' => true,
// Conservé pour compatibilité avec les anciens écrans : la date de
// contrat n'est plus requise pour calculer la période du quota.
'date_contrat_manquante' => false,
];
}
public function enrich(array $quota): array
{
return array_merge($quota, [
'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']),
'valides' => $this->formatMinutes((int) $quota['minutes_valides']),
'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']),
'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']),
'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']),
]);
}
public function formatMinutes(int $minutes): array
{
$sign = $minutes < 0 ? -1 : 1;
$absolute = abs($minutes);
$hours = intdiv($absolute, 60);
$mins = $absolute % 60;
return [
'minutes' => $minutes,
'heures_decimales' => round($minutes / 60, 2),
'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT),
];
}
private function quotaProfile(int $agentId, int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes
FROM quota_agent_annuel
WHERE id_agent = :agent_id
ORDER BY CASE
WHEN annee = :year_exact THEN 0
WHEN annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN annee <= :year_latest THEN annee END DESC,
CASE WHEN annee > :year_future THEN annee END ASC
LIMIT 1"
);
$stmt->execute([
'agent_id' => $agentId,
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$quota = $stmt->fetch();
if (!$quota) {
return [
'quotite_travail' => 100.00,
'quota_reference_minutes' => self::REFERENCE_MINUTES,
'quota_cible_minutes' => self::REFERENCE_MINUTES,
'quota_source_annee' => $year,
];
}
return [
'quotite_travail' => (float) $quota['quotite_travail'],
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
'quota_cible_minutes' => (int) $quota['quota_cible_minutes'],
'quota_source_annee' => (int) $quota['annee'],
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class SchemaModel extends BaseModel
{
public function assertCurrent(): void
{
$requiredTables = [
'agent',
'structure',
'agent_structure',
'motif_planning',
'semaine',
'planning',
'creneau_horaire',
'quota_agent_annuel',
'modele_semaine_agent',
'modele_semaine_creneau',
'structure_couverture_horaire',
'periode_vacance',
'poste_agent',
'pta_annuel',
'agent_structure_souhait',
'agent_contrainte',
'equipe_pta',
'equipe_pta_membre',
'structure_creneau_reference',
];
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredTables);
$existing = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$missingTables = array_values(array_diff($requiredTables, $existing));
if ($missingTables) {
throw new \RuntimeException(
'Base PTA incomplète : table(s) manquante(s) : '
. implode(', ', $missingTables)
. '. Exécutez database/install_complet.sql ou les migrations nécessaires.'
);
}
$requiredColumns = [
'creneau_horaire' => ['id_motif'],
'planning' => ['date_validation'],
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'],
'structure' => ['type_affectation'],
];
foreach ($requiredColumns as $table => $columns) {
$columnPlaceholders = implode(',', array_fill(0, count($columns), '?'));
$params = array_merge([$table], $columns);
$columnStmt = $this->pdo->prepare(
"SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME IN ($columnPlaceholders)"
);
$columnStmt->execute($params);
$existingColumns = $columnStmt->fetchAll(\PDO::FETCH_COLUMN);
$missingColumns = array_values(array_diff($columns, $existingColumns));
if ($missingColumns) {
throw new \RuntimeException(sprintf(
'Base PTA incomplète : colonne(s) manquante(s) dans %s : %s.',
$table,
implode(', ', $missingColumns)
));
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Models;
final class StructureModel extends BaseModel
{
public function allActive(): array
{
return $this->pdo->query(
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE actif = TRUE ORDER BY nom'
)->fetchAll();
}
public function findActive(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom, adresse, type_affectation FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1'
);
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
public function codeExists(string $code): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE code = :code LIMIT 1');
$stmt->execute(['code' => $code]);
return $stmt->fetchColumn() !== false;
}
public function create(string $code, string $name, ?string $address, string $typeAffectation): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO structure (code, nom, adresse, type_affectation, actif)
VALUES (:code, :nom, :adresse, :type_affectation, TRUE)'
);
$stmt->execute([
'code' => $code,
'nom' => $name,
'adresse' => $address,
'type_affectation' => $typeAffectation,
]);
return [
'id_structure' => (int) $this->pdo->lastInsertId(),
'code' => $code,
'nom' => $name,
'adresse' => $address,
'type_affectation' => $typeAffectation,
];
}
public function updateType(int $structureId, string $typeAffectation): void
{
$stmt = $this->pdo->prepare(
'UPDATE structure SET type_affectation = :type_affectation WHERE id_structure = :id_structure'
);
$stmt->execute([
'type_affectation' => $typeAffectation,
'id_structure' => $structureId,
]);
}
public function agentsForOverview(int $structureId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT DISTINCT a.id_agent, a.matricule, a.nom, a.prenom,
a.id_structure AS id_structure_principale
FROM agent a
LEFT JOIN planning p
ON p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
LEFT JOIN semaine sem
ON sem.id_semaine = p.id_semaine
AND sem.annee = :annee
AND sem.numero_semaine = :semaine
WHERE a.actif = TRUE
AND (a.id_structure = :structure_id_default OR sem.id_semaine IS NOT NULL)
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'structure_id_planning' => $structureId,
'structure_id_default' => $structureId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetchAll();
}
public function entriesForOverview(int $structureId, int $year, int $week): array
{
$stmt = $this->pdo->prepare(
"SELECT p.id_agent, p.statut, c.date_jour,
TIME_FORMAT(c.heure_debut, '%H:%i') AS heure_debut,
TIME_FORMAT(c.heure_fin, '%H:%i') AS heure_fin,
m.code AS motif_code, m.libelle AS motif_libelle, m.compte_dans_quota
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
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
WHERE p.id_structure = :structure_id
AND s.annee = :annee
AND s.numero_semaine = :semaine
ORDER BY p.id_agent, c.date_jour, c.heure_debut"
);
$stmt->execute([
'structure_id' => $structureId,
'annee' => $year,
'semaine' => $week,
]);
return $stmt->fetchAll();
}
}

236
app/Models/TeamModel.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Throwable;
final class TeamModel extends BaseModel
{
public function all(?string $startDate = null, ?string $endDate = null): array
{
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
e.type_equipe, e.libelle, e.date_debut, e.date_fin,
e.nombre_enfants_moins_6, e.nombre_enfants_6_plus,
e.ratio_moins_6, e.ratio_6_plus, e.minimum_agents,
e.pourcentage_diplomes_min, e.statut, e.commentaire,
GREATEST(e.minimum_agents,
CEIL(e.nombre_enfants_moins_6 / e.ratio_moins_6)
+ CEIL(e.nombre_enfants_6_plus / e.ratio_6_plus)
) AS agents_requis,
COUNT(em.id_agent) AS agents_affectes,
SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) AS agents_diplomes,
SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables
FROM equipe_pta e
JOIN structure s ON s.id_structure = e.id_structure
LEFT JOIN equipe_pta_membre em ON em.id_equipe = e.id_equipe
LEFT JOIN agent a ON a.id_agent = em.id_agent";
$params = [];
$conditions = [];
if ($startDate !== null) {
$conditions[] = 'e.date_fin >= :date_debut';
$params['date_debut'] = $startDate;
}
if ($endDate !== null) {
$conditions[] = 'e.date_debut <= :date_fin';
$params['date_fin'] = $endDate;
}
if ($conditions !== []) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= " GROUP BY e.id_equipe, e.id_structure, s.nom, e.type_equipe, e.libelle,
e.date_debut, e.date_fin, e.nombre_enfants_moins_6,
e.nombre_enfants_6_plus, e.ratio_moins_6, e.ratio_6_plus,
e.minimum_agents, e.pourcentage_diplomes_min, e.statut,
e.commentaire
ORDER BY e.date_debut, s.nom, e.libelle";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$teams = $stmt->fetchAll();
foreach ($teams as &$team) {
$team['members'] = $this->members((int) $team['id_equipe']);
$team['controls'] = $this->controls($team, $team['members']);
}
unset($team);
return $teams;
}
public function find(int $teamId): ?array
{
foreach ($this->all() as $team) {
if ((int) $team['id_equipe'] === $teamId) {
return $team;
}
}
return null;
}
public function create(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta (
id_structure, type_equipe, libelle, date_debut, date_fin,
nombre_enfants_moins_6, nombre_enfants_6_plus,
ratio_moins_6, ratio_6_plus, minimum_agents,
pourcentage_diplomes_min, statut, commentaire
) VALUES (
:id_structure, :type_equipe, :libelle, :date_debut, :date_fin,
:nombre_enfants_moins_6, :nombre_enfants_6_plus,
:ratio_moins_6, :ratio_6_plus, :minimum_agents,
:pourcentage_diplomes_min, 'BROUILLON', :commentaire
)"
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
{
$team = $this->teamDates($teamId);
if ($team === null) {
throw new \RuntimeException('Équipe introuvable.');
}
$forbidden = $this->pdo->prepare(
"SELECT 1
FROM agent_structure_souhait
WHERE id_agent = :agent_id
AND id_structure = :structure_id
AND type_souhait = 'INTERDICTION'
AND actif = TRUE
LIMIT 1"
);
$forbidden->execute(['agent_id' => $agentId, 'structure_id' => $team['id_structure']]);
if ($forbidden->fetchColumn() !== false) {
throw new \RuntimeException('Cet agent a une interdiction active pour ce lieu.');
}
// Les contraintes médicales et thérapeutiques restent prioritaires, mais
// laffectation nest pas bloquée automatiquement : elles sont signalées
// dans les contrôles de léquipe pour validation humaine.
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta_membre (id_equipe, id_agent, fonction_equipe, commentaire)
VALUES (:id_equipe, :id_agent, :fonction_equipe, :commentaire)
ON DUPLICATE KEY UPDATE
fonction_equipe = VALUES(fonction_equipe),
commentaire = VALUES(commentaire)"
);
$stmt->execute([
'id_equipe' => $teamId,
'id_agent' => $agentId,
'fonction_equipe' => $function,
'commentaire' => $comment,
]);
}
public function removeMember(int $teamId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM equipe_pta_membre WHERE id_equipe = :team_id AND id_agent = :agent_id'
);
$stmt->execute(['team_id' => $teamId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
public function setStatus(int $teamId, string $status): void
{
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
$stmt->execute(['statut' => $status, 'id' => $teamId]);
}
private function members(int $teamId): array
{
$stmt = $this->pdo->prepare(
"SELECT em.id_agent, em.fonction_equipe, em.commentaire,
a.matricule, a.nom, a.prenom, a.est_diplome,
a.diplome_libelle, a.adresse, a.id_structure,
po.code AS poste_code, po.libelle AS poste_libelle,
s.nom AS structure_principale_nom,
sw.type_souhait, sw.priorite, sw.distance_km,
(SELECT COUNT(*)
FROM agent_contrainte ac
WHERE ac.id_agent = a.id_agent
AND ac.actif = TRUE
AND ac.date_debut <= e.date_fin
AND COALESCE(ac.date_fin, '9999-12-31') >= e.date_debut) AS contraintes_actives
FROM equipe_pta_membre em
JOIN agent a ON a.id_agent = em.id_agent
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN equipe_pta e ON e.id_equipe = em.id_equipe
LEFT JOIN agent_structure_souhait sw
ON sw.id_agent = a.id_agent
AND sw.id_structure = e.id_structure
AND sw.actif = TRUE
WHERE em.id_equipe = :team_id
ORDER BY FIELD(em.fonction_equipe, 'RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'), a.nom, a.prenom"
);
$stmt->execute(['team_id' => $teamId]);
return $stmt->fetchAll();
}
private function controls(array $team, array $members): array
{
$controls = [];
$required = (int) $team['agents_requis'];
$assigned = (int) $team['agents_affectes'];
if ($assigned < $required) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'ENCADREMENT_INSUFFISANT',
'message' => sprintf('%d agent(s) affecté(s) pour %d requis.', $assigned, $required),
];
}
$qualified = (int) $team['agents_diplomes'];
$qualifiedRate = $assigned > 0 ? round(($qualified / $assigned) * 100, 2) : 0;
if ($qualifiedRate < (float) $team['pourcentage_diplomes_min']) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'QUALIFICATION_INSUFFISANTE',
'message' => sprintf('%.2f %% dagents diplômés pour un minimum paramétré à %.2f %%.', $qualifiedRate, (float) $team['pourcentage_diplomes_min']),
];
}
if ((int) $team['responsables'] < 1) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'RESPONSABLE_MANQUANT',
'message' => 'Aucun responsable nest identifié dans léquipe.',
];
}
foreach ($members as $member) {
if ((int) ($member['contraintes_actives'] ?? 0) > 0) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'CONTRAINTE_PRIORITAIRE',
'message' => sprintf('%s %s possède une préconisation médicale ou un temps partiel thérapeutique actif sur la période.', $member['prenom'], $member['nom']),
];
}
if (($member['type_souhait'] ?? null) === 'PREFERENCE') {
$controls[] = [
'niveau' => 'INFO',
'code' => 'PREFERENCE_RESPECTEE',
'message' => sprintf('%s %s a exprimé une préférence pour ce lieu.', $member['prenom'], $member['nom']),
];
}
}
if ($controls === []) {
$controls[] = ['niveau' => 'INFO', 'code' => 'OK', 'message' => 'Équipe conforme aux paramètres enregistrés.'];
}
return $controls;
}
private function teamDates(int $teamId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id_structure, date_debut, date_fin FROM equipe_pta WHERE id_equipe = :id LIMIT 1'
);
$stmt->execute(['id' => $teamId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Models;
use DateTimeImmutable;
use PDO;
final class VacationModel extends BaseModel
{
public function all(?string $schoolYear = null): array
{
$sql = "SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire,
academie, zone, source, date_creation
FROM periode_vacance
WHERE actif = TRUE";
$params = [];
if ($schoolYear !== null && $schoolYear !== '') {
$sql .= ' AND annee_scolaire = :annee_scolaire';
$params['annee_scolaire'] = $schoolYear;
}
$sql .= ' ORDER BY date_debut, libelle';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function saveMany(array $periods): int
{
if ($periods === []) {
return 0;
}
$sql = "INSERT INTO periode_vacance
(libelle, date_debut, date_fin, annee_scolaire, academie, zone, source, actif)
VALUES
(:libelle, :date_debut, :date_fin, :annee_scolaire, :academie, :zone, :source, TRUE)
ON DUPLICATE KEY UPDATE
libelle = VALUES(libelle),
zone = VALUES(zone),
source = VALUES(source),
actif = TRUE";
$stmt = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
try {
foreach ($periods as $period) {
$stmt->execute([
'libelle' => $period['libelle'],
'date_debut' => $period['date_debut'],
'date_fin' => $period['date_fin'],
'annee_scolaire' => $period['annee_scolaire'],
'academie' => $period['academie'],
'zone' => $period['zone'],
'source' => $period['source'],
]);
}
$this->pdo->commit();
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
return count($periods);
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM periode_vacance WHERE id_periode = :id');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
public function periodsBetween(string $startDate, string $endDate): array
{
$stmt = $this->pdo->prepare(
"SELECT id_periode, libelle, date_debut, date_fin, annee_scolaire, academie, zone, source
FROM periode_vacance
WHERE actif = TRUE
AND date_debut <= :date_fin
AND date_fin >= :date_debut
ORDER BY date_debut"
);
$stmt->execute([
'date_debut' => $startDate,
'date_fin' => $endDate,
]);
return $stmt->fetchAll();
}
/**
* Retourne le type de période pour les cinq jours ouvrés d'une semaine ISO.
* Une date comprise dans une période de vacances confirmée est EXTRASCOLAIRE,
* sinon elle est PERISCOLAIRE.
*/
public function typesForIsoWeek(int $year, int $week): array
{
$monday = (new DateTimeImmutable())->setISODate($year, $week, 1)->setTime(0, 0);
$friday = $monday->modify('+4 days');
$periods = $this->periodsBetween($monday->format('Y-m-d'), $friday->format('Y-m-d'));
$days = [];
for ($offset = 0; $offset < 5; $offset++) {
$date = $monday->modify('+' . $offset . ' days')->format('Y-m-d');
$matchingPeriod = null;
foreach ($periods as $period) {
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
$matchingPeriod = $period;
break;
}
}
$days[] = [
'date' => $date,
'type_affectation' => $matchingPeriod ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE',
'vacances' => $matchingPeriod ? [
'id_periode' => (int) $matchingPeriod['id_periode'],
'libelle' => (string) $matchingPeriod['libelle'],
'annee_scolaire' => (string) $matchingPeriod['annee_scolaire'],
] : null,
];
}
return $days;
}
}

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