Some checks failed
Main Confidence / confidence (push) Failing after 54s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`. Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request. This PR was created by agent on user request; do not merge automatically. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #308
144 lines
6.0 KiB
PHP
144 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\PortfolioCompare;
|
|
|
|
final class CrossTenantPromotionPreflight
|
|
{
|
|
/**
|
|
* @param array{
|
|
* selection?: array<string, mixed>,
|
|
* subjects?: list<array<string, mixed>>
|
|
* } $preview
|
|
* @return array{
|
|
* selection: array<string, mixed>,
|
|
* summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int},
|
|
* blockedReasonCounts: array<string, int>,
|
|
* buckets: array{
|
|
* ready: list<array<string, mixed>>,
|
|
* blocked: list<array<string, mixed>>,
|
|
* manual_mapping_required: list<array<string, mixed>>
|
|
* }
|
|
* }
|
|
*/
|
|
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<string, mixed> $subject
|
|
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
|
|
*/
|
|
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<string, mixed>|null $evidence
|
|
*/
|
|
private function evidenceSupportsPromotion(?array $evidence): bool
|
|
{
|
|
return is_array($evidence)
|
|
&& is_string($evidence['fidelity'] ?? null)
|
|
&& (string) $evidence['fidelity'] === 'content';
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $reasonCodes
|
|
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
|
|
*/
|
|
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.',
|
|
};
|
|
}
|
|
}
|