$preview * @param array $preflight * @return array{ * selection: array, * summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int}, * items: list>, * excluded: list>, * identity: array * } */ 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 $payload * @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list} */ 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 $preflight * @return list> */ 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 $preflight * @return list> */ 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 $subject * @return array|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 $subject * @return array */ 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> $items * @return list> */ 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 $selection * @param list> $items * @return array */ 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 $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 */ 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 !== '')); } }