La liste des motifs d'une nouvelle plage s'ouvrait sur un placeholder vide « Choisir un motif », alors que le temps de travail est le cas très majoritaire : elle propose désormais « Heure de travail » (code TRAVAIL), avec repli sur le premier motif décomptant le quota si le référentiel ne contient pas ce code. Côté PDF, seul le code générique TRAVAIL était traité comme du temps travaillé. Les motifs ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire, préparations et ménage de fond tombaient donc : - dans un total invisible du bilan annuel (ligne « Heures travaillées » à 0 h 00 alors que les heures étaient bien décomptées plus bas) ; - dans la couleur grise « Autre absence » des plannings agent et lieu. La classification est centralisée dans App\Core\MotifCategories, réutilisée par PtaModel (qui portait déjà la même liste) et par les deux services PDF. Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il décompte le quota, pour qu'aucun total ne puisse disparaître d'un document. La ligne « Heures travaillées » détaille en outre les motifs regroupés, et le total décompté suit maintenant le drapeau compte_dans_quota comme QuotaModel : il additionnait auparavant les congés, hors quota, en contradiction avec sa propre note de bas de tableau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
444 lines
18 KiB
PHP
444 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\MotifCategories;
|
|
use DateTimeImmutable;
|
|
|
|
final class PlanningPdfService
|
|
{
|
|
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.
|
|
*/
|
|
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).
|
|
*/
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** @param array<string, array<int, array>> $entriesByDate */
|
|
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++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
|
|
/** @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');
|
|
}
|
|
|
|
/** @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
|
|
);
|
|
}
|
|
|
|
/** @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'],
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
|
|
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'));
|
|
}
|
|
|
|
private function shortDayLabel(string $label): string
|
|
{
|
|
return match ($label) {
|
|
'Lundi' => 'Lun.',
|
|
'Mardi' => 'Mar.',
|
|
'Mercredi' => 'Mer.',
|
|
'Jeudi' => 'Jeu.',
|
|
'Vendredi' => 'Ven.',
|
|
default => $label,
|
|
};
|
|
}
|
|
|
|
private function typeLabel(string $type): string
|
|
{
|
|
return match (strtoupper($type)) {
|
|
'EXTRASCOLAIRE' => 'Extrascolaire',
|
|
'PERISCOLAIRE' => 'Periscolaire',
|
|
default => 'Non renseigne',
|
|
};
|
|
}
|
|
}
|