Compare commits

...
Author SHA1 Message Date
nala 8854b2928c Allow private stage report preview and explicit Word/HTML download 2026-11-13 15:02:15 +02:00
nala 723ce2a0a4 Record grouped investor reports installed and preservation verified 2026-11-13 14:28:10 +02:00
nala 07182c7c73 Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Waiting to run
2026-11-13 14:09:52 +02:00
nala bb6824d75d Keep concurrent manual additions outside demographic delivery 2026-11-13 14:08:20 +02:00
nala 095280e29d Record demographic matrix status while preserving concurrent additions in working copy 2026-11-13 14:07:52 +02:00
nala e92519dd75 Record HTTP500 correction verified in real PHP-FPM with process execution blocked 2026-11-13 14:06:58 +02:00
nala 4988ca89dc Use verified CLI preparations when web PHP cannot execute processes
tests / ci (push) Waiting to run
2026-11-13 13:57:01 +02:00
nala 258c8bd28b Record installed demography access correction and refreshed readiness 2026-11-13 13:49:32 +02:00
nala df543a4414 Recognise designated superusers in authenticated demography installation
tests / ci (push) Waiting to run
2026-11-13 13:41:48 +02:00
nala 291a1c420f Record demography code installed in homologation, authenticated data confirmation pending 2026-11-13 13:40:16 +02:00
nala b20068d04b Deliver additive INE demography and authenticated homologation installation
tests / ci (push) Waiting to run
2026-11-13 13:27:36 +02:00
nala 880e12890c Record mining and energy reference delivery installed in f2767818 2026-11-11 14:20:23 +02:00
44 changed files with 4694 additions and 10 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']]);
}
}
@@ -20,6 +20,16 @@ use Inertia\Response;
class InvestorPathController extends Controller
{
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(app(InvestorPath::class)->available(), 404);
$request->validate(['opportunity' => 'nullable|string|max:150']);
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
->header('X-Content-Type-Options', 'nosniff');
}
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
+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');
}
}
+260
View File
@@ -0,0 +1,260 @@
<?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
{
/**
* CLI preparation is read-only input, never an import or approval.
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') { return null; }
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) { return null; }
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
}
/** @param array<string,array<string,mixed>> $files */
public function preparedSourcesReady(array $files): bool
{
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
}
return $files !== [];
} catch (\Throwable) { return false; }
}
public function authorize(User $actor): void
{
$access = app(PortalProfileAccess::class);
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->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');
$prepared = $this->preparedSource($sha, $baselineYear);
if ($prepared !== null) {
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
} else {
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
$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()]);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?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));
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
try {
if (! $runtimeReady && function_exists('proc_open')) {
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
$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.'];
@@ -275,6 +280,24 @@ final class InvestmentPreparationWorkspace
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
}
/** Reuses the authorised workspace and its preserved exports; never creates an export or a review.
* @return array<string,mixed> */
public function reportSummary(User $actor, string $id, ?string $opportunity): array
{
$view = $this->view($actor, $id, $opportunity);
$query = $view['opportunity'] ? '?opportunity='.urlencode($view['opportunity']) : '';
$base = '/investimentos/'.$id.'/preparacao';
return ['case_id' => $id, 'opportunity' => $view['opportunity'], 'version' => $view['version'],
'context_revision' => $view['context']['revision'], 'href' => $base.$query, 'api_state' => $view['api_state'],
'api_issues' => $view['api_issues'], 'external' => $view['apiex']['external'],
'exports' => array_map(fn ($export) => ['id' => $export['id'], 'at' => $export['at'],
'context_revision' => $export['context_revision'], 'fingerprint' => $export['fingerprint'],
'historical' => $export['fingerprint'] !== $view['api_fingerprint'],
'href' => $base.'/exportar/'.$export['id'].$query], $this->rows($view['apiex']['exports'])),
'swot' => $view['swot'], 'swot_review_required' => $view['swot_review_required']];
}
/** @return list<string> */
public function taskKeys(User $actor, string $id, string $opportunity): array
{
+12
View File
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -171,6 +172,17 @@ final class InvestorPath
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
'company_reports' => ($owner || $manager) ? array_filter([
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
]) : [],
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
+58
View File
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
}
$this->requireMatch($request, $draft);
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
$sources[] = $sourceSnapshot;
$economic['simulation_sources'] = $sources;
$economic['reuse_simulation'] = 'yes';
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
});
}
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
* @return list<array<string,mixed>> */
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
if (! $draft) { return []; }
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$sources = [];
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
foreach ($records as $source) {
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
}
return $sources;
});
}
/** Reads the already preserved copy, including a source originally calculated anonymously.
* @return array<string,mixed> */
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
$source = null;
foreach ($records as $item) {
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
}
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
});
}
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
public function claimPreview(Request $request): ?array
{
+11
View File
@@ -0,0 +1,11 @@
<?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'),
'prepared_directory' => env('DEMOGRAPHY_PREPARED_DIRECTORY'),
// 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,105 @@
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
## 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.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## Energia — consulta e preparação no mesmo dossier (QA,11/11/2026)
1. Abra **Sectores de investimento → Energia** (`/sectores/energia-especifica`). Seleccione localização e segmento energético,ou pesquise uma actividade.
@@ -9646,3 +9748,39 @@ S08 permanece por concluir, separada da revis?o visual; a especifica??o da janel
### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio
?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado.
## 13/11/2026 — relatórios agrupados no dossier: implementação em QA
Pedido do proprietário: verificar e resolver a dispersão das saídas produzidas pelas etapas do investimento. No percurso existente `/investimentos/{id}#relatorios`, o índice reúne resultados financeiros, versões fiscais preservadas, comparação/preparação da empresa, guias guardados, análise SWOT, exportações APIEX, preparação de acesso à terra e documentos das etapas. Cada referência conserva dossier, oportunidade ou contexto comum/por confirmar, revisão, data quando registada e estado; etapas sem resultado associado são explicitadas. A mesma versão documental ligada a mais de um pedido aparece uma vez, sem fundir originais diferentes.
Exportação consolidada Word e HTML para impressão/PDF reutiliza os geradores existentes e os cálculos de viabilidade já aceites. Inclui contexto do investimento, etapas e pendências, financiamento/orçamento/empréstimo/ponto de equilíbrio recuperados das entradas guardadas, conclusões SWOT com fonte/data/classificação, preparação APIEX e referências aos documentos. Exportações APIEX históricas mantêm o original, sem acrescentar SWOT privada ao pacote destinado à entidade externa. O índice dá acesso aos originais pelas autorizações existentes; o relatório não envia mensagens, submete processos, concede crédito, executa pagamentos ou publica conteúdo.
A cópia fiscal preservada no rascunho é recuperável pelo titular em `/investimentos/{id}/relatorios/fiscais/{reportId}`, incluindo quando a simulação original era anónima ou deixou de existir. A posse de acesso administrativo ao dossier não concede acesso financeiro. Fontes fiscais novas conservam a oportunidade/revisão no momento da associação; versões antigas sem essa identificação permanecem disponíveis como âmbito por confirmar, sem serem atribuídas silenciosamente à oportunidade actual. Preparação de terra não é apresentada como título DUAT. Seleccionar oportunidade é necessário antes de consolidar um dossier com várias escolhas.
Provas: `outputs/dossier-reports-20261113/`. Vinte e um testes frontend aprovados; prova final do servidor com 44 asserções, em dados sintéticos e transacção revertida, além da regressão focada anterior de quatro testes/151 asserções. Sem migrações ou novas permissões. Estado: implementado em QA; instalação desta alteração e conferência visual no uso normal ainda pendentes. O saldo dos restantes módulos e resíduos fiscais permanece inalterado.
Relatórios do dossier — conferência final de 13/11/2026: regressão focada do servidor 4 testes/168 asserções; caso final de recuperação, descargas privadas, âmbito, repetição e bloqueio com módulo desactivado 45 asserções. Os 21 testes frontend passaram; tipos, análise PHP, lint e compilação QA aprovados. Delta próprio de relatórios preparado, sem instalação remota e sem interferir na incorporação demográfica em curso.
## 13/11/2026 — relatórios do dossier instalados na homologação em 07182c7c
Entrega autorizada pelo proprietário e instalada pelo executor Git em `https://teste-investidor.tempo.co.mz`. A revisão servida é `07182c7c735efa88d850551da71b671c3dadfa9d`. Entrada: `/investimentos`, abrir o dossier autorizado e a secção `#relatorios`. Índice por etapa e exportação consolidada Word/HTML disponíveis; cópias fiscais preservadas são recuperadas pelo titular a partir do próprio dossier. Continua sem envio ou publicação automática.
Backup/restauro fresco:193 tabelas e381ficheiros. Conferência pós-entrega:12ficheiros de fonte por SHA256,HTTPS e controlos de entrada privada,12tabelas de negócio/geometria sem alteração,mais os três lotes demográficos presentes no backup conservados por contagens/checksum de metadados,observações e eventos. Durante a entrega foram concluídas,por operações autenticadas independentes,as restantes incorporações demográficas:a verificação final encontrou12lotes aprovados/920456observações. Esta entrega de relatórios não fez importações ou migrações;actualizou o vínculo da readiness à nova revisão,preservando as preparações CLI aceites.
Prova final `outputs/dossier-reports-20261113/homologation-installed.json`;estado agregado `verification.json`. QA funcional,privacidade,recuperação,exportações,tipos,lint,análise PHP e compilação aprovados. A conferência visual no uso normal permanece pendente,sem reabrir a instalação nem as provas aceites. Este estado substitui a indicação histórica de QA sem instalação acima e não declara concluídos outros módulos ou relatórios ainda por implementar.
## 13/11/2026 — consulta e descarga de resumos das 19 etapas, correcção em QA
Conferência com conta sintética e MFA normal em `http://127.0.0.1:18814/investimentos/5245f06c-bdbd-4caf-bc37-900f10bf582c`, base isolada `investmoz_demography_test:55441`. Novo dossier, versão 21: os serviços existentes guardaram fundamentos sintéticos em S01–S19. Não foram alterados dossiers anteriores nem simuladas decisões/licenças externas. Na homologação foi criado apenas o dossier separado de ensaio `db85f087-0737-4164-892a-4fa4fe1313df`, com fundamento sintético S01; os três dossiers anteriores foram preservados.
Lacuna corrigida: o componente existente passa a permitir seleccionar a etapa, consultar o resumo dos registos recuperados e preparar Word ou HTML. A ligação de descarga permanece visível com indicação de ficheiro pronto; o conteúdo e as pendências não são confundidos com documentos oficiais em falta. As limitações do modelo passam a integrar ambos os formatos. Mudar de dossier, versão ou oportunidade reinicializa a selecção/ligação local para não reutilizar uma exportação de outro contexto.
Prova integrada: consulta das 19 etapas e 38 descargas reais pelo Edge (19 Word/19 HTML), conferidas em disco por identidade, etapa, versão, fundamento, limitações e ausência de registos de outra etapa. Actualizar a página recuperou os 19 registos e a versão 21. Evidência em `outputs/dossier-reports-20261113/stage-authenticated-browser-checks.json`, `stage-authenticated-download-verification.json`, `stage-recovery.json` e `visual/04-dossier-qa-S19-descarga.jpg`. Os ensaios isolados do componente estão identificados separadamente, sem os apresentar como prova de persistência.
Validação nova, limitada ao delta: dois testes frontend (incluem exportação das 19 etapas e escape das limitações), tipos, lint dos três ficheiros de produto afectados e compilação QA. Não foram repetidas as provas aceites de APIEX/SWOT, cálculo fiscal, permissões ou instalação. O perfil antigo de QA referia contas que já não existem no cluster actualmente ligado; foi localizado e usado o perfil documentado da demografia, sem criar permissões ou reiniciar o cluster. A serialização da chave MFA da conta recém-criada foi corrigida apenas nessa conta sintética.
Estado: correcção implementada e conferida em QA, ainda sem instalação. A homologação mantém `07182c7c` para o recorte de relatórios anteriormente instalado. A prova demonstra resumos de preparação por etapa; não declara produzidos estudos financeiros, licenças, decisões, pacotes APIEX ou documentos que não existam nos respectivos percursos. A instalação deste novo delta é a pendência de entrega; os saldos próprios dos outros módulos mantêm-se.
@@ -1,3 +1,13 @@
## 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.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,13 @@
## 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.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
@@ -0,0 +1,172 @@
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
## 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>
);
}
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import {
createInvestorDossierReport,
createInvestorStageReport,
investorDossierReportIndex,
type InvestorDossierReportInput,
} from '@/lib/investor-dossier-report';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '@/lib/project-dossier-report';
export function InvestorDossierReports({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
return (
<InvestorDossierReportsContent
key={`${dossier.case.id}:${dossier.case.version}:${dossier.context_opportunity ?? ''}`}
dossier={dossier}
/>
);
}
function InvestorDossierReportsContent({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [stage, setStage] = useState('');
const [download, setDownload] = useState<{
url: string;
name: string;
} | null>(null);
useEffect(
() => () => {
if (download) URL.revokeObjectURL(download.url);
},
[download],
);
const requiresChoice =
dossier.opportunity_options.length > 1 && !dossier.context_opportunity;
const entries = requiresChoice ? [] : investorDossierReportIndex(dossier);
async function exportReport(format: 'word' | 'html') {
setBusy(true);
setError('');
try {
const model = stage
? createInvestorStageReport(dossier, stage)
: createInvestorDossierReport(dossier);
const blob =
format === 'word'
? await generateProjectDossierDocx(model, [], true)
: new Blob(
[
await createProjectDossierPrintHtml(
model,
[],
true,
),
],
{ type: 'text/html;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const name = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}${stage ? `-${stage}` : ''}.${format === 'word' ? 'docx' : 'html'}`;
setDownload({ url, name });
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar o relatório. Os registos foram preservados.',
);
} finally {
setBusy(false);
}
}
return (
<section id="relatorios" className="space-y-4 rounded-xl border p-4">
<h2 className="text-lg font-semibold">Relatórios deste dossier</h2>
<p>
Dossier {dossier.case.title} · versão {dossier.case.version} ·
oportunidade {dossier.context_opportunity ?? 'contexto comum'}.
</p>
<p>
Reúna as etapas, a viabilidade, a análise SWOT e a preparação
APIEX. O relatório mantém os estados e as pendências; os
originais continuam nas respectivas versões.
</p>
{requiresChoice ? (
<p role="status">
Seleccione a oportunidade em tratamento para reunir os seus
relatórios sem misturar contextos.
</p>
) : (
<>
<label className="block">
Relatório a consultar e descarregar
<select
className="mt-1 block w-full rounded border p-2"
value={stage}
disabled={busy}
onChange={(event) => {
setStage(event.target.value);
setDownload(null);
}}
>
<option value="">Dossier completo</option>
{dossier.steps.map((step) => (
<option key={step.code} value={step.code}>
{step.code} — {step.title}
</option>
))}
</select>
</label>
{stage && (
<StageReportPreview dossier={dossier} stage={stage} />
)}
<div className="flex flex-wrap gap-3">
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('word')}
>
Preparar{' '}
{stage
? `relatório ${stage}`
: 'relatório consolidado'}{' '}
(Word)
</button>
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('html')}
>
Preparar versão para impressão/PDF
</button>
</div>
{busy && <p role="status">A preparar o ficheiro…</p>}
{download && (
<p role="status">
Ficheiro pronto.{' '}
<a
className="font-medium underline"
href={download.url}
download={download.name}
>
Descarregar {download.name}
</a>
</p>
)}
<p className="text-sm">
A versão HTML pode ser aberta e impressa ou guardada
como PDF. As descargas são privadas; não enviam
processos ou mensagens a entidades externas.
</p>
{!!entries.length && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Índice por etapa, versão e data
</caption>
<thead>
<tr>
<th className="p-2">Etapa</th>
<th className="p-2">
Relatório ou documento
</th>
<th className="p-2">Versão e data</th>
<th className="p-2">Estado</th>
<th className="p-2">Consultar</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-t">
<td className="p-2">
{entry.stage}
</td>
<td className="p-2">
{entry.title}
</td>
<td className="max-w-56 p-2 break-words">
{entry.version} ·{' '}
{entry.date ||
'Data não registada'}
</td>
<td className="p-2">
{entry.state}
</td>
<td className="p-2">
<a
className="underline"
href={entry.href}
>
{entry.action}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!entries.length && (
<p>
Nenhum relatório ou documento produzido e autorizado
neste âmbito. O relatório consolidado identifica as
etapas e as pendências existentes.
</p>
)}
<p className="text-sm">
Os relatórios financeiros são apresentados apenas ao
titular autorizado do rascunho. Consulte os documentos
de cada acto antes de executar o investimento.
</p>
</>
)}
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
</section>
);
}
function StageReportPreview({
dossier,
stage,
}: {
dossier: InvestorDossierReportInput;
stage: string;
}) {
const report = createInvestorStageReport(dossier, stage);
return (
<article
className="space-y-3 rounded border p-3"
aria-label={`Relatório da etapa ${stage}`}
>
<h3 className="font-semibold">{report.title}</h3>
<p className="text-sm">{report.version}</p>
{report.sections.map((section) => (
<section key={section.id} className="space-y-2">
<h4 className="font-medium">{section.title}</h4>
{section.paragraphs.map((text) => (
<p key={text}>{text}</p>
))}
<dl className="space-y-2">
{section.rows.map((row, index) => (
<div key={index} className="break-words">
<dt className="font-medium">{row.field}</dt>
<dd>{row.value}</dd>
<dd className="text-sm">
{row.classification} · {row.origin}
</dd>
</div>
))}
</dl>
</section>
))}
{report.limitations.map((text) => (
<p className="text-sm" key={text}>
{text}
</p>
))}
</article>
);
}
@@ -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 (
+614
View File
@@ -0,0 +1,614 @@
import { pathStates, type PathResult } from './investor-path.ts';
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
import { buildProjectDossierRows } from './project-dossier.ts';
import type { SwotDraft } from './investment-preparation.ts';
import type {
DossierReportModel,
DossierReportRow,
} from './project-dossier-report.ts';
export type PreparationReport = {
case_id: string;
opportunity: string | null;
version: number;
context_revision: number;
href: string;
api_state: string;
api_issues: string[];
exports: {
id: string;
at: string;
context_revision: number;
fingerprint: string;
historical: boolean;
href: string;
}[];
external: {
kind: string;
date: string;
reference: string;
export_id: string;
outcome?: string;
}[];
swot: SwotDraft;
swot_review_required: boolean;
};
export type InvestorDossierReportInput = {
case: { id: string; title: string; version: number };
context_opportunity: string | null;
opportunity_options: unknown[];
investment_context: {
revision: number;
fields?: Record<string, string>;
} | null;
phases: Record<string, string>;
steps: {
code: string;
title: string;
phase: number;
version?: number;
state: string;
reason: string;
detail: {
scope?: string;
document_id?: string;
authority?: string;
reference?: string;
decision_date?: string;
recorded_at?: string;
};
}[];
results: PathResult[];
documents: {
id: string;
document_id?: string;
file_name: string;
status: string;
malware_scan_status: string;
version_no: number;
sha256: string;
}[];
preparation_report?: PreparationReport | null;
fiscal_reports?: {
simulation_id: string;
report_id: string;
report_version_no?: number;
report_sha256?: string;
consented_at?: string;
scope_state?: 'identified' | 'common' | 'unconfirmed';
}[];
land_report?: {
id: string;
version: number;
updated_at: string | null;
href: string;
} | null;
company_reports?: {
comparison?: { revision: number; at: string };
preparation?: { revision: number; at: string };
guides?: Record<
string,
{ revision: number; at: string; slug: string; title: string }
>;
};
};
export type DossierReportIndexEntry = {
id: string;
stage: string;
title: string;
version: string;
date: string;
state: string;
href: string;
action: string;
};
const externalStates: Record<string, string> = {
preparing: 'Em preparação',
ready: 'Pronto para entrega',
delivery_recorded: 'Entrega externa registada',
decision_received: 'Decisão recebida',
};
const externalRecords: Record<string, string> = {
delivery: 'Entrega externa',
clarification: 'Pedido de esclarecimento',
response: 'Resposta recebida',
decision: 'Decisão recebida',
};
const decisions: Record<string, string> = {
approved: 'Favorável',
refused: 'Desfavorável',
conditional: 'Condicionada',
other: 'Outra decisão',
};
const dimensions = {
strength: 'Forças',
weakness: 'Fraquezas',
opportunity: 'Oportunidades',
threat: 'Ameaças',
};
const classifications = {
documented_fact: 'Facto documentado',
hypothesis: 'Hipótese',
unconfirmed: 'Por confirmar',
};
function checkScope(input: InvestorDossierReportInput): void {
if (input.opportunity_options.length > 1 && !input.context_opportunity)
throw new Error(
'Seleccione a oportunidade antes de reunir os relatórios.',
);
const preparation = input.preparation_report;
if (
preparation &&
(preparation.case_id !== input.case.id ||
preparation.opportunity !== input.context_opportunity)
)
throw new Error(
'A preparação pertence a outro dossier ou oportunidade.',
);
}
function documentsInDossier(input: InvestorDossierReportInput) {
const seen = new Set<string>();
return input.documents.filter((document) => {
const key = `${document.document_id ?? document.id}:${document.version_no}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function investorDossierReportIndex(
input: InvestorDossierReportInput,
): DossierReportIndexEntry[] {
checkScope(input);
const base = `/investimentos/${encodeURIComponent(input.case.id)}`;
const query = input.context_opportunity
? `?opportunity=${encodeURIComponent(input.context_opportunity)}`
: '';
const entries: DossierReportIndexEntry[] = [];
const seen = new Set<string>();
const add = (entry: DossierReportIndexEntry) => {
if (!seen.has(entry.id)) {
seen.add(entry.id);
entries.push(entry);
}
};
for (const result of input.results) {
if ((result.context_opportunity ?? null) !== input.context_opportunity)
continue;
add({
id: `finance:${result.id}`,
stage: 'S03',
title: 'Resultado de viabilidade financeira',
version: String(result.draft_version),
date: result.recorded_at,
state:
result.context_state === 'review_required' ||
result.context_revision !== input.investment_context?.revision
? 'Histórico — requer revisão'
: 'Resultado guardado',
href: `${base}${query}#resultados-financeiros`,
action: 'Consultar resultado',
});
}
for (const source of input.fiscal_reports ?? [])
add({
id: `fiscal:${source.report_id}`,
stage: 'S03',
title: 'Relatório de simulação fiscal',
version: String(source.report_version_no ?? 'Por identificar'),
date: source.consented_at ?? '',
state:
source.scope_state === 'identified'
? 'Versão preservada — estimativa fiscal'
: source.scope_state === 'common'
? 'Versão preservada — contexto comum do dossier'
: 'Versão preservada — âmbito por confirmar',
href: `${base}/relatorios/fiscais/${encodeURIComponent(source.report_id)}${query}`,
action: 'Descarregar versão preservada (JSON)',
});
for (const kind of ['comparison', 'preparation'] as const) {
const saved = input.company_reports?.[kind];
if (saved)
add({
id: `company:${kind}`,
stage: 'S05',
title:
kind === 'comparison'
? 'Comparação de formas jurídicas'
: 'Preparação da empresa',
version: String(saved.revision),
date: saved.at,
state: 'Preparação guardada',
href: `${base}/${kind === 'comparison' ? 'forma-juridica' : 'preparar-empresa'}`,
action: 'Consultar preparação',
});
}
const preparation = input.preparation_report;
if (input.land_report) {
add({
id: `land:${input.land_report.id}`,
stage: 'S10',
title: 'Preparação de acesso à terra',
version: String(input.land_report.version),
date: input.land_report.updated_at ?? '',
state: 'Preparação guardada — não é título DUAT',
href: input.land_report.href,
action: 'Abrir versão para impressão',
});
}
for (const exported of preparation?.exports ?? [])
add({
id: `apiex:${exported.id}`,
stage: 'S06',
title: 'Dossier documental APIEX',
version: exported.id,
date: exported.at,
state: exported.historical
? 'Exportação histórica preservada'
: 'Exportação preparada',
href: exported.href,
action: 'Descarregar pacote',
});
if (preparation?.swot.rows.length)
add({
id: 'swot',
stage: 'S02',
title: 'Análise SWOT',
version: String(preparation.version),
date: preparation.swot.review?.at ?? '',
state: preparation.swot_review_required
? 'Revisão necessária'
: 'Revista pela equipa',
href: preparation.href,
action: 'Consultar análise',
});
for (const guide of Object.values(input.company_reports?.guides ?? {}))
add({
id: `guide:${guide.slug}`,
stage: 'S06',
title: guide.title,
version: String(guide.revision),
date: guide.at,
state: 'Referência guardada para o dossier',
href: `${base}/guias/${encodeURIComponent(guide.slug)}`,
action: 'Consultar guia',
});
for (const document of documentsInDossier(input)) {
const stages = input.steps.filter(
(step) => step.detail.document_id === document.document_id,
);
add({
id: `document:${document.id}`,
stage:
stages.map((step) => step.code).join(', ') || 'Anexos comuns',
title: document.file_name,
version: String(document.version_no),
date: '',
state:
document.malware_scan_status === 'clean' &&
document.status === 'active'
? 'Documento associado'
: 'Disponibilidade por conferir',
href: `/balcao-virtual/dossiers/${encodeURIComponent(input.case.id)}/documentos/${encodeURIComponent(document.id)}`,
action: 'Consultar documento',
});
}
for (const step of input.steps) {
if (
entries.some((entry) => entry.stage.split(', ').includes(step.code))
)
continue;
add({
id: `stage:${step.code}`,
stage: step.code,
title: step.title,
version: String(step.version ?? 'Não registada'),
date: step.detail.recorded_at ?? '',
state:
step.state === 'dispensed'
? 'Preparação dispensada — consultar fundamento'
: 'Sem relatório ou documento associado',
href: `${base}${query}#${step.code}`,
action: 'Consultar etapa',
});
}
return entries.sort((a, b) => a.stage.localeCompare(b.stage));
}
export function createInvestorDossierReport(
input: InvestorDossierReportInput,
generatedAt = new Date().toISOString(),
): DossierReportModel {
const index = investorDossierReportIndex(input);
const row = (
field: string,
value: string,
origin = 'Registo guardado neste dossier',
classification = 'Preparação do investimento',
): DossierReportRow => ({ field, value, origin, classification });
const finance: DossierReportRow[] = [];
for (const result of input.results.filter(
(item) =>
(item.context_opportunity ?? null) === input.context_opportunity,
)) {
const reference = `Resultado ${result.id}; rascunho ${result.draft_id}, versão ${result.draft_version}; ${result.recorded_at}`;
const state =
result.context_revision === input.investment_context?.revision &&
result.context_state !== 'review_required'
? 'Estimativa guardada'
: 'Histórico — rever antes de utilizar';
try {
if (
result.engine !== 'financial-calculators:v1' ||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
)
throw new Error('Versão não recuperável');
const currency = result.currency ?? '';
const baseline = new Set(
buildProjectDossierRows({ currency }).map((item) =>
JSON.stringify(item),
),
);
const sections = new Set([
'Plano financeiro',
'Empréstimo educativo',
'Ponto de equilíbrio',
'Financiamento',
'Orçamento',
]);
const rows = buildProjectDossierRows({
currency,
financial_tools_snapshot: result.financial_snapshot,
});
for (const item of rows.filter(
(item) =>
sections.has(item[0]) &&
!baseline.has(JSON.stringify(item)),
)) {
finance.push(
row(
`${item[0]} — ${item[1]}`,
item[2],
`${reference}; ${item[3]}`,
state,
),
);
}
} catch {
finance.push(
row(
'Resultado preservado',
'Formato indisponível para cálculo; consulte a origem, sem substituir valores por zero.',
reference,
state,
),
);
}
}
const preparation = input.preparation_report;
const swot = preparation?.swot;
return {
title: input.case.title,
generatedAt,
version: `Dossier ${input.case.id} · v${input.case.version} · contexto v${input.investment_context?.revision ?? 'não identificado'} · oportunidade ${input.context_opportunity ?? 'contexto comum'}`,
references: [],
limitations: [
'Relatório privado de preparação; não comprova licenciamento, aprovação ou financiamento.',
'Os actos externos só valem pelo respectivo documento e entidade. Pendências e versões históricas permanecem identificadas.',
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
],
sections: [
{
id: 'project',
title: 'Investimento e localização em preparação',
private: true,
paragraphs: [
'Dados guardados no contexto seleccionado; não representam aprovação ou validação externa.',
],
rows: Object.entries({
project_title: 'Projecto',
project_summary: 'Descrição',
sector: 'Sector',
province: 'Província',
district: 'Distrito',
location: 'Localização',
total_investment: 'Investimento previsto',
currency: 'Moeda',
expected_start_date: 'Início previsto',
})
.filter(([key]) =>
input.investment_context?.fields?.[key]?.trim(),
)
.map(([key, label]) =>
row(
label,
input.investment_context?.fields?.[key] ?? '',
`Contexto v${input.investment_context?.revision ?? 'não identificado'}`,
),
),
},
{
id: 'summary',
title: 'Identificação e índice dos relatórios',
private: true,
paragraphs: [
`Dossier: ${input.case.id}`,
`Oportunidade: ${input.context_opportunity ?? 'Contexto comum do dossier'}`,
`Gerado em ${generatedAt}; versão do dossier ${input.case.version}.`,
],
rows: index.map((entry) =>
row(
`${entry.stage} — ${entry.title}`,
`${entry.state}; versão ${entry.version}; ${entry.date || 'data não registada'}`,
entry.href,
entry.action,
),
),
},
{
id: 'execution',
title: 'Etapas, resultados e pendências',
private: true,
paragraphs: [
'A situação de uma etapa não significa que o respectivo relatório ou acto externo tenha sido produzido.',
],
rows: input.steps.map((step) =>
row(
`${step.code} — ${step.title}`,
`${pathStates[step.state] ?? step.state}; versão ${step.version ?? 'não registada'}. ${step.reason || 'Sem fundamento adicional registado.'} ${step.detail.authority ?? ''} ${step.detail.reference ?? ''} ${step.detail.decision_date ?? ''}`,
`Fase ${step.phase}: ${input.phases[String(step.phase)] ?? ''}`,
step.state === 'not_started'
? 'Pendente'
: 'Estado guardado da etapa',
),
),
},
{
id: 'finance',
title: 'Viabilidade e relatórios fiscais',
private: true,
paragraphs: finance.length
? [
'Resultados recuperados das entradas guardadas pelo motor existente; impostos destinam-se à simulação de viabilidade.',
]
: [
'Nenhum resultado financeiro devolvido e autorizado neste âmbito. Não foi gerado um valor em falta.',
],
rows: [
...finance,
...(input.fiscal_reports ?? []).map((source) =>
row(
`Relatório fiscal ${source.report_id}`,
`Versão ${source.report_version_no ?? 'não indicada'}; SHA256 ${source.report_sha256 ?? 'não indicado'}`,
`/investimentos/${input.case.id}/relatorios/fiscais/${source.report_id}${input.context_opportunity ? `?opportunity=${encodeURIComponent(input.context_opportunity)}` : ''}`,
source.scope_state === 'identified'
? 'Versão fiscal preservada'
: source.scope_state === 'common'
? 'Versão preservada no contexto comum do dossier'
: 'Origem preservada no dossier; âmbito por confirmar',
),
),
],
},
{
id: 'market',
title: 'Análise SWOT',
private: true,
paragraphs: swot?.rows.length
? [
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
]
: ['Análise SWOT ainda não produzida neste âmbito.'],
rows: (swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
),
),
},
{
id: 'sources',
title: 'Preparação documental APIEX e actos externos',
private: true,
paragraphs: preparation
? [
`Estado: ${externalStates[preparation.api_state] ?? preparation.api_state}. Exportar não equivale a entregar; entrega não equivale a aprovação.`,
...preparation.api_issues,
]
: ['Preparação APIEX ainda não disponível neste âmbito.'],
rows: (preparation?.external ?? []).map((item) =>
row(
`${externalRecords[item.kind] ?? item.kind} — ${item.date}`,
`${item.reference}; ${item.outcome ? (decisions[item.outcome] ?? item.outcome) : 'sem decisão registada'}`,
`Exportação ${item.export_id}`,
'Registo de entrega ou resposta externa',
),
),
},
{
id: 'resources',
title: 'Documentos associados ao dossier',
private: true,
paragraphs: [
'Consulte os originais no dossier. Os controlos de acesso e disponibilidade mantêm-se em cada descarga.',
'Documentos comuns não comprovam a sua aplicabilidade a todas as oportunidades; confira a associação à etapa e ao pedido.',
],
rows: documentsInDossier(input).map((document) =>
row(
document.file_name,
`Versão ${document.version_no}; SHA256 ${document.sha256}`,
`/balcao-virtual/dossiers/${input.case.id}/documentos/${document.id}`,
document.status === 'active' &&
document.malware_scan_status === 'clean'
? 'Documento associado'
: 'Disponibilidade por conferir',
),
),
},
],
};
}
/** A preparation summary, never a substitute for an external act or its original. */
export function createInvestorStageReport(
input: InvestorDossierReportInput,
stageCode: string,
generatedAt = new Date().toISOString(),
): DossierReportModel {
const step = input.steps.find((item) => item.code === stageCode);
if (!step) throw new Error('Etapa não encontrada neste dossier.');
const report = createInvestorDossierReport(input, generatedAt);
const entries = investorDossierReportIndex(input).filter((entry) =>
entry.stage.split(', ').includes(stageCode),
);
const origins = new Set(entries.map((entry) => entry.href));
const specificSections = new Set([
...(stageCode === 'S02' ? ['market'] : []),
...(stageCode === 'S03' ? ['finance'] : []),
...(['S06', 'S07'].includes(stageCode) ? ['sources'] : []),
]);
return {
...report,
title: `${input.case.title} — ${stageCode}: ${step.title}`,
version: `${report.version} · etapa v${step.version ?? 'não registada'}`,
limitations: [
...report.limitations,
'Resumo dos registos disponíveis nesta etapa; não cria documentos, decisões ou resultados em falta.',
],
sections: report.sections
.filter(
(section) =>
['project', 'summary', 'execution', 'resources'].includes(
section.id,
) || specificSections.has(section.id),
)
.map((section) => {
if (section.id === 'summary')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
if (section.id === 'execution')
return {
...section,
rows: section.rows.filter((item) =>
item.field.startsWith(`${stageCode} —`),
),
};
if (section.id === 'resources')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
return section;
}),
};
}
@@ -396,6 +396,11 @@ export async function generateProjectDossierDocx(
],
}),
];
for (const limitation of model.limitations) {
children.push(
new Paragraph({ children: [new TextRun({ text: limitation })] }),
);
}
for (const section of model.sections) {
if (section.id === 'annexes') {
if (!attachments.some((item) => item.include)) {
@@ -591,6 +596,8 @@ export async function createProjectDossierPrintHtml(
annexesIncluded: boolean,
): Promise<string> {
const content: string[] = [];
for (const limitation of model.limitations)
content.push(`<p>${escapeHtml(limitation)}</p>`);
for (const section of model.sections) {
if (
section.id === 'annexes' &&
+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>
+11 -1
View File
@@ -1,4 +1,6 @@
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
import {
financialCategories,
financialTypes,
@@ -31,6 +33,7 @@ import {
} from '@/lib/investor-path';
type Step = {
version: number;
code: string;
title: string;
phase: number;
@@ -45,6 +48,10 @@ type Step = {
source_paragraph: number;
};
type Path = {
preparation_report?: InvestorDossierReportInput['preparation_report'];
company_reports?: InvestorDossierReportInput['company_reports'];
fiscal_reports?: InvestorDossierReportInput['fiscal_reports'];
land_report?: InvestorDossierReportInput['land_report'];
sector_references?: SectorResourceSelection[];
funding_selections?: {
id: string;
@@ -117,6 +124,8 @@ type Path = {
file_name: string;
status: string;
malware_scan_status: string;
version_no: number;
sha256: string;
}[];
requests: {
id: string;
@@ -995,7 +1004,8 @@ export default function InvestmentPath(path: Path) {
</div>
</details>
))}
<section className="space-y-3">
<InvestorDossierReports dossier={path} />
<section id="resultados-financeiros" className="space-y-3">
<h2 className="text-lg font-semibold">
Resultados devolvidos ao dossier
</h2>
@@ -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>
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Http\Controllers\DemographyController;
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');
// Each action enforces its existing installer/importer authorisation. The legacy
// catalogue gate does not recognise a designated profile superuser.
Route::prefix('management/demography')->middleware(['auth', 'verified', 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
@@ -24,6 +24,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
Route::get('/{id}/preparacao', [InvestmentPreparationController::class, 'show'])->whereUuid('id');
Route::get('/{id}/relatorios/fiscais/{reportId}', [Path::class, 'fiscalReport'])->whereUuid(['id', 'reportId']);
Route::post('/{id}/preparacao', [InvestmentPreparationController::class, 'change'])->whereUuid('id');
Route::get('/{id}/preparacao/previa', [InvestmentPreparationController::class, 'preview'])->whereUuid('id');
Route::get('/{id}/preparacao/exportar/{exportId}', [InvestmentPreparationController::class, 'download'])->whereUuid('id')->whereUuid('exportId');
+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,141 @@
<?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_designated_superuser_does_not_require_the_legacy_manager_gate(): void
{
config(['portal.managers' => []]);
$this->assertFalse($this->manager->can('manage-portal'));
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->component('management/demography-install')->where('plan.mfa_confirmed', true));
app(\App\Services\DemographicImport::class)->authorize($this->manager);
$this->assertTrue(true);
}
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_disabled_process_support_does_not_crash_the_installation_page(): void
{
if (function_exists('proc_open')) { $this->markTestSkipped('Run this regression with -d disable_functions=proc_open.'); }
config(['demography.prepared_directory' => null]);
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->component('management/demography-install')->where('plan.runtime_ready', false)->where('plan.ready', false));
}
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']]);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?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_cli_preparation_imports_without_process_execution_and_keeps_exact_values(): void
{
Storage::fake('local');
$disk = Storage::disk('local');
$original = 'SYNTHETIC prepared original'; $sha = hash('sha256', $original);
$directory = 'prepared/'.$sha; $disk->makeDirectory($directory);
$disk->put('synthetic.xlsx', $original);
$row = ['province' => 'SYNTHETIC province', 'territory' => 'SYNTHETIC province', 'level' => 'province',
'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas',
'data_kind' => 'projected', 'original_value' => '100.125', 'numeric_value' => '100.125', 'sheet' => '2026', 'cell' => 'B4'];
$records = json_encode($row, JSON_THROW_ON_ERROR)."\n";
$report = ['sha256' => $sha, 'source' => 'SYNTHETIC QA ONLY', 'record_count' => 1, 'records_file_sha256' => hash('sha256', $records),
'territories' => [['SYNTHETIC province', 'SYNTHETIC province', 'province']], 'issues' => [], 'issue_counts' => []];
$preview = json_encode($report, JSON_THROW_ON_ERROR);
$receipt = ['sha256' => $sha, 'baseline_year' => null, 'parser_sha256' => hash_file('sha256', base_path('tools/demography_workbook.py')),
'preview_sha256' => hash('sha256', $preview), 'records_sha256' => hash('sha256', $records), 'record_count' => 1];
$disk->put($directory.'/preview.json', $preview); $disk->put($directory.'/records.ndjson', $records);
$disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
config(['demography.prepared_directory' => $disk->path('prepared'), 'demography.python' => '/missing-python-must-not-run']);
$this->mock(\App\Services\Malware\MalwareScanner::class)->shouldReceive('scan')->once()->andReturn([
'result' => 'clean', 'engine' => 'synthetic', 'engine_version' => 'test']);
$service = app(DemographicImport::class);
$this->assertTrue($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
$result = $service->prepare($this->manager, $disk->path('synthetic.xlsx'), 'synthetic.xlsx', 'SYNTHETIC', null);
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $result['id'])->count());
$service->confirm($this->manager, $result['id'], $sha, [], 'Synthetic authenticated import from CLI preparation.');
$this->assertSame('100.125', DB::table('demographic_observations')->where('import_id', $result['id'])->value('numeric_value'));
$this->assertSame('imported', DB::table('demographic_imports')->where('id', $result['id'])->value('status'));
$disk->put($directory.'/records.ndjson', 'TAMPERED');
try { $service->preparedSource($sha, null); $this->fail('Changed prepared records must be refused.'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(409, $error->getStatusCode()); }
$receipt['parser_sha256'] = str_repeat('0', 64); $disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
$this->assertFalse($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
}
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);
}
}
}
@@ -71,6 +71,62 @@ class InvestmentPreparationWorkspaceTest extends TestCase
return $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => $view['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC explicit review, not real external action', 'action' => $action, ...$data])->assertOk()->json();
}
public function test_dossier_reports_recover_preserved_exports_and_keep_financial_sources_private(): void
{
$view = $this->workspace();
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$view = $this->action($view, 'apiex_export');
$export = $view['apiex']['exports'][0]['id'];
$draft = (string) Str::uuid(); $report = (string) Str::uuid(); $simulation = (string) Str::uuid();
$source = ['simulation_id' => $simulation, 'report_id' => $report, 'report_version_no' => 2, 'report_sha256' => str_repeat('a', 64), 'report_snapshot' => ['private' => 'SYNTHETIC NEVER EXPOSED'], 'consented_at' => now()->toIso8601String()];
$otherReport = (string) Str::uuid();
$otherSource = [...$source, 'report_id' => $otherReport, 'association_opportunity' => 'other-opportunity'];
\App\Models\SimulationFormDraft::create(['id' => $draft, 'owner_user_id' => $this->owner->id,
'create_key_hash' => hash('sha256', $draft), 'create_payload_hash' => hash('sha256', $draft), 'snapshot_sha256' => hash('sha256', $draft),
'personal_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot([]),
'economic_snapshot' => \App\Models\SimulationFormDraft::encryptSnapshot(['purpose' => 'request_support', 'simulation_sources' => [$source, $otherSource]])]);
DB::table('desk_cases')->where('id', $this->caseId)->update(['simulation_id' => $draft]);
$land = \App\Models\LandAssistanceCase::create(['user_id' => $this->owner->id, 'reference' => 'QA-'.Str::uuid(), 'crm_submission_key' => (string) Str::uuid(),
'version' => 3, 'payload' => ['investor_path_case_id' => $this->caseId]]);
$before = DB::table('simulation_form_drafts')->where('id', $draft)->first();
$data = $this->getJson('/investimentos/'.$this->caseId)->assertOk()->json();
$this->assertSame($this->caseId, $data['preparation_report']['case_id']);
$this->assertSame($export, $data['preparation_report']['exports'][0]['id']);
$this->assertStringContainsString('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export, $data['preparation_report']['exports'][0]['href']);
$this->assertSame($report, $data['fiscal_reports'][0]['report_id']);
$this->assertCount(1, $data['fiscal_reports']);
$this->assertSame('/investidor/terra-duat/'.$land->id.'/imprimir', $data['land_report']['href']);
$this->assertStringNotContainsString('SYNTHETIC NEVER EXPOSED', json_encode($data, JSON_THROW_ON_ERROR));
$download = $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()
->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('case_id', $this->caseId)->assertJsonPath('report_snapshot.private', 'SYNTHETIC NEVER EXPOSED')->assertJsonPath('scope_state', 'unconfirmed');
$this->assertSame($download->getContent(), $this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertOk()->getContent());
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.Str::uuid())->assertNotFound();
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$otherReport)->assertNotFound();
$this->assertEquals($before, DB::table('simulation_form_drafts')->where('id', $draft)->first());
$this->getJson('/investimentos/'.$this->caseId.'?opportunity=other-opportunity')->assertUnprocessable();
$this->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('preparation_report.exports.0.id', $export)->assertJsonPath('fiscal_reports.0.report_id', $report);
$drafts = app(\App\Services\SimulationFormDrafts::class);
$current = \App\Models\SimulationFormDraft::findOrFail($draft);
$request = \Illuminate\Http\Request::create('/api/v1/simulation-drafts/'.$draft.'/simulations', 'POST');
$request->setUserResolver(fn () => $this->owner);
$request->headers->set('If-Match', $drafts->etag($current));
$newSource = [...$source, 'simulation_id' => (string) Str::uuid(), 'report_id' => (string) Str::uuid()];
\App\Models\Simulation::create(['id' => $newSource['simulation_id'], 'owner_user_id' => $this->owner->id, 'status' => 'completed', 'input_snapshot' => ['synthetic' => true]]);
$attached = $drafts->attachSimulation($draft, $newSource, $request);
$saved = \App\Models\SimulationFormDraft::decryptSnapshot($attached['draft']->economic_snapshot)['simulation_sources'];
$this->assertSame($source, $saved[0]);
$this->assertArrayHasKey('association_opportunity', $saved[2]);
$this->assertNull($saved[2]['association_opportunity']);
$this->assertTrue($drafts->attachSimulation($draft, $newSource, $request)['replayed']);
config(['investor-path.enabled' => false]);
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertNotFound();
config(['investor-path.enabled' => true]);
$manager = User::factory()->create(); config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->getJson('/investimentos/'.$this->caseId)->assertOk()->assertJsonPath('fiscal_reports', [])->assertJsonPath('results', [])->assertJsonPath('land_report', null);
$this->get('/investimentos/'.$this->caseId.'/relatorios/fiscais/'.$report)->assertForbidden();
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$this->caseId)->assertForbidden();
}
public function test_apiex_existing_dossier_requirements_review_zip_and_external_delivery_without_submission_or_approval(): void
{
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
+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()
@@ -0,0 +1,311 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import {
createInvestorDossierReport,
investorDossierReportIndex,
} from '../../resources/js/lib/investor-dossier-report.ts';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '../../resources/js/lib/project-dossier-report.ts';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
function fixture() {
return {
case: {
id: 'case-a',
title: 'Investimento QA <script>alert(1)</script>',
version: 9,
},
context_opportunity: 'op-a',
opportunity_options: ['op-a', 'op-b'],
investment_context: {
revision: 3,
fields: { project_title: 'Projecto QA', province: 'Manica' },
},
phases: { 1: 'Viabilidade', 2: 'Preparação' },
steps: [
{
code: 'S03',
title: 'Viabilidade',
phase: 1,
version: 2,
state: 'preparing',
reason: 'Conferir pressupostos',
detail: {},
},
{
code: 'S08',
title: 'Licenciamento prévio',
phase: 2,
version: 4,
state: 'not_started',
reason: '',
detail: { document_id: 'doc-a' },
},
],
results: [],
land_report: {
id: 'land-a',
version: 3,
updated_at: '2026-11-13',
href: '/investidor/terra-duat/land-a/imprimir',
},
documents: [
{
id: 'link-a',
document_id: 'doc-a',
file_name: 'Estudo QA',
status: 'active',
malware_scan_status: 'clean',
version_no: 2,
sha256: 'a'.repeat(64),
},
],
preparation_report: {
case_id: 'case-a',
opportunity: 'op-a',
version: 7,
context_revision: 3,
href: '/investimentos/case-a/preparacao?opportunity=op-a',
api_state: 'delivery_recorded',
api_issues: [],
exports: [
{
id: 'export-old',
at: '2026-11-12',
context_revision: 2,
historical: true,
fingerprint: 'old',
href: '/investimentos/case-a/preparacao/exportar/export-old?opportunity=op-a',
},
{
id: 'export-new',
at: '2026-11-13',
context_revision: 3,
historical: false,
fingerprint: 'new',
href: '/investimentos/case-a/preparacao/exportar/export-new?opportunity=op-a',
},
],
external: [
{
kind: 'delivery',
date: '2026-11-13',
reference: 'QA-ENTREGA',
export_id: 'export-new',
},
],
swot_review_required: true,
swot: {
object_key: 'investment:case-a',
scope: 'Produção QA',
rows: [
{
id: 's1',
dimension: 'strength',
text: 'Equipa experiente',
reference: 'Fonte QA',
date: '2026-11-12',
classification: 'hypothesis',
},
],
review: null,
actions: [],
},
},
fiscal_reports: [
{
simulation_id: 'simulation-a',
report_id: 'report-a',
report_version_no: 2,
report_sha256: 'b'.repeat(64),
consented_at: '2026-11-13',
},
],
company_reports: {
comparison: { revision: 2, at: '2026-11-12' },
guides: {
energy: {
slug: 'energia',
title: 'Guia de energia',
revision: 1,
at: '2026-11-12',
},
},
},
};
}
void test('one dossier groups stage evidence, SWOT, fiscal and all preserved APIEX versions without modifying sources', () => {
const input = fixture();
input.documents.push({ ...input.documents[0], id: 'second-link' });
const before = JSON.stringify(input);
const index = investorDossierReportIndex(input);
assert.equal(
index.filter((entry) => entry.id.startsWith('apiex:')).length,
2,
);
assert.equal(
index.find((entry) => entry.id === 'document:link-a').stage,
'S08',
);
assert.equal(
index.filter((entry) => entry.id.startsWith('document:')).length,
1,
);
assert.match(
index.find((entry) => entry.id === 'apiex:export-old').state,
/histórica/,
);
assert.equal(
index.find((entry) => entry.id === 'swot').state,
'Revisão necessária',
);
assert.match(
index.find((entry) => entry.id === 'land:land-a').state,
/não é título DUAT/,
);
const report = createInvestorDossierReport(input, '2026-11-13T12:00:00Z');
assert.match(report.version, /case-a.*v9.*contexto v3.*op-a/);
assert.match(
report.sections.find((s) => s.id === 'execution').rows[1].value,
/Por iniciar.*versão 4/,
);
assert.match(
report.sections.find((s) => s.id === 'market').rows[0].value,
/Hipótese/,
);
assert.match(
report.sections.find((s) => s.id === 'sources').paragraphs.join(' '),
/Entrega externa registada.*não equivale a aprovação/,
);
assert.equal(JSON.stringify(input), before);
});
void test('cross-dossier or cross-opportunity preparation is rejected and an ambiguous scope requires selection', () => {
const input = fixture();
input.preparation_report.case_id = 'case-b';
assert.throws(() => createInvestorDossierReport(input), /outro dossier/);
input.preparation_report.case_id = 'case-a';
input.preparation_report.opportunity = 'op-b';
assert.throws(() => createInvestorDossierReport(input), /oportunidade/);
input.context_opportunity = null;
assert.throws(() => investorDossierReportIndex(input), /Seleccione/);
});
void test('financial results from a different opportunity do not enter the consolidated report or its index', () => {
const input = fixture();
input.results = [
{
id: 'other',
context_opportunity: 'op-b',
financial_snapshot: 'OTHER-PRIVATE-INPUTS',
draft_id: 'other-draft',
draft_version: 8,
recorded_at: '2026-11-13',
engine: 'unknown',
currency: 'MZN',
},
];
assert.ok(
!JSON.stringify(createInvestorDossierReport(input)).includes(
'OTHER-PRIVATE-INPUTS',
),
);
assert.ok(
!investorDossierReportIndex(input).some(
(entry) => entry.id === 'finance:other',
),
);
assert.match(
createInvestorDossierReport(input).sections.find(
(s) => s.id === 'finance',
).paragraphs[0],
/Nenhum resultado/,
);
});
void test('the consolidated report reuses loan, break-even, financing and budget outputs with currency and historical state', () => {
const input = fixture();
const financial_snapshot = serializeFinancialToolsSnapshot({
budgetPeriod: 'Mensal',
loan: {
principal: '100000',
rate: '0',
months: '12',
rateType: 'nominal',
periodMonths: '1',
gracePeriods: '0',
upfrontFee: '0',
recurringFee: '0',
},
breakEven: { fixed: '1000', price: '100', variable: '50' },
funding: { total: '100000', secured: '30000' },
incomes: [{ label: 'Receita QA', amount: '100' }],
expenses: [{ label: 'Custo QA', amount: '50' }],
});
input.results = [
{
id: 'finance-a',
context_opportunity: 'op-a',
context_revision: 2,
context_state: 'review_required',
financial_snapshot,
draft_id: 'draft-a',
draft_version: 5,
recorded_at: '2026-11-12',
engine: 'financial-calculators:v1',
currency: 'MZN',
},
];
const report = createInvestorDossierReport(input);
const rows = report.sections.find(
(section) => section.id === 'finance',
).rows;
assert.equal(
rows.find(
(item) => item.field === 'Financiamento — Diferença por financiar',
).value,
'70000.00 MZN',
);
assert.equal(
rows.find(
(item) => item.field === 'Ponto de equilíbrio — Unidades estimadas',
).value,
'20',
);
assert.equal(
rows.find(
(item) => item.field === 'Orçamento — Saldo operacional estimado',
).value,
'50.00 MZN',
);
assert.ok(
rows.some((item) => item.field.startsWith('Empréstimo educativo')),
);
assert.match(rows[0].classification, /Histórico/);
assert.equal(input.results[0].financial_snapshot, financial_snapshot);
});
void test('the existing Word and print exporters retain dossier identity, pending stages, source and history, escaping active HTML', async () => {
const report = createInvestorDossierReport(
fixture(),
'2026-11-13T12:00:00Z',
);
const html = await createProjectDossierPrintHtml(report, [], false);
assert.ok(!html.includes('<script>alert(1)</script>'));
assert.match(html, /case-a/);
assert.match(html, /Fonte QA/);
assert.match(html, /export-old/);
assert.ok(html.includes('a'.repeat(64)));
const docx = await generateProjectDossierDocx(report, [], false);
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
const xml = await zip.file('word/document.xml').async('string');
assert.match(xml, /case-a/);
assert.match(xml, /Licenciamento prévio/);
assert.match(xml, /Por iniciar/);
assert.match(xml, /Fonte QA/);
assert.match(xml, /export-old/);
});
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import { createInvestorStageReport } from '../../resources/js/lib/investor-dossier-report.ts';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '../../resources/js/lib/project-dossier-report.ts';
const input = {
case: {
id: 'synthetic-stage-dossier',
title: 'Ensaio sintético',
version: 3,
},
context_opportunity: null,
opportunity_options: [],
investment_context: {
revision: 1,
fields: { project_title: 'Fábrica sintética', province: 'Sofala' },
},
phases: { 1: 'Preparação sintética' },
results: [],
documents: [],
steps: Array.from({ length: 19 }, (_, i) => ({
code: `S${String(i + 1).padStart(2, '0')}`,
title: `Etapa sintética ${i + 1}`,
phase: 1,
version: i + 2,
state: 'preparing',
reason: `Fundamento sintético exclusivo ${i + 1}.`,
detail: { recorded_at: '2026-11-13' },
})),
};
void test('all 19 stage reports can be consulted and exported without inventing outcomes or changing source data', async () => {
const before = JSON.stringify(input);
for (const step of input.steps) {
const model = createInvestorStageReport(
input,
step.code,
'2026-11-13T15:00:00Z',
);
assert.match(model.title, new RegExp(step.code));
const execution = model.sections.find((s) => s.id === 'execution');
assert.equal(execution.rows.length, 1);
assert.ok(execution.rows[0].value.includes(step.reason));
assert.ok(
model.sections
.find((s) => s.id === 'summary')
.rows.every((r) => r.field.startsWith(step.code)),
);
assert.ok(
!model.sections.some((s) => s.id === 'finance') ||
step.code === 'S03',
);
const html = await createProjectDossierPrintHtml(model, [], true);
assert.ok(html.includes(step.reason));
assert.ok(html.includes('synthetic-stage-dossier'));
const blob = await generateProjectDossierDocx(model, [], true);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const xml = await zip.file('word/document.xml').async('string');
assert.ok(xml.includes(step.reason));
assert.ok(xml.includes('não cria documentos'));
}
assert.equal(JSON.stringify(input), before);
assert.throws(
() => createInvestorStageReport(input, 'S99'),
/não encontrada/,
);
assert.throws(
() =>
createInvestorStageReport(
{ ...input, opportunity_options: ['a', 'b'] },
'S01',
),
/Seleccione/,
);
});
void test('exported limitations retain literal text and cannot inject active HTML', async () => {
const model = createInvestorStageReport(input, 'S08');
model.limitations.push('Limite <script>alert(1)</script> & origem');
const html = await createProjectDossierPrintHtml(model, [], true);
assert.ok(
html.includes(
'Limite &lt;script&gt;alert(1)&lt;/script&gt; &amp; origem',
),
);
assert.ok(!html.includes('<script>alert(1)</script>'));
const zip = await JSZip.loadAsync(
await (await generateProjectDossierDocx(model, [], true)).arrayBuffer(),
);
assert.ok(
(await zip.file('word/document.xml').async('string')).includes(
'Limite &lt;script&gt;alert(1)&lt;/script&gt; &amp; origem',
),
);
});
+288
View File
@@ -0,0 +1,288 @@
"""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")
cli.add_argument("--preparation-receipt")
args = cli.parse_args()
if args.preparation_receipt and not args.records_output:
cli.error('--preparation-receipt requires --records-output')
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")
if args.preparation_receipt:
receipt = dict(sha256=result['sha256'], baseline_year=args.baseline_year,
parser_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
preview_sha256=hashlib.sha256(Path(args.output).read_bytes()).hexdigest(),
records_sha256=result['records_file_sha256'], record_count=result['record_count'])
Path(args.preparation_receipt).write_text(json.dumps(receipt, separators=(',', ':')), encoding='utf-8')
print(json.dumps({k: v for k, v in result.items() if k not in ("records", "issues")}, ensure_ascii=False))