pour prod
This commit is contained in:
440
app/Models/AgentModel.php
Normal file
440
app/Models/AgentModel.php
Normal 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));
|
||||
}
|
||||
}
|
||||
342
app/Models/AnnualOverviewModel.php
Normal file
342
app/Models/AnnualOverviewModel.php
Normal 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
14
app/Models/BaseModel.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
abstract class BaseModel
|
||||
{
|
||||
public function __construct(protected PDO $pdo)
|
||||
{
|
||||
}
|
||||
}
|
||||
225
app/Models/CoverageModel.php
Normal file
225
app/Models/CoverageModel.php
Normal 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
26
app/Models/MotifModel.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
986
app/Models/PlanningModel.php
Normal file
986
app/Models/PlanningModel.php
Normal 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 : l’agent 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
642
app/Models/PtaModel.php
Normal 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 d’au 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 d’entretien 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 l’enveloppe 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 l’agent 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 l’agent, 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
209
app/Models/QuotaModel.php
Normal 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 l’anné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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Models/SchemaModel.php
Normal file
82
app/Models/SchemaModel.php
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Models/StructureModel.php
Normal file
114
app/Models/StructureModel.php
Normal 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
236
app/Models/TeamModel.php
Normal 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
|
||||
// l’affectation n’est 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 %% d’agents 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 n’est 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;
|
||||
}
|
||||
}
|
||||
131
app/Models/VacationModel.php
Normal file
131
app/Models/VacationModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
861
app/Models/WeekTemplateModel.php
Normal file
861
app/Models/WeekTemplateModel.php
Normal file
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DomainException;
|
||||
use Throwable;
|
||||
|
||||
final class WeekTemplateModel extends BaseModel
|
||||
{
|
||||
public function listForAgent(int $agentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT m.id_modele, m.nom, m.date_creation, m.date_modification,
|
||||
COUNT(c.id_modele_creneau) AS nombre_creneaux,
|
||||
COUNT(DISTINCT c.id_structure) AS nombre_lieux
|
||||
FROM modele_semaine_agent m
|
||||
LEFT JOIN modele_semaine_creneau c ON c.id_modele = m.id_modele
|
||||
WHERE m.id_agent = :agent_id
|
||||
GROUP BY m.id_modele, m.nom, m.date_creation, m.date_modification
|
||||
ORDER BY m.nom"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findForAgent(int $templateId, int $agentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT id_modele, id_agent, nom, date_creation, date_modification
|
||||
FROM modele_semaine_agent
|
||||
WHERE id_modele = :template_id AND id_agent = :agent_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function entries(int $templateId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.id_modele_creneau, c.jour_semaine, c.id_structure, c.id_motif,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom,
|
||||
s.type_affectation AS structure_type_affectation,
|
||||
s.actif AS structure_actif,
|
||||
m.libelle AS motif_libelle,
|
||||
m.compte_dans_quota,
|
||||
m.actif AS motif_actif
|
||||
FROM modele_semaine_creneau c
|
||||
INNER JOIN structure s ON s.id_structure = c.id_structure
|
||||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||||
WHERE c.id_modele = :template_id
|
||||
ORDER BY c.jour_semaine, c.heure_debut, c.heure_fin, s.nom"
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || strlen($name) > 240) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
|
||||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||||
if (!$sourceEntries) {
|
||||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$existingStmt = $this->pdo->prepare(
|
||||
'SELECT id_modele FROM modele_semaine_agent WHERE id_agent = :agent_id AND nom = :nom FOR UPDATE'
|
||||
);
|
||||
$existingStmt->execute(['agent_id' => $agentId, 'nom' => $name]);
|
||||
$existingId = (int) ($existingStmt->fetchColumn() ?: 0);
|
||||
|
||||
if ($existingId > 0 && !$replaceExisting) {
|
||||
throw new DomainException('Une semaine type porte déjà ce nom pour cet agent.');
|
||||
}
|
||||
|
||||
if ($existingId > 0) {
|
||||
$templateId = $existingId;
|
||||
$delete = $this->pdo->prepare('DELETE FROM modele_semaine_creneau WHERE id_modele = :template_id');
|
||||
$delete->execute(['template_id' => $templateId]);
|
||||
$touch = $this->pdo->prepare('UPDATE modele_semaine_agent SET date_modification = CURRENT_TIMESTAMP WHERE id_modele = :template_id');
|
||||
$touch->execute(['template_id' => $templateId]);
|
||||
} else {
|
||||
$insertTemplate = $this->pdo->prepare(
|
||||
'INSERT INTO modele_semaine_agent (id_agent, nom) VALUES (:agent_id, :nom)'
|
||||
);
|
||||
$insertTemplate->execute(['agent_id' => $agentId, 'nom' => $name]);
|
||||
$templateId = (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO modele_semaine_creneau
|
||||
(id_modele, jour_semaine, id_structure, id_motif, heure_debut, heure_fin)
|
||||
VALUES
|
||||
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
|
||||
);
|
||||
|
||||
foreach ($sourceEntries as $entry) {
|
||||
$insertEntry->execute([
|
||||
'template_id' => $templateId,
|
||||
'weekday' => (int) $entry['jour_semaine'],
|
||||
'structure_id' => (int) $entry['id_structure'],
|
||||
'motif_id' => (int) $entry['id_motif'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return [
|
||||
'id_modele' => $templateId,
|
||||
'nom' => $name,
|
||||
'nombre_creneaux' => count($sourceEntries),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function applyToWeek(int $templateId, int $agentId, int $year, int $weekNumber, string $mode): array
|
||||
{
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
throw new DomainException('Mode d’import invalide.');
|
||||
}
|
||||
|
||||
$template = $this->findForAgent($templateId, $agentId);
|
||||
if ($template === null) {
|
||||
throw new DomainException('Semaine type introuvable pour cet agent.');
|
||||
}
|
||||
|
||||
$templateEntries = $this->entries($templateId);
|
||||
if (!$templateEntries) {
|
||||
throw new DomainException('Cette semaine type ne contient aucun créneau.');
|
||||
}
|
||||
foreach ($templateEntries as $entry) {
|
||||
if (!(bool) $entry['structure_actif']) {
|
||||
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
|
||||
}
|
||||
if (!(bool) $entry['motif_actif']) {
|
||||
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
|
||||
}
|
||||
}
|
||||
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekEnd = $weekStart->modify('+6 days');
|
||||
|
||||
$targetEntries = array_map(static function (array $entry) use ($weekStart): array {
|
||||
$date = $weekStart->modify('+' . ((int) $entry['jour_semaine'] - 1) . ' days')->format('Y-m-d');
|
||||
return [
|
||||
'date' => $date,
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
'heure_debut' => $entry['heure_debut'],
|
||||
'heure_fin' => $entry['heure_fin'],
|
||||
'structure_nom' => $entry['structure_nom'],
|
||||
];
|
||||
}, $templateEntries);
|
||||
|
||||
$this->assertTemplateNoOverlap($targetEntries);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$weekId = $this->ensureWeek($year, $weekNumber, $weekStart, $weekEnd);
|
||||
|
||||
$existingPlanningsStmt = $this->pdo->prepare(
|
||||
"SELECT id_planning, id_structure, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$existingPlanningsStmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
|
||||
$existingPlannings = $existingPlanningsStmt->fetchAll();
|
||||
|
||||
$reopened = [];
|
||||
if ($mode === 'replace') {
|
||||
foreach ($existingPlannings as $planning) {
|
||||
if ($planning['statut'] === 'VALIDE') {
|
||||
$reopened[] = (int) $planning['id_planning'];
|
||||
}
|
||||
}
|
||||
if ($existingPlannings) {
|
||||
$ids = array_map(static fn(array $row): int => (int) $row['id_planning'], $existingPlannings);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_planning IN ($placeholders)");
|
||||
$delete->execute($ids);
|
||||
$reopen = $this->pdo->prepare("UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($placeholders)");
|
||||
$reopen->execute($ids);
|
||||
}
|
||||
} else {
|
||||
$this->assertNoExistingOverlap($agentId, $weekId, $targetEntries);
|
||||
}
|
||||
|
||||
$planningByStructure = [];
|
||||
foreach ($existingPlannings as $planning) {
|
||||
$planningByStructure[(int) $planning['id_structure']] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
}
|
||||
|
||||
$selectPlanning = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$insertPlanning = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$reopenPlanning = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
|
||||
);
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
|
||||
);
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($targetEntries as $entry) {
|
||||
$structureId = (int) $entry['id_structure'];
|
||||
if (!isset($planningByStructure[$structureId])) {
|
||||
$selectPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $selectPlanning->fetch();
|
||||
if ($planning) {
|
||||
$planningByStructure[$structureId] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
} else {
|
||||
$insertPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planningByStructure[$structureId] = [
|
||||
'id_planning' => (int) $this->pdo->lastInsertId(),
|
||||
'statut' => 'BROUILLON',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$planningInfo = &$planningByStructure[$structureId];
|
||||
if ($planningInfo['statut'] === 'VALIDE') {
|
||||
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
|
||||
$reopened[] = $planningInfo['id_planning'];
|
||||
$planningInfo['statut'] = 'BROUILLON';
|
||||
}
|
||||
|
||||
$insertEntry->execute([
|
||||
'planning_id' => $planningInfo['id_planning'],
|
||||
'motif_id' => $entry['id_motif'],
|
||||
'date_jour' => $entry['date'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
$inserted++;
|
||||
unset($planningInfo);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return [
|
||||
'template' => $template,
|
||||
'inserted' => $inserted,
|
||||
'mode' => $mode,
|
||||
'reopened_planning_ids' => array_values(array_unique($reopened)),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function previewPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter = 'all'
|
||||
): array {
|
||||
return $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, true);
|
||||
}
|
||||
|
||||
public function applyToPeriod(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter = 'all'
|
||||
): array {
|
||||
$plan = $this->buildPeriodPlan($templateId, $agentId, $startDate, $endDate, $mode, $periodFilter, false);
|
||||
$targetEntries = $plan['target_entries'];
|
||||
|
||||
if ($targetEntries === []) {
|
||||
throw new DomainException(
|
||||
'Aucun créneau de cette semaine type ne correspond au filtre de calendrier sélectionné sur cette période. Vérifiez les dates et les vacances enregistrées dans PTA.'
|
||||
);
|
||||
}
|
||||
|
||||
$targetDates = array_values(array_unique(array_column($targetEntries, 'date')));
|
||||
$targetDateLookup = array_fill_keys($targetDates, true);
|
||||
$reopened = [];
|
||||
$deleted = 0;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate, true);
|
||||
if ($mode === 'merge') {
|
||||
$conflicts = $this->findConflicts($targetEntries, $existing);
|
||||
if ($conflicts !== []) {
|
||||
$first = $conflicts[0];
|
||||
throw new DomainException(sprintf(
|
||||
'Application impossible en mode « Compléter » : %d conflit%s détecté%s. Premier conflit le %s entre %s-%s et un créneau existant %s-%s à « %s ». Utilisez l’aperçu puis le mode « Remplacer les jours concernés » ou corrigez le planning existant.',
|
||||
count($conflicts),
|
||||
count($conflicts) > 1 ? 's' : '',
|
||||
count($conflicts) > 1 ? 's' : '',
|
||||
$first['date'],
|
||||
substr($first['template_start'], 0, 5),
|
||||
substr($first['template_end'], 0, 5),
|
||||
substr($first['existing_start'], 0, 5),
|
||||
substr($first['existing_end'], 0, 5),
|
||||
$first['existing_structure']
|
||||
));
|
||||
}
|
||||
} else {
|
||||
$entriesToDelete = array_values(array_filter(
|
||||
$existing,
|
||||
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
|
||||
));
|
||||
|
||||
if ($entriesToDelete !== []) {
|
||||
$entryIds = array_map(static fn(array $entry): int => (int) $entry['id_creneau'], $entriesToDelete);
|
||||
$planningIds = [];
|
||||
foreach ($entriesToDelete as $entry) {
|
||||
if ((string) $entry['statut'] === 'VALIDE') {
|
||||
$planningIds[(int) $entry['id_planning']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($entryIds), '?'));
|
||||
$delete = $this->pdo->prepare("DELETE FROM creneau_horaire WHERE id_creneau IN ($placeholders)");
|
||||
$delete->execute($entryIds);
|
||||
$deleted = count($entryIds);
|
||||
|
||||
if ($planningIds !== []) {
|
||||
$ids = array_keys($planningIds);
|
||||
$planningPlaceholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$reopen = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning IN ($planningPlaceholders)"
|
||||
);
|
||||
$reopen->execute($ids);
|
||||
$reopened = array_merge($reopened, $ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$weekIds = [];
|
||||
$planningCache = [];
|
||||
$selectPlanning = $this->pdo->prepare(
|
||||
"SELECT id_planning, statut
|
||||
FROM planning
|
||||
WHERE id_agent = :agent_id AND id_semaine = :week_id AND id_structure = :structure_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$insertPlanning = $this->pdo->prepare(
|
||||
"INSERT INTO planning (id_agent, id_semaine, id_structure, statut)
|
||||
VALUES (:agent_id, :week_id, :structure_id, 'BROUILLON')"
|
||||
);
|
||||
$reopenPlanning = $this->pdo->prepare(
|
||||
"UPDATE planning SET statut = 'BROUILLON', date_validation = NULL WHERE id_planning = :planning_id"
|
||||
);
|
||||
$insertEntry = $this->pdo->prepare(
|
||||
"INSERT INTO creneau_horaire (id_planning, id_motif, date_jour, heure_debut, heure_fin)
|
||||
VALUES (:planning_id, :motif_id, :date_jour, :start, :end)"
|
||||
);
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($targetEntries as $entry) {
|
||||
$date = new DateTimeImmutable($entry['date']);
|
||||
$isoYear = (int) $date->format('o');
|
||||
$weekNumber = (int) $date->format('W');
|
||||
$weekKey = sprintf('%04d-W%02d', $isoYear, $weekNumber);
|
||||
|
||||
if (!isset($weekIds[$weekKey])) {
|
||||
$weekStart = (new DateTimeImmutable())->setISODate($isoYear, $weekNumber, 1)->setTime(0, 0);
|
||||
$weekIds[$weekKey] = $this->ensureWeek(
|
||||
$isoYear,
|
||||
$weekNumber,
|
||||
$weekStart,
|
||||
$weekStart->modify('+6 days')
|
||||
);
|
||||
}
|
||||
|
||||
$weekId = $weekIds[$weekKey];
|
||||
$structureId = (int) $entry['id_structure'];
|
||||
$planningKey = $weekId . ':' . $structureId;
|
||||
|
||||
if (!isset($planningCache[$planningKey])) {
|
||||
$selectPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planning = $selectPlanning->fetch();
|
||||
if ($planning) {
|
||||
$planningCache[$planningKey] = [
|
||||
'id_planning' => (int) $planning['id_planning'],
|
||||
'statut' => (string) $planning['statut'],
|
||||
];
|
||||
} else {
|
||||
$insertPlanning->execute([
|
||||
'agent_id' => $agentId,
|
||||
'week_id' => $weekId,
|
||||
'structure_id' => $structureId,
|
||||
]);
|
||||
$planningCache[$planningKey] = [
|
||||
'id_planning' => (int) $this->pdo->lastInsertId(),
|
||||
'statut' => 'BROUILLON',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$planningInfo = &$planningCache[$planningKey];
|
||||
if ($planningInfo['statut'] === 'VALIDE') {
|
||||
$reopenPlanning->execute(['planning_id' => $planningInfo['id_planning']]);
|
||||
$reopened[] = $planningInfo['id_planning'];
|
||||
$planningInfo['statut'] = 'BROUILLON';
|
||||
}
|
||||
|
||||
$insertEntry->execute([
|
||||
'planning_id' => $planningInfo['id_planning'],
|
||||
'motif_id' => (int) $entry['id_motif'],
|
||||
'date_jour' => $entry['date'],
|
||||
'start' => $entry['heure_debut'],
|
||||
'end' => $entry['heure_fin'],
|
||||
]);
|
||||
$inserted++;
|
||||
unset($planningInfo);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
return [
|
||||
'template' => $plan['template'],
|
||||
'inserted' => $inserted,
|
||||
'deleted' => $deleted,
|
||||
'mode' => $mode,
|
||||
'period_filter' => $plan['period_filter'],
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'weeks_touched' => count($weekIds),
|
||||
'days_touched' => count($targetDates),
|
||||
'skipped_count' => $plan['summary']['skipped_count'],
|
||||
'skipped_vacation_count' => $plan['summary']['skipped_vacation_count'],
|
||||
'skipped_school_count' => $plan['summary']['skipped_school_count'],
|
||||
'reopened_planning_ids' => array_values(array_unique(array_map('intval', $reopened))),
|
||||
'years' => $plan['summary']['years'],
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $templateId, int $agentId): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM modele_semaine_agent WHERE id_modele = :template_id AND id_agent = :agent_id'
|
||||
);
|
||||
$stmt->execute(['template_id' => $templateId, 'agent_id' => $agentId]);
|
||||
return $stmt->rowCount() === 1;
|
||||
}
|
||||
|
||||
private function sourceWeekEntries(int $agentId, int $year, int $weekNumber): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT WEEKDAY(c.date_jour) + 1 AS jour_semaine,
|
||||
p.id_structure, c.id_motif,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin
|
||||
FROM planning p
|
||||
INNER JOIN semaine s ON s.id_semaine = p.id_semaine
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND s.annee = :annee
|
||||
AND s.numero_semaine = :week_number
|
||||
AND c.date_jour BETWEEN s.date_debut AND s.date_fin
|
||||
AND WEEKDAY(c.date_jour) BETWEEN 0 AND 4
|
||||
ORDER BY c.date_jour, c.heure_debut, p.id_structure"
|
||||
);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'annee' => $year,
|
||||
'week_number' => $weekNumber,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function buildPeriodPlan(
|
||||
int $templateId,
|
||||
int $agentId,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $mode,
|
||||
string $periodFilter,
|
||||
bool $includeExistingAnalysis
|
||||
): array {
|
||||
if (!in_array($mode, ['merge', 'replace'], true)) {
|
||||
throw new DomainException('Mode d’application invalide.');
|
||||
}
|
||||
if (!in_array($periodFilter, ['all', 'school', 'extra'], true)) {
|
||||
throw new DomainException('Filtre de calendrier invalide.');
|
||||
}
|
||||
|
||||
$start = $this->parseStrictDate($startDate, 'date de début');
|
||||
$end = $this->parseStrictDate($endDate, 'date de fin');
|
||||
if ($end < $start) {
|
||||
throw new DomainException('La date de fin doit être postérieure ou égale à la date de début.');
|
||||
}
|
||||
if ((int) $start->diff($end)->days > 730) {
|
||||
throw new DomainException('La période ne peut pas dépasser deux ans.');
|
||||
}
|
||||
|
||||
$template = $this->findForAgent($templateId, $agentId);
|
||||
if ($template === null) {
|
||||
throw new DomainException('Semaine type introuvable pour cet agent.');
|
||||
}
|
||||
|
||||
$templateEntries = $this->entries($templateId);
|
||||
if ($templateEntries === []) {
|
||||
throw new DomainException('Cette semaine type ne contient aucun créneau.');
|
||||
}
|
||||
foreach ($templateEntries as $entry) {
|
||||
if (!(bool) $entry['structure_actif']) {
|
||||
throw new DomainException(sprintf('Le lieu « %s » utilisé par cette semaine type est désormais inactif.', $entry['structure_nom']));
|
||||
}
|
||||
if (!(bool) $entry['motif_actif']) {
|
||||
throw new DomainException(sprintf('Le motif « %s » utilisé par cette semaine type est désormais inactif.', $entry['motif_libelle']));
|
||||
}
|
||||
}
|
||||
|
||||
$entriesByWeekday = [];
|
||||
foreach ($templateEntries as $entry) {
|
||||
$entriesByWeekday[(int) $entry['jour_semaine']][] = $entry;
|
||||
}
|
||||
|
||||
$vacationPeriods = (new VacationModel($this->pdo))->periodsBetween($startDate, $endDate);
|
||||
$targetEntries = [];
|
||||
$skippedEntries = [];
|
||||
$days = [];
|
||||
$weeks = [];
|
||||
$years = [];
|
||||
|
||||
for ($date = $start; $date <= $end; $date = $date->modify('+1 day')) {
|
||||
$weekday = (int) $date->format('N');
|
||||
if ($weekday > 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateValue = $date->format('Y-m-d');
|
||||
$vacation = $this->vacationForDate($dateValue, $vacationPeriods);
|
||||
$periodType = $vacation ? 'EXTRASCOLAIRE' : 'PERISCOLAIRE';
|
||||
$dayEntries = $entriesByWeekday[$weekday] ?? [];
|
||||
$eligibleCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($dayEntries as $entry) {
|
||||
$dateIsEligible = match ($periodFilter) {
|
||||
'school' => $periodType === 'PERISCOLAIRE',
|
||||
'extra' => $periodType === 'EXTRASCOLAIRE',
|
||||
default => true,
|
||||
};
|
||||
|
||||
if (!$dateIsEligible) {
|
||||
$skippedCount++;
|
||||
$skippedEntries[] = [
|
||||
'date' => $dateValue,
|
||||
'reason' => $periodType === 'EXTRASCOLAIRE' ? 'VACANCES' : 'HORS_VACANCES',
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
'structure_name' => (string) $entry['structure_nom'],
|
||||
'start' => (string) $entry['heure_debut'],
|
||||
'end' => (string) $entry['heure_fin'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$eligibleCount++;
|
||||
$targetEntries[] = [
|
||||
'date' => $dateValue,
|
||||
'id_structure' => (int) $entry['id_structure'],
|
||||
'id_motif' => (int) $entry['id_motif'],
|
||||
'heure_debut' => (string) $entry['heure_debut'],
|
||||
'heure_fin' => (string) $entry['heure_fin'],
|
||||
'structure_nom' => (string) $entry['structure_nom'],
|
||||
'motif_libelle' => (string) $entry['motif_libelle'],
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
];
|
||||
$weeks[$date->format('o-W')] = true;
|
||||
$years[(int) $date->format('Y')] = true;
|
||||
}
|
||||
|
||||
if ($dayEntries !== []) {
|
||||
$days[$dateValue] = [
|
||||
'date' => $dateValue,
|
||||
'weekday' => $weekday,
|
||||
'period_type' => $periodType,
|
||||
'vacation_label' => $vacation['libelle'] ?? null,
|
||||
'template_entry_count' => count($dayEntries),
|
||||
'eligible_count' => $eligibleCount,
|
||||
'skipped_count' => $skippedCount,
|
||||
'conflict_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertTemplateNoOverlap($targetEntries);
|
||||
|
||||
$existing = [];
|
||||
$conflicts = [];
|
||||
$existingOnTargetDates = [];
|
||||
if ($includeExistingAnalysis && $targetEntries !== []) {
|
||||
$existing = $this->existingEntriesBetween($agentId, $startDate, $endDate);
|
||||
$conflicts = $this->findConflicts($targetEntries, $existing);
|
||||
$targetDateLookup = array_fill_keys(array_unique(array_column($targetEntries, 'date')), true);
|
||||
$existingOnTargetDates = array_values(array_filter(
|
||||
$existing,
|
||||
static fn(array $entry): bool => isset($targetDateLookup[$entry['date_jour']])
|
||||
));
|
||||
|
||||
foreach ($conflicts as $conflict) {
|
||||
if (isset($days[$conflict['date']])) {
|
||||
$days[$conflict['date']]['conflict_count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skippedVacationCount = count(array_filter(
|
||||
$skippedEntries,
|
||||
static fn(array $entry): bool => $entry['reason'] === 'VACANCES'
|
||||
));
|
||||
$skippedSchoolCount = count($skippedEntries) - $skippedVacationCount;
|
||||
|
||||
return [
|
||||
'template' => [
|
||||
'id' => (int) $template['id_modele'],
|
||||
'name' => (string) $template['nom'],
|
||||
],
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'mode' => $mode,
|
||||
'period_filter' => $periodFilter,
|
||||
'target_entries' => $targetEntries,
|
||||
'skipped_entries' => $skippedEntries,
|
||||
'conflicts' => $conflicts,
|
||||
'days' => array_values($days),
|
||||
'vacation_periods' => array_map(static fn(array $period): array => [
|
||||
'id' => (int) $period['id_periode'],
|
||||
'label' => (string) $period['libelle'],
|
||||
'start_date' => (string) $period['date_debut'],
|
||||
'end_date' => (string) $period['date_fin'],
|
||||
'school_year' => (string) $period['annee_scolaire'],
|
||||
], $vacationPeriods),
|
||||
'summary' => [
|
||||
'entry_count' => count($targetEntries),
|
||||
'target_day_count' => count(array_unique(array_column($targetEntries, 'date'))),
|
||||
'week_count' => count($weeks),
|
||||
'skipped_count' => count($skippedEntries),
|
||||
'skipped_vacation_count' => $skippedVacationCount,
|
||||
'skipped_school_count' => $skippedSchoolCount,
|
||||
'conflict_count' => count($conflicts),
|
||||
'existing_entry_count_on_target_dates' => count($existingOnTargetDates),
|
||||
'years' => array_values(array_map('intval', array_keys($years))),
|
||||
'vacation_period_count' => count($vacationPeriods),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseStrictDate(string $value, string $label): DateTimeImmutable
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
$hasErrors = is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0);
|
||||
if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) {
|
||||
throw new DomainException(sprintf('La %s est invalide.', $label));
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function vacationForDate(string $date, array $periods): ?array
|
||||
{
|
||||
foreach ($periods as $period) {
|
||||
if ($date >= $period['date_debut'] && $date <= $period['date_fin']) {
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function existingEntriesBetween(int $agentId, string $startDate, string $endDate, bool $forUpdate = false): array
|
||||
{
|
||||
$sql = "SELECT c.id_creneau, c.id_planning, p.statut, c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND c.date_jour BETWEEN :start_date AND :end_date
|
||||
ORDER BY c.date_jour, c.heure_debut";
|
||||
if ($forUpdate) {
|
||||
$sql .= ' FOR UPDATE';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
private function findConflicts(array $targetEntries, array $existingEntries): array
|
||||
{
|
||||
$conflicts = [];
|
||||
foreach ($targetEntries as $target) {
|
||||
foreach ($existingEntries as $existing) {
|
||||
if ($target['date'] !== $existing['date_jour']) {
|
||||
continue;
|
||||
}
|
||||
if ($target['heure_debut'] < $existing['heure_fin'] && $target['heure_fin'] > $existing['heure_debut']) {
|
||||
$conflicts[] = [
|
||||
'date' => $target['date'],
|
||||
'template_start' => $target['heure_debut'],
|
||||
'template_end' => $target['heure_fin'],
|
||||
'template_structure' => $target['structure_nom'],
|
||||
'existing_entry_id' => (int) $existing['id_creneau'],
|
||||
'existing_start' => $existing['heure_debut'],
|
||||
'existing_end' => $existing['heure_fin'],
|
||||
'existing_structure' => $existing['structure_nom'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
private function ensureWeek(int $year, int $weekNumber, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"INSERT INTO semaine (annee, numero_semaine, date_debut, date_fin, statut)
|
||||
VALUES (:annee, :numero, :date_debut, :date_fin, 'OUVERTE')
|
||||
ON DUPLICATE KEY UPDATE id_semaine = LAST_INSERT_ID(id_semaine)"
|
||||
);
|
||||
$stmt->execute([
|
||||
'annee' => $year,
|
||||
'numero' => $weekNumber,
|
||||
'date_debut' => $weekStart->format('Y-m-d'),
|
||||
'date_fin' => $weekEnd->format('Y-m-d'),
|
||||
]);
|
||||
$weekId = (int) $this->pdo->lastInsertId();
|
||||
if ($weekId > 0) {
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
$find = $this->pdo->prepare('SELECT id_semaine FROM semaine WHERE annee = :annee AND numero_semaine = :numero');
|
||||
$find->execute(['annee' => $year, 'numero' => $weekNumber]);
|
||||
$weekId = (int) $find->fetchColumn();
|
||||
if ($weekId <= 0) {
|
||||
throw new \RuntimeException('Impossible de créer ou retrouver la semaine cible.');
|
||||
}
|
||||
return $weekId;
|
||||
}
|
||||
|
||||
private function assertTemplateNoOverlap(array $entries): void
|
||||
{
|
||||
for ($i = 0, $count = count($entries); $i < $count; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$first = $entries[$i];
|
||||
$second = $entries[$j];
|
||||
if ($first['date'] === $second['date']
|
||||
&& $first['heure_debut'] < $second['heure_fin']
|
||||
&& $first['heure_fin'] > $second['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'La semaine type contient un chevauchement le %s entre %s-%s (%s) et %s-%s (%s).',
|
||||
$first['date'],
|
||||
substr($first['heure_debut'], 0, 5),
|
||||
substr($first['heure_fin'], 0, 5),
|
||||
$first['structure_nom'],
|
||||
substr($second['heure_debut'], 0, 5),
|
||||
substr($second['heure_fin'], 0, 5),
|
||||
$second['structure_nom']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNoExistingOverlap(int $agentId, int $weekId, array $entries): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT c.date_jour,
|
||||
TIME_FORMAT(c.heure_debut, '%H:%i:%s') AS heure_debut,
|
||||
TIME_FORMAT(c.heure_fin, '%H:%i:%s') AS heure_fin,
|
||||
s.nom AS structure_nom
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
INNER JOIN structure s ON s.id_structure = p.id_structure
|
||||
WHERE p.id_agent = :agent_id AND p.id_semaine = :week_id
|
||||
ORDER BY c.date_jour, c.heure_debut"
|
||||
);
|
||||
$stmt->execute(['agent_id' => $agentId, 'week_id' => $weekId]);
|
||||
$existing = $stmt->fetchAll();
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
foreach ($existing as $current) {
|
||||
if ($entry['date'] === $current['date_jour']
|
||||
&& $entry['heure_debut'] < $current['heure_fin']
|
||||
&& $entry['heure_fin'] > $current['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'Import impossible en mode « compléter » : le modèle %s-%s chevauche un créneau existant %s-%s à « %s » le %s.',
|
||||
substr($entry['heure_debut'], 0, 5),
|
||||
substr($entry['heure_fin'], 0, 5),
|
||||
substr($current['heure_debut'], 0, 5),
|
||||
substr($current['heure_fin'], 0, 5),
|
||||
$current['structure_nom'],
|
||||
$entry['date']
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user