251 lines
9.4 KiB
PHP
251 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantReviews;
|
|
|
|
use App\Jobs\ComposeTenantReviewJob;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\TenantReviewStatus;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
final class TenantReviewService
|
|
{
|
|
public function __construct(
|
|
private readonly OperationRunService $operationRuns,
|
|
private readonly WorkspaceAuditLogger $auditLogger,
|
|
private readonly TenantReviewComposer $composer,
|
|
private readonly TenantReviewFingerprint $fingerprint,
|
|
) {}
|
|
|
|
public function create(Tenant $tenant, EvidenceSnapshot $snapshot, User $user): TenantReview
|
|
{
|
|
return $this->queueComposition(
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
user: $user,
|
|
existingReview: null,
|
|
auditAction: AuditActionId::TenantReviewCreated,
|
|
);
|
|
}
|
|
|
|
public function refresh(TenantReview $review, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
|
|
{
|
|
$tenant = $review->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
throw new InvalidArgumentException('Review tenant could not be resolved.');
|
|
}
|
|
|
|
$snapshot ??= $this->resolveLatestSnapshot($tenant) ?? $review->evidenceSnapshot;
|
|
|
|
return $this->queueComposition(
|
|
tenant: $tenant,
|
|
snapshot: $snapshot,
|
|
user: $user,
|
|
existingReview: $review,
|
|
auditAction: AuditActionId::TenantReviewRefreshed,
|
|
);
|
|
}
|
|
|
|
public function compose(TenantReview $review): TenantReview
|
|
{
|
|
$review->loadMissing(['tenant', 'evidenceSnapshot.items']);
|
|
|
|
$snapshot = $review->evidenceSnapshot;
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
throw new InvalidArgumentException('Review evidence snapshot could not be resolved.');
|
|
}
|
|
|
|
$payload = $this->composer->compose($snapshot, $review);
|
|
|
|
DB::transaction(function () use ($review, $payload, $snapshot): void {
|
|
$review->forceFill([
|
|
'fingerprint' => $payload['fingerprint'],
|
|
'completeness_state' => $payload['completeness_state'],
|
|
'status' => $payload['status'],
|
|
'summary' => $payload['summary'],
|
|
'generated_at' => now(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
])->save();
|
|
|
|
$review->sections()->delete();
|
|
|
|
foreach ($payload['sections'] as $section) {
|
|
$review->sections()->create([
|
|
'workspace_id' => (int) $review->workspace_id,
|
|
'tenant_id' => (int) $review->tenant_id,
|
|
'section_key' => $section['section_key'],
|
|
'title' => $section['title'],
|
|
'sort_order' => $section['sort_order'],
|
|
'required' => $section['required'],
|
|
'completeness_state' => $section['completeness_state'],
|
|
'source_snapshot_fingerprint' => $section['source_snapshot_fingerprint'],
|
|
'summary_payload' => $section['summary_payload'],
|
|
'render_payload' => $section['render_payload'],
|
|
'measured_at' => $section['measured_at'],
|
|
]);
|
|
}
|
|
});
|
|
|
|
return $review->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack']);
|
|
}
|
|
|
|
public function resolveLatestSnapshot(Tenant $tenant): ?EvidenceSnapshot
|
|
{
|
|
return EvidenceSnapshot::query()
|
|
->forTenant((int) $tenant->getKey())
|
|
->current()
|
|
->latest('generated_at')
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
|
|
public function activeCompositionRun(Tenant $tenant, ?EvidenceSnapshot $snapshot = null): ?OperationRun
|
|
{
|
|
$snapshot ??= $this->resolveLatestSnapshot($tenant);
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
return null;
|
|
}
|
|
|
|
return $this->operationRuns->findCanonicalRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::TenantReviewCompose->value,
|
|
identityInputs: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
|
|
],
|
|
);
|
|
}
|
|
|
|
private function queueComposition(
|
|
Tenant $tenant,
|
|
?EvidenceSnapshot $snapshot,
|
|
User $user,
|
|
?TenantReview $existingReview,
|
|
AuditActionId $auditAction,
|
|
): TenantReview {
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
throw new InvalidArgumentException('An eligible evidence snapshot is required.');
|
|
}
|
|
|
|
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
|
throw new InvalidArgumentException('Evidence snapshot does not belong to the target tenant.');
|
|
}
|
|
|
|
$fingerprint = $this->fingerprint->forSnapshot($tenant, $snapshot);
|
|
$review = $existingReview;
|
|
|
|
if (! $review instanceof TenantReview) {
|
|
$existing = $this->findExistingMutableReview($tenant, $fingerprint);
|
|
|
|
if ($existing instanceof TenantReview) {
|
|
return $existing->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
|
}
|
|
}
|
|
|
|
$operationRun = $this->operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: OperationRunType::TenantReviewCompose->value,
|
|
identityInputs: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
],
|
|
context: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'review_fingerprint' => $fingerprint,
|
|
'review_id' => $existingReview?->getKey(),
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
$review ??= TenantReview::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
'status' => TenantReviewStatus::Draft->value,
|
|
'completeness_state' => (string) $snapshot->completeness_state,
|
|
'summary' => [
|
|
'evidence_basis' => [
|
|
'snapshot_id' => (int) $snapshot->getKey(),
|
|
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
|
|
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
],
|
|
'publish_blockers' => [],
|
|
'has_ready_export' => false,
|
|
'last_requested_at' => now()->toIso8601String(),
|
|
],
|
|
]);
|
|
|
|
if ($existingReview instanceof TenantReview) {
|
|
$existingReview->forceFill([
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'fingerprint' => $fingerprint,
|
|
'status' => TenantReviewStatus::Draft->value,
|
|
])->save();
|
|
|
|
$review = $existingReview->refresh();
|
|
}
|
|
|
|
if ($operationRun->wasRecentlyCreated) {
|
|
$this->operationRuns->dispatchOrFail($operationRun, function () use ($review, $operationRun): void {
|
|
ComposeTenantReviewJob::dispatch(
|
|
tenantReviewId: (int) $review->getKey(),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
);
|
|
});
|
|
}
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $tenant->workspace,
|
|
action: $auditAction,
|
|
context: [
|
|
'metadata' => [
|
|
'review_id' => (int) $review->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'status' => (string) $review->status,
|
|
'fingerprint' => $fingerprint,
|
|
],
|
|
],
|
|
actor: $user,
|
|
resourceType: 'tenant_review',
|
|
resourceId: (string) $review->getKey(),
|
|
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
tenant: $tenant,
|
|
);
|
|
|
|
return $review->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
|
}
|
|
|
|
private function findExistingMutableReview(Tenant $tenant, string $fingerprint): ?TenantReview
|
|
{
|
|
return TenantReview::query()
|
|
->forTenant((int) $tenant->getKey())
|
|
->mutable()
|
|
->where('fingerprint', $fingerprint)
|
|
->latest('id')
|
|
->first();
|
|
}
|
|
}
|