,locations:list>,memberships:list>,pending:list>,prepared_opportunities:list>} */ class InvestmentRelationshipRepair { public function directory(): string { return config('investment.repair_directory', storage_path('app/private/anchor-project-reconciliation')); } /** @return array{hash:string,plan:RepairPlan,territorial:list>,history:list>,unproposed:list>} */ public function preview(): array { $file = $this->directory().'/preview.json'; abort_unless(is_file($file), 404, 'Pré-visualização não preparada.'); $contents = file_get_contents($file); abort_if($contents === false, 503, 'Pré-visualização não pode ser lida.'); $hash = hash('sha256', $contents); $plan = $this->decodePlan($contents); $history = $this->history($hash); $validation = app(InvestmentTerritorialValidation::class); $territorial = []; foreach ($plan['locations'] as $row) { $result = $validation->validate($row); $result['applied'] = $this->wasApplied($history, $result['row_id']); $result['validation_hash'] = hash('sha256', json_encode($result, JSON_THROW_ON_ERROR)); $territorial[] = $result; } $unproposed = []; foreach (array_unique(array_column($plan['pending'], 'code')) as $code) { if (! preg_match('/^A\d{2}$/', $code) || collect($plan['locations'])->contains('code', $code)) { continue; } $project = DB::table('projects')->where('code', $code)->whereNull('deleted_at')->first(); if (! $project) { continue; } $dossier = DB::table('investment_dossiers')->where('project_id', $project->id)->whereNull('deleted_at')->first(); $unproposed[] = ['code' => $code, 'current' => app(InvestmentRelations::class)->projectLocations($project->id, true), 'source' => $dossier?->source, 'location_text' => $dossier ? (json_decode($dossier->content, true)['location'] ?? null) : null, 'reasons' => collect($plan['pending'])->where('code', $code)->pluck('reason')->all()]; } return ['hash' => $hash, 'plan' => $plan, 'territorial' => $territorial, 'history' => $history, 'unproposed' => $unproposed]; } /** @return RepairPlan */ private function decodePlan(string $contents): array { $plan = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); abort_unless(is_array($plan) && is_array($plan['target'] ?? null) && is_array($plan['locations'] ?? null) && is_array($plan['memberships'] ?? null), 422, 'Plano de associações inválido.'); $target = []; foreach ($plan['target'] as $key => $value) { abort_unless(is_string($key), 422, 'Destino inválido.'); $target[$key] = $value; } return ['target' => $target, 'locations' => $this->decodeRows($plan['locations']), 'memberships' => $this->decodeRows($plan['memberships']), 'pending' => $this->decodeRows($plan['pending'] ?? []), 'prepared_opportunities' => $this->decodeRows($plan['prepared_opportunities'] ?? [])]; } /** @return list> */ private function decodeRows(mixed $rows): array { abort_unless(is_array($rows), 422, 'Linhas do plano inválidas.'); $result = []; foreach ($rows as $row) { abort_unless(is_array($row), 422, 'Linha do plano inválida.'); $item = []; foreach ($row as $key => $value) { abort_unless(is_string($key), 422, 'Campo do plano inválido.'); $item[$key] = $value; } $result[] = $item; } return $result; } /** @return list> */ private function history(string $hash): array { return array_values(DB::table('audit_events as event')->leftJoin('users as actor', 'actor.id', '=', 'event.actor_user_id') ->where('event.source_system', 'documented-relations-repair')->whereRaw("event.after_data->>'plan_hash' = ?", [$hash]) ->orderBy('event.created_at')->orderBy('event.id')->get(['event.id', 'event.created_at', 'event.actor_user_id', 'actor.name as actor_name', 'event.after_data']) ->map(fn ($event) => ['id' => $event->id, 'date' => $event->created_at, 'actor_id' => $event->actor_user_id, 'actor' => $event->actor_name, 'data' => json_decode($event->after_data, true)])->all()); } /** @param list> $history */ private function wasApplied(array $history, string $rowId): bool { foreach ($history as $event) { if (! isset($event['data']['location_row_ids']) || in_array($rowId, $event['data']['location_row_ids'], true)) { return true; } } return false; } /** @return array */ public function apply(string $hash, int $actor, ?string $rowId = null, ?string $validationHash = null): array { $preview = $this->preview(); abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada. Reveja as associações.'); $proofFile = $this->directory().'/backup-proof.json'; abort_unless(is_file($proofFile), 422, 'Backup e restauro isolado ainda não verificados.'); $contents = file_get_contents($proofFile); abort_if($contents === false, 503, 'Evidência de restauro não pode ser lida.'); $proof = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); $dumpHash = is_file($proof['dump_path'] ?? '') ? hash_file('sha256', $proof['dump_path']) : false; abort_unless(($proof['restore_verified'] ?? false) && is_file($proof['dump_path'] ?? '') && $dumpHash !== false && hash_equals($proof['sha256'], $dumpHash), 422, 'Evidência de backup/restauro inválida.'); $plan = $preview['plan']; $validation = app(InvestmentTerritorialValidation::class); if ($rowId !== null) { abort_unless(collect($plan['locations'])->contains(fn ($row) => $validation->rowId($row) === $rowId), 422, 'Associação não pertence ao plano.'); } $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); abort_unless($identity->database === $plan['target']['database'] && (int) $identity->port === (int) $plan['target']['port'] && ($proof['target'] ?? null) === $plan['target'], 409, 'Base de destino diferente da pré-visualização e do backup.'); if (app()->environment('testing')) { abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base *_test isolada na porta 55441.'); } return DB::transaction(function () use ($plan, $hash, $actor, $rowId, $validation, $validationHash): array { DB::select('select pg_advisory_xact_lock(71026, 150000)'); $history = $this->history($hash); if (($rowId !== null && $this->wasApplied($history, $rowId)) || ($rowId === null && collect($history)->contains(fn ($event) => ! isset($event['data']['location_row_ids']) || ($event['data']['batch_complete'] ?? false)))) { return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0]; } DB::statement('LOCK TABLE geo_locations, geo_geometries IN SHARE MODE'); $created = $memberships = 0; $projects = $appliedRows = $sources = $before = []; foreach ($plan['locations'] as $row) { $id = $validation->rowId($row); if (($rowId !== null && $rowId !== $id) || $this->wasApplied($history, $id)) { continue; } $territory = $validation->validate($row); if ($rowId !== null) { $territory['applied'] = false; abort_unless($validationHash !== null && hash_equals(hash('sha256', json_encode($territory, JSON_THROW_ON_ERROR)), $validationHash), 409, 'Validação territorial alterada. Reveja a proposta.'); } if ($territory['status'] !== 'grounded') { abort_if($rowId !== null, 409, 'Associação pendente ou contraditória; não pode ser aplicada.'); continue; } $project = $projects[$row['project_id']] ?? DB::table('projects')->where('id', $row['project_id'])->whereNull('deleted_at')->lockForUpdate()->first(); $increments = collect($history)->filter(fn ($event) => in_array($row['project_id'], $event['data']['project_ids'] ?? [], true))->count(); abort_unless($project && (int) $project->version_no === (int) $row['expected_project_version'] + $increments, 409, 'Projecto alterado desde a pré-visualização.'); $dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($dossier && $dossier->project_id === $project->id && (int) $dossier->version_no === (int) $row['dossier_version'], 409, 'Dossier alterado.'); $content = json_decode($dossier->content, true); abort_unless(($content['location'] ?? null) === $row['source_quote'] && $dossier->source === $row['source_reference'], 409, 'Citação documental alterada.'); abort_unless($row['data_classification'] !== 'public' || $dossier->publication_status === 'published', 409, 'Autorização de divulgação alterada.'); abort_unless(app(InvestmentGeography::class)->references(true)->where('id', $row['geo_location_id'])->exists(), 409, 'Referência territorial indisponível.'); $appliedRows[] = $id; $before[] = ['row_id' => $id, 'project_id' => $project->id, 'current' => $territory['current']]; $sources[] = ['row_id' => $id, 'project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role'], 'source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'reason' => $row['reason']]; $key = ['project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role']]; if (! DB::table('project_locations')->where($key)->whereNull('deleted_at')->exists()) { DB::table('project_locations')->insert([...$key, 'id' => (string) Str::uuid(), 'source_system' => 'documented-relations-repair', 'external_id' => $row['dossier_id'].':'.$row['geo_location_id'].':'.$row['role'], 'data_classification' => $row['data_classification']]); $metadata = json_decode($project->metadata, true); $metadata['documented_location_sources'][$row['geo_location_id'].':'.$row['role']] = ['source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'plan_hash' => $hash, 'reason' => $row['reason'], 'actor_user_id' => $actor, 'applied_at' => now()->toIso8601String()]; $project->metadata = json_encode($metadata, JSON_THROW_ON_ERROR); $projects[$project->id] = $project; $created++; } } foreach ($projects as $project) { DB::table('projects')->where('id', $project->id)->update(['metadata' => $project->metadata, 'version_no' => $project->version_no + 1]); } foreach ($rowId === null ? $plan['memberships'] : [] as $row) { if ($row['existing_id']) { continue; } $key = ['cluster_id' => $row['cluster_id'], 'project_id' => $row['project_id']]; $dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($dossier && $dossier->cluster_id === $row['cluster_id'] && (int) $dossier->version_no === (int) $row['dossier_version'] && (json_decode($dossier->content, true)['anchors'] ?? null) === $row['source_quote'], 409, 'Fonte da relação do cluster alterada.'); abort_unless(DB::table('projects')->where('id', $row['project_id'])->where('code', $row['project_code'])->whereNull('deleted_at')->exists(), 409, 'Identidade do projecto alterada.'); if (! DB::table('cluster_projects')->where($key)->whereNull('deleted_at')->exists()) { DB::table('cluster_projects')->insert([...$key, 'id' => (string) Str::uuid(), 'role' => $row['role'], 'source_system' => 'documented-relations-repair', 'source_reference' => $row['source_reference'], 'evidence_reference' => $row['dossier_id'], 'editor_id' => $actor, 'data_classification' => $dossier->publication_status === 'published' ? 'public' : 'internal', 'validation_status' => 'pending', 'association_type' => 'potential']); $memberships++; } } if (! $appliedRows && ! $memberships) { return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0]; } DB::table('audit_events')->insert(['source_system' => 'documented-relations-repair', 'entity_type' => 'project_locations', 'actor_user_id' => $actor, 'action' => 'documented_relations_applied', 'before_data' => json_encode($before, JSON_THROW_ON_ERROR), 'after_data' => json_encode(['plan_hash' => $hash, 'locations_created' => $created, 'memberships_created' => $memberships, 'project_ids' => array_keys($projects), 'location_row_ids' => $appliedRows, 'sources' => $sources, 'batch_complete' => $rowId === null && count(array_filter($plan['locations'], fn ($row) => ! in_array($validation->rowId($row), $appliedRows, true) && ! $this->wasApplied($history, $validation->rowId($row)))) === 0], JSON_THROW_ON_ERROR)]); return ['replayed' => false, 'locations_created' => $created, 'memberships_created' => $memberships]; }); } }