TenantAtlas/app/Support/OpsUx/AssignmentJobFingerprint.php
ahmido 03127a670b Spec 096: Ops polish (assignment summaries + dedupe + reconcile tracking + seed DX) (#115)
Implements Spec 096 ops polish bundle:

- Persist durable OperationRun.summary_counts for assignment fetch/restore (final attempt wins)
- Server-side dedupe for assignment jobs (15-minute cooldown + non-canonical skip)
- Track ReconcileAdapterRunsJob via workspace-scoped OperationRun + stable failure codes + overlap prevention
- Seed DX: ensure seeded tenants use UUID v4 external_id and seed satisfies workspace_id NOT NULL constraints

Verification (local / evidence-based):
- `vendor/bin/sail artisan test --compact tests/Feature/Operations/AssignmentRunSummaryCountsTest.php tests/Feature/Operations/AssignmentJobDedupeTest.php tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php tests/Feature/Seed/PoliciesSeederExternalIdTest.php`
- `vendor/bin/sail bin pint --dirty`

Spec artifacts included under `specs/096-ops-polish-assignment-dedupe-system-tracking/` (spec/plan/tasks/checklists).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #115
2026-02-15 20:49:38 +00:00

140 lines
4.9 KiB
PHP

<?php
namespace App\Support\OpsUx;
final class AssignmentJobFingerprint
{
public static function forFetch(
int $backupItemId,
string $tenantExternalId,
string $policyExternalId,
): string {
return self::hash('assignments.fetch', [
'backup_item_id' => $backupItemId,
'tenant_external_id' => trim($tenantExternalId),
'policy_external_id' => trim($policyExternalId),
]);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @param array<string, mixed> $groupMapping
* @param array<string, mixed> $foundationMapping
*/
public static function forRestore(
int $restoreRunId,
int $tenantId,
string $policyType,
string $policyId,
array $assignments,
array $groupMapping,
array $foundationMapping = [],
): string {
return self::hash('assignments.restore', [
'restore_run_id' => $restoreRunId,
'tenant_id' => $tenantId,
'policy_type' => trim($policyType),
'policy_id' => trim($policyId),
'assignments' => self::normalizeAssignments($assignments),
'group_mapping' => $groupMapping,
'foundation_mapping' => $foundationMapping,
]);
}
public static function executionIdentityKey(
string $jobType,
int $tenantId,
string $fingerprint,
?int $operationRunId = null,
): string {
if (is_int($operationRunId) && $operationRunId > 0) {
return 'operation_run:'.$operationRunId;
}
return 'tenant:'.$tenantId
.'|job_type:'.trim($jobType)
.'|fingerprint:'.trim($fingerprint);
}
/**
* @param array<string, mixed> $identityInputs
*/
private static function hash(string $jobType, array $identityInputs): string
{
$payload = [
'job_type' => trim($jobType),
'identity' => self::normalize($identityInputs),
];
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return hash('sha256', (string) $json);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, array<string, mixed>>
*/
private static function normalizeAssignments(array $assignments): array
{
$normalized = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = is_array($assignment['target'] ?? null) ? $assignment['target'] : [];
$normalized[] = [
'id' => is_scalar($assignment['id'] ?? null) ? (string) $assignment['id'] : '',
'target_type' => is_scalar($target['@odata.type'] ?? null) ? (string) $target['@odata.type'] : '',
'group_id' => is_scalar($target['groupId'] ?? null) ? (string) $target['groupId'] : '',
'assignment_filter_id' => is_scalar($assignment['deviceAndAppManagementAssignmentFilterId'] ?? null)
? (string) $assignment['deviceAndAppManagementAssignmentFilterId']
: (is_scalar($target['deviceAndAppManagementAssignmentFilterId'] ?? null) ? (string) $target['deviceAndAppManagementAssignmentFilterId'] : ''),
'assignment_filter_type' => is_scalar($assignment['deviceAndAppManagementAssignmentFilterType'] ?? null)
? (string) $assignment['deviceAndAppManagementAssignmentFilterType']
: (is_scalar($target['deviceAndAppManagementAssignmentFilterType'] ?? null) ? (string) $target['deviceAndAppManagementAssignmentFilterType'] : ''),
];
}
usort($normalized, static function (array $left, array $right): int {
$leftJson = json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$rightJson = json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return strcmp((string) $leftJson, (string) $rightJson);
});
return $normalized;
}
private static function normalize(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
$items = array_map(static fn (mixed $item): mixed => self::normalize($item), $value);
usort($items, static function (mixed $left, mixed $right): int {
$leftJson = json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$rightJson = json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return strcmp((string) $leftJson, (string) $rightJson);
});
return array_values($items);
}
ksort($value);
foreach ($value as $key => $item) {
$value[$key] = self::normalize($item);
}
return $value;
}
}