167 lines
10 KiB
PHP
167 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\InvestmentPortfolioController;
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/** Attach evidence to existing relationships; never grant legal approval or infer new geography. */
|
|
class InvestmentTerritorialTraceability
|
|
{
|
|
public function directory(): string
|
|
{
|
|
return config('investment.traceability_directory', storage_path('app/private/investmoz-v2'));
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
return hash('sha256', implode('', array_map(fn ($file) => hash_file('sha256', base_path($file)), [
|
|
'app/Services/InvestmentTerritorialTraceability.php', 'app/Http/Controllers/InvestmentRelationshipRepairController.php',
|
|
'resources/js/components/territorial-traceability-plan.tsx', 'tools/investmoz-v2-prepare-territorial-plan.cjs',
|
|
])));
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function preview(): ?array
|
|
{
|
|
$file = $this->directory().'/territory-activation-plan.json';
|
|
if (! is_file($file)) {
|
|
return null;
|
|
}
|
|
$bytes = file_get_contents($file);
|
|
abort_if($bytes === false, 503, 'Plano não pode ser lido.');
|
|
$plan = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless(is_array($plan) && ($plan['schema'] ?? null) === 1 && is_array($plan['rows'] ?? null), 422, 'Plano inválido.');
|
|
$hash = hash('sha256', $bytes);
|
|
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->first();
|
|
|
|
return ['hash' => $hash, 'plan' => $plan, 'code_matches' => ($plan['code_hash'] ?? '') === $this->fingerprint(),
|
|
'applied' => $last?->action === 'traceability_applied', 'reversed' => $last?->action === 'traceability_reversed',
|
|
'history' => DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->orderByDesc('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at', 'after_data'])->map(function ($event) {
|
|
$data = json_decode($event->after_data, true);
|
|
|
|
return ['id' => $event->id, 'action' => $event->action, 'actor_user_id' => $event->actor_user_id, 'occurred_at' => $event->occurred_at, 'reason' => $data['reason'] ?? null];
|
|
})->all(), 'backup_ready' => $this->backupReady($plan['target'], $hash)];
|
|
}
|
|
|
|
/** @param array<string,mixed> $target */
|
|
private function backupReady(array $target, string $hash): bool
|
|
{
|
|
$file = $this->directory().'/territory-backup-proof.json';
|
|
if (! is_file($file)) {
|
|
return false;
|
|
}
|
|
$proof = json_decode(file_get_contents($file) ?: '{}', true);
|
|
|
|
return ($proof['restore_verified'] ?? false) === true && ($proof['reversal_verified'] ?? false) === true
|
|
&& ($proof['plan_hash'] ?? '') === $hash && ($proof['code_hash'] ?? '') === $this->fingerprint()
|
|
&& ($proof['target'] ?? []) === $target && strtotime($proof['verified_at'] ?? '') >= time() - 86400
|
|
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null);
|
|
}
|
|
|
|
/** @param array<string,mixed> $guard */
|
|
private function checkGuard(array $guard): void
|
|
{
|
|
abort_unless(in_array($guard['table'] ?? '', ['projects', 'economic_clusters', 'investment_zones', 'opportunities', 'investment_dossiers', 'cluster_projects', 'project_locations', 'investment_zone_locations', 'investment_legal_sources'], true), 422, 'Tabela fora do plano permitido.');
|
|
$row = DB::table($guard['table'])->where('id', $guard['id'])->lockForUpdate()->first();
|
|
abort_unless($row !== null, 409, 'Registo do plano já não existe.');
|
|
foreach ($guard['expected'] as $field => $value) {
|
|
abort_unless(property_exists($row, $field) && ($row->$field === null ? $value === null : $value !== null && (string) $row->$field === (string) $value), 409, 'Registo alterado desde a pré-visualização: '.$guard['table'].'.'.$field);
|
|
}
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function apply(string $hash, int $actor, bool $reverse = false, ?string $reason = null): array
|
|
{
|
|
abort_if($reverse && trim($reason ?? '') === '', 422, 'Indique o motivo da reversão.');
|
|
$preview = $this->preview();
|
|
abort_unless($preview && hash_equals($preview['hash'], $hash) && $preview['code_matches'], 409, 'Plano ou código alterado. Reveja a pré-visualização.');
|
|
$plan = $preview['plan'];
|
|
$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'], 409, 'Destino diferente do plano.');
|
|
if (app()->environment('testing')) {
|
|
abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base isolada.');
|
|
}
|
|
abort_unless($preview['backup_ready'], 422, 'Backup/restauro/reversão verificados e atualizados são necessários.');
|
|
|
|
return DB::transaction(function () use ($plan, $hash, $actor, $reverse, $reason) {
|
|
DB::select('SELECT pg_advisory_xact_lock(81026, 160000)');
|
|
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->lockForUpdate()->first();
|
|
if ((! $reverse && $last?->action === 'traceability_applied') || ($reverse && $last?->action === 'traceability_reversed')) {
|
|
return ['replayed' => true, 'changed' => 0];
|
|
}
|
|
abort_if(! $reverse && $last?->action === 'traceability_reversed', 409, 'Plano revertido; prepare um novo plano com o estado atual.');
|
|
$before = [];
|
|
$applied = ['updates' => []];
|
|
if ($reverse) {
|
|
abort_unless($last?->action === 'traceability_applied', 409, 'Não há aplicação para reverter.');
|
|
$applied = json_decode($last->after_data, true);
|
|
foreach ($applied['updates'] as $update) {
|
|
$this->checkGuard(['table' => 'cluster_projects', 'id' => $update['id'], 'expected' => $update['after']]);
|
|
$values = $update['before'];
|
|
$values['version_no'] = $update['after']['version_no'] + 1;
|
|
DB::table('cluster_projects')->where('id', $update['id'])->update($values);
|
|
$before[] = ['id' => $update['id'], 'before' => $update['after'], 'after' => $values];
|
|
}
|
|
} else {
|
|
foreach ($plan['guards'] as $guard) {
|
|
$this->checkGuard($guard);
|
|
}
|
|
foreach ($plan['updates'] as $update) {
|
|
abort_unless($update['table'] === 'cluster_projects' && array_diff(array_keys($update['values']), ['source_reference', 'evidence_reference']) === [], 422, 'Alteração fora do âmbito da rastreabilidade.');
|
|
$row = DB::table('cluster_projects')->where('id', $update['id'])->first();
|
|
$after = [...$update['values'], 'version_no' => $row->version_no + 1];
|
|
$before[] = ['id' => $row->id, 'before' => ['source_reference' => $row->source_reference, 'evidence_reference' => $row->evidence_reference, 'version_no' => $row->version_no], 'after' => $after];
|
|
DB::table('cluster_projects')->where('id', $row->id)->update($after);
|
|
}
|
|
}
|
|
DB::table('audit_events')->insert(['source_system' => 'territory-traceability-v2', 'entity_type' => 'territorial_sources', 'actor_user_id' => $actor,
|
|
'action' => $reverse ? 'traceability_reversed' : 'traceability_applied', 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'),
|
|
'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'reason' => $reverse ? trim($reason) : null, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
|
|
|
|
return ['replayed' => false, 'changed' => $reverse ? count($applied['updates']) : count($before), 'associations' => count($plan['rows'])];
|
|
});
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function sources(string $kind, string|int $id): array
|
|
{
|
|
$visible = match ($kind) {
|
|
'projects' => InvestmentPortfolioController::visible()->where('project_id', $id)->exists(),
|
|
'economic_clusters' => InvestmentPortfolioController::visible()->where('cluster_id', $id)->exists(),
|
|
'investment_zones' => app(InvestmentZones::class)->visible()->whereKey($id)->exists(),
|
|
'opportunities' => Opportunity::published()->whereKey($id)->exists(),
|
|
default => false,
|
|
};
|
|
if (! $visible) {
|
|
return [];
|
|
}
|
|
$events = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->orderBy('occurred_at')->orderBy('id')->get(['action', 'after_data']);
|
|
$plans = [];
|
|
foreach ($events as $event) {
|
|
$data = json_decode($event->after_data, true);
|
|
$plans[$data['plan_hash']] = $event->action === 'traceability_applied' ? $data['rows'] : [];
|
|
}
|
|
$sources = [];
|
|
foreach ($plans as $rows) {
|
|
foreach ($rows as $row) {
|
|
if ($row['kind'] === $kind && (string) $row['record_id'] === (string) $id && ($row['public'] ?? false)) {
|
|
if (! empty($row['dossier_id']) && ! InvestmentPortfolioController::visible()->whereKey($row['dossier_id'])->exists()) {
|
|
continue;
|
|
}
|
|
if (! empty($row['public_location_id']) && ! app(InvestmentRelations::class)->locations([['id' => $row['public_location_id'], 'role' => $row['role']]])) {
|
|
continue;
|
|
}
|
|
if (! empty($row['association_id']) && ! app(InvestmentRelations::class)->memberships()->where('cp.id', $row['association_id'])->exists()) {
|
|
continue;
|
|
}
|
|
$sources[] = array_intersect_key($row, array_flip(['association', 'role', 'source_reference', 'source_quote', 'supports', 'traceability', 'official_source_accepted', 'existing_validation']));
|
|
}
|
|
}
|
|
}
|
|
|
|
return $sources;
|
|
}
|
|
}
|