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