Files
Investidor-V2/app/Services/TerritoryGeometryImport.php
T
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

268 lines
21 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/** Imports only the retained, accepted INE edition. Publication is a separate action. */
class TerritoryGeometryImport
{
public function fingerprint(): string
{
return hash('sha256', implode('', array_map(fn ($f) => hash_file('sha256', $f), [__FILE__, app_path('Services/RetainedGeoJsonCollection.php'), config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), base_path('routes/web.php'), resource_path('js/pages/management/territory-geometry-import.tsx'),
base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'),
resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')])));
}
private function guard(User $actor, bool $write = false): void
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless(config('geometry-import.enabled'), 503, 'Importação cartográfica não disponibilizada.');
if (! $write) {
return;
}
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme a autenticação de dois factores para aplicar ou reverter cartografia.');
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441 && config('database.connections.pgsql.host') === '127.0.0.1';
if ($qa) {
return;
}
$file = storage_path('app/private/investmoz-v2/geometry-import-activation-proof.json');
$p = is_file($file) ? json_decode($this->readFile($file), true) : [];
abort_unless(($p['restore_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['ui_passed'] ?? false)
&& ($p['code_hash'] ?? '') === $this->fingerprint() && ($p['target']['database'] ?? '') === $target->database
&& (int) ($p['target']['port'] ?? 0) === (int) $target->port && strtotime($p['verified_at'] ?? '') >= time() - 86400
&& is_file($p['dump_path'] ?? '') && hash_file('sha256', $p['dump_path']) === ($p['sha256'] ?? null)
&& ($p['files_verified'] ?? false) && is_file($p['files_path'] ?? '') && hash_file('sha256', $p['files_path']) === ($p['files_sha256'] ?? null), 422, 'Backup/restauro e provas desta revisão e alvo necessários; preparação não autoriza ativação.');
}
/** @return array<string,mixed> */
public function preview(User $actor, int $level): array
{
$this->guard($actor);
abort_unless(in_array($level, [1, 2, 3], true), 422);
$directory = config('geometry-import.directory');
$file = $directory.'/moz-adm'.$level.'-ine-20190607.geojson';
$metadata = $directory.'/moz-adm'.$level.'-ine-metadata.html';
abort_unless(is_file($file) && is_file($metadata), 422, 'Exemplar privado indisponível.');
$text = $this->readFile($metadata);
abort_unless(str_contains($text, 'Mozambique INE - Instituto Nacional de Estatistica') && str_contains($text, '2019-06-07'), 422, 'Metadados do exemplar divergentes.');
$sourceHash = hash_file('sha256', $file);
$collection = new RetainedGeoJsonCollection($file);
$source = $collection->manifest();
abort_unless(($source['type'] ?? '') === 'FeatureCollection' && $source['feature_count'] === [1 => 11, 2 => 159, 3 => 411][$level] && ! ($source['exceededTransferLimit'] ?? false), 422, 'Exemplar incompleto.');
$locations = DB::table('geo_locations')->where('location_type', [1 => 'province', 2 => 'district', 3 => 'admin_post'][$level])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'source_system', 'source_code', 'external_id']);
$crosswalk = config('geometry-correspondences');
$correspondences = $crosswalk['rows'][$level] ?? [];
abort_if($correspondences && (($crosswalk['edition'] ?? null) !== '2019-06-07' || ($crosswalk['source_sha256'][$level] ?? null) !== $sourceHash), 422, 'Correspondências não pertencem a este exemplar; nenhuma aproximação aplicada.');
$normalize = fn ($n) => strtolower(trim(Str::ascii($n)));
$countryNames = array_map($normalize, [$source['first_properties']['ADM0_PT'], $source['first_properties']['ADM0_EN']]);
$countries = DB::table('geo_locations')->where('location_type', 'country')->whereNull('deleted_at')->get(['id', 'name'])->filter(fn ($c) => in_array($normalize($c->name), $countryNames, true))->values();
$countryId = count($countries) === 1 ? $countries[0]->id : null;
$parentSourceHash = $level > 1 ? hash_file('sha256', $directory.'/moz-adm'.($level - 1).'-ine-20190607.geojson') : null;
$rows = [];
foreach ($collection->features() as $feature) {
$p = $feature['properties'];
abort_unless(($p['ADM0_PCODE'] ?? '') === 'MZ', 422, 'Âmbito territorial do exemplar divergente.');
$external = '2019-06-07:adm'.$level.':'.$p['ADM'.$level.'_PCODE'];
$parent = $level > 1 ? DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', '2019-06-07:adm'.($level - 1).':'.$p['ADM'.($level - 1).'_PCODE'])->where('properties->source_sha256', $parentSourceHash)->whereNull('deleted_at')->value('geo_location_id') : $countryId;
$matches = $locations->filter(fn ($l) => $normalize($l->name) === $normalize($p['ADM'.$level.'_PT']) && $parent && $l->parent_id === $parent)->values();
$correspondence = $correspondences[$p['ADM'.$level.'_PCODE']] ?? null;
$matchBasis = 'unique_exact_name_and_matched_parent_after_case_and_diacritics';
if ($correspondence) {
abort_unless($correspondence['source_name'] === $p['ADM'.$level.'_PT'] && $correspondence['parent_source_code'] === $p['ADM'.($level - 1).'_PCODE'], 422, 'Nome/pai do exemplar divergente da correspondência explícita.');
$matches = $locations->filter(fn ($l) => $parent && $l->parent_id === $parent
&& $normalize($l->name) === $normalize($correspondence['target_name']) && $l->source_code === $correspondence['target_source_code']
&& $l->source_system === $crosswalk['catalogue_source_system'] && str_starts_with($l->external_id ?? '', $crosswalk['catalogue_sha256'].':'))->values();
$matchBasis = 'explicit_versioned_correspondence';
}
$row = ['external_id' => $external, 'name' => $p['ADM'.$level.'_PT'], 'parent_name' => $level > 1 ? $p['ADM'.($level - 1).'_PT'] : null,
'parent_code' => $level > 1 ? $p['ADM'.($level - 1).'_PCODE'] : null, 'candidates' => $matches->all(), 'location_id' => count($matches) === 1 ? $matches[0]->id : null,
'status' => count($matches) === 1 ? 'insert' : (count($matches) > 1 ? ($correspondence ? 'correspondence_conflict' : 'ambiguous_name') : (! $parent ? 'parent_unresolved' : 'name_unresolved')),
'match_basis' => $matchBasis, 'correspondence' => $correspondence, 'correspondence_version' => $correspondence ? $crosswalk['version'] : null];
if ($row['location_id']) {
$geometry = json_encode($feature['geometry'], JSON_THROW_ON_ERROR);
$valid = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_NDims(g)=2 AND GeometryType(g) IN (\'POLYGON\',\'MULTIPOLYGON\') AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) q', [$geometry]);
if (! $valid->valid) {
$row['status'] = 'invalid_geometry';
} else {
$existing = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', $external)->first();
if ($existing) {
$props = json_decode($existing->properties, true);
$same = DB::selectOne('SELECT ST_Equals(geom,ST_SetSRID(ST_GeomFromGeoJSON(?),4326)) AS same FROM geo_geometries WHERE id=?', [$geometry, $existing->id])->same;
$row['status'] = $same && $existing->geo_location_id === $row['location_id'] && ($props['source_sha256'] ?? null) === $sourceHash && ! $existing->deleted_at ? 'reuse' : 'identity_conflict';
$row['geometry_id'] = $existing->id;
$row['version'] = $existing->version_no;
$row['classification'] = $existing->data_classification;
} elseif (DB::table('geo_geometries')->where('geo_location_id', $row['location_id'])->where('geometry_type', strtolower($feature['geometry']['type']))->whereNull('deleted_at')->exists()) {
$row['status'] = 'territory_geometry_conflict';
}
}
}
$rows[] = $row;
}
$scanFile = $directory.'/geometry-source-scans.json';
$scans = is_file($scanFile) ? json_decode($this->readFile($scanFile), true) : [];
$scan = null;
foreach ($scans['files'] ?? [] as $s) {
if ($s['file'] === basename($file) && $s['sha256'] === $sourceHash) {
$scan = ($s['scan']['result'] ?? '') === 'clean' ? $s['scan'] : ($s['gis_scan'] ?? null);
}
}
$scanReady = ($scan['result'] ?? '') === 'clean' && ($scan['engine'] ?? '') === 'ClamAV' && strtotime($scans['captured_at'] ?? '') >= time() - config('geometry-import.scan_max_age_seconds');
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$plan = ['level' => $level, 'target' => $target, 'edition' => '2019-06-07', 'source_sha256' => $sourceHash, 'metadata_sha256' => hash_file('sha256', $metadata), 'code_hash' => $this->fingerprint(),
'correspondence_version' => $crosswalk['version'], 'correspondence_sha256' => hash('sha256', json_encode($crosswalk, JSON_THROW_ON_ERROR)),
'expected' => count($rows), 'resolved' => count(array_filter($rows, fn ($r) => in_array($r['status'], ['insert', 'reuse'], true))), 'rows' => $rows, 'scan_ready' => $scanReady, 'scan' => $scan, 'scan_at' => $scans['captured_at'] ?? null, 'published' => false];
$plan['pending'] = $plan['expected'] - $plan['resolved'];
$plan['hash'] = hash_hmac('sha256', json_encode([$actor->id, $plan], JSON_THROW_ON_ERROR), config('app.key'));
return $plan;
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function apply(User $actor, array $input): array
{
$this->guard($actor, true);
$data = Validator::make($input, ['level' => 'required|integer|in:1,2,3', 'hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
return $this->receipt($actor, 'geometry-import-apply', $data, function () use ($actor, $data) {
$plan = $this->preview($actor, (int) $data['level']);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Correspondências, fonte ou estado alterados; repita a pré-visualização.');
abort_unless($plan['scan_ready'], 423, 'Scan limpo atual ligado ao original necessário.');
abort_if(count(array_filter($plan['rows'], fn ($r) => str_contains($r['status'], 'conflict') || $r['status'] === 'invalid_geometry')) > 0, 409, 'Conflito de identidade/geometria: nenhuma escrita aplicada.');
$sourcePath = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-20190607.geojson';
abort_unless(hash_file('sha256', $sourcePath) === $plan['source_sha256'], 409);
$collection = new RetainedGeoJsonCollection($sourcePath);
$metadata = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-metadata.html';
$metadataSize = filesize($metadata);
abort_unless(is_int($metadataSize), 422, 'Metadados indisponíveis durante a aplicação.');
$metadataScan = app(MalwareScanner::class)->scan($metadata, $metadataSize, storage_path('app/private'));
abort_unless($metadataScan['result'] === 'clean', 423, 'Scan dos metadados do exemplar pendente ou inconclusivo.');
abort_unless(hash_file('sha256', $metadata) === $plan['metadata_sha256'], 409, 'Metadados alterados durante o scan.');
$metadataScan['signatures_at'] = $metadataScan['signatures_at']?->format(DATE_ATOM);
$batch = (string) Str::uuid();
$created = [];
$reused = [];
foreach ($collection->features() as $i => $feature) {
$row = $plan['rows'][$i];
if ($row['status'] === 'reuse') {
$reused[] = $row['geometry_id'];
}
if ($row['status'] !== 'insert') {
continue;
}
$id = (string) Str::uuid();
$trace = ['authority' => 'Mozambique INE - Instituto Nacional de Estatistica', 'provider' => 'UNOCHA; exemplar hosted by World Bank', 'source_version' => $plan['edition'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'source_url' => 'https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer/'.($data['level'] + 2), 'source_properties' => $feature['properties'], 'source_scan' => $plan['scan'], 'batch_id' => $batch, 'purpose' => 'administrative_boundary', 'investment_site' => false, 'legal_currentness' => 'not_determined', 'match_basis' => $row['match_basis'], 'correspondence' => $row['correspondence'], 'correspondence_version' => $row['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256']];
DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?::jsonb,?)', [$id, 'ine-cod-ab', $row['external_id'], $row['location_id'], strtolower($feature['geometry']['type']), json_encode($feature['geometry'], JSON_THROW_ON_ERROR), json_encode($trace, JSON_THROW_ON_ERROR), 'internal']);
$created[] = ['id' => $id, 'checksum' => $this->snapshotHash($id)];
}
$result = ['batch_id' => $batch, 'level' => $data['level'], 'preview_hash' => $plan['hash'], 'expected' => $plan['expected'], 'resolved' => $plan['resolved'], 'created' => $created, 'inserted' => count($created), 'reused' => count($reused), 'reused_ids' => $reused, 'pending' => $plan['pending'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'correspondence_version' => $plan['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256'], 'metadata_scan' => $metadataScan, 'published' => false, 'reason' => $data['reason']];
$this->audit($actor, $batch, 'geometry_import_applied', $result);
return $result;
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function reverse(User $actor, string $batch, array $input): array
{
$this->guard($actor, true);
$data = Validator::make([...$input, 'batch_id' => $batch], ['batch_id' => 'required|uuid', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
return $this->receipt($actor, 'geometry-import-reverse', $data, function () use ($actor, $data, $batch) {
$event = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_applied')->first();
abort_unless($event !== null, 404);
$old = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_reversed')->first();
if ($old) {
return json_decode($old->after_data, true);
}
$manifest = json_decode($event->after_data, true);
foreach ($manifest['created'] as $row) {
$geometry = DB::table('geo_geometries')->where('id', $row['id'])->lockForUpdate()->first();
abort_unless($this->snapshotHash($row['id']) === $row['checksum'], 409, 'Geometria alterada ou publicada; reversão recusada.');
// Source hierarchy is a real import dependency even though it is not a geometry FK.
if (in_array((int) $manifest['level'], [1, 2], true)) {
$properties = json_decode($geometry->properties, true, 512, JSON_THROW_ON_ERROR);
$parentField = 'ADM'.$manifest['level'].'_PCODE';
$code = $properties['source_properties'][$parentField] ?? null;
abort_unless($code, 409, 'Correspondência original sem código territorial; reversão exige reconciliação.');
$descendants = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->whereNull('deleted_at')
->where('properties->source_version', $properties['source_version'])
->where('properties->source_properties->'.$parentField, $code)
->where(function ($query) use ($manifest) {
foreach (range((int) $manifest['level'] + 1, 3) as $level) {
$query->orWhere('external_id', 'like', '2019-06-07:adm'.$level.':%');
}
});
abort_if($descendants->exists(), 409, 'Limites descendentes dependem desta correspondência. Reverta primeiro postos, depois distritos e províncias; preserve os lotes anteriores.');
}
// Enumerate all real FKs, rather than assuming one consumer table.
$references = DB::select("SELECT n.nspname AS schema,t.relname AS table_name,a.attname AS column_name FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=c.conkey[1] WHERE c.contype='f' AND c.confrelid='geo_geometries'::regclass");
foreach ($references as $ref) {
abort_if(DB::table($ref->schema.'.'.$ref->table_name)->where($ref->column_name, $row['id'])->exists(), 409, 'Geometria associada a um consumidor; preserve a relação.');
}
}
foreach ($manifest['created'] as $row) {
DB::table('geo_geometries')->where('id', $row['id'])->update(['deleted_at' => now(), 'updated_at' => now(), 'version_no' => DB::raw('version_no+1')]);
}
$result = ['batch_id' => $batch, 'reversed' => count($manifest['created']), 'reused_preserved' => $manifest['reused'], 'published' => false, 'reason' => $data['reason']];
$this->audit($actor, $batch, 'geometry_import_reversed', $result);
return $result;
});
}
private function snapshotHash(string $id): string
{
$r = DB::selectOne('SELECT row_to_json(q)::text AS value FROM (SELECT *,encode(ST_AsEWKB(geom),\'hex\') AS binary_geometry FROM geo_geometries WHERE id=?) q', [$id]);
return hash('sha256', $r->value ?? 'missing');
}
/** @param array<string,mixed> $data
* @param callable():array<string,mixed> $operation
* @return array<string,mixed>
*/
private function receipt(User $actor, string $scope, array $data, callable $operation): array
{
return DB::transaction(function () use ($actor, $scope, $data, $operation) {
DB::select('SELECT pg_advisory_xact_lock(91026,20190607)');
DB::select('SELECT pg_advisory_xact_lock(280926,150000)');
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$receipt = DB::table('geo_write_receipts')->where('actor_id', $actor->id)->where('scope', $scope)->where('idempotency_key', $data['idempotency_key'])->first();
if ($receipt) {
abort_unless(hash_equals(trim($receipt->request_hash), $hash), 409, 'Chave reutilizada com outro pedido.');
return json_decode($receipt->response, true);
}
$result = $operation();
DB::table('geo_write_receipts')->insert(['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'scope' => $scope, 'idempotency_key' => $data['idempotency_key'], 'request_hash' => $hash, 'response' => json_encode($result, JSON_THROW_ON_ERROR), 'http_status' => 200]);
return $result;
});
}
/** @param array<string,mixed> $data */
private function audit(User $actor, string $batch, string $action, array $data): void
{
DB::table('audit_events')->insert(['source_system' => 'geometry-import', 'entity_type' => 'geo_geometries', 'entity_id' => $batch, 'correlation_id' => $batch, 'actor_user_id' => $actor->id, 'action' => $action, 'after_data' => json_encode($data, JSON_THROW_ON_ERROR), 'data_classification' => 'internal']);
}
private function readFile(string $path): string
{
$contents = file_get_contents($path);
abort_unless(is_string($contents), 422, 'Exemplar/prova indisponível durante a leitura; operação recusada.');
return $contents;
}
}