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
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,
|
|
);
|
|
}
|
|
}
|