TenantAtlas/apps/platform/app/Services/TenantReviews/TenantReviewComposer.php
Ahmed Darrazi 7ffdfff054
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s
chore: commit alles (automatisch)
2026-05-01 15:08:09 +02:00

338 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantReviews;
use App\Models\EvidenceSnapshot;
use App\Models\TenantReview;
use App\Support\TenantReviewStatus;
final class TenantReviewComposer
{
public function __construct(
private readonly TenantReviewFingerprint $fingerprint,
private readonly TenantReviewSectionFactory $sectionFactory,
private readonly TenantReviewReadinessGate $readinessGate,
) {}
/**
* @return array{
* fingerprint: string,
* completeness_state: string,
* status: string,
* summary: array<string, mixed>,
* sections: list<array<string, mixed>>
* }
*/
public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null): array
{
$tenant = $snapshot->tenant;
if ($tenant === null) {
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
}
$sections = $this->sectionFactory->make($snapshot);
$blockers = $this->readinessGate->blockersForSections($sections);
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
$completeness = $this->readinessGate->completenessForSections($sections);
$status = $this->readinessGate->statusForSections($sections);
$executiveSummarySection = collect($sections)
->firstWhere('section_key', 'executive_summary');
$controlInterpretationSection = collect($sections)
->firstWhere('section_key', 'control_interpretation');
$openRisksSection = collect($sections)
->firstWhere('section_key', 'open_risks');
$acceptedRisksSection = collect($sections)
->firstWhere('section_key', 'accepted_risks');
$operationsSection = collect($sections)
->firstWhere('section_key', 'operations_health');
if ($review instanceof TenantReview && $review->isPublished()) {
$status = TenantReviewStatus::Published;
}
return [
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
'completeness_state' => $completeness->value,
'status' => $status->value,
'summary' => [
'evidence_basis' => [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'section_count' => count($sections),
'section_state_counts' => $sectionStateCounts,
'publish_blockers' => $blockers,
'has_ready_export' => false,
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')
: [],
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
? array_merge(
data_get($controlInterpretationSection, 'summary_payload'),
[
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
? data_get($controlInterpretationSection, 'render_payload.entries')
: [],
],
)
: [],
'report_count' => 2,
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []),
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
'governance_package' => $this->governancePackageSummary(
snapshot: $snapshot,
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
),
'last_composed_at' => now()->toIso8601String(),
],
'sections' => $sections,
];
}
/**
* @param array<string, mixed> $executiveSummarySection
* @param array<string, mixed> $controlInterpretationSection
* @param array<string, mixed> $openRisksSection
* @param array<string, mixed> $acceptedRisksSection
* @return array<string, mixed>
*/
private function governancePackageSummary(
EvidenceSnapshot $snapshot,
array $executiveSummarySection,
array $controlInterpretationSection,
array $openRisksSection,
array $acceptedRisksSection,
): array {
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
? $executiveSummarySection['summary_payload']
: [];
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
? $executiveSummarySection['render_payload']
: [];
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
? $controlInterpretationSection['summary_payload']
: [];
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->take(3)
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
->values()
->all();
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
->filter(static fn (mixed $entry): bool => is_array($entry))
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
->values();
$governanceDecisionEntries = $acceptedRiskEntries
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$stableAcceptedRiskEntries = $acceptedRiskEntries
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
->values();
$governanceDecisions = $governanceDecisionEntries
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
->values()
->all();
return [
'delivery_artifact_family' => 'review_pack',
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
? $controlInterpretationSummary['version_key']
: null,
'executive_summary' => $this->governancePackageExecutiveSummary(
executiveSummaryPayload: $executiveSummaryPayload,
executiveRenderPayload: $executiveRenderPayload,
controlInterpretationSummary: $controlInterpretationSummary,
acceptedRiskCount: $acceptedRiskEntries->count(),
),
'top_findings' => $openRiskEntries,
'accepted_risks' => $stableAcceptedRiskEntries->all(),
'governance_decisions' => $governanceDecisions,
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
snapshot: $snapshot,
controlInterpretationSummary: $controlInterpretationSummary,
),
'supporting_artifact_links' => [
[
'artifact_family' => 'evidence_snapshot',
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
'purpose' => 'evidence_basis',
],
[
'artifact_family' => 'review_pack',
'artifact_key' => 'review_pack:current_export',
'purpose' => 'stakeholder_delivery',
],
],
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageFindingEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Open finding'),
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
];
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageAcceptedRiskEntry(array $entry): array
{
return [
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
'title' => $this->entryTitle($entry, 'Accepted risk'),
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
'owner_label' => $this->ownerLabel($entry),
];
}
/**
* @param array<string, mixed> $entry
*/
private function requiresGovernanceDecisionFollowUp(array $entry): bool
{
return in_array((string) ($entry['governance_state'] ?? ''), [
'expired_exception',
'revoked_exception',
'risk_accepted_without_valid_exception',
], true);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function packageGovernanceDecisionEntry(array $entry): array
{
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
return [
'finding_id' => $entry['finding_id'] ?? null,
'title' => $entry['title'] ?? 'Governance decision',
'governance_state' => $governanceState,
'summary' => match ($governanceState) {
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
default => 'This governance decision needs follow-up before stakeholder delivery.',
},
];
}
/**
* @param array<string, mixed> $executiveSummaryPayload
* @param array<string, mixed> $executiveRenderPayload
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageExecutiveSummary(
array $executiveSummaryPayload,
array $executiveRenderPayload,
array $controlInterpretationSummary,
int $acceptedRiskCount,
): string {
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
->values();
if ($highlights->isNotEmpty()) {
return (string) $highlights->first();
}
return sprintf(
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
$acceptedRiskCount,
);
}
/**
* @param array<string, mixed> $controlInterpretationSummary
*/
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
{
return sprintf(
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
(int) $snapshot->getKey(),
(string) $snapshot->completeness_state,
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
);
}
/**
* @param array<string, mixed> $entry
*/
private function entryTitle(array $entry, string $fallback): string
{
foreach (['title', 'name', 'finding_title'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function entrySummary(array $entry, string $fallback): string
{
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
$value = $entry[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
return $fallback;
}
/**
* @param array<string, mixed> $entry
*/
private function ownerLabel(array $entry): ?string
{
$owner = $entry['owner'] ?? null;
if (is_array($owner)) {
$name = $owner['name'] ?? null;
if (is_string($name) && trim($name) !== '') {
return $name;
}
}
return null;
}
}