Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
723ce2a0a4 | ||
|
|
07182c7c73 | ||
|
|
bb6824d75d | ||
|
|
095280e29d | ||
|
|
e92519dd75 | ||
|
|
4988ca89dc | ||
|
|
258c8bd28b | ||
|
|
df543a4414 | ||
|
|
291a1c420f | ||
|
|
b20068d04b | ||
|
|
880e12890c |
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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'] ?? []) : [],
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,26 @@ 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.
|
||||
|
||||
@@ -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,165 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
createInvestorDossierReport,
|
||||
investorDossierReportIndex,
|
||||
type InvestorDossierReportInput,
|
||||
} from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
createProjectDossierPrintHtml,
|
||||
generateProjectDossierDocx,
|
||||
} from '@/lib/project-dossier-report';
|
||||
|
||||
export function InvestorDossierReports({
|
||||
dossier,
|
||||
}: {
|
||||
dossier: InvestorDossierReportInput;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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 = 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 link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}.${format === 'word' ? 'docx' : 'html'}`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} 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>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('word')}
|
||||
>
|
||||
Descarregar relatório consolidado (Word)
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('html')}
|
||||
>
|
||||
Descarregar versão para impressão/PDF
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
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',
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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']]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,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))
|
||||
Reference in New Issue
Block a user