Deliver additive INE demography and authenticated homologation installation
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-13 13:27:36 +02:00
parent 880e12890c
commit b20068d04b
31 changed files with 2971 additions and 2 deletions
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class DemographyController extends Controller
{
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if ($request->expectsJson()) {
return response()->json($evidence);
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
{
if (! Schema::hasTable('demographic_imports')) {
return redirect('/management/demography/install');
}
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
return Inertia::render('management/demography', ['qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
public function installation(Request $request, DemographicInstallation $service): Response
{
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
}
public function install(Request $request, DemographicInstallation $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function prepare(Request $request, DemographicImport $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
$file = $request->file('file');
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
}
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
}
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
$service->transition($request->user(), $id, $data['action'], $data['reason']);
return response()->json(['id' => $id, 'status' => $data['action']]);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DemographicEvidence
{
public function territories(): Builder
{
$public = app(InvestmentGeography::class)->publicReferences();
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
->where(function ($q) use ($public) {
$q->whereIn('id', (clone $public)->select('id'));
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
}
});
}
public function installed(): bool
{
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
}
/** @return array<string,string> */
public static function sectors(): array
{
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
}
/** @param list<string> $territories
* @return array<string,mixed> */
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
{
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
if (! $this->installed() || $territories === []) {
return $result;
}
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
foreach ($locations as $location) {
$series = [];
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
$first = $rows->first();
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$values = $rows->filter(function ($r) use ($sector) {
if ($r->indicator !== 'population') {
return in_array($sector, ['health', 'general'], true);
}
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
$years = array_values(array_unique(array_column($values, 'year')));
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
}
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
'factors_to_assess' => match ($sector) {
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
}];
}
// Identify concurrent values without summing editions or choosing a supposedly newer one.
foreach ($result['territories'] as $territory) {
$byDimension = [];
foreach ($territory['series'] as $series) {
foreach ($series['values'] as $row) {
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
}
}
foreach ($byDimension as $key => $values) {
if (count($values) > 1) {
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
}
}
}
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
foreach ($result['territories'] as $i => $left) {
foreach (array_slice($result['territories'], $i + 1) as $right) {
foreach ($left['series'] as $a) {
foreach ($right['series'] as $b) {
foreach (array_unique([$start, $end]) as $year) {
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
if (! $x || ! $y) {
continue;
}
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
}
}
}
}
}
return $result;
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function forContext(array $context): array
{
$fields = $context['fields'] ?? [];
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
// No inference from an influence area to a project's implantation territory.
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
final class DemographicImport
{
public function authorize(User $actor): void
{
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
}
}
/** @return array<string,mixed> */
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
{
$this->authorize($actor);
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
$sha = CheckedFile::sha256($file);
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
if ($existing) {
return $this->preview($actor, $existing->id);
}
$id = (string) Str::uuid();
$directory = 'demography/'.$id;
$disk = Storage::disk('local');
$disk->makeDirectory($directory);
$path = $disk->path($directory.'/original.xlsx');
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
$output = $disk->path($directory.'/preview.json');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
$summary = array_diff_key($report, ['records' => true]);
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
// The supplied publication label is provenance metadata, never an invented edition/date.
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
return $this->preview($actor, $id);
}
public static function key(string $province, string $territory, string $level): string
{
return $province.'|'.$territory.'|'.$level;
}
/** @return array<string,mixed> */
public function preview(User $actor, string $id): array
{
$this->authorize($actor);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
$matches = [];
foreach ($report['territories'] as [$province, $territory, $level]) {
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
$key = self::key($province, $territory, $level);
if ($level === 'district') {
$headers = $report['territory_headers'][$key] ?? [];
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
}
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
}
$manifest = DemographicInstallation::remainingManifest();
$hashes = array_column($manifest['files'], 'sha256');
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
if ($alias !== null) {
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
}
}
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
}
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
}
private function normalize(string $name): string
{
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
}
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
/** @param array<string,string> $mapping
* @return array<string,mixed> */
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
{
$this->authorize($actor);
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
if ($batch->status !== 'prepared') {
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
}
$disk = Storage::disk('local');
$file = $disk->path('demography/'.$id.'/preview.json');
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
$plan = $this->preview($actor, $id);
$resolved = [];
foreach ($plan['matches'] as $match) {
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
if ($match['level'] === 'district') {
$provinceKey = self::key($match['province'], $match['province'], 'province');
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
}
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
$resolved[$match['key']] = $locationId;
}
$chunk = [];
$recordCount = 0;
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
$recordCount++;
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
if (count($chunk) === 500) {
DB::table('demographic_observations')->insert($chunk);
$chunk = [];
}
}
if ($chunk !== []) {
DB::table('demographic_observations')->insert($chunk);
}
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
});
}
/** @param array<string,mixed> $report
* @return iterable<array<string,mixed>> */
private function recordRows(array $report, string $path): iterable
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
if (trim($line) === '') { continue; }
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
public function transition(User $actor, string $id, string $action, string $reason): void
{
$this->authorize($actor);
DB::transaction(function () use ($actor, $id, $action, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
});
}
/** @param array<string,mixed> $details */
private function event(string $id, User $actor, string $action, string $reason, array $details): void
{
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}
+303
View File
@@ -0,0 +1,303 @@
<?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;
});
}
}
+4
View File
@@ -138,6 +138,10 @@ final class InvestmentGeography
$query->where(function ($workbook) {
$workbook->where('source_system', 'cartografia-workbook')
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
})->orWhere(function ($demography) {
// Owner, 13/11/2026: only these source-backed additions are authorised.
$demography->where('source_system', 'ine-demography')->where('location_type', 'district')
->whereIn('external_id', DemographicInstallation::publicDistrictRefs());
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
});
}
@@ -127,6 +127,11 @@ final class InvestmentPreparationWorkspace
{
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
$evidence = [];
$demography = app(DemographicEvidence::class)->forContext($context);
if ($demography['state'] === 'available') {
$evidence['demography'] = ['key' => 'demography', 'label' => 'Contexto demográfico INE', 'current' => $demography,
'notice' => 'Evidência para análise SWOT; não produz conclusões automáticas. Consulte ano, edição e limitações.'];
}
foreach ($context['sector_references'] ?? [] as $reference) {
$current = hash_equals((SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? ''), $reference['source_sha256'] ?? '') ? $reference : ['unavailable' => true, 'source_sha256' => (SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? '')];
$evidence['sector-resource:'.$reference['key']] = ['key' => 'sector-resource:'.$reference['key'], 'label' => $reference['title'], 'consulted' => $reference, 'current' => $current, 'notice' => 'Referência sectorial e de cadeia de valor; não certifica direitos, licenças nem disponibilidade de projecto.'];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Remote activation is explicit; local/QA behaviour remains unchanged.
'enabled' => env('DEMOGRAPHY_ENABLED', env('APP_URL') !== 'https://teste-investidor.tempo.co.mz'
&& in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
'source_directory' => env('DEMOGRAPHY_SOURCE_DIRECTORY', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias'),
// An explicit interpreter avoids Windows Store execution aliases in web workers.
'python' => env('DEMOGRAPHY_PYTHON', 'python'),
];
@@ -0,0 +1,120 @@
{
"files": {
"Cabo Delgado.xlsx": {
"sha256": "419ebf09f26055a2d46dac7027143835ee2e2dc8a91e3b1f498c5b459566ce16",
"records": 92808,
"province": "Cabo Delgado",
"issues": {
"missing_value": 6336
}
},
"Gaza.xlsx": {
"sha256": "b66ccde3478a74a854e8e2c4d51209401d844a15a701b16267e005fa44b70dc4",
"records": 82618,
"province": "Gaza",
"issues": {
"age_total_mismatch": 4,
"missing_value": 2,
"residence_total_mismatch": 4,
"sex_total_mismatch": 1
}
},
"Maputo Provincia.xlsx": {
"sha256": "1bf2921ef43d18817878a6d05e487ce5b107c14aa149ad17bedae6f1c652430e",
"records": 49504,
"province": "Maputo Província",
"issues": {
"cached_formula_value": 42972,
"missing_value": 68
}
},
"Maputo Cidade.xlsx": {
"sha256": "ba72c0b64200e5bdb4b1dda60a0a974bc7ce7c6d28d37a490735437261f1e513",
"records": 44064,
"province": "Maputo Cidade",
"issues": {}
},
"Nampula.xlsx": {
"sha256": "cc960b06d7dfd0cb3f9a16edd94ff8db0451e8f3e24f3ad8442d03f7a053ac3b",
"records": 131400,
"province": "Nampula",
"issues": {
"missing_value": 792
}
},
"Niassa.xlsx": {
"sha256": "3116278d32de729058c7133ccc8204b42f14a0f8ba2b8d0aa8be46c9ff08d160",
"records": 93632,
"province": "Niassa",
"issues": {
"age_total_mismatch": 3,
"cached_formula_value": 12,
"district_total_mismatch": 2,
"missing_value": 4,
"residence_total_mismatch": 1
}
},
"Sofala.xlsx": {
"sha256": "4eaf5ef5e332ed50d5739330e14d78e2ead2551f407de7e31e0d1a96a76c956e",
"records": 77108,
"province": "Sofala",
"issues": {
"age_total_mismatch": 6,
"missing_value": 4,
"residence_total_mismatch": 6
}
},
"Tete.xlsx": {
"sha256": "4bbd7856107c4a3716b533caf7968a691deab4662e42689a7c529b58b80e2d58",
"records": 67932,
"province": "Tete",
"issues": {
"missing_value": 20196
}
}
},
"new_districts": {
"Gaza": [
"Chongoene",
"Limpopo",
"Mapai"
],
"Nampula": [
"Larde",
"Liupo"
],
"Niassa": [
"Chimbonila"
],
"Tete": [
"Doa",
"Marara"
]
},
"aliases": {
"Cabo Delgado|Metuge|district": "PEMBA - METUGE",
"Gaza|Mandlakaze|district": "MANDLACAZE",
"Nampula|Erati|district": "NAMAPA-ERATI",
"Nampula|Ilha de Mocambique|district": "CIDADE ILHA DE MOCAMBIQUE",
"Nampula|Nacala-a-Velha|district": "NACALA-VELHA",
"Nampula|Rapale|district": "NAMPULA",
"Maputo Cidade|KaMphumu|district": "DISTRITO URBANO 1",
"Maputo Cidade|Nlhamankulu|district": "DISTRITO URBANO 2",
"Maputo Cidade|KaMaxakeni|district": "DISTRITO URBANO 3",
"Maputo Cidade|KaMavota|district": "DISTRITO URBANO 4",
"Maputo Cidade|KaMubukwana|district": "DISTRITO URBANO 5",
"Maputo Cidade|KaTembe|district": "DISTRITO DE CATEMBE",
"Maputo Cidade|KaNyaka|district": "DISTRITO DE INHACA",
"Nampula|Nampula|district": "CIDADE DE NAMPULA",
"Niassa|Lichinga|district": "CIDADE DE LICHINGA",
"Gaza|Xai-Xai|district": "CIDADE DE XAI-XAI"
},
"authority": "Pedido de instalar restantes províncias e autorização expressa de oito distritos,13/11/2026. Correspondências históricas apresentadas na prévia autenticada,sem renomear nem fundir IDs.",
"sources": [
"https://archive.gazettes.africa/archive/mz/2013/mz-government-gazette-series-i-dated-2013-12-18-no-101.pdf",
"https://international.ipums.org/international-action/variables/MZ2017A_0436",
"https://archive.gazettes.africa/archive/mz/2020/mz-government-gazette-series-i-dated-2020-05-04-no-83.pdf"
],
"missing_province_source": "Zambézia",
"city_mapping_decision": "Proprietário confirmou expressamente em13/11/2026 a ligação destes três rótulos às respectivas cidades,preservando Rapale,Chimbonila,Limpopo separados."
}
@@ -0,0 +1,23 @@
{
"files": {
"Zambezia.xlsx": {
"sha256": "8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7",
"records": 126684,
"province": "Zambezia",
"issues": {}
}
},
"new_districts": {
"Zambezia": [
"Derre",
"Luabo",
"Mocubela",
"Molumbo",
"Mulevala"
]
},
"aliases": {},
"sources": [],
"authority": "Proprietário autorizou expressamente Derre,Luabo,Mocubela,Molumbo e Mulevala e a incorporação de Zambezia.xlsx em13/11/2026,sem códigos oficiais ou geometria inferidos.",
"missing_province_source": null
}
@@ -0,0 +1,70 @@
<?php
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! app(DemographicInstallation::class)->schemaAllowed()) {
throw new RuntimeException('Demografia: instalação exige QA isolado ou a acção autenticada autorizada.');
}
Schema::create('demographic_imports', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->char('sha256', 64)->unique();
$t->string('file_name');
$t->string('source');
$t->string('publication_version');
$t->string('status')->default('prepared');
$t->jsonb('report');
$t->char('preview_sha256', 64);
$t->foreignId('prepared_by')->constrained('users')->restrictOnDelete();
$t->timestampsTz();
});
Schema::create('demographic_observations', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->unsignedSmallInteger('year');
$t->string('indicator', 80);
$t->string('sex', 10);
$t->string('residence', 10);
$t->string('age_group', 30);
$t->string('unit', 80);
$t->string('data_kind', 20);
$t->string('original_value', 100);
$t->text('numeric_value'); // Exact decimal text, including Excel storage precision.
$t->string('sheet');
$t->string('cell', 20);
$t->string('validation_status', 20);
$t->unique(['import_id', 'geo_location_id', 'year', 'indicator', 'sex', 'residence', 'age_group'], 'demography_dimension_unique');
$t->index(['geo_location_id', 'year', 'indicator']);
});
Schema::create('demographic_events', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('action', 30);
$t->text('reason');
$t->jsonb('details');
$t->timestampTz('created_at')->useCurrent();
});
DB::statement("ALTER TABLE demographic_imports ADD CONSTRAINT demographic_import_state CHECK (status IN ('prepared','imported','approved','disabled'))");
DB::statement("ALTER TABLE demographic_observations ADD CONSTRAINT demographic_observation_state CHECK (validation_status IN ('valid','warning'))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('demographic_imports')->exists()) {
throw new RuntimeException('Preserve originais e histórico demográfico antes de reverter.');
}
Schema::dropIfExists('demographic_events');
Schema::dropIfExists('demographic_observations');
Schema::dropIfExists('demographic_imports');
}
};
@@ -1,3 +1,65 @@
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
## 13/11/2026 — homologação demográfica e instalação operacional preparadas
Proprietário confirmou `investemoz:5432` no portal operacional local, incluindo três ficheiros e Macate/Vanduzi. Backup/restauro conferidos: 296 tabelas/872 ficheiros; cinco testes de instalação na cópia restaurada (21 asserções) e 11 regressões de integração (36 asserções) aprovados. Palavra-passe, TOTP e prévia administrativa QA passaram após corrigir apenas o formato do envelope da conta sintética. Controlos e credenciais operacionais preservados.
Frontend/instalador publicados, `.env` intacto. **Dados activos ainda não instalados**: abrir `https://portal-investidor.test/management/demography/install`, autenticar-se como gestor de instalação habitual com MFA de sessão, conferir destino/ficheiros/advertências e executar **Instalar e disponibilizar**. É o controlo autenticado existente, não uma nova autorização organizacional. A ferramenta de navegador está indisponível; não foi contornada a autenticação. Clientes CLI apresentaram impedimentos na conferência TLS da cadeia local, sem alterações de certificados ou redução da validação.
A acção instala apenas a migração demográfica, acrescenta quando ausentes os dois distritos autorizados e importa/aprova os três exemplares exactos com histórico, hashes, scans e contagens homologadas. Confere preservação de dossiers, simulações, instituições financeiras, oportunidades, competências e projectos/clusters. Repetição conserva contagens. Após executar: verificar três lotes/154 706 observações e comparação territorial no portal; manter as seis advertências de Manica/2031 e as unidades/edições ausentes identificadas.
Recibos `outputs/demography-20261113/release-readiness.json`, `installation-tests.log`, `qa-http-homologation.json`, `operational-status.json`. Relatório e reversão na adenda de `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Não declarar disponibilização dos dados concluída antes de existir o recibo autenticado.
## 13/11/2026 — demografia INE em desenvolvimento/QA
Entrega implementada na base nova `investmoz_demography_test:55441`, sem migração, importação, permissões ou publicação no activo. Inventário consultado: cadastro/caminhos territoriais, mapa, comparação, simulador, percurso e fontes SWOT existentes; provas e aceitações anteriores conservadas. O delta acrescenta consulta demográfica aos consumidores existentes, sem substituir cálculos, contexto, projectos ou fontes. Não foi feita uma nova aceitação global do portal nem declarada reposição de funcionalidade desaparecida.
**Utilização:** em `http://127.0.0.1:18813/localizacoes/demografia`, escolher até quatro províncias/distritos, sector e anos. GONDOLA/GURO permitem comparar a evolução 2017–2050, consultar originais por folha/célula e distinguir edições. Na oportunidade, comparação, simulador e localização documentada de projecto/cluster, abrir “Demografia na análise de viabilidade”; o acesso leva também à selecção temporal. Na preparação/SWOT do dossier, a fonte “Contexto demográfico INE” conserva a revisão e as conclusões manuais existentes. Não equiparar população a procura ou mão-de-obra efectiva.
**Administração QA:** `/management/demography`, usando a permissão de gestão e MFA existentes. Preparar → conferir correspondências/advertências → confirmar → aprovar utilização em QA. Desactivar retira a série das análises e conserva observações/eventos. Edições e hashes coexistem. Manica: 71 604 observações; Inhambane: 82 620; nacional: 482; total 154 706. Macate/Vanduzi existem apenas no cadastro QA por resposta expressa do proprietário. Seis advertências de Manica/2031, 24 valores urbanos/rurais ausentes e sete cabeçalhos vitais sem unidade permanecem visíveis; não foram preenchidos ou corrigidos por inferência.
**Verificação e saldo:** leitor (6 testes), integração (11 testes), regressão frontend de mapa/preparação/simulador (38 testes), TypeScript, análise PHP focada e build QA aprovados. Consulta pública conferida em Chromium a 1440 e 390 px, com dois distritos, filtros conservados e sem overflow da página. Conferência visual administrativa pendente: duas tentativas de navegação após desafio TOTP da conta sintética não avançaram; paragem dessa frente, sem contornar MFA. Permissões/MFA/importação continuam cobertas pelos testes HTTP. PHPStan global tem 19 erros em três ficheiros de pesquisa intactos e a formatação global tem falhas preexistentes; não declarar CI integral aprovado.
Relatório e procedimento de homologação: `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Recibos, hashes, reconciliação de 72 dimensões entre exemplares, screenshots e diagnóstico de navegação em `outputs/demography-20261113/`. Implementação QA disponível; homologação com ressalvas estatísticas e conferência visual administrativa pendentes. Incorporar outras províncias ou instalar no activo continua fora desta execução.
## 11/11/2026 — Mineração e Energia instaladas em f2767818
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
@@ -11125,3 +11125,4 @@ AUD-SIM-L1-P130,Transmissão de bens digitais,"Refinamento Tempo24/10 do saldoL1
AUD-SIM-L1-P131,"Facto gerador/exigibilidade: entrega, serviço, importação, instalação e operações continuadas",Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"G11A/P131 instalado e8b5ae43:goods_available,goods_transport,goods_installation,service_completed,service_early_charge,import_clearance,import_auction,continuous_period,contract_effects. Eventos7(1)a/b/c,7(2),7(3),7(7):disponibilização,início transporte,instalação/montagem concluída,serviço realizado/cobrado ou debitado antecipadamente pelo montante respectivo,desembaraço/arrematação/venda na importação,termo do período sucessivo,efeitos translativos com excepções3(3)a/b. Facto gerador separado de exigibilidade8;factura comercial não desloca evento aduaneiro.",Disponível na homologação e8b5ae43;delta instalado conferido,19frontend/API1/65rollback/tipos/build/EdgeCUA;provas anteriores reutilizadas.,"Demonstrado em QA e entregue na homologação e8b5ae43,no âmbito do ramo","Sem pendência neste recorte de eventos7/exigibilidade8 parametrizada;2026contexto;facto gerador7 e exigibilidade8 por data/âmbito;uma parcela de recebimento e restante por linha,ou parcelas de âmbitos distintos,sem agregação de totais entre linhas. Prazo de emissão é parâmetro legal identificado,não calendário presumido;importação segue7c. P104parcial não aplica câmbio fiscal.Sem nova base/IVA,taxa,dedução,campo11,regularização ou totaisModeloA;P133regimes suspensivos/viagens mantém âmbito separado.","Eventos7(1)a/b/c,7(2),7(3),7(7):disponibilização,início transporte,instalação/montagem concluída,serviço realizado/cobrado ou debitado antecipadamente pelo montante respectivo,desembaraço/arrematação/venda na importação,termo do período sucessivo,efeitos translativos com excepções3(3)a/b. Facto gerador separado de exigibilidade8;factura comercial não desloca evento aduaneiro.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P131/P132 entregue conjuntamente e8b5ae43 sobre92eaa935;backup/restauro174tabelas/369ficheiros e delta conferidos. | Histórico: QAconcluído7e6e462e. | Histórico: PreparaçãoQA P104/P131/P132;118fechos inventariados antes dos testes,sem perdas confirmadas. | Histórico: 25/10/2026 impedimento: Falta motor de eventos7/8 para entrega,serviço,importação,instalação e operações continuadas. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.",Manter encerrado no âmbito entregue;reutilizar provas e conferir apenas deltas afectados.,currencyEvents nosnapshot/API/dossier;convertCents existente exportado semmudarlógica,Backup/restauro/destino autenticado;sem schema/permissões alterados ou ensaios sintéticos no activo.,"CIVA Lei13/2016:7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272),páginas visualmente conferidas.Lei22/2022 eLei10/2025 não alteram7/8;15(8)mantido.RIVADecreto7/2008,21,PDF4:regra histórica5dias úteis e excepções;prazo actual não automatizado,exige disposição/calendário/âmbito identificados.","Desenvolvimento,QA e entrega conjunta concluídos no recorte;L1aberto",Sem esforço funcional pendente neste recorte;não estimativa de todoL1.,"Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-currency-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-currency-events-qa-20261025.json | iva_l1_currency_events_P131_P132_partial_P104_homologation_20261025:manifesto/24marcadores/5hashes/simuladorHTTP200,preservação174tabelas/369ficheiros/29fichas/28relações/408geometrias;QA individual reutilizada",G11A,25/10/2026 — entrega conjuntaP131/P132 e8b5ae43,"{""article"": ""7(1–3);8"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação e8b5ae43;L1aberto
AUD-SIM-L1-P132,"Exigibilidade em uso próprio,comissão,consignação e adiantamentos",Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"G11B/P132 instalado e8b5ae43:own_goods,own_services,commission_delivery,consignment_deadline,advance_payment. Eventos7(4)/(5)/(6) e8c:afectações/transmissões assimiladas3(3)e/f,serviços4(2),disponibilização pelo comissário,termo180dias de mercadorias à consignação3(3)d e pagamentos anteriores à factura. Corrigida remissão a consignação,não embalagensP102.8a/b/c separam parcela recebida e restante,sem dupla contagem.",Disponível na homologação e8b5ae43;delta instalado conferido,19frontend/API1/65rollback/tipos/build/EdgeCUA;provas anteriores reutilizadas.,"Demonstrado em QA e entregue na homologação e8b5ae43,no âmbito do ramo","Sem pendência neste recorte de eventos7/exigibilidade8 parametrizada;2026contexto;facto gerador7 e exigibilidade8 por data/âmbito;uma parcela de recebimento e restante por linha,ou parcelas de âmbitos distintos,sem agregação de totais entre linhas. Prazo de emissão é parâmetro legal identificado,não calendário presumido;importação segue7c. P104parcial não aplica câmbio fiscal.Sem nova base/IVA,taxa,dedução,campo11,regularização ou totaisModeloA;P133regimes suspensivos/viagens mantém âmbito separado.","Eventos7(4)/(5)/(6) e8c:afectações/transmissões assimiladas3(3)e/f,serviços4(2),disponibilização pelo comissário,termo180dias de mercadorias à consignação3(3)d e pagamentos anteriores à factura. Corrigida remissão a consignação,não embalagensP102.8a/b/c separam parcela recebida e restante,sem dupla contagem.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P131/P132 entregue conjuntamente e8b5ae43 sobre92eaa935;backup/restauro174tabelas/369ficheiros e delta conferidos. | Histórico: QAconcluído7e6e462e. | Histórico: PreparaçãoQA P104/P131/P132;118fechos inventariados antes dos testes,sem perdas confirmadas. | Histórico: 25/10/2026 impedimento: Falta exigibilidade dos eventos específicos de uso próprio/comissão/embalagens/adiantamentos. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.",Manter encerrado no âmbito entregue;reutilizar provas e conferir apenas deltas afectados.,currencyEvents nosnapshot/API/dossier;convertCents existente exportado semmudarlógica,Backup/restauro/destino autenticado;sem schema/permissões alterados ou ensaios sintéticos no activo.,"CIVA Lei13/2016:7(1)–(7),PDF8/p1426(266);8,PDF9/p1426(267);15(8),PDF14/p1426(272),páginas visualmente conferidas.Lei22/2022 eLei10/2025 não alteram7/8;15(8)mantido.RIVADecreto7/2008,21,PDF4:regra histórica5dias úteis e excepções;prazo actual não automatizado,exige disposição/calendário/âmbito identificados.","Desenvolvimento,QA e entrega conjunta concluídos no recorte;L1aberto",Sem esforço funcional pendente neste recorte;não estimativa de todoL1.,"Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-currency-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-currency-events-qa-20261025.json | iva_l1_currency_events_P131_P132_partial_P104_homologation_20261025:manifesto/24marcadores/5hashes/simuladorHTTP200,preservação174tabelas/369ficheiros/29fichas/28relações/408geometrias;QA individual reutilizada",G11B,25/10/2026 — entrega conjuntaP131/P132 e8b5ae43,"{""article"": ""7(4–7)"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação e8b5ae43;L1aberto
AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"P133 instalado a48e9749: Cessação efectiva de regime7(8), distinta de mudança de armazém/transferência; viagens7(9)/RIVA40 pelo primeiro entre integral e início/primeiro serviço, sem subtrair dia nem inferir ordem intradiária. Exigibilidade8 por factura/recebimento separada, incluindo sinal e saldo integral sem dupla soma. Sem montante de imposto, desembaraço, direito ou ICE apurados.",Disponível na homologação a48e9749; delta instalado conferido no âmbito demonstrado.,29testes frontend; API privada1teste/68asserções; tipos/build; Edge gravação/recuperação/relatório;10operações/5períodos/12eventos.,"Demonstrado em QA e entregue na homologação a48e9749, no âmbito do ramo","Sem pendência de entrega neste recorte de margem/eventos. Sem cálculo de IVA devido ou dedução. Continuidade do apuramento/liquidação em AUD-SIM-INT02; deduções/regularizações nos lotes próprios, incluindoL2/L3, não encerradas. Factura/modelo oficial/regularização não executados.","Fontes do regime fiscal e evento específico cotejadas; moedaMZN para base, âmbito completo por operador/período, dados/datas/referências concretos. P104 não bloqueia classificação/eventos independentes.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P129/P133 entregues conjuntamente a48e9749 sobre df43aeb1;123fechos preservados; backup/restauro e delta conferidos; limitação ligada a AUD-SIM-INT02. | Histórico: P133 QA concluído no âmbito demonstrado,123fechos preservados,sem repetição de provas aceites. | Histórico: Preparação QA viagens/eventos:123fechos inventariados antes dos testes; nenhuma perda confirmada. | Histórico: 25/10/2026 impedimento: Falta motor de saída de regimes suspensivos e evento das viagens,no regime específico. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.","Manter encerrado no âmbito de margem/eventos demonstrado, reutilizando provas. Conservar ligação ao trabalho fiscal restante em AUD-SIM-INT02: apuramento/liquidação, deduções e regularizações próprias não concluídos por esta entrega.","Snapshot/API/dossier, moeda, datas UTC, histórico e motor de exigibilidade8 existentes; viagem classificada associada à mesma oportunidade, sem copiar edições deliberadas.","Destino autenticado e backup/restauro; sem migração, alteração de permissões, importação/publicação documental ou ensaio sintético no activo.","RIVA Decreto7/2008,38–46,acervoBR16-I16/04/2008PDF6–8/p104–106 (6/7 conferidas visualmente,46(2) texto extraídoPDF8). CIVA Lei13/2016,4(5)/5(2),PDF7/p1426(265);7(8)/(9)/8,PDF9/p1426(267);14(1),PDF13/p1426(271),páginas visualmente conferidas. CIVA17(1),Lei22/2022PDF3,16% usado no divisor da base;Lei10/2025não altera4/7/8/17. Não confundir regime fiscal com licenciamento turístico.","Desenvolvimento, QA e entrega conjunta concluídos no âmbito de margem/eventos; IVA devido/dedução não calculados e trabalho restante rastreável.",Sem esforço de entrega pendente neste recorte; não representa fecho ou estimativa do restante trabalho fiscal.,Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-travel-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-travel-events-qa-20261025.json | iva_l1_travel_events_P129_P133_homologation_20261025:24marcadores/6hashes/simuladorHTTP200;174tabelas/369ficheiros/29fichas/28relações/408geometrias;provasQA reutilizadas;fiscal_continuity:AUD-SIM-INT02,Viagens e eventos P129/P133,25/10/2026 — entrega conjunta a48e9749,"{""article"": ""7(8–9)"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação a48e9749 no âmbito demonstrado;L1 aberto
INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"investemoz:5432:12lotes/920456observações,incluindo Zambézia126684,de2017–2050;cinco distritos novos consultáveis;onze lotes anteriores preservados.",154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO.,Todos os ficheiros provinciais fornecidos instalados/verificados no portal operacional local;12lotes aprovados,Nenhuma importação provincial fornecida pendente. Conferência visual administrativa e limites estatísticos/documentais mantidos;sem extrapolação ou dados fabricados.,PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,,
Can't render this file because it is too large.
@@ -0,0 +1,142 @@
## 13/11/2026 — entrega remota autorizada, em execução
Destino autorizado pelo proprietário: `https://teste-investidor.tempo.co.mz`, base `teste_investidor:5432`. A revisão anterior confirmada é `f2767818`; a demografia ainda não estava instalada nesse servidor. Backup de 190 tabelas e 369 ficheiros com restauro isolado integral aprovado. Doze originais privados transferidos e conferidos por SHA256, sem copiar a base local. O exemplar partilhado actual de Inhambane diverge do aprovado; foi usada a cópia privada aceite, de hash `51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818`, preservando ambas as versões.
Adaptação delimitada: activação explícita de consulta na homologação, identidade real da base conferida e pasta privada configurável para originais. Permanecem autenticação/MFA, backup/restauro, controlo de integridade, operações idempotentes e preservação dos dados. Testes focados: 13/45 asserções de consumidores, 5/12 dos controlos de instalação e 1/6 do destino remoto. A entrega Git não executa a importação nem a aprovação dos lotes: os dados continuam sujeitos à operação autenticada existente. Instalação remota e conferência de contagens ainda por confirmar neste ponto do histórico.
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
# Demografia INE — entrega em desenvolvimento e QA, 13/11/2026
## Adenda posterior — homologação e disponibilização autorizadas em 13/11/2026
O proprietário pediu «homologar e disponibilizar» e confirmou o destino **portal operacional local, `investemoz` em `127.0.0.1:5432`, incluindo a instalação das tabelas, importação dos três ficheiros e Macate/Vanduzi**. Esta decisão substitui, para este recorte concreto, o limite anterior de QA. Não autoriza alterações noutros produtos ou concessão de permissões.
**Estado actual:** homologação funcional em QA aprovada; frontend e instalador publicados no portal operacional. As tabelas e os dados no activo aguardam a execução autenticada em `https://portal-investidor.test/management/demography/install`, com a identidade de instalação e MFA de sessão existentes. Não foi criada ou simulada uma sessão de utilizador operacional. Última conferência: zero observações demográficas no activo e nenhuma execução autenticada registada. A página pública responde, mas ainda comunica indisponibilidade de séries neste destino.
Preparação realizada:
- Backup consistente do activo e restauro numa base nova `demography_restore_20261113_075052_test:55441`: **296 tabelas e 872 ficheiros conferidos**, preservados em armazenamento privado. A primeira comparação diferiu apenas na ordenação das mesmas 14 sessões entre collations dos servidores; a comparação pelo multiconjunto exacto de bytes passou. Nenhuma linha foi omitida ou normalizada para obter igualdade.
- Cinco testes adicionais de instalação, 21 asserções, aprovados na cópia restaurada. Exercitaram migração exacta, importação real de 154 706 observações, novos distritos, repetição sem duplicação, fontes e preservação do estado protegido. As identidades de ensaio e todas as mutações do ensaio ficaram numa transacção QA revertida; não foram usadas identidades operacionais para simular autorização.
- Os 11 testes de regressão da integração voltaram a passar, agora com 36 asserções. Mantêm-se as seis verificações do leitor e os 38 testes frontend já aceites para o recorte inalterado. TypeScript, análise PHP focada e build de disponibilização aprovados. As falhas globais preexistentes continuam separadas.
- A pendência TOTP foi delimitada ao envelope criado para a **conta sintética QA**: `encryptString` era incompatível com o `decrypt` do Fortify. Corrigida apenas essa conta, com `encrypt` serializado; palavra-passe, TOTP real e acesso à prévia administrativa passaram por HTTP autenticado. Nenhuma credencial, autenticação ou permissão operacional foi alterada. Conferência visual administrativa continua dependente da sessão do operador, pois o acesso automatizado ao navegador está indisponível.
- Assets produzidos primeiro em `outputs/demography-20261113/release-build`, conferidos e copiados para `public/build`, mantendo os chunks anteriores para compatibilidade de páginas abertas. Configuração `.env` preservada. Recibo `release-readiness.json`; dump, ficheiros e readiness autenticada mantidos privados. A prova vincula código, manifest, hashes dos assets, destino, backup/restauro e ensaio.
- O instalador reutiliza `PortalProfileInstallation::authorize`, incluindo o MFA efectivamente concluído na sessão. A migração operacional só é permitida dentro dessa acção; não há comando CLI de migração operacional sem actor. A importação utiliza a prévia homologada, confere os hashes exactos e contagens, faz scan dos originais e regista actor, motivo e recibo. Reenvios são serializados e idempotentes. Não é executada qualquer migração pendente fora deste recorte.
- A autorização específica de Macate/Vanduzi é reflectida como origem `ine-demography`, com hash do exemplar e auditoria. Os dois identificadores territoriais ficam disponíveis nos selectores existentes, sem inventar código oficial, limites, geometria, vigência jurídica, licenças ou direitos. A disponibilidade territorial mantém-se independente da desactivação de uma série estatística.
- Smoke test do kernel operacional: instalador anónimo redirecciona para autenticação; página pública e API respondem 200. Esta prova é de aplicação, não de navegador/TLS. O cliente HTTPS Windows não conseguiu conferir revogação e o cliente Python recusou a cadeia por ausência de Authority Key Identifier; não foram desactivadas verificações TLS nem alterados certificados. A navegação real deve ser conferida no navegador habitual, sem ultrapassar avisos de segurança.
**Reversão preparada:** o backup privado contém a configuração, código, documentos e build anteriores. Se for necessário suspender a demografia depois da instalação, desactivar as séries pelo fluxo autenticado, conservando observações e eventos; não usar rollback destrutivo ou eliminar os novos distritos ligados a investimentos. Para reverter a apresentação, repor os manifests/assets do arquivo conferido. Recuperação integral da base exige intervenção delimitada com o dump privado, nunca reposição indiscriminada sobre actividade posterior.
**Acção final do operador:** abrir o instalador, autenticar-se com a conta habitual e MFA, conferir o destino/ficheiros/advertências, confirmar e executar «Instalar e disponibilizar». Depois conferir o recibo autenticado, três lotes aprovados, 154 706 observações, consulta de duas localizações e contagens/checksums protegidos. A decisão de autorização já está registada; esta etapa cumpre o controlo de execução autenticada existente.
Ficheiros adicionais desta adenda: `app/Services/DemographicInstallation.php`, extensão da guarda de `DemographicImport` e da migração, referências em `InvestmentGeography`, métodos/rotas do controlador, `resources/js/pages/management/demography-install.tsx`, rótulos administrativos, `tests/Feature/DemographicInstallationTest.php`, ferramentas de backup/preparação/estado da entrega. Não foi necessário alterar as regras de autenticação.
O restante texto abaixo conserva a entrega QA original e as suas evidências históricas; a adenda acima prevalece quanto à autorização e ao estado actual.
Implementação local, sem instalação ou importação no activo. Base nova `investmoz_demography_test`, PostgreSQL/PostGIS em `127.0.0.1:55441`; servidor QA `http://127.0.0.1:18813`. `.env` operacional não foi alterado. A configuração operacional aponta para `investemoz:5432`; foi usada exclusivamente uma transacção de leitura para inventariar referências territoriais públicas, encerrada com rollback. Não foram copiados investidores, dossiers, credenciais ou outros dados privados.
## Resultado e componentes reutilizados
- Modelo nacional com observações por território/ano/indicador/sexo/idade/residência, unidade, tipo estatístico, valor original exacto, exemplar/hash, folha/célula, importação, validação e histórico. Edições coexistem; não há eliminação ou substituição automática.
- Leitor XLSX por relações, títulos e cabeçalhos; valores decimais conservados como texto numérico exacto. Não executa fórmulas/macros. Duas etapas: preparação privada com scan ClamAV e prévia; confirmação transaccional. Aprovação para utilização e desactivação são acções separadas e auditadas. Hash único do original e chave única das dimensões impedem duplicação.
- Cadastro territorial, identidades e regras de acesso existentes reutilizados. Normalização de acentos/espaços e prefixos administrativos sem correspondência aproximada; ambiguidades ficam pendentes. Correspondências manuais verificam nível, província e fusões indevidas. Macate e Vanduzi acrescentados **somente em QA**, com referência ao hash de Manica, por resposta expressa do proprietário nesta sessão. Nenhuma alteração no cadastro operacional.
- Consulta pública `/localizacoes/demografia`, API `/api/v1/investment/demography`: até quatro territórios, sector, ano inicial/final, horizonte até 50 anos, edições separadas e anos em falta. Diferenças numéricas só para mesmo nível, ano, unidade, tipo e identificação de edição; explicação e fontes visíveis. Sem extrapolação ou percentagem de sucesso.
- Consulta demográfica incorporada no simulador existente, nas oportunidades, na comparação de oportunidades e nas localizações documentadas de projectos/clusters. A comparação existente conserva os seus factores económicos/sectoriais; a demografia acrescenta evidência, sem alterar regras fiscais. Localização principal, implantação e área de influência não são fundidas.
- `InvestmentPreparationWorkspace` acrescenta a evidência demográfica às fontes da SWOT do contexto escolhido; conclusões e revisão continuam no fluxo existente. O contexto deliberado e os dados fiscais não são sobrescritos.
- Administração em `/management/demography`: envio, prévia, correspondências, advertências, confirmação, aprovação QA, desactivação e eventos. Mantidos `manage-portal`, exclusão do auditor, MFA e guarda da identidade real da base QA. Não são concedidos novos papéis ou direitos.
Migração: `database/migrations/2026_11_13_100000_create_demographic_series.php`, com três tabelas: `demographic_imports`, `demographic_observations`, `demographic_events`. A migração só admite QA isolado e recusa rollback que apague histórico existente. O código de consulta está limitado a local/testing; esta entrega não autoriza activação em produção.
## Importações efectivas
| Original | Observações gravadas | Territórios | Anos | Rejeições numéricas |
| --- | ---: | --- | --- | ---: |
| Manica.xlsx | 71 604 | 1 província + 12 distritos | 2017–2050 | 0 |
| Inhambane.xlsx | 82 620 | 1 província + 14 distritos | 2017–2050 | 0 |
| Dados demograficos.xlsx | 482 | 11 províncias | 2017 | 0 |
| Total | 154 706 | 11 províncias + 26 distritos distintos | — | 0 |
Originais preservados, copiados apenas para armazenamento privado QA; scans reais ClamAV 1.5.4 com resultado `clean`. Hashes, correspondências individuais, folhas, anos, recibos e tempos em `outputs/demography-20261113/verification.json`. Preparar/importar/verificar repetição/aprovar demorou aproximadamente 14,1 s para Manica, 15,3 s para Inhambane e 1,5 s para o ficheiro nacional nesta máquina; não é um benchmark de capacidade nacional. Repetir a confirmação manteve as contagens.
Inventário integral da pasta demografia: 22 ficheiros, incluindo originais adicionais, PDFs, documentos e ficheiros temporários, com tamanho e SHA256 em `source-inventory.json`. Os restantes exemplares foram inventariados, sem importação ou activação automática. Não se pressupõe a mesma estrutura para todas as províncias.
## Resultados estatísticos e limitações concretas
Inhambane passou a reconciliação de sexo, residência, grupos etários e soma dos distritos. Em Manica há seis advertências em **2031**, preservadas:
| Quadro/célula | Verificação | Diferença (total menos parcelas) |
| --- | --- | ---: |
| Mossurize, E204 | Sexo | 200824.43870750687 |
| Mossurize, C204 | Residência, homens | 99260.016965195867 |
| Mossurize, D204 | Residência, mulheres | 101564.421742311014 |
| Mossurize, F204 | Soma etária, homens urbanos | -99260.01696519587193 |
| Mossurize, G204 | Soma etária, mulheres urbanas | -101564.42174231099699 |
| Manica, B4 | Total provincial versus distritos | -69384.494114521566 |
Não foram corrigidos valores por inferência. Tolerância de reconciliação: `0.000001` pessoa, exclusivamente para resíduos de representação numérica do Excel; não arredonda valores gravados. As advertências acompanham a série, inclusive quando aprovada em QA, e devem ser consideradas ao usar o ano afectado.
O ficheiro nacional contém população por sexo/residência, três grandes grupos etários e seis famílias de indicadores vitais: esperança de vida, mortalidade infantil, mortalidade bruta, natalidade, fecundidade e crescimento. Esperança de vida e mortalidade infantil estão discriminadas por sexo. Há **sete cabeçalhos de unidade ausente**, abrangendo cinco famílias: três colunas de mortalidade infantil e quatro outros indicadores. Esses valores mantêm unidade `unspecified`; a esperança de vida usa anos. Não são assumidas percentagens ou valores por mil.
Há 24 valores ausentes de população urbana/rural de Maputo Cidade, mantidos ausentes. A folha de população não imprime ano: 2017 foi fornecido expressamente nos metadados de importação, em coerência com a adenda; as folhas etária e vital imprimem 2017. A edição de publicação não consta dos exemplares: o rótulo declara essa ausência e o SHA256 identifica cada versão. As séries anuais dizem “projectada”; o ficheiro nacional não declara observado/projectado, pelo que fica `unspecified`.
Reconciliação independente de população total e grandes grupos etários de Manica/Inhambane em 2017: **72 comparações coincidentes dentro da precisão**, zero divergências/incompletudes nestas comparações. As somas das faixas etárias são evidência calculada de reconciliação, não novos valores oficiais importados. A igualdade numérica não funde exemplares nem prova igualdade de metodologia/edição.
`Outros indicadores` contém somente 11 títulos, sem valores. Escolaridade, pobreza, água, saneamento e electricidade podem usar o modelo por indicador/unidade/dimensões, mas nenhum valor foi inventado. População em idade activa não é mão-de-obra disponível/qualificada; população residente não é procura efectiva ou poder de compra. Densidade não é calculada sem área territorial aplicável.
## Verificação
- Leitor: seis testes automatizados de cabeçalhos deslocados, decimais exactos, duplicados, incompletude, totais e idade — aprovados.
- Integração: 11 testes — aprovados. Cobrem consulta pública, versões, anos ausentes, comparação exacta, confidencialidade territorial/ancestral, estados aprovados/desactivados, permissões, MFA, guarda QA, idempotência, fontes SWOT, hash, chave única e preservação do histórico no rollback.
- Regressão existente: 38 testes frontend de mapa, preparação/SWOT e formulário/cálculos do simulador — aprovados. Não foi executada a suite integral; não se declara aceitação integral do portal.
- TypeScript e análise PHP focada nos serviços/controlador alterados: aprovados. Compilação em `public/build-qa-opportunities`: aprovada; os assets operacionais não foram compilados/publicados.
- Verificações globais: `npm run check` encontrou problemas de formatação em 56 ficheiros, incluindo ficheiros intactos; os quatro componentes/páginas novos foram formatados e passaram lint focado. PHPStan global encontrou 19 erros em `GlobalSearchController`, `FederatedSearch` e `SearchTerms`, ficheiros não alterados por esta tarefa. Estes problemas preexistentes não foram ocultados ou corrigidos fora do âmbito.
- Consulta pública conferida visualmente em Chromium a 1440 e 390 px, com filtros mantidos, duas tabelas, comparação e sem transbordo da página. Conferência visual administrativa pendente: a conta sintética chegou ao desafio TOTP, mas duas tentativas não avançaram após o código. O diagnóstico conservou URL e apresentação; essa frente foi interrompida sem contornar MFA. Alternativa concreta: conferência manual com login/MFA QA, mantendo os testes HTTP já aprovados.
- Provas: `verification.json`, `source-reconciliation.json`, `frontend-regression.log`, `build.log`, `browser-public-verification.json`, `browser-admin-verification.json` e screenshots em `outputs/demography-20261113/`. Conferência visual e saldo final registados no manual existente.
## Homologação e próximas províncias
1. Usar exclusivamente o servidor/base QA identificados acima. As credenciais da conta sintética de ensaio permanecem privadas; não são contas operacionais.
2. Em Administração → Demografia INE, consultar os três lotes, as correspondências e as advertências. Repetir a confirmação de um lote deve conservar as contagens. Desactivar/aprovar em QA deve retirar/repor a série da consulta, mantendo os eventos e observações.
3. Em Localizações → demografia, seleccionar GONDOLA e GURO, sector Indústria, 2017–2050. Conferir anos, faixas etárias, exemplares e diferenças; seleccionar um ano fora da cobertura deve mostrar ausência de dados. Repetir para províncias em 2017 para consultar exemplares nacionais e provinciais separados.
4. Num dossier QA com território seleccionado, abrir preparação/SWOT e conferir `Contexto demográfico INE` como fonte. No simulador, consultar demografia e voltar ao rascunho sem alterar os valores fiscais. Em projectos/clusters, conferir a evidência dentro do papel territorial documentado.
5. Registar a decisão de homologação, incluindo o tratamento das divergências de 2031. Homologar esta entrega não implica instalar, publicar, migrar ou importar no activo.
Recomendação: o modelo/importador estão preparados para **pré-visualizar** os restantes XLSX, um exemplar de cada vez, conferindo cabeçalhos, nomes territoriais, anos e totais antes de confirmar. O inventário também contém XLS legado e indicadores de outras naturezas: exigem adaptador/âmbito próprio, sem converter ou sobrescrever originais. Não se declara já demonstrada a importação das restantes províncias nem se autoriza execução desse trabalho.
@@ -0,0 +1,58 @@
import { useState } from 'react';
import DemographicEvidence, {
type DemographicAnalysis,
} from './demographic-evidence';
export default function DemographicContext({
territoryId,
}: {
territoryId?: string | null;
}) {
const [evidence, setEvidence] = useState<DemographicAnalysis | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
if (!territoryId) return null;
return (
<details className="rounded border p-4">
<summary>Demografia na análise de viabilidade</summary>
<p>
Consulte a população e a estrutura etária da localização
escolhida, com fontes, anos e limitações.
</p>
<button
type="button"
className="my-2 underline"
disabled={busy}
onClick={async () => {
setBusy(true);
setMessage('');
try {
const response = await fetch(
`/api/v1/investment/demography?territories[]=${encodeURIComponent(territoryId)}`,
{ headers: { Accept: 'application/json' } },
);
if (!response.ok)
throw new Error('Consulta indisponível.');
setEvidence(await response.json());
} catch {
setMessage(
'Não foi possível consultar a evidência demográfica.',
);
} finally {
setBusy(false);
}
}}
>
Consultar indicadores do ano actual
</button>
{message && <p role="alert">{message}</p>}
{evidence && <DemographicEvidence evidence={evidence} />}
<a
className="block underline"
href={`/localizacoes/demografia?territories[]=${encodeURIComponent(territoryId)}`}
>
Escolher período, sector e comparar localizações
</a>
</details>
);
}
@@ -0,0 +1,231 @@
export type DemographicAnalysis = {
state: string;
start_year: number;
end_year: number;
limitations: string[];
comparisons: {
year: number;
left: string;
right: string;
left_value: string;
right_value: string;
left_import: string;
right_import: string;
difference: string | null;
explanation: string;
}[];
differences: {
territory_id: string;
dimension: string;
state: string;
values: { import_id: string; value: string; data_kind: string }[];
}[];
territories: {
id: string;
name: string;
level: string;
factors_to_assess: string[];
series: {
import_id: string;
source: string;
version: string;
sha256: string;
file_name: string;
missing_years: number[];
issue_counts: Record<string, number>;
values: {
year: number;
indicator: string;
age_group: string;
residence: string;
value: string;
original_value: string;
unit: string;
data_kind: string;
sheet: string;
cell: string;
validation_status: string;
}[];
}[];
}[];
};
const indicators: Record<string, string> = {
population: 'População',
life_expectancy: 'Esperança de vida',
infant_mortality: 'Mortalidade infantil',
crude_death_rate: 'Mortalidade bruta',
crude_birth_rate: 'Natalidade bruta',
fertility_rate: 'Fecundidade',
population_growth_rate: 'Crescimento populacional',
};
export default function DemographicEvidence({
evidence,
}: {
evidence: DemographicAnalysis;
}) {
return (
<section className="space-y-4" aria-label="Evidência demográfica">
{evidence.state !== 'available' && (
<p>Sem séries demográficas aprovadas para esta selecção.</p>
)}
{evidence.territories.map((t) => (
<article
key={t.id}
className="space-y-3 rounded border bg-white p-4 text-slate-900"
>
<h2 className="text-xl font-semibold">
{t.name} —{' '}
{t.level === 'district' ? 'Distrito' : 'Província'}
</h2>
{t.factors_to_assess.map((f) => (
<p key={f}>{f}</p>
))}
{t.series.length === 0 && (
<p>Sem dados no período escolhido.</p>
)}
{t.series.map((s) => (
<details key={s.import_id} open={t.series.length === 1}>
<summary>
{s.source} · {s.version} · {s.file_name}
</summary>
<p className="text-xs break-all">
SHA256: {s.sha256}
</p>
{s.missing_years.length > 0 && (
<p>
Anos sem dados: {s.missing_years.join(', ')}
</p>
)}
{Object.keys(s.issue_counts).length > 0 && (
<details>
<summary>Advertências da série</summary>
<pre className="overflow-auto text-xs">
{JSON.stringify(
s.issue_counts,
null,
2,
)}
</pre>
</details>
)}
<div className="max-h-96 overflow-auto">
<table className="w-full text-left text-sm">
<caption className="text-left">
Valores originais por ano e dimensão —
versões apresentadas separadamente
</caption>
<thead>
<tr>
{[
'Ano',
'Indicador',
'Idade',
'Residência',
'Valor',
'Unidade',
'Tipo',
'Referência',
].map((h) => (
<th className="p-2" key={h}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{s.values.map((v) => (
<tr
key={`${v.sheet}:${v.cell}`}
className="border-t"
>
<td className="p-2">
{v.year}
</td>
<td>
{indicators[v.indicator] ??
v.indicator}
</td>
<td>
{v.age_group === 'all'
? 'Todas'
: v.age_group}
</td>
<td>
{
{
all: 'Total',
urban: 'Urbana',
rural: 'Rural',
}[v.residence]
}
</td>
<td>{v.original_value}</td>
<td>
{v.unit === 'unspecified'
? 'Não indicada na fonte'
: v.unit}
</td>
<td>
{v.data_kind === 'projected'
? 'Projecção'
: 'Não especificado'}
</td>
<td>
{v.sheet}!{v.cell}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
))}
</article>
))}
{evidence.comparisons.length > 0 && (
<section className="space-y-3">
<h2 className="text-xl font-semibold">
Diferenças entre localizações
</h2>
{evidence.comparisons.map((c) => (
<p
key={`${c.year}:${c.left_import}:${c.right_import}:${c.left}:${c.right}`}
>
{c.year}: {c.left} ({c.left_value}) / {c.right} (
{c.right_value}). Diferença:{' '}
{c.difference ?? 'não comparável'}. {c.explanation}
</p>
))}
</section>
)}
{evidence.differences.length > 0 && (
<details>
<summary>
Reconciliação entre exemplares:{' '}
{evidence.differences.length} dimensões comuns
</summary>
<ul>
{evidence.differences.map((d) => (
<li key={`${d.territory_id}:${d.dimension}`}>
{d.dimension}:{' '}
{d.state === 'matching_values'
? 'valores coincidentes; conservar ambas as fontes'
: 'valores divergentes'}{' '}
—{' '}
{d.values
.map((v) => `${v.value} (${v.data_kind})`)
.join(' / ')}
</li>
))}
</ul>
</details>
)}
<ul className="list-disc pl-5 text-sm">
{evidence.limitations.map((l) => (
<li key={l}>{l}</li>
))}
</ul>
</section>
);
}
@@ -1,4 +1,5 @@
import { SimulationIrpcAnnual } from '@/components/simulation-irpc-annual';
import DemographicContext from '@/components/demographic-context';
import { SimulationIrpcNormal } from '@/components/simulation-irpc-normal';
import { SimulationIrpcWithholdings } from '@/components/simulation-irpc-withholdings';
import { SimulationIspcResiduals } from '@/components/simulation-ispc-residuals';
@@ -3077,6 +3078,10 @@ export function SimulationForm() {
))}
</ol>
</nav>
<DemographicContext
key={String(draft.district_id ?? draft.province_id ?? '')}
territoryId={String(draft.district_id || draft.province_id || '')}
/>
<progress
className="h-2 w-full accent-emerald-800"
aria-label="Progresso do formulário"
@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react';
import DemographicContext from './demographic-context';
import TerritorialSources, {
type TerritorialSourceEvidence,
} from './territorial-source-evidence';
@@ -95,6 +96,12 @@ export function LocationList({
</div>
))}
</dl>
<DemographicContext
key={location.id}
territoryId={location.path
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
{location.coordinates.map((point) => (
<p key={point.geometry_id}>
Coordenadas documentadas (
+33 -2
View File
@@ -3,6 +3,7 @@ import {
persistOpportunitySelection,
} from '@/lib/opportunity-selection';
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
import DemographicContext from '@/components/demographic-context';
import OpportunityCorridorGroups, {
type CorridorGroup,
} from '@/components/opportunity-corridor-groups';
@@ -22,7 +23,7 @@ import type { QuantitativeMapContext } from '@/components/investment-map-indicat
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
import { type TerritoryOption } from '@/components/investment-territory';
import { Frame, button, card, money, api } from './shared';
import type { Opportunity, References } from './shared';
import type { Opportunity, References, Context } from './shared';
import {
ArrowUpRight,
Bookmark,
@@ -171,7 +172,9 @@ export default function Catalogue({
const [territoryPath, setTerritoryPath] = useState(territorySelection);
const [saved, setSaved] = useState(favourites);
const [selected, setSelected] = useState<number[]>([]);
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
const [comparison, setComparison] = useState<
{ record: Opportunity; context?: Context }[]
>([]);
const comparisonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (view === 'corridors' && comparison.length)
@@ -406,6 +409,21 @@ export default function Catalogue({
: 'Catálogo de investimento'
}
>
<Link
className="underline"
href={
'/localizacoes/demografia' +
(territoryPath
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id
? '?territories[]=' + territoryPath
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id
: '')
}
>
Comparar demografia e evolução das localizações
</Link>
{!!territoryPath.length && (
<Link
className={button}
@@ -1074,6 +1092,19 @@ export default function Catalogue({
))}
</tr>
))}
<tr>
<th className="border p-3">Demografia INE</th>
{comparison.map(({ record: r, context: c }) => (
<td className="border p-3" key={r.id}>
<DemographicContext
key={r.id}
territoryId={c?.primary_territory_path
?.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
</td>
))}
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,128 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import DemographicEvidence, {
type DemographicAnalysis,
} from '@/components/demographic-evidence';
import { Frame, button } from './shared';
export default function Demography({
evidence,
filters,
territories,
sectors,
}: {
evidence: DemographicAnalysis;
filters: {
territories?: string[];
start_year?: number;
end_year?: number;
sector?: string;
};
territories: {
id: string;
name: string;
location_type: string;
parent_id: string | null;
}[];
sectors: Record<string, string>;
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [start, setStart] = useState(
filters.start_year ?? new Date().getFullYear(),
);
const [end, setEnd] = useState(filters.end_year ?? start);
const [sector, setSector] = useState(filters.sector ?? 'general');
return (
<Frame title="Demografia e viabilidade territorial">
<p>
Compare até quatro localizações no mesmo sector e período. Use
esta evidência em conjunto com as condições económicas, fiscais
e territoriais do investimento.
</p>
<form
className="grid gap-4 rounded border bg-white p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
router.get(
'/localizacoes/demografia',
{
territories: selected,
start_year: start,
end_year: end,
sector,
},
{ preserveState: true },
);
}}
>
<label>
Províncias ou distritos (até quatro)
<select
multiple
className="block h-48 w-full rounded border p-2"
value={selected}
onChange={(e) =>
setSelected(
Array.from(
e.target.selectedOptions,
(o) => o.value,
).slice(0, 4),
)
}
>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name} —{' '}
{t.location_type === 'province'
? 'Província'
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
</option>
))}
</select>
</label>
<div className="space-y-3">
<label className="block">
Sector
<select
className="block w-full rounded border p-2"
value={sector}
onChange={(e) => setSector(e.target.value)}
>
{Object.entries(sectors).map(([key, label]) => (
<option value={key} key={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Ano inicial
<input
className="block rounded border p-2"
type="number"
min="1900"
max="2100"
value={start}
onChange={(e) => setStart(Number(e.target.value))}
/>
</label>
<label className="block">
Ano final
<input
className="block rounded border p-2"
type="number"
min={start}
max={Math.min(2100, start + 50)}
value={end}
onChange={(e) => setEnd(Number(e.target.value))}
/>
</label>
<button className={button} disabled={selected.length === 0}>
Consultar e comparar
</button>
</div>
</form>
<DemographicEvidence evidence={evidence} />
</Frame>
);
}
+7
View File
@@ -1,4 +1,5 @@
import { ProactiveSummary } from '@/components/proactive-investment';
import DemographicContext from '@/components/demographic-context';
import EducationalGuides from '@/components/educational-guides';
import InvestmentComplements, {
type ComplementOption,
@@ -183,6 +184,12 @@ export default function Detail({
filters={{ opportunity_id: r.id }}
territoryId={c.primary_territory_path?.at(-1)?.id}
/>
<DemographicContext
key={c.primary_territory_path?.at(-1)?.id}
territoryId={c.primary_territory_path
?.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
{c.primary_territory_path &&
c.primary_territory_path.length > 0 && (
<p>
@@ -0,0 +1,180 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { Management, button, control } from './shared';
type Plan = {
mode: 'initial' | 'remaining' | 'zambezia';
pending_count: number;
aliases: Record<string,string>;
alias_sources: string[];
missing_province_source: string | null;
hash: string;
ready: boolean;
installed: boolean;
consistent: boolean;
backup_verified: boolean;
mfa_confirmed: boolean;
target: { database: string; port: number };
migration: string;
code_sha256: string;
files: Record<string, { sha256: string; records: number; issues?: Record<string,number> }>;
new_districts: string[];
notice: string;
};
export default function DemographyInstallation({ plan }: { plan: Plan }) {
const [reason, setReason] = useState(
'Disponibilização autorizada pela Tempo em 13/11/2026, preservando valores, advertências e histórico.',
);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [operation] = useState(createOperationKey);
async function apply() {
setBusy(true);
setMessage('');
try {
const cookie =
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')
.slice(1)
.join('=') ?? '';
const response = await fetch('/management/demography/install', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(cookie),
},
body: JSON.stringify({
hash: plan.hash,
mode: plan.mode,
confirm: confirmed,
operation_key: operation,
reason,
}),
});
const body = await response.text();
if (!body.trim() || !response.headers.get('content-type')?.includes('application/json')) {
throw new Error(`O servidor não devolveu um recibo JSON (HTTP ${response.status}). Consulte o estado antes de repetir a operação.`);
}
const result = JSON.parse(body);
if (!response.ok)
throw new Error(
result.message ??
'Não foi possível disponibilizar os dados.',
);
router.visit('/management/demography');
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Falha de comunicação. Consulte o estado antes de repetir.',
);
} finally {
setBusy(false);
}
}
return (
<Management title="Disponibilizar dados demográficos INE">
<p>
Destino:{' '}
<strong>
{plan.target.database}:{plan.target.port}
</strong>
. {plan.mode !== 'initial' ? 'Disponibiliza os ficheiros pendentes deste conjunto e acrescenta apenas os distritos autorizados, preservando todos os lotes já aprovados.' : 'Instala as tabelas demográficas e disponibiliza os três exemplares homologados.'} Mantém os dados existentes, as versões e os valores originais.
</p>
<dl className="grid gap-2 sm:grid-cols-2">
<dt>Backup e restauro da entrega</dt>
<dd>
{plan.backup_verified
? 'Conferidos'
: 'Pendentes ou desactualizados'}
</dd>
<dt>Estrutura</dt>
<dd>
{plan.consistent
? plan.installed
? 'Instalada'
: 'Preparada'
: 'Divergente — reconciliar'}
</dd>
<dt>MFA da sessão</dt>
<dd>
{plan.mfa_confirmed
? 'Confirmado'
: 'Conclua o MFA da sua conta antes de executar'}
</dd>
</dl>
<table className="w-full text-left">
<caption className="text-left font-semibold">
Pré-visualização aprovada para disponibilização
</caption>
<thead>
<tr>
<th>Exemplar</th>
<th>Observações</th>
</tr>
</thead>
<tbody>
{Object.entries(plan.files).map(([name, file]) => (
<tr key={name}>
<td>{name}</td>
<td>{file.records.toLocaleString('pt-MZ')}</td>
</tr>
))}
</tbody>
</table>
<p>
Distritos a acrescentar quando ausentes:{' '}
{plan.new_districts.join(' e ')}. Não são criados limites
geográficos, licenças ou direitos territoriais.
</p>
<p>{plan.notice}</p>
{plan.mode !== 'initial' && <details><summary>Conferir advertências por ficheiro</summary><p>Nas células com fórmulas, são conservados apenas os valores numéricos guardados no original. O portal não executa nem recalcula fórmulas. Valores ausentes não se tornam zero.</p><ul>{Object.entries(plan.files).map(([name,file])=><li key={name}><strong>{name}</strong><ul>{Object.entries(file.issues ?? {}).map(([code,count])=><li key={code}>{({cached_formula_value:'Valores guardados em células com fórmula',missing_value:'Valores em falta',age_total_mismatch:'Divergências na soma de idades',sex_total_mismatch:'Divergências na soma por sexo',residence_total_mismatch:'Divergências entre urbano/rural',district_total_mismatch:'Divergências na soma de distritos'} as Record<string,string>)[code] ?? code}: {count.toLocaleString('pt-MZ')}</li>)}</ul></li>)}</ul></details>}
{plan.mode !== 'initial' && <p>{plan.pending_count} ficheiros ainda por disponibilizar. {plan.missing_province_source && <>{plan.missing_province_source}: ficheiro provincial não fornecido; não se presume cobertura anual.</>}</p>}
{Object.keys(plan.aliases).length > 0 && <details><summary>Conferir correspondências com o cadastro existente</summary><ul>{Object.entries(plan.aliases).map(([key,name])=><li key={key}>{key.split('|').slice(0,2).join(' — ')} → {name}</li>)}</ul><p>Designações originais preservadas; os identificadores do cadastro não são renomeados ou fundidos.</p><ul>{plan.alias_sources.map(url=><li key={url}><a href={url} target="_blank" rel="noopener noreferrer">Consultar fundamento da correspondência</a></li>)}</ul></details>}
{plan.pending_count === 0 && <p role="status">Todos os ficheiros deste conjunto já estão aprovados. Não é necessário repetir a operação.</p>}
<label className="block">
Motivo
<textarea
className={control}
value={reason}
minLength={20}
maxLength={1000}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
Confirmo o destino, os {Object.keys(plan.files).length} exemplares, as correspondências territoriais e a preservação das advertências apresentadas.
</label>
<button
className={button}
disabled={
!plan.ready ||
!plan.mfa_confirmed ||
!confirmed ||
reason.length < 20 ||
busy
}
onClick={() => void apply()}
>
{busy ? 'A importar e conferir…' : plan.mode === 'zambezia' ? 'Disponibilizar Zambézia' : plan.mode !== 'initial' ? 'Disponibilizar restantes províncias' : 'Instalar e disponibilizar'}
</button>
{message && <p role="alert">{message}</p>}
<p>
<a className="underline" href="/localizacoes/demografia">
Consultar os dados disponíveis
</a>
</p>
</Management>
);
}
@@ -0,0 +1,307 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { Management, button, control } from './shared';
type Preview = {
id: string;
file_name: string;
sha256: string;
publication_version: string;
status: string;
report: {
record_count: number;
issue_counts: Record<string, number>;
issues: { code: string; sheet: string; cell: string; delta?: string }[];
};
matches: {
key: string;
province: string;
territory: string;
level: string;
geo_location_id: string | null;
state: string;
}[];
territory_options: {
id: string;
name: string;
location_type: string;
parent_id: string | null;
}[];
events: {
id: number;
action: string;
reason: string;
created_at: string;
}[];
};
export default function DemographyManagement({
batches,
preview,
qa = true,
}: {
qa?: boolean;
batches: {
id: string;
file_name: string;
publication_version: string;
status: string;
}[];
preview: Preview | null;
}) {
const [file, setFile] = useState<File | null>(null);
const [version, setVersion] = useState('');
const [year, setYear] = useState('');
const [reason, setReason] = useState('');
const [mapping, setMapping] = useState<Record<string, string>>({});
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
async function execute(path: string, body: FormData | object) {
setBusy(true);
setMessage('');
try {
const token =
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')
.slice(1)
.join('=') ?? '';
const response = await fetch(path, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token),
...(body instanceof FormData
? {}
: { 'Content-Type': 'application/json' }),
},
body: body instanceof FormData ? body : JSON.stringify(body),
});
const data = await response.json();
if (!response.ok)
throw new Error(
data.message ?? 'Não foi possível executar a operação.',
);
setMapping({});
router.get('/management/demography', { batch: data.id });
} catch (error) {
setMessage(
error instanceof Error ? error.message : 'Erro de comunicação.',
);
} finally {
setBusy(false);
}
}
return (
<Management
title={
qa ? 'Dados demográficos INE — QA' : 'Dados demográficos INE'
}
>
<p>
Preparar não grava observações. Confirmar importa para consulta
administrativa. Aprovar disponibiliza a série nas análises deste
ambiente; desactivar conserva dados e histórico.
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (!file) return;
const body = new FormData();
body.append('file', file);
body.append('publication_version', version);
if (year) body.append('baseline_year', year);
void execute('/management/demography/prepare', body);
}}
>
<label className="block">
Excel original
<input
type="file"
accept=".xlsx"
required
className={control}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="block">
Publicação / identificação do exemplar
<input
required
maxLength={255}
className={control}
value={version}
onChange={(e) => setVersion(e.target.value)}
/>
</label>
<label className="block">
Ano de referência, apenas para folha sem ano
<input
type="number"
min="1900"
max="2100"
className={control}
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</label>
<button className={button} disabled={busy}>
Preparar e verificar
</button>
</form>
{message && <p role="alert">{message}</p>}
<h2 className="text-xl font-semibold">Importações anteriores</h2>
<ul>
{batches.map((b) => (
<li key={b.id}>
<a
className="underline"
href={`/management/demography?batch=${b.id}`}
>
{b.file_name} · {b.publication_version} · {b.status}
</a>
</li>
))}
</ul>
{preview && (
<section key={preview.id} className="space-y-4">
<h2 className="text-xl font-semibold">
{preview.file_name} — {preview.status}
</h2>
<p>
{preview.report.record_count} observações extraídas.
SHA256: {preview.sha256}
</p>
<details>
<summary>Erros e advertências</summary>
<pre>
{JSON.stringify(
preview.report.issue_counts,
null,
2,
)}
</pre>
<div className="max-h-72 overflow-auto">
<ul>
{preview.report.issues.map((i, n) => (
<li key={n}>
{i.sheet}!{i.cell}: {i.code}{' '}
{i.delta ?? ''}
</li>
))}
</ul>
</div>
</details>
<h3 className="font-semibold">
Correspondências territoriais
</h3>
{preview.matches.map((m) => (
<label className="block" key={m.key}>
{m.province} / {m.territory} ({m.level}) — {m.state}
<select
className={control}
disabled={preview.status !== 'prepared'}
value={
mapping[m.key] ?? m.geo_location_id ?? ''
}
onChange={(e) =>
setMapping({
...mapping,
[m.key]: e.target.value,
})
}
>
<option value="">Por resolver</option>
{preview.territory_options
.filter((t) => t.location_type === m.level)
.map((t) => (
<option value={t.id} key={t.id}>
{t.name} (
{preview.territory_options.find(
(p) => p.id === t.parent_id,
)?.name ?? 'Província'}
)
</option>
))}
</select>
</label>
))}
<label className="block">
Motivo e tratamento das advertências
<textarea
className={control}
minLength={10}
maxLength={2000}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<div className="flex flex-wrap gap-3">
{preview.status === 'prepared' ? (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/confirm`,
{
sha256: preview.sha256,
mapping,
reason,
},
)
}
>
Confirmar importação
</button>
) : (
<>
{preview.status !== 'approved' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'approved', reason },
)
}
>
{qa
? 'Aprovar utilização em QA'
: 'Aprovar utilização'}
</button>
)}
{preview.status !== 'disabled' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'disabled', reason },
)
}
>
Desactivar série
</button>
)}
</>
)}
</div>
<details>
<summary>Histórico</summary>
<ul>
{preview.events.map((e) => (
<li key={e.id}>
{e.created_at}: {e.action} — {e.reason}
</li>
))}
</ul>
</details>
</section>
)}
</Management>
);
}
+1
View File
@@ -78,6 +78,7 @@ export function Management({
Revisão da origem das oportunidades
</Link>
<Link href="/management/history">Histórico</Link>
<Link href="/management/demography">Demografia INE</Link>
<Link href="/management/crm/leads">CRM</Link>
<Link href="/management/crm/investors">Investidores</Link>
<Link href="/management/projects">Projectos</Link>
+17
View File
@@ -0,0 +1,17 @@
<?php
use App\Http\Controllers\DemographyController;
use App\Http\Middleware\EnsureScopedPortalAdministration;
use App\Http\Middleware\PrivateInvestmentResponse;
use Illuminate\Support\Facades\Route;
Route::get('/localizacoes/demografia', [DemographyController::class, 'index'])->middleware('throttle:30,1');
Route::get('/api/v1/investment/demography', [DemographyController::class, 'index'])->middleware('throttle:30,1');
Route::prefix('management/demography')->middleware(['auth', 'verified', EnsureScopedPortalAdministration::class, PrivateInvestmentResponse::class, 'throttle:20,1'])->group(function () {
Route::get('/install', [DemographyController::class, 'installation']);
Route::post('/install', [DemographyController::class, 'install']);
Route::get('/', [DemographyController::class, 'management']);
Route::post('/prepare', [DemographyController::class, 'prepare']);
Route::post('/{id}/confirm', [DemographyController::class, 'confirm'])->whereUuid('id');
Route::post('/{id}/transition', [DemographyController::class, 'transition'])->whereUuid('id');
});
+1
View File
@@ -354,6 +354,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('/porque-investir', fn () => Inertia::render('portal/why-invest'))->name('portal.why-invest');
require __DIR__.'/investor-path.php';
require __DIR__.'/demography.php';
// Serialize management requests for the same session so background dropdown/coordinate
// reads cannot overwrite validation feedback from a concurrent form submission.
@@ -0,0 +1,121 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographicInstallationTest extends TestCase
{
use DatabaseTransactions;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->manager = User::factory()->create();
$this->manager->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$second = User::factory()->create();
// Test-only installation identities. This entire transaction is rolled back in QA.
if (DB::table('portal_superuser_pair')->where('id', 1)->exists()) {
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $this->manager->id, 'second_user_id' => $second->id]);
}
config(['portal.managers' => [$this->manager->email]]);
}
public function test_installation_requires_fresh_session_mfa_even_for_a_designated_manager(): void
{
$this->actingAs($this->manager)->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA installation access test.'])->assertForbidden();
}
public function test_an_unauthorised_account_cannot_read_the_installation_plan(): void
{
$this->actingAs(User::factory()->create())->getJson('/management/demography/install')->assertForbidden();
}
public function test_an_unavailable_interpreter_blocks_the_plan_before_installation(): void
{
config(['demography.python' => base_path('storage/nonexistent-demography-python.exe')]);
$plan = app(DemographicInstallation::class)->preview($this->manager);
$this->assertFalse($plan['runtime_ready']);
$this->assertFalse($plan['ready']);
$this->assertStringContainsString('interpretador', $plan['notice']);
}
public function test_schema_flag_alone_does_not_authorise_the_operational_target(): void
{
$this->app['env'] = 'local';
config(['demography.authenticated_installation' => true, 'database.connections.pgsql.database' => 'investemoz', 'database.connections.pgsql.port' => '5432']);
$this->assertFalse(app(DemographicInstallation::class)->schemaAllowed());
}
public function test_stale_plan_is_rejected_before_schema_or_data_changes(): void
{
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA stale installation plan test.'])->assertConflict();
}
public function test_release_installs_imports_and_replays_on_the_restored_target(): void
{
if (getenv('DEMOGRAPHY_RELEASE_TESTS') !== '1') {
$this->markTestSkipped('Opt in only on the specifically restored demographic QA database with the real supplied Excel files and scanner.');
}
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
$this->assertFalse(Schema::hasTable('demographic_imports'));
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$service = app(DemographicInstallation::class);
$plan = $service->preview($this->manager);
$this->assertTrue($plan['ready']);
$input = ['hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Homologação da entrega numa cópia restaurada; apenas dados demográficos oficiais e identidades sintéticas QA.'];
$result = $service->apply($this->manager, $input);
$this->assertTrue($result['installed']);
$this->assertSame(154706, $result['record_count']);
$this->assertSame(154706, DB::table('demographic_observations')->count());
$this->assertSame(3, DB::table('demographic_imports')->where('status', 'approved')->count());
$repeat = $service->apply($this->manager, $input);
$this->assertTrue($repeat['repeated']);
$this->assertSame(154706, DB::table('demographic_observations')->count());
$districts = app(DemographicEvidence::class)->territories()->whereIn('name', ['Macate', 'Vanduzi'])->pluck('id')->all();
$this->assertCount(2, $districts);
$this->assertSame('available', app(DemographicEvidence::class)->analyse($districts, 2017, 2050, 'industry')['state']);
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'actor_user_id' => $this->manager->id, 'correlation_id' => $input['operation_key']]);
$originals = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
$originalIds = DB::table('demographic_imports')->pluck('id')->all();
$originalEvents = DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson();
$plan = $service->preview($this->manager);
$this->assertSame('remaining', $plan['mode']);
$this->assertSame(8, $plan['pending_count']);
$this->assertCount(8, $plan['new_districts']);
$next = ['hash' => $plan['hash'], 'mode' => 'remaining', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Oito províncias e oito distritos autorizados em13/11;QA restaurada sem alterações no activo.'];
$result = $service->apply($this->manager, $next);
$this->assertSame(639066, $result['record_count']);
$this->assertSame(793772, DB::table('demographic_observations')->count());
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
$this->assertSame($originals, DB::table('demographic_imports')->whereIn('id', $originalIds)->orderBy('id')->get()->toJson());
$this->assertSame($originalEvents, DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson());
$this->assertTrue($service->apply($this->manager, $next)['repeated']);
$this->assertSame(793772, DB::table('demographic_observations')->count());
$this->assertSame('zambezia', $service->preview($this->manager)['mode']);
$this->assertSame(1, $service->preview($this->manager)['pending_count']);
$this->assertTrue($service->preview($this->manager)['ready']);
$this->assertCount(10, app(DemographicEvidence::class)->territories()->where('source_system', 'ine-demography')->get());
foreach (['Chongoene','Larde','Chimbonila','Doa','KaMphumu','Beira'] as $name) {
$literal = $name === 'KaMphumu' ? 'DISTRITO URBANO 1' : ($name === 'Beira' ? 'CIDADE DA BEIRA' : $name);
$territory = app(DemographicEvidence::class)->territories()->whereRaw('lower(name)=?', [mb_strtolower($literal)])->value('id');
$this->assertNotNull($territory, $name);
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$territory],2017,2050,'general')['state']);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographicZambeziaTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
}
private function manager(): User
{
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$second = User::factory()->create();
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $actor->id, 'second_user_id' => $second->id]);
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
return $actor;
}
public function test_new_cohort_requires_current_session_mfa(): void
{
$this->manager();
$this->postJson('/management/demography/install', ['mode' => 'zambezia', 'hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic fresh session test.'])->assertForbidden();
$this->assertSame(793772, DB::table('demographic_observations')->count());
}
public function test_only_zambezia_is_added_and_all_previous_imports_are_preserved(): void
{
$actor = $this->manager();
$this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time()]);
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
$oldIds = DB::table('demographic_imports')->pluck('id');
$oldImports = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
$oldEvents = DB::table('demographic_events')->orderBy('id')->get()->toJson();
$service = app(DemographicInstallation::class);
$plan = $service->preview($actor);
$this->assertSame('zambezia', $plan['mode']);
$this->assertSame(1, $plan['pending_count']);
$this->assertCount(1, $plan['files']);
$this->assertCount(5, $plan['new_districts']);
$body = ['mode' => 'zambezia', 'hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Incorporação de Zambezia.xlsx autorizada e cinco distritos;ensaio apenas na cópia restaurada.'];
$result = $service->apply($actor, $body);
$this->assertSame(126684, $result['record_count']);
$this->assertCount(1, $result['imports']);
$this->assertSame(12, DB::table('demographic_imports')->where('status', 'approved')->count());
$this->assertSame(920456, DB::table('demographic_observations')->count());
$this->assertSame($oldImports, DB::table('demographic_imports')->whereIn('id', $oldIds)->orderBy('id')->get()->toJson());
$this->assertSame($oldEvents, DB::table('demographic_events')->whereIn('import_id', $oldIds)->orderBy('id')->get()->toJson());
$this->assertTrue($service->apply($actor, $body)['repeated']);
$this->assertSame(920456, DB::table('demographic_observations')->count());
$this->assertFalse($service->preview($actor)['ready']);
$this->assertSame(0, $service->preview($actor)['pending_count']);
foreach (['Derre', 'Luabo', 'Mocubela', 'Molumbo', 'Mulevala'] as $name) {
$ids = app(DemographicEvidence::class)->territories()->where('name', $name)->pluck('id')->all();
$this->assertCount(1, $ids);
$this->assertSame('available', app(DemographicEvidence::class)->analyse($ids, 2017, 2050)['state']);
}
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'action' => 'extend_zambezia', 'correlation_id' => $body['operation_key']]);
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\InvestmentPreparationWorkspace;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographyTest extends TestCase
{
use DatabaseTransactions;
private string $province;
private string $district;
private string $batch;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->manager = User::factory()->create();
$this->manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC QA ONLY'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$this->manager->email]]);
$country = (string) Str::uuid();
$this->province = (string) Str::uuid();
$this->district = (string) Str::uuid();
foreach ([[$country, null, 'country'], [$this->province, $country, 'province'], [$this->district, $this->province, 'district']] as [$id, $parent, $type]) {
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id, 'data_classification' => 'public']);
}
$this->batch = $this->batch('approved');
foreach ([$this->province, $this->district] as $id) {
foreach ([2026 => '100.125', 2027 => '110.250'] as $year => $value) {
DB::table('demographic_observations')->insert(['import_id' => $this->batch, 'geo_location_id' => $id, 'year' => $year, 'indicator' => 'population',
'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => $value, 'numeric_value' => $value, 'sheet' => (string) $year, 'cell' => 'B4', 'validation_status' => 'valid']);
}
}
}
private function batch(string $status): string
{
$id = (string) Str::uuid();
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => hash('sha256', $id), 'file_name' => 'SYNTHETIC-QA.xlsx', 'source' => 'SYNTHETIC TEST ONLY',
'publication_version' => 'synthetic/1', 'status' => $status, 'report' => json_encode(['record_count' => 4, 'territories' => [], 'issue_counts' => []]),
'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $this->manager->id, 'created_at' => now(), 'updated_at' => now()]);
return $id;
}
public function test_public_comparison_preserves_decimals_versions_and_missing_years(): void
{
$response = $this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->district], 'start_year' => 2026, 'end_year' => 2028, 'sector' => 'industry']))->assertOk();
$response->assertJsonPath('state', 'available')->assertJsonCount(2, 'territories')
->assertJsonPath('territories.0.series.0.values.0.original_value', '100.125')
->assertJsonPath('territories.0.series.0.missing_years', [2028]);
$this->assertStringNotContainsString('probability', $response->getContent());
}
public function test_only_approved_series_and_public_territories_are_exposed(): void
{
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'disabled']);
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2027)['state']);
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'approved']);
DB::table('geo_locations')->where('id', $this->province)->update(['data_classification' => 'internal']);
$this->assertSame([], app(DemographicEvidence::class)->analyse([$this->district], 2026, 2027)['territories']);
}
public function test_new_editions_remain_separate_and_divergence_is_reported(): void
{
$other = $this->batch('approved');
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->where('geo_location_id', $this->province)->where('year', 2026)->first();
unset($row['id']);
DB::table('demographic_observations')->insert([...$row, 'import_id' => $other, 'numeric_value' => '101.125', 'original_value' => '101.125']);
$analysis = app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026);
$this->assertCount(2, $analysis['territories'][0]['series']);
$this->assertSame('divergent_values', $analysis['differences'][0]['state']);
$this->assertCount(1, app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026, 'general', $other)['territories'][0]['series']);
}
public function test_permissions_mfa_and_qa_guard_are_enforced(): void
{
$this->get('/management/demography')->assertRedirect();
$this->actingAs(User::factory()->create())->getJson('/management/demography')->assertForbidden();
$this->manager->forceFill(['two_factor_confirmed_at' => null])->save();
$this->actingAs($this->manager)->getJson('/management/demography')->assertForbidden();
$this->manager->forceFill(['two_factor_confirmed_at' => now()])->save();
config(['database.connections.pgsql.port' => '5432']);
$this->getJson('/management/demography')->assertForbidden();
}
public function test_confirmation_is_idempotent_and_status_changes_preserve_observations(): void
{
$service = app(DemographicImport::class);
$sha = DB::table('demographic_imports')->where('id', $this->batch)->value('sha256');
$receipt = $service->confirm($this->manager, $this->batch, $sha, [], 'Synthetic replay test only.');
$this->assertTrue($receipt['repeated']);
$this->assertSame(4, $receipt['records']);
$service->transition($this->manager, $this->batch, 'disabled', 'Synthetic deactivate test only.');
$this->assertSame(4, DB::table('demographic_observations')->where('import_id', $this->batch)->count());
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
app(DemographicImport::class)->transition($this->manager, $this->batch, 'approved', 'Synthetic restore test only.');
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
$this->assertSame(2, DB::table('demographic_events')->where('import_id', $this->batch)->count());
}
public function test_invalid_period_and_duplicate_territory_are_rejected(): void
{
$this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->province]]))->assertUnprocessable();
$this->getJson('/api/v1/investment/demography?start_year=2050&end_year=2017')->assertUnprocessable();
$this->getJson('/api/v1/investment/demography?start_year=2000&end_year=2100')->assertUnprocessable();
}
public function test_same_level_comparison_computes_exact_difference_and_explains_it(): void
{
$id = (string) Str::uuid();
$location = (array) DB::table('geo_locations')->where('id', $this->district)->first();
DB::table('geo_locations')->insert([...$location, 'id' => $id, 'name' => 'SYNTHETIC second district', 'code' => $id, 'external_id' => $location['external_id'].'-second']);
$row = (array) DB::table('demographic_observations')->where('geo_location_id', $this->district)->where('year', 2026)->first();
unset($row['id']);
DB::table('demographic_observations')->insert([...$row, 'geo_location_id' => $id, 'numeric_value' => '80.025', 'original_value' => '80.025']);
$analysis = app(DemographicEvidence::class)->analyse([$this->district, $id], 2026, 2026, 'industry');
$this->assertCount(1, $analysis['comparisons']);
$this->assertSame('20.100', ltrim($analysis['comparisons'][0]['difference'], '-'));
$this->assertStringContainsString('Não mede procura efectiva', $analysis['comparisons'][0]['explanation']);
}
public function test_evidence_reuses_context_without_modifying_it(): void
{
$context = ['fields' => ['province_id' => $this->province, 'district_id' => $this->district]];
$before = $context;
$analysis = app(DemographicEvidence::class)->forContext($context);
$this->assertSame('available', $analysis['state']);
$this->assertSame($this->district, $analysis['territories'][0]['id']);
$this->assertSame($before, $context);
$fallback = app(DemographicEvidence::class)->forContext(['fields' => ['province_id' => $this->province, 'district_id' => '']]);
$this->assertSame($this->province, $fallback['territories'][0]['id']);
$method = new \ReflectionMethod(InvestmentPreparationWorkspace::class, 'sources');
[, $sources] = $method->invoke(app(InvestmentPreparationWorkspace::class), $this->manager, (object) ['id' => (string) Str::uuid(), 'title' => 'Synthetic QA investment'], $context, '');
$source = collect($sources)->firstWhere('key', 'demography');
$this->assertSame('available', $source['current']['state']);
}
public function test_preview_or_original_tampering_prevents_import(): void
{
$prepared = $this->batch('prepared');
Storage::fake('local');
Storage::disk('local')->put('demography/'.$prepared.'/preview.json', '{}');
$sha = DB::table('demographic_imports')->where('id', $prepared)->value('sha256');
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic integrity test only.'])->assertConflict();
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
}
public function test_stream_integrity_and_count_are_verified_before_import(): void
{
Storage::fake('local');
foreach (['tampered', 'wrong-count'] as $case) {
$prepared = $this->batch('prepared');
$directory = 'demography/'.$prepared;
$original = 'SYNTHETIC NDJSON integrity '.$prepared;
$sha = hash('sha256', $original);
$stream = $case === 'tampered' ? 'changed' : '';
$summary = ['record_count' => 1, 'records_file_sha256' => hash('sha256', ''), 'territories' => [], 'issues' => [], 'issue_counts' => []];
$preview = json_encode($summary, JSON_THROW_ON_ERROR);
Storage::disk('local')->put($directory.'/original.xlsx', $original);
Storage::disk('local')->put($directory.'/preview.json', $preview);
Storage::disk('local')->put($directory.'/records.ndjson', $stream);
DB::table('demographic_imports')->where('id', $prepared)->update(['sha256' => $sha, 'preview_sha256' => hash('sha256', $preview), 'report' => $preview]);
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic stream integrity and count test.'])->assertConflict();
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
$this->assertSame('prepared', DB::table('demographic_imports')->where('id', $prepared)->value('status'));
}
}
public function test_database_rejects_duplicate_observation_dimensions(): void
{
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->first();
unset($row['id']);
$this->expectException(QueryException::class);
DB::table('demographic_observations')->insert($row);
}
public function test_rollback_refuses_to_delete_imported_history(): void
{
$migration = require database_path('migrations/2026_11_13_100000_create_demographic_series.php');
$this->expectException(\RuntimeException::class);
$migration->down();
}
public function test_remote_consultation_requires_explicit_activation_and_preserves_versions(): void
{
$environment = app()->environment();
try {
app()->instance('env', 'production');
config(['demography.enabled' => false]);
$service = app(DemographicEvidence::class);
$this->assertSame('unavailable', $service->analyse([$this->province], 2026, 2026)['state']);
config(['demography.enabled' => true]);
$result = $service->analyse([$this->province], 2026, 2026);
$this->assertSame('available', $result['state']);
$this->assertSame($this->batch, $result['territories'][0]['series'][0]['import_id']);
} finally {
app()->instance('env', $environment);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests\Unit;
use App\Services\DemographicInstallation;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class DemographicTargetTest extends TestCase
{
public function test_homologation_requires_activation_exact_url_and_actual_database_identity(): void
{
app()->instance('env', 'production');
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'demography.enabled' => true,
'database.default' => 'pgsql', 'database.connections.pgsql.host' => '127.0.0.1',
'database.connections.pgsql.port' => 5432, 'database.connections.pgsql.database' => 'teste_investidor',
'database.connections.pgsql.url' => null]);
DB::shouldReceive('selectOne')->twice()->with('SELECT current_database() AS database,inet_server_port() AS port')
->andReturn((object) ['database' => 'teste_investidor', 'port' => 5432], (object) ['database' => 'other_database', 'port' => 5432]);
$service = app(DemographicInstallation::class);
$this->assertTrue($service->activeTarget());
$this->assertFalse($service->activeTarget());
config(['demography.enabled' => false]);
$this->assertFalse($service->activeTarget());
config(['demography.enabled' => true, 'app.url' => 'https://other.example']);
$this->assertFalse($service->activeTarget());
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'database.connections.pgsql.host' => 'remote.example']);
$this->assertFalse($service->activeTarget());
}
}
+109
View File
@@ -0,0 +1,109 @@
"""Run: python -m unittest discover -s tests -p demography_workbook_test.py"""
from decimal import Decimal
from pathlib import Path
import sys
import tempfile
import unittest
from zipfile import ZipFile
from xml.sax.saxutils import escape
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
from demography_workbook import age_group, parse
def workbook(path, rows):
cells = []
for number, row in enumerate(rows, 1):
content = ''
for column, value in row.items():
reference = f'{column}{number}'
content += f'<c r="{reference}" t="inlineStr"><is><t>{escape(value)}</t></is></c>'
cells.append(f'<row r="{number}">{content}</row>')
with ZipFile(path, 'w') as archive:
archive.writestr('xl/workbook.xml', '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="2017" sheetId="1" r:id="r1"/></sheets></workbook>')
archive.writestr('xl/_rels/workbook.xml.rels', '<Relationships><Relationship Id="r1" Target="worksheets/custom.xml"/></Relationships>')
archive.writestr('xl/worksheets/custom.xml', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' + ''.join(cells) + '</sheetData></worksheet>')
class WorkbookTest(unittest.TestCase):
def fixture(self, rows):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'synthetic.xlsx'
workbook(path, rows)
return parse(path)
def rows(self):
return [dict(A='QUADRO 1. População projectada por área de residência e sexo segundo idade. Manica, 2017'),
dict(A='Idade', C='TOTAL', F='URBANA', I='RURAL'),
dict(C='Total', D='Homens', E='Mulheres', F='Total', G='Homens', H='Mulheres', I='Total', J='Homens', K='Mulheres'),
dict(A='Total', C='100.125', D='40.025', E='60.100', F='40.025', G='20', H='20.025', I='60.100', J='20.025', K='40.075')]
def test_shifted_headers_exact_decimals_and_no_header_as_age_row(self):
report = self.fixture(self.rows())
self.assertEqual(9, report['record_count'])
self.assertEqual({}, report['issue_counts'])
self.assertEqual('100.125', report['records'][0]['original_value'])
self.assertEqual(Decimal('100.125'), Decimal(report['records'][0]['numeric_value']))
def test_conflicting_duplicates_are_reported(self):
rows = self.rows()
rows.append({**rows[-1], 'C': '200'})
self.assertEqual(1, self.fixture(rows)['issue_counts']['conflicting_value'])
def test_incomplete_cells_never_become_zero(self):
rows = self.rows()
del rows[-1]['K']
report = self.fixture(rows)
self.assertEqual(1, report['issue_counts']['missing_value'])
self.assertEqual(8, report['record_count'])
def test_sex_inconsistency_detected(self):
rows = self.rows()
rows[-1]['C'] = '999'
self.assertEqual(1, self.fixture(rows)['issue_counts']['sex_total_mismatch'])
def test_unknown_workbook_rejected(self):
with self.assertRaises(ValueError):
self.fixture([dict(A='unrecognised data')])
def test_city_da_and_municipal_headers_do_not_become_second_provinces(self):
for title, expected in [('Cidade da Matola', 'Matola'), ('Distrito Municipal KaMphumu', 'KaMphumu')]:
with self.subTest(title=title):
rows = self.rows()
rows += [dict(A='QUADRO 2. População projectada por área de residência e sexo segundo idade. '+title+', 2017'), rows[-1].copy()]
report = self.fixture(rows)
district = [r for r in report['records'] if r['level'] == 'district']
self.assertEqual(9, len(district))
self.assertEqual(expected, district[0]['territory'])
self.assertEqual('Manica', district[0]['province'])
self.assertEqual([title], report['territory_headers']['Manica|'+expected+'|district'])
def test_formula_uses_only_the_original_numeric_cache_and_reports_provenance(self):
for cache in ['100.125', '']:
with self.subTest(cache=cache), tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'cached.xlsx'
workbook(path, self.rows())
with ZipFile(path) as source:
members = {n: source.read(n) for n in source.namelist()}
xml = members['xl/worksheets/custom.xml'].decode()
xml = xml.replace('<c r="C4" t="inlineStr"><is><t>100.125</t></is></c>', '<c r="C4"><f>SUM(D4:E4)</f><v>'+cache+'</v></c>')
members['xl/worksheets/custom.xml'] = xml.encode()
with ZipFile(path, 'w') as target:
for name, content in members.items(): target.writestr(name, content)
report = parse(path)
if cache:
self.assertEqual('100.125', report['records'][0]['original_value'])
self.assertEqual(1, report['issue_counts']['cached_formula_value'])
self.assertNotIn('invalid_numeric_or_formula', report['issue_counts'])
self.assertEqual('SUM(D4:E4)', report['issues'][0]['formula'])
else:
self.assertEqual(1, report['issue_counts']['invalid_numeric_or_formula'])
self.assertEqual(8, report['record_count'])
def test_age_labels(self):
self.assertEqual('15-64', age_group('População em idade activa (15–64 anos)'))
self.assertEqual('65+', age_group('População idosa (65 ou mais anos)'))
if __name__ == '__main__':
unittest.main()
+279
View File
@@ -0,0 +1,279 @@
"""Read-only INE XLSX adapter. Uses labels/relationships, never executes formulas/macros."""
import argparse
from collections import defaultdict
from decimal import Decimal, InvalidOperation
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import unicodedata
import xml.etree.ElementTree as ET
from zipfile import ZipFile
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
FORMAT = "investmoz-demography/1"
def normalized(value):
return " ".join("".join(c for c in unicodedata.normalize("NFKD", value.casefold()) if not unicodedata.combining(c)).split())
def sheets(path):
with ZipFile(path) as archive:
if len(archive.infolist()) > 2000 or sum(i.file_size for i in archive.infolist()) > 150_000_000:
raise ValueError("Workbook exceeds safe expanded size")
if any("vbaProject" in i.filename or "externalLinks/" in i.filename for i in archive.infolist()):
raise ValueError("Macros/external links are not supported")
strings = []
if "xl/sharedStrings.xml" in archive.namelist():
strings = ["".join(x.itertext()) for x in ET.fromstring(archive.read("xl/sharedStrings.xml")).findall("s:si", NS)]
relationships = {x.attrib["Id"]: x.attrib["Target"] for x in ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))}
for sheet in ET.fromstring(archive.read("xl/workbook.xml")).find("s:sheets", NS):
target = relationships[sheet.attrib[REL]]
member = target.lstrip("/") if target.startswith("/") else str(PurePosixPath("xl") / target)
rows = []
for row in ET.fromstring(archive.read(member)).findall("s:sheetData/s:row", NS):
cells = {}
for cell in row:
value = cell.findtext("s:v", "", NS)
kind = cell.attrib.get("t")
if kind == "s":
value = strings[int(value)]
elif kind == "inlineStr":
value = "".join(cell.find("s:is", NS).itertext())
if value or cell.find("s:f", NS) is not None:
cells[re.sub(r"\d", "", cell.attrib["r"])] = {"value": value, "cell": cell.attrib["r"], "invalid": kind == "e", "formula": cell.findtext("s:f", None, NS)}
if cells:
rows.append(cells)
yield sheet.attrib["name"], rows
def age_group(label):
label = normalized(label).replace("–", "-").replace(" ", "")
if label == "total":
return "all"
match = re.search(r"(\d+)-(\d+)", label)
if match:
return match[1] + "-" + match[2]
if "65oumais" in label:
return "65+"
if re.fullmatch(r"\d+\+", label):
return label
return None
def population_columns(rows):
"""Two-level area/sex headers, with merged area headings carried forward."""
area_row = next((r for r in rows if any(normalized(c["value"]) in ("urbana", "populacao urbana") for c in r.values())), {})
sex_row = next((r for r in rows if any(normalized(c["value"]) == "homens" for c in r.values())), {})
result, area = {}, "all"
for column, cell in sorted(sex_row.items(), key=lambda x: (len(x[0]), x[0])):
heading = normalized(area_row.get(column, {}).get("value", ""))
if "urbana" in heading:
area = "urban"
elif "rural" in heading:
area = "rural"
elif heading in ("total", "populacao total"):
area = "all"
sex = {"total": "all", "homens": "male", "mulheres": "female"}.get(normalized(cell["value"]))
if sex:
result[column] = (sex, area)
return result
def parse(path, baseline_year=None):
path = Path(path)
report = {"format": FORMAT, "file_name": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"source": "Instituto Nacional de Estatística de Moçambique (INE)", "publication_version": None,
"records": [], "issues": [], "sheets": [], "territories": [], "years": [], "territory_headers": {}}
seen = {}
def issue(code, sheet, cell="", **extra):
report["issues"].append(dict(code=code, sheet=sheet, cell=cell, **extra))
def add(sheet, cell, province, territory, level, year, indicator, sex="all", area="all", age="all", unit="pessoas", kind="projected"):
if cell and cell.get("formula") is not None and cell["value"] == "":
issue("invalid_numeric_or_formula", sheet, cell["cell"], reason="formula_without_numeric_cache")
return
if not cell or cell["value"] == "":
issue("missing_value", sheet, territory=territory, indicator=indicator, age_group=age, sex=sex, residence=area)
return
raw = cell["value"]
try:
value = Decimal(raw)
if cell["invalid"] or not value.is_finite() or value < 0:
raise InvalidOperation
except InvalidOperation:
issue("invalid_numeric_or_formula", sheet, cell["cell"])
return
if cell.get("formula") is not None:
issue("cached_formula_value", sheet, cell["cell"], formula=cell["formula"])
row = dict(province=province, territory=territory, level=level, year=year, indicator=indicator, sex=sex,
residence=area, age_group=age, unit=unit, data_kind=kind, original_value=raw,
numeric_value=format(value, "f"), sheet=sheet, cell=cell["cell"])
key = tuple(row[k] for k in ("province", "territory", "level", "year", "indicator", "sex", "residence", "age_group"))
if key in seen:
issue("duplicate_value" if seen[key] == raw else "conflicting_value", sheet, cell["cell"])
return
seen[key] = raw
report["records"].append(row)
workbook = list(sheets(path))
annual = any(re.fullmatch(r"20\d{2}", name) for name, _ in workbook)
province = None
for sheet, rows in workbook:
before = len(report["records"])
if annual:
if not re.fullmatch(r"20\d{2}", sheet):
report["sheets"].append(dict(name=sheet, records=0, state="index"))
continue
year = int(sheet)
columns = population_columns(rows)
if len(columns) != 9:
issue("unsupported_population_headers", sheet)
continue
territory = None
for row in rows:
label = next(iter(row.values()))["value"]
title = re.search(r"segundo idade\.\s*(.+),\s*(20\d{2})\s*$", label, re.I)
if title:
if int(title[2]) != year:
issue("title_year_mismatch", sheet, next(iter(row.values()))["cell"])
territory = None
continue
place = title[1].strip()
prefix = r"^(?:Distrito Municipal\s+|(?:Distrito|Cidade) (?:de|da)\s+)"
level = "district" if re.match(prefix, place, re.I) else "province"
territory = re.sub(prefix, "", place, flags=re.I)
if level == "province":
if province and normalized(province) != normalized(territory):
raise ValueError("Multiple provinces in annual workbook")
province = territory
if province:
header_key = "|".join([province, territory, level])
report["territory_headers"].setdefault(header_key, [])
if place not in report["territory_headers"][header_key]:
report["territory_headers"][header_key].append(place)
continue
age = age_group(row["A"]["value"]) if "A" in row else None
if territory and age and province:
for column, (sex, area) in columns.items():
add(sheet, row.get(column), province, territory, level, year, "population", sex, area, age)
else:
name = normalized(sheet)
if name.startswith("populacao_") or "grupo etario" in name:
columns = population_columns(rows)
is_age = "grupo etario" in name
explicit = [int(c["value"]) for r in rows[:2] for c in r.values() if re.fullmatch(r"20\d{2}", c["value"])]
year = explicit[0] if explicit else baseline_year
if not year:
issue("reference_year_required", sheet)
continue
if not explicit:
issue("year_from_import_metadata", sheet, year=year)
province = None
for row in rows[2:]:
if row.get("A"):
province = row["A"]["value"].strip()
age = age_group(row.get("B", {}).get("value", "")) if is_age else "all"
if province and age:
for column, (sex, area) in columns.items():
add(sheet, row.get(column), province, province, "province", year, "population", sex, area, age, kind="unspecified")
elif "vitais" in name:
years = re.findall(r"20\d{2}", " ".join(c["value"] for r in rows[:3] for c in r.values()))
if len(set(years)) != 1:
issue("reference_year_required", sheet)
continue
# Indicator names derive from the actual two-row headings. Units absent in the source remain unspecified.
heading = rows[1]
active = ""
for column, cell in sorted(rows[2].items(), key=lambda x: (len(x[0]), x[0])):
if column == "A":
continue
if column in heading:
active = normalized(heading[column]["value"])
detail = normalized(cell["value"])
if "esperanca" in active:
indicator, unit = "life_expectancy", "anos"
elif "infantil" in active:
indicator, unit = "infant_mortality", "unspecified"
else:
indicator = {"mortalidade": "crude_death_rate", "natalidade": "crude_birth_rate", "fecundidade": "fertility_rate", "exponencial": "population_growth_rate"}.get(detail)
unit = "unspecified"
if not indicator:
issue("unsupported_vital_header", sheet, cell["cell"])
continue
if unit == "unspecified":
issue("unit_not_stated", sheet, cell["cell"], indicator=indicator)
sex = {"homens": "male", "mulheres": "female"}.get(detail, "all")
for row in rows[3:]:
if "A" in row:
p = row["A"]["value"].strip()
add(sheet, row.get(column), p, p, "province", int(years[0]), indicator, sex, unit=unit, kind="unspecified")
else:
issue("unpopulated_indicator_catalogue" if not any(re.fullmatch(r"\d+(\.\d+)?", c["value"]) for r in rows for c in r.values()) else "unsupported_sheet", sheet)
report["sheets"].append(dict(name=sheet, records=len(report["records"]) - before))
reconcile(report)
report["territories"] = sorted({(r["province"], r["territory"], r["level"]) for r in report["records"]})
report["years"] = sorted({r["year"] for r in report["records"]})
report["record_count"] = len(report["records"])
report["issue_counts"] = dict(sorted(__import__("collections").Counter(i["code"] for i in report["issues"]).items()))
if not report["records"]:
raise ValueError("No supported demographic data found")
return report
def reconcile(report):
groups = defaultdict(dict)
territorial = defaultdict(dict)
ages = defaultdict(dict)
for r in report["records"]:
if r["indicator"] != "population":
continue
base = (r["province"], r["territory"], r["level"], r["year"], r["age_group"])
groups[base][(r["sex"], r["residence"])] = r
ages[base[:4] + (r["sex"], r["residence"])][r["age_group"]] = r
if r["age_group"] == r["sex"] == r["residence"] == "all":
territorial[(r["province"], r["year"])][(r["level"], r["territory"])] = r
def check(total, parts, code):
if total and all(parts):
delta = Decimal(total["numeric_value"]) - sum(Decimal(p["numeric_value"]) for p in parts)
# Binary Excel numeric storage leaves sub-micro-person residues; retain originals,
# but do not report these as demographic inconsistencies.
if abs(delta) > Decimal("0.000001"):
report["issues"].append(dict(code=code, sheet=total["sheet"], cell=total["cell"], delta=str(delta), territory=total["territory"], year=total["year"]))
for rows in groups.values():
for area in ("all", "urban", "rural"):
check(rows.get(("all", area)), [rows.get(("male", area)), rows.get(("female", area))], "sex_total_mismatch")
for sex in ("all", "male", "female"):
check(rows.get((sex, "all")), [rows.get((sex, "urban")), rows.get((sex, "rural"))], "residence_total_mismatch")
for rows in ages.values():
detail = [v for k, v in rows.items() if k != "all"]
if detail:
check(rows.get("all"), detail, "age_total_mismatch")
for rows in territorial.values():
provinces = [v for (kind, _), v in rows.items() if kind == "province"]
districts = [v for (kind, _), v in rows.items() if kind == "district"]
if provinces and districts:
check(provinces[0], districts, "district_total_mismatch")
if __name__ == "__main__":
cli = argparse.ArgumentParser()
cli.add_argument("file")
cli.add_argument("--baseline-year", type=int)
cli.add_argument("--output", required=True)
cli.add_argument("--records-output")
args = cli.parse_args()
result = parse(args.file, args.baseline_year)
if args.records_output:
record_path = Path(args.records_output)
with record_path.open('w', encoding='utf-8', newline='\n') as target:
for record in result.pop('records'):
target.write(json.dumps(record, ensure_ascii=False, separators=(',', ':'))+'\n')
result['records_file_sha256'] = hashlib.sha256(record_path.read_bytes()).hexdigest()
Path(args.output).write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print(json.dumps({k: v for k, v in result.items() if k not in ("records", "issues")}, ensure_ascii=False))