Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -10,13 +10,36 @@ use DateTimeZone;
/**
* Génère le document annuel de validation du quota horaire d'un agent.
* Le PDF est autonome et ne nécessite aucune dépendance Composer.
*
* Document CONTRACTUEL, destiné à être signé : il n'affiche que le volume
* annualisé dû sur la période et la clause de validation, jamais le détail des
* créneaux planifiés.
*
* Seul PDF de l'application en A4 PORTRAIT (les plannings sont en paysage) :
* c'est le format attendu d'une pièce à signer et à classer.
*
* Contrairement aux PDF de planning, les libellés portent ici leurs ACCENTS :
* la valeur juridique du document prime, et iconv est présent dans le conteneur
* fourni. À vérifier si l'application est déployée sur un PHP sans iconv.
*/
final class AgentHoursContractPdfService
{
/** A4 PORTRAIT en points PDF, contrairement aux plannings en paysage. */
private const PAGE_WIDTH = 595.28;
private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0;
/**
* Assemble le contrat horaire annuel.
*
* La période affichée est celle du quota (année civile) et non l'année de
* contrat ; le libellé est repris tel quel de QuotaModel.
*
* @param array<string, mixed> $agent
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
*
* @return string Octets du PDF.
*/
public function annualContract(array $agent, array $quota): string
{
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
@@ -39,6 +62,7 @@ final class AgentHoursContractPdfService
return $pdf->output();
}
/** Titre, période de référence, date d'édition et identité de l'agent. */
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$pdf->text(self::MARGIN, 48, 'CONTRAT HORAIRE ANNUEL', 18, true);
@@ -56,6 +80,7 @@ final class AgentHoursContractPdfService
);
}
/** Bloc d'identité : matricule, poste, contrat, lieu, quotité. */
private function drawAgentIdentity(SimplePdf $pdf, array $agent): void
{
$x = self::MARGIN;
@@ -90,6 +115,7 @@ final class AgentHoursContractPdfService
$pdf->text($rightX, $row4, 'Type du lieu : ' . $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? '')), 8.5);
}
/** Volume annualisé dû : référence, quotité appliquée, quota cible. */
private function drawQuotaSummary(SimplePdf $pdf, array $quota): void
{
$x = self::MARGIN;
@@ -127,6 +153,11 @@ final class AgentHoursContractPdfService
}
}
/**
* Clause de validation : le texte que les parties reconnaissent en signant.
* Toute modification a une portée juridique — à ne pas retoucher pour des
* raisons de mise en page.
*/
private function drawValidationClause(SimplePdf $pdf, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$x = self::MARGIN;
@@ -157,6 +188,7 @@ final class AgentHoursContractPdfService
$pdf->text($x, 658, 'Document établi le ' . $generatedAt->format('d/m/Y') . '.', 8);
}
/** Cartouches de signature, laissés vides pour un remplissage manuscrit. */
private function drawSignatures(SimplePdf $pdf): void
{
$x = self::MARGIN;
@@ -184,6 +216,7 @@ final class AgentHoursContractPdfService
$pdf->text($rightX, $y + 101, 'Signature et cachet :', 8.5);
}
/** Libellé du type de lieu d'affectation. */
private function typeLabel(string $type): string
{
return match (strtoupper($type)) {
@@ -193,12 +226,14 @@ final class AgentHoursContractPdfService
};
}
/** Quotité en pourcentage, telle qu'elle figure au contrat. */
private function percentLabel(float $rate): string
{
$formatted = rtrim(rtrim(number_format($rate, 2, ',', ' '), '0'), ',');
return $formatted . ' %';
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives. */
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';

View File

@@ -10,6 +10,13 @@ use DateTimeZone;
/**
* Génère un bilan annuel synthétique du temps d'un agent, ventilé par motif.
*
* Complément du contrat horaire : là où celui-ci fixe le volume DÛ, ce bilan
* montre le volume RÉELLEMENT planifié et sa ventilation. A4 portrait également.
*
* La ventilation s'appuie sur App\Core\MotifCategories, partagé avec l'écran et
* les autres PDF : tous les motifs de travail effectif alimentent une seule
* ligne « Heures travaillées », le détail par motif figurant en dessous.
*/
final class AgentTimeSummaryPdfService
{
@@ -17,6 +24,16 @@ final class AgentTimeSummaryPdfService
private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0;
/**
* Assemble le bilan d'heures annuel.
*
* @param array<string, mixed> $agent
* @param array<string, mixed> $quota Quota enrichi (forDateForApi).
* @param array<int, array<string, mixed>> $rows Cumuls par motif issus de
* AgentModel::timeSummaryForRange().
*
* @return string Octets du PDF.
*/
public function annualSummary(array $agent, array $quota, array $rows): string
{
$pdf = new SimplePdf(self::PAGE_WIDTH, self::PAGE_HEIGHT);
@@ -33,6 +50,7 @@ final class AgentTimeSummaryPdfService
return $pdf->output();
}
/** Titre, période couverte et date d'édition. */
private function drawHeader(SimplePdf $pdf, array $agent, string $periodLabel, DateTimeImmutable $generatedAt): void
{
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
@@ -42,6 +60,7 @@ final class AgentTimeSummaryPdfService
$pdf->line(self::MARGIN, 94, self::PAGE_WIDTH - self::MARGIN, 94, 0.72);
}
/** Bloc d'identité de l'agent. */
private function drawIdentity(SimplePdf $pdf, array $agent): void
{
$x = self::MARGIN;
@@ -58,6 +77,10 @@ final class AgentTimeSummaryPdfService
$pdf->text($x + 270, $y + 73, 'Lieu principal : ' . (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigné'), 9.2);
}
/**
* Tableau de ventilation : une ligne par catégorie, chacune avec sa couleur,
* puis le détail des motifs de travail sous la ligne « Heures travaillées ».
*/
private function drawBreakdown(SimplePdf $pdf, array $totals): void
{
$x = self::MARGIN;
@@ -109,6 +132,14 @@ final class AgentTimeSummaryPdfService
* @param array<string, int> $detail
* @return string[]
*/
/**
* Met en forme le détail des motifs de travail pour tenir dans la largeur
* disponible.
*
* @param array<string, int> $detail Minutes indexées par libellé de motif.
*
* @return string[]
*/
private function detailLines(SimplePdf $pdf, array $detail, float $width): array
{
arsort($detail);
@@ -143,6 +174,10 @@ final class AgentTimeSummaryPdfService
return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7);
}
/**
* Bloc de conclusion : quota cible, temps décompté, et écart.
* C'est la seule partie du document qui confronte le réalisé à l'attendu.
*/
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
{
// Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel :
@@ -182,6 +217,7 @@ final class AgentTimeSummaryPdfService
}
}
/** Pied de page : origine du document, matricule, horodatage. */
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
{
$pdf->text(
@@ -197,6 +233,15 @@ final class AgentTimeSummaryPdfService
* (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire,
* préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas
* seulement le motif générique TRAVAIL.
*
* Le total « DECOMPTE » est calculé indépendamment des catégories, sur le
* seul drapeau `compte_dans_quota` : c'est lui qui se compare au quota
* cible, et il ne se déduit pas de la somme des catégories.
*
* @param array<int, array<string, mixed>> $rows
*
* @return array<string, mixed> Une clé par catégorie, plus « DECOMPTE » et
* « DETAIL_TRAVAIL » (minutes par libellé de motif).
*/
private function normalizeTotals(array $rows): array
{
@@ -230,7 +275,11 @@ final class AgentTimeSummaryPdfService
return $totals;
}
/** @return array{accent:string} */
/**
* Couleur d'accent d'une catégorie, alignée sur celle des PDF de planning.
*
* @return array{accent:string}
*/
private function palette(string $code): array
{
return match (strtoupper($code)) {
@@ -242,6 +291,7 @@ final class AgentTimeSummaryPdfService
};
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives (écart de quota). */
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';

View File

@@ -7,13 +7,35 @@ namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable;
/**
* Mise en page des plannings au format PDF, par agent ou par lieu.
*
* Deux documents, tous deux en A4 PAYSAGE (SimplePdf par défaut) :
* - agentWeeks() : un agent sur 1 à 7 semaines, en blocs compacts ;
* - structureWeek() : un lieu sur une semaine, en tableau agents × jours, avec
* saut de page géré à la main.
*
* CONTRAINTE TRANSVERSE — les libellés sont écrits SANS ACCENT dans tout ce
* fichier (« Document genere », « Periscolaire »). Ce n'est pas une négligence :
* SimplePdf convertit en Windows-1252 avec translittération, et cette écriture
* garantit un rendu identique même quand iconv n'est pas disponible. Les données
* venant de la base, elles, gardent leurs accents.
*
* Toutes les coordonnées sont en POINTS PDF, origine en HAUT à gauche (voir
* SimplePdf). Les valeurs numériques éparpillées dans les méthodes de dessin
* sont des positions absolues ajustées visuellement : les modifier suppose de
* regénérer un document pour vérifier.
*/
final class PlanningPdfService
{
/** Marge extérieure commune à tous les documents. */
private const MARGIN = 28.0;
private const WEEK_DAYS = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
/**
* Compatibilité avec l'ancien export d'une seule semaine.
* Conservée pour d'éventuels appels externes ; le code actuel passe
* directement par agentWeeks().
*/
public function agentWeek(array $agent, array $entries, array $week, array $quota): string
{
@@ -23,6 +45,24 @@ final class PlanningPdfService
/**
* Génère un planning compact de 1 à 7 semaines. Avec 7 semaines, le rendu
* tient volontairement sur deux pages A4 paysage maximum (4 + 3 semaines).
*
* Le découpage en pages est fait par array_chunk(..., 4) : QUATRE SEMAINES
* PAR PAGE, chaque bloc occupant 112 points de haut à partir de y = 96. Ce
* plafond de quatre est une contrainte physique, SimplePdf ne sachant pas
* reporter un tableau sur la page suivante. Passer à cinq exigerait de
* réduire la hauteur des blocs.
*
* Au-delà de sept semaines, la liste est silencieusement TRONQUÉE : le
* contrôleur n'accepte de toute façon que 6 ou 7.
*
* @param array<string, mixed> $agent
* @param array<int, array<string, mixed>> $entries Créneaux de toute la période.
* @param array<int, array<string, mixed>> $weeks Semaines à rendre.
* @param array<string, mixed> $quota
*
* @return string Octets du PDF.
*
* @throws \InvalidArgumentException Si aucune semaine n'est fournie.
*/
public function agentWeeks(array $agent, array $entries, array $weeks, array $quota): string
{
@@ -60,6 +100,24 @@ final class PlanningPdfService
return $pdf->output();
}
/**
* Planning d'un lieu sur une semaine : un tableau agents × cinq jours.
*
* Contrairement à agentWeeks(), la hauteur des lignes n'est PAS connue à
* l'avance — elle dépend du nombre de créneaux de chaque agent et du
* découpage des libellés. La méthode mesure donc chaque ligne avant de la
* tracer, et déclenche une nouvelle page dès que $bottomLimit serait
* dépassé. La closure $drawHeader existe pour cette raison : l'en-tête du
* tableau doit être redessiné en tête de CHAQUE page, sans quoi les colonnes
* ne seraient plus identifiables.
*
* @param array<string, mixed> $structure
* @param array<int, array<string, mixed>> $agents Chaque agent porte ses
* propres clés `entries` et `quota`.
* @param array<string, mixed> $week
*
* @return string Octets du PDF.
*/
public function structureWeek(array $structure, array $agents, array $week): string
{
$pdf = new SimplePdf();
@@ -201,6 +259,10 @@ final class PlanningPdfService
return $pdf->output();
}
/**
* En-tête du planning agent : identité, lieu principal, période couverte,
* quota restant, et pagination si le document tient sur plusieurs pages.
*/
private function drawAgentMultiWeekHeader(
SimplePdf $pdf,
array $agent,
@@ -228,7 +290,19 @@ final class PlanningPdfService
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
}
/** @param array<string, array<int, array>> $entriesByDate */
/**
* Dessine UN bloc de semaine : bandeau de titre, en-têtes de jours, puis les
* créneaux de chaque journée dans une colonne.
*
* La hauteur du corps est FIXE (77 points) pour que les blocs s'empilent à
* pas régulier. Les créneaux qui n'y tiennent pas ne sont pas rendus — le
* compteur $rendered plus bas s'en charge. Une journée très chargée peut
* donc apparaître tronquée : c'est le compromis retenu pour garantir sept
* semaines sur deux pages.
*
* @param array<string, array<int, array>> $entriesByDate Créneaux indexés par date.
* @param float $y Ordonnée du haut du bloc.
*/
private function drawCompactWeek(SimplePdf $pdf, array $week, array $entriesByDate, float $y): void
{
$weekDays = $this->weekDays($week);
@@ -287,7 +361,13 @@ final class PlanningPdfService
}
}
/** @return string[] */
/**
* Lignes d'un créneau dans la vue agent : horaires, motif si informatif,
* lieu, et la mention « Brouillon » quand le planning n'est pas validé —
* un document imprimé doit dire s'il est définitif.
*
* @return string[]
*/
private function compactAgentEntryLines(SimplePdf $pdf, array $entry, float $width): array
{
$lines = [sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin'])];
@@ -310,7 +390,12 @@ final class PlanningPdfService
return $lines;
}
/** @return string[] */
/**
* Lignes d'un créneau dans la vue lieu : plus condensées que dans la vue
* agent, le lieu étant déjà celui du document.
*
* @return string[]
*/
private function structureEntryLines(SimplePdf $pdf, array $entry, float $width): array
{
$first = sprintf('%s-%s', $entry['heure_debut'], $entry['heure_fin']);
@@ -333,7 +418,11 @@ final class PlanningPdfService
: (string) ($entry['motif_libelle'] ?? 'Affectation');
}
/** @return array{accent:string,background:string,label:string} */
/**
* Palette d'un créneau, déduite de son motif.
*
* @return array{accent:string,background:string,label:string}
*/
private function entryPalette(array $entry): array
{
return $this->motifPalette(
@@ -342,7 +431,15 @@ final class PlanningPdfService
);
}
/** @return array{accent:string,background:string,label:string} */
/**
* Couleurs par CATÉGORIE de motif — travail en bleu, formation en violet,
* congé en vert, absence en rouge, reste en gris.
*
* Le classement passe par App\Core\MotifCategories, partagé avec l'écran et
* les autres PDF : une même catégorie garde partout la même couleur.
*
* @return array{accent:string,background:string,label:string}
*/
private function motifPalette(string $code, bool $countsInQuota = false): array
{
return match (MotifCategories::category($code, $countsInQuota)) {
@@ -354,6 +451,12 @@ final class PlanningPdfService
};
}
/**
* Légende des couleurs, en une ligne au pied du document.
*
* Les entrées sont espacées selon la LARGEUR ESTIMÉE de leur libellé et non
* à pas fixe : la première est trop longue pour une colonne régulière.
*/
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{
$items = [
@@ -374,7 +477,15 @@ final class PlanningPdfService
}
}
/** @return array<string, array<int, array>> */
/**
* Indexe les créneaux par date et les trie par heure de début.
*
* Le tri est refait ici bien que le SQL trie déjà : les créneaux peuvent
* avoir été fusionnés depuis plusieurs sources (plusieurs lieux, plusieurs
* plannings) avant d'arriver.
*
* @return array<string, array<int, array>>
*/
private function entriesByDate(array $entries): array
{
$grouped = [];
@@ -388,6 +499,7 @@ final class PlanningPdfService
return $grouped;
}
/** En-tête commun du planning par lieu : titre, sous-titre, semaine, filet. */
private function drawDocumentHeader(SimplePdf $pdf, string $title, string $subtitle, string $weekLabel): void
{
$pdf->text(self::MARGIN, 34, 'PTA', 10, true);
@@ -397,7 +509,12 @@ final class PlanningPdfService
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
}
/** @return array<int, array{date:string,label:string,display:string}> */
/**
* Les cinq jours ouvrés d'une semaine, à partir de sa date de début.
* S'arrête au vendredi, le week-end n'étant pas planifiable.
*
* @return array<int, array{date:string,label:string,display:string}>
*/
private function weekDays(array $week): array
{
$start = new DateTimeImmutable((string) $week['date_debut']);
@@ -413,6 +530,7 @@ final class PlanningPdfService
return $days;
}
/** « Semaine du 16/02/2026 au 20/02/2026 » — bornée au vendredi. */
private function weekLabel(array $week): string
{
$start = new DateTimeImmutable((string) $week['date_debut']);
@@ -420,6 +538,7 @@ final class PlanningPdfService
return sprintf('Semaine du %s au %s', $start->format('d/m/Y'), $end->format('d/m/Y'));
}
/** Abrège le nom du jour pour les colonnes étroites : « Lundi » → « Lun. ». */
private function shortDayLabel(string $label): string
{
return match ($label) {
@@ -432,6 +551,7 @@ final class PlanningPdfService
};
}
/** Libellé du type de lieu, sans accent (contrainte d'encodage du PDF). */
private function typeLabel(string $type): string
{
return match (strtoupper($type)) {

View File

@@ -8,8 +8,26 @@ use DateTimeImmutable;
use RuntimeException;
use Throwable;
/**
* Import des vacances scolaires depuis l'API publique data.education.gouv.fr.
*
* Ce service ne fait que PROPOSER : il ne touche jamais la base. L'écriture
* relève de VacationModel, après validation manuelle par un utilisateur. Ce
* découpage est délibéré — les libellés et découpages publiés varient d'une
* académie et d'une année à l'autre, et rien de tout cela n'entre en base sans
* relecture humaine.
*
* TOLÉRANCE AUX PANNES, principe directeur de tout le fichier. Le portail public
* change de version d'API, de graphie d'académie et de format de réponse sans
* préavis. Chaque étape est donc doublée d'un repli, et un échec partiel produit
* un AVERTISSEMENT plutôt qu'une erreur : mieux vaut proposer les dates
* disponibles que rien du tout.
*
* Nécessite un accès réseau SORTANT depuis le conteneur php.
*/
final class SchoolHolidayApiService
{
/** Jeu de données public du calendrier scolaire. */
private const DATASET = 'fr-en-calendrier-scolaire';
/**
@@ -24,35 +42,24 @@ final class SchoolHolidayApiService
private const LEGACY_ENDPOINT = 'https://data.education.gouv.fr/api/records/1.0/search/';
/**
* Zone de vacances (A, B ou C) de chaque académie métropolitaine.
*
* Table locale servant de REPLI quand l'API ne publie pas de ligne propre à
* l'académie mais seulement une ligne commune à sa zone. À mettre à jour si
* le découpage officiel évolue — il a déjà changé par le passé, et les
* académies d'outre-mer n'y figurent pas.
*/
private const ACADEMY_ZONES = [
'aix-marseille' => 'Zone B',
'amiens' => 'Zone B',
'besancon' => 'Zone A',
'bordeaux' => 'Zone A',
'clermont-ferrand' => 'Zone A',
'creteil' => 'Zone C',
'dijon' => 'Zone A',
'grenoble' => 'Zone A',
'lille' => 'Zone B',
'limoges' => 'Zone A',
'lyon' => 'Zone A',
'montpellier' => 'Zone C',
'nancy-metz' => 'Zone B',
'nantes' => 'Zone B',
'nice' => 'Zone B',
'normandie' => 'Zone B',
'orleans-tours' => 'Zone B',
'paris' => 'Zone C',
'poitiers' => 'Zone A',
'reims' => 'Zone B',
'rennes' => 'Zone B',
'strasbourg' => 'Zone B',
'toulouse' => 'Zone C',
'versailles' => 'Zone C',
'dijon' => 'Zone A'
];
/**
* Compatibilité avec le code existant : retourne uniquement les périodes.
* Compatibilité avec le code existant : retourne uniquement les périodes,
* sans les avertissements. Le code actuel appelle plutôt la variante
* détaillée, qui permet d'afficher ce qui n'a pas pu être chargé.
*
* @return array<int, array<string, mixed>>
*/
public function previewCalendarYear(string $academy, int $calendarYear): array
{
@@ -65,7 +72,19 @@ final class SchoolHolidayApiService
* scolaires. L'absence de publication de l'une d'elles ne doit donc plus
* empêcher l'affichage des dates déjà disponibles.
*
* Une année civile couvre la FIN d'une année scolaire et le DÉBUT de la
* suivante : les deux sont donc interrogées, puis les périodes fusionnées et
* dédoublonnées sur (libellé, début, fin). Ne sont conservées que celles qui
* INTERSECTENT l'année civile demandée.
*
* L'exception n'est levée que si AUCUNE des deux requêtes n'a abouti : un
* seul succès suffit à rendre un résultat exploitable, l'échec de l'autre
* partant en avertissement.
*
* @return array{periods: array, warnings: array, school_years: array}
*
* @throws RuntimeException Année ou académie invalide, ou API totalement
* injoignable.
*/
public function previewCalendarYearDetailed(string $academy, int $calendarYear): array
{
@@ -135,13 +154,28 @@ final class SchoolHolidayApiService
}
/**
* Compatibilité avec l'ancienne API interne V5.17.
* Compatibilité avec l'ancienne API interne V5.17, qui raisonnait en année
* scolaire et non en année civile.
*
* @return array<int, array<string, mixed>>
*/
public function preview(string $academy, string $schoolYear): array
{
return $this->previewSchoolYear($academy, $schoolYear);
}
/**
* Interroge l'API pour UNE année scolaire, avec trois stratégies en cascade.
*
* On s'arrête au PREMIER résultat non vide ; les erreurs intermédiaires sont
* accumulées et seule la dernière est remontée si tout échoue. Un résultat
* vide sans erreur n'est pas une anomalie : l'académie peut simplement ne
* rien avoir publié.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si toutes les stratégies ont échoué en erreur.
*/
private function previewSchoolYear(string $academy, string $schoolYear): array
{
$errors = [];
@@ -212,6 +246,16 @@ final class SchoolHolidayApiService
return [];
}
/**
* Interroge l'API Explore, en essayant successivement chaque version
* d'endpoint jusqu'à obtenir une réponse exploitable.
*
* @param array<string, mixed> $parameters Paramètres de requête Explore.
*
* @return array<int, mixed> Contenu brut de la clé `results`.
*
* @throws RuntimeException Si aucun endpoint n'a répondu correctement.
*/
private function fetchExploreRecords(array $parameters): array
{
$query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
@@ -233,6 +277,17 @@ final class SchoolHolidayApiService
throw new RuntimeException($lastError?->getMessage() ?? 'API Explore indisponible.');
}
/**
* Dernier repli : l'ancienne API Opendatasoft 1.0.
*
* Son format diffère — les champs utiles sont imbriqués sous `fields` — et
* sont remis à plat ici pour que normalize() traite les deux sources de la
* même façon.
*
* @return array<int, array<string, mixed>>
*
* @throws RuntimeException Si la réponse n'a pas la forme attendue.
*/
private function fetchLegacyRecords(string $academy, string $schoolYear): array
{
$query = http_build_query([
@@ -257,6 +312,30 @@ final class SchoolHolidayApiService
)));
}
/**
* Convertit les enregistrements bruts de l'API en périodes exploitables.
*
* Trois filtres successifs :
* 1. la POPULATION doit concerner les élèves — un enregistrement réservé
* aux enseignants ne fait pas de vacances pour l'accueil de loisirs ;
* 2. hors repli par zone, l'ACADÉMIE doit correspondre à celle demandée,
* comparée après normalisation (accents et tirets neutralisés) ;
* 3. la DESCRIPTION doit ressembler à des vacances (voir
* isVacationDescription()), afin d'écarter rentrées et pré-rentrées.
*
* CONVERSION DE DATE ESSENTIELLE : l'API publie la DATE DE REPRISE des
* cours, alors que PTA raisonne en bornes INCLUSIVES. Le dernier jour de
* vacances est donc la veille de la reprise. Sans cette correction, chaque
* période gagnerait un jour et le premier jour de classe serait qualifié
* d'extrascolaire.
*
* @param array<int, mixed> $records
* @param bool $zoneFallback true quand les données viennent du repli par
* zone : le filtre sur l'académie est alors levé, et l'académie
* demandée est conservée dans le résultat.
*
* @return array<int, array<string, mixed>> Triées par date de début.
*/
private function normalize(array $records, string $academy, string $schoolYear, bool $zoneFallback): array
{
$periods = [];
@@ -319,6 +398,18 @@ final class SchoolHolidayApiService
return $periods;
}
/**
* Reconnaît une période de vacances à partir de sa description.
*
* Filtrage par MOTS-CLÉS faute de champ de type dans le jeu de données. Les
* rentrées et pré-rentrées sont exclues explicitement : ce sont des
* événements ponctuels, pas des périodes. « été austral » et « hiver
* austral » couvrent les académies d'outre-mer.
*
* Une description non reconnue est SILENCIEUSEMENT ignorée : un libellé
* inédit peut donc faire disparaître une période, à surveiller si des
* vacances manquent à l'appel.
*/
private function isVacationDescription(string $description): bool
{
$normalized = $this->normalizeText($description);
@@ -337,6 +428,17 @@ final class SchoolHolidayApiService
return false;
}
/**
* Graphies alternatives d'un nom d'académie à essayer successivement.
*
* Le portail n'est pas constant : « Nancy-Metz », « Nancy Metz »,
* « Orléans-Tours » ou « Orleans-Tours » selon les enregistrements, avec
* parfois un tiret cadratin plutôt qu'un trait d'union. On essaie donc
* l'original, les tirets uniformisés, la version sans accents, et les
* variantes à espaces.
*
* @return array<int, string> Sans doublon, dans l'ordre d'essai.
*/
private function academyVariants(string $academy): array
{
$academy = trim(preg_replace('/\s+/u', ' ', $academy) ?? $academy);
@@ -349,12 +451,35 @@ final class SchoolHolidayApiService
return array_values(array_unique(array_filter(array_map('trim', $variants))));
}
/**
* Zone de vacances d'une académie, ou null si elle est inconnue de la table
* (outre-mer, ou nom mal orthographié).
*/
private function zoneForAcademy(string $academy): ?string
{
$key = $this->normalizeAcademy($academy);
return self::ACADEMY_ZONES[str_replace(' ', '-', $key)] ?? null;
}
/**
* Effectue la requête HTTP et décode le JSON.
*
* Deux implémentations : cURL quand l'extension est présente, sinon
* file_get_contents() avec un contexte de flux. Ce second chemin ne
* fonctionne que si `allow_url_fopen` est actif, d'où le message explicite
* en cas d'échec — c'est la panne la plus fréquente sur un hébergement
* mutualisé.
*
* Délais volontairement courts (8 s de connexion, 20 s au total) : l'appel
* est SYNCHRONE dans une requête web, l'utilisateur attend devant son écran.
*
* `ignore_errors` et CURLOPT_RETURNTRANSFER permettent de lire le corps même
* sur un code d'erreur, mais tout code >= 400 est traité comme un échec.
*
* @return array<mixed> Charge utile décodée.
*
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
*/
private function fetchJson(string $url): array
{
$body = false;
@@ -412,11 +537,23 @@ final class SchoolHolidayApiService
return $data;
}
/**
* Échappe une valeur pour la clause `where` de l'API Explore, dont la
* syntaxe est proche du SQL : les guillemets et les barres obliques
* inverses doivent être protégés pour ne pas altérer la requête distante.
*/
private function quoteWhereValue(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
/**
* Extrait la partie date d'un horodatage ISO.
* L'API renvoie tantôt « 2026-02-21 », tantôt
* « 2026-02-21T23:00:00+00:00 » ; seule la date nous intéresse.
*
* @return string|null null si aucune date n'est reconnaissable.
*/
private function dateOnly(string $value): ?string
{
if (!preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
@@ -425,6 +562,11 @@ final class SchoolHolidayApiService
return $matches[1];
}
/**
* Ramène une valeur d'API à une chaîne.
* Certains champs (zones, population) sont tantôt scalaires, tantôt des
* tableaux selon la version de l'API ; les tableaux sont aplatis.
*/
private function stringValue(mixed $value): string
{
if (is_array($value)) {
@@ -434,6 +576,13 @@ final class SchoolHolidayApiService
}
/**
* Vrai si l'enregistrement concerne les élèves.
*
* Acceptent : la valeur vide, « - », « Élèves », « Tous », « Tout public ».
* Les vacances communes sont en effet publiées avec une population « - » ;
* ne retenir que « Élèves » les faisait disparaître du résultat.
*/
private function isStudentPopulation(mixed $value): bool
{
$values = is_array($value) ? $value : [$value];
@@ -446,12 +595,21 @@ final class SchoolHolidayApiService
return false;
}
/**
* Forme canonique d'un nom d'académie pour comparaison : tirets et
* soulignés deviennent des espaces, accents et casse neutralisés.
* « Nancy-Metz », « nancy metz » et « NANCY_METZ » se rejoignent ainsi.
*/
private function normalizeAcademy(string $value): string
{
$value = str_replace(['–', '—', '‑', '_', '-'], ' ', $value);
return $this->normalizeText($value);
}
/**
* Forme canonique d'un texte : sans accent, en minuscules, réduit aux
* lettres, chiffres et tirets, espaces compactés.
*/
private function normalizeText(string $value): string
{
$value = $this->stripAccents($value);
@@ -462,6 +620,14 @@ final class SchoolHolidayApiService
return trim($value);
}
/**
* Retire les accents par une table de correspondance explicite.
*
* Table écrite à la main plutôt qu'iconv ou intl : ces extensions ne sont
* pas garanties partout, et leur comportement de translittération varie
* selon la locale du système. Une table donne un résultat identique
* quel que soit l'hébergement.
*/
private function stripAccents(string $value): string
{
return strtr($value, [

View File

@@ -8,9 +8,27 @@ namespace App\Services;
* Petit générateur PDF autonome, volontairement limité aux besoins des plannings.
* Il utilise les polices standard PDF Helvetica / Helvetica-Bold et ne requiert
* aucune dépendance Composer.
*
* SYSTÈME DE COORDONNÉES — le point le plus important à comprendre ici. Le
* format PDF place l'origine EN BAS À GAUCHE, l'axe Y montant. Cette classe
* expose au contraire une origine EN HAUT À GAUCHE, Y descendant, comme en CSS :
* c'est ainsi qu'on raisonne pour composer une page. La conversion
* (`height - y`) est faite dans CHAQUE méthode de dessin. Toute nouvelle
* primitive doit l'appliquer aussi, sinon elle dessinera à l'envers.
*
* Ce que cette classe ne sait PAS faire, et qui contraint les appelants :
* - aucune image, aucune police embarquée (Helvetica et Helvetica-Bold seules) ;
* - aucun retour à la ligne automatique : voir wrap(), qui ESTIME la largeur ;
* - aucun saut de page automatique : l'appelant appelle addPage() lui-même et
* doit surveiller ses hauteurs. C'est pourquoi le PDF de planning agent est
* plafonné à 6 ou 7 semaines.
*
* Le texte est converti en Windows-1252 (voir escapeText()) : les caractères
* hors de ce jeu, émoji compris, sont translittérés ou remplacés.
*/
final class SimplePdf
{
/** Dimensions A4 paysage en POINTS PDF (1 pt = 1/72 pouce). */
public const A4_LANDSCAPE_WIDTH = 841.89;
public const A4_LANDSCAPE_HEIGHT = 595.28;
@@ -35,6 +53,12 @@ final class SimplePdf
return $this->height;
}
/**
* Ouvre une nouvelle page.
*
* La page en cours est empilée si elle contient quelque chose. Appeler
* addPage() deux fois de suite ne produit donc pas de page blanche.
*/
public function addPage(): void
{
if ($this->currentPage !== '') {
@@ -43,6 +67,13 @@ final class SimplePdf
$this->currentPage = '';
}
/**
* Écrit du texte en noir. ($x, $y) est le point de BASE (« baseline ») du
* premier caractère, mesuré depuis le HAUT de la page.
*
* @param float $size Corps en points.
* @param bool $bold true pour Helvetica-Bold.
*/
public function text(float $x, float $y, string $text, float $size = 10, bool $bold = false): void
{
$font = $bold ? 'F2' : 'F1';
@@ -58,6 +89,11 @@ final class SimplePdf
);
}
/**
* Trace un trait.
*
* @param float $gray Niveau de gris, 0 = noir, 1 = blanc.
*/
public function line(float $x1, float $y1, float $x2, float $y2, float $gray = 0.75): void
{
$this->currentPage .= sprintf(
@@ -70,6 +106,10 @@ final class SimplePdf
);
}
/**
* Trace le CONTOUR d'un rectangle. ($x, $y) est son coin supérieur gauche ;
* la conversion vers le coin inférieur gauche attendu par PDF est faite ici.
*/
public function rect(float $x, float $y, float $w, float $h, float $strokeGray = 0.75): void
{
$this->currentPage .= sprintf(
@@ -82,6 +122,7 @@ final class SimplePdf
);
}
/** Rectangle PLEIN en niveau de gris (fonds de cellules, bandeaux). */
public function fillRect(float $x, float $y, float $w, float $h, float $gray = 0.95): void
{
$this->currentPage .= sprintf(
@@ -94,6 +135,12 @@ final class SimplePdf
);
}
/**
* Rectangle plein en couleur.
*
* @param string $hex Couleur « #RRGGBB » ou « #RGB » ; une valeur invalide
* retombe sur le noir plutôt que d'échouer.
*/
public function fillRectColor(float $x, float $y, float $w, float $h, string $hex): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
@@ -109,6 +156,7 @@ final class SimplePdf
);
}
/** Contour de rectangle en couleur, d'épaisseur réglable. */
public function rectColor(float $x, float $y, float $w, float $h, string $hex, float $lineWidth = 1.0): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
@@ -125,6 +173,7 @@ final class SimplePdf
);
}
/** Texte en couleur. Mêmes conventions que text(). */
public function textColor(float $x, float $y, string $text, string $hex, float $size = 10, bool $bold = false): void
{
[$r, $g, $b] = $this->hexToRgb($hex);
@@ -145,7 +194,19 @@ final class SimplePdf
}
/**
* @return string[]
* Découpe un texte en lignes tenant dans une largeur donnée.
*
* APPROXIMATION ASSUMÉE : la largeur réelle d'un caractère Helvetica varie
* (un « i » et un « W » n'occupent pas la même place). Mesurer exactement
* exigerait d'embarquer les métriques AFM de la police, ce qui va à
* l'encontre du parti pris « aucune dépendance ». On retient donc une
* moyenne de 0,52 em par caractère, volontairement CONSERVATRICE : le texte
* peut être coupé un peu tôt, mais ne déborde jamais de sa cellule.
*
* Un mot plus long que la largeur disponible est coupé de force, sans quoi
* il déborderait à coup sûr.
*
* @return string[] Toujours au moins une ligne, éventuellement vide.
*/
public function wrap(string $text, float $maxWidth, float $fontSize = 9): array
{
@@ -186,6 +247,21 @@ final class SimplePdf
return $lines ?: [''];
}
/**
* Assemble le document et retourne les octets du PDF.
*
* Structure produite, conforme à un PDF 1.4 minimal :
* objet 1 = catalogue, 2 = arbre des pages, 3/4 = les deux polices,
* puis, pour chaque page, un objet /Page et un objet de contenu.
*
* Vient ensuite la table de références croisées (« xref »), qui donne le
* DÉCALAGE EN OCTETS de chaque objet depuis le début du fichier — d'où les
* strlen() successifs. Le tout se termine par le trailer et startxref, qui
* indique où trouver cette table.
*
* À ne appeler qu'UNE FOIS : la méthode consomme la page courante en la
* poussant dans la pile. Un second appel ajouterait une page vide.
*/
public function output(): string
{
if ($this->currentPage !== '' || $this->pages === []) {
@@ -242,11 +318,17 @@ final class SimplePdf
return $pdf;
}
/**
* Longueur en CARACTÈRES, avec repli sur les octets si mbstring est absent.
* Le repli surestime la longueur d'un texte accentué, ce qui ne fait que
* couper un peu plus tôt — jamais déborder.
*/
private function stringLength(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
}
/** Extraction sûre en UTF-8, avec repli sur substr() sans mbstring. */
private function stringSlice(string $value, int $start, ?int $length = null): string
{
if (function_exists('mb_substr')) {
@@ -255,7 +337,13 @@ final class SimplePdf
return $length === null ? substr($value, $start) : substr($value, $start, $length);
}
/** @return array{0:float,1:float,2:float} */
/**
* Convertit « #RRGGBB » ou « #RGB » en composantes RVB de 0 à 1, échelle
* attendue par les opérateurs PDF. Une valeur invalide donne du noir plutôt
* qu'une erreur : un PDF illisible serait pire qu'une couleur fausse.
*
* @return array{0:float,1:float,2:float}
*/
private function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
@@ -273,6 +361,20 @@ final class SimplePdf
];
}
/**
* Prépare une chaîne pour l'insertion dans un flux PDF.
*
* Deux opérations distinctes :
* 1. CONVERSION en Windows-1252, encodage déclaré des polices. //TRANSLIT
* approche les caractères absents (« œ » → « oe »), //IGNORE écarte le
* reste. Sans iconv, tout ce qui n'est pas ASCII imprimable devient « ? ».
* 2. ÉCHAPPEMENT des caractères qui ont un sens dans la syntaxe PDF : la
* barre oblique inverse et les parenthèses, qui délimitent les chaînes.
* Un texte contenant une parenthèse non échappée corromprait le document.
*
* Les retours à la ligne sont supprimés ou remplacés par un espace : le
* découpage en lignes relève de wrap(), pas du contenu.
*/
private function escapeText(string $text): string
{
$converted = function_exists('iconv')