Files
Investidor-V2/app/Services/InvestmentRelationshipRepair.php
T
nala bdd766c68a
tests / ci (push) Canceled after 0s
resolvemos o problema dos ZEE e ZFI
2026-10-07 19:46:35 +02:00

214 lines
14 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** @phpstan-type RepairPlan array{target:array<string,mixed>,locations:list<array<string,mixed>>,memberships:list<array<string,mixed>>,pending:list<array<string,mixed>>,prepared_opportunities:list<array<string,mixed>>} */
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<array<string,mixed>>,history:list<array<string,mixed>>,unproposed:list<array<string,mixed>>} */
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<array<string,mixed>> */
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<array<string,mixed>> */
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<array<string,mixed>> $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<string,mixed> */
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];
});
}
}