Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
156 lines
5.4 KiB
PHP
156 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\ReviewPacks;
|
|
|
|
use App\Services\Pdf\PdfRenderingGateway;
|
|
use App\Services\Pdf\PdfRenderRequest;
|
|
use App\Services\Pdf\PdfRenderResult;
|
|
|
|
final class ManagementReportPdfRenderer
|
|
{
|
|
public function __construct(
|
|
private readonly PdfRenderingGateway $pdfRenderingGateway,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function render(array $payload, string $correlationId): PdfRenderResult
|
|
{
|
|
return $this->pdfRenderingGateway->renderHtml(new PdfRenderRequest(
|
|
html: $this->html($payload),
|
|
options: [
|
|
'printBackground' => true,
|
|
'preferCssPageSize' => true,
|
|
'displayHeaderFooter' => true,
|
|
'headerTemplate' => '<div style="font-size:8px;color:#6b7280;width:100%;padding:0 16mm;">TenantPilot Management Report</div>',
|
|
'footerTemplate' => '<div style="font-size:8px;color:#6b7280;width:100%;padding:0 16mm;text-align:right;"><span class="pageNumber"></span>/<span class="totalPages"></span></div>',
|
|
],
|
|
correlationId: $correlationId,
|
|
outputFilename: 'tenantpilot-management-report',
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function html(array $payload): string
|
|
{
|
|
$title = e((string) ($payload['title'] ?? 'TenantPilot Management Report'));
|
|
$environment = e((string) data_get($payload, 'managed_environment.name', 'Managed environment'));
|
|
$profile = e((string) ($payload['profile_label'] ?? 'Customer executive'));
|
|
$classification = e((string) ($payload['classification'] ?? 'Customer-facing management report'));
|
|
$generatedAt = e((string) data_get($payload, 'provenance.generated_at', now()->toIso8601String()));
|
|
$chapters = is_array($payload['chapters'] ?? null) ? $payload['chapters'] : [];
|
|
$chapterHtml = collect($chapters)
|
|
->filter(static fn (mixed $chapter): bool => is_array($chapter))
|
|
->map(fn (array $chapter): string => $this->chapterHtml($chapter))
|
|
->implode("\n");
|
|
|
|
return <<<HTML
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{$title}</title>
|
|
<style>
|
|
@page { size: A4; margin: 18mm 16mm; }
|
|
body { color: #111827; font-family: Arial, sans-serif; font-size: 12px; line-height: 1.5; }
|
|
h1 { color: #111827; font-size: 28px; margin: 0 0 8px; }
|
|
h2 { border-bottom: 1px solid #d1d5db; color: #111827; font-size: 18px; margin: 28px 0 12px; padding-bottom: 6px; }
|
|
h3 { color: #374151; font-size: 13px; margin: 12px 0 4px; }
|
|
p { margin: 0 0 8px; }
|
|
ul { margin: 0 0 10px 18px; padding: 0; }
|
|
li { margin-bottom: 6px; }
|
|
.cover { border-bottom: 2px solid #111827; margin-bottom: 22px; padding-bottom: 18px; }
|
|
.meta { color: #4b5563; font-size: 11px; }
|
|
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; margin-bottom: 10px; }
|
|
.kv div:nth-child(odd) { color: #6b7280; font-weight: 700; }
|
|
.empty { color: #6b7280; font-style: italic; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="cover">
|
|
<h1>{$title}</h1>
|
|
<p>{$environment}</p>
|
|
<p class="meta">Profile: {$profile} · Classification: {$classification} · Generated: {$generatedAt}</p>
|
|
</section>
|
|
{$chapterHtml}
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $chapter
|
|
*/
|
|
private function chapterHtml(array $chapter): string
|
|
{
|
|
$title = e((string) ($chapter['title'] ?? 'Chapter'));
|
|
$content = is_array($chapter['content'] ?? null) ? $chapter['content'] : [];
|
|
$contentHtml = $this->contentHtml($content);
|
|
|
|
return <<<HTML
|
|
<section>
|
|
<h2>{$title}</h2>
|
|
{$contentHtml}
|
|
</section>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $content
|
|
*/
|
|
private function contentHtml(array $content): string
|
|
{
|
|
if ($content === []) {
|
|
return '<p class="empty">No items recorded.</p>';
|
|
}
|
|
|
|
$html = '';
|
|
|
|
foreach ($content as $key => $value) {
|
|
if (is_array($value)) {
|
|
$html .= '<h3>'.e($this->humanize((string) $key)).'</h3>'.$this->arrayHtml($value);
|
|
} elseif ($value !== null && trim((string) $value) !== '') {
|
|
$html .= '<div class="kv"><div>'.e($this->humanize((string) $key)).'</div><div>'.e((string) $value).'</div></div>';
|
|
}
|
|
}
|
|
|
|
return $html !== '' ? $html : '<p class="empty">No items recorded.</p>';
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $items
|
|
*/
|
|
private function arrayHtml(array $items): string
|
|
{
|
|
if ($items === []) {
|
|
return '<p class="empty">No items recorded.</p>';
|
|
}
|
|
|
|
$listItems = collect($items)
|
|
->map(function (mixed $item): string {
|
|
if (is_array($item)) {
|
|
$parts = collect($item)
|
|
->map(fn (mixed $value, string|int $key): string => '<strong>'.e($this->humanize((string) $key)).':</strong> '.e((string) $value))
|
|
->implode('<br>');
|
|
|
|
return '<li>'.$parts.'</li>';
|
|
}
|
|
|
|
return '<li>'.e((string) $item).'</li>';
|
|
})
|
|
->implode('');
|
|
|
|
return '<ul>'.$listItems.'</ul>';
|
|
}
|
|
|
|
private function humanize(string $value): string
|
|
{
|
|
return ucfirst(str_replace('_', ' ', $value));
|
|
}
|
|
}
|