TenantAtlas/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php
Ahmed Darrazi 983abb18a1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m22s
chore: commit workspace changes (automated)
2026-05-02 16:36:21 +02:00

272 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use DomainException;
use InvalidArgumentException;
final class CrossTenantPromotionExecutionPlanner
{
/**
* @param array<string, mixed> $preview
* @param array<string, mixed> $preflight
* @return array{
* selection: array<string, mixed>,
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
* items: list<array<string, mixed>>,
* excluded: list<array<string, mixed>>,
* identity: array<string, mixed>
* }
*/
public function build(array $preview, array $preflight): array
{
$previewSelection = $this->selection($preview);
$preflightSelection = $this->selection($preflight);
if ($previewSelection !== $preflightSelection) {
throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.');
}
$items = [];
$excluded = $this->excludedSubjects($preflight);
foreach ($this->readySubjects($preflight) as $subject) {
$item = $this->executionItem($subject);
if ($item === null) {
$excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing');
continue;
}
$items[] = $item;
}
$items = $this->sortItems($items);
$excluded = $this->sortItems($excluded);
if ($items === []) {
throw new DomainException('Promotion preflight has no executable ready subjects.');
}
$summary = [
'total' => count($items) + count($excluded),
'ready' => count($items),
'excluded' => count($excluded),
'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')),
'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')),
'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')),
];
return [
'selection' => $previewSelection,
'summary' => $summary,
'items' => $items,
'excluded' => $excluded,
'identity' => $this->identity($previewSelection, $items),
];
}
/**
* @param array<string, mixed> $payload
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
*/
private function selection(array $payload): array
{
$selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : [];
$policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : [];
$policyTypes = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
$policyTypes,
), static fn (string $value): bool => $value !== '')));
sort($policyTypes);
return [
'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null,
'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null,
'policyTypes' => $policyTypes,
];
}
/**
* @param array<string, mixed> $preflight
* @return list<array<string, mixed>>
*/
private function readySubjects(array $preflight): array
{
$subjects = data_get($preflight, 'buckets.ready', []);
if (! is_array($subjects)) {
return [];
}
return array_values(array_filter($subjects, 'is_array'));
}
/**
* @param array<string, mixed> $preflight
* @return list<array<string, mixed>>
*/
private function excludedSubjects(array $preflight): array
{
$excluded = [];
foreach (['blocked', 'manual_mapping_required'] as $bucket) {
$subjects = data_get($preflight, 'buckets.'.$bucket, []);
if (! is_array($subjects)) {
continue;
}
foreach ($subjects as $subject) {
if (! is_array($subject)) {
continue;
}
$excluded[] = $this->excludedSubject($subject, $bucket);
}
}
return $excluded;
}
/**
* @param array<string, mixed> $subject
* @return array<string, mixed>|null
*/
private function executionItem(array $subject): ?array
{
$policyVersionId = data_get($subject, 'source.evidence.policyVersionId');
if (! is_numeric($policyVersionId)) {
return null;
}
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
$action = match ($state) {
'match' => 'skip_aligned',
'missing' => 'create_missing',
default => 'update_existing',
};
return [
'policy_type' => $this->stringValue($subject, 'policyType'),
'display_name' => $this->stringValue($subject, 'displayName'),
'subject_key' => $this->stringValue($subject, 'subjectKey'),
'compare_state' => $state,
'execution_action' => $action,
'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
'source' => [
'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')),
'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')),
'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')),
'policy_version_id' => (int) $policyVersionId,
'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')),
],
'target' => [
'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')),
'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')),
'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')),
],
];
}
/**
* @param array<string, mixed> $subject
* @return array<string, mixed>
*/
private function excludedSubject(array $subject, string $reason): array
{
return [
'policy_type' => $this->stringValue($subject, 'policyType'),
'display_name' => $this->stringValue($subject, 'displayName'),
'subject_key' => $this->stringValue($subject, 'subjectKey'),
'compare_state' => $this->stringValue($subject, 'state'),
'excluded_reason' => $reason,
'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
];
}
/**
* @param list<array<string, mixed>> $items
* @return list<array<string, mixed>>
*/
private function sortItems(array $items): array
{
usort($items, static function (array $left, array $right): int {
return [
(string) ($left['policy_type'] ?? ''),
(string) ($left['subject_key'] ?? ''),
(string) ($left['display_name'] ?? ''),
] <=> [
(string) ($right['policy_type'] ?? ''),
(string) ($right['subject_key'] ?? ''),
(string) ($right['display_name'] ?? ''),
];
});
return $items;
}
/**
* @param array<string, mixed> $selection
* @param list<array<string, mixed>> $items
* @return array<string, mixed>
*/
private function identity(array $selection, array $items): array
{
return [
'source_tenant_id' => $selection['sourceTenantId'] ?? null,
'target_tenant_id' => $selection['targetTenantId'] ?? null,
'policy_types' => $selection['policyTypes'] ?? [],
'subjects' => array_map(static fn (array $item): array => [
'policy_type' => $item['policy_type'] ?? '',
'subject_key' => $item['subject_key'] ?? '',
'source_policy_version_id' => data_get($item, 'source.policy_version_id'),
'source_evidence_hash' => data_get($item, 'source.evidence_hash'),
'target_subject_external_id' => data_get($item, 'target.subject_external_id'),
'execution_action' => $item['execution_action'] ?? '',
], $items),
];
}
/**
* @param array<string, mixed> $subject
*/
private function stringValue(array $subject, string $key): string
{
$value = $subject[$key] ?? null;
return is_string($value) ? $value : '';
}
private function nullableString(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
/**
* @return list<string>
*/
private function stringList(mixed $values): array
{
if (! is_array($values)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
$values,
), static fn (string $value): bool => $value !== ''));
}
}