TenantAtlas/apps/platform/app/Services/ReviewPacks/ManagementReportPdfService.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

414 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\ReviewPacks;
use App\Jobs\GenerateManagementReportPdfJob;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReviewPacks\ManagementReportPdfPayloadBuilder;
use App\Support\ReviewPacks\ManagementReportPdfRuntimeGate;
use App\Support\ReviewPacks\ReportProfileRegistry;
use App\Support\ReviewPackStatus;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManagementReportPdfService
{
public function __construct(
private readonly OperationRunService $operationRunService,
private readonly WorkspaceAuditLogger $auditLogger,
private readonly ManagementReportPdfRuntimeGate $runtimeGate,
) {}
/**
* @return array<string, mixed>
*/
public function generationDecision(ReviewPack $reviewPack): array
{
$readyReport = $this->findReadyReport($reviewPack);
if ($readyReport instanceof StoredReport) {
return [
'state' => 'ready',
'is_blocked' => false,
'reason_code' => null,
'reason' => null,
'report_id' => (int) $readyReport->getKey(),
];
}
$reviewPack->loadMissing(['tenant', 'environmentReview.currentExportReviewPack']);
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
return $this->blocked('review_pack_not_ready', 'Management PDF generation requires a ready Review Pack.');
}
if ($reviewPack->expires_at !== null && $reviewPack->expires_at->isPast()) {
return $this->blocked('review_pack_expired', 'Management PDF generation requires a non-expired Review Pack.');
}
if (! filled($reviewPack->file_disk) || ! filled($reviewPack->file_path)) {
return $this->blocked('source_artifact_missing', 'Management PDF generation requires the Review Pack artifact to be present.');
}
if (! Storage::disk((string) $reviewPack->file_disk)->exists((string) $reviewPack->file_path)) {
return $this->blocked('source_artifact_missing', 'Management PDF generation requires the Review Pack artifact to be present.');
}
$review = $reviewPack->environmentReview;
if (! $review || (int) ($review->current_export_review_pack_id ?? 0) !== (int) $reviewPack->getKey()) {
return $this->blocked('review_pack_not_current', 'Management PDF generation requires the current Review Pack for the released review.');
}
$activeReport = $this->findActiveReport($reviewPack);
if ($activeReport instanceof StoredReport) {
return [
'state' => 'active',
'is_blocked' => false,
'reason_code' => null,
'reason' => null,
'report_id' => (int) $activeReport->getKey(),
'operation_run_id' => $activeReport->operation_run_id !== null ? (int) $activeReport->operation_run_id : null,
];
}
$runtimeDecision = $this->runtimeGate->decision();
if ((bool) ($runtimeDecision['is_blocked'] ?? false)) {
return [
'state' => 'blocked',
'is_blocked' => true,
'reason_code' => $runtimeDecision['reason_code'] ?? 'runtime_blocked',
'reason' => $runtimeDecision['reason'] ?? 'Management PDF generation is blocked by runtime configuration.',
'runtime' => $runtimeDecision,
];
}
$disclosureDecision = ManagementReportPdfPayloadBuilder::customerExecutiveDisclosureDecision($reviewPack);
if ((bool) ($disclosureDecision['is_blocked'] ?? false)) {
return [
'state' => 'blocked',
'is_blocked' => true,
'reason_code' => $disclosureDecision['reason_code'] ?? 'disclosure_blocked',
'reason' => $disclosureDecision['reason'] ?? 'Management PDF generation is blocked by the customer-facing disclosure policy.',
'runtime' => $runtimeDecision,
'disclosure' => $disclosureDecision['disclosure'] ?? [],
'readiness' => $disclosureDecision['readiness'] ?? [],
];
}
return [
'state' => 'available',
'is_blocked' => false,
'reason_code' => null,
'reason' => null,
'runtime' => $runtimeDecision,
'disclosure' => $disclosureDecision['disclosure'] ?? [],
];
}
/**
* @return array{mode:string, report:?StoredReport, operation_run:?OperationRun, decision:array<string, mixed>}
*/
public function startGeneration(ReviewPack $reviewPack, User $actor): array
{
$reviewPack->loadMissing(['tenant.workspace', 'environmentReview']);
$tenant = $reviewPack->tenant;
if (! $tenant instanceof ManagedEnvironment || ! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
abort_unless($actor->can(Capabilities::REVIEW_PACK_MANAGE, $tenant), 403);
$readyReport = $this->findReadyReport($reviewPack);
if ($readyReport instanceof StoredReport) {
return [
'mode' => 'ready',
'report' => $readyReport,
'operation_run' => $readyReport->operationRun,
'decision' => $this->generationDecision($reviewPack),
];
}
$decision = $this->generationDecision($reviewPack);
if ((bool) ($decision['is_blocked'] ?? false)) {
$this->logLifecycleEvent(
reviewPack: $reviewPack,
actor: $actor,
action: AuditActionId::ManagementReportPdfGenerationBlocked,
status: 'blocked',
mode: 'blocked',
operationRun: null,
report: null,
context: ['decision' => $decision],
);
return [
'mode' => 'blocked',
'report' => null,
'operation_run' => null,
'decision' => $decision,
];
}
$activeReport = $this->findActiveReport($reviewPack);
if ($activeReport instanceof StoredReport) {
return [
'mode' => 'active',
'report' => $activeReport,
'operation_run' => $activeReport->operationRun,
'decision' => $decision,
];
}
$fingerprint = $this->computeFingerprint($reviewPack);
$retryableReport = $this->findRetryableReport($reviewPack, $fingerprint);
$operationRun = $this->operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::ManagementReportGenerate->value,
identityInputs: [
'source_review_pack_id' => (int) $reviewPack->getKey(),
'source_review_pack_fingerprint' => (string) $reviewPack->fingerprint,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'report_type' => StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF,
],
context: [
'source_review_pack_id' => (int) $reviewPack->getKey(),
'source_environment_review_id' => $reviewPack->environment_review_id !== null ? (int) $reviewPack->environment_review_id : null,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'report_type' => StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF,
'runtime_gate' => $decision['runtime'] ?? [],
],
initiator: $actor,
);
$existingRunReport = $this->findReportForRun($operationRun);
$report = $existingRunReport
?? $retryableReport
?? StoredReport::create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'source_environment_review_id' => $reviewPack->environment_review_id !== null ? (int) $reviewPack->environment_review_id : null,
'source_review_pack_id' => (int) $reviewPack->getKey(),
'operation_run_id' => (int) $operationRun->getKey(),
'generated_by_user_id' => (int) $actor->getKey(),
'report_type' => StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF,
'report_format' => StoredReport::FORMAT_PDF,
'status' => StoredReport::STATUS_QUEUED,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'fingerprint' => $fingerprint,
'payload' => [
'state' => StoredReport::STATUS_QUEUED,
'source_review_pack_id' => (int) $reviewPack->getKey(),
'source_environment_review_id' => $reviewPack->environment_review_id !== null ? (int) $reviewPack->environment_review_id : null,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
],
]);
if ($report->operation_run_id !== (int) $operationRun->getKey()
|| $report->status === StoredReport::STATUS_FAILED
) {
$report->forceFill([
'operation_run_id' => (int) $operationRun->getKey(),
'generated_by_user_id' => (int) $actor->getKey(),
'status' => StoredReport::STATUS_QUEUED,
'file_disk' => null,
'file_path' => null,
'file_size' => null,
'sha256' => null,
'generated_at' => null,
'payload' => [
'state' => StoredReport::STATUS_QUEUED,
'source_review_pack_id' => (int) $reviewPack->getKey(),
'source_environment_review_id' => $reviewPack->environment_review_id !== null ? (int) $reviewPack->environment_review_id : null,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'retry_of_failed_report' => true,
],
])->save();
}
$shouldDispatch = $operationRun->wasRecentlyCreated || ! $existingRunReport instanceof StoredReport;
if ($shouldDispatch) {
$this->operationRunService->dispatchOrFail($operationRun, function () use ($report, $operationRun): void {
GenerateManagementReportPdfJob::dispatch(
storedReportId: (int) $report->getKey(),
operationRunId: (int) $operationRun->getKey(),
);
});
}
$this->logLifecycleEvent(
reviewPack: $reviewPack,
actor: $actor,
action: AuditActionId::ManagementReportPdfGenerationRequested,
status: 'queued',
mode: $shouldDispatch ? 'queued' : 'reused_active_run',
operationRun: $operationRun,
report: $report,
);
return [
'mode' => $shouldDispatch ? 'queued' : 'active',
'report' => $report,
'operation_run' => $operationRun,
'decision' => $decision,
];
}
/**
* @param array<string, scalar|null> $parameters
*/
public function generateDownloadUrl(StoredReport $report, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.management-report-pdfs.download',
array_merge(['storedReport' => $report->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}
public function findReadyReport(ReviewPack $reviewPack): ?StoredReport
{
return StoredReport::query()
->where('source_review_pack_id', (int) $reviewPack->getKey())
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
->where('report_format', StoredReport::FORMAT_PDF)
->where('profile', ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->where('status', StoredReport::STATUS_READY)
->whereNotNull('file_disk')
->whereNotNull('file_path')
->latest('generated_at')
->latest('id')
->first();
}
public function findActiveReport(ReviewPack $reviewPack): ?StoredReport
{
return StoredReport::query()
->with('operationRun')
->where('source_review_pack_id', (int) $reviewPack->getKey())
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
->where('report_format', StoredReport::FORMAT_PDF)
->where('profile', ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->whereIn('status', [StoredReport::STATUS_QUEUED, StoredReport::STATUS_GENERATING])
->whereHas('operationRun', static function ($query): void {
$query->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value]);
})
->latest('id')
->first();
}
public function findReportForRun(OperationRun $operationRun): ?StoredReport
{
return StoredReport::query()
->where('operation_run_id', (int) $operationRun->getKey())
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
->latest('id')
->first();
}
private function findRetryableReport(ReviewPack $reviewPack, string $fingerprint): ?StoredReport
{
return StoredReport::query()
->where('managed_environment_id', (int) $reviewPack->managed_environment_id)
->where('source_review_pack_id', (int) $reviewPack->getKey())
->where('report_type', StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF)
->where('report_format', StoredReport::FORMAT_PDF)
->where('profile', ReportProfileRegistry::CUSTOMER_EXECUTIVE)
->where('fingerprint', $fingerprint)
->where('status', StoredReport::STATUS_FAILED)
->latest('id')
->first();
}
public function computeFingerprint(ReviewPack $reviewPack): string
{
return hash('sha256', json_encode([
'source_review_pack_id' => (int) $reviewPack->getKey(),
'source_review_pack_fingerprint' => (string) $reviewPack->fingerprint,
'source_environment_review_id' => $reviewPack->environment_review_id !== null ? (int) $reviewPack->environment_review_id : null,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'report_type' => StoredReport::REPORT_TYPE_MANAGEMENT_REPORT_PDF,
'report_format' => StoredReport::FORMAT_PDF,
], JSON_THROW_ON_ERROR));
}
/**
* @return array<string, mixed>
*/
private function blocked(string $reasonCode, string $reason): array
{
return [
'state' => 'blocked',
'is_blocked' => true,
'reason_code' => $reasonCode,
'reason' => $reason,
];
}
/**
* @param array<string, mixed> $context
*/
private function logLifecycleEvent(
ReviewPack $reviewPack,
User $actor,
AuditActionId $action,
string $status,
string $mode,
?OperationRun $operationRun,
?StoredReport $report,
array $context = [],
): void {
$tenant = $reviewPack->tenant;
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$this->auditLogger->log(
workspace: $tenant->workspace,
action: $action,
context: array_replace_recursive([
'metadata' => [
'mode' => $mode,
'review_pack_id' => (int) $reviewPack->getKey(),
'environment_review_id' => $reviewPack->environment_review_id !== null
? (int) $reviewPack->environment_review_id
: null,
'stored_report_id' => $report instanceof StoredReport ? (int) $report->getKey() : null,
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
],
], $context),
actor: $actor,
status: $status,
resourceType: 'stored_report',
resourceId: $report instanceof StoredReport ? (string) $report->getKey() : 'review_pack:'.$reviewPack->getKey(),
targetLabel: sprintf('Management report PDF for review pack #%d', (int) $reviewPack->getKey()),
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
tenant: $tenant,
);
}
}