TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationStepPlanner.php
ahmido ba7622a158 feat: implement ReviewPublicationResolutionWorkflow (Spec 386) (#457)
## Summary\n- Implements the ReviewPublicationResolutionWorkflow for Spec 386.\n- Adds resolution case/step persistence, policies, services, audit action IDs, and Filament integration.\n- Updates specs, UI/UX documentation, screenshots, and Pest coverage.\n\n## Tests\n- Not run during this handoff; branch was already clean and pushed.\n\n## Target\n- Base: platform-dev\n- Head/topic: 386-review-publication-resolution-workflow-v1

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #457
2026-06-18 21:06:20 +00:00

289 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Models\EnvironmentReview;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Support\Collection;
final class ReviewPublicationStepPlanner
{
public function __construct(
private readonly ReviewPublicationProofResolver $proofResolver,
) {}
/**
* @param array<string, mixed> $readiness
* @return array{
* steps:list<array<string, mixed>>,
* case_status:ReviewPublicationResolutionCaseStatus,
* current_step_key:?string
* }
*/
public function plan(EnvironmentReview $review, array $readiness, ?ReviewPublicationResolutionCase $case = null): array
{
$existing = $case instanceof ReviewPublicationResolutionCase
? $case->loadMissing(['steps.operationRun'])->steps->keyBy('step_key')
: new Collection;
$steps = [];
foreach ($this->relevantStepKeys($readiness, $existing) as $index => $stepKey) {
$steps[] = $this->stepPlan(
review: $review,
readiness: $readiness,
stepKey: $stepKey,
position: $index + 1,
existingStep: $existing->get($stepKey->value),
);
}
$steps = $this->activateFirstIncompleteStep($steps);
$currentStep = collect($steps)->first(
static fn (array $step): bool => in_array($step['status'], [
ReviewPublicationResolutionStepStatus::Actionable->value,
ReviewPublicationResolutionStepStatus::Running->value,
ReviewPublicationResolutionStepStatus::Failed->value,
], true),
);
$caseStatus = $this->caseStatus($steps, is_array($currentStep) ? (string) $currentStep['step_key'] : null);
return [
'steps' => $steps,
'case_status' => $caseStatus,
'current_step_key' => is_array($currentStep) ? (string) $currentStep['step_key'] : null,
];
}
/**
* @param array<string, mixed> $readiness
* @return array<string, mixed>
*/
private function stepPlan(
EnvironmentReview $review,
array $readiness,
ReviewPublicationResolutionStepKey $stepKey,
int $position,
?ReviewPublicationResolutionStep $existingStep,
): array {
$status = $this->baseStatus($stepKey, $readiness, $existingStep);
$proof = $this->proofResolver->proofFor($stepKey, $review);
if ($existingStep instanceof ReviewPublicationResolutionStep
&& in_array($status, [
ReviewPublicationResolutionStepStatus::Running,
ReviewPublicationResolutionStepStatus::Failed,
], true)) {
$proof = [
'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'],
'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'],
'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'],
'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'],
];
}
return [
'position' => $position,
'step_key' => $stepKey->value,
'status' => $status->value,
'primary_action_key' => $stepKey->primaryActionKey(),
'operation_run_id' => $proof['operation_run_id'],
'proof_type' => $proof['proof_type'],
'proof_id' => $proof['proof_id'],
'proof_status' => $proof['proof_status'],
'summary' => $this->summary($stepKey, $readiness, $status),
'metadata' => [
'readiness_fingerprint' => (string) $readiness['fingerprint'],
'planned_at' => now()->toIso8601String(),
],
];
}
/**
* @param array<string, mixed> $readiness
*/
private function baseStatus(
ReviewPublicationResolutionStepKey $stepKey,
array $readiness,
?ReviewPublicationResolutionStep $existingStep,
): ReviewPublicationResolutionStepStatus {
$readinessStatus = $this->readinessStatus($stepKey, $readiness);
if ($readinessStatus === ReviewPublicationResolutionStepStatus::Completed) {
return ReviewPublicationResolutionStepStatus::Completed;
}
if ($existingStep instanceof ReviewPublicationResolutionStep) {
if ($stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication
&& $existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Completed) {
return ReviewPublicationResolutionStepStatus::Completed;
}
if ($existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Running && $existingStep->operationRun?->status !== OperationRunStatus::Completed->value) {
return ReviewPublicationResolutionStepStatus::Running;
}
if ($existingStep->operationRun?->status === OperationRunStatus::Completed->value
&& in_array((string) $existingStep->operationRun->outcome, [OperationRunOutcome::Failed->value, OperationRunOutcome::Blocked->value], true)) {
return ReviewPublicationResolutionStepStatus::Failed;
}
}
return $readinessStatus;
}
/**
* @param Collection<string, ReviewPublicationResolutionStep> $existing
* @return list<ReviewPublicationResolutionStepKey>
*/
private function relevantStepKeys(array $readiness, Collection $existing): array
{
$keys = [
ReviewPublicationResolutionStepKey::ValidateReviewReadiness,
];
if (((array) ($readiness['missing_report_dimensions'] ?? [])) !== []
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CompleteRequiredReports)) {
$keys[] = ReviewPublicationResolutionStepKey::CompleteRequiredReports;
}
if ((bool) ($readiness['evidence_incomplete'] ?? true)
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot)) {
$keys[] = ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot;
}
if ((bool) ($readiness['review_requires_refresh'] ?? true)
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::RefreshReviewComposition)) {
$keys[] = ReviewPublicationResolutionStepKey::RefreshReviewComposition;
}
if (! (bool) ($readiness['has_ready_export'] ?? false)
|| $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::GenerateReviewPack)) {
$keys[] = ReviewPublicationResolutionStepKey::GenerateReviewPack;
}
$keys[] = ReviewPublicationResolutionStepKey::ReturnToPublication;
return $keys;
}
/**
* @param Collection<string, ReviewPublicationResolutionStep> $existing
*/
private function hasExistingStep(Collection $existing, ReviewPublicationResolutionStepKey $stepKey): bool
{
$step = $existing->get($stepKey->value);
return $step instanceof ReviewPublicationResolutionStep
&& $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Superseded;
}
/**
* @param array<string, mixed> $readiness
*/
private function readinessStatus(
ReviewPublicationResolutionStepKey $stepKey,
array $readiness,
): ReviewPublicationResolutionStepStatus {
return match ($stepKey) {
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => ReviewPublicationResolutionStepStatus::Completed,
ReviewPublicationResolutionStepKey::CompleteRequiredReports => ((array) ($readiness['missing_report_dimensions'] ?? [])) === []
? ReviewPublicationResolutionStepStatus::Completed
: ReviewPublicationResolutionStepStatus::Pending,
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => (bool) ($readiness['evidence_incomplete'] ?? true)
? ReviewPublicationResolutionStepStatus::Pending
: ReviewPublicationResolutionStepStatus::Completed,
ReviewPublicationResolutionStepKey::RefreshReviewComposition => (bool) ($readiness['review_requires_refresh'] ?? true)
? ReviewPublicationResolutionStepStatus::Pending
: ReviewPublicationResolutionStepStatus::Completed,
ReviewPublicationResolutionStepKey::GenerateReviewPack => (bool) ($readiness['has_ready_export'] ?? false)
? ReviewPublicationResolutionStepStatus::Completed
: ReviewPublicationResolutionStepStatus::Pending,
ReviewPublicationResolutionStepKey::ReturnToPublication => ReviewPublicationResolutionStepStatus::Pending,
};
}
/**
* @param list<array<string, mixed>> $steps
* @return list<array<string, mixed>>
*/
private function activateFirstIncompleteStep(array $steps): array
{
foreach ($steps as $index => $step) {
if ($step['status'] !== ReviewPublicationResolutionStepStatus::Pending->value) {
continue;
}
$steps[$index]['status'] = ReviewPublicationResolutionStepStatus::Actionable->value;
$steps[$index]['summary']['state_description'] = 'Ready for operator action.';
break;
}
return $steps;
}
/**
* @param list<array<string, mixed>> $steps
*/
private function caseStatus(array $steps, ?string $currentStepKey): ReviewPublicationResolutionCaseStatus
{
if ($currentStepKey === null) {
return ReviewPublicationResolutionCaseStatus::Completed;
}
$currentStep = collect($steps)->firstWhere('step_key', $currentStepKey);
if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Running->value) {
return ReviewPublicationResolutionCaseStatus::WaitingForRun;
}
if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Failed->value) {
return ReviewPublicationResolutionCaseStatus::Blocked;
}
if ($currentStepKey === ReviewPublicationResolutionStepKey::ReturnToPublication->value) {
return ReviewPublicationResolutionCaseStatus::ReadyToContinue;
}
return ReviewPublicationResolutionCaseStatus::InProgress;
}
/**
* @param array<string, mixed> $readiness
* @return array<string, mixed>
*/
private function summary(
ReviewPublicationResolutionStepKey $stepKey,
array $readiness,
ReviewPublicationResolutionStepStatus $status,
): array {
return [
'label' => $stepKey->label(),
'description' => match ($stepKey) {
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Review readiness has been evaluated from current evidence and section state.',
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required report-backed evidence dimensions must be current before publication can continue.',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The evidence snapshot must be complete and current for the review output.',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review sections must be recomposed from current evidence before publication.',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'A current review pack is required before returning to publication.',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review publication action after blockers are resolved.',
},
'state_description' => $status === ReviewPublicationResolutionStepStatus::Completed
? 'Requirement is satisfied.'
: 'Waiting for prerequisite steps.',
'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])),
'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])),
'evidence_state' => (string) ($readiness['evidence_state'] ?? ''),
'review_status' => (string) ($readiness['review_status'] ?? ''),
'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false),
];
}
}