fix: complete InvesteMoz validation and error corrections

This commit is contained in:
2026-10-29 21:47:33 +02:00
parent 63147312e2
commit daf8623441
718 changed files with 127308 additions and 20959 deletions
+192 -61
View File
@@ -4,19 +4,22 @@ namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */
class EconomicReferenceInstallation
{
public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy';
public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw'];
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Services/EconomicReferenceCatalog.php', 'app/Services/EconomicReferenceInstallation.php',
@@ -32,13 +35,22 @@ class EconomicReferenceInstallation
return hash('sha256', json_encode(array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()), JSON_THROW_ON_ERROR));
}
private function directory(): string { return storage_path('app/private/investmoz-v2/economic-references/lot-01'); }
private function directory(): string
{
return storage_path('app/private/investmoz-v2/economic-references/lot-01');
}
/**
* @return array<array-key, mixed>
*/
private function target(): array
{
return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
}
/**
* @param array<array-key, mixed> $target
*/
private function qa(array $target): bool
{
return app()->environment('testing') && str_ends_with($target['database'], '_test') && (int) $target['port'] === 55441
@@ -48,20 +60,32 @@ class EconomicReferenceInstallation
private function authorize(User $actor, bool $write = false, string $operation = ''): void
{
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Acesso reservado à gestão autorizada.');
if (! $write) { return; }
if (! $write) {
return;
}
$access = app(PortalProfileAccess::class);
abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.');
abort_unless($access->mfa($actor), 403, 'Conclua MFA nesta sessão para executar esta ação.');
if ($operation === 'schema') { app(PortalProfileInstallation::class)->authorize($actor, true); }
if ($operation === 'schema') {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
abort_unless(! app()->environment('testing') || $this->qa($this->target()), 403, 'Alvo QA isolado inválido.');
}
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code, string $operation): array
{
if ($this->qa($target)) { return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; }
if (app()->environment('testing')) { return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; }
if ($this->qa($target)) {
return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null];
}
$path = $this->directory().'/readiness.json';
$proof = is_file($path) ? json_decode(file_get_contents($path), true) : [];
$proof = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($proof['verified_at'] ?? '');
$permission = $proof['active_authorization'] ?? [];
$ready = ! config('database.connections.pgsql.url') && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
@@ -73,8 +97,8 @@ class EconomicReferenceInstallation
&& ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& str_ends_with($proof['restore_target']['database'] ?? '', '_test') && (int) ($proof['restore_target']['port'] ?? 0) === 55441
&& is_file($proof['dump_path'] ?? '') && is_file($proof['files_path'] ?? '')
&& hash_equals($proof['dump_sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))
&& hash_equals($proof['dump_sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))
&& ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code
&& ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
&& in_array($operation, $permission['operations'] ?? [], true);
@@ -83,25 +107,35 @@ class EconomicReferenceInstallation
'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/** No source scan/write in preview. Paths are fixed, private and confined to this lot. */
/** No source scan/write in preview. Paths are fixed, private and confined to this lot.
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function sources(array $manifest): array
{
$root = realpath($this->directory()); $sources = [];
$root = realpath($this->directory());
$sources = [];
foreach ($manifest['sources'] as $entry) {
$path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false;
$inside = $path && str_starts_with(strtolower(str_replace('\\', '/', $path)), strtolower(str_replace('\\', '/', $root)).'/');
$matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], hash_file('sha256', $path));
$matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], CheckedFile::sha256($path));
$sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches];
}
return $sources;
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function referenceRows(array $manifest): array
{
$rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]];
foreach (['activities' => 'economic_activities', 'chains' => 'value_chains', 'links' => 'value_chain_links'] as $group => $table) {
foreach ($manifest[$group] as $row) { $rows[] = ['table' => $table, 'data' => $row]; }
foreach ($manifest[$group] as $row) {
$rows[] = ['table' => $table, 'data' => $row];
}
}
return $rows;
@@ -113,41 +147,67 @@ class EconomicReferenceInstallation
->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists();
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function references(array $manifest, bool $schemaReady): array
{
$results = [];
foreach ($this->referenceRows($manifest) as $row) {
$expected = $row['data']; $table = $row['table'];
$expected = $row['data'];
$table = $row['table'];
$current = DB::table($table)->where('id', $expected['id'])->first();
$conflict = false;
foreach ($expected as $key => $value) {
if (! $current || ($key === 'metadata' && ! $schemaReady)) { continue; }
if ($key === 'status') { $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); continue; }
if ($key === 'data_classification') { $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); continue; }
if (! $current || ($key === 'metadata' && ! $schemaReady)) {
continue;
}
if ($key === 'status') {
$conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true);
continue;
}
if ($key === 'data_classification') {
$conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true);
continue;
}
$actual = $key === 'metadata' ? json_decode($current->$key, true) : $current->$key;
if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) { $conflict = true; }
if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) {
$conflict = true;
}
}
$identity = DB::table($table)->where('id', '!=', $expected['id']);
if ($table === 'reference_catalog_versions') { $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']); }
elseif ($table === 'economic_activities') { $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); }
else { $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); }
if ($table === 'reference_catalog_versions') {
$identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']);
} elseif ($table === 'economic_activities') {
$identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']);
} else {
$identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']);
}
$conflict = $conflict || $identity->exists();
if ($table === 'value_chains') {
$conflict = $conflict || DB::table($table)->where('id', '!=', $expected['id'])->where('cluster_id', $expected['cluster_id'])->where('code', $expected['code'])->whereNull('deleted_at')->exists();
}
$results[] = ['table' => $table, 'id' => $expected['id'], 'code' => $expected['code'] ?? $expected['external_id'] ?? $expected['catalog_code'],
'status' => $conflict ? 'conflict' : ($current ? 'reuse' : 'insert'), 'current_hash' => $current ? OpportunityCanonicalValue::hash((array) $current) : null,
'current_visibility' => $current?->status ?? $current?->data_classification ?? null];
'current_visibility' => $current->status ?? $current->data_classification ?? null];
}
return $results;
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);
$catalog = app(EconomicReferenceCatalog::class); $manifest = $catalog->manifest();
$target = $this->target(); $code = $this->fingerprint();
$catalog = app(EconomicReferenceCatalog::class);
$manifest = $catalog->manifest();
$target = $this->target();
$code = $this->fingerprint();
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$column = $catalog->schemaAvailable();
$unique = DB::selectOne("SELECT 1 FROM pg_constraint WHERE conrelid='public.economic_activities'::regclass AND conname='economic_activities_sector_uq'") !== null;
@@ -155,19 +215,31 @@ class EconomicReferenceInstallation
$index = DB::selectOne("SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='economic_activities_sector_idx'") !== null;
$installed = $ledger && $column && ! $unique && $sectorNullable && $index;
$consistent = $installed || (! $ledger && ! $column && $unique && ! $sectorNullable && ! $index);
$rows = $this->references($manifest, $column); $sources = $this->sources($manifest); $reasons = [];
$rows = $this->references($manifest, $column);
$sources = $this->sources($manifest);
$reasons = [];
foreach (['sector' => 'sectors', 'cluster' => 'economic_clusters'] as $name => $table) {
$expected = $manifest['existing'][$name]; $record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first();
$expected = $manifest['existing'][$name];
$record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first();
foreach (array_filter($expected, fn ($v) => $v !== null) as $field => $value) {
if (! $record || (string) ($record->$field ?? '') !== (string) $value) { $reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.'; break; }
if (! $record || (string) ($record->$field ?? '') !== (string) $value) {
$reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.';
break;
}
}
}
if (! $consistent) { $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.'; }
if (collect($rows)->contains('status', 'conflict')) { $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; }
if (collect($sources)->contains('ready', false)) { $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; }
if (! $consistent) {
$reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.';
}
if (collect($rows)->contains('status', 'conflict')) {
$reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.';
}
if (collect($sources)->contains('ready', false)) {
$reasons[] = 'Exemplar privado ausente ou com bytes diferentes.';
}
$imported = ! collect($rows)->contains(fn ($r) => $r['status'] !== 'reuse');
$scanPath = $this->directory().'/scans.json';
$scan = is_file($scanPath) ? json_decode(file_get_contents($scanPath), true) : null;
$scan = is_file($scanPath) ? json_decode(CheckedFile::read($scanPath), true) : null;
$state = ['target' => $target, 'code_hash' => $code, 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256,
'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported,
'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan,
@@ -175,15 +247,27 @@ class EconomicReferenceInstallation
$operations = [];
foreach (self::OPERATIONS as $operation) {
$why = $reasons;
try { $this->authorize($actor, true, $operation); } catch (HttpException $e) { $why[] = $e->getMessage(); }
try {
$this->authorize($actor, true, $operation);
} catch (HttpException $e) {
$why[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code, $operation);
if (! $evidence['ready']) { $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.'; }
if ($operation !== 'schema' && ! $installed) { $why[] = 'Instale primeiro o esquema nominal.'; }
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; }
if (! $evidence['ready']) {
$why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.';
}
if ($operation !== 'schema' && ! $installed) {
$why[] = 'Instale primeiro o esquema nominal.';
}
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) {
$why[] = 'Importação privada completa necessária antes de publicar/retirar.';
}
if (in_array($operation, ['publish', 'withdraw'], true) && ! DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')->where('after_data->result->manifest_sha256', EconomicReferenceCatalog::MANIFEST_SHA256)->exists()) {
$why[] = 'Execute a importação privada autenticada, inclusive para registos reutilizados.';
}
if ($operation === 'withdraw' && $state['publication'] === 'private') { $why[] = 'O lote ainda não teve publicação para retirar.'; }
if ($operation === 'withdraw' && $state['publication'] === 'private') {
$why[] = 'O lote ainda não teve publicação para retirar.';
}
if ($operation === 'withdraw' && collect($rows)->contains(fn ($r) => $r['status'] === 'reuse'
&& in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id']))) {
$why[] = 'Há referências anteriores já públicas reutilizadas: reconciliar o mapa de retirada antes de agir.';
@@ -195,22 +279,34 @@ class EconomicReferenceInstallation
return [...$state, 'operations' => $operations];
}
/**
* @param array<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
private function scan(array $manifest): array
{
$directory = realpath($this->directory()); $records = [];
abort_unless($directory, 422, 'Área privada do lote indisponível.');
$directory = realpath($this->directory());
$records = [];
abort_unless(is_string($directory), 422, 'Área privada do lote indisponível.');
$paths = [['name' => 'manifesto', 'path' => database_path('data/economic-references-lot-01.json'), 'sha256' => EconomicReferenceCatalog::MANIFEST_SHA256]];
foreach ($manifest['sources'] as $source) { $paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']]; }
foreach ($manifest['sources'] as $source) {
$paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']];
}
foreach ($paths as $file) {
abort_unless(is_file($file['path']) && hash_equals($file['sha256'], hash_file('sha256', $file['path'])), 409, 'Originais alterados após prévia.');
abort_unless(is_file($file['path']) && hash_equals($file['sha256'], CheckedFile::sha256($file['path'])), 409, 'Originais alterados após prévia.');
// Scanner's path confinement uses the private source directory; manifest is scanned via a private exact copy.
$path = $file['path'];
if ($file['name'] === 'manifesto') { $path = $directory.'/manifest-scanned.json'; file_put_contents($path, file_get_contents($file['path'])); }
$result = app(MalwareScanner::class)->scan($path, filesize($path), $directory);
if ($result['signatures_at'] instanceof \DateTimeInterface) { $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); }
if ($file['name'] === 'manifesto') {
$path = $directory.'/manifest-scanned.json';
file_put_contents($path, file_get_contents($file['path']));
}
$result = app(MalwareScanner::class)->scan($path, CheckedFile::size($path), $directory);
if ($result['signatures_at'] instanceof \DateTimeInterface) {
$result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM);
}
$records[] = ['name' => $file['name'], 'sha256' => $file['sha256'], 'scan' => $result];
abort_unless($result['result'] === 'clean', 423, 'Scan não concluído ou não limpo para '.$file['name'].'. Nenhuma referência foi importada/publicada.');
abort_unless(hash_equals($file['sha256'], hash_file('sha256', $file['path'])) && hash_equals($file['sha256'], hash_file('sha256', $path)), 409, 'Bytes alterados durante o scan.');
abort_unless(CheckedFile::matches($file['path'], $file['sha256']) && CheckedFile::matches($path, $file['sha256']), 409, 'Bytes alterados durante o scan.');
}
$proof = ['at' => now()->toIso8601String(), 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'files' => $records];
file_put_contents($directory.'/scans.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
@@ -218,11 +314,15 @@ class EconomicReferenceInstallation
return $proof;
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, string $operation, array $input): array
{
abort_unless(in_array($operation, self::OPERATIONS, true), 404);
$this->authorize($actor, true, $operation);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']), 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.');
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']) !== [], 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function () use ($actor, $operation, $data) {
@@ -235,44 +335,73 @@ class EconomicReferenceInstallation
return [...$receipt['result'], 'replayed' => true, 'current_publication' => app(EconomicReferenceCatalog::class)->publicationState()];
}
$plan = $this->preview($actor); $phase = $plan['operations'][$operation];
$plan = $this->preview($actor);
$phase = $plan['operations'][$operation];
abort_unless(hash_equals($phase['hash'], $data['hash']), 409, 'Alvo, ator, revisão ou estado alterados; consulte nova prévia.');
abort_unless($phase['ready'], 422, implode(' ', $phase['reasons']));
$manifest = app(EconomicReferenceCatalog::class)->manifest(); $changed = []; $reused = [];
foreach ($this->referenceRows($manifest) as $row) { DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); }
$manifest = app(EconomicReferenceCatalog::class)->manifest();
$changed = [];
$reused = [];
foreach ($this->referenceRows($manifest) as $row) {
DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first();
}
DB::table('sectors')->where('id', $manifest['existing']['sector']['id'])->lockForUpdate()->first();
DB::table('economic_clusters')->where('id', $manifest['existing']['cluster']['id'])->lockForUpdate()->first();
// Recheck after locks in case another approved writer changed a reused master.
abort_unless($this->preview($actor)['operations'][$operation]['hash'] === $phase['hash'], 409, 'Referências alteradas durante a preparação da operação.');
if ($operation === 'schema' && ! $plan['installed']) {
config(['economic_references.authenticated_installation' => true]);
try { abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.'); }
finally { config(['economic_references.authenticated_installation' => false]); }
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.');
} finally {
config(['economic_references.authenticated_installation' => false]);
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema instalado não corresponde ao plano.');
$changed[] = self::MIGRATION;
} elseif ($operation === 'import') {
$scan = $this->scan($manifest);
foreach ($this->referenceRows($manifest) as $i => $row) {
if ($plan['rows'][$i]['status'] === 'reuse') { $reused[] = $row['data']['id']; continue; }
$values = $row['data']; if (isset($values['metadata'])) { $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); }
if ($plan['rows'][$i]['status'] === 'reuse') {
$reused[] = $row['data']['id'];
continue;
}
$values = $row['data'];
if (isset($values['metadata'])) {
$values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR);
}
DB::table($row['table'])->insert([...$values, 'created_at' => now(), 'updated_at' => now()]);
$changed[] = $values['id'];
}
} elseif ($operation === 'publish' || $operation === 'withdraw') {
if ($operation === 'publish') { $scan = $this->scan($manifest); }
if ($operation === 'publish') {
$scan = $this->scan($manifest);
}
foreach ($this->referenceRows($manifest) as $row) {
$table = $row['table']; $record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first();
$table = $row['table'];
$record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first();
if (in_array($table, ['reference_catalog_versions', 'economic_activities'], true)) {
if ($operation === 'publish' && $record->status === 'draft') {
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); $changed[] = $record->id;
} else { $reused[] = $record->id; }
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]);
$changed[] = $record->id;
} else {
$reused[] = $record->id;
}
} else {
$visibility = $operation === 'publish' ? 'public' : 'internal';
// Withdrawal touches only objects this importer created, never pre-existing public references.
$createdHere = $this->createdHere($record->id);
if ($operation === 'withdraw' && ! $createdHere) { $reused[] = $record->id; continue; }
if ($record->data_classification !== $visibility) { DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); $changed[] = $record->id; }
else { $reused[] = $record->id; }
if ($operation === 'withdraw' && ! $createdHere) {
$reused[] = $record->id;
continue;
}
if ($record->data_classification !== $visibility) {
DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]);
$changed[] = $record->id;
} else {
$reused[] = $record->id;
}
}
}
}
@@ -280,7 +409,9 @@ class EconomicReferenceInstallation
'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'code_hash' => $plan['code_hash'], 'replayed' => false,
'publications' => $operation === 'publish' ? 'references only; no investment or original source publication' : 'none', 'business_records_changed' => 0,
'receipt' => $data['idempotency_key'], 'scan' => $scan ?? null];
if ($operation === 'import') { $result['previously_public'] = array_values(array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id')); }
if ($operation === 'import') {
$result['previously_public'] = array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id');
}
DB::table('audit_events')->insert(['source_system' => EconomicReferenceCatalog::SOURCE, 'entity_type' => 'economic-reference-batch', 'entity_id' => $manifest['catalog']['id'],
'actor_user_id' => $actor->id, 'correlation_id' => $data['idempotency_key'], 'action' => $operation, 'data_classification' => 'restricted',
'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);