423 lines
24 KiB
PHP
423 lines
24 KiB
PHP
<?php
|
|
|
|
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\Validator;
|
|
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',
|
|
'app/Http/Controllers/EconomicReferenceController.php', 'app/Services/OpportunityCanonicalContract.php',
|
|
'app/Services/OpportunityClassification.php', 'app/Services/ExistingBusinessClassification.php',
|
|
'app/Http/Controllers/Api/V1/ReferenceCatalogController.php', 'resources/js/components/opportunity-canonical-fields.tsx',
|
|
'resources/js/components/opportunity-classification.tsx', 'resources/js/pages/management/economic-references.tsx',
|
|
'routes/web.php', 'database/migrations/'.self::MIGRATION.'.php', 'database/data/economic-references-lot-01.json'];
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
|
|
}
|
|
|
|
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;
|
|
}
|
|
$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);
|
|
}
|
|
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];
|
|
}
|
|
$path = $this->directory().'/readiness.json';
|
|
$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)
|
|
&& $target['database'] === 'investemoz' && (int) $target['port'] === 5432
|
|
&& ($proof['target']['database'] ?? '') === $target['database'] && (int) ($proof['target']['port'] ?? 0) === (int) $target['port']
|
|
&& ($proof['code_hash'] ?? '') === $code && ($proof['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
|
|
&& $date !== false && $date <= time() && $date >= time() - 86400
|
|
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false)
|
|
&& ($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'] ?? '', 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);
|
|
|
|
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'date' => $proof['verified_at'] ?? null,
|
|
'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.
|
|
* @param array<array-key, mixed> $manifest
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
private function sources(array $manifest): array
|
|
{
|
|
$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'], 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];
|
|
}
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function createdHere(string $id): bool
|
|
{
|
|
return DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')
|
|
->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'];
|
|
$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;
|
|
}
|
|
$actual = $key === 'metadata' ? json_decode($current->$key, true) : $current->$key;
|
|
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']);
|
|
}
|
|
$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];
|
|
}
|
|
|
|
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();
|
|
$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;
|
|
$sectorNullable = DB::selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='economic_activities' AND column_name='sector_id'")?->is_nullable === 'YES';
|
|
$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 = [];
|
|
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();
|
|
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 (! $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(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,
|
|
'divergence' => $manifest['divergence'], 'convention' => $manifest['convention'], 'common_reasons' => $reasons];
|
|
$operations = [];
|
|
foreach (self::OPERATIONS as $operation) {
|
|
$why = $reasons;
|
|
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 (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' && 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.';
|
|
}
|
|
$token = hash_hmac('sha256', OpportunityCanonicalValue::hash([$actor->id, session()->getId(), $operation, $state, $evidence]), config('app.key'));
|
|
$operations[$operation] = ['ready' => $why === [], 'reasons' => array_values(array_unique($why)), 'hash' => $token, 'backupDate' => $evidence['date'], 'evidence_mode' => $evidence['mode']];
|
|
}
|
|
|
|
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(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 ($paths as $file) {
|
|
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, 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(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));
|
|
|
|
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.');
|
|
$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) {
|
|
DB::select('SELECT pg_advisory_xact_lock(161026,120000)');
|
|
$requestHash = OpportunityCanonicalValue::hash([$operation, $data]);
|
|
$old = DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
|
|
if ($old) {
|
|
$receipt = json_decode($old->after_data, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave de repetição já usada com ator/conteúdo diferentes.');
|
|
|
|
return [...$receipt['result'], 'replayed' => true, 'current_publication' => app(EconomicReferenceCatalog::class)->publicationState()];
|
|
}
|
|
$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();
|
|
}
|
|
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]);
|
|
}
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
foreach ($this->referenceRows($manifest) as $row) {
|
|
$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;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$result = ['operation' => $operation, 'lot' => 'lot-01', 'target' => $plan['target'], 'changed' => $changed, 'reused' => $reused,
|
|
'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_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)]);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
}
|