|null $selectedItemIds * @return array{summary: array, diffs: array>} */ public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array { if ($backupSet->tenant_id !== $tenant->id) { throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.'); } if ($selectedItemIds === []) { $selectedItemIds = null; } $items = $this->loadItems($backupSet, $selectedItemIds); $policyItems = $items ->reject(fn (BackupItem $item): bool => $item->isFoundation()) ->values(); $policyIds = $policyItems ->pluck('policy_id') ->filter() ->unique() ->values() ->all(); $latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds); $maxDetailedDiffs = 25; $maxEntriesPerSection = 200; $policiesChanged = 0; $assignmentsChanged = 0; $scopeTagsChanged = 0; $diffs = []; $diffsOmitted = 0; foreach ($policyItems as $index => $item) { $policyId = $item->policy_id ? (int) $item->policy_id : null; $currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null; $currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : []; $backupSnapshot = is_array($item->payload) ? $item->payload : []; $policyType = (string) ($item->policy_type ?? ''); $platform = $item->platform; $from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform); $to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform); $diff = $this->versionDiff->compare($from, $to); $summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0]; $hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0; if ($hasPolicyChanges) { $policiesChanged++; } $assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments); if ($assignmentDiff) { $assignmentsChanged++; } $scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion); if ($scopeTagDiff) { $scopeTagsChanged++; } $diffEntry = [ 'backup_item_id' => $item->id, 'display_name' => $item->resolvedDisplayName(), 'policy_identifier' => $item->policy_identifier, 'policy_type' => $policyType, 'platform' => $platform, 'action' => $currentVersion ? 'update' : 'create', 'diff' => [ 'summary' => $summary, 'added' => [], 'removed' => [], 'changed' => [], ], 'assignments_changed' => $assignmentDiff, 'scope_tags_changed' => $scopeTagDiff, 'diff_omitted' => false, 'diff_truncated' => false, ]; if ($index >= $maxDetailedDiffs) { $diffEntry['diff_omitted'] = true; $diffEntry['diff_truncated'] = true; $diffEntry['diff'] = [ 'summary' => $summary, ]; $diffsOmitted++; $diffs[] = $diffEntry; continue; } $added = is_array($diff['added'] ?? null) ? $diff['added'] : []; $removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : []; $changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : []; $diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection || count($removed) > $maxEntriesPerSection || count($changed) > $maxEntriesPerSection; $diffEntry['diff'] = [ 'summary' => $summary, 'added' => array_slice($added, 0, $maxEntriesPerSection, true), 'removed' => array_slice($removed, 0, $maxEntriesPerSection, true), 'changed' => array_slice($changed, 0, $maxEntriesPerSection, true), ]; $diffs[] = $diffEntry; } return [ 'summary' => [ 'generated_at' => CarbonImmutable::now()->toIso8601String(), 'policies_total' => $policyItems->count(), 'policies_changed' => $policiesChanged, 'assignments_changed' => $assignmentsChanged, 'scope_tags_changed' => $scopeTagsChanged, 'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs), 'diffs_omitted' => $diffsOmitted, 'limits' => [ 'max_detailed_diffs' => $maxDetailedDiffs, 'max_entries_per_section' => $maxEntriesPerSection, ], ], 'diffs' => $diffs, ]; } /** * @param array|null $selectedItemIds * @return Collection */ private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection { $query = $backupSet->items()->getQuery(); if ($selectedItemIds !== null) { $query->whereIn('id', $selectedItemIds); } return $query->orderBy('id')->get(); } /** * @param array $policyIds * @return array */ private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array { if ($policyIds === []) { return []; } $latestVersionsQuery = PolicyVersion::query() ->where('tenant_id', $tenant->id) ->whereIn('policy_id', $policyIds) ->selectRaw('policy_id, max(version_number) as version_number') ->groupBy('policy_id'); return PolicyVersion::query() ->where('tenant_id', $tenant->id) ->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void { $join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id') ->on('policy_versions.version_number', '=', 'latest_versions.version_number'); }) ->get() ->keyBy('policy_id') ->all(); } private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool { $backup = $this->normalizeAssignments($backupAssignments); $current = $this->normalizeAssignments($currentAssignments); return $backup !== $current; } private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool { $backupIds = $backupItem->scope_tag_ids; $backupIds = is_array($backupIds) ? $backupIds : []; $backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0')); sort($backupIds); $scopeTags = $currentVersion?->scope_tags; $currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : []; $currentIds = is_array($currentIds) ? $currentIds : []; $currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0')); sort($currentIds); return $backupIds !== $currentIds; } /** * @return array> */ private function normalizeAssignments(?array $assignments): array { $assignments = is_array($assignments) ? $assignments : []; $normalized = []; foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } $normalized[] = $assignment; } usort($normalized, function (array $a, array $b): int { $left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; $right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; return $left <=> $right; }); return $normalized; } }