V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree ux-service-enfance : - tableau de bord Service Enfance (dashboard.php) et navigation regroupée ; - guide d'utilisation (faq.php) accessible depuis toute la navigation ; - éditeur de semaine type mis en avant sur l'écran de saisie ; - droit à l'enveloppe de formation par agent, en remplacement de la case « formation déjà prévue dans la répartition annuelle » ; - correctif de « [object Object] » sur les cartes de quota du PTA ; - motif de travail par défaut à la saisie, heures travaillées complètes dans les PDF. Le docker-compose.yml de production est conservé : celui du worktree décrit une pile de test (conteneurs et volume suffixés « test », ports 9080/9081), qui aurait fait redémarrer l'application sur un volume MariaDB vide. Les migrations database/migration_v7_9*.sql sont obligatoires ; le service db-migrate les applique au déploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,34 @@ use Throwable;
|
||||
|
||||
final class AgentModel extends BaseModel
|
||||
{
|
||||
/** Enveloppe annuelle complémentaire de formation : 14 h. */
|
||||
public const ENVELOPPE_FORMATION_MINUTES = 840;
|
||||
|
||||
/**
|
||||
* Un agent sans droit à l'enveloppe de formation ne peut pas se voir
|
||||
* affecter le motif « Formation ». Le droit vaut par défaut lorsque
|
||||
* l'information n'est pas projetée : ne jamais retirer un droit sur une
|
||||
* donnée absente.
|
||||
*/
|
||||
public static function hasTrainingRight(array $agent): bool
|
||||
{
|
||||
return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes de formation ouvertes à l'agent sur l'année : l'enveloppe de 14 h
|
||||
* s'il y a droit, rien sinon.
|
||||
*/
|
||||
public static function trainingEnvelopeMinutes(array $agent): int
|
||||
{
|
||||
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||
}
|
||||
|
||||
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.droit_enveloppe_formation, 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,
|
||||
@@ -129,7 +152,9 @@ final class AgentModel extends BaseModel
|
||||
$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;
|
||||
$trainingEnvelope = self::trainingEnvelopeMinutes([
|
||||
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
@@ -139,13 +164,13 @@ final class AgentModel extends BaseModel
|
||||
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,
|
||||
droit_enveloppe_formation, 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,
|
||||
:droit_enveloppe_formation, :commentaire_pta,
|
||||
:structure_id, TRUE
|
||||
)'
|
||||
);
|
||||
@@ -162,7 +187,7 @@ final class AgentModel extends BaseModel
|
||||
'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,
|
||||
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
|
||||
'commentaire_pta' => $data['commentaire_pta'],
|
||||
'structure_id' => $data['structure_id'],
|
||||
]);
|
||||
@@ -260,7 +285,7 @@ final class AgentModel extends BaseModel
|
||||
id_poste = :id_poste,
|
||||
type_contrat = :type_contrat,
|
||||
date_fin_contrat = :date_fin_contrat,
|
||||
formation_repartition_annuelle = :formation_repartition_annuelle,
|
||||
droit_enveloppe_formation = :droit_enveloppe_formation,
|
||||
commentaire_pta = :commentaire_pta,
|
||||
id_structure = :structure_id
|
||||
WHERE id_agent = :agent_id'
|
||||
@@ -275,7 +300,7 @@ final class AgentModel extends BaseModel
|
||||
$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(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
|
||||
$update->bindValue(':commentaire_pta', $data['commentaire_pta']);
|
||||
if ($newStructureId === null) {
|
||||
$update->bindValue(':structure_id', null, PDO::PARAM_NULL);
|
||||
|
||||
183
app/Models/DashboardModel.php
Normal file
183
app/Models/DashboardModel.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* Agrégats de pilotage du tableau de bord.
|
||||
*
|
||||
* Ce modèle ne porte AUCUNE règle métier nouvelle : il ne fait que compter et
|
||||
* additionner ce que les autres écrans affichent déjà en détail. Le calcul du
|
||||
* quota reprend exactement la logique de QuotaModel (année civile, motifs
|
||||
* décomptés uniquement, quota de l'année la plus proche à défaut de l'année
|
||||
* exacte) afin qu'un agent n'affiche jamais deux valeurs différentes selon
|
||||
* l'écran consulté.
|
||||
*/
|
||||
final class DashboardModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Suivi du quota annuel de tous les agents actifs, en deux requêtes.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function quotaTracking(int $year): array
|
||||
{
|
||||
$profiles = $this->quotaProfiles($year);
|
||||
$rows = $this->allocatedMinutes($year);
|
||||
$tracking = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$agentId = (int) $row['id_agent'];
|
||||
$profile = $profiles[$agentId] ?? [
|
||||
'quotite_travail' => 100.0,
|
||||
'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES,
|
||||
'annee' => $year,
|
||||
];
|
||||
|
||||
$target = (int) $profile['quota_cible_minutes'];
|
||||
$validated = (int) $row['minutes_valides'];
|
||||
$draft = (int) $row['minutes_brouillon'];
|
||||
$allocated = $validated + $draft;
|
||||
|
||||
$tracking[] = [
|
||||
'id_agent' => $agentId,
|
||||
'matricule' => (string) ($row['matricule'] ?? ''),
|
||||
'nom' => (string) $row['nom'],
|
||||
'prenom' => (string) $row['prenom'],
|
||||
'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null,
|
||||
'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null,
|
||||
'quotite_travail' => (float) $profile['quotite_travail'],
|
||||
'quota_cible_minutes' => $target,
|
||||
'quota_source_annee' => (int) $profile['annee'],
|
||||
'quota_estime' => !isset($profiles[$agentId]),
|
||||
'minutes_valides' => $validated,
|
||||
'minutes_brouillon' => $draft,
|
||||
'minutes_affectees' => $allocated,
|
||||
'minutes_restantes' => $target - $allocated,
|
||||
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes décomptées de l'année civile, par agent actif.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function allocatedMinutes(int $year): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT a.id_agent, a.matricule, a.nom, a.prenom,
|
||||
s.nom AS structure_nom,
|
||||
po.libelle AS poste_libelle,
|
||||
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL 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' AND m.id_motif IS NOT NULL 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 agent a
|
||||
LEFT JOIN structure s ON s.id_structure = a.id_structure
|
||||
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
|
||||
LEFT JOIN planning p ON p.id_agent = a.id_agent
|
||||
LEFT JOIN creneau_horaire c
|
||||
ON c.id_planning = p.id_planning
|
||||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||||
LEFT JOIN motif_planning m
|
||||
ON m.id_motif = c.id_motif
|
||||
AND m.compte_dans_quota = TRUE
|
||||
WHERE a.actif = TRUE
|
||||
GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle
|
||||
ORDER BY a.nom, a.prenom"
|
||||
);
|
||||
$stmt->execute([
|
||||
'date_debut' => sprintf('%04d-01-01', $year),
|
||||
'date_fin' => sprintf('%04d-12-31', $year),
|
||||
]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota retenu pour chaque agent : année exacte si elle existe, sinon
|
||||
* l'année passée la plus proche, sinon l'année future la plus proche.
|
||||
* Même ordre de préférence que QuotaModel::quotaProfile().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function quotaProfiles(int $year): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_agent, annee, quotite_travail, quota_cible_minutes
|
||||
FROM (
|
||||
SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY q.id_agent
|
||||
ORDER BY CASE
|
||||
WHEN q.annee = :year_exact THEN 0
|
||||
WHEN q.annee < :year_past THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
CASE WHEN q.annee <= :year_latest THEN q.annee END DESC,
|
||||
CASE WHEN q.annee > :year_future THEN q.annee END ASC
|
||||
) AS rang
|
||||
FROM quota_agent_annuel q
|
||||
) AS classement
|
||||
WHERE rang = 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'year_exact' => $year,
|
||||
'year_past' => $year,
|
||||
'year_latest' => $year,
|
||||
'year_future' => $year,
|
||||
]);
|
||||
|
||||
$profiles = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$profiles[(int) $row['id_agent']] = [
|
||||
'annee' => (int) $row['annee'],
|
||||
'quotite_travail' => (float) $row['quotite_travail'],
|
||||
'quota_cible_minutes' => (int) $row['quota_cible_minutes'],
|
||||
];
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de brouillons contenant au moins une affectation.
|
||||
* Requête volontairement légère : elle alimente la pastille de navigation
|
||||
* rechargée sur chaque page.
|
||||
*/
|
||||
public function pendingCount(): int
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
"SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT p.id_planning
|
||||
FROM planning p
|
||||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||||
WHERE p.statut = 'BROUILLON'
|
||||
GROUP BY p.id_planning
|
||||
) AS brouillons_servis"
|
||||
);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre d'agents actifs et de lieux actifs, pour situer les compteurs.
|
||||
*
|
||||
* @return array{agents: int, structures: int}
|
||||
*/
|
||||
public function scope(): array
|
||||
{
|
||||
return [
|
||||
'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(),
|
||||
'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
|
||||
final class MotifModel extends BaseModel
|
||||
{
|
||||
public function allActive(): array
|
||||
@@ -23,4 +25,29 @@ final class MotifModel extends BaseModel
|
||||
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Motifs réellement affectables à un agent : « Formation » disparaît de la
|
||||
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
|
||||
*/
|
||||
public function allActiveForAgent(array $agent): array
|
||||
{
|
||||
if (AgentModel::hasTrainingRight($agent)) {
|
||||
return $this->allActive();
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->allActive(),
|
||||
static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code'])
|
||||
));
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
public function activeIdsForAgent(array $agent): array
|
||||
{
|
||||
return array_map(
|
||||
static fn(array $motif): int => (int) $motif['id_motif'],
|
||||
$this->allActiveForAgent($agent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\MotifCategories;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
@@ -12,12 +13,8 @@ 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',
|
||||
];
|
||||
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
|
||||
private const WORK_CODES = MotifCategories::WORK_CODES;
|
||||
|
||||
private const PREPARATION_CODES = [
|
||||
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
|
||||
@@ -148,7 +145,7 @@ final class PtaModel extends BaseModel
|
||||
return $pta;
|
||||
}
|
||||
|
||||
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||||
$training = AgentModel::trainingEnvelopeMinutes($agent);
|
||||
$insert = $this->pdo->prepare(
|
||||
"INSERT INTO pta_annuel (
|
||||
id_agent, date_debut, date_fin, quotite_travail,
|
||||
@@ -381,7 +378,13 @@ final class PtaModel extends BaseModel
|
||||
}
|
||||
|
||||
$formationMinutes = $categories['FORMATION'];
|
||||
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
|
||||
$formationTarget = AgentModel::trainingEnvelopeMinutes($agent);
|
||||
if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) {
|
||||
$controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf(
|
||||
'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.',
|
||||
$this->formatMinutes($formationMinutes)
|
||||
));
|
||||
}
|
||||
if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
|
||||
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
|
||||
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',
|
||||
|
||||
@@ -16,6 +16,16 @@ use InvalidArgumentException;
|
||||
*/
|
||||
final class QuotaModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
|
||||
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
|
||||
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
|
||||
* les 7 h de la journée de solidarité).
|
||||
*
|
||||
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
|
||||
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
|
||||
* travail effectif alimente le compteur, via `compte_dans_quota`.
|
||||
*/
|
||||
public const REFERENCE_MINUTES = 1607 * 60;
|
||||
|
||||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||||
|
||||
@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
|
||||
$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'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
|
||||
'structure' => ['type_affectation'],
|
||||
];
|
||||
|
||||
@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertAccessViews();
|
||||
}
|
||||
|
||||
/**
|
||||
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
|
||||
* manquent, l'application doit refuser de démarrer plutôt que de tourner
|
||||
* sans contrôle de périmètre.
|
||||
*/
|
||||
private function assertAccessViews(): void
|
||||
{
|
||||
$requiredViews = [
|
||||
'v_acces_role_page',
|
||||
'v_acces_role_permission',
|
||||
'v_acces_identite_agent',
|
||||
'v_acces_identite_structure',
|
||||
'v_acces_agent_par_structure',
|
||||
'v_acces_perimetre_creneau',
|
||||
'v_acces_perimetre_planning',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT TABLE_NAME
|
||||
FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($requiredViews);
|
||||
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||
|
||||
if ($missing) {
|
||||
throw new \RuntimeException(
|
||||
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
|
||||
. implode(', ', $missing)
|
||||
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +62,175 @@ final class WeekTemplateModel extends BaseModel
|
||||
|
||||
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.');
|
||||
}
|
||||
// La colonne `nom` fait 120 caractères : la borne annoncée à
|
||||
// l'utilisateur et celle réellement appliquée doivent coïncider.
|
||||
$name = $this->normalizeName($name);
|
||||
|
||||
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
|
||||
if (!$sourceEntries) {
|
||||
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une semaine type composée directement dans l'éditeur, sans
|
||||
* partir d'une semaine réellement planifiée.
|
||||
*
|
||||
* Les plages arrivent du navigateur : elles sont intégralement revalidées
|
||||
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
|
||||
* le serveur reste seul juge de ce qui entre en base.
|
||||
*
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds motifs affectables à cet agent
|
||||
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
|
||||
*/
|
||||
public function saveFromEntries(
|
||||
int $agentId,
|
||||
string $name,
|
||||
array $rawEntries,
|
||||
array $allowedMotifIds,
|
||||
array $allowedStructureIds,
|
||||
bool $replaceExisting = false
|
||||
): array {
|
||||
$name = $this->normalizeName($name);
|
||||
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
|
||||
|
||||
if ($entries === []) {
|
||||
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
|
||||
}
|
||||
|
||||
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
|
||||
}
|
||||
|
||||
private function normalizeName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rawEntries
|
||||
* @param int[] $allowedMotifIds
|
||||
* @param int[] $allowedStructureIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
|
||||
{
|
||||
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
|
||||
$entries = [];
|
||||
|
||||
foreach ($rawEntries as $rawEntry) {
|
||||
if (!is_array($rawEntry)) {
|
||||
throw new DomainException('Une plage de la semaine type est illisible.');
|
||||
}
|
||||
|
||||
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1, 'max_range' => 5],
|
||||
]);
|
||||
if ($weekday === false) {
|
||||
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
|
||||
}
|
||||
|
||||
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
|
||||
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
|
||||
if ($end <= $start) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, la fin (%s) doit être postérieure au début (%s).',
|
||||
$jours[$weekday],
|
||||
substr($end, 0, 5),
|
||||
substr($start, 0, 5)
|
||||
));
|
||||
}
|
||||
|
||||
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
|
||||
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
|
||||
}
|
||||
|
||||
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 1],
|
||||
]);
|
||||
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le motif choisi le %s n’est pas affectable à cet agent.',
|
||||
$jours[$weekday]
|
||||
));
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'jour_semaine' => (int) $weekday,
|
||||
'id_structure' => (int) $structureId,
|
||||
'id_motif' => (int) $motifId,
|
||||
'heure_debut' => $start,
|
||||
'heure_fin' => $end,
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertEditorEntriesNoOverlap($entries, $jours);
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
|
||||
{
|
||||
$time = trim((string) $value);
|
||||
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
|
||||
$dayLabel
|
||||
));
|
||||
}
|
||||
return substr($time, 0, 5) . ':00';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
|
||||
* lieux différents : un agent n'est jamais à deux endroits à la fois.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @param array<int, string> $jours
|
||||
*/
|
||||
private function assertEditorEntriesNoOverlap(array $entries, array $jours): 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['jour_semaine'] === $second['jour_semaine']
|
||||
&& $first['heure_debut'] < $second['heure_fin']
|
||||
&& $first['heure_fin'] > $second['heure_debut']) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
|
||||
$jours[$first['jour_semaine']] ?? 'jour',
|
||||
substr($first['heure_debut'], 0, 5),
|
||||
substr($first['heure_fin'], 0, 5),
|
||||
substr($second['heure_debut'], 0, 5),
|
||||
substr($second['heure_fin'], 0, 5)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
|
||||
* une semaine réellement planifiée ou l'éditeur de semaine type.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
|
||||
{
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
@@ -106,7 +265,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
|
||||
);
|
||||
|
||||
foreach ($sourceEntries as $entry) {
|
||||
foreach ($rows as $entry) {
|
||||
$insertEntry->execute([
|
||||
'template_id' => $templateId,
|
||||
'weekday' => (int) $entry['jour_semaine'],
|
||||
@@ -121,7 +280,7 @@ final class WeekTemplateModel extends BaseModel
|
||||
return [
|
||||
'id_modele' => $templateId,
|
||||
'nom' => $name,
|
||||
'nombre_creneaux' => count($sourceEntries),
|
||||
'nombre_creneaux' => count($rows),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
|
||||
Reference in New Issue
Block a user