TenantAtlas/apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php
ahmido 61feb48d8a
Some checks failed
Main Confidence / confidence (push) Failing after 54s
chore(platform): merge platform-dev into dev (#308)
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
2026-04-30 07:52:08 +00:00

417 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\BaselineSubjectKey;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class CrossTenantComparePreviewBuilder
{
public function __construct(
private readonly CurrentStateHashResolver $currentStateHashResolver,
) {}
/**
* @return array{
* selection: array{
* workspaceId: int,
* sourceTenantId: int,
* sourceTenantName: string,
* targetTenantId: int,
* targetTenantName: string,
* policyTypes: list<string>
* },
* summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int},
* subjects: list<array<string, mixed>>
* }
*/
public function build(CrossTenantCompareSelection $selection): array
{
$sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes);
$targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes);
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']);
$targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']);
$subjects = [];
$summary = [
'match' => 0,
'different' => 0,
'missing' => 0,
'ambiguous' => 0,
'blocked' => 0,
'total' => 0,
];
foreach ($sourceIndex['preview_subjects'] as $sourceSubject) {
$previewSubject = $this->buildPreviewSubject(
sourceSubject: $sourceSubject,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
targetIndex: $targetIndex['subjects'],
sourceEvidence: $sourceEvidence,
targetEvidence: $targetEvidence,
);
$subjects[] = $previewSubject;
$summary[$previewSubject['state']]++;
$summary['total']++;
}
usort($subjects, function (array $left, array $right): int {
$policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? ''));
if ($policyTypeComparison !== 0) {
return $policyTypeComparison;
}
$displayNameComparison = strcmp(
Str::lower((string) ($left['displayName'] ?? '')),
Str::lower((string) ($right['displayName'] ?? '')),
);
if ($displayNameComparison !== 0) {
return $displayNameComparison;
}
return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? ''));
});
return [
'selection' => [
'workspaceId' => $selection->workspaceId(),
'sourceTenantId' => $selection->sourceTenantId(),
'sourceTenantName' => (string) $selection->sourceTenant->name,
'targetTenantId' => $selection->targetTenantId(),
'targetTenantName' => (string) $selection->targetTenant->name,
'policyTypes' => $selection->policyTypes,
],
'summary' => $summary,
'subjects' => $subjects,
];
}
/**
* @param Tenant $tenant
* @param list<string> $policyTypes
* @return array{
* preview_subjects: list<array<string, mixed>>,
* evidence_subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: array<string, array<string, mixed>>
* }
*/
private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array
{
$inventoryItems = InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->when(
$policyTypes !== [],
fn ($query) => $query->whereIn('policy_type', $policyTypes),
)
->orderBy('policy_type')
->orderBy('display_name')
->orderBy('id')
->get();
$subjects = [];
$previewSubjects = [];
$evidenceSubjects = [];
foreach ($inventoryItems as $inventoryItem) {
if (! $inventoryItem instanceof InventoryItem) {
continue;
}
$policyType = trim((string) $inventoryItem->policy_type);
$subjectKey = BaselineSubjectKey::forPolicy(
$policyType,
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
);
$subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey);
if ($subjectKey === null) {
$previewSubjects[] = [
...$subjectRecord,
'resolution' => 'identifier_missing',
'duplicateCount' => 1,
];
continue;
}
$indexKey = $this->subjectIndexKey($policyType, $subjectKey);
if (! array_key_exists($indexKey, $subjects)) {
$subjects[$indexKey] = [
'policyType' => $policyType,
'subjectKey' => $subjectKey,
'displayName' => $subjectRecord['displayName'],
'items' => [],
];
}
$subjects[$indexKey]['items'][] = $subjectRecord;
}
foreach ($subjects as $indexKey => $subjectGroup) {
$items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : [];
$firstItem = $items[0] ?? null;
if (! is_array($firstItem)) {
continue;
}
$previewSubjects[] = [
...$firstItem,
'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved',
'duplicateCount' => count($items),
];
if (count($items) === 1) {
$evidenceSubjects[] = [
'policy_type' => (string) $firstItem['policyType'],
'subject_external_id' => (string) $firstItem['subjectExternalId'],
];
}
$subjects[$indexKey]['representative'] = $firstItem;
$subjects[$indexKey]['duplicateCount'] = count($items);
}
return [
'preview_subjects' => $previewSubjects,
'evidence_subjects' => $evidenceSubjects,
'subjects' => $subjects,
];
}
/**
* @param array<string, array<string, mixed>> $targetIndex
* @param array<string, ResolvedEvidence|null> $sourceEvidence
* @param array<string, ResolvedEvidence|null> $targetEvidence
* @return array<string, mixed>
*/
private function buildPreviewSubject(
array $sourceSubject,
Tenant $sourceTenant,
Tenant $targetTenant,
array $targetIndex,
array $sourceEvidence,
array $targetEvidence,
): array {
$policyType = (string) ($sourceSubject['policyType'] ?? '');
$displayName = (string) ($sourceSubject['displayName'] ?? '');
$subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null;
$reasonCodes = [];
$state = 'blocked';
$trustLevel = 'unusable';
$sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject);
$targetEvidenceRecord = null;
$targetSubject = null;
if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') {
$reasonCodes[] = 'source_identifier_missing';
} elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'source_subject_ambiguous';
} elseif ($subjectKey !== null) {
$targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null;
if (! is_array($targetSubject)) {
$state = 'missing';
$trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence
&& $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
$reasonCodes[] = 'target_subject_missing';
} elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'target_subject_ambiguous';
} else {
$representative = $targetSubject['representative'] ?? null;
if (is_array($representative)) {
$targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative);
}
if (! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if (! $targetEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) {
$state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different';
$trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
&& $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
} else {
$state = 'blocked';
$trustLevel = 'unusable';
}
}
}
if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($state === 'blocked' && $reasonCodes === []) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
$reasonCodes = array_values(array_unique($reasonCodes));
return [
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'state' => $state,
'trustLevel' => $trustLevel,
'reasonCodes' => $reasonCodes,
'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord),
'target' => $this->subjectSidePayload(
$targetTenant,
is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null,
$targetEvidenceRecord,
),
];
}
/**
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @return array<string, ResolvedEvidence|null>
*/
private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array
{
return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects);
}
/**
* @param array<string, mixed>|null $subject
* @return array<string, mixed>
*/
private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array
{
return [
'tenantId' => (int) $tenant->getKey(),
'tenantName' => (string) $tenant->name,
'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null,
'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null,
'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null,
'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null,
'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null,
'evidence' => $this->evidencePayload($evidence),
];
}
/**
* @return array{
* policyType: string,
* displayName: string,
* subjectKey: ?string,
* inventoryItemId: int,
* subjectExternalId: string,
* lastSeenAt: ?string
* }
*/
private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array
{
$displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : '';
$displayName = $displayName !== ''
? $displayName
: ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType));
return [
'tenantId' => (int) $tenant->getKey(),
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'inventoryItemId' => (int) $inventoryItem->getKey(),
'subjectExternalId' => (string) $inventoryItem->external_id,
'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(),
];
}
/**
* @param array<string, ResolvedEvidence|null> $evidenceMap
*/
private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence
{
$policyType = trim((string) ($subject['policyType'] ?? ''));
$subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? ''));
if ($policyType === '' || $subjectExternalId === '') {
return null;
}
$key = $policyType.'|'.$subjectExternalId;
$evidence = $evidenceMap[$key] ?? null;
return $evidence instanceof ResolvedEvidence ? $evidence : null;
}
/**
* @return array{
* hash: string,
* fidelity: string,
* source: string,
* observedAt: ?string,
* policyVersionId: ?int,
* operationRunId: ?int,
* capturePurpose: ?string
* }|null
*/
private function evidencePayload(?ResolvedEvidence $evidence): ?array
{
if (! $evidence instanceof ResolvedEvidence) {
return null;
}
return [
'hash' => $evidence->hash,
'fidelity' => $evidence->fidelity,
'source' => $evidence->source,
'observedAt' => $evidence->observedAt?->toIso8601String(),
'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null)
? (int) $evidence->meta['policy_version_id']
: null,
'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null)
? (int) $evidence->meta['operation_run_id']
: null,
'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null)
? (string) $evidence->meta['capture_purpose']
: null,
];
}
private function subjectIndexKey(string $policyType, string $subjectKey): string
{
return $policyType.'|'.$subjectKey;
}
}