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
322 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|