Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #450
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));
|
|
}
|
|
}
|