Files
Investidor-V2/app/Services/OpportunityCanonicalMetadata.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

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;
}
}