113 lines
4.7 KiB
PHP
113 lines
4.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Str;
|
|
|
|
/** Identities and mutation history inside the existing workflow JSON, never parallel tables. */
|
|
class OpportunityCanonicalMetadata
|
|
{
|
|
/** @param array<string,mixed> $payload
|
|
* @return array<string,array<string,mixed>> */
|
|
private function items(array $payload, string $path = ''): array
|
|
{
|
|
$found = [];
|
|
foreach ($payload as $key => $value) {
|
|
$pointer = ltrim($path.'.'.$key, '.');
|
|
if (! is_array($value)) {
|
|
continue;
|
|
}
|
|
if (! array_is_list($value)) {
|
|
$found[$pointer] = $value;
|
|
}
|
|
$found += $this->items($value, $pointer);
|
|
}
|
|
|
|
return $found;
|
|
}
|
|
|
|
/** @param array<string,mixed> $before
|
|
* @param array<string,mixed> $after
|
|
* @param array<string,mixed> $metadata
|
|
* @return array<string,mixed> */
|
|
public function mutate(array $before, array $after, array $metadata, User $actor, string $reason): array
|
|
{
|
|
$old = $this->items($before);
|
|
$new = $this->items($after);
|
|
$active = $metadata['active'] ?? [];
|
|
$retired = $metadata['deleted'] ?? [];
|
|
$next = [];
|
|
$used = [];
|
|
foreach ($new as $path => $value) {
|
|
$fingerprint = OpportunityCanonicalValue::hash($value);
|
|
$match = null;
|
|
// A collection reorder preserves identity by bytes, not by its new index.
|
|
foreach ($active as $previousPath => $item) {
|
|
if (! isset($used[$previousPath]) && ($item['fingerprint'] ?? '') === $fingerprint
|
|
&& preg_replace('/\.\d+(?=\.|$)/', '.*', $previousPath) === preg_replace('/\.\d+(?=\.|$)/', '.*', $path)) {
|
|
$match = $previousPath;
|
|
break;
|
|
}
|
|
}
|
|
// Named singleton modules have a stable path; list rows need an unambiguous match.
|
|
if ($match === null && ! preg_match('/\.\d+(?:\.|$)/', $path) && isset($active[$path]) && ! isset($used[$path])) {
|
|
$match = $path;
|
|
}
|
|
$item = $match !== null ? $active[$match] : null;
|
|
if ($match !== null) {
|
|
$used[$match] = true;
|
|
}
|
|
$changed = $item === null || ($item['fingerprint'] ?? '') !== $fingerprint;
|
|
$legacy = $item === null && isset($old[$path]);
|
|
$item ??= ['id' => (string) Str::uuid(), 'version' => 0, 'created_by' => $legacy ? null : $actor->id,
|
|
'created_at' => $legacy ? null : now()->toIso8601String(), 'first_observed_at' => now()->toIso8601String(),
|
|
'identity_source' => $legacy ? 'legacy_first_observation_not_creation' : 'server_generated_new_item'];
|
|
if ($changed) {
|
|
$item['version']++;
|
|
$item['updated_by'] = $actor->id;
|
|
$item['updated_at'] = now()->toIso8601String();
|
|
}
|
|
$next[$path] = [...$item, 'fingerprint' => $fingerprint, 'is_deleted' => false,
|
|
'active' => true, 'deleted_at' => null, 'deleted_by' => null, 'delete_reason' => null];
|
|
}
|
|
foreach ($active as $path => $item) {
|
|
if (isset($used[$path])) {
|
|
continue;
|
|
}
|
|
$retired[$item['id']] = [...$item, 'path' => $path, 'version' => $item['version'] + 1,
|
|
'is_deleted' => true, 'active' => false, 'deleted_at' => now()->toIso8601String(),
|
|
'deleted_by' => $actor->id, 'delete_reason' => $reason, 'last_value' => $old[$path] ?? null];
|
|
}
|
|
|
|
return ['active' => $next, 'deleted' => $retired, 'rule' => 'existing-json-identities-v1'];
|
|
}
|
|
|
|
/** @param array<string,mixed> $payload
|
|
* @param array<string,mixed> $metadata
|
|
* @return array<string,mixed> */
|
|
public function project(array $payload, array $metadata): array
|
|
{
|
|
// Deepest paths first so a parent does not overwrite the children's identities.
|
|
$paths = array_keys($metadata['active'] ?? []);
|
|
foreach ($paths as $path) {
|
|
if (! is_string($path)) {
|
|
throw new \UnexpectedValueException('Canonical item paths must be named fields.');
|
|
}
|
|
}
|
|
usort($paths, fn ($a, $b) => substr_count($b, '.') <=> substr_count($a, '.'));
|
|
foreach ($paths as $path) {
|
|
$value = Arr::get($payload, $path);
|
|
if (! is_array($value)) {
|
|
continue;
|
|
}
|
|
$item = $metadata['active'][$path];
|
|
unset($item['fingerprint']);
|
|
Arr::set($payload, $path, [...$value, ...$item]);
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
}
|