TenantAtlas/apps/platform/app/Jobs/GenerateManagementReportPdfJob.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

389 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs;
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\Services\ReviewPacks\ManagementReportPdfRenderer;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ReviewPacks\ManagementReportPdfPayloadBuilder;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use Throwable;
class GenerateManagementReportPdfJob implements ShouldQueue
{
use Queueable;
public int $timeout = 240;
public bool $failOnTimeout = true;
public function __construct(
public int $storedReportId,
public int $operationRunId,
) {}
public function handle(
OperationRunService $operationRunService,
ManagementReportPdfPayloadBuilder $payloadBuilder,
ManagementReportPdfRenderer $renderer,
WorkspaceAuditLogger $auditLogger,
): void {
$report = StoredReport::query()
->with(['tenant.workspace', 'sourceReviewPack.environmentReview.sections', 'operationRun', 'generatedBy'])
->find($this->storedReportId);
$operationRun = OperationRun::query()->find($this->operationRunId);
if (! $report instanceof StoredReport || ! $operationRun instanceof OperationRun) {
Log::warning('GenerateManagementReportPdfJob: missing records', [
'stored_report_id' => $this->storedReportId,
'operation_run_id' => $this->operationRunId,
]);
return;
}
$tenant = $report->tenant;
$reviewPack = $report->sourceReviewPack;
if (! $tenant instanceof ManagedEnvironment || ! $reviewPack instanceof ReviewPack) {
$this->markFailed($report, $operationRun, $operationRunService, $auditLogger, 'source_missing', 'Management PDF source records are unavailable.');
return;
}
$operationRun = $operationRunService->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, [
'total' => 1,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
]);
$report->update([
'status' => StoredReport::STATUS_GENERATING,
'payload' => array_replace_recursive(is_array($report->payload) ? $report->payload : [], [
'state' => StoredReport::STATUS_GENERATING,
'started_at' => now()->toIso8601String(),
]),
]);
try {
try {
$payload = $payloadBuilder->build($reviewPack);
} catch (InvalidArgumentException $exception) {
$this->markBlocked(
report: $report,
operationRun: $operationRun,
operationRunService: $operationRunService,
auditLogger: $auditLogger,
code: $this->blockedReasonCode($reviewPack, $exception),
message: $exception->getMessage(),
);
return;
}
$result = $renderer->render($payload, $this->correlationId($report, $operationRun));
if ($result->failed() || $result->pdfBytes === null) {
$this->markFailed(
report: $report,
operationRun: $operationRun,
operationRunService: $operationRunService,
auditLogger: $auditLogger,
code: $result->failureCode ?? 'renderer_failed',
message: $result->safeMessage ?? 'PDF renderer failed to create the document.',
payload: $payload,
);
return;
}
$this->storePdf($report, $operationRun, $tenant, $payload, $result->pdfBytes, $operationRunService, $auditLogger);
} catch (Throwable $exception) {
$this->markFailed(
report: $report,
operationRun: $operationRun,
operationRunService: $operationRunService,
auditLogger: $auditLogger,
code: 'generation_exception',
message: 'Management report PDF generation failed.',
);
throw $exception;
}
}
/**
* @param array<string, mixed> $payload
*/
private function storePdf(
StoredReport $report,
OperationRun $operationRun,
ManagedEnvironment $tenant,
array $payload,
string $pdfBytes,
OperationRunService $operationRunService,
WorkspaceAuditLogger $auditLogger,
): void {
$filePath = sprintf(
'management-reports/%s/review-pack-%d-%s-%d.pdf',
$this->safePathSegment((string) ($tenant->external_id ?: $tenant->getKey())),
(int) $report->source_review_pack_id,
now()->format('YmdHis'),
(int) $report->getKey(),
);
try {
$stored = Storage::disk('exports')->put($filePath, $pdfBytes);
} catch (Throwable $exception) {
$this->markFailed(
report: $report,
operationRun: $operationRun,
operationRunService: $operationRunService,
auditLogger: $auditLogger,
code: 'storage_failed',
message: 'Management report PDF storage failed.',
payload: $payload,
);
throw $exception;
}
if (! $stored) {
$this->markFailed(
report: $report,
operationRun: $operationRun,
operationRunService: $operationRunService,
auditLogger: $auditLogger,
code: 'storage_failed',
message: 'Management report PDF storage failed.',
payload: $payload,
);
return;
}
$sha256 = hash('sha256', $pdfBytes);
$report->update([
'status' => StoredReport::STATUS_READY,
'payload' => array_replace_recursive($payload, [
'state' => StoredReport::STATUS_READY,
'artifact' => [
'file_disk' => 'exports',
'file_path' => $filePath,
'file_size' => strlen($pdfBytes),
'sha256' => $sha256,
],
]),
'file_disk' => 'exports',
'file_path' => $filePath,
'file_size' => strlen($pdfBytes),
'sha256' => $sha256,
'generated_at' => now(),
]);
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => 1,
'processed' => 1,
'succeeded' => 1,
'failed' => 0,
'report_created' => 1,
],
);
$this->audit($report->refresh(), $operationRun, $auditLogger, AuditActionId::ManagementReportPdfGenerated, 'success', 'Management report PDF generated.');
}
/**
* @param array<string, mixed>|null $payload
*/
private function markFailed(
StoredReport $report,
OperationRun $operationRun,
OperationRunService $operationRunService,
WorkspaceAuditLogger $auditLogger,
string $code,
string $message,
?array $payload = null,
): void {
$report->update([
'status' => StoredReport::STATUS_FAILED,
'payload' => array_replace_recursive($payload ?? (is_array($report->payload) ? $report->payload : []), [
'state' => StoredReport::STATUS_FAILED,
'failure' => [
'code' => $code,
'message' => $message,
],
]),
]);
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => 1,
'processed' => 1,
'succeeded' => 0,
'failed' => 1,
],
failures: [
[
'code' => 'management_report_pdf.'.$code,
'message' => $message,
],
],
);
$this->audit($report->refresh(), $operationRun, $auditLogger, AuditActionId::ManagementReportPdfGenerationFailed, 'failed', $message, [
'failure_code' => $code,
]);
}
private function blockedReasonCode(ReviewPack $reviewPack, InvalidArgumentException $exception): string
{
try {
$decision = ManagementReportPdfPayloadBuilder::customerExecutiveDisclosureDecision($reviewPack);
if ((bool) ($decision['is_blocked'] ?? false) && filled($decision['reason_code'] ?? null)) {
return (string) $decision['reason_code'];
}
} catch (Throwable) {
// Fall through to message-based source blockers below.
}
$message = $exception->getMessage();
return match (true) {
str_contains($message, 'current review pack') => 'review_pack_not_current',
str_contains($message, 'tenant, workspace, and released review') => 'source_missing',
str_contains($message, 'customer executive profile') => 'management_report_pdf_profile_invalid',
str_contains($message, 'disclosure policy') => 'disclosure_blocked',
default => 'generation_blocked',
};
}
/**
* @param array<string, mixed>|null $payload
*/
private function markBlocked(
StoredReport $report,
OperationRun $operationRun,
OperationRunService $operationRunService,
WorkspaceAuditLogger $auditLogger,
string $code,
string $message,
?array $payload = null,
): void {
$report->update([
'status' => StoredReport::STATUS_FAILED,
'payload' => array_replace_recursive($payload ?? (is_array($report->payload) ? $report->payload : []), [
'state' => StoredReport::STATUS_FAILED,
'blocked' => true,
'failure' => [
'code' => $code,
'message' => $message,
],
]),
]);
$operationRunService->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Blocked->value,
summaryCounts: [
'total' => 1,
'processed' => 1,
'succeeded' => 0,
'failed' => 0,
],
failures: [
[
'code' => 'management_report_pdf.'.$code,
'reason_code' => $code,
'message' => $message,
],
],
);
$this->audit($report->refresh(), $operationRun, $auditLogger, AuditActionId::ManagementReportPdfGenerationBlocked, 'blocked', $message, [
'failure_code' => $code,
'reason_code' => $code,
]);
}
/**
* @param array<string, mixed> $extraContext
*/
private function audit(
StoredReport $report,
OperationRun $operationRun,
WorkspaceAuditLogger $auditLogger,
AuditActionId $action,
string $status,
string $summary,
array $extraContext = [],
): void {
$tenant = $report->tenant;
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$actor = $report->generatedBy;
$auditLogger->log(
workspace: $tenant->workspace,
action: $action,
context: array_replace_recursive([
'metadata' => [
'stored_report_id' => (int) $report->getKey(),
'review_pack_id' => $report->source_review_pack_id !== null ? (int) $report->source_review_pack_id : null,
'environment_review_id' => $report->source_environment_review_id !== null ? (int) $report->source_environment_review_id : null,
'profile' => (string) $report->profile,
'sha256' => $report->sha256,
],
], $extraContext),
actor: $actor instanceof User ? $actor : null,
status: $status,
resourceType: 'stored_report',
resourceId: (string) $report->getKey(),
targetLabel: sprintf('Management report PDF #%d', (int) $report->getKey()),
summary: $summary,
operationRunId: (int) $operationRun->getKey(),
tenant: $tenant,
);
}
private function correlationId(StoredReport $report, OperationRun $operationRun): string
{
return sprintf('management-report-pdf-%d-run-%d', (int) $report->getKey(), (int) $operationRun->getKey());
}
private function safePathSegment(string $value): string
{
$value = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($value)) ?? '';
return trim($value, '-') !== '' ? trim($value, '-') : 'unknown';
}
}