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
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';
|
|
}
|
|
}
|