304 lines
21 KiB
PHP
304 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Support\CheckedFile;
|
|
use App\Support\IsolatedQaDatabase;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Exception\ExceptionInterface;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/** Owner-authorised release; schema and data still require existing session MFA. */
|
|
final class DemographicInstallation
|
|
{
|
|
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
|
|
|
|
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
|
|
|
|
public const FILES = [
|
|
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
|
|
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
|
|
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
|
|
];
|
|
|
|
/** @return array<string,mixed> */
|
|
public static function remainingManifest(): array
|
|
{
|
|
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public static function zambeziaManifest(): array
|
|
{
|
|
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public static function publicDistrictRefs(): array
|
|
{
|
|
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
|
|
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
|
|
foreach ($manifest['new_districts'] as $province => $names) {
|
|
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
|
foreach ($names as $name) { $refs[] = $source.':'.$name; }
|
|
}
|
|
}
|
|
return $refs;
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function files(): array
|
|
{
|
|
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
|
|
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
|
|
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
|
|
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
|
|
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
|
|
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
|
|
'resources/js/pages/management/shared.tsx'];
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
$files = [];
|
|
foreach ($this->files() as $path) {
|
|
$files[$path] = CheckedFile::sha256(base_path($path));
|
|
}
|
|
|
|
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
public function activeTarget(): bool
|
|
{
|
|
$c = config('database.connections.pgsql');
|
|
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
|
|
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
|
|
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|
|
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|
|
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
|
|
return false;
|
|
}
|
|
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
|
|
|
return $target->database === $c['database'] && (int) $target->port === 5432;
|
|
}
|
|
|
|
public function schemaAllowed(): bool
|
|
{
|
|
if (IsolatedQaDatabase::allowsSchemaChanges()) {
|
|
return true;
|
|
}
|
|
$actor = auth()->user();
|
|
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
|
|
return false;
|
|
}
|
|
app(PortalProfileInstallation::class)->authorize($actor, true);
|
|
|
|
return true;
|
|
}
|
|
|
|
private function authorize(User $actor, bool $write): void
|
|
{
|
|
app(PortalProfileInstallation::class)->authorize($actor, $write);
|
|
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function preview(User $actor): array
|
|
{
|
|
$this->authorize($actor, false);
|
|
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
|
|
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
|
|
$code = $this->fingerprint();
|
|
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
|
$qa = IsolatedQaDatabase::allowsSchemaChanges();
|
|
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
|
|
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
|
|
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
|
|
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
|
|
if (! $qa) {
|
|
foreach (['dump', 'files'] as $kind) {
|
|
$file = $proof[$kind.'_path'] ?? '';
|
|
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
|
|
}
|
|
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
|
|
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
|
|
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
|
|
$file = public_path('build/'.$relative);
|
|
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
|
|
}
|
|
}
|
|
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
|
|
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
|
|
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
|
|
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
|
|
try {
|
|
$runtime->run();
|
|
$runtimeReady = $runtime->isSuccessful();
|
|
} catch (ExceptionInterface) {
|
|
$runtimeReady = false;
|
|
}
|
|
$mode = $ledger ? 'remaining' : 'initial';
|
|
$manifest = self::remainingManifest();
|
|
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
|
|
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
|
|
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
|
|
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
|
|
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
|
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
|
|
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
|
|
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
|
|
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
|
|
|
|
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
|
|
}
|
|
|
|
/** @param list<string> $preservedHashes
|
|
* @return array<string,array{count:int,sha256:string}> */
|
|
private function protectedState(array $preservedHashes = []): array
|
|
{
|
|
$state = [];
|
|
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
|
|
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
|
|
if (Schema::hasTable($table)) {
|
|
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
|
|
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
|
|
}
|
|
}
|
|
|
|
if (Schema::hasTable('demographic_imports')) {
|
|
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
|
|
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
|
|
$digest = hash_init('sha256');
|
|
$count = 0;
|
|
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
|
|
foreach ($rows as $row) {
|
|
if ($count > 0) { hash_update($digest, "\n"); }
|
|
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
|
|
$count++;
|
|
}
|
|
});
|
|
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
|
|
}
|
|
}
|
|
return $state;
|
|
}
|
|
|
|
private function territories(User $actor): void
|
|
{
|
|
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
|
|
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
|
|
foreach (['Macate', 'Vanduzi'] as $name) {
|
|
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
|
|
if ($matches->isNotEmpty()) {
|
|
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
|
|
|
|
continue;
|
|
}
|
|
$id = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
|
|
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
|
|
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
|
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
|
|
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
|
|
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $manifest */
|
|
private function remainingTerritories(User $actor, array $manifest): void
|
|
{
|
|
foreach ($manifest['new_districts'] as $province => $names) {
|
|
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
|
|
abort_unless($parents->count() === 1, 409);
|
|
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
|
|
foreach ($names as $name) {
|
|
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
|
|
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
|
|
$id = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
|
|
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
public function apply(User $actor, array $input): array
|
|
{
|
|
$this->authorize($actor, true);
|
|
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
|
|
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
|
$mode = $data['mode'] ?? 'initial';
|
|
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
|
|
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
|
|
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
|
|
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
|
|
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
|
if ($existing) {
|
|
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
|
|
|
return [...$event['result'], 'repeated' => true];
|
|
}
|
|
$plan = $this->preview($actor);
|
|
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
|
|
set_time_limit(300);
|
|
|
|
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
|
|
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
|
|
// Recheck after the lock as another request may have completed while we waited.
|
|
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
|
|
if ($existing) {
|
|
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
|
|
|
|
return [...$event['result'], 'repeated' => true];
|
|
}
|
|
$before = $this->protectedState($preservedHashes);
|
|
$previous = config('demography.authenticated_installation');
|
|
config(['demography.authenticated_installation' => true]);
|
|
try {
|
|
if ($mode === 'initial') {
|
|
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
|
|
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
|
|
} finally {
|
|
config(['demography.authenticated_installation' => $previous]);
|
|
}
|
|
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
|
|
$receipts = [];
|
|
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
|
|
$service = app(DemographicImport::class);
|
|
foreach ($files as $name => $expected) {
|
|
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
|
|
if ($old && $old->status === 'approved') {
|
|
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
|
|
abort_unless($count === $expected['records'], 409);
|
|
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
|
|
continue;
|
|
}
|
|
$file = $directory.'/'.$name;
|
|
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
|
|
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
|
|
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
|
|
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
|
|
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
|
|
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
|
|
}
|
|
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
|
|
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
|
|
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
|
|
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
|
|
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
|
|
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
}
|