302 lines
12 KiB
PHP
302 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|