, * subjects?: list> * } $preview * @return array{ * selection: array, * summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int}, * blockedReasonCounts: array, * buckets: array{ * ready: list>, * blocked: list>, * manual_mapping_required: list> * } * } */ public function build(array $preview): array { $subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : []; $buckets = [ 'ready' => [], 'blocked' => [], 'manual_mapping_required' => [], ]; $blockedReasonCounts = []; foreach ($subjects as $subject) { if (! is_array($subject)) { continue; } $decision = $this->classifySubject($subject); $subject['preflight'] = $decision; $buckets[$decision['bucket']][] = $subject; if ($decision['bucket'] !== 'ready') { foreach ($decision['reasonCodes'] as $reasonCode) { $blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1; } } } return [ 'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [], 'summary' => [ 'ready' => count($buckets['ready']), 'blocked' => count($buckets['blocked']), 'manual_mapping_required' => count($buckets['manual_mapping_required']), 'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']), ], 'blockedReasonCounts' => $blockedReasonCounts, 'buckets' => $buckets, ]; } /** * @param array $subject * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} */ private function classifySubject(array $subject): array { $state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked'; $reasonCodes = is_array($subject['reasonCodes'] ?? null) ? array_values(array_filter($subject['reasonCodes'], 'is_string')) : []; if (in_array('source_identifier_missing', $reasonCodes, true)) { return $this->decision('blocked', ['source_identifier_missing']); } if (in_array('source_subject_ambiguous', $reasonCodes, true)) { return $this->decision('blocked', ['source_subject_ambiguous']); } if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') { return $this->decision('manual_mapping_required', ['target_subject_ambiguous']); } $sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null; $targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null; if (! $this->evidenceSupportsPromotion($sourceEvidence)) { return $this->decision('blocked', ['source_evidence_refresh_required']); } if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) { return $this->decision('blocked', ['target_evidence_refresh_required']); } return match ($state) { 'match' => $this->decision('ready', ['target_already_aligned']), 'different' => $this->decision('ready', ['target_subject_requires_update']), 'missing' => $this->decision('ready', ['target_subject_missing']), default => $this->decision('blocked', ['source_evidence_refresh_required']), }; } /** * @param array|null $evidence */ private function evidenceSupportsPromotion(?array $evidence): bool { return is_array($evidence) && is_string($evidence['fidelity'] ?? null) && (string) $evidence['fidelity'] === 'content'; } /** * @param list $reasonCodes * @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list, reasonLabels: list} */ private function decision(string $bucket, array $reasonCodes): array { return [ 'bucket' => $bucket, 'reasonCodes' => $reasonCodes, 'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes), ]; } private function reasonLabel(string $reasonCode): string { return match ($reasonCode) { 'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.', 'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.', 'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.', 'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.', 'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.', 'target_already_aligned' => 'Target tenant already matches the source for this subject.', 'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.', 'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.', default => 'This subject needs additional review before promotion planning can continue.', }; } }