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

302 lines
12 KiB
PHP
Raw 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;
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
{
private const PAGE_WIDTH = 595.28;
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);
$periodLabel = (string) ($quota['periode_libelle'] ?? ('année ' . ($quota['annee'] ?? date('Y'))));
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
$totals = $this->normalizeTotals($rows);
$this->drawHeader($pdf, $agent, $periodLabel, $generatedAt);
$this->drawIdentity($pdf, $agent);
$this->drawBreakdown($pdf, $totals);
$this->drawResult($pdf, $quota, $totals);
$this->drawFooter($pdf, $agent, $generatedAt);
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);
$pdf->text(self::MARGIN, 58, 'Compte-rendu du temps de l\'agent', 17, true);
$pdf->text(self::MARGIN, 79, 'Synthèse des heures planifiées et décomptées pour la période ' . $periodLabel, 9.5);
$pdf->text(self::PAGE_WIDTH - 190, 34, 'Édité le ' . $generatedAt->format('d/m/Y'), 8);
$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;
$y = 118.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$h = 90.0;
$pdf->fillRect($x, $y, $w, 28, 0.93);
$pdf->rect($x, $y, $w, $h, 0.68);
$pdf->text($x + 12, $y + 19, 'Agent', 10.5, true);
$pdf->text($x + 12, $y + 50, 'Nom : ' . (($agent['nom'] ?? '') ?: '-'), 9.2);
$pdf->text($x + 270, $y + 50, 'Prénom : ' . (($agent['prenom'] ?? '') ?: '-'), 9.2);
$pdf->text($x + 12, $y + 73, 'Matricule : ' . (($agent['matricule'] ?? '') ?: '-'), 9.2);
$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;
$y = 232.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$headerH = 31.0;
$rowH = 43.0;
$rows = [
[MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
[MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
[MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
[MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
[MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
];
$pdf->fillRect($x, $y, $w, $headerH, 0.92);
$pdf->rect($x, $y, $w, $headerH + (count($rows) * $rowH), 0.65);
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
$cursorY = $y + $headerH;
foreach ($rows as [$code, $label, $minutes, $detail]) {
$palette = $this->palette($code);
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
$detailLines = $detail === [] ? [] : $this->detailLines($pdf, $detail, $w - 174);
if ($detailLines === []) {
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
} else {
$pdf->text($x + 32, $cursorY + 19, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 19, $this->minutesLabel($minutes), 10, true);
$detailY = $cursorY + 30;
foreach ($detailLines as $line) {
$pdf->text($x + 32, $detailY, $line, 7);
$detailY += 9;
}
}
$cursorY += $rowH;
}
}
/**
* Détaille les motifs regroupés dans les heures travaillées (ALP matin, ALP soir,
* restauration, préparations…), sur deux lignes au maximum.
*
* @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);
$parts = [];
foreach ($detail as $label => $minutes) {
if ($minutes > 0) {
$parts[] = $label . ' ' . $this->minutesLabel($minutes);
}
}
if ($parts === []) {
return [];
}
// Les motifs sont ajoutés un par un : la coupure tombe ainsi entre deux motifs
// et jamais au milieu d'un libellé, en gardant la place des points de suspension.
$text = '';
$truncated = false;
foreach ($parts as $part) {
$candidate = $text === '' ? 'Dont ' . $part : $text . ' · ' . $part;
if (count($pdf->wrap($candidate . ' …', $width, 7)) > 2) {
$truncated = true;
break;
}
$text = $candidate;
}
if ($text === '') {
return [];
}
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 :
// additionner les catégories affichées y ferait entrer les congés, hors quota.
$counted = (int) $totals['DECOMPTE'];
$target = (int) ($quota['quota_cible_minutes'] ?? 0);
$remaining = $target - $counted;
$validated = (int) ($quota['minutes_valides'] ?? 0);
$draft = (int) ($quota['minutes_brouillon'] ?? 0);
$x = self::MARGIN;
$y = 507.0;
$w = self::PAGE_WIDTH - (self::MARGIN * 2);
$pdf->line($x, $y, $x + $w, $y, 0.55);
$pdf->text($x, $y + 32, 'Résultat - heures décomptées', 12, true);
$pdf->text($x + $w - 150, $y + 32, $this->minutesLabel($counted), 13, true);
$pdf->text($x, $y + 55, sprintf('Dont %s validées et %s en brouillon.', $this->minutesLabel($validated), $this->minutesLabel($draft)), 8.5);
$boxY = $y + 82;
$boxH = 126.0;
$pdf->fillRect($x, $boxY, $w, $boxH, 0.965);
$pdf->rect($x, $boxY, $w, $boxH, 0.65);
$pdf->text($x + 14, $boxY + 27, 'Quota à effectuer sur la période', 10, true);
$pdf->text($x + $w - 160, $boxY + 27, $this->minutesLabel($target), 11, true);
$pdf->line($x + 12, $boxY + 43, $x + $w - 12, $boxY + 43, 0.85);
$pdf->text($x + 14, $boxY + 72, 'Heures décomptées à ce jour', 10, true);
$pdf->text($x + $w - 160, $boxY + 72, $this->minutesLabel($counted), 11, true);
$pdf->line($x + 12, $boxY + 88, $x + $w - 12, $boxY + 88, 0.85);
$pdf->text($x + 14, $boxY + 114, $remaining >= 0 ? 'Heures restant à effectuer' : 'Dépassement du quota', 10.5, true);
$pdf->textColor($x + $w - 160, $boxY + 114, $this->minutesLabel(abs($remaining)), $remaining >= 0 ? '#1D4ED8' : '#B91C1C', 12, true);
$noteY = $boxY + $boxH + 27;
foreach ($pdf->wrap('Le résultat ci-dessus additionne uniquement les motifs qui décomptent le quota annuel. Les absences non décomptées apparaissent dans le détail mais ne réduisent pas le nombre d’heures restant à effectuer.', $w, 8.5) as $line) {
$pdf->text($x, $noteY, $line, 8.5);
$noteY += 12;
}
}
/** Pied de page : origine du document, matricule, horodatage. */
private function drawFooter(SimplePdf $pdf, array $agent, DateTimeImmutable $generatedAt): void
{
$pdf->text(
self::MARGIN,
812,
sprintf('Document généré par PTA le %s - Référence agent : %s', $generatedAt->format('d/m/Y H:i'), (string) ($agent['matricule'] ?? '-')),
7
);
}
/**
* Regroupe les minutes par catégorie métier. Tous les motifs de travail effectif
* (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
{
$totals = [
MotifCategories::TRAVAIL => 0,
MotifCategories::ABSENCE => 0,
MotifCategories::FORMATION => 0,
MotifCategories::CONGE => 0,
MotifCategories::NON_DECOMPTE => 0,
'DECOMPTE' => 0,
'DETAIL_TRAVAIL' => [],
];
foreach ($rows as $row) {
$code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
$minutes = (int) ($row['minutes_total'] ?? 0);
$counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
$category = MotifCategories::category($code, $counts);
$totals[$category] += $minutes;
if ($counts) {
$totals['DECOMPTE'] += $minutes;
}
if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
$label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
$totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
}
}
return $totals;
}
/**
* 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)) {
MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
MotifCategories::FORMATION => ['accent' => '#7C3AED'],
MotifCategories::CONGE => ['accent' => '#059669'],
MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
default => ['accent' => '#64748B'],
};
}
/** 450 → « 7 h 30 ». Gère les valeurs négatives (écart de quota). */
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$absolute = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
}
}