Files
pta/app/Services/AgentTimeSummaryPdfService.php
PTA 4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00

252 lines
10 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.
*/
final class AgentTimeSummaryPdfService
{
private const PAGE_WIDTH = 595.28;
private const PAGE_HEIGHT = 841.89;
private const MARGIN = 42.0;
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();
}
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);
}
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);
}
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[]
*/
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);
}
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;
}
}
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.
*/
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;
}
/** @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'],
};
}
private function minutesLabel(int $minutes): string
{
$sign = $minutes < 0 ? '-' : '';
$absolute = abs($minutes);
return sprintf('%s%d h %02d', $sign, intdiv($absolute, 60), $absolute % 60);
}
}