['sha256' => self::MANICA_SHA256, 'records' => 71604], 'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620], 'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482], ]; /** @return array */ 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 */ 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 */ 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 */ 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 */ 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 $preservedHashes * @return array */ 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 $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 $input * @return array */ 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; }); } }