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:
PTA
2026-08-17 16:53:04 +02:00
parent 57bd8da5c8
commit 4201894e28
69 changed files with 5228 additions and 246 deletions

View File

@@ -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);

View 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(),
];
}
}

View File

@@ -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)
);
}
}

View File

@@ -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.',

View File

@@ -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

View File

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

View File

@@ -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()) {