Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
414 lines
17 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|