Files
pta/app/Services/PlanningPdfService.php
2026-08-20 13:22:49 +02:00

564 lines
23 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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
{
return $this->agentWeeks($agent, $entries, [$week], $quota);
}
/**
* 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
{
$weeks = array_values(array_slice($weeks, 0, 7));
if ($weeks === []) {
throw new \InvalidArgumentException('Au moins une semaine est nécessaire pour générer le planning.');
}
$pdf = new SimplePdf();
$entriesByDate = $this->entriesByDate($entries);
$chunks = array_chunk($weeks, 4);
foreach ($chunks as $pageIndex => $pageWeeks) {
if ($pageIndex > 0) {
$pdf->addPage();
}
$this->drawAgentMultiWeekHeader($pdf, $agent, $weeks, $quota, $pageIndex + 1, count($chunks));
$sectionY = 96.0;
foreach ($pageWeeks as $week) {
$this->drawCompactWeek($pdf, $week, $entriesByDate, $sectionY);
$sectionY += 112.0;
}
$this->drawLegend($pdf, self::MARGIN, 563);
$pdf->text(
self::MARGIN,
584,
sprintf('Document genere depuis PTA - %d semaine%s affichee%s.', count($weeks), count($weeks) > 1 ? 's' : '', count($weeks) > 1 ? 's' : ''),
7
);
}
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();
$this->drawDocumentHeader(
$pdf,
'Planning hebdomadaire du lieu d\'affectation',
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
$this->weekLabel($week)
);
$weekDays = $this->weekDays($week);
$margin = self::MARGIN;
$tableY = 100.0;
$agentWidth = 126.0;
$quotaWidth = 84.0;
$usableWidth = $pdf->width() - ($margin * 2);
$dayWidth = ($usableWidth - $agentWidth - $quotaWidth) / 5;
$headerHeight = 38.0;
$bottomLimit = 548.0;
$drawHeader = function () use ($pdf, $weekDays, $margin, $tableY, $agentWidth, $quotaWidth, $dayWidth, $headerHeight): void {
$x = $margin;
$headers = [['Agent', $agentWidth]];
foreach ($weekDays as $day) {
$headers[] = [$day['label'] . ' ' . $day['display'], $dayWidth];
}
$headers[] = ['Quota restant', $quotaWidth];
foreach ($headers as [$label, $width]) {
$pdf->fillRect($x, $tableY, $width, $headerHeight, 0.92);
$pdf->rect($x, $tableY, $width, $headerHeight, 0.72);
$lines = $pdf->wrap((string) $label, $width - 10, 8);
$lineY = $tableY + 15;
foreach (array_slice($lines, 0, 2) as $line) {
$pdf->text($x + 5, $lineY, $line, 8, true);
$lineY += 10;
}
$x += $width;
}
};
$drawHeader();
$y = $tableY + $headerHeight;
foreach ($agents as $agent) {
$entriesByDate = $this->entriesByDate($agent['entries'] ?? []);
$dayBlocks = [];
$maxCellHeight = 34.0;
foreach ($weekDays as $day) {
$blocks = [];
foreach ($entriesByDate[$day['date']] ?? [] as $entry) {
$labelLines = $this->structureEntryLines($pdf, $entry, $dayWidth - 17);
$blockHeight = max(16.0, 6.0 + (count($labelLines) * 8.0));
$blocks[] = [
'entry' => $entry,
'lines' => $labelLines,
'height' => $blockHeight,
];
}
$dayBlocks[] = $blocks;
$cellHeight = 8.0;
foreach ($blocks as $block) {
$cellHeight += $block['height'] + 4.0;
}
$maxCellHeight = max($maxCellHeight, $cellHeight);
}
$agentLines = $pdf->wrap(
sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']),
$agentWidth - 10,
7.5
);
$rowHeight = max(44.0, $maxCellHeight, 14.0 + (count($agentLines) * 9.0));
if ($y + $rowHeight > $bottomLimit) {
$pdf->addPage();
$this->drawDocumentHeader(
$pdf,
'Planning hebdomadaire du lieu d\'affectation - suite',
(string) $structure['nom'] . ' - ' . $this->typeLabel((string) ($structure['type_affectation'] ?? '')),
$this->weekLabel($week)
);
$drawHeader();
$y = $tableY + $headerHeight;
}
$x = $margin;
$widths = [$agentWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $dayWidth, $quotaWidth];
foreach ($widths as $width) {
$pdf->rect($x, $y, $width, $rowHeight, 0.80);
$x += $width;
}
$lineY = $y + 13;
foreach ($agentLines as $index => $line) {
$pdf->text($margin + 5, $lineY, $line, 7.5, $index === 0);
$lineY += 9;
}
foreach ($dayBlocks as $dayIndex => $blocks) {
$cellX = $margin + $agentWidth + ($dayIndex * $dayWidth);
if ($blocks === []) {
$pdf->text($cellX + 6, $y + 18, '-', 7.2);
continue;
}
$blockY = $y + 5;
foreach ($blocks as $block) {
$entry = $block['entry'];
$palette = $this->entryPalette($entry);
$blockHeight = (float) $block['height'];
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
$textY = $blockY + 10;
foreach ($block['lines'] as $lineIndex => $line) {
$isFirst = $lineIndex === 0;
$pdf->text($cellX + 10, $textY, $line, 7.0, $isFirst);
$textY += 8;
}
$blockY += $blockHeight + 4;
}
}
$quotaText = (string) ($agent['quota']['restantes']['libelle'] ?? '-');
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 18, $quotaText, 8, true);
$pdf->text($margin + $agentWidth + (5 * $dayWidth) + 5, $y + 31, (($agent['lieu_principal'] ?? false) ? 'Principal' : 'Ponctuel'), 7);
$y += $rowHeight;
}
if ($agents === []) {
$pdf->text($margin, $y + 24, 'Aucun agent rattache ou planifie sur ce lieu pour cette semaine.', 10);
}
$this->drawLegend($pdf, self::MARGIN, 562);
$pdf->text(self::MARGIN, 584, 'Document genere depuis PTA - Planning du lundi au vendredi.', 7);
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,
array $weeks,
array $quota,
int $page,
int $pageCount
): void {
$first = $weeks[0];
$last = $weeks[count($weeks) - 1];
$periodStart = (new DateTimeImmutable((string) $first['date_debut']))->format('d/m/Y');
$periodEnd = (new DateTimeImmutable((string) $last['date_debut']))->modify('+4 days')->format('d/m/Y');
$principalLocation = (($agent['structure_principale_nom'] ?? '') ?: 'Non renseigne');
$principalType = $this->typeLabel((string) ($agent['structure_principale_type_affectation'] ?? ''));
$pdf->text(self::MARGIN, 25, 'PTA', 9, true);
$pdf->text(self::MARGIN, 44, 'Planning de l\'agent - vue multi-semaines', 16, true);
$pdf->text(self::MARGIN, 60, sprintf('%s %s - %s', $agent['prenom'], $agent['nom'], $agent['matricule']), 9.5, true);
$pdf->text(self::MARGIN, 74, 'Lieu principal : ' . $principalLocation . ' - ' . $principalType, 8);
$pdf->text($pdf->width() - 260, 44, sprintf('Du %s au %s', $periodStart, $periodEnd), 9.5, true);
$pdf->text($pdf->width() - 260, 60, 'Quota restant : ' . ($quota['restantes']['libelle'] ?? '-'), 8.5, true);
if ($pageCount > 1) {
$pdf->text($pdf->width() - 260, 74, sprintf('Page %d / %d', $page, $pageCount), 8);
}
$pdf->line(self::MARGIN, 86, $pdf->width() - self::MARGIN, 86, 0.72);
}
/**
* 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);
$usableWidth = $pdf->width() - (self::MARGIN * 2);
$colWidth = $usableWidth / 5;
$weekTitleHeight = 16.0;
$dayHeaderHeight = 16.0;
$bodyHeight = 77.0;
$pdf->fillRect(self::MARGIN, $y, $usableWidth, $weekTitleHeight, 0.965);
$pdf->rect(self::MARGIN, $y, $usableWidth, $weekTitleHeight + $dayHeaderHeight + $bodyHeight, 0.78);
$pdf->text(self::MARGIN + 6, $y + 11, $this->weekLabel($week), 8.5, true);
$dayHeaderY = $y + $weekTitleHeight;
$bodyY = $dayHeaderY + $dayHeaderHeight;
foreach ($weekDays as $index => $day) {
$colX = self::MARGIN + ($index * $colWidth);
$pdf->fillRect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight, 0.92);
$pdf->rect($colX, $dayHeaderY, $colWidth, $dayHeaderHeight + $bodyHeight, 0.82);
$pdf->text($colX + 5, $dayHeaderY + 11, $this->shortDayLabel($day['label']) . ' ' . substr($day['display'], 0, 5), 7.2, true);
$dayEntries = $entriesByDate[$day['date']] ?? [];
if ($dayEntries === []) {
$pdf->text($colX + 6, $bodyY + 17, 'Aucune affectation', 6.5);
continue;
}
$cursorY = $bodyY + 4;
$rendered = 0;
foreach ($dayEntries as $entryIndex => $entry) {
$lines = $this->compactAgentEntryLines($pdf, $entry, $colWidth - 18);
$cardHeight = max(18.0, 5.0 + (count($lines) * 7.2));
if ($cursorY + $cardHeight > $bodyY + $bodyHeight - 4) {
$remaining = count($dayEntries) - $rendered;
if ($remaining > 0) {
$pdf->text($colX + 7, $bodyY + $bodyHeight - 7, '+' . $remaining . ' autre(s) affectation(s)', 6.2, true);
}
break;
}
$palette = $this->entryPalette($entry);
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
$lineY = $cursorY + 9;
foreach ($lines as $lineIndex => $line) {
$bold = $lineIndex === 0;
$pdf->text($colX + 10, $lineY, $line, 6.5, $bold);
$lineY += 7.2;
}
$cursorY += $cardHeight + 3.0;
$rendered++;
}
}
}
/**
* 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'])];
$motifLabel = $this->motifDisplayLabel($entry);
if ($motifLabel !== '') {
foreach ($pdf->wrap($motifLabel, $width, 6.3) as $line) {
$lines[] = $line;
}
}
$location = (string) ($entry['structure_nom'] ?? 'Lieu non renseigne');
foreach ($pdf->wrap('Lieu : ' . $location, $width, 6.1) as $line) {
$lines[] = $line;
}
if (($entry['statut'] ?? 'VALIDE') === 'BROUILLON') {
$lines[] = 'Brouillon';
}
return $lines;
}
/**
* 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']);
$motif = $this->motifDisplayLabel($entry);
if ($motif !== '') {
$first .= ' ' . $motif;
}
return $pdf->wrap($first, $width, 7.0);
}
/**
* Le motif générique TRAVAIL n'apporte aucune information sur une plage déjà colorée
* en « temps de travail » ; les motifs de travail spécialisés (ALP matin, ALP soir,
* restauration…) restent affichés, ils précisent la nature du temps travaillé.
*/
private function motifDisplayLabel(array $entry): string
{
return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL'
? ''
: (string) ($entry['motif_libelle'] ?? 'Affectation');
}
/**
* 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(
(string) ($entry['motif_code'] ?? 'AUTRE'),
(int) ($entry['compte_dans_quota'] ?? 0) === 1
);
}
/**
* 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)) {
MotifCategories::TRAVAIL => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
MotifCategories::FORMATION => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
MotifCategories::CONGE => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
MotifCategories::ABSENCE => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
};
}
/**
* 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 = [
['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
['FORMATION', 'Formation'],
['CONGE', 'Conge'],
['ABSENCE', 'Absence'],
['JNT', 'Autre absence'],
];
foreach ($items as [$code, $label]) {
$palette = $this->motifPalette($code);
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
$pdf->text($x + 12, $y, $label, 6.5);
// Largeur estimée du libellé (Helvetica ~0,52 em) plutôt qu'un pas fixe :
// la première entrée est trop longue pour une colonne de 76 points.
$x += 12 + (mb_strlen($label) * 6.5 * 0.52) + 18;
}
}
/**
* 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 = [];
foreach ($entries as $entry) {
$grouped[(string) $entry['date_jour']][] = $entry;
}
foreach ($grouped as &$dayEntries) {
usort($dayEntries, static fn (array $a, array $b): int => strcmp((string) $a['heure_debut'], (string) $b['heure_debut']));
}
unset($dayEntries);
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);
$pdf->text(self::MARGIN, 55, $title, 18, true);
$pdf->text(self::MARGIN, 72, $subtitle, 10);
$pdf->text($pdf->width() - 250, 55, $weekLabel, 10, true);
$pdf->line(self::MARGIN, 90, $pdf->width() - self::MARGIN, 90, 0.72);
}
/**
* 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']);
$days = [];
for ($dayNumber = 1; $dayNumber <= 5; $dayNumber++) {
$date = $start->modify('+' . ($dayNumber - 1) . ' days');
$days[] = [
'date' => $date->format('Y-m-d'),
'label' => self::WEEK_DAYS[$dayNumber],
'display' => $date->format('d/m/Y'),
];
}
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']);
$end = $start->modify('+4 days');
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) {
'Lundi' => 'Lun.',
'Mardi' => 'Mar.',
'Mercredi' => 'Mer.',
'Jeudi' => 'Jeu.',
'Vendredi' => 'Ven.',
default => $label,
};
}
/** Libellé du type de lieu, sans accent (contrainte d'encodage du PDF). */
private function typeLabel(string $type): string
{
return match (strtoupper($type)) {
'EXTRASCOLAIRE' => 'Extrascolaire',
'PERISCOLAIRE' => 'Periscolaire',
default => 'Non renseigne',
};
}
}