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.
389 lines
13 KiB
PHP
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';
|
|
}
|
|
}
|