TenantAtlas/apps/platform/app/Support/ReviewPacks/ManagementReportPdfPayloadBuilder.php
ahmido dbff2a0a90 feat(report): implement management report pdf runtime (#450)
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
2026-06-15 11:36:29 +00:00

322 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPacks;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\Workspace;
use InvalidArgumentException;
final class ManagementReportPdfPayloadBuilder
{
public const string PROFILE = ReportProfileRegistry::CUSTOMER_EXECUTIVE;
/**
* @return array<string, mixed>
*/
public function build(ReviewPack $reviewPack): array
{
$reviewPack->loadMissing([
'tenant.workspace',
'environmentReview.sections',
'environmentReview.evidenceSnapshot',
'environmentReview.currentExportReviewPack',
]);
$tenant = $reviewPack->tenant;
$workspace = $tenant?->workspace;
$review = $reviewPack->environmentReview;
if (! $tenant instanceof ManagedEnvironment || ! $workspace instanceof Workspace || ! $review instanceof EnvironmentReview) {
throw new InvalidArgumentException('Management report PDF requires a tenant, workspace, and released review.');
}
if ((int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) {
throw new InvalidArgumentException('Management report PDF can only be generated from the current review pack.');
}
$disclosureDecision = self::customerExecutiveDisclosureDecision($reviewPack);
$profile = is_array($disclosureDecision['profile'] ?? null) ? $disclosureDecision['profile'] : [];
$readiness = is_array($disclosureDecision['readiness'] ?? null) ? $disclosureDecision['readiness'] : [];
$disclosure = is_array($disclosureDecision['disclosure'] ?? null) ? $disclosureDecision['disclosure'] : [];
if ((string) ($disclosureDecision['reason_code'] ?? '') === 'management_report_pdf_profile_invalid') {
throw new InvalidArgumentException('Management report PDF requires the customer executive profile without fallback.');
}
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness);
if ((bool) ($disclosureDecision['is_blocked'] ?? false)) {
throw new InvalidArgumentException('Management report PDF generation is blocked by the customer-facing disclosure policy.');
}
$payload = [
'title' => 'TenantPilot Management Report',
'report_type' => 'management_report_pdf',
'profile' => self::PROFILE,
'profile_label' => (string) ($profile['label'] ?? 'Customer executive'),
'audience_label' => (string) ($profile['audience_label'] ?? 'Customer executive'),
'classification' => 'Customer-facing management report',
'workspace' => [
'id' => (int) $workspace->getKey(),
'name' => (string) $workspace->name,
],
'managed_environment' => [
'id' => (int) $tenant->getKey(),
'name' => (string) $tenant->name,
'external_id' => (string) $tenant->external_id,
],
'provenance' => [
'environment_review_id' => (int) $review->getKey(),
'review_pack_id' => (int) $reviewPack->getKey(),
'review_status' => (string) $review->status,
'review_fingerprint' => (string) $review->fingerprint,
'review_pack_fingerprint' => (string) $reviewPack->fingerprint,
'review_pack_sha256' => (string) $reviewPack->sha256,
'generated_at' => now()->toIso8601String(),
],
'chapters' => [
$this->chapter('cover', 'Cover', [
'environment' => (string) $tenant->name,
'workspace' => (string) $workspace->name,
'profile' => (string) ($profile['label'] ?? 'Customer executive'),
'classification' => 'Customer-facing management report',
'generated_at' => now()->toFormattedDateString(),
]),
$this->chapter('executive_summary', 'Executive summary', [
'summary' => $this->firstString(
data_get($reviewPack->summary, 'governance_package.executive_summary'),
data_get($review->summary, 'governance_package.executive_summary'),
data_get($review->summary, 'executive_summary'),
__('localization.review.rendered_report_summary_fallback'),
),
]),
$this->chapter('governance_posture', 'Governance posture', [
'state' => (string) ($guidance['label'] ?? 'Unavailable'),
'boundary' => (string) ($guidance['boundary_label'] ?? 'Needs review'),
'reason' => (string) ($guidance['primary_reason'] ?? ''),
'impact' => (string) ($guidance['impact'] ?? ''),
]),
$this->chapter('key_decisions', 'Key decisions', [
'items' => $this->listItems(
data_get($reviewPack->summary, 'decision_summary.entries')
?? data_get($review->summary, 'decision_summary.entries')
?? data_get($review->summary, 'governance_decisions'),
['title', 'label', 'decision', 'summary', 'rationale'],
),
]),
$this->chapter('top_risks', 'Top risks and findings', [
'items' => $this->listItems(
data_get($reviewPack->summary, 'top_findings')
?? data_get($review->summary, 'top_findings')
?? data_get($review->summary, 'finding_report_buckets.high'),
['title', 'label', 'severity', 'summary', 'recommendation'],
5,
),
]),
$this->chapter('accepted_risks', 'Accepted risks', [
'items' => $this->listItems(
data_get($reviewPack->summary, 'risk_acceptance')
?? data_get($review->summary, 'risk_acceptance'),
['title', 'label', 'risk', 'summary', 'accepted_until'],
5,
),
]),
$this->chapter('evidence_basis', 'Evidence basis', [
'evidence_snapshot_id' => $review->evidence_snapshot_id !== null ? (int) $review->evidence_snapshot_id : null,
'evidence_completeness' => (string) ($readiness['evidence_completeness_state'] ?? 'unknown'),
'review_pack_generated_at' => $reviewPack->generated_at?->toIso8601String(),
'review_pack_sha256' => (string) $reviewPack->sha256,
]),
$this->chapter('limitations', 'Limitations and disclosures', [
'limitations' => $this->listItems($guidance['limitations'] ?? [], ['label', 'reason', 'severity']),
'warnings' => $this->listItems($disclosure['warnings'], ['label', 'summary']),
'mandatory_disclosures' => $this->listItems($disclosure['mandatory_disclosures'], ['label', 'summary', 'proof_state']),
]),
$this->chapter('next_actions', 'Next actions', [
'items' => $this->listItems(
data_get($reviewPack->summary, 'recommended_next_actions')
?? data_get($review->summary, 'recommended_next_actions')
?? [],
['title', 'label', 'summary', 'owner', 'due'],
),
]),
$this->chapter('method_summary', 'Method summary', [
'summary' => 'Generated from the current customer-safe Review Pack and released review state. Raw evidence payloads, secrets, and Graph API responses are not included in this management PDF.',
]),
],
];
return $this->sanitize($payload);
}
/**
* @return array{
* is_blocked: bool,
* reason_code: ?string,
* reason: ?string,
* profile: array<string, mixed>,
* readiness: array<string, mixed>,
* disclosure: array<string, mixed>
* }
*/
public static function customerExecutiveDisclosureDecision(ReviewPack $reviewPack): array
{
$reviewPack->loadMissing([
'environmentReview.sections',
'environmentReview.evidenceSnapshot',
'environmentReview.currentExportReviewPack',
]);
$review = $reviewPack->environmentReview;
if (! $review instanceof EnvironmentReview) {
throw new InvalidArgumentException('Management report PDF requires a released review.');
}
$profile = ReportProfileRegistry::resolve(self::PROFILE, self::PROFILE);
$readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review);
if ((string) ($profile['effective_key'] ?? '') !== self::PROFILE || (bool) ($profile['is_fallback'] ?? false)) {
return [
'is_blocked' => true,
'reason_code' => 'management_report_pdf_profile_invalid',
'reason' => 'Management report PDF requires the customer executive profile without fallback.',
'profile' => $profile,
'readiness' => $readiness,
'disclosure' => [
'blocking_reasons' => [],
'warnings' => [],
'mandatory_disclosures' => [],
'proof_states' => [],
],
];
}
$metadata = [
'non_certification_disclosure' => data_get($reviewPack->summary, 'control_interpretation.non_certification_disclosure')
?? data_get($review->summary, 'control_interpretation.non_certification_disclosure'),
];
$disclosure = ReportDisclosurePolicy::evaluate($profile, $readiness, $metadata);
$blockingReason = $disclosure['blocking_reasons'][0] ?? null;
if (is_array($blockingReason)) {
$label = trim((string) ($blockingReason['label'] ?? ''));
$summary = trim((string) ($blockingReason['summary'] ?? ''));
return [
'is_blocked' => true,
'reason_code' => (string) ($blockingReason['key'] ?? 'disclosure_blocked'),
'reason' => $label !== '' && $summary !== '' ? "{$label}: {$summary}" : ($summary !== '' ? $summary : $label),
'profile' => $profile,
'readiness' => $readiness,
'disclosure' => $disclosure,
];
}
return [
'is_blocked' => false,
'reason_code' => null,
'reason' => null,
'profile' => $profile,
'readiness' => $readiness,
'disclosure' => $disclosure,
];
}
/**
* @param array<string, mixed> $content
* @return array{key:string,title:string,content:array<string, mixed>}
*/
private function chapter(string $key, string $title, array $content): array
{
return compact('key', 'title', 'content');
}
private function firstString(mixed ...$values): string
{
foreach ($values as $value) {
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
}
/**
* @param list<string> $fields
* @return list<array<string, string>>
*/
private function listItems(mixed $items, array $fields, int $limit = 8): array
{
if (! is_iterable($items)) {
return [];
}
$normalized = [];
foreach ($items as $item) {
if (is_scalar($item)) {
$normalized[] = ['summary' => trim((string) $item)];
} elseif (is_array($item)) {
$entry = [];
foreach ($fields as $field) {
$value = data_get($item, $field);
if (is_scalar($value) && trim((string) $value) !== '') {
$entry[$field] = trim((string) $value);
}
}
if ($entry !== []) {
$normalized[] = $entry;
}
}
if (count($normalized) >= $limit) {
break;
}
}
return $normalized;
}
private function sanitize(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $key => $item) {
$sanitized[$key] = $this->sanitize($item);
}
return $sanitized;
}
if (! is_scalar($value) && $value !== null) {
return null;
}
$text = preg_replace('/\s+/', ' ', trim((string) $value));
if (! is_string($text)) {
return null;
}
if ($text === '') {
return $text;
}
if (preg_match('/SQLSTATE|Bearer\s+|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password/i', $text) === 1) {
return '[redacted]';
}
return mb_substr(strip_tags($text), 0, 1200);
}
}