Compare commits

...
Author SHA1 Message Date
nala a045162773 Record authorised grouped PEC installation and preserved data 2026-11-13 23:29:17 +02:00
nala f059c226d2 Group documented prior PEC basis and excess PPC reconciliation for viability
tests / ci (push) Waiting to run
2026-11-13 23:14:22 +02:00
nala d9f088587b Record authorised observation nature delivery and preserved data 2026-11-13 23:07:27 +02:00
nala 2bad72c042 Expose calendar duration and overlap before comparing territorial observations
tests / ci (push) Waiting to run
2026-11-13 22:54:49 +02:00
nala 39bddad091 Record authorised observation nature delivery and preserved data 2026-11-13 22:54:46 +02:00
nala 42b9324acb Classify sourced territorial observations without inferring legacy values
tests / ci (push) Waiting to run
2026-11-13 22:44:42 +02:00
nala 7e7bd4fde3 Record authorised territorial comparison delivery and preserved data 2026-11-13 22:09:58 +02:00
nala 1263b4f6b7 Compare sourced territorial observations without inferring statistical equivalence
tests / ci (push) Waiting to run
2026-11-13 21:52:38 +02:00
nala 2f1ef2fdfc Align quick search with current financial directory while retaining historical records
tests / ci (push) Waiting to run
2026-11-13 21:14:25 +02:00
nala 61b4d9f1f9 Record authorised stage report installation and preservation 2026-11-13 15:19:12 +02:00
nala 1d52c3d8f4 Preserve installed dossier and support presentation while delivering authorised quick search
tests / ci (push) Waiting to run
2026-11-13 15:17:26 +02:00
nala 0f79d0d54c Keep institutional contact test independent of CSS module loading in Node CI
tests / ci (push) Waiting to run
2026-11-13 15:09:08 +02:00
nala d325b72fad Improve investor quick search with typed counts, product categories and licensing guidance 2026-11-13 15:06:11 +02:00
nala 8854b2928c Allow private stage report preview and explicit Word/HTML download 2026-11-13 15:02:15 +02:00
paulomassigue f53a308327 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Waiting to run
2026-11-13 14:43:34 +02:00
nala 723ce2a0a4 Record grouped investor reports installed and preservation verified 2026-11-13 14:28:10 +02:00
nala 07182c7c73 Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Waiting to run
2026-11-13 14:09:52 +02:00
nala bb6824d75d Keep concurrent manual additions outside demographic delivery 2026-11-13 14:08:20 +02:00
nala 095280e29d Record demographic matrix status while preserving concurrent additions in working copy 2026-11-13 14:07:52 +02:00
nala e92519dd75 Record HTTP500 correction verified in real PHP-FPM with process execution blocked 2026-11-13 14:06:58 +02:00
nala 4988ca89dc Use verified CLI preparations when web PHP cannot execute processes
tests / ci (push) Waiting to run
2026-11-13 13:57:01 +02:00
nala 258c8bd28b Record installed demography access correction and refreshed readiness 2026-11-13 13:49:32 +02:00
nala df543a4414 Recognise designated superusers in authenticated demography installation
tests / ci (push) Waiting to run
2026-11-13 13:41:48 +02:00
nala 291a1c420f Record demography code installed in homologation, authenticated data confirmation pending 2026-11-13 13:40:16 +02:00
nala b20068d04b Deliver additive INE demography and authenticated homologation installation
tests / ci (push) Waiting to run
2026-11-13 13:27:36 +02:00
nala 880e12890c Record mining and energy reference delivery installed in f2767818 2026-11-11 14:20:23 +02:00
68 changed files with 6425 additions and 85 deletions
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class DemographyController extends Controller
{
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if ($request->expectsJson()) {
return response()->json($evidence);
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
{
if (! Schema::hasTable('demographic_imports')) {
return redirect('/management/demography/install');
}
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
return Inertia::render('management/demography', ['qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
public function installation(Request $request, DemographicInstallation $service): Response
{
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
}
public function install(Request $request, DemographicInstallation $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function prepare(Request $request, DemographicImport $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
$file = $request->file('file');
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
}
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
}
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
$service->transition($request->user(), $id, $data['action'], $data['reason']);
return response()->json(['id' => $id, 'status' => $data['action']]);
}
}
+152 -6
View File
@@ -3,31 +3,105 @@
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\LegacyContent;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use App\Services\SectorResourceReferences;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$term = trim($input['q'] ?? '');
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
$original = trim($input['q'] ?? '');
$intent = $search->requestIntent($original);
if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
$intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
'filter' => $intent['filter'] ?? $original,
'category' => match ($input['category']) {
'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
}];
}
$term = $intent['filter'] ?? $original;
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) {
if ($tokens !== [] || $intent !== null) {
$factories = [
'Produtos e localizações' => function () use ($search, $tokens, $term) {
try {
return $this->productReferences($search, $tokens, $term);
} catch (\RuntimeException $exception) {
throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
}
},
'Produtos financeiros' => function () use ($search, $tokens, $term) {
$service = app(FundingProgrammes::class);
if (! $service->installed()) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = $service->catalogueQuery([])->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
foreach ($tokens as $token) {
$query->where(function ($match) use ($search, $token) {
$search->orFields($match, ["(v.payload->>'title')",
"(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
"(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
$text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
if ($search->matches($text, [$token])) {
$match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
}
}
foreach (FundingInstitutionDetails::NATURES as $code => $label) {
if ($search->matches($label, [$token])) {
$match->orWhere('v.payload->institution_nature', $code);
}
}
});
}
return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
$p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
$products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
$services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
$places = [];
foreach ($p['locations'] ?? [] as $location) {
if (! empty($location['location_text'])) {
$places[] = $location['location_text'];
}
foreach ($location['territory_path'] ?? [] as $node) {
if ($node['type'] !== 'country') {
$places[] = $node['name'];
}
}
}
$national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
'url' => '/fundos/'.$row->id];
});
},
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
@@ -63,9 +137,15 @@ class GlobalSearchController extends Controller
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term) {
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
if ($intent !== null && $intent['kind'] !== null) {
[$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
$query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
// The existing unique entity key already prevents duplicate dossiers.
}
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
@@ -126,13 +206,75 @@ class GlobalSearchController extends Controller
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
if ($intent !== null) {
$categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
$factories = array_intersect_key($factories, array_flip($categories));
}
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
$answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
'count' => $results['unavailable'] === [] ? $results['total'] : null];
if ($answer !== null && $intent['type'] === 'products') {
$answer['category'] = match ($intent['category']) {
'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
};
}
return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
}
/** @param list<string> $tokens
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function productReferences(SearchTerms $search, array $tokens, string $term): array
{
$rows = [];
foreach (['mineracao', 'energia-especifica'] as $sector) {
if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
continue;
}
$data = app(SectorResourceReferences::class)->view($sector);
foreach ($data['groups'] as $group) {
$locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
$rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
}
}
if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
try {
app(LegacyContent::class)->article('agronegocio', false);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
foreach ($entries as $entry) {
if (! isset($published[$entry['key']])) {
continue;
}
$rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
}
} catch (HttpException $exception) {
if ($exception->getStatusCode() !== 404) {
throw $exception;
}
}
}
$rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param callable(mixed):array<string,mixed> $present
* @param literal-string $title
* @param literal-string $id
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
@@ -142,10 +284,14 @@ class GlobalSearchController extends Controller
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them. */
/** Public service entry points and terms used to find them.
* @return list<array{title:string,description:string,url:string}>
*/
private function pages(): array
{
return [
['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
@@ -20,6 +20,16 @@ use Inertia\Response;
class InvestorPathController extends Controller
{
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(app(InvestorPath::class)->available(), 404);
$request->validate(['opportunity' => 'nullable|string|max:150']);
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
->header('X-Content-Type-Options', 'nosniff');
}
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DemographicEvidence
{
public function territories(): Builder
{
$public = app(InvestmentGeography::class)->publicReferences();
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
->where(function ($q) use ($public) {
$q->whereIn('id', (clone $public)->select('id'));
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
}
});
}
public function installed(): bool
{
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
}
/** @return array<string,string> */
public static function sectors(): array
{
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
}
/** @param list<string> $territories
* @return array<string,mixed> */
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
{
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
if (! $this->installed() || $territories === []) {
return $result;
}
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
foreach ($locations as $location) {
$series = [];
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
$first = $rows->first();
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$values = $rows->filter(function ($r) use ($sector) {
if ($r->indicator !== 'population') {
return in_array($sector, ['health', 'general'], true);
}
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
$years = array_values(array_unique(array_column($values, 'year')));
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
}
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
'factors_to_assess' => match ($sector) {
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
}];
}
// Identify concurrent values without summing editions or choosing a supposedly newer one.
foreach ($result['territories'] as $territory) {
$byDimension = [];
foreach ($territory['series'] as $series) {
foreach ($series['values'] as $row) {
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
}
}
foreach ($byDimension as $key => $values) {
if (count($values) > 1) {
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
}
}
}
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
foreach ($result['territories'] as $i => $left) {
foreach (array_slice($result['territories'], $i + 1) as $right) {
foreach ($left['series'] as $a) {
foreach ($right['series'] as $b) {
foreach (array_unique([$start, $end]) as $year) {
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
if (! $x || ! $y) {
continue;
}
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
}
}
}
}
}
return $result;
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function forContext(array $context): array
{
$fields = $context['fields'] ?? [];
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
// No inference from an influence area to a project's implantation territory.
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') { return null; }
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) { return null; }
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try { $receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Recibo CLI inválido.'); }
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try { $report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR); }
catch (\JsonException) { abort(409, 'Prévia CLI inválida.'); }
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
}
/** @param array<string,array<string,mixed>> $files */
public function preparedSourcesReady(array $files): bool
{
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) { return false; }
}
return $files !== [];
} catch (\Throwable) { return false; }
}
public function authorize(User $actor): void
{
$access = app(PortalProfileAccess::class);
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->has($actor, 'auditor'), 403);
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
}
}
/** @return array<string,mixed> */
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
{
$this->authorize($actor);
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
$sha = CheckedFile::sha256($file);
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
if ($existing) {
return $this->preview($actor, $existing->id);
}
$id = (string) Str::uuid();
$directory = 'demography/'.$id;
$disk = Storage::disk('local');
$disk->makeDirectory($directory);
$path = $disk->path($directory.'/original.xlsx');
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
$output = $disk->path($directory.'/preview.json');
$prepared = $this->preparedSource($sha, $baselineYear);
if ($prepared !== null) {
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
} else {
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
}
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
$summary = array_diff_key($report, ['records' => true]);
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
// The supplied publication label is provenance metadata, never an invented edition/date.
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
return $this->preview($actor, $id);
}
public static function key(string $province, string $territory, string $level): string
{
return $province.'|'.$territory.'|'.$level;
}
/** @return array<string,mixed> */
public function preview(User $actor, string $id): array
{
$this->authorize($actor);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
$matches = [];
foreach ($report['territories'] as [$province, $territory, $level]) {
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
$key = self::key($province, $territory, $level);
if ($level === 'district') {
$headers = $report['territory_headers'][$key] ?? [];
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
}
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
}
$manifest = DemographicInstallation::remainingManifest();
$hashes = array_column($manifest['files'], 'sha256');
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
if ($alias !== null) {
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
}
}
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
}
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
}
private function normalize(string $name): string
{
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
}
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
/** @param array<string,string> $mapping
* @return array<string,mixed> */
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
{
$this->authorize($actor);
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
if ($batch->status !== 'prepared') {
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
}
$disk = Storage::disk('local');
$file = $disk->path('demography/'.$id.'/preview.json');
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
$plan = $this->preview($actor, $id);
$resolved = [];
foreach ($plan['matches'] as $match) {
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
if ($match['level'] === 'district') {
$provinceKey = self::key($match['province'], $match['province'], 'province');
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
}
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
$resolved[$match['key']] = $locationId;
}
$chunk = [];
$recordCount = 0;
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
$recordCount++;
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
if (count($chunk) === 500) {
DB::table('demographic_observations')->insert($chunk);
$chunk = [];
}
}
if ($chunk !== []) {
DB::table('demographic_observations')->insert($chunk);
}
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
});
}
/** @param array<string,mixed> $report
* @return iterable<array<string,mixed>> */
private function recordRows(array $report, string $path): iterable
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
if (trim($line) === '') { continue; }
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
public function transition(User $actor, string $id, string $action, string $reason): void
{
$this->authorize($actor);
DB::transaction(function () use ($actor, $id, $action, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
});
}
/** @param array<string,mixed> $details */
private function event(string $id, User $actor, string $action, string $reason, array $details): void
{
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\Process\Exception\ExceptionInterface;
use Symfony\Component\Process\Process;
/** Owner-authorised release; schema and data still require existing session MFA. */
final class DemographicInstallation
{
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
public const FILES = [
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
];
/** @return array<string,mixed> */
public static function remainingManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return array<string,mixed> */
public static function zambeziaManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return list<string> */
public static function publicDistrictRefs(): array
{
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
foreach ($manifest['new_districts'] as $province => $names) {
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) { $refs[] = $source.':'.$name; }
}
}
return $refs;
}
/** @return list<string> */
public function files(): array
{
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
'resources/js/pages/management/shared.tsx'];
}
public function fingerprint(): string
{
$files = [];
foreach ($this->files() as $path) {
$files[$path] = CheckedFile::sha256(base_path($path));
}
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
public function activeTarget(): bool
{
$c = config('database.connections.pgsql');
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
return false;
}
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
return $target->database === $c['database'] && (int) $target->port === 5432;
}
public function schemaAllowed(): bool
{
if (IsolatedQaDatabase::allowsSchemaChanges()) {
return true;
}
$actor = auth()->user();
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
return false;
}
app(PortalProfileInstallation::class)->authorize($actor, true);
return true;
}
private function authorize(User $actor, bool $write): void
{
app(PortalProfileInstallation::class)->authorize($actor, $write);
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
}
/** @return array<string,mixed> */
public function preview(User $actor): array
{
$this->authorize($actor, false);
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
$code = $this->fingerprint();
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$qa = IsolatedQaDatabase::allowsSchemaChanges();
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
if (! $qa) {
foreach (['dump', 'files'] as $kind) {
$file = $proof[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
}
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
$file = public_path('build/'.$relative);
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
}
}
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
try {
if (! $runtimeReady && function_exists('proc_open')) {
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
$runtime->run();
$runtimeReady = $runtime->isSuccessful();
}
} catch (ExceptionInterface) {
$runtimeReady = false;
}
$mode = $ledger ? 'remaining' : 'initial';
$manifest = self::remainingManifest();
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
/** @param list<string> $preservedHashes
* @return array<string,array{count:int,sha256:string}> */
private function protectedState(array $preservedHashes = []): array
{
$state = [];
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
if (Schema::hasTable($table)) {
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
}
}
if (Schema::hasTable('demographic_imports')) {
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
$digest = hash_init('sha256');
$count = 0;
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
foreach ($rows as $row) {
if ($count > 0) { hash_update($digest, "\n"); }
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
$count++;
}
});
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
}
}
return $state;
}
private function territories(User $actor): void
{
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
foreach (['Macate', 'Vanduzi'] as $name) {
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
if ($matches->isNotEmpty()) {
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
continue;
}
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
}
}
/** @param array<string,mixed> $manifest */
private function remainingTerritories(User $actor, array $manifest): void
{
foreach ($manifest['new_districts'] as $province => $names) {
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
abort_unless($parents->count() === 1, 409);
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) {
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
}
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
$mode = $data['mode'] ?? 'initial';
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$plan = $this->preview($actor);
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
set_time_limit(300);
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
// Recheck after the lock as another request may have completed while we waited.
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$before = $this->protectedState($preservedHashes);
$previous = config('demography.authenticated_installation');
config(['demography.authenticated_installation' => true]);
try {
if ($mode === 'initial') {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
} finally {
config(['demography.authenticated_installation' => $previous]);
}
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
$receipts = [];
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
$service = app(DemographicImport::class);
foreach ($files as $name => $expected) {
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
if ($old && $old->status === 'approved') {
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
abort_unless($count === $expected['records'], 409);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
continue;
}
$file = $directory.'/'.$name;
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
}
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}
+12 -3
View File
@@ -3,11 +3,14 @@
namespace App\Services;
use Illuminate\Database\QueryException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable<array<string,mixed>>}> $factories
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,unavailable:list<string>}
*/
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
@@ -15,7 +18,10 @@ final class FederatedSearch
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
} catch (QueryException $exception) {
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
}
@@ -45,7 +51,10 @@ final class FederatedSearch
if ($batch === []) {
$positions[$label] = $source['count'];
}
} catch (QueryException $exception) {
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
+4
View File
@@ -138,6 +138,10 @@ final class InvestmentGeography
$query->where(function ($workbook) {
$workbook->where('source_system', 'cartografia-workbook')
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
})->orWhere(function ($demography) {
// Owner, 13/11/2026: only these source-backed additions are authorised.
$demography->where('source_system', 'ine-demography')->where('location_type', 'district')
->whereIn('external_id', DemographicInstallation::publicDistrictRefs());
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
});
}
+1
View File
@@ -58,6 +58,7 @@ final class InvestmentMapIndicators
'zone_id' => $zone->id, 'zone_name' => $zone->name, 'zone_version' => (int) $zone->version_no, 'url' => '/zonas/'.$zone->id,
'theme' => $row['indicator'] === 'employment' ? 'workforce' : 'economic_potential',
'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing,
'measure_kind' => in_array($row['measure_kind'] ?? null, ['observed', 'planned', 'projected'], true) ? $row['measure_kind'] : 'unknown',
];
}
}
@@ -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
{
@@ -18,7 +18,7 @@ class InvestmentZoneIntegralDetails
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'],
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'observations' => ['indicator', 'measure_kind', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
@@ -59,6 +59,7 @@ class InvestmentZoneIntegralDetails
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$rules['observations.*.measure_kind'] = 'nullable|in:unknown,observed,planned,projected';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
@@ -148,7 +149,7 @@ class InvestmentZoneIntegralDetails
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
}$key = implode('|', [$item['indicator'], $item['measure_kind'] ?? 'unknown', $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
+12
View File
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -171,6 +172,17 @@ final class InvestorPath
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
'company_reports' => ($owner || $manager) ? array_filter([
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
]) : [],
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
+89 -5
View File
@@ -7,6 +7,64 @@ use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
/** @return array{type:string,category:?string,label:string,kind:?string,filter:string}|null */
public function requestIntent(string $query): ?array
{
if ($count = $this->countIntent($query)) {
return ['type' => 'count', ...$count];
}
$normalized = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
if (preg_match('/^(?:como|onde|quero|preciso de)\s+(?:(?:posso|faco para|devo)\s+)?licenciar(?:$|\s+(?:(?:uma|a|minha|o|um|meu)\s+)?(?:empresa|negocio|actividade|atividade|investimento)\b)/', $normalized)) {
return ['type' => 'guide', 'category' => 'Páginas e serviços', 'label' => 'Licenciamento da empresa', 'kind' => null, 'filter' => 'licenciamento'];
}
$product = null;
if (preg_match('/^(?:(?:tipos?|lista|catalogo) de |quais (?:sao )?(?:os )?)?produtos?\b(.*)$/', $normalized, $match)) {
$product = $match[1];
} elseif (preg_match('/^onde (?:encontrar|produzir|se produz|existem|ha)\s+(.+)$/', $normalized, $match)) {
$product = $match[1];
}
if ($product !== null) {
$category = preg_match('/\bfinanceiros?\b/', $product) ? 'Produtos financeiros' : (preg_match('/\bfisicos?\b/', $product) ? 'Produtos e localizações' : null);
$product = preg_replace('/\b(?:e (?:a )?(?:sua )?localizacao|e (?:as )?(?:suas )?localizacoes|financeiros?|fisicos?|existem|disponiveis|no portal)\b/', ' ', $product);
return ['type' => 'products', 'category' => $category, 'label' => 'Produtos e localização', 'kind' => null, 'filter' => trim(preg_replace('/\s+/', ' ', $product))];
}
if (preg_match('/^(?:listar|mostrar|mostre|ver|quais (?:sao )?(?:os|as))\s+(.+)$/', $normalized, $match)
&& ($list = $this->countIntent('quantos '.$match[1]))) {
return ['type' => 'list', ...$list];
}
return null;
}
/** @return array{category:string,label:string,kind:?string,filter:string}|null */
public function countIntent(string $query): ?array
{
$query = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
$types = [
'corredor|corredores' => ['Carteira: projectos, clusters e corredores', 'corredores', 'corridor'],
'cluster|clusters' => ['Carteira: projectos, clusters e corredores', 'clusters', 'cluster'],
'projectos? ancora|projetos? ancora' => ['Carteira: projectos, clusters e corredores', 'projectos âncora', 'project'],
'oportunidade|oportunidades' => ['Oportunidades', 'oportunidades', null],
'negocio|negocios' => ['Negócios', 'negócios', null],
'documento|documentos' => ['Documentos e legislação', 'documentos', null],
'artigo|artigos' => ['Artigos', 'artigos', null],
'sector|sectores|setor|setores' => ['Sectores', 'sectores', null],
'localizacao|localizacoes' => ['Localizações', 'localizações', null],
'zonas? de investimento|zona|zonas' => ['Zonas de investimento', 'zonas de investimento', null],
'parceria|parcerias' => ['Parcerias', 'parcerias', null],
];
foreach ($types as $pattern => [$category, $label, $kind]) {
if (preg_match('/^(?:quantos|quantas|numero de|quantidade de|total de|qual (?:e )?o numero de)\s+(?:'.$pattern.')\b(.*)$/u', $query, $match)) {
$filter = preg_replace('/\b(?:no portal|no investemoz|em mocambique|de investimento|existem|existe|existentes|existente|ha|temos|estao|sao|disponiveis|disponivel|publicados|publicadas|registados|registadas|cadastrados|cadastradas)\b/u', ' ', $match[1]);
return ['category' => $category, 'label' => $label, 'kind' => $kind, 'filter' => trim(preg_replace('/\s+/', ' ', $filter))];
}
}
return null;
}
/** @return list<string> */
public function tokens(string $query): array
{
@@ -24,13 +82,16 @@ final class SearchTerms
return $tokens;
}
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
/** Known Portuguese spelling and number variants; do not infer semantic equivalence.
* @return list<string>
*/
public function variants(string $token): array
{
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) {
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas'],
['banco', 'bancos'], ['seguro', 'seguros'], ['produto', 'produtos'], ['agricola', 'agricolas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
@@ -44,7 +105,10 @@ final class SearchTerms
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
/** Columns are trusted application constants, never user input. */
/** Columns are trusted application constants, never user input.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
*/
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
@@ -54,6 +118,10 @@ final class SearchTerms
}
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
* @param list<string> $tokens
*/
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
@@ -61,6 +129,7 @@ final class SearchTerms
}
}
/** @param list<string> $tokens */
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
@@ -73,11 +142,15 @@ final class SearchTerms
return true;
}
/** @param literal-string $column
* @return literal-string
*/
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
/** @param list<string> $tokens */
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
@@ -92,7 +165,12 @@ final class SearchTerms
return $score;
}
/** The same title score used for in-memory pages, with bound search terms. */
/** The same title score used for in-memory pages, with bound search terms.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param literal-string $titleColumn
* @param literal-string $tieColumn
*/
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
@@ -107,6 +185,12 @@ final class SearchTerms
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
}
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
$query->orderByRaw('('.$sql.') DESC', $bindings);
if (str_contains($titleColumn, '(')) {
$query->orderByRaw($titleColumn);
} else {
$query->orderBy($titleColumn);
}
$query->orderBy($tieColumn);
}
}
+58
View File
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
}
$this->requireMatch($request, $draft);
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
$sources[] = $sourceSnapshot;
$economic['simulation_sources'] = $sources;
$economic['reuse_simulation'] = 'yes';
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
});
}
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
* @return list<array<string,mixed>> */
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
if (! $draft) { return []; }
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$sources = [];
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
foreach ($records as $source) {
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
}
return $sources;
});
}
/** Reads the already preserved copy, including a source originally calculated anonymously.
* @return array<string,mixed> */
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
$source = null;
foreach ($records as $item) {
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
}
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
});
}
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
public function claimPreview(Request $request): ?array
{
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
// Remote activation is explicit; local/QA behaviour remains unchanged.
'enabled' => env('DEMOGRAPHY_ENABLED', env('APP_URL') !== 'https://teste-investidor.tempo.co.mz'
&& in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
'source_directory' => env('DEMOGRAPHY_SOURCE_DIRECTORY', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias'),
'prepared_directory' => env('DEMOGRAPHY_PREPARED_DIRECTORY'),
// An explicit interpreter avoids Windows Store execution aliases in web workers.
'python' => env('DEMOGRAPHY_PYTHON', 'python'),
];
@@ -0,0 +1,120 @@
{
"files": {
"Cabo Delgado.xlsx": {
"sha256": "419ebf09f26055a2d46dac7027143835ee2e2dc8a91e3b1f498c5b459566ce16",
"records": 92808,
"province": "Cabo Delgado",
"issues": {
"missing_value": 6336
}
},
"Gaza.xlsx": {
"sha256": "b66ccde3478a74a854e8e2c4d51209401d844a15a701b16267e005fa44b70dc4",
"records": 82618,
"province": "Gaza",
"issues": {
"age_total_mismatch": 4,
"missing_value": 2,
"residence_total_mismatch": 4,
"sex_total_mismatch": 1
}
},
"Maputo Provincia.xlsx": {
"sha256": "1bf2921ef43d18817878a6d05e487ce5b107c14aa149ad17bedae6f1c652430e",
"records": 49504,
"province": "Maputo Província",
"issues": {
"cached_formula_value": 42972,
"missing_value": 68
}
},
"Maputo Cidade.xlsx": {
"sha256": "ba72c0b64200e5bdb4b1dda60a0a974bc7ce7c6d28d37a490735437261f1e513",
"records": 44064,
"province": "Maputo Cidade",
"issues": {}
},
"Nampula.xlsx": {
"sha256": "cc960b06d7dfd0cb3f9a16edd94ff8db0451e8f3e24f3ad8442d03f7a053ac3b",
"records": 131400,
"province": "Nampula",
"issues": {
"missing_value": 792
}
},
"Niassa.xlsx": {
"sha256": "3116278d32de729058c7133ccc8204b42f14a0f8ba2b8d0aa8be46c9ff08d160",
"records": 93632,
"province": "Niassa",
"issues": {
"age_total_mismatch": 3,
"cached_formula_value": 12,
"district_total_mismatch": 2,
"missing_value": 4,
"residence_total_mismatch": 1
}
},
"Sofala.xlsx": {
"sha256": "4eaf5ef5e332ed50d5739330e14d78e2ead2551f407de7e31e0d1a96a76c956e",
"records": 77108,
"province": "Sofala",
"issues": {
"age_total_mismatch": 6,
"missing_value": 4,
"residence_total_mismatch": 6
}
},
"Tete.xlsx": {
"sha256": "4bbd7856107c4a3716b533caf7968a691deab4662e42689a7c529b58b80e2d58",
"records": 67932,
"province": "Tete",
"issues": {
"missing_value": 20196
}
}
},
"new_districts": {
"Gaza": [
"Chongoene",
"Limpopo",
"Mapai"
],
"Nampula": [
"Larde",
"Liupo"
],
"Niassa": [
"Chimbonila"
],
"Tete": [
"Doa",
"Marara"
]
},
"aliases": {
"Cabo Delgado|Metuge|district": "PEMBA - METUGE",
"Gaza|Mandlakaze|district": "MANDLACAZE",
"Nampula|Erati|district": "NAMAPA-ERATI",
"Nampula|Ilha de Mocambique|district": "CIDADE ILHA DE MOCAMBIQUE",
"Nampula|Nacala-a-Velha|district": "NACALA-VELHA",
"Nampula|Rapale|district": "NAMPULA",
"Maputo Cidade|KaMphumu|district": "DISTRITO URBANO 1",
"Maputo Cidade|Nlhamankulu|district": "DISTRITO URBANO 2",
"Maputo Cidade|KaMaxakeni|district": "DISTRITO URBANO 3",
"Maputo Cidade|KaMavota|district": "DISTRITO URBANO 4",
"Maputo Cidade|KaMubukwana|district": "DISTRITO URBANO 5",
"Maputo Cidade|KaTembe|district": "DISTRITO DE CATEMBE",
"Maputo Cidade|KaNyaka|district": "DISTRITO DE INHACA",
"Nampula|Nampula|district": "CIDADE DE NAMPULA",
"Niassa|Lichinga|district": "CIDADE DE LICHINGA",
"Gaza|Xai-Xai|district": "CIDADE DE XAI-XAI"
},
"authority": "Pedido de instalar restantes províncias e autorização expressa de oito distritos,13/11/2026. Correspondências históricas apresentadas na prévia autenticada,sem renomear nem fundir IDs.",
"sources": [
"https://archive.gazettes.africa/archive/mz/2013/mz-government-gazette-series-i-dated-2013-12-18-no-101.pdf",
"https://international.ipums.org/international-action/variables/MZ2017A_0436",
"https://archive.gazettes.africa/archive/mz/2020/mz-government-gazette-series-i-dated-2020-05-04-no-83.pdf"
],
"missing_province_source": "Zambézia",
"city_mapping_decision": "Proprietário confirmou expressamente em13/11/2026 a ligação destes três rótulos às respectivas cidades,preservando Rapale,Chimbonila,Limpopo separados."
}
@@ -0,0 +1,23 @@
{
"files": {
"Zambezia.xlsx": {
"sha256": "8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7",
"records": 126684,
"province": "Zambezia",
"issues": {}
}
},
"new_districts": {
"Zambezia": [
"Derre",
"Luabo",
"Mocubela",
"Molumbo",
"Mulevala"
]
},
"aliases": {},
"sources": [],
"authority": "Proprietário autorizou expressamente Derre,Luabo,Mocubela,Molumbo e Mulevala e a incorporação de Zambezia.xlsx em13/11/2026,sem códigos oficiais ou geometria inferidos.",
"missing_province_source": null
}
@@ -0,0 +1,70 @@
<?php
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! app(DemographicInstallation::class)->schemaAllowed()) {
throw new RuntimeException('Demografia: instalação exige QA isolado ou a acção autenticada autorizada.');
}
Schema::create('demographic_imports', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->char('sha256', 64)->unique();
$t->string('file_name');
$t->string('source');
$t->string('publication_version');
$t->string('status')->default('prepared');
$t->jsonb('report');
$t->char('preview_sha256', 64);
$t->foreignId('prepared_by')->constrained('users')->restrictOnDelete();
$t->timestampsTz();
});
Schema::create('demographic_observations', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->unsignedSmallInteger('year');
$t->string('indicator', 80);
$t->string('sex', 10);
$t->string('residence', 10);
$t->string('age_group', 30);
$t->string('unit', 80);
$t->string('data_kind', 20);
$t->string('original_value', 100);
$t->text('numeric_value'); // Exact decimal text, including Excel storage precision.
$t->string('sheet');
$t->string('cell', 20);
$t->string('validation_status', 20);
$t->unique(['import_id', 'geo_location_id', 'year', 'indicator', 'sex', 'residence', 'age_group'], 'demography_dimension_unique');
$t->index(['geo_location_id', 'year', 'indicator']);
});
Schema::create('demographic_events', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('action', 30);
$t->text('reason');
$t->jsonb('details');
$t->timestampTz('created_at')->useCurrent();
});
DB::statement("ALTER TABLE demographic_imports ADD CONSTRAINT demographic_import_state CHECK (status IN ('prepared','imported','approved','disabled'))");
DB::statement("ALTER TABLE demographic_observations ADD CONSTRAINT demographic_observation_state CHECK (validation_status IN ('valid','warning'))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('demographic_imports')->exists()) {
throw new RuntimeException('Preserve originais e histórico demográfico antes de reverter.');
}
Schema::dropIfExists('demographic_events');
Schema::dropIfExists('demographic_observations');
Schema::dropIfExists('demographic_imports');
}
};
@@ -1,3 +1,115 @@
## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA
A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais.
Produtos físicos e financeiros aparecem em categorias separadas, com opções Todos / Produtos físicos / Produtos financeiros e preservação do filtro e paginação. Exemplos: «tipo de produtos e sua localização», «onde encontrar grafite em Cabo Delgado» e «produtos financeiros em Nampula». Reutiliza as referências aceites de Mineração/Energia, oportunidades agrícolas publicadas e versões publicadas das instituições. A ligação mineral conserva grupo/local seleccionado; a ligação financeira abre a instituição existente. Presença física e cobertura financeira aparecem separadas. Rascunhos e publicações futuras não são divulgados.
Limites: localização nestes exemplos é correspondência textual aos dados documentados; não representa geocodificação, elegibilidade ou garantia de oferta/financiamento. Só entram conteúdos publicados das fontes efectivamente disponíveis. Não é uma resposta aberta por IA. Erros 404/503 de fontes são identificados sem ocultar os outros resultados; 403 continua recusado.
Verificação focada em QA isolado 55441: integração 4 testes/118 asserções; regressão federada 3 aprovados e 1 ignorado por guarda de ambiente; unidade 8 testes/39 asserções; frontend 7 testes. Tipos TypeScript, PHPStan e compilação aprovados. Provas em `outputs/quick-search-20261113/`. Sem migrações, alterações de dados ou permissões. Conferência visual no navegador e instalação desta candidata ainda pendentes. Preservar as entregas e os doze lotes demográficos já aceites.
## 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 +9758,136 @@ S08 permanece por concluir, separada da revis?o visual; a especifica??o da janel
### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio
?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado.
## 13/11/2026 — relatórios agrupados no dossier: implementação em QA
Pedido do proprietário: verificar e resolver a dispersão das saídas produzidas pelas etapas do investimento. No percurso existente `/investimentos/{id}#relatorios`, o índice reúne resultados financeiros, versões fiscais preservadas, comparação/preparação da empresa, guias guardados, análise SWOT, exportações APIEX, preparação de acesso à terra e documentos das etapas. Cada referência conserva dossier, oportunidade ou contexto comum/por confirmar, revisão, data quando registada e estado; etapas sem resultado associado são explicitadas. A mesma versão documental ligada a mais de um pedido aparece uma vez, sem fundir originais diferentes.
Exportação consolidada Word e HTML para impressão/PDF reutiliza os geradores existentes e os cálculos de viabilidade já aceites. Inclui contexto do investimento, etapas e pendências, financiamento/orçamento/empréstimo/ponto de equilíbrio recuperados das entradas guardadas, conclusões SWOT com fonte/data/classificação, preparação APIEX e referências aos documentos. Exportações APIEX históricas mantêm o original, sem acrescentar SWOT privada ao pacote destinado à entidade externa. O índice dá acesso aos originais pelas autorizações existentes; o relatório não envia mensagens, submete processos, concede crédito, executa pagamentos ou publica conteúdo.
A cópia fiscal preservada no rascunho é recuperável pelo titular em `/investimentos/{id}/relatorios/fiscais/{reportId}`, incluindo quando a simulação original era anónima ou deixou de existir. A posse de acesso administrativo ao dossier não concede acesso financeiro. Fontes fiscais novas conservam a oportunidade/revisão no momento da associação; versões antigas sem essa identificação permanecem disponíveis como âmbito por confirmar, sem serem atribuídas silenciosamente à oportunidade actual. Preparação de terra não é apresentada como título DUAT. Seleccionar oportunidade é necessário antes de consolidar um dossier com várias escolhas.
Provas: `outputs/dossier-reports-20261113/`. Vinte e um testes frontend aprovados; prova final do servidor com 44 asserções, em dados sintéticos e transacção revertida, além da regressão focada anterior de quatro testes/151 asserções. Sem migrações ou novas permissões. Estado: implementado em QA; instalação desta alteração e conferência visual no uso normal ainda pendentes. O saldo dos restantes módulos e resíduos fiscais permanece inalterado.
Relatórios do dossier — conferência final de 13/11/2026: regressão focada do servidor 4 testes/168 asserções; caso final de recuperação, descargas privadas, âmbito, repetição e bloqueio com módulo desactivado 45 asserções. Os 21 testes frontend passaram; tipos, análise PHP, lint e compilação QA aprovados. Delta próprio de relatórios preparado, sem instalação remota e sem interferir na incorporação demográfica em curso.
## 13/11/2026 — relatórios do dossier instalados na homologação em 07182c7c
Entrega autorizada pelo proprietário e instalada pelo executor Git em `https://teste-investidor.tempo.co.mz`. A revisão servida é `07182c7c735efa88d850551da71b671c3dadfa9d`. Entrada: `/investimentos`, abrir o dossier autorizado e a secção `#relatorios`. Índice por etapa e exportação consolidada Word/HTML disponíveis; cópias fiscais preservadas são recuperadas pelo titular a partir do próprio dossier. Continua sem envio ou publicação automática.
Backup/restauro fresco:193 tabelas e381ficheiros. Conferência pós-entrega:12ficheiros de fonte por SHA256,HTTPS e controlos de entrada privada,12tabelas de negócio/geometria sem alteração,mais os três lotes demográficos presentes no backup conservados por contagens/checksum de metadados,observações e eventos. Durante a entrega foram concluídas,por operações autenticadas independentes,as restantes incorporações demográficas:a verificação final encontrou12lotes aprovados/920456observações. Esta entrega de relatórios não fez importações ou migrações;actualizou o vínculo da readiness à nova revisão,preservando as preparações CLI aceites.
Prova final `outputs/dossier-reports-20261113/homologation-installed.json`;estado agregado `verification.json`. QA funcional,privacidade,recuperação,exportações,tipos,lint,análise PHP e compilação aprovados. A conferência visual no uso normal permanece pendente,sem reabrir a instalação nem as provas aceites. Este estado substitui a indicação histórica de QA sem instalação acima e não declara concluídos outros módulos ou relatórios ainda por implementar.
## 13/11/2026 — consulta e descarga de resumos das 19 etapas, correcção em QA
Conferência com conta sintética e MFA normal em `http://127.0.0.1:18814/investimentos/5245f06c-bdbd-4caf-bc37-900f10bf582c`, base isolada `investmoz_demography_test:55441`. Novo dossier, versão 21: os serviços existentes guardaram fundamentos sintéticos em S01–S19. Não foram alterados dossiers anteriores nem simuladas decisões/licenças externas. Na homologação foi criado apenas o dossier separado de ensaio `db85f087-0737-4164-892a-4fa4fe1313df`, com fundamento sintético S01; os três dossiers anteriores foram preservados.
Lacuna corrigida: o componente existente passa a permitir seleccionar a etapa, consultar o resumo dos registos recuperados e preparar Word ou HTML. A ligação de descarga permanece visível com indicação de ficheiro pronto; o conteúdo e as pendências não são confundidos com documentos oficiais em falta. As limitações do modelo passam a integrar ambos os formatos. Mudar de dossier, versão ou oportunidade reinicializa a selecção/ligação local para não reutilizar uma exportação de outro contexto.
Prova integrada: consulta das 19 etapas e 38 descargas reais pelo Edge (19 Word/19 HTML), conferidas em disco por identidade, etapa, versão, fundamento, limitações e ausência de registos de outra etapa. Actualizar a página recuperou os 19 registos e a versão 21. Evidência em `outputs/dossier-reports-20261113/stage-authenticated-browser-checks.json`, `stage-authenticated-download-verification.json`, `stage-recovery.json` e `visual/04-dossier-qa-S19-descarga.jpg`. Os ensaios isolados do componente estão identificados separadamente, sem os apresentar como prova de persistência.
Validação nova, limitada ao delta: dois testes frontend (incluem exportação das 19 etapas e escape das limitações), tipos, lint dos três ficheiros de produto afectados e compilação QA. Não foram repetidas as provas aceites de APIEX/SWOT, cálculo fiscal, permissões ou instalação. O perfil antigo de QA referia contas que já não existem no cluster actualmente ligado; foi localizado e usado o perfil documentado da demografia, sem criar permissões ou reiniciar o cluster. A serialização da chave MFA da conta recém-criada foi corrigida apenas nessa conta sintética.
Estado: correcção implementada e conferida em QA, ainda sem instalação. A homologação mantém `07182c7c` para o recorte de relatórios anteriormente instalado. A prova demonstra resumos de preparação por etapa; não declara produzidos estudos financeiros, licenças, decisões, pacotes APIEX ou documentos que não existam nos respectivos percursos. A instalação deste novo delta é a pendência de entrega; os saldos próprios dos outros módulos mantêm-se.
## 13/11/2026 — resumos por etapa instalados na homologação em 8854b292
Autorização expressa do proprietário recebida e executada. Revisão servida `8854b2928c6907b6742a1eb622ae70e6b6a9e998`, construída em 2026-11-13T13:16:06Z; troca concluída em 13:16:09Z. Entrada habitual: `https://teste-investidor.tempo.co.mz/investimentos`, abrir o dossier e a secção de relatórios, seleccionar a etapa, consultar o resumo, preparar Word ou HTML e descarregar o ficheiro pronto. O relatório consolidado permanece disponível.
Backup/restauro fresco: 193 tabelas/409 ficheiros, conferidos por contagens e hashes. Verificação pós-entrega: quatro ficheiros por SHA256; 18 tabelas protegidas inalteradas, incluindo instituições, dossiers, rascunhos, documentos, jornadas, etapas e as três tabelas demográficas. Mantidos 12 lotes aprovados/920456 observações. Sem migrações, importações, publicação ou alterações de permissões. Preparação demográfica e os controlos autenticados foram preservados e a readiness foi vinculada à revisão instalada, mantendo o exemplar anterior arquivado.
O executor Git existente e os seus controlos obrigatórios de CI foram reutilizados sem alteração de fonte ou da configuração partilhada. A entrega ficou fixada na branch aprovada `entrega/relatorios-etapas-20261113`, pois a main já continha alterações alheias de pesquisa/suporte com integração falhada. Essas alterações e a main não foram substituídas nem instaladas por esta autorização.
Prova pós-instalação no Edge, na sessão autorizada já existente: dossier sintético `db85f087-0737-4164-892a-4fa4fe1313df`, resumo S01 e fundamento guardado recuperados; Word e HTML efectivamente descarregados e conferidos. Nenhum dossier foi alterado nesta conferência. Não se repetiram as 19 consultas/38 descargas aceites em QA; o CI obrigatório do executor correu normalmente. Recibo: `outputs/dossier-reports-20261113/stage-homologation-installed.json`; captura `visual/06-homologacao-S01-descarga.jpg` no mesmo directório.
Estado deste delta: instalado e conferido. Substitui a pendência de instalação do registo anterior, preservando os seus limites: são resumos de preparação dos registos disponíveis; não se declaram produzidas licenças, decisões, estudos ou outros originais ainda em falta nos respectivos percursos. O pacote QA selado `outputs/entrega-relatorios-etapas-20261113.zip` permanece inalterado; o recibo posterior comprova a instalação.
## 13/11/2026 — ACT-02: confronto de observações quantitativas, QA
Primeiro recorte executável da continuidade documentada de ACT-20261029-02: a comparação das observações públicas já recebidas no mapa. Reutiliza `InvestmentMapIndicators` e os filtros existentes; não acrescenta API, cadastro, publicação, fonte externa ou acesso a dados privados. Duas selecções progressivas mostram território, descrição/natureza da grandeza, indicador, valor textual exacto, unidade, período, âmbito, população/base, metodologia, situação, fonte/data e versão.
A conferência assinala diferenças de território/indicador/unidade/âmbito/base/metodologia, campos em falta (incluindo descrição/natureza), datas inválidas, escolha repetida e informação desconhecida/em divergência. Metadados alinhados não são certificação de comparabilidade. Duração, sobreposição dos períodos e natureza prevista/efectiva exigem leitura da fonte. Não soma, calcula evolução, converte grandezas, cria pontuação ou índice regional. Zero, precisão decimal e fontes originais são preservados.
Selecções identificadas por território, observação e versão. Fora dos filtros deixam de entrar na comparação; mudar a versão exige nova selecção. Conferência no Edge com três observações inteiramente sintéticas, sem API/escritas na base, exercitando o componente real compilado. Capturas e recibo em `outputs/map-comparison-20261113/`. Três testes novos dirigidos ao delta, tipos, lint e compilação QA aprovados; os testes aceites dos filtros anteriores não foram repetidos.
Estado: implementado e conferido em QA, sem instalação. V2-07/V2-10 e ACT-02 permanecem parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia de potencial económico continuam com o seu saldo próprio. Este incremento encerra apenas a ferramenta de confronto das observações recebidas. Não altera dossiers, relatórios de 8854b292, pesquisa ou suporte aceites. A candidata incorpora também o recibo documental de 8854b292, sem repetir a instalação ou as provas dessa entrega.
## 13/11/2026 — ACT-02: comparação de observações instalada em 1263b4f6
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `1263b4f6b7609c3d4d293c2b9f8a68abb9d3e3c4`. No mapa das oportunidades, o bloco de indicadores apresenta «Comparar duas observações» quando há pelo menos duas observações públicas nos filtros actuais. A comparação mantém valores, descrições, unidades, períodos, âmbito, base, metodologia, situação, fontes e versões; não infere evolução, conversão, elegibilidade ou potencial regional.
Backup/restauro fresco de 193 tabelas/410 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes novos e a conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/map-comparison-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia económica. Esta entrega fecha a ferramenta de confronto das observações recebidas, não esses requisitos amplos.
## 13/11/2026 — ACT-02: natureza estruturada das observações, QA
Continuidade do confronto instalado em 1263b4f6. O editor existente permite declarar segundo a fonte se o valor é observado/efectivo, planeado/meta, projectado/estimado ou por confirmar. A classificação segue o envelope privado, a recuperação e o fluxo editorial existentes; preparar uma alteração não modifica a versão pública. Omissão por clientes anteriores preserva uma escolha deliberada. Sem indicação anterior, a consulta apresenta por confirmar; não infere natureza pela descrição nem reescreve registos. Fonte, valor textual exacto, zero, identidade, versão, confidencialidade e histórico mantidos. Não há migrações, novas permissões, publicação automática ou importação.
A consulta e a comparação mostram a natureza separada da situação documental; naturezas diferentes ou desconhecidas exigem conferência. Metadados alinhados não provam comparabilidade estatística. QA: três testes backend/21 asserções, cinco testes frontend afectados, análise PHP, Pint, tipos, lint e compilação aprovados. Edge: componentes reais compilados com dados sintéticos; selector e edição preservados, observado/projectado distintos, legado por confirmar, zero/precisão mantidos. Provas em outputs/observation-nature-20261113/. Reutilizadas as provas aceites dos restantes percursos.
Estado: implementado em QA, instalação pendente de autorização concreta; homologação mantém 1263b4f6. Encerra a capacidade estruturada de declarar a natureza, não a classificação factual de todo o acervo. ACT-02/V2-07/V2-10 continuam parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, conferência documental dos valores antigos e metodologia de potencial económico. Relatórios, pesquisa, suporte e entregas aceites preservados.
## 13/11/2026 — ACT-02: natureza das observações instalada em 42b9324a
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `42b9324acbf0a15418424e8a539a0740932b2377`. O editor existente permite declarar observado/efectivo, planeado/meta, projectado/estimado ou por confirmar; a escolha segue a preparação privada e o fluxo editorial existente. Consulta e comparação distinguem natureza e situação documental, sem inferir a classificação dos registos antigos ou reescrever o acervo.
Backup/restauro fresco de 193 tabelas/411 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes backend/21 asserções, cinco frontend afectados, análise/tipos/lint/build e conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/observation-nature-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, classificação factual do legado e metodologia económica. Esta entrega fecha a capacidade de declarar/recuperar/consultar a natureza das observações, não esses requisitos amplos.
## 13/11/2026 — ACT-02: conferência temporal da comparação, QA
Continuidade após a natureza das observações instalada em 42b9324a. A comparação existente mostra a duração dos períodos de referência em dias de calendário, com ambos os limites incluídos. Durações diferentes exigem conferência; períodos distintos com sobreposição, incluindo um único dia comum, mostram aviso próprio. Datas incompletas, inválidas ou invertidas continuam por confirmar, sem duração inferida da data da fonte. Não anualiza, calcula taxas/evolução, soma observações ou declara comparabilidade estatística. Valores textuais, zeros, fontes, versões, filtros e âmbito territorial preservados.
QA: dois testes novos de duração/ano bissexto/sobreposição/limites e datas inválidas; sete testes afectados passaram. Tipos, lint e compilação aprovados. Edge com componente real compilado e dados sintéticos: duração inclusiva de 366/365 dias e aviso de sobreposição de um dia conferidos. Sem API, base de dados, migração, importação ou publicação. Provas: outputs/observation-period-20261113/. Provas aceites dos restantes percursos reutilizadas.
Estado: implementado em QA, sem instalação. ACT-02 permanece parcial: cobertura factual, classificação documental do legado, séries estatisticamente comparáveis, oferta laboral e metodologia económica conservam o saldo próprio. Esta entrega encerra a conferência automática de duração e sobreposição das datas recebidas, não a comparabilidade factual das séries.
## 13/11/2026 — ACT-02: comparação temporal instalada em 2bad72c0
Instalação autorizada e concluída na homologação https://teste-investidor.tempo.co.mz pelo executor Git existente. Revisão servida `2bad72c042f1a779849bfacc8a73ccd842b27fe7`. A comparação de observações mostra duração em dias com limites incluídos e assinala durações diferentes e sobreposição; não infere taxas, evolução ou comparabilidade estatística.
Backup/restauro fresco de193tabelas/412ficheiros;15fontes verificadas por hash;21tabelas protegidas sem diferenças. Mantidos12lotes/920456observações demográficas, instituições, dossiers, relatórios, pesquisa, suporte e permissões. Sem migrações/importações ou publicação de dados. Readiness demográfica vinculada à revisão com original arquivado. Configuração e fonte do executor inalteradas. HTTPS e entrada privada conferidos. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/observation-period-20261113/homologation-installed.json; pacote QA selado preservado.
Este delta deixa de ser pendência de instalação; ACT-02 continua parcial quanto à cobertura factual, classificação do legado, oferta laboral, séries estatisticamente comparáveis e metodologia económica. A ferramenta não cria séries factuais por si.
## 13/11/2026 — lote PEC ordinário: último montante e PPC acima da base, QA
Conforme a directiva de agrupar melhorias relacionadas, este lote reúne dois resíduos do percurso de viabilidade fiscal, sem instalação por cada correcção. Reutiliza a preparação PEC, livro anual, rascunho privado, histórico e relatório do mesmo dossier; não cria um motor ou módulo paralelo. Recorte:2026, residente geral comercial/industrial/agrícola, ano civil, MZN. Homologação mantém2bad72c0.
1. Sem volume anterior, permite indicar o último montante PEC já determinado com NUIT, exercício, valor, fonte/fundamento e confirmação de que é o último aplicável ao caso. A hipótese documental29(5) conserva esse montante; não o confunde com volume de negócios ou pagamentos somados, não aplica de novo0,5%/limites nem deduz PPC pela segunda vez. Ausência, incompatibilidade, exercício futuro/anterior ao início ou falta de conferência mantêm a estimativa indeterminada. A referência fica conservada quando deixa de ser usada; alterações exigem reconferência. Uma rectificação documental para volume zero usa a mesma referência. Isto implementa a hipótese documentada, não uma inferência automática da qualificação fiscal ou do último montante a partir de dados inexistentes.
2. No cálculo ordinário29(2), PPC documentados acima da base limitada produzem estimativa PEC zero e diferença documental explícita. Não criam valor negativo, reembolso, crédito, transporte ou dedução anual. Esta regra tem marcador de versão na chave do livro apenas quando afecta o caso; obriga a recalcular resultados antigos dependentes, preservando histórico e casos ordinários anteriores. Fonte reutilizada:BR16/I16-04-2008,p122/PDF24,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.
Os campos têm rótulos e controlos separados de largura completa; referências vazias aparecem por confirmar. Consulta do suporte e apresentação dos efeitos são parte do mesmo formulário. Não executa pagamentos ou declarações. Fonte, escolhas, original e histórico conservados no rascunho e no relatório.
QA:21testes frontend afectados, incluindo seis novos; uma prova nativa/42asserções de POST/PUT/GET em transacção com rollback na base isolada investmoz_demography_test:55441. Quatro cenários:último montante42500,25, fonte ausente, volume positivo com referência conservada e PPC60000/base50000/estimativa0/diferença10000. Recuperação e relatório financeiro do dossier conferidos; saldo anual138MZN e histórico anterior inalterados. Tipos, lint focado, sintaxe PHP, Pint e build QA aprovados. Edge:componentes reais compilados com dados sintéticos, ambos os efeitos, confirmação retirada após edição e apresentação final conferidos; sem API/escrita na base pela prévia. A primeira prévia importava um auxiliar Node com filesystem; foi substituído por JSON sintético gerado, sem alterar o produto para contornar o erro. Provas em outputs/irpc-pec-last-amount-20261113/. Não repetidas as provas aceites dos percursos não afectados.
Estado:implementado e verificado em QA; pacote agrupado, ainda sem instalação e dependente de autorização concreta. Não encerra L8 nem o portal. Restam períodos/regimes especiais, qualificação documental de casos fora deste recorte e os demais resíduos L1–L7/restanteL8/L9–L16 e módulos. Execução de pagamentos não é requisito do portal. A instalação deverá usar o executor Git, com backup/restauro fresco e reversão de release; preservar snapshots/históricos produzidos após a entrega, sem restaurar uma base antiga sobre edições posteriores.
## 13/11/2026 — lote PEC ordinário instalado em f059c226
Entrega expressamente autorizada e instalada pelo executor Git existente na main em https://teste-investidor.tempo.co.mz. Revisão servida f059c226d26ce0e9f5fec30ecfed44a65d61d6e3. Inclui referência ao último montante PEC documentado, reconciliação de PPC acima da base e apresentação do formulário/relatório. São hipóteses para viabilidade; não há execução de pagamentos, declarações, reembolso ou dedução criada pelo portal.
Backup/restauro fresco de193tabelas/413ficheiros e conferência pós-instalação de21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, histórico, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas.23fontes conferidas por hash; revisão pública e HTTPS verificados. Executor/configuração inalterados, zero migrações/importações; readiness demográfica vinculada à revisão com original arquivado. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/irpc-pec-last-amount-20261113/homologation-installed.json. Pacote agrupado v2 selado preservado; a versão anterior do ZIP conserva-se como histórico de normalização de finais de linha, sem alteração do produto.
Este lote deixa de ser pendência de instalação. L8 continua parcial para períodos/regimes especiais e casos fora do recorte; os resíduos L1–L7/restanteL8/L9–L16 e restantes módulos mantêm o saldo próprio. Próximo lote compatível: projecção conjunta PPC/PEC no ecrã e relatório, separada dos comprovativos pagos e do fecho anual.
@@ -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>
);
}
@@ -22,6 +22,7 @@ export type QuantitativeMapContext = {
source_reference?: string | null;
source_date?: string | null;
knowledge: string;
measure_kind?: 'unknown' | 'observed' | 'planned' | 'projected';
missing_metadata: string[];
}[];
zones_consulted?: number;
@@ -47,6 +48,17 @@ const metadataLabels: Record<string, string> = {
source_date: 'data da fonte',
};
const text = (value?: string | null) => value?.trim() || 'Não indicado';
const measureKinds: Record<string, string> = {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
};
const measureKind = (row: QuantitativeMapContext['records'][number]) =>
row.measure_kind &&
['observed', 'planned', 'projected'].includes(row.measure_kind)
? row.measure_kind
: 'unknown';
const completeDate = (value?: string | null) =>
Boolean(
value &&
@@ -55,6 +67,286 @@ const completeDate = (value?: string | null) =>
new Date(value).toISOString().slice(0, 10) === value,
);
type MapObservation = QuantitativeMapContext['records'][number];
// Calendar metadata only: inclusive source dates, never a rate or annualisation.
function periodDays(row: MapObservation): number | null {
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
row.period_start! > row.period_end!
)
return null;
return (
(Date.parse(row.period_end!) - Date.parse(row.period_start!)) /
86_400_000 +
1
);
}
const observationKey = (row: MapObservation) =>
JSON.stringify([row.zone_id, row.id, row.zone_version]);
export function compareMapObservations(
first: MapObservation,
second: MapObservation,
) {
const issues: string[] = [];
if (observationKey(first) === observationKey(second))
issues.push('Escolha duas observações distintas.');
if (first.zone_id !== second.zone_id)
issues.push(
'Territórios distintos: os valores não representam uma série do mesmo local.',
);
for (const [label, row] of [
['Primeira', first],
['Segunda', second],
] as const) {
if (measureKind(row) === 'unknown')
issues.push(
`${label} observação: natureza do valor por confirmar na fonte.`,
);
if (row.knowledge !== 'documented')
issues.push(
`${label} observação: informação desconhecida ou em divergência.`,
);
if (
row.value === null ||
!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(row.value)
)
issues.push(
`${label} observação: valor quantitativo por confirmar.`,
);
const comparisonMetadata: Record<string, string> = {
...metadataLabels,
description: 'descrição/natureza da grandeza',
};
const missing = Object.keys(comparisonMetadata).filter(
(field) => !row[field as keyof MapObservation]?.toString().trim(),
);
if (missing.length)
issues.push(
`${label} observação sem ${missing.map((field) => comparisonMetadata[field]).join(', ')}.`,
);
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
(row.period_start ?? '') > (row.period_end ?? '')
)
issues.push(`${label} observação: período incompleto ou inválido.`);
if (!completeDate(row.source_date))
issues.push(
`${label} observação: data da fonte incompleta ou inválida.`,
);
}
const firstDays = periodDays(first);
const secondDays = periodDays(second);
if (firstDays !== null && secondDays !== null) {
if (firstDays !== secondDays)
issues.push(
'Duração dos períodos diferente; não trate os valores como bases temporais equivalentes.',
);
if (
(first.period_start !== second.period_start ||
first.period_end !== second.period_end) &&
first.period_start! <= second.period_end! &&
second.period_start! <= first.period_end!
)
issues.push(
'Os períodos sobrepõem-se; as observações podem abranger os mesmos dias.',
);
}
if (measureKind(first) !== measureKind(second))
issues.push(
'Natureza do valor diferente: observado, planeado e projectado não são equivalentes.',
);
for (const [field, label] of Object.entries({
indicator: 'Indicador',
unit: 'Unidade',
scope: 'Âmbito',
population: 'População/base',
methodology: 'Metodologia',
})) {
if (
(first[field as keyof MapObservation] ?? '').toString().trim() !==
(second[field as keyof MapObservation] ?? '').toString().trim()
)
issues.push(`${label} diferente entre as observações.`);
}
return {
state: issues.length ? 'review_required' : 'metadata_aligned',
issues,
};
}
export function MapObservationComparison({
first,
second,
}: {
first?: MapObservation;
second?: MapObservation;
}) {
if (!first || !second)
return (
<p role="status">
Seleccione duas observações disponíveis nos filtros actuais.
</p>
);
const assessment = compareMapObservations(first, second);
const fields: [string, (row: MapObservation) => string][] = [
['Território', (row) => row.zone_name],
['Observação', (row) => text(row.label)],
['Descrição e natureza da grandeza', (row) => text(row.description)],
['Natureza do valor', (row) => measureKinds[measureKind(row)]],
['Indicador', (row) => labels[row.indicator] ?? row.indicator],
[
'Valor original',
(row) => (row.value === null ? 'Por confirmar' : row.value),
],
['Unidade', (row) => text(row.unit)],
[
'Período',
(row) => `${text(row.period_start)} — ${text(row.period_end)}`,
],
[
'Duração do período (dias, limites incluídos)',
(row) => {
const days = periodDays(row);
return days === null ? 'Por confirmar' : String(days);
},
],
['Âmbito', (row) => text(row.scope)],
['População/base', (row) => text(row.population)],
['Metodologia', (row) => text(row.methodology)],
[
'Situação da informação',
(row) =>
row.knowledge === 'documented'
? 'Documentada'
: row.knowledge === 'disputed'
? 'Em divergência'
: 'Por confirmar',
],
['Fonte', (row) => text(row.source_reference)],
['Data da fonte', (row) => text(row.source_date)],
['Versão da ficha territorial', (row) => String(row.zone_version)],
];
return (
<div className="space-y-3">
<p role="status">
{assessment.state === 'metadata_aligned'
? 'Metadados alinhados; confirme nas fontes se os valores podem ser comparados.'
: 'Há diferenças ou lacunas a conferir antes de comparar os valores.'}
</p>
{!!assessment.issues.length && (
<ul className="list-disc pl-5">
{assessment.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<p>
Períodos distintos ou sobrepostos exigem conferência da duração
e da base na fonte. Esta apresentação não calcula evolução,
soma, conversão, pontuação ou estimativa regional. A natureza
declarada deve ser conferida com a descrição e a fonte.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Conferência de duas observações quantitativas
</caption>
<thead>
<tr>
<th className="p-2">Campo</th>
<th className="p-2">Primeira observação</th>
<th className="p-2">Segunda observação</th>
</tr>
</thead>
<tbody>
{fields.map(([label, value]) => (
<tr key={label} className="border-t">
<th className="p-2" scope="row">
{label}
</th>
<td className="p-2 break-words">
{value(first)}
</td>
<td className="p-2 break-words">
{value(second)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function MapObservationComparisonPicker({ rows }: { rows: MapObservation[] }) {
const id = useId();
const [firstKey, setFirst] = useState('');
const [secondKey, setSecond] = useState('');
const first = rows.find((row) => observationKey(row) === firstKey);
const second = rows.find((row) => observationKey(row) === secondKey);
return (
<details className="space-y-3 rounded border p-3">
<summary>Comparar duas observações</summary>
<p>
Confronte os dados e as fontes antes de os usar na decisão.
Selecções fora dos filtros deixam de ser comparadas; uma versão
nova exige nova selecção.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{(['Primeira', 'Segunda'] as const).map((label, index) => (
<label
className="grid gap-1"
key={label}
htmlFor={`${id}-${index}`}
>
{label} observação
<select
className="w-full min-w-0 rounded border p-2"
id={`${id}-${index}`}
value={
index === 0
? first
? firstKey
: ''
: second
? secondKey
: ''
}
onChange={(event) =>
(index === 0 ? setFirst : setSecond)(
event.target.value,
)
}
>
<option value="">Seleccionar observação</option>
{rows.map((row) => (
<option
key={observationKey(row)}
value={observationKey(row)}
>
{row.zone_name} —{' '}
{row.label ||
labels[row.indicator] ||
row.indicator}{' '}
— {row.period_start || '?'} /{' '}
{row.period_end || '?'} — v
{row.zone_version}
</option>
))}
</select>
</label>
))}
</div>
<MapObservationComparison first={first} second={second} />
</details>
);
}
export function filterMapIndicators(
records: QuantitativeMapContext['records'],
indicator: string,
@@ -114,9 +406,9 @@ export default function InvestmentMapIndicators({
contexto territorial, independentes das evidências das
oportunidades. Emprego não mede trabalhadores disponíveis;
investimento, produção e exportações não constituem um índice de
potencial ou garantia de retorno. A natureza prevista ou
efectiva de cada valor depende da descrição da fonte, não é
determinada automaticamente.
potencial ou garantia de retorno. A natureza do valor é
declarada segundo a fonte; sem essa indicação permanece por
confirmar.
</p>
{!context ? (
<p role="status">A carregar indicadores quantitativos…</p>
@@ -242,6 +534,9 @@ export default function InvestmentMapIndicators({
observações. Refine a localização.
</p>
)}
{rows.length > 1 && (
<MapObservationComparisonPicker rows={rows} />
)}
{rows.length === 0 ? (
<p>
{filtered
@@ -308,6 +603,14 @@ export default function InvestmentMapIndicators({
'Por determinar'}{' '}
· {text(row.unit)}
</p>
<p>
Natureza:{' '}
{
measureKinds[
measureKind(row)
]
}
</p>
<p>
{row.knowledge ===
'documented'
@@ -60,7 +60,8 @@ const labels: Record<string, string> = {
geometry_id: 'Geometria já associada ao parque',
zone_link_id: 'Relação económica já associada ao parque',
indicator: 'Indicador',
value: 'Valor observado',
measure_kind: 'Natureza do valor segundo a fonte',
value: 'Valor indicado na fonte',
unit: 'Unidade de medida',
period_start: 'Início do período',
period_end: 'Fim do período',
@@ -86,6 +87,7 @@ const extras: Record<Group, string[]> = {
],
observations: [
'indicator',
'measure_kind',
'value',
'unit',
'period_start',
@@ -105,6 +107,12 @@ const extras: Record<Group, string[]> = {
],
};
const choices: Record<string, Record<string, string>> = {
measure_kind: {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
},
knowledge: {
unknown: 'Não informado',
documented: 'Documentado pela fonte',
@@ -248,7 +256,11 @@ export default function ParkIntegralEditor({
})
}
>
<option value="">Não informado / sem associação</option>
<option value="">
{key === 'measure_kind'
? 'Por confirmar na fonte'
: 'Não informado / sem associação'}
</option>
{Object.entries(options).map(([k, v]) => (
<option key={k} value={k}>
{v}
@@ -541,7 +553,9 @@ export function ParkIntegralPublic({ value }: { value?: ParkIntegral }) {
)
: item[k] == null ||
item[k] === ''
? 'Não informado'
? k === 'measure_kind'
? 'Por confirmar'
: 'Não informado'
: k === 'expansion'
? item[k]
? 'Sim, segundo a fonte'
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import {
createInvestorDossierReport,
createInvestorStageReport,
investorDossierReportIndex,
type InvestorDossierReportInput,
} from '@/lib/investor-dossier-report';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '@/lib/project-dossier-report';
export function InvestorDossierReports({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
return (
<InvestorDossierReportsContent
key={`${dossier.case.id}:${dossier.case.version}:${dossier.context_opportunity ?? ''}`}
dossier={dossier}
/>
);
}
function InvestorDossierReportsContent({
dossier,
}: {
dossier: InvestorDossierReportInput;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [stage, setStage] = useState('');
const [download, setDownload] = useState<{
url: string;
name: string;
} | null>(null);
useEffect(
() => () => {
if (download) URL.revokeObjectURL(download.url);
},
[download],
);
const requiresChoice =
dossier.opportunity_options.length > 1 && !dossier.context_opportunity;
const entries = requiresChoice ? [] : investorDossierReportIndex(dossier);
async function exportReport(format: 'word' | 'html') {
setBusy(true);
setError('');
try {
const model = stage
? createInvestorStageReport(dossier, stage)
: createInvestorDossierReport(dossier);
const blob =
format === 'word'
? await generateProjectDossierDocx(model, [], true)
: new Blob(
[
await createProjectDossierPrintHtml(
model,
[],
true,
),
],
{ type: 'text/html;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const name = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}${stage ? `-${stage}` : ''}.${format === 'word' ? 'docx' : 'html'}`;
setDownload({ url, name });
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar o relatório. Os registos foram preservados.',
);
} finally {
setBusy(false);
}
}
return (
<section id="relatorios" className="space-y-4 rounded-xl border p-4">
<h2 className="text-lg font-semibold">Relatórios deste dossier</h2>
<p>
Dossier {dossier.case.title} · versão {dossier.case.version} ·
oportunidade {dossier.context_opportunity ?? 'contexto comum'}.
</p>
<p>
Reúna as etapas, a viabilidade, a análise SWOT e a preparação
APIEX. O relatório mantém os estados e as pendências; os
originais continuam nas respectivas versões.
</p>
{requiresChoice ? (
<p role="status">
Seleccione a oportunidade em tratamento para reunir os seus
relatórios sem misturar contextos.
</p>
) : (
<>
<label className="block">
Relatório a consultar e descarregar
<select
className="mt-1 block w-full rounded border p-2"
value={stage}
disabled={busy}
onChange={(event) => {
setStage(event.target.value);
setDownload(null);
}}
>
<option value="">Dossier completo</option>
{dossier.steps.map((step) => (
<option key={step.code} value={step.code}>
{step.code} — {step.title}
</option>
))}
</select>
</label>
{stage && (
<StageReportPreview dossier={dossier} stage={stage} />
)}
<div className="flex flex-wrap gap-3">
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('word')}
>
Preparar{' '}
{stage
? `relatório ${stage}`
: 'relatório consolidado'}{' '}
(Word)
</button>
<button
className="rounded border px-3 py-2"
disabled={busy}
onClick={() => exportReport('html')}
>
Preparar versão para impressão/PDF
</button>
</div>
{busy && <p role="status">A preparar o ficheiro…</p>}
{download && (
<p role="status">
Ficheiro pronto.{' '}
<a
className="font-medium underline"
href={download.url}
download={download.name}
>
Descarregar {download.name}
</a>
</p>
)}
<p className="text-sm">
A versão HTML pode ser aberta e impressa ou guardada
como PDF. As descargas são privadas; não enviam
processos ou mensagens a entidades externas.
</p>
{!!entries.length && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Índice por etapa, versão e data
</caption>
<thead>
<tr>
<th className="p-2">Etapa</th>
<th className="p-2">
Relatório ou documento
</th>
<th className="p-2">Versão e data</th>
<th className="p-2">Estado</th>
<th className="p-2">Consultar</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-t">
<td className="p-2">
{entry.stage}
</td>
<td className="p-2">
{entry.title}
</td>
<td className="max-w-56 p-2 break-words">
{entry.version} ·{' '}
{entry.date ||
'Data não registada'}
</td>
<td className="p-2">
{entry.state}
</td>
<td className="p-2">
<a
className="underline"
href={entry.href}
>
{entry.action}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!entries.length && (
<p>
Nenhum relatório ou documento produzido e autorizado
neste âmbito. O relatório consolidado identifica as
etapas e as pendências existentes.
</p>
)}
<p className="text-sm">
Os relatórios financeiros são apresentados apenas ao
titular autorizado do rascunho. Consulte os documentos
de cada acto antes de executar o investimento.
</p>
</>
)}
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
</section>
);
}
function StageReportPreview({
dossier,
stage,
}: {
dossier: InvestorDossierReportInput;
stage: string;
}) {
const report = createInvestorStageReport(dossier, stage);
return (
<article
className="space-y-3 rounded border p-3"
aria-label={`Relatório da etapa ${stage}`}
>
<h3 className="font-semibold">{report.title}</h3>
<p className="text-sm">{report.version}</p>
{report.sections.map((section) => (
<section key={section.id} className="space-y-2">
<h4 className="font-medium">{section.title}</h4>
{section.paragraphs.map((text) => (
<p key={text}>{text}</p>
))}
<dl className="space-y-2">
{section.rows.map((row, index) => (
<div key={index} className="break-words">
<dt className="font-medium">{row.field}</dt>
<dd>{row.value}</dd>
<dd className="text-sm">
{row.classification} · {row.origin}
</dd>
</div>
))}
</dl>
</section>
))}
{report.limitations.map((text) => (
<p className="text-sm" key={text}>
{text}
</p>
))}
</article>
);
}
+3 -3
View File
@@ -259,7 +259,7 @@ export default function PortalHero({
aria-describedby="portal-opportunity-search-help"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Ex.: agronegócio, Nampula, legislação..."
placeholder="Corredores, produtos e locais, licenciamento…"
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
/>
<button
@@ -274,8 +274,8 @@ export default function PortalHero({
id="portal-opportunity-search-help"
className="mt-2 text-left text-sm text-white/90"
>
Combine palavras-chave, sector e localização. Ex.:
plantio de algodão ou agronegócio Nampula.
Ex.: quantos corredores existem; grafite Cabo Delgado;
como licenciar uma empresa.
</p>
</form>
@@ -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,5 +1,5 @@
import {SimulationIrpcPecCorrection} from './simulation-irpc-pec-correction';
import {SimulationIrpcPecPlan} from './simulation-irpc-pec-plan';
import { SimulationIrpcPecCorrection } from './simulation-irpc-pec-correction';
import { SimulationIrpcPecPlan } from './simulation-irpc-pec-plan';
import {
newPecObligation,
calculatePecObligation,
@@ -42,12 +42,16 @@ export function SimulationIrpcPecObligation({
patch = (changes: Partial<PecObligation>) =>
onChange({ ...p, ...changes });
const field = (
key: Exclude<keyof PecObligation, 'version' | 'scope' | 'plan' | 'correction'>,
key: Exclude<
keyof PecObligation,
'version' | 'scope' | 'plan' | 'correction' | 'lastAmount'
>,
label: string,
) => (
<label className="block">
{label}
<input
className="mt-1 block w-full rounded border px-3 py-2"
aria-label={label}
value={p[key]}
onChange={(e) => patch({ [key]: e.target.value })}
@@ -63,6 +67,7 @@ export function SimulationIrpcPecObligation({
<label className="block">
Enquadramento PEC
<select
className="mt-1 block w-full rounded border px-3 py-2"
aria-label="Enquadramento PEC"
value={p.scope}
onChange={(e) =>
@@ -104,7 +109,131 @@ export function SimulationIrpcPecObligation({
'turnoverReference',
'Fonte do volume de negócios — PEC',
)}
<SimulationIrpcPecCorrection value={p.correction} original={p.previousTurnover} subjectNuit={p.subjectNuit} onChange={correction=>patch({correction})}/>
<SimulationIrpcPecCorrection
value={p.correction}
original={p.previousTurnover}
subjectNuit={p.subjectNuit}
onChange={(correction) => patch({ correction })}
/>
{(m.requiresLastAmount || p.lastAmount) && (
<details className="rounded border p-3">
<summary>
Sem volume anterior — referência PEC dos
anos anteriores
</summary>
<p>
Indique o último montante PEC já
determinado, com suporte do mesmo
titular, ano civil e MZN. Não introduza
volume de negócios ou somatório de
pagamentos. A hipótese conserva o valor
documental sem nova percentagem ou
segunda dedução de PPC.
</p>
{!p.lastAmount ? (
<button
type="button"
onClick={() =>
patch({
lastAmount: {
version: 1,
subjectNuit:
p.subjectNuit,
year: '',
amount: '',
reference: '',
confirmed: '',
},
})
}
>
Acrescentar referência PEC anterior
</button>
) : (
<>
{(
[
'subjectNuit',
'year',
'amount',
'reference',
] as const
).map((key) => {
const names = {
subjectNuit:
'NUIT do montante PEC anterior',
year: 'Exercício do último montante PEC',
amount: 'Último montante PEC já determinado — MZN',
reference:
'Fonte e fundamento do último montante PEC',
};
return (
<label
className="block"
key={key}
>
{names[key]}
<input
className="mt-1 block w-full rounded border px-3 py-2"
aria-label={
names[key]
}
value={
p.lastAmount![
key
]
}
onChange={(e) =>
patch({
lastAmount:
{
...p.lastAmount!,
[key]: e
.target
.value,
confirmed:
'',
},
})
}
/>
</label>
);
})}
<label className="block">
<input
type="checkbox"
checked={
p.lastAmount
.confirmed === 'yes'
}
onChange={(e) =>
patch({
lastAmount: {
...p.lastAmount!,
confirmed: e
.target
.checked
? 'yes'
: '',
},
})
}
/>
Conferi no suporte que é o
último montante PEC aplicável a
esta hipótese
</label>
<p>
Alterar a referência exige nova
conferência. Guardar conserva o
original e o histórico; não
certifica a situação fiscal.
</p>
</>
)}
</details>
)}
{field(
'previousPpcPaid',
'PPC efectivamente pagos no ano anterior — MZN',
@@ -124,18 +253,31 @@ export function SimulationIrpcPecObligation({
) : (
<>
<p>
Total estimado:{' '}
{fractionPresentation(m.total!).decimal} MZN.
Total estimado: {fractionPresentation(m.total!).decimal}{' '}
MZN.
</p>
<ul>
{m.months.map((month) => (
<li key={month}>{month}</li>
))}
</ul>
</>
)}
<SimulationIrpcPecPlan plan={p.plan} total={m.total} notApplicable={m.notApplicable} year={book.year} onChange={plan=>patch({plan})}/>
{m.ppcExcess && (
<p role="status">
Os PPC anteriores cobrem a base limitada desta hipótese.
Estimativa PEC: 0 MZN;diferença documental:{' '}
{fractionPresentation(m.ppcExcess).decimal} MZN. Esta
diferença não cria reembolso,crédito ou dedução anual.
</p>
)}
<SimulationIrpcPecPlan
plan={p.plan}
total={m.total}
notApplicable={m.notApplicable}
year={book.year}
onChange={(plan) => patch({ plan })}
/>
<p>
Guardar o rascunho conserva esta preparação. Calcular o fecho
anual inclui-a no relatório. Não regista pagamentos,cria
@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react';
import DemographicContext from './demographic-context';
import TerritorialSources, {
type TerritorialSourceEvidence,
} from './territorial-source-evidence';
@@ -95,6 +96,12 @@ export function LocationList({
</div>
))}
</dl>
<DemographicContext
key={location.id}
territoryId={location.path
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
{location.coordinates.map((point) => (
<p key={point.geometry_id}>
Coordenadas documentadas (
+614
View File
@@ -0,0 +1,614 @@
import { pathStates, type PathResult } from './investor-path.ts';
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
import { buildProjectDossierRows } from './project-dossier.ts';
import type { SwotDraft } from './investment-preparation.ts';
import type {
DossierReportModel,
DossierReportRow,
} from './project-dossier-report.ts';
export type PreparationReport = {
case_id: string;
opportunity: string | null;
version: number;
context_revision: number;
href: string;
api_state: string;
api_issues: string[];
exports: {
id: string;
at: string;
context_revision: number;
fingerprint: string;
historical: boolean;
href: string;
}[];
external: {
kind: string;
date: string;
reference: string;
export_id: string;
outcome?: string;
}[];
swot: SwotDraft;
swot_review_required: boolean;
};
export type InvestorDossierReportInput = {
case: { id: string; title: string; version: number };
context_opportunity: string | null;
opportunity_options: unknown[];
investment_context: {
revision: number;
fields?: Record<string, string>;
} | null;
phases: Record<string, string>;
steps: {
code: string;
title: string;
phase: number;
version?: number;
state: string;
reason: string;
detail: {
scope?: string;
document_id?: string;
authority?: string;
reference?: string;
decision_date?: string;
recorded_at?: string;
};
}[];
results: PathResult[];
documents: {
id: string;
document_id?: string;
file_name: string;
status: string;
malware_scan_status: string;
version_no: number;
sha256: string;
}[];
preparation_report?: PreparationReport | null;
fiscal_reports?: {
simulation_id: string;
report_id: string;
report_version_no?: number;
report_sha256?: string;
consented_at?: string;
scope_state?: 'identified' | 'common' | 'unconfirmed';
}[];
land_report?: {
id: string;
version: number;
updated_at: string | null;
href: string;
} | null;
company_reports?: {
comparison?: { revision: number; at: string };
preparation?: { revision: number; at: string };
guides?: Record<
string,
{ revision: number; at: string; slug: string; title: string }
>;
};
};
export type DossierReportIndexEntry = {
id: string;
stage: string;
title: string;
version: string;
date: string;
state: string;
href: string;
action: string;
};
const externalStates: Record<string, string> = {
preparing: 'Em preparação',
ready: 'Pronto para entrega',
delivery_recorded: 'Entrega externa registada',
decision_received: 'Decisão recebida',
};
const externalRecords: Record<string, string> = {
delivery: 'Entrega externa',
clarification: 'Pedido de esclarecimento',
response: 'Resposta recebida',
decision: 'Decisão recebida',
};
const decisions: Record<string, string> = {
approved: 'Favorável',
refused: 'Desfavorável',
conditional: 'Condicionada',
other: 'Outra decisão',
};
const dimensions = {
strength: 'Forças',
weakness: 'Fraquezas',
opportunity: 'Oportunidades',
threat: 'Ameaças',
};
const classifications = {
documented_fact: 'Facto documentado',
hypothesis: 'Hipótese',
unconfirmed: 'Por confirmar',
};
function checkScope(input: InvestorDossierReportInput): void {
if (input.opportunity_options.length > 1 && !input.context_opportunity)
throw new Error(
'Seleccione a oportunidade antes de reunir os relatórios.',
);
const preparation = input.preparation_report;
if (
preparation &&
(preparation.case_id !== input.case.id ||
preparation.opportunity !== input.context_opportunity)
)
throw new Error(
'A preparação pertence a outro dossier ou oportunidade.',
);
}
function documentsInDossier(input: InvestorDossierReportInput) {
const seen = new Set<string>();
return input.documents.filter((document) => {
const key = `${document.document_id ?? document.id}:${document.version_no}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function investorDossierReportIndex(
input: InvestorDossierReportInput,
): DossierReportIndexEntry[] {
checkScope(input);
const base = `/investimentos/${encodeURIComponent(input.case.id)}`;
const query = input.context_opportunity
? `?opportunity=${encodeURIComponent(input.context_opportunity)}`
: '';
const entries: DossierReportIndexEntry[] = [];
const seen = new Set<string>();
const add = (entry: DossierReportIndexEntry) => {
if (!seen.has(entry.id)) {
seen.add(entry.id);
entries.push(entry);
}
};
for (const result of input.results) {
if ((result.context_opportunity ?? null) !== input.context_opportunity)
continue;
add({
id: `finance:${result.id}`,
stage: 'S03',
title: 'Resultado de viabilidade financeira',
version: String(result.draft_version),
date: result.recorded_at,
state:
result.context_state === 'review_required' ||
result.context_revision !== input.investment_context?.revision
? 'Histórico — requer revisão'
: 'Resultado guardado',
href: `${base}${query}#resultados-financeiros`,
action: 'Consultar resultado',
});
}
for (const source of input.fiscal_reports ?? [])
add({
id: `fiscal:${source.report_id}`,
stage: 'S03',
title: 'Relatório de simulação fiscal',
version: String(source.report_version_no ?? 'Por identificar'),
date: source.consented_at ?? '',
state:
source.scope_state === 'identified'
? 'Versão preservada — estimativa fiscal'
: source.scope_state === 'common'
? 'Versão preservada — contexto comum do dossier'
: 'Versão preservada — âmbito por confirmar',
href: `${base}/relatorios/fiscais/${encodeURIComponent(source.report_id)}${query}`,
action: 'Descarregar versão preservada (JSON)',
});
for (const kind of ['comparison', 'preparation'] as const) {
const saved = input.company_reports?.[kind];
if (saved)
add({
id: `company:${kind}`,
stage: 'S05',
title:
kind === 'comparison'
? 'Comparação de formas jurídicas'
: 'Preparação da empresa',
version: String(saved.revision),
date: saved.at,
state: 'Preparação guardada',
href: `${base}/${kind === 'comparison' ? 'forma-juridica' : 'preparar-empresa'}`,
action: 'Consultar preparação',
});
}
const preparation = input.preparation_report;
if (input.land_report) {
add({
id: `land:${input.land_report.id}`,
stage: 'S10',
title: 'Preparação de acesso à terra',
version: String(input.land_report.version),
date: input.land_report.updated_at ?? '',
state: 'Preparação guardada — não é título DUAT',
href: input.land_report.href,
action: 'Abrir versão para impressão',
});
}
for (const exported of preparation?.exports ?? [])
add({
id: `apiex:${exported.id}`,
stage: 'S06',
title: 'Dossier documental APIEX',
version: exported.id,
date: exported.at,
state: exported.historical
? 'Exportação histórica preservada'
: 'Exportação preparada',
href: exported.href,
action: 'Descarregar pacote',
});
if (preparation?.swot.rows.length)
add({
id: 'swot',
stage: 'S02',
title: 'Análise SWOT',
version: String(preparation.version),
date: preparation.swot.review?.at ?? '',
state: preparation.swot_review_required
? 'Revisão necessária'
: 'Revista pela equipa',
href: preparation.href,
action: 'Consultar análise',
});
for (const guide of Object.values(input.company_reports?.guides ?? {}))
add({
id: `guide:${guide.slug}`,
stage: 'S06',
title: guide.title,
version: String(guide.revision),
date: guide.at,
state: 'Referência guardada para o dossier',
href: `${base}/guias/${encodeURIComponent(guide.slug)}`,
action: 'Consultar guia',
});
for (const document of documentsInDossier(input)) {
const stages = input.steps.filter(
(step) => step.detail.document_id === document.document_id,
);
add({
id: `document:${document.id}`,
stage:
stages.map((step) => step.code).join(', ') || 'Anexos comuns',
title: document.file_name,
version: String(document.version_no),
date: '',
state:
document.malware_scan_status === 'clean' &&
document.status === 'active'
? 'Documento associado'
: 'Disponibilidade por conferir',
href: `/balcao-virtual/dossiers/${encodeURIComponent(input.case.id)}/documentos/${encodeURIComponent(document.id)}`,
action: 'Consultar documento',
});
}
for (const step of input.steps) {
if (
entries.some((entry) => entry.stage.split(', ').includes(step.code))
)
continue;
add({
id: `stage:${step.code}`,
stage: step.code,
title: step.title,
version: String(step.version ?? 'Não registada'),
date: step.detail.recorded_at ?? '',
state:
step.state === 'dispensed'
? 'Preparação dispensada — consultar fundamento'
: 'Sem relatório ou documento associado',
href: `${base}${query}#${step.code}`,
action: 'Consultar etapa',
});
}
return entries.sort((a, b) => a.stage.localeCompare(b.stage));
}
export function createInvestorDossierReport(
input: InvestorDossierReportInput,
generatedAt = new Date().toISOString(),
): DossierReportModel {
const index = investorDossierReportIndex(input);
const row = (
field: string,
value: string,
origin = 'Registo guardado neste dossier',
classification = 'Preparação do investimento',
): DossierReportRow => ({ field, value, origin, classification });
const finance: DossierReportRow[] = [];
for (const result of input.results.filter(
(item) =>
(item.context_opportunity ?? null) === input.context_opportunity,
)) {
const reference = `Resultado ${result.id}; rascunho ${result.draft_id}, versão ${result.draft_version}; ${result.recorded_at}`;
const state =
result.context_revision === input.investment_context?.revision &&
result.context_state !== 'review_required'
? 'Estimativa guardada'
: 'Histórico — rever antes de utilizar';
try {
if (
result.engine !== 'financial-calculators:v1' ||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
)
throw new Error('Versão não recuperável');
const currency = result.currency ?? '';
const baseline = new Set(
buildProjectDossierRows({ currency }).map((item) =>
JSON.stringify(item),
),
);
const sections = new Set([
'Plano financeiro',
'Empréstimo educativo',
'Ponto de equilíbrio',
'Financiamento',
'Orçamento',
]);
const rows = buildProjectDossierRows({
currency,
financial_tools_snapshot: result.financial_snapshot,
});
for (const item of rows.filter(
(item) =>
sections.has(item[0]) &&
!baseline.has(JSON.stringify(item)),
)) {
finance.push(
row(
`${item[0]} — ${item[1]}`,
item[2],
`${reference}; ${item[3]}`,
state,
),
);
}
} catch {
finance.push(
row(
'Resultado preservado',
'Formato indisponível para cálculo; consulte a origem, sem substituir valores por zero.',
reference,
state,
),
);
}
}
const preparation = input.preparation_report;
const swot = preparation?.swot;
return {
title: input.case.title,
generatedAt,
version: `Dossier ${input.case.id} · v${input.case.version} · contexto v${input.investment_context?.revision ?? 'não identificado'} · oportunidade ${input.context_opportunity ?? 'contexto comum'}`,
references: [],
limitations: [
'Relatório privado de preparação; não comprova licenciamento, aprovação ou financiamento.',
'Os actos externos só valem pelo respectivo documento e entidade. Pendências e versões históricas permanecem identificadas.',
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
],
sections: [
{
id: 'project',
title: 'Investimento e localização em preparação',
private: true,
paragraphs: [
'Dados guardados no contexto seleccionado; não representam aprovação ou validação externa.',
],
rows: Object.entries({
project_title: 'Projecto',
project_summary: 'Descrição',
sector: 'Sector',
province: 'Província',
district: 'Distrito',
location: 'Localização',
total_investment: 'Investimento previsto',
currency: 'Moeda',
expected_start_date: 'Início previsto',
})
.filter(([key]) =>
input.investment_context?.fields?.[key]?.trim(),
)
.map(([key, label]) =>
row(
label,
input.investment_context?.fields?.[key] ?? '',
`Contexto v${input.investment_context?.revision ?? 'não identificado'}`,
),
),
},
{
id: 'summary',
title: 'Identificação e índice dos relatórios',
private: true,
paragraphs: [
`Dossier: ${input.case.id}`,
`Oportunidade: ${input.context_opportunity ?? 'Contexto comum do dossier'}`,
`Gerado em ${generatedAt}; versão do dossier ${input.case.version}.`,
],
rows: index.map((entry) =>
row(
`${entry.stage} — ${entry.title}`,
`${entry.state}; versão ${entry.version}; ${entry.date || 'data não registada'}`,
entry.href,
entry.action,
),
),
},
{
id: 'execution',
title: 'Etapas, resultados e pendências',
private: true,
paragraphs: [
'A situação de uma etapa não significa que o respectivo relatório ou acto externo tenha sido produzido.',
],
rows: input.steps.map((step) =>
row(
`${step.code} — ${step.title}`,
`${pathStates[step.state] ?? step.state}; versão ${step.version ?? 'não registada'}. ${step.reason || 'Sem fundamento adicional registado.'} ${step.detail.authority ?? ''} ${step.detail.reference ?? ''} ${step.detail.decision_date ?? ''}`,
`Fase ${step.phase}: ${input.phases[String(step.phase)] ?? ''}`,
step.state === 'not_started'
? 'Pendente'
: 'Estado guardado da etapa',
),
),
},
{
id: 'finance',
title: 'Viabilidade e relatórios fiscais',
private: true,
paragraphs: finance.length
? [
'Resultados recuperados das entradas guardadas pelo motor existente; impostos destinam-se à simulação de viabilidade.',
]
: [
'Nenhum resultado financeiro devolvido e autorizado neste âmbito. Não foi gerado um valor em falta.',
],
rows: [
...finance,
...(input.fiscal_reports ?? []).map((source) =>
row(
`Relatório fiscal ${source.report_id}`,
`Versão ${source.report_version_no ?? 'não indicada'}; SHA256 ${source.report_sha256 ?? 'não indicado'}`,
`/investimentos/${input.case.id}/relatorios/fiscais/${source.report_id}${input.context_opportunity ? `?opportunity=${encodeURIComponent(input.context_opportunity)}` : ''}`,
source.scope_state === 'identified'
? 'Versão fiscal preservada'
: source.scope_state === 'common'
? 'Versão preservada no contexto comum do dossier'
: 'Origem preservada no dossier; âmbito por confirmar',
),
),
],
},
{
id: 'market',
title: 'Análise SWOT',
private: true,
paragraphs: swot?.rows.length
? [
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
]
: ['Análise SWOT ainda não produzida neste âmbito.'],
rows: (swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
),
),
},
{
id: 'sources',
title: 'Preparação documental APIEX e actos externos',
private: true,
paragraphs: preparation
? [
`Estado: ${externalStates[preparation.api_state] ?? preparation.api_state}. Exportar não equivale a entregar; entrega não equivale a aprovação.`,
...preparation.api_issues,
]
: ['Preparação APIEX ainda não disponível neste âmbito.'],
rows: (preparation?.external ?? []).map((item) =>
row(
`${externalRecords[item.kind] ?? item.kind} — ${item.date}`,
`${item.reference}; ${item.outcome ? (decisions[item.outcome] ?? item.outcome) : 'sem decisão registada'}`,
`Exportação ${item.export_id}`,
'Registo de entrega ou resposta externa',
),
),
},
{
id: 'resources',
title: 'Documentos associados ao dossier',
private: true,
paragraphs: [
'Consulte os originais no dossier. Os controlos de acesso e disponibilidade mantêm-se em cada descarga.',
'Documentos comuns não comprovam a sua aplicabilidade a todas as oportunidades; confira a associação à etapa e ao pedido.',
],
rows: documentsInDossier(input).map((document) =>
row(
document.file_name,
`Versão ${document.version_no}; SHA256 ${document.sha256}`,
`/balcao-virtual/dossiers/${input.case.id}/documentos/${document.id}`,
document.status === 'active' &&
document.malware_scan_status === 'clean'
? 'Documento associado'
: 'Disponibilidade por conferir',
),
),
},
],
};
}
/** A preparation summary, never a substitute for an external act or its original. */
export function createInvestorStageReport(
input: InvestorDossierReportInput,
stageCode: string,
generatedAt = new Date().toISOString(),
): DossierReportModel {
const step = input.steps.find((item) => item.code === stageCode);
if (!step) throw new Error('Etapa não encontrada neste dossier.');
const report = createInvestorDossierReport(input, generatedAt);
const entries = investorDossierReportIndex(input).filter((entry) =>
entry.stage.split(', ').includes(stageCode),
);
const origins = new Set(entries.map((entry) => entry.href));
const specificSections = new Set([
...(stageCode === 'S02' ? ['market'] : []),
...(stageCode === 'S03' ? ['finance'] : []),
...(['S06', 'S07'].includes(stageCode) ? ['sources'] : []),
]);
return {
...report,
title: `${input.case.title} — ${stageCode}: ${step.title}`,
version: `${report.version} · etapa v${step.version ?? 'não registada'}`,
limitations: [
...report.limitations,
'Resumo dos registos disponíveis nesta etapa; não cria documentos, decisões ou resultados em falta.',
],
sections: report.sections
.filter(
(section) =>
['project', 'summary', 'execution', 'resources'].includes(
section.id,
) || specificSections.has(section.id),
)
.map((section) => {
if (section.id === 'summary')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
if (section.id === 'execution')
return {
...section,
rows: section.rows.filter((item) =>
item.field.startsWith(`${stageCode} —`),
),
};
if (section.id === 'resources')
return {
...section,
rows: section.rows.filter((item) =>
origins.has(item.origin),
),
};
return section;
}),
};
}
+2 -1
View File
@@ -1,6 +1,6 @@
import {pecCorrectionVersion} from './irpc-pec-correction.ts';
import {pecPlanVersion} from './irpc-pec-plan.ts';
import {validatePecObligation,pecObligationReport,pecObligationVersion,type PecObligation} from './irpc-pec-obligation.ts';
import {validatePecObligation,pecObligationReport,pecObligationVersion,pecPpcExcessVersion,calculatePecObligation,type PecObligation} from './irpc-pec-obligation.ts';
import { validatePpcObligation, ppcObligationReport, ppcObligationVersion, type PpcObligation } from './irpc-ppc-obligation.ts';
import {
newInvestmentCredit,
@@ -575,6 +575,7 @@ function key(d: Record<string, unknown>, b: AnnualBook) {
dependencies(d, !!b.investmentCredit),
...(b.ppcObligation ? [ppcObligationVersion] : []),
...(b.pecObligation ? [pecObligationVersion] : []),
...(b.pecObligation && calculatePecObligation(b.pecObligation,b.subjectNuit,b.year,b.assessment).ppcExcess ? [pecPpcExcessVersion] : []),
...(b.pecObligation?.plan ? [pecPlanVersion] : []),
...(b.pecObligation?.correction ? [pecCorrectionVersion] : []),
...(b.investmentCredit && b.creditCarry?.lots.length
+203 -32
View File
@@ -1,5 +1,14 @@
import {validatePecCorrection,correctedPecTurnover,pecCorrectionSource,type PecCorrection} from './irpc-pec-correction.ts';
import {validatePecPlan,pecPlanReport,type PecPlan} from './irpc-pec-plan.ts';
import {
validatePecCorrection,
correctedPecTurnover,
pecCorrectionSource,
type PecCorrection,
} from './irpc-pec-correction.ts';
import {
validatePecPlan,
pecPlanReport,
type PecPlan,
} from './irpc-pec-plan.ts';
import {
decimalFraction,
fraction,
@@ -8,9 +17,21 @@ import {
type FiscalFraction,
} from './fiscal-rational.ts';
export const pecObligationVersion = 'IRPC-PEC-obligation/1';
export const pecPpcExcessVersion = 'IRPC-PEC-PPC-excess/1';
export const pecObligationSource =
'RIRPC,Decreto9/2008,art29(1–5),BR16/I16-04-2008,original SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.0,5% do volume de negócios anterior,limites30.000/100.000MZN,menos PPC efectivamente pagos no ano anterior.Junho/Agosto/Outubro;opção de pagamento único prevista,sem data ou repartição inventada. Ano de início excluído29(4);sem volume anterior exige último montante29(5). Recorte2026,residente geral comercial/industrial/agrícola,ano civil,MZN. Regimes/períodos especiais,rectificação29(3),base de anos anteriores29(5),guias/juros separados.';
export type PecLastAmount = {
version: 1;
subjectNuit: string;
year: string;
amount: string;
reference: string;
confirmed: '' | 'yes';
};
export const pecLastAmountSource =
'RIRPC,Decreto9/2008,art29(5),BR16/I16-04-2008,p122/PDF24,original SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee. Hipótese documentada: último montante PEC já determinado,para titular/ano civil/MZN correspondentes;sem recalcular 0,5%,limites ou deduzir PPC pela segunda vez. Não demonstra por si a qualificação fiscal do caso.';
export type PecObligation = {
lastAmount?: PecLastAmount;
version: 1;
plan?: PecPlan;
correction?: PecCorrection;
@@ -42,8 +63,24 @@ export const newPecObligation = (
ppcReference: '',
});
export function validatePecObligation(p: PecObligation) {
if(p?.correction!==undefined)validatePecCorrection(p.correction);
if(p?.plan!==undefined)validatePecPlan(p.plan);
if (p?.lastAmount !== undefined) {
const v = p.lastAmount;
if (
!v ||
v.version !== 1 ||
!['', 'yes'].includes(v.confirmed) ||
['subjectNuit', 'year', 'amount', 'reference'].some(
(k) =>
typeof (v as unknown as Record<string, unknown>)[k] !==
'string',
)
)
throw Error(
'Referência PEC anterior não recuperável;original preservado.',
);
}
if (p?.correction !== undefined) validatePecCorrection(p.correction);
if (p?.plan !== undefined) validatePecPlan(p.plan);
if (
!p ||
p.version !== 1 ||
@@ -68,11 +105,18 @@ export function calculatePecObligation(
) {
validatePecObligation(p);
const pending: string[] = [];
const turnover=correctedPecTurnover(p.correction,p.previousTurnover,p.subjectNuit,p.previousYear);
const turnover = correctedPecTurnover(
p.correction,
p.previousTurnover,
p.subjectNuit,
p.previousYear,
);
let percentage: FiscalFraction | null = null,
limited: FiscalFraction | null = null,
total: FiscalFraction | null = null,
notApplicable = false;
ppcExcess: FiscalFraction | null = null,
notApplicable = false,
lastAmountApplied = false;
if (
p.scope !== 'ordinary_resident' ||
assessment !== 'normal' ||
@@ -117,11 +161,26 @@ export function calculatePecObligation(
if (!pending.length) {
const v = decimalFraction(turnover.value),
ppc = decimalFraction(p.previousPpcPaid);
if (BigInt(v.numerator) === 0n)
pending.push(
'PEC29(5):sem volume de negócios em2025,recuperar último montante dos anos anteriores;zero não determina automaticamente o mínimo.',
);
else {
if (BigInt(v.numerator) === 0n) {
const last = p.lastAmount;
if (
!last ||
last.confirmed !== 'yes' ||
!last.reference.trim() ||
last.subjectNuit !== subjectNuit ||
!/^\d{4}$/.test(last.year) ||
Number(last.year) < Number(p.activityStartYear) ||
Number(last.year) >= Number(year) ||
!/^\d{1,18}(\.\d{1,2})?$/.test(last.amount)
) {
pending.push(
'PEC29(5):sem volume de negócios em2025,recuperar último montante PEC já determinado dos anos anteriores,com titular,exercício,fonte e confirmação de que é o último aplicável;zero não determina automaticamente o mínimo.',
);
} else {
total = decimalFraction(last.amount);
lastAmountApplied = true;
}
} else {
percentage = fraction(
BigInt(v.numerator),
BigInt(v.denominator) * 200n,
@@ -135,23 +194,51 @@ export function calculatePecObligation(
? fraction(100000n)
: percentage;
const residual = subtractFractions(limited, ppc);
if (BigInt(residual.numerator) < 0n)
pending.push(
'PEC:PPC anteriores excedem o montante limitado;caso separado para reconciliação,não gera estimativa negativa ou reembolso.',
);
else total = residual;
if (BigInt(residual.numerator) < 0n) {
total = fraction(0n);
ppcExcess = subtractFractions(ppc, limited);
} else total = residual;
}
}
}
let source = p.correction
? pecObligationSource.replace('rectificação29(3),', '') +
' ' +
pecCorrectionSource
: pecObligationSource;
if (p.lastAmount)
source =
source.replace('base de anos anteriores29(5),', '') +
' ' +
pecLastAmountSource;
if (ppcExcess)
source +=
' Reconciliação da estimativa29(2):PPC documentados cobrem a base limitada;diferença conservada sem gerar direito a reembolso ou crédito.';
return {
version: pecObligationVersion,
source: p.correction ? pecObligationSource.replace('rectificação29(3),','')+' '+pecCorrectionSource : pecObligationSource,
source,
lastAmountSource: p.lastAmount ? pecLastAmountSource : null,
pending,
percentage,
limited,
total,
notApplicable,
turnoverCorrection: p.correction ? {original:p.correction.originalTurnover,revised:p.correction.revisedTurnover,applied:!notApplicable&&turnover.applied,pending:turnover.pending,reference:p.correction.reference,source:pecCorrectionSource}:null,
lastAmountApplied,
ppcExcess,
requiresLastAmount:
!notApplicable &&
turnover.pending.length === 0 &&
/^0+(\.0+)?$/.test(turnover.value),
turnoverCorrection: p.correction
? {
original: p.correction.originalTurnover,
revised: p.correction.revisedTurnover,
applied: !notApplicable && turnover.applied,
pending: turnover.pending,
reference: p.correction.reference,
source: pecCorrectionSource,
}
: null,
months:
total && !notApplicable ? ['2026-06', '2026-08', '2026-10'] : [],
instalmentAmountsDetermined: false,
@@ -168,11 +255,86 @@ export function pecObligationReport(
const m = calculatePecObligation(p, subjectNuit, year, assessment),
show = (v: FiscalFraction | null) =>
v ? fractionPresentation(v).decimal + ' MZN' : 'Indeterminado';
const originalEstimate=p.correction ? calculatePecObligation({...p,correction:undefined},subjectNuit,year,assessment).total:null;
const correctionEffect=originalEstimate&&m.total ? fractionPresentation(subtractFractions(m.total,originalEstimate)).decimal+' MZN':'por determinar';
const originalEstimate = p.correction
? calculatePecObligation(
{ ...p, correction: undefined },
subjectNuit,
year,
assessment,
).total
: null;
const correctionEffect =
originalEstimate && m.total
? fractionPresentation(subtractFractions(m.total, originalEstimate))
.decimal + ' MZN'
: 'por determinar';
return [
...(p.correction ? [['IRPC — L8','PEC — rectificação do volume','Original: '+p.correction.originalTurnover+' MZN;corrigido: '+p.correction.revisedTurnover+' MZN;'+(m.turnoverCorrection?.applied?'aplicada à estimativa.':'conservada sem aplicação à estimativa.')+' Estimativa original: '+show(originalEstimate)+';efeito na estimativa: '+correctionEffect+'. '+turnoverCorrectionNotice(m.turnoverCorrection),p.correction.reference+'; '+pecCorrectionSource] as [string,string,string,string]]:[]),
...(p.plan ? pecPlanReport(p.plan,m.total,m.notApplicable,year) : []),
...(m.ppcExcess
? [
[
'IRPC — L8',
'PEC — PPC acima da base limitada',
'PPC documentados: ' +
p.previousPpcPaid +
' MZN;base limitada: ' +
show(m.limited) +
';diferença: ' +
show(m.ppcExcess) +
';estimativa PEC: 0 MZN. A diferença é apenas reconciliação desta hipótese; não cria reembolso,crédito,transporte ou dedução anual.',
p.ppcReference +
'; ' +
p.turnoverReference +
'; RIRPC29(2); ' +
pecPpcExcessVersion,
] as [string, string, string, string],
]
: []),
...(p.lastAmount
? [
[
'IRPC — L8',
'PEC — último montante documentado',
'Exercício ' +
(p.lastAmount.year || 'por confirmar') +
';montante PEC já determinado: ' +
(p.lastAmount.amount || 'por confirmar') +
' MZN;' +
(m.lastAmountApplied
? 'usado como hipótese documentada29(5),sem novo cálculo percentual ou segunda dedução de PPC.'
: 'conservado sem aplicação à estimativa.') +
' Não regista pagamento nem dedução anual.',
(p.lastAmount.reference || 'Fonte por indicar') +
'; ' +
pecLastAmountSource,
] as [string, string, string, string],
]
: []),
...(p.correction
? [
[
'IRPC — L8',
'PEC — rectificação do volume',
'Original: ' +
p.correction.originalTurnover +
' MZN;corrigido: ' +
p.correction.revisedTurnover +
' MZN;' +
(m.turnoverCorrection?.applied
? 'aplicada à estimativa.'
: 'conservada sem aplicação à estimativa.') +
' Estimativa original: ' +
show(originalEstimate) +
';efeito na estimativa: ' +
correctionEffect +
'. ' +
turnoverCorrectionNotice(m.turnoverCorrection),
p.correction.reference + '; ' + pecCorrectionSource,
] as [string, string, string, string],
]
: []),
...(p.plan
? pecPlanReport(p.plan, m.total, m.notApplicable, year)
: []),
[
'IRPC — L8',
'PEC — estimativa fiscal',
@@ -180,15 +342,22 @@ export function pecObligationReport(
? m.pending.join(' ')
: m.notApplicable
? 'Não aplicável no ano de início da actividade29(4);total0 MZN.'
: '0,5%: ' +
show(m.percentage) +
';limitado antes de PPC: ' +
show(m.limited) +
';total estimado: ' +
show(m.total) +
';meses: ' +
m.months.join(', ') +
(p.plan ? '. Plano explícito apresentado separadamente;prazo legal não validado.' : '. Montantes por prestação e opção de pagamento único não determinados neste recorte.') + ' Preparação não é pagamento nem dedução anual.',
: m.lastAmountApplied
? 'Hipótese documentada29(5): ' +
show(m.total) +
';último montante PEC já determinado,sem segunda dedução de PPC. Confirmar enquadramento com o suporte indicado.'
: '0,5%: ' +
show(m.percentage) +
';limitado antes de PPC: ' +
show(m.limited) +
';total estimado: ' +
show(m.total) +
';meses: ' +
m.months.join(', ') +
(p.plan
? '. Plano explícito apresentado separadamente;prazo legal não validado.'
: '. Montantes por prestação e opção de pagamento único não determinados neste recorte.') +
' Preparação não é pagamento nem dedução anual.',
p.activityReference +
'; ' +
p.turnoverReference +
@@ -200,4 +369,6 @@ export function pecObligationReport(
];
}
function turnoverCorrectionNotice(c:{pending:string[]}|null){return c?.pending.join(' ')??'';}
function turnoverCorrectionNotice(c: { pending: string[] } | null) {
return c?.pending.join(' ') ?? '';
}
@@ -396,6 +396,11 @@ export async function generateProjectDossierDocx(
],
}),
];
for (const limitation of model.limitations) {
children.push(
new Paragraph({ children: [new TextRun({ text: limitation })] }),
);
}
for (const section of model.sections) {
if (section.id === 'annexes') {
if (!attachments.some((item) => item.include)) {
@@ -591,6 +596,8 @@ export async function createProjectDossierPrintHtml(
annexesIncluded: boolean,
): Promise<string> {
const content: string[] = [];
for (const limitation of model.limitations)
content.push(`<p>${escapeHtml(limitation)}</p>`);
for (const section of model.sections) {
if (
section.id === 'annexes' &&
+33 -2
View File
@@ -3,6 +3,7 @@ import {
persistOpportunitySelection,
} from '@/lib/opportunity-selection';
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
import DemographicContext from '@/components/demographic-context';
import OpportunityCorridorGroups, {
type CorridorGroup,
} from '@/components/opportunity-corridor-groups';
@@ -22,7 +23,7 @@ import type { QuantitativeMapContext } from '@/components/investment-map-indicat
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
import { type TerritoryOption } from '@/components/investment-territory';
import { Frame, button, card, money, api } from './shared';
import type { Opportunity, References } from './shared';
import type { Opportunity, References, Context } from './shared';
import {
ArrowUpRight,
Bookmark,
@@ -171,7 +172,9 @@ export default function Catalogue({
const [territoryPath, setTerritoryPath] = useState(territorySelection);
const [saved, setSaved] = useState(favourites);
const [selected, setSelected] = useState<number[]>([]);
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
const [comparison, setComparison] = useState<
{ record: Opportunity; context?: Context }[]
>([]);
const comparisonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (view === 'corridors' && comparison.length)
@@ -406,6 +409,21 @@ export default function Catalogue({
: 'Catálogo de investimento'
}
>
<Link
className="underline"
href={
'/localizacoes/demografia' +
(territoryPath
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id
? '?territories[]=' + territoryPath
.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id
: '')
}
>
Comparar demografia e evolução das localizações
</Link>
{!!territoryPath.length && (
<Link
className={button}
@@ -1074,6 +1092,19 @@ export default function Catalogue({
))}
</tr>
))}
<tr>
<th className="border p-3">Demografia INE</th>
{comparison.map(({ record: r, context: c }) => (
<td className="border p-3" key={r.id}>
<DemographicContext
key={r.id}
territoryId={c?.primary_territory_path
?.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
</td>
))}
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,128 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import DemographicEvidence, {
type DemographicAnalysis,
} from '@/components/demographic-evidence';
import { Frame, button } from './shared';
export default function Demography({
evidence,
filters,
territories,
sectors,
}: {
evidence: DemographicAnalysis;
filters: {
territories?: string[];
start_year?: number;
end_year?: number;
sector?: string;
};
territories: {
id: string;
name: string;
location_type: string;
parent_id: string | null;
}[];
sectors: Record<string, string>;
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [start, setStart] = useState(
filters.start_year ?? new Date().getFullYear(),
);
const [end, setEnd] = useState(filters.end_year ?? start);
const [sector, setSector] = useState(filters.sector ?? 'general');
return (
<Frame title="Demografia e viabilidade territorial">
<p>
Compare até quatro localizações no mesmo sector e período. Use
esta evidência em conjunto com as condições económicas, fiscais
e territoriais do investimento.
</p>
<form
className="grid gap-4 rounded border bg-white p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
router.get(
'/localizacoes/demografia',
{
territories: selected,
start_year: start,
end_year: end,
sector,
},
{ preserveState: true },
);
}}
>
<label>
Províncias ou distritos (até quatro)
<select
multiple
className="block h-48 w-full rounded border p-2"
value={selected}
onChange={(e) =>
setSelected(
Array.from(
e.target.selectedOptions,
(o) => o.value,
).slice(0, 4),
)
}
>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name} —{' '}
{t.location_type === 'province'
? 'Província'
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
</option>
))}
</select>
</label>
<div className="space-y-3">
<label className="block">
Sector
<select
className="block w-full rounded border p-2"
value={sector}
onChange={(e) => setSector(e.target.value)}
>
{Object.entries(sectors).map(([key, label]) => (
<option value={key} key={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Ano inicial
<input
className="block rounded border p-2"
type="number"
min="1900"
max="2100"
value={start}
onChange={(e) => setStart(Number(e.target.value))}
/>
</label>
<label className="block">
Ano final
<input
className="block rounded border p-2"
type="number"
min={start}
max={Math.min(2100, start + 50)}
value={end}
onChange={(e) => setEnd(Number(e.target.value))}
/>
</label>
<button className={button} disabled={selected.length === 0}>
Consultar e comparar
</button>
</div>
</form>
<DemographicEvidence evidence={evidence} />
</Frame>
);
}
+7
View File
@@ -1,4 +1,5 @@
import { ProactiveSummary } from '@/components/proactive-investment';
import DemographicContext from '@/components/demographic-context';
import EducationalGuides from '@/components/educational-guides';
import InvestmentComplements, {
type ComplementOption,
@@ -183,6 +184,12 @@ export default function Detail({
filters={{ opportunity_id: r.id }}
territoryId={c.primary_territory_path?.at(-1)?.id}
/>
<DemographicContext
key={c.primary_territory_path?.at(-1)?.id}
territoryId={c.primary_territory_path
?.filter((t) => ['province', 'district'].includes(t.location_type))
.at(-1)?.id}
/>
{c.primary_territory_path &&
c.primary_territory_path.length > 0 && (
<p>
+11 -1
View File
@@ -1,4 +1,6 @@
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
import {
financialCategories,
financialTypes,
@@ -31,6 +33,7 @@ import {
} from '@/lib/investor-path';
type Step = {
version: number;
code: string;
title: string;
phase: number;
@@ -45,6 +48,10 @@ type Step = {
source_paragraph: number;
};
type Path = {
preparation_report?: InvestorDossierReportInput['preparation_report'];
company_reports?: InvestorDossierReportInput['company_reports'];
fiscal_reports?: InvestorDossierReportInput['fiscal_reports'];
land_report?: InvestorDossierReportInput['land_report'];
sector_references?: SectorResourceSelection[];
funding_selections?: {
id: string;
@@ -117,6 +124,8 @@ type Path = {
file_name: string;
status: string;
malware_scan_status: string;
version_no: number;
sha256: string;
}[];
requests: {
id: string;
@@ -995,7 +1004,8 @@ export default function InvestmentPath(path: Path) {
</div>
</details>
))}
<section className="space-y-3">
<InvestorDossierReports dossier={path} />
<section id="resultados-financeiros" className="space-y-3">
<h2 className="text-lg font-semibold">
Resultados devolvidos ao dossier
</h2>
@@ -0,0 +1,180 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { Management, button, control } from './shared';
type Plan = {
mode: 'initial' | 'remaining' | 'zambezia';
pending_count: number;
aliases: Record<string,string>;
alias_sources: string[];
missing_province_source: string | null;
hash: string;
ready: boolean;
installed: boolean;
consistent: boolean;
backup_verified: boolean;
mfa_confirmed: boolean;
target: { database: string; port: number };
migration: string;
code_sha256: string;
files: Record<string, { sha256: string; records: number; issues?: Record<string,number> }>;
new_districts: string[];
notice: string;
};
export default function DemographyInstallation({ plan }: { plan: Plan }) {
const [reason, setReason] = useState(
'Disponibilização autorizada pela Tempo em 13/11/2026, preservando valores, advertências e histórico.',
);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [operation] = useState(createOperationKey);
async function apply() {
setBusy(true);
setMessage('');
try {
const cookie =
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')
.slice(1)
.join('=') ?? '';
const response = await fetch('/management/demography/install', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(cookie),
},
body: JSON.stringify({
hash: plan.hash,
mode: plan.mode,
confirm: confirmed,
operation_key: operation,
reason,
}),
});
const body = await response.text();
if (!body.trim() || !response.headers.get('content-type')?.includes('application/json')) {
throw new Error(`O servidor não devolveu um recibo JSON (HTTP ${response.status}). Consulte o estado antes de repetir a operação.`);
}
const result = JSON.parse(body);
if (!response.ok)
throw new Error(
result.message ??
'Não foi possível disponibilizar os dados.',
);
router.visit('/management/demography');
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Falha de comunicação. Consulte o estado antes de repetir.',
);
} finally {
setBusy(false);
}
}
return (
<Management title="Disponibilizar dados demográficos INE">
<p>
Destino:{' '}
<strong>
{plan.target.database}:{plan.target.port}
</strong>
. {plan.mode !== 'initial' ? 'Disponibiliza os ficheiros pendentes deste conjunto e acrescenta apenas os distritos autorizados, preservando todos os lotes já aprovados.' : 'Instala as tabelas demográficas e disponibiliza os três exemplares homologados.'} Mantém os dados existentes, as versões e os valores originais.
</p>
<dl className="grid gap-2 sm:grid-cols-2">
<dt>Backup e restauro da entrega</dt>
<dd>
{plan.backup_verified
? 'Conferidos'
: 'Pendentes ou desactualizados'}
</dd>
<dt>Estrutura</dt>
<dd>
{plan.consistent
? plan.installed
? 'Instalada'
: 'Preparada'
: 'Divergente — reconciliar'}
</dd>
<dt>MFA da sessão</dt>
<dd>
{plan.mfa_confirmed
? 'Confirmado'
: 'Conclua o MFA da sua conta antes de executar'}
</dd>
</dl>
<table className="w-full text-left">
<caption className="text-left font-semibold">
Pré-visualização aprovada para disponibilização
</caption>
<thead>
<tr>
<th>Exemplar</th>
<th>Observações</th>
</tr>
</thead>
<tbody>
{Object.entries(plan.files).map(([name, file]) => (
<tr key={name}>
<td>{name}</td>
<td>{file.records.toLocaleString('pt-MZ')}</td>
</tr>
))}
</tbody>
</table>
<p>
Distritos a acrescentar quando ausentes:{' '}
{plan.new_districts.join(' e ')}. Não são criados limites
geográficos, licenças ou direitos territoriais.
</p>
<p>{plan.notice}</p>
{plan.mode !== 'initial' && <details><summary>Conferir advertências por ficheiro</summary><p>Nas células com fórmulas, são conservados apenas os valores numéricos guardados no original. O portal não executa nem recalcula fórmulas. Valores ausentes não se tornam zero.</p><ul>{Object.entries(plan.files).map(([name,file])=><li key={name}><strong>{name}</strong><ul>{Object.entries(file.issues ?? {}).map(([code,count])=><li key={code}>{({cached_formula_value:'Valores guardados em células com fórmula',missing_value:'Valores em falta',age_total_mismatch:'Divergências na soma de idades',sex_total_mismatch:'Divergências na soma por sexo',residence_total_mismatch:'Divergências entre urbano/rural',district_total_mismatch:'Divergências na soma de distritos'} as Record<string,string>)[code] ?? code}: {count.toLocaleString('pt-MZ')}</li>)}</ul></li>)}</ul></details>}
{plan.mode !== 'initial' && <p>{plan.pending_count} ficheiros ainda por disponibilizar. {plan.missing_province_source && <>{plan.missing_province_source}: ficheiro provincial não fornecido; não se presume cobertura anual.</>}</p>}
{Object.keys(plan.aliases).length > 0 && <details><summary>Conferir correspondências com o cadastro existente</summary><ul>{Object.entries(plan.aliases).map(([key,name])=><li key={key}>{key.split('|').slice(0,2).join(' — ')} → {name}</li>)}</ul><p>Designações originais preservadas; os identificadores do cadastro não são renomeados ou fundidos.</p><ul>{plan.alias_sources.map(url=><li key={url}><a href={url} target="_blank" rel="noopener noreferrer">Consultar fundamento da correspondência</a></li>)}</ul></details>}
{plan.pending_count === 0 && <p role="status">Todos os ficheiros deste conjunto já estão aprovados. Não é necessário repetir a operação.</p>}
<label className="block">
Motivo
<textarea
className={control}
value={reason}
minLength={20}
maxLength={1000}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
Confirmo o destino, os {Object.keys(plan.files).length} exemplares, as correspondências territoriais e a preservação das advertências apresentadas.
</label>
<button
className={button}
disabled={
!plan.ready ||
!plan.mfa_confirmed ||
!confirmed ||
reason.length < 20 ||
busy
}
onClick={() => void apply()}
>
{busy ? 'A importar e conferir…' : plan.mode === 'zambezia' ? 'Disponibilizar Zambézia' : plan.mode !== 'initial' ? 'Disponibilizar restantes províncias' : 'Instalar e disponibilizar'}
</button>
{message && <p role="alert">{message}</p>}
<p>
<a className="underline" href="/localizacoes/demografia">
Consultar os dados disponíveis
</a>
</p>
</Management>
);
}
@@ -0,0 +1,307 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { Management, button, control } from './shared';
type Preview = {
id: string;
file_name: string;
sha256: string;
publication_version: string;
status: string;
report: {
record_count: number;
issue_counts: Record<string, number>;
issues: { code: string; sheet: string; cell: string; delta?: string }[];
};
matches: {
key: string;
province: string;
territory: string;
level: string;
geo_location_id: string | null;
state: string;
}[];
territory_options: {
id: string;
name: string;
location_type: string;
parent_id: string | null;
}[];
events: {
id: number;
action: string;
reason: string;
created_at: string;
}[];
};
export default function DemographyManagement({
batches,
preview,
qa = true,
}: {
qa?: boolean;
batches: {
id: string;
file_name: string;
publication_version: string;
status: string;
}[];
preview: Preview | null;
}) {
const [file, setFile] = useState<File | null>(null);
const [version, setVersion] = useState('');
const [year, setYear] = useState('');
const [reason, setReason] = useState('');
const [mapping, setMapping] = useState<Record<string, string>>({});
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
async function execute(path: string, body: FormData | object) {
setBusy(true);
setMessage('');
try {
const token =
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')
.slice(1)
.join('=') ?? '';
const response = await fetch(path, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token),
...(body instanceof FormData
? {}
: { 'Content-Type': 'application/json' }),
},
body: body instanceof FormData ? body : JSON.stringify(body),
});
const data = await response.json();
if (!response.ok)
throw new Error(
data.message ?? 'Não foi possível executar a operação.',
);
setMapping({});
router.get('/management/demography', { batch: data.id });
} catch (error) {
setMessage(
error instanceof Error ? error.message : 'Erro de comunicação.',
);
} finally {
setBusy(false);
}
}
return (
<Management
title={
qa ? 'Dados demográficos INE — QA' : 'Dados demográficos INE'
}
>
<p>
Preparar não grava observações. Confirmar importa para consulta
administrativa. Aprovar disponibiliza a série nas análises deste
ambiente; desactivar conserva dados e histórico.
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (!file) return;
const body = new FormData();
body.append('file', file);
body.append('publication_version', version);
if (year) body.append('baseline_year', year);
void execute('/management/demography/prepare', body);
}}
>
<label className="block">
Excel original
<input
type="file"
accept=".xlsx"
required
className={control}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
<label className="block">
Publicação / identificação do exemplar
<input
required
maxLength={255}
className={control}
value={version}
onChange={(e) => setVersion(e.target.value)}
/>
</label>
<label className="block">
Ano de referência, apenas para folha sem ano
<input
type="number"
min="1900"
max="2100"
className={control}
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</label>
<button className={button} disabled={busy}>
Preparar e verificar
</button>
</form>
{message && <p role="alert">{message}</p>}
<h2 className="text-xl font-semibold">Importações anteriores</h2>
<ul>
{batches.map((b) => (
<li key={b.id}>
<a
className="underline"
href={`/management/demography?batch=${b.id}`}
>
{b.file_name} · {b.publication_version} · {b.status}
</a>
</li>
))}
</ul>
{preview && (
<section key={preview.id} className="space-y-4">
<h2 className="text-xl font-semibold">
{preview.file_name} — {preview.status}
</h2>
<p>
{preview.report.record_count} observações extraídas.
SHA256: {preview.sha256}
</p>
<details>
<summary>Erros e advertências</summary>
<pre>
{JSON.stringify(
preview.report.issue_counts,
null,
2,
)}
</pre>
<div className="max-h-72 overflow-auto">
<ul>
{preview.report.issues.map((i, n) => (
<li key={n}>
{i.sheet}!{i.cell}: {i.code}{' '}
{i.delta ?? ''}
</li>
))}
</ul>
</div>
</details>
<h3 className="font-semibold">
Correspondências territoriais
</h3>
{preview.matches.map((m) => (
<label className="block" key={m.key}>
{m.province} / {m.territory} ({m.level}) — {m.state}
<select
className={control}
disabled={preview.status !== 'prepared'}
value={
mapping[m.key] ?? m.geo_location_id ?? ''
}
onChange={(e) =>
setMapping({
...mapping,
[m.key]: e.target.value,
})
}
>
<option value="">Por resolver</option>
{preview.territory_options
.filter((t) => t.location_type === m.level)
.map((t) => (
<option value={t.id} key={t.id}>
{t.name} (
{preview.territory_options.find(
(p) => p.id === t.parent_id,
)?.name ?? 'Província'}
)
</option>
))}
</select>
</label>
))}
<label className="block">
Motivo e tratamento das advertências
<textarea
className={control}
minLength={10}
maxLength={2000}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<div className="flex flex-wrap gap-3">
{preview.status === 'prepared' ? (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/confirm`,
{
sha256: preview.sha256,
mapping,
reason,
},
)
}
>
Confirmar importação
</button>
) : (
<>
{preview.status !== 'approved' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'approved', reason },
)
}
>
{qa
? 'Aprovar utilização em QA'
: 'Aprovar utilização'}
</button>
)}
{preview.status !== 'disabled' && (
<button
disabled={busy || reason.length < 10}
className={button}
onClick={() =>
void execute(
`/management/demography/${preview.id}/transition`,
{ action: 'disabled', reason },
)
}
>
Desactivar série
</button>
)}
</>
)}
</div>
<details>
<summary>Histórico</summary>
<ul>
{preview.events.map((e) => (
<li key={e.id}>
{e.created_at}: {e.action} — {e.reason}
</li>
))}
</ul>
</details>
</section>
)}
</Management>
);
}
+1
View File
@@ -78,6 +78,7 @@ export function Management({
Revisão da origem das oportunidades
</Link>
<Link href="/management/history">Histórico</Link>
<Link href="/management/demography">Demografia INE</Link>
<Link href="/management/crm/leads">CRM</Link>
<Link href="/management/crm/investors">Investidores</Link>
<Link href="/management/projects">Projectos</Link>
+48 -4
View File
@@ -23,6 +23,8 @@ type Result = {
description: string | null;
url: string;
category: string;
location?: string | null;
coverage?: string | null;
};
type Results = {
data: Result[];
@@ -33,9 +35,26 @@ type Results = {
unavailable: string[];
};
type SearchAnswer = {
type: 'count' | 'guide' | 'products' | 'list';
label: string;
count: number | null;
filter: string;
category?: 'all' | 'physical' | 'financial';
};
const ANSWER_HELP: Record<SearchAnswer['type'], string> = {
count: 'Contagem dos registos publicados e disponíveis para consulta no InvesteMoz.',
list: 'Registos publicados e disponíveis para consulta no InvesteMoz.',
guide: 'Consulte o roteiro público e prepare depois o licenciamento no seu dossier. Os requisitos dependem da actividade e localização.',
products: 'Produtos físicos e referências de cadeias de valor separados dos produtos financeiros. A presença física de uma instituição não determina a cobertura do financiamento.',
};
type CategoryMeta = { icon: LucideIcon; iconClass: string; badgeClass: string };
const CATEGORY_META: Record<string, CategoryMeta> = {
'Produtos e localizações': { icon: Factory, iconClass: 'bg-teal-50 text-teal-700', badgeClass: 'bg-teal-50 text-teal-700' },
'Produtos financeiros': { icon: Landmark, iconClass: 'bg-blue-50 text-blue-700', badgeClass: 'bg-blue-50 text-blue-700' },
'Páginas e serviços': {
icon: Compass,
iconClass: 'bg-slate-100 text-slate-700',
@@ -292,9 +311,11 @@ function QuickLinks() {
export default function Search({
query,
results,
answer = null,
}: {
query: string;
results: Results;
answer?: SearchAnswer | null;
}) {
const [input, setInput] = useState(query);
useEffect(() => setInput(query), [query]);
@@ -308,7 +329,7 @@ export default function Search({
unavailable,
} = results;
const pageUrl = (page: number) =>
`/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
`/pesquisa?${new URLSearchParams({ q: query, page: String(page), ...(answer?.category ? { category: answer.category } : {}) })}`;
const start = total ? (current - 1) * perPage + 1 : 0;
const end = Math.min(start + data.length - 1, total);
const highlight = useMemo(() => buildHighlightRegex(query), [query]);
@@ -340,6 +361,7 @@ export default function Search({
role="search"
className="mt-6 flex max-w-3xl flex-col gap-2 sm:flex-row"
>
{answer?.category && <input type="hidden" name="category" value={answer.category} />}
<label htmlFor="search-query" className="sr-only">
Pesquisar no InvesteMoz
</label>
@@ -376,6 +398,26 @@ export default function Search({
</section>
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
{answer && (
<section aria-label="Resposta à pesquisa" className="mb-6 rounded-xl border border-slate-200 bg-white p-5">
<p className="text-xl font-semibold text-[#101820]" aria-live="polite">
{answer.type !== 'count' ? answer.label : answer.count === null ? 'Contagem indisponível de momento' : `${answer.count.toLocaleString('pt-MZ')} ${answer.label}`}
</p>
<p className="mt-2 text-sm text-slate-600">{ANSWER_HELP[answer.type]}</p>
{answer.filter && answer.type !== 'guide' && <p className="mt-1 text-sm text-slate-600">Correspondência textual: {answer.filter}.</p>}
{answer.type === 'products' && (
<nav aria-label="Categoria de produtos" className="mt-4 flex flex-wrap gap-2">
{([['all', 'Todos'], ['physical', 'Produtos físicos'], ['financial', 'Produtos financeiros']] as const).map(([category, label]) => (
<Link key={category} href={`/pesquisa?${new URLSearchParams({ q: answer.filter, category })}`}
aria-current={(answer.category ?? 'all') === category ? 'page' : undefined}
className={`rounded-full border px-3 py-2 text-sm font-semibold ${(answer.category ?? 'all') === category ? 'border-slate-800 bg-slate-800 text-white' : 'border-slate-200 text-slate-700 hover:bg-slate-100'}`}>
{label}
</Link>
))}
</nav>
)}
</section>
)}
{unavailable.length > 0 && (
<p
role="alert"
@@ -387,7 +429,7 @@ export default function Search({
</p>
)}
{query ? (
{query || answer ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<p
@@ -395,8 +437,8 @@ export default function Search({
className="text-sm text-slate-600"
>
{total > 0
? `Resultados ${start}–${end} de ${total} para “${query}”.`
: `Nenhum resultado nas fontes consultadas para “${query}”.`}
? `Resultados ${start}–${end} de ${total} para “${query || answer?.label}”.`
: `Nenhum resultado nas fontes consultadas para “${query || answer?.label}”.`}
</p>
{last > 1 && (
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
@@ -473,6 +515,8 @@ export default function Search({
/>
</p>
)}
{item.location && <p className="mt-2 text-sm text-slate-700"><MapPin size={14} className="mr-1 inline" aria-hidden="true" />{item.location}</p>}
{item.coverage && <p className="mt-1 text-xs text-slate-600">{item.coverage}</p>}
</div>
<ArrowUpRight
size={20}
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Http\Controllers\DemographyController;
use App\Http\Middleware\PrivateInvestmentResponse;
use Illuminate\Support\Facades\Route;
Route::get('/localizacoes/demografia', [DemographyController::class, 'index'])->middleware('throttle:30,1');
Route::get('/api/v1/investment/demography', [DemographyController::class, 'index'])->middleware('throttle:30,1');
// Each action enforces its existing installer/importer authorisation. The legacy
// catalogue gate does not recognise a designated profile superuser.
Route::prefix('management/demography')->middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:20,1'])->group(function () {
Route::get('/install', [DemographyController::class, 'installation']);
Route::post('/install', [DemographyController::class, 'install']);
Route::get('/', [DemographyController::class, 'management']);
Route::post('/prepare', [DemographyController::class, 'prepare']);
Route::post('/{id}/confirm', [DemographyController::class, 'confirm'])->whereUuid('id');
Route::post('/{id}/transition', [DemographyController::class, 'transition'])->whereUuid('id');
});
+1
View File
@@ -24,6 +24,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
Route::get('/{id}/preparacao', [InvestmentPreparationController::class, 'show'])->whereUuid('id');
Route::get('/{id}/relatorios/fiscais/{reportId}', [Path::class, 'fiscalReport'])->whereUuid(['id', 'reportId']);
Route::post('/{id}/preparacao', [InvestmentPreparationController::class, 'change'])->whereUuid('id');
Route::get('/{id}/preparacao/previa', [InvestmentPreparationController::class, 'preview'])->whereUuid('id');
Route::get('/{id}/preparacao/exportar/{exportId}', [InvestmentPreparationController::class, 'download'])->whereUuid('id')->whereUuid('exportId');
+1
View File
@@ -354,6 +354,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('/porque-investir', fn () => Inertia::render('portal/why-invest'))->name('portal.why-invest');
require __DIR__.'/investor-path.php';
require __DIR__.'/demography.php';
// Serialize management requests for the same session so background dropdown/coordinate
// reads cannot overwrite validation feedback from a concurrent form submission.
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographicInstallationTest extends TestCase
{
use DatabaseTransactions;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->manager = User::factory()->create();
$this->manager->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$second = User::factory()->create();
// Test-only installation identities. This entire transaction is rolled back in QA.
if (DB::table('portal_superuser_pair')->where('id', 1)->exists()) {
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $this->manager->id, 'second_user_id' => $second->id]);
}
config(['portal.managers' => [$this->manager->email]]);
}
public function test_installation_requires_fresh_session_mfa_even_for_a_designated_manager(): void
{
$this->actingAs($this->manager)->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA installation access test.'])->assertForbidden();
}
public function test_an_unauthorised_account_cannot_read_the_installation_plan(): void
{
$this->actingAs(User::factory()->create())->getJson('/management/demography/install')->assertForbidden();
}
public function test_designated_superuser_does_not_require_the_legacy_manager_gate(): void
{
config(['portal.managers' => []]);
$this->assertFalse($this->manager->can('manage-portal'));
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->component('management/demography-install')->where('plan.mfa_confirmed', true));
app(\App\Services\DemographicImport::class)->authorize($this->manager);
$this->assertTrue(true);
}
public function test_an_unavailable_interpreter_blocks_the_plan_before_installation(): void
{
config(['demography.python' => base_path('storage/nonexistent-demography-python.exe')]);
$plan = app(DemographicInstallation::class)->preview($this->manager);
$this->assertFalse($plan['runtime_ready']);
$this->assertFalse($plan['ready']);
$this->assertStringContainsString('interpretador', $plan['notice']);
}
public function test_disabled_process_support_does_not_crash_the_installation_page(): void
{
if (function_exists('proc_open')) { $this->markTestSkipped('Run this regression with -d disable_functions=proc_open.'); }
config(['demography.prepared_directory' => null]);
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->get('/management/demography/install')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->component('management/demography-install')->where('plan.runtime_ready', false)->where('plan.ready', false));
}
public function test_schema_flag_alone_does_not_authorise_the_operational_target(): void
{
$this->app['env'] = 'local';
config(['demography.authenticated_installation' => true, 'database.connections.pgsql.database' => 'investemoz', 'database.connections.pgsql.port' => '5432']);
$this->assertFalse(app(DemographicInstallation::class)->schemaAllowed());
}
public function test_stale_plan_is_rejected_before_schema_or_data_changes(): void
{
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$this->postJson('/management/demography/install', ['hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA stale installation plan test.'])->assertConflict();
}
public function test_release_installs_imports_and_replays_on_the_restored_target(): void
{
if (getenv('DEMOGRAPHY_RELEASE_TESTS') !== '1') {
$this->markTestSkipped('Opt in only on the specifically restored demographic QA database with the real supplied Excel files and scanner.');
}
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
$this->assertFalse(Schema::hasTable('demographic_imports'));
$this->actingAs($this->manager)->withSession(['portal.mfa_actor' => $this->manager->id, 'portal.mfa_at' => time()]);
$service = app(DemographicInstallation::class);
$plan = $service->preview($this->manager);
$this->assertTrue($plan['ready']);
$input = ['hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Homologação da entrega numa cópia restaurada; apenas dados demográficos oficiais e identidades sintéticas QA.'];
$result = $service->apply($this->manager, $input);
$this->assertTrue($result['installed']);
$this->assertSame(154706, $result['record_count']);
$this->assertSame(154706, DB::table('demographic_observations')->count());
$this->assertSame(3, DB::table('demographic_imports')->where('status', 'approved')->count());
$repeat = $service->apply($this->manager, $input);
$this->assertTrue($repeat['repeated']);
$this->assertSame(154706, DB::table('demographic_observations')->count());
$districts = app(DemographicEvidence::class)->territories()->whereIn('name', ['Macate', 'Vanduzi'])->pluck('id')->all();
$this->assertCount(2, $districts);
$this->assertSame('available', app(DemographicEvidence::class)->analyse($districts, 2017, 2050, 'industry')['state']);
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'actor_user_id' => $this->manager->id, 'correlation_id' => $input['operation_key']]);
$originals = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
$originalIds = DB::table('demographic_imports')->pluck('id')->all();
$originalEvents = DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson();
$plan = $service->preview($this->manager);
$this->assertSame('remaining', $plan['mode']);
$this->assertSame(8, $plan['pending_count']);
$this->assertCount(8, $plan['new_districts']);
$next = ['hash' => $plan['hash'], 'mode' => 'remaining', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Oito províncias e oito distritos autorizados em13/11;QA restaurada sem alterações no activo.'];
$result = $service->apply($this->manager, $next);
$this->assertSame(639066, $result['record_count']);
$this->assertSame(793772, DB::table('demographic_observations')->count());
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
$this->assertSame($originals, DB::table('demographic_imports')->whereIn('id', $originalIds)->orderBy('id')->get()->toJson());
$this->assertSame($originalEvents, DB::table('demographic_events')->whereIn('import_id', $originalIds)->orderBy('id')->get()->toJson());
$this->assertTrue($service->apply($this->manager, $next)['repeated']);
$this->assertSame(793772, DB::table('demographic_observations')->count());
$this->assertSame('zambezia', $service->preview($this->manager)['mode']);
$this->assertSame(1, $service->preview($this->manager)['pending_count']);
$this->assertTrue($service->preview($this->manager)['ready']);
$this->assertCount(10, app(DemographicEvidence::class)->territories()->where('source_system', 'ine-demography')->get());
foreach (['Chongoene','Larde','Chimbonila','Doa','KaMphumu','Beira'] as $name) {
$literal = $name === 'KaMphumu' ? 'DISTRITO URBANO 1' : ($name === 'Beira' ? 'CIDADE DA BEIRA' : $name);
$territory = app(DemographicEvidence::class)->territories()->whereRaw('lower(name)=?', [mb_strtolower($literal)])->value('id');
$this->assertNotNull($territory, $name);
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$territory],2017,2050,'general')['state']);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographicZambeziaTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->assertMatchesRegularExpression('/^demography_restore_\d{8}_\d{6}_test$/', DB::connection()->getDatabaseName());
}
private function manager(): User
{
$actor = User::factory()->create();
$actor->forceFill(['two_factor_secret' => Crypt::encrypt('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$second = User::factory()->create();
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $actor->id, 'second_user_id' => $second->id]);
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
return $actor;
}
public function test_new_cohort_requires_current_session_mfa(): void
{
$this->manager();
$this->postJson('/management/demography/install', ['mode' => 'zambezia', 'hash' => str_repeat('0', 64), 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic fresh session test.'])->assertForbidden();
$this->assertSame(793772, DB::table('demographic_observations')->count());
}
public function test_only_zambezia_is_added_and_all_previous_imports_are_preserved(): void
{
$actor = $this->manager();
$this->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time()]);
$this->assertSame(11, DB::table('demographic_imports')->where('status', 'approved')->count());
$oldIds = DB::table('demographic_imports')->pluck('id');
$oldImports = DB::table('demographic_imports')->orderBy('id')->get()->toJson();
$oldEvents = DB::table('demographic_events')->orderBy('id')->get()->toJson();
$service = app(DemographicInstallation::class);
$plan = $service->preview($actor);
$this->assertSame('zambezia', $plan['mode']);
$this->assertSame(1, $plan['pending_count']);
$this->assertCount(1, $plan['files']);
$this->assertCount(5, $plan['new_districts']);
$body = ['mode' => 'zambezia', 'hash' => $plan['hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Incorporação de Zambezia.xlsx autorizada e cinco distritos;ensaio apenas na cópia restaurada.'];
$result = $service->apply($actor, $body);
$this->assertSame(126684, $result['record_count']);
$this->assertCount(1, $result['imports']);
$this->assertSame(12, DB::table('demographic_imports')->where('status', 'approved')->count());
$this->assertSame(920456, DB::table('demographic_observations')->count());
$this->assertSame($oldImports, DB::table('demographic_imports')->whereIn('id', $oldIds)->orderBy('id')->get()->toJson());
$this->assertSame($oldEvents, DB::table('demographic_events')->whereIn('import_id', $oldIds)->orderBy('id')->get()->toJson());
$this->assertTrue($service->apply($actor, $body)['repeated']);
$this->assertSame(920456, DB::table('demographic_observations')->count());
$this->assertFalse($service->preview($actor)['ready']);
$this->assertSame(0, $service->preview($actor)['pending_count']);
foreach (['Derre', 'Luabo', 'Mocubela', 'Molumbo', 'Mulevala'] as $name) {
$ids = app(DemographicEvidence::class)->territories()->where('name', $name)->pluck('id')->all();
$this->assertCount(1, $ids);
$this->assertSame('available', app(DemographicEvidence::class)->analyse($ids, 2017, 2050)['state']);
}
$this->assertDatabaseHas('audit_events', ['source_system' => 'demography-installation', 'action' => 'extend_zambezia', 'correlation_id' => $body['operation_key']]);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\InvestmentPreparationWorkspace;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
final class DemographyTest extends TestCase
{
use DatabaseTransactions;
private string $province;
private string $district;
private string $batch;
private User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->manager = User::factory()->create();
$this->manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC QA ONLY'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$this->manager->email]]);
$country = (string) Str::uuid();
$this->province = (string) Str::uuid();
$this->district = (string) Str::uuid();
foreach ([[$country, null, 'country'], [$this->province, $country, 'province'], [$this->district, $this->province, 'district']] as [$id, $parent, $type]) {
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id, 'data_classification' => 'public']);
}
$this->batch = $this->batch('approved');
foreach ([$this->province, $this->district] as $id) {
foreach ([2026 => '100.125', 2027 => '110.250'] as $year => $value) {
DB::table('demographic_observations')->insert(['import_id' => $this->batch, 'geo_location_id' => $id, 'year' => $year, 'indicator' => 'population',
'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => $value, 'numeric_value' => $value, 'sheet' => (string) $year, 'cell' => 'B4', 'validation_status' => 'valid']);
}
}
}
private function batch(string $status): string
{
$id = (string) Str::uuid();
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => hash('sha256', $id), 'file_name' => 'SYNTHETIC-QA.xlsx', 'source' => 'SYNTHETIC TEST ONLY',
'publication_version' => 'synthetic/1', 'status' => $status, 'report' => json_encode(['record_count' => 4, 'territories' => [], 'issue_counts' => []]),
'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $this->manager->id, 'created_at' => now(), 'updated_at' => now()]);
return $id;
}
public function test_public_comparison_preserves_decimals_versions_and_missing_years(): void
{
$response = $this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->district], 'start_year' => 2026, 'end_year' => 2028, 'sector' => 'industry']))->assertOk();
$response->assertJsonPath('state', 'available')->assertJsonCount(2, 'territories')
->assertJsonPath('territories.0.series.0.values.0.original_value', '100.125')
->assertJsonPath('territories.0.series.0.missing_years', [2028]);
$this->assertStringNotContainsString('probability', $response->getContent());
}
public function test_only_approved_series_and_public_territories_are_exposed(): void
{
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'disabled']);
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2027)['state']);
DB::table('demographic_imports')->where('id', $this->batch)->update(['status' => 'approved']);
DB::table('geo_locations')->where('id', $this->province)->update(['data_classification' => 'internal']);
$this->assertSame([], app(DemographicEvidence::class)->analyse([$this->district], 2026, 2027)['territories']);
}
public function test_new_editions_remain_separate_and_divergence_is_reported(): void
{
$other = $this->batch('approved');
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->where('geo_location_id', $this->province)->where('year', 2026)->first();
unset($row['id']);
DB::table('demographic_observations')->insert([...$row, 'import_id' => $other, 'numeric_value' => '101.125', 'original_value' => '101.125']);
$analysis = app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026);
$this->assertCount(2, $analysis['territories'][0]['series']);
$this->assertSame('divergent_values', $analysis['differences'][0]['state']);
$this->assertCount(1, app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026, 'general', $other)['territories'][0]['series']);
}
public function test_permissions_mfa_and_qa_guard_are_enforced(): void
{
$this->get('/management/demography')->assertRedirect();
$this->actingAs(User::factory()->create())->getJson('/management/demography')->assertForbidden();
$this->manager->forceFill(['two_factor_confirmed_at' => null])->save();
$this->actingAs($this->manager)->getJson('/management/demography')->assertForbidden();
$this->manager->forceFill(['two_factor_confirmed_at' => now()])->save();
config(['database.connections.pgsql.port' => '5432']);
$this->getJson('/management/demography')->assertForbidden();
}
public function test_confirmation_is_idempotent_and_status_changes_preserve_observations(): void
{
$service = app(DemographicImport::class);
$sha = DB::table('demographic_imports')->where('id', $this->batch)->value('sha256');
$receipt = $service->confirm($this->manager, $this->batch, $sha, [], 'Synthetic replay test only.');
$this->assertTrue($receipt['repeated']);
$this->assertSame(4, $receipt['records']);
$service->transition($this->manager, $this->batch, 'disabled', 'Synthetic deactivate test only.');
$this->assertSame(4, DB::table('demographic_observations')->where('import_id', $this->batch)->count());
$this->assertSame('no_data', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
app(DemographicImport::class)->transition($this->manager, $this->batch, 'approved', 'Synthetic restore test only.');
$this->assertSame('available', app(DemographicEvidence::class)->analyse([$this->province], 2026, 2026)['state']);
$this->assertSame(2, DB::table('demographic_events')->where('import_id', $this->batch)->count());
}
public function test_invalid_period_and_duplicate_territory_are_rejected(): void
{
$this->getJson('/api/v1/investment/demography?'.http_build_query(['territories' => [$this->province, $this->province]]))->assertUnprocessable();
$this->getJson('/api/v1/investment/demography?start_year=2050&end_year=2017')->assertUnprocessable();
$this->getJson('/api/v1/investment/demography?start_year=2000&end_year=2100')->assertUnprocessable();
}
public function test_same_level_comparison_computes_exact_difference_and_explains_it(): void
{
$id = (string) Str::uuid();
$location = (array) DB::table('geo_locations')->where('id', $this->district)->first();
DB::table('geo_locations')->insert([...$location, 'id' => $id, 'name' => 'SYNTHETIC second district', 'code' => $id, 'external_id' => $location['external_id'].'-second']);
$row = (array) DB::table('demographic_observations')->where('geo_location_id', $this->district)->where('year', 2026)->first();
unset($row['id']);
DB::table('demographic_observations')->insert([...$row, 'geo_location_id' => $id, 'numeric_value' => '80.025', 'original_value' => '80.025']);
$analysis = app(DemographicEvidence::class)->analyse([$this->district, $id], 2026, 2026, 'industry');
$this->assertCount(1, $analysis['comparisons']);
$this->assertSame('20.100', ltrim($analysis['comparisons'][0]['difference'], '-'));
$this->assertStringContainsString('Não mede procura efectiva', $analysis['comparisons'][0]['explanation']);
}
public function test_evidence_reuses_context_without_modifying_it(): void
{
$context = ['fields' => ['province_id' => $this->province, 'district_id' => $this->district]];
$before = $context;
$analysis = app(DemographicEvidence::class)->forContext($context);
$this->assertSame('available', $analysis['state']);
$this->assertSame($this->district, $analysis['territories'][0]['id']);
$this->assertSame($before, $context);
$fallback = app(DemographicEvidence::class)->forContext(['fields' => ['province_id' => $this->province, 'district_id' => '']]);
$this->assertSame($this->province, $fallback['territories'][0]['id']);
$method = new \ReflectionMethod(InvestmentPreparationWorkspace::class, 'sources');
[, $sources] = $method->invoke(app(InvestmentPreparationWorkspace::class), $this->manager, (object) ['id' => (string) Str::uuid(), 'title' => 'Synthetic QA investment'], $context, '');
$source = collect($sources)->firstWhere('key', 'demography');
$this->assertSame('available', $source['current']['state']);
}
public function test_preview_or_original_tampering_prevents_import(): void
{
$prepared = $this->batch('prepared');
Storage::fake('local');
Storage::disk('local')->put('demography/'.$prepared.'/preview.json', '{}');
$sha = DB::table('demographic_imports')->where('id', $prepared)->value('sha256');
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic integrity test only.'])->assertConflict();
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
}
public function test_stream_integrity_and_count_are_verified_before_import(): void
{
Storage::fake('local');
foreach (['tampered', 'wrong-count'] as $case) {
$prepared = $this->batch('prepared');
$directory = 'demography/'.$prepared;
$original = 'SYNTHETIC NDJSON integrity '.$prepared;
$sha = hash('sha256', $original);
$stream = $case === 'tampered' ? 'changed' : '';
$summary = ['record_count' => 1, 'records_file_sha256' => hash('sha256', ''), 'territories' => [], 'issues' => [], 'issue_counts' => []];
$preview = json_encode($summary, JSON_THROW_ON_ERROR);
Storage::disk('local')->put($directory.'/original.xlsx', $original);
Storage::disk('local')->put($directory.'/preview.json', $preview);
Storage::disk('local')->put($directory.'/records.ndjson', $stream);
DB::table('demographic_imports')->where('id', $prepared)->update(['sha256' => $sha, 'preview_sha256' => hash('sha256', $preview), 'report' => $preview]);
$this->actingAs($this->manager)->postJson('/management/demography/'.$prepared.'/confirm', ['sha256' => $sha, 'mapping' => [], 'reason' => 'Synthetic stream integrity and count test.'])->assertConflict();
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $prepared)->count());
$this->assertSame('prepared', DB::table('demographic_imports')->where('id', $prepared)->value('status'));
}
}
public function test_database_rejects_duplicate_observation_dimensions(): void
{
$row = (array) DB::table('demographic_observations')->where('import_id', $this->batch)->first();
unset($row['id']);
$this->expectException(QueryException::class);
DB::table('demographic_observations')->insert($row);
}
public function test_rollback_refuses_to_delete_imported_history(): void
{
$migration = require database_path('migrations/2026_11_13_100000_create_demographic_series.php');
$this->expectException(\RuntimeException::class);
$migration->down();
}
public function test_cli_preparation_imports_without_process_execution_and_keeps_exact_values(): void
{
Storage::fake('local');
$disk = Storage::disk('local');
$original = 'SYNTHETIC prepared original'; $sha = hash('sha256', $original);
$directory = 'prepared/'.$sha; $disk->makeDirectory($directory);
$disk->put('synthetic.xlsx', $original);
$row = ['province' => 'SYNTHETIC province', 'territory' => 'SYNTHETIC province', 'level' => 'province',
'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas',
'data_kind' => 'projected', 'original_value' => '100.125', 'numeric_value' => '100.125', 'sheet' => '2026', 'cell' => 'B4'];
$records = json_encode($row, JSON_THROW_ON_ERROR)."\n";
$report = ['sha256' => $sha, 'source' => 'SYNTHETIC QA ONLY', 'record_count' => 1, 'records_file_sha256' => hash('sha256', $records),
'territories' => [['SYNTHETIC province', 'SYNTHETIC province', 'province']], 'issues' => [], 'issue_counts' => []];
$preview = json_encode($report, JSON_THROW_ON_ERROR);
$receipt = ['sha256' => $sha, 'baseline_year' => null, 'parser_sha256' => hash_file('sha256', base_path('tools/demography_workbook.py')),
'preview_sha256' => hash('sha256', $preview), 'records_sha256' => hash('sha256', $records), 'record_count' => 1];
$disk->put($directory.'/preview.json', $preview); $disk->put($directory.'/records.ndjson', $records);
$disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
config(['demography.prepared_directory' => $disk->path('prepared'), 'demography.python' => '/missing-python-must-not-run']);
$this->mock(\App\Services\Malware\MalwareScanner::class)->shouldReceive('scan')->once()->andReturn([
'result' => 'clean', 'engine' => 'synthetic', 'engine_version' => 'test']);
$service = app(DemographicImport::class);
$this->assertTrue($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
$result = $service->prepare($this->manager, $disk->path('synthetic.xlsx'), 'synthetic.xlsx', 'SYNTHETIC', null);
$this->assertSame(0, DB::table('demographic_observations')->where('import_id', $result['id'])->count());
$service->confirm($this->manager, $result['id'], $sha, [], 'Synthetic authenticated import from CLI preparation.');
$this->assertSame('100.125', DB::table('demographic_observations')->where('import_id', $result['id'])->value('numeric_value'));
$this->assertSame('imported', DB::table('demographic_imports')->where('id', $result['id'])->value('status'));
$disk->put($directory.'/records.ndjson', 'TAMPERED');
try { $service->preparedSource($sha, null); $this->fail('Changed prepared records must be refused.'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(409, $error->getStatusCode()); }
$receipt['parser_sha256'] = str_repeat('0', 64); $disk->put($directory.'/preparation.json', json_encode($receipt, JSON_THROW_ON_ERROR));
$this->assertFalse($service->preparedSourcesReady(['synthetic.xlsx' => ['sha256' => $sha, 'records' => 1]]));
}
public function test_remote_consultation_requires_explicit_activation_and_preserves_versions(): void
{
$environment = app()->environment();
try {
app()->instance('env', 'production');
config(['demography.enabled' => false]);
$service = app(DemographicEvidence::class);
$this->assertSame('unavailable', $service->analyse([$this->province], 2026, 2026)['state']);
config(['demography.enabled' => true]);
$result = $service->analyse([$this->province], 2026, 2026);
$this->assertSame('available', $result['state']);
$this->assertSame($this->batch, $result['territories'][0]['series'][0]['import_id']);
} finally {
app()->instance('env', $environment);
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Models\DevelopmentCorridor;
use App\Models\InvestmentDossier;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class GlobalSearchCountTest extends TestCase
{
use DatabaseTransactions;
public function test_licensing_question_opens_existing_guidance_without_mixing_catalogue_matches(): void
{
$this->withoutVite();
$this->get('/pesquisa?'.http_build_query(['q' => 'como licenciar uma empresa']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'guide')->where('results.total', 2)
->where('results.data.0.url', '/como-investir#planeamento')
->where('results.data.1.url', '/investimentos/iniciar?step=S08'));
}
public function test_physical_product_search_keeps_the_product_and_documented_location_in_its_link(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
config(['portal.legacy_content_enabled' => false, 'portal.legacy_content_qa_enabled' => false]);
$sector = Sector::firstOrCreate(['slug' => 'mineracao'], ['name' => 'Mineração']);
$sector->forceFill(['is_active' => true, 'published_at' => now()->subMinute()])->save();
$this->get('/pesquisa?'.http_build_query(['q' => 'onde encontrar grafite em Cabo Delgado']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
->where('results.data.0.title', 'Grafite')->where('results.data.0.category', 'Produtos e localizações')
->where('results.data.0.location', fn ($text) => str_contains($text, 'Cabo Delgado'))
->where('results.data.0.url', fn ($url) => str_contains($url, 'resource_group=') && str_contains($url, 'resource_location=Cabo+Delgado')));
$this->get('/pesquisa?'.http_build_query(['q' => 'grafite Cabo Delgado', 'category' => 'physical']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.category', 'physical')->where('results.total', 1)
->where('results.data.0.category', 'Produtos e localizações'));
$this->get('/pesquisa?category=invalid')->assertRedirect();
}
public function test_financial_product_search_excludes_drafts_and_future_publications_and_distinguishes_coverage(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
if (! Schema::hasTable('funding_programmes')) {
// Only this transaction in the isolated QA database; no active schema changes.
(require database_path('migrations/2026_11_02_210000_funding_programme_catalogue.php'))->up();
}
$actor = User::factory()->create();
$marker = 'SYNTHFIN'.Str::random(8);
foreach (['published', 'draft', 'future', 'historical', 'merged'] as $state) {
$id = (string) Str::uuid();
$version = (string) Str::uuid();
$payload = json_encode(['title' => $marker.' '.$state, 'kind' => 'institution', 'summary' => 'Nota de preparação: ficha e documento gerador.',
'institution_nature' => 'bank', 'institution_services' => ['investment'],
'institution_status' => in_array($state, ['historical', 'merged'], true) ? $state : 'reported',
'locations' => [['location_text' => 'Nampula']], 'products' => [['title' => 'Crédito PME', 'coverage' => 'national', 'needs' => ['investment']]]], JSON_THROW_ON_ERROR);
DB::table('funding_programmes')->insert(['id' => $id, 'code' => $marker.$state, 'draft' => $payload, 'created_by' => $actor->id]);
DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => 1, 'payload' => $payload,
'reason' => 'Synthetic QA only', 'actor_id' => $actor->id, 'published_at' => $state === 'future' ? now()->addDay() : now()->subMinute()]);
if ($state !== 'draft') {
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version]);
}
}
$this->get('/pesquisa?'.http_build_query(['q' => 'produtos financeiros '.$marker.' em Nampula']))->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
->where('results.data.0.category', 'Produtos financeiros')->where('results.data.0.description', 'Crédito PME')
->where('results.data.0.location', 'Presença física: Nampula')
->where('results.data.0.coverage', fn ($text) => str_contains($text, 'nacional')));
}
public function test_corridor_count_excludes_other_types_drafts_and_deleted_entities(): void
{
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
$marker = 'SYNTHCOUNT'.Str::random(10);
foreach (range(1, 4) as $n) {
$corridor = DevelopmentCorridor::create(['code' => $marker.$n, 'name' => $marker.$n, 'status' => 'active', 'data_classification' => 'public']);
InvestmentDossier::create(['corridor_id' => $corridor->id, 'title' => $marker.' '.$n,
'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => $n === 3 ? 'draft' : 'published',
'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
if ($n === 4) {
$corridor->delete();
}
}
InvestmentDossier::create(['project_id' => Project::factory()->create()->id, 'title' => $marker.' corredor relacionado',
'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => 'published', 'published_at' => now(),
'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
$query = 'quantos corredores existem '.$marker;
$this->get('/pesquisa?'.http_build_query(['q' => $query]))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('portal/search')->where('query', $query)->where('answer.count', 2)->where('answer.label', 'corredores')
->where('results.total', 2)->has('results.data', 2)->where('results.unavailable', []));
$this->get('/pesquisa?'.http_build_query(['q' => 'quantos corredores existem '.$marker.' inexistente']))
->assertOk()->assertInertia(fn (Assert $page) => $page->where('answer.count', 0)->where('results.total', 0));
$this->get('/pesquisa?q=quantos+corredores+existem')->assertOk()->assertInertia(fn (Assert $page) => $page
->where('answer.label', 'corredores')->where('answer.count', fn ($count) => $count >= 2)
->where('results.unavailable', []));
}
}
+13
View File
@@ -7,6 +7,7 @@ use App\Services\FederatedSearch;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class GlobalSearchTest extends TestCase
@@ -35,6 +36,18 @@ final class GlobalSearchTest extends TestCase
$this->assertSame('Como investir', $result['data'][0]['title']);
}
public function test_unavailable_public_reference_does_not_hide_other_sources_or_swallow_forbidden_access(): void
{
$result = app(FederatedSearch::class)->page([
'Referências' => fn () => throw new HttpException(503),
'Páginas' => fn () => ['count' => 1, 'fetch' => fn () => [['title' => 'Como investir', 'url' => '/como-investir']]],
], 1);
$this->assertSame(1, $result['total']);
$this->assertSame(['Referências'], $result['unavailable']);
$this->expectException(HttpException::class);
app(FederatedSearch::class)->page(['Restrito' => fn () => throw new HttpException(403)], 1);
}
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
{
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentZone;
use App\Models\User;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneIntegralDetails;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalProfile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class InvestmentObservationNatureTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
Http::preventStrayRequests();
}
private function observation(?string $kind): array
{
$item = ['id' => (string) Str::uuid(), 'label' => 'SYNTHETIC nature QA', 'description' => 'Synthetic source only.',
'indicator' => 'employment', 'value' => '0', 'unit' => 'pessoas', 'period_start' => '2025-01-01', 'period_end' => '2025-12-31',
'scope' => 'Synthetic zone', 'population' => 'Synthetic establishments', 'methodology' => 'Synthetic register',
'source_reference' => 'SYNTHETIC source; not real statistics', 'source_date' => '2026-01-15', 'knowledge' => 'documented', 'disclosure' => true];
return $kind === null ? $item : [...$item, 'measure_kind' => $kind];
}
private function zone(): InvestmentZone
{
$actor = User::factory()->create(['email' => 'measure-'.Str::uuid().'@example.test']);
return InvestmentZone::create(['code' => 'MEASURE-QA-'.Str::uuid(), 'category' => 'industrial_park', 'name' => 'SYNTHETIC nature QA',
'editor_id' => $actor->id, 'publication_status' => 'draft', 'validation_status' => 'pending']);
}
public function test_explicit_natures_persist_recover_and_project_without_inference_or_private_disclosure(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$ops = app(InvestmentZoneOperationalDetails::class);
$rows = array_map(fn ($kind) => $this->observation($kind), ['observed', 'planned', 'projected', null]);
$hidden = [...$this->observation('observed'), 'disclosure' => false, 'source_reference' => 'PRIVATE-NATURE-SOURCE'];
$merged = $service->merge($zone, [], ['observations' => [...$rows, $hidden]]);
$zone->forceFill(['operational_details' => $ops->pack(['integral' => $merged])])->save();
$saved = $zone->fresh();
$before = $saved->getRawOriginal('operational_details');
$recovered = $ops->recover($saved)['integral']['observations'];
$this->assertSame(['observed', 'planned', 'projected'], array_column(array_slice($recovered, 0, 3), 'measure_kind'));
$this->assertArrayNotHasKey('measure_kind', $recovered[3]);
$this->assertSame('0', $recovered[0]['value']);
$view = app(InvestmentMapIndicators::class)->forZones(collect([$saved]));
$this->assertSame(['observed', 'planned', 'projected', 'unknown'], array_column($view['records'], 'measure_kind'));
$this->assertSame(['0', '0', '0', '0'], array_column($view['records'], 'value'));
$this->assertStringNotContainsString('PRIVATE-NATURE-SOURCE', json_encode($view, JSON_THROW_ON_ERROR));
$this->assertSame($before, $zone->fresh()->getRawOriginal('operational_details'));
$this->assertSame(1, $zone->fresh()->version_no);
}
public function test_invalid_nature_is_rejected_and_an_omitted_nature_does_not_erase_an_explicit_choice(): void
{
$zone = $this->zone();
$service = app(InvestmentZoneIntegralDetails::class);
$row = $this->observation('observed');
$before = $service->merge($zone, [], ['observations' => [$row]]);
$withoutKind = $row;
unset($withoutKind['measure_kind']);
$recovered = $service->merge($zone, $before, ['observations' => [$withoutKind]]);
$this->assertSame('observed', $recovered['observations'][0]['measure_kind']);
$changed = $service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'planned']]]);
$this->assertSame('planned', $changed['observations'][0]['measure_kind']);
$this->assertSame('observed', $before['observations'][0]['measure_kind']);
try {
$service->merge($zone, $before, ['observations' => [[...$row, 'measure_kind' => 'inferred_from_description']]]);
$this->fail('Invalid nature accepted');
} catch (ValidationException $error) {
$this->assertArrayHasKey('observations.0.measure_kind', $error->errors());
}
}
public function test_nature_change_stays_in_private_preparation_with_history_until_existing_editorial_review(): void
{
$zone = $this->zone();
$ops = app(InvestmentZoneOperationalDetails::class);
$row = $this->observation('observed');
$fields = app(InvestmentZoneIntegralDetails::class)->merge($zone, [], ['observations' => [$row]]);
// Synthetic public snapshot only, inside the rolled-back QA transaction.
$zone->forceFill(['publication_status' => 'published', 'validation_status' => 'validated',
'reviewer_id' => User::factory()->create(['email' => 'nature-review-'.Str::uuid().'@example.test'])->id,
'published_at' => now(), 'physical_category' => 'industrial_park',
'source_evidence' => [['source_type' => 'promoter', 'title' => 'SYNTHETIC QA only', 'url' => 'https://example.invalid/qa', 'supports' => 'Synthetic test fixture']],
'operational_details' => $ops->pack(['integral' => $fields])])->save();
$original = $zone->fresh()->getRawOriginal('operational_details');
$profile = app(InvestmentZoneOperationalProfile::class);
$prepared = $profile->prepare($zone->fresh(), ['version_no' => 1,
'operational_details' => ['integral' => ['observations' => [[...$row, 'measure_kind' => 'planned']]]]], $zone->editor_id);
$this->assertSame($original, $prepared->getRawOriginal('operational_details'));
$this->assertSame('observed', $ops->publicData($prepared)['integral']['observations'][0]['measure_kind']);
$this->assertSame('planned', $profile->draft($prepared)['fields']['operational_details']['integral']['observations'][0]['measure_kind']);
$this->assertSame(1, $prepared->version_no);
$this->assertSame(1, DB::table('investment_zone_revisions')->where('zone_id', $zone->id)->where('action', 'profile_prepared')->count());
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $zone->id)->where('action', 'profile_prepared')->count());
}
}
@@ -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,50 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPecLastAmountDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('investmoz_demography_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
$this->actingAs($actor);
$id = null;
$etag = null;
foreach (['documented' => 138, 'missing' => 138, 'positive' => 138, 'excess' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-pec-last-amount-20261113/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame('42500.25', $book['pecObligation']['lastAmount']['amount']);
$this->assertSame('2024', $book['pecObligation']['lastAmount']['year']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-pec-last-amount-20261113/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests\Unit;
use App\Services\DemographicInstallation;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class DemographicTargetTest extends TestCase
{
public function test_homologation_requires_activation_exact_url_and_actual_database_identity(): void
{
app()->instance('env', 'production');
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'demography.enabled' => true,
'database.default' => 'pgsql', 'database.connections.pgsql.host' => '127.0.0.1',
'database.connections.pgsql.port' => 5432, 'database.connections.pgsql.database' => 'teste_investidor',
'database.connections.pgsql.url' => null]);
DB::shouldReceive('selectOne')->twice()->with('SELECT current_database() AS database,inet_server_port() AS port')
->andReturn((object) ['database' => 'teste_investidor', 'port' => 5432], (object) ['database' => 'other_database', 'port' => 5432]);
$service = app(DemographicInstallation::class);
$this->assertTrue($service->activeTarget());
$this->assertFalse($service->activeTarget());
config(['demography.enabled' => false]);
$this->assertFalse($service->activeTarget());
config(['demography.enabled' => true, 'app.url' => 'https://other.example']);
$this->assertFalse($service->activeTarget());
config(['app.url' => 'https://teste-investidor.tempo.co.mz', 'database.connections.pgsql.host' => 'remote.example']);
$this->assertFalse($service->activeTarget());
}
}
+27
View File
@@ -9,6 +9,33 @@ use PHPUnit\Framework\TestCase;
final class SearchTermsTest extends TestCase
{
public function test_count_questions_select_a_category_instead_of_searching_question_words(): void
{
$search = new SearchTerms;
$intent = $search->countIntent('Quantos corredores existem?');
$this->assertSame('corridor', $intent['kind']);
$this->assertSame('', $intent['filter']);
$this->assertSame('corredores', $intent['label']);
$this->assertSame('Oportunidades', $search->countIntent('Quantas oportunidades existem no portal?')['category']);
$this->assertSame('em nampula', $search->countIntent('Quantos corredores existem em Nampula?')['filter']);
$this->assertSame('cluster', $search->countIntent('Qual é o número de clusters disponíveis?')['kind']);
$this->assertNull($search->countIntent('oportunidades nos corredores'));
$this->assertNull($search->countIntent('documentos sobre quantos corredores existem'));
}
public function test_investor_requests_distinguish_products_guidance_and_lists(): void
{
$search = new SearchTerms;
$this->assertSame('guide', $search->requestIntent('como licenciar uma empresa')['type']);
$this->assertSame('guide', $search->requestIntent('como faço para licenciar a empresa em Nampula?')['type']);
$this->assertNull($search->requestIntent('como licenciar software'));
$this->assertSame('list', $search->requestIntent('quais são os corredores existentes')['type']);
$this->assertSame('Produtos financeiros', $search->requestIntent('produtos financeiros em Nampula')['category']);
$this->assertNull($search->requestIntent('tipo de produtos e sua localização')['category']);
$this->assertSame('', $search->requestIntent('tipo de produtos e sua localização')['filter']);
$this->assertSame('grafite em cabo delgado', $search->requestIntent('onde encontrar grafite em Cabo Delgado?')['filter']);
}
public function test_matches_all_words_in_any_order_without_case_or_accents(): void
{
$search = new SearchTerms;
+109
View File
@@ -0,0 +1,109 @@
"""Run: python -m unittest discover -s tests -p demography_workbook_test.py"""
from decimal import Decimal
from pathlib import Path
import sys
import tempfile
import unittest
from zipfile import ZipFile
from xml.sax.saxutils import escape
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
from demography_workbook import age_group, parse
def workbook(path, rows):
cells = []
for number, row in enumerate(rows, 1):
content = ''
for column, value in row.items():
reference = f'{column}{number}'
content += f'<c r="{reference}" t="inlineStr"><is><t>{escape(value)}</t></is></c>'
cells.append(f'<row r="{number}">{content}</row>')
with ZipFile(path, 'w') as archive:
archive.writestr('xl/workbook.xml', '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="2017" sheetId="1" r:id="r1"/></sheets></workbook>')
archive.writestr('xl/_rels/workbook.xml.rels', '<Relationships><Relationship Id="r1" Target="worksheets/custom.xml"/></Relationships>')
archive.writestr('xl/worksheets/custom.xml', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' + ''.join(cells) + '</sheetData></worksheet>')
class WorkbookTest(unittest.TestCase):
def fixture(self, rows):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'synthetic.xlsx'
workbook(path, rows)
return parse(path)
def rows(self):
return [dict(A='QUADRO 1. População projectada por área de residência e sexo segundo idade. Manica, 2017'),
dict(A='Idade', C='TOTAL', F='URBANA', I='RURAL'),
dict(C='Total', D='Homens', E='Mulheres', F='Total', G='Homens', H='Mulheres', I='Total', J='Homens', K='Mulheres'),
dict(A='Total', C='100.125', D='40.025', E='60.100', F='40.025', G='20', H='20.025', I='60.100', J='20.025', K='40.075')]
def test_shifted_headers_exact_decimals_and_no_header_as_age_row(self):
report = self.fixture(self.rows())
self.assertEqual(9, report['record_count'])
self.assertEqual({}, report['issue_counts'])
self.assertEqual('100.125', report['records'][0]['original_value'])
self.assertEqual(Decimal('100.125'), Decimal(report['records'][0]['numeric_value']))
def test_conflicting_duplicates_are_reported(self):
rows = self.rows()
rows.append({**rows[-1], 'C': '200'})
self.assertEqual(1, self.fixture(rows)['issue_counts']['conflicting_value'])
def test_incomplete_cells_never_become_zero(self):
rows = self.rows()
del rows[-1]['K']
report = self.fixture(rows)
self.assertEqual(1, report['issue_counts']['missing_value'])
self.assertEqual(8, report['record_count'])
def test_sex_inconsistency_detected(self):
rows = self.rows()
rows[-1]['C'] = '999'
self.assertEqual(1, self.fixture(rows)['issue_counts']['sex_total_mismatch'])
def test_unknown_workbook_rejected(self):
with self.assertRaises(ValueError):
self.fixture([dict(A='unrecognised data')])
def test_city_da_and_municipal_headers_do_not_become_second_provinces(self):
for title, expected in [('Cidade da Matola', 'Matola'), ('Distrito Municipal KaMphumu', 'KaMphumu')]:
with self.subTest(title=title):
rows = self.rows()
rows += [dict(A='QUADRO 2. População projectada por área de residência e sexo segundo idade. '+title+', 2017'), rows[-1].copy()]
report = self.fixture(rows)
district = [r for r in report['records'] if r['level'] == 'district']
self.assertEqual(9, len(district))
self.assertEqual(expected, district[0]['territory'])
self.assertEqual('Manica', district[0]['province'])
self.assertEqual([title], report['territory_headers']['Manica|'+expected+'|district'])
def test_formula_uses_only_the_original_numeric_cache_and_reports_provenance(self):
for cache in ['100.125', '']:
with self.subTest(cache=cache), tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'cached.xlsx'
workbook(path, self.rows())
with ZipFile(path) as source:
members = {n: source.read(n) for n in source.namelist()}
xml = members['xl/worksheets/custom.xml'].decode()
xml = xml.replace('<c r="C4" t="inlineStr"><is><t>100.125</t></is></c>', '<c r="C4"><f>SUM(D4:E4)</f><v>'+cache+'</v></c>')
members['xl/worksheets/custom.xml'] = xml.encode()
with ZipFile(path, 'w') as target:
for name, content in members.items(): target.writestr(name, content)
report = parse(path)
if cache:
self.assertEqual('100.125', report['records'][0]['original_value'])
self.assertEqual(1, report['issue_counts']['cached_formula_value'])
self.assertNotIn('invalid_numeric_or_formula', report['issue_counts'])
self.assertEqual('SUM(D4:E4)', report['issues'][0]['formula'])
else:
self.assertEqual(1, report['issue_counts']['invalid_numeric_or_formula'])
self.assertEqual(8, report['record_count'])
def test_age_labels(self):
self.assertEqual('15-64', age_group('População em idade activa (15–64 anos)'))
self.assertEqual('65+', age_group('População idosa (65 ou mais anos)'))
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -35,6 +35,53 @@ new Function('require', 'module', 'exports', compiled)(
);
const Search = module.exports.default;
test('product categories preserve the location filter and pagination even without a query', () => {
const results = { data: [{ title: 'Grafite', description: '', category: 'Produtos e localizações', url: '/sectores/mineracao' }], total: 6, current_page: 1, last_page: 2, per_page: 5, unavailable: [] };
const html = renderToStaticMarkup(React.createElement(Search, {
query: 'Nampula', results,
answer: { type: 'products', label: 'Produtos e localização', count: 6, filter: 'Nampula', category: 'physical' },
}));
assert.match(html, /q=Nampula&amp;category=financial/);
assert.match(html, /q=Nampula&amp;page=2&amp;category=physical/);
assert.match(html, /name="category" value="physical"/);
const all = renderToStaticMarkup(React.createElement(Search, {
query: '', results, answer: { type: 'products', label: 'Produtos e localização', count: 6, filter: '', category: 'all' },
}));
assert.match(all, /href="\/sectores\/mineracao"/);
});
test('count answer is explicit and zero is distinct from an unavailable count', () => {
const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: [] };
const html = renderToStaticMarkup(React.createElement(Search, {
query: 'quantos corredores existem', results,
answer: { type: 'count', label: 'corredores', count: 0, filter: '' },
}));
assert.match(html, /0 corredores/);
assert.match(html, /registos publicados/);
const failed = renderToStaticMarkup(React.createElement(Search, {
query: 'quantos corredores existem', results,
answer: { type: 'count', label: 'corredores', count: null, filter: '' },
}));
assert.match(failed, /Contagem indisponível/);
assert.doesNotMatch(failed, /0 corredores/);
});
test('products show their location separately from financial coverage and guidance has no invented count', () => {
const results = { data: [{ title: 'Banco de ensaio', description: 'Crédito PME', location: 'Presença física: Maputo', coverage: 'Cobertura nacional', category: 'Produtos financeiros', url: '/fundos/qa' }], total: 1, current_page: 1, last_page: 1, per_page: 5, unavailable: [] };
const html = renderToStaticMarkup(React.createElement(Search, {
query: 'produtos financeiros', results, answer: { type: 'products', label: 'Produtos e localização', count: 1, filter: '' },
}));
assert.match(html, /Produtos financeiros/);
assert.match(html, /Presença física: Maputo/);
assert.match(html, /Cobertura nacional/);
assert.match(html, /não determina a cobertura/);
const guide = renderToStaticMarkup(React.createElement(Search, {
query: 'como licenciar empresa', results: { ...results, data: [] }, answer: { type: 'guide', label: 'Licenciamento da empresa', count: 2, filter: 'licenciamento' },
}));
assert.match(guide, /Licenciamento da empresa/);
assert.doesNotMatch(guide, /2 Licenciamento/);
});
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
const results = {
data: [
@@ -29,6 +29,8 @@ compileFunction(compiled, ['require', 'module', 'exports'])(
);
const Component = module.exports.default;
const filterIndicators = module.exports.filterMapIndicators;
const compareObservations = module.exports.compareMapObservations;
const Comparison = module.exports.MapObservationComparison;
const row = {
id: '1',
zone_id: 'park-a',
@@ -219,3 +221,170 @@ test('quantitative filters are labelled, scoped and explain source date and cove
assert.match(html, /não alteram oportunidades ou limites de cobertura/);
assert.match(html, /Limpar filtros dos indicadores/);
});
test('comparison delta: metadata alignment never manufactures a trend or changes exact source values', () => {
const first = {
...row,
description: 'Emprego observado na fonte sintética.',
measure_kind: 'observed',
value: '1234567890123.456789',
};
const second = {
...first,
id: 'next-period',
period_start: '2026-01-01',
period_end: '2026-12-31',
value: '0',
};
const before = JSON.stringify([first, second]);
assert.equal(compareObservations(first, second).state, 'metadata_aligned');
const html = renderToStaticMarkup(
React.createElement(Comparison, { first, second }),
);
assert.match(html, /1234567890123\.456789/);
assert.match(html, />0</);
assert.match(html, /confirme nas fontes/);
assert.match(html, /não calcula evolução/);
assert.ok(!html.includes('<script>synthetic source</script>'));
assert.match(html, /&lt;script&gt;synthetic source/);
assert.equal(JSON.stringify([first, second]), before);
});
test('comparison delta: differences, missing evidence, unknown values and disputed information require review', () => {
const documented = {
...row,
description: 'Emprego observado na fonte sintética.',
measure_kind: 'observed',
};
const second = { ...documented, id: 'different' };
assert.equal(
compareObservations(documented, second).state,
'metadata_aligned',
);
for (const change of [
{ zone_id: 'other' },
{ indicator: 'exports' },
{ unit: 'USD' },
{ scope: 'other base' },
{ population: 'other population' },
{ methodology: 'other method' },
{ knowledge: 'disputed' },
{ knowledge: 'unknown', value: null },
{ value: 'NaN' },
{ source_reference: '' },
{ description: '' },
{ source_date: '2026-02-30' },
{ period_end: '2024-01-01' },
]) {
const result = compareObservations(documented, {
...second,
...change,
});
assert.equal(result.state, 'review_required', JSON.stringify(change));
assert.ok(result.issues.length);
}
assert.equal(compareObservations(row, row).state, 'review_required');
const html = renderToStaticMarkup(
React.createElement(Comparison, {
first: row,
second: { ...second, knowledge: 'unknown', value: null },
}),
);
assert.match(html, /Por confirmar/);
assert.match(html, /lacunas a conferir/);
});
test('measure kind delta: legacy nature stays unknown and observed quantities cannot align with plans or projections', () => {
const observed = {
...row,
description: 'Fonte sintética',
measure_kind: 'observed',
};
const same = { ...observed, id: 'second' };
assert.equal(compareObservations(observed, same).state, 'metadata_aligned');
for (const kind of ['planned', 'projected', 'unknown', null, undefined]) {
const other = { ...same, measure_kind: kind };
const before = JSON.stringify(other);
const result = compareObservations(observed, other);
assert.equal(result.state, 'review_required');
assert.ok(
result.issues.some((issue) =>
issue.includes('Natureza do valor diferente'),
),
);
assert.equal(JSON.stringify(other), before);
}
const legacy = compareObservations(
{ ...observed, measure_kind: undefined },
{ ...same, measure_kind: undefined },
);
assert.equal(legacy.state, 'review_required');
assert.ok(
legacy.issues.every((issue) =>
issue.includes('natureza do valor por confirmar'),
),
);
});
test('measure kind delta: public comparison labels nature independently of documentary confidence and retains zero', () => {
const first = {
...row,
description: 'Fonte sintética',
measure_kind: 'observed',
};
const second = { ...first, id: 'plan', measure_kind: 'planned' };
const html = renderToStaticMarkup(
React.createElement(Comparison, { first, second }),
);
assert.match(html, /Observado \/ efectivo/);
assert.match(html, /Planeado \/ meta/);
assert.match(html, /Documentada/);
assert.match(html, />0</);
const legacyHtml = renderToStaticMarkup(
React.createElement(Comparison, {
first: { ...first, measure_kind: undefined },
second,
}),
);
assert.match(legacyHtml, /natureza do valor por confirmar/);
assert.match(legacyHtml, /Por confirmar/);
});
test('comparison delta: progressive selection stays inside the received filters and shows an incomplete pair explicitly', () => {
const html = render(context);
assert.match(html, /Comparar duas observações/);
assert.match(html, /Primeira.*observação/);
assert.match(html, /Segunda.*observação/);
assert.match(html, /uma versão nova exige nova selecção/);
assert.match(html, /Seleccione duas observações/);
const single = render({ ...context, records: [row] });
assert.ok(!single.includes('Comparar duas observações'));
});
test('temporal delta: unequal duration and overlapping boundaries require review without annualising values', () => {
const first = {...row, description:'Synthetic source', measure_kind:'observed', period_start:'2024-01-01', period_end:'2024-12-31', value:'123.4500'};
const second = {...first, id:'second', period_start:'2025-01-01', period_end:'2025-12-31', value:'0'};
const before = JSON.stringify([first,second]);
assert.ok(compareObservations(first,second).issues.some(x=>x.includes('Duração dos períodos diferente')));
const boundary = {...second, period_start:'2024-12-31'};
assert.ok(compareObservations(first,boundary).issues.some(x=>x.includes('sobrepõem-se')));
const identical = {...first,id:'same-period'};
assert.equal(compareObservations(first,identical).state,'metadata_aligned');
const html = renderToStaticMarkup(React.createElement(Comparison,{first,second}));
assert.match(html,/Duração do período/);assert.match(html,/>366</);assert.match(html,/>365</);
assert.match(html,/123\.4500/);assert.match(html,/>0</);
assert.equal(JSON.stringify([first,second]),before);
});
test('temporal delta: missing invalid and inverted dates never become a duration', () => {
const first = {...row, description:'Synthetic', measure_kind:'observed'};
for (const change of [{period_start:null},{period_start:'2025'},{period_start:'2025-02-30'},{period_start:'2026-01-01'}]) {
const second = {...first,id:'invalid',...change};
const result = compareObservations(first,second);
assert.ok(result.issues.some(x=>x.includes('período incompleto ou inválido')));
assert.ok(!result.issues.some(x=>x.includes('Duração dos períodos diferente')));
const html = renderToStaticMarkup(React.createElement(Comparison,{first,second}));
assert.match(html,/Por confirmar/);assert.ok(!html.includes('NaN'));
}
});
@@ -0,0 +1,311 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import {
createInvestorDossierReport,
investorDossierReportIndex,
} from '../../resources/js/lib/investor-dossier-report.ts';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '../../resources/js/lib/project-dossier-report.ts';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
function fixture() {
return {
case: {
id: 'case-a',
title: 'Investimento QA <script>alert(1)</script>',
version: 9,
},
context_opportunity: 'op-a',
opportunity_options: ['op-a', 'op-b'],
investment_context: {
revision: 3,
fields: { project_title: 'Projecto QA', province: 'Manica' },
},
phases: { 1: 'Viabilidade', 2: 'Preparação' },
steps: [
{
code: 'S03',
title: 'Viabilidade',
phase: 1,
version: 2,
state: 'preparing',
reason: 'Conferir pressupostos',
detail: {},
},
{
code: 'S08',
title: 'Licenciamento prévio',
phase: 2,
version: 4,
state: 'not_started',
reason: '',
detail: { document_id: 'doc-a' },
},
],
results: [],
land_report: {
id: 'land-a',
version: 3,
updated_at: '2026-11-13',
href: '/investidor/terra-duat/land-a/imprimir',
},
documents: [
{
id: 'link-a',
document_id: 'doc-a',
file_name: 'Estudo QA',
status: 'active',
malware_scan_status: 'clean',
version_no: 2,
sha256: 'a'.repeat(64),
},
],
preparation_report: {
case_id: 'case-a',
opportunity: 'op-a',
version: 7,
context_revision: 3,
href: '/investimentos/case-a/preparacao?opportunity=op-a',
api_state: 'delivery_recorded',
api_issues: [],
exports: [
{
id: 'export-old',
at: '2026-11-12',
context_revision: 2,
historical: true,
fingerprint: 'old',
href: '/investimentos/case-a/preparacao/exportar/export-old?opportunity=op-a',
},
{
id: 'export-new',
at: '2026-11-13',
context_revision: 3,
historical: false,
fingerprint: 'new',
href: '/investimentos/case-a/preparacao/exportar/export-new?opportunity=op-a',
},
],
external: [
{
kind: 'delivery',
date: '2026-11-13',
reference: 'QA-ENTREGA',
export_id: 'export-new',
},
],
swot_review_required: true,
swot: {
object_key: 'investment:case-a',
scope: 'Produção QA',
rows: [
{
id: 's1',
dimension: 'strength',
text: 'Equipa experiente',
reference: 'Fonte QA',
date: '2026-11-12',
classification: 'hypothesis',
},
],
review: null,
actions: [],
},
},
fiscal_reports: [
{
simulation_id: 'simulation-a',
report_id: 'report-a',
report_version_no: 2,
report_sha256: 'b'.repeat(64),
consented_at: '2026-11-13',
},
],
company_reports: {
comparison: { revision: 2, at: '2026-11-12' },
guides: {
energy: {
slug: 'energia',
title: 'Guia de energia',
revision: 1,
at: '2026-11-12',
},
},
},
};
}
void test('one dossier groups stage evidence, SWOT, fiscal and all preserved APIEX versions without modifying sources', () => {
const input = fixture();
input.documents.push({ ...input.documents[0], id: 'second-link' });
const before = JSON.stringify(input);
const index = investorDossierReportIndex(input);
assert.equal(
index.filter((entry) => entry.id.startsWith('apiex:')).length,
2,
);
assert.equal(
index.find((entry) => entry.id === 'document:link-a').stage,
'S08',
);
assert.equal(
index.filter((entry) => entry.id.startsWith('document:')).length,
1,
);
assert.match(
index.find((entry) => entry.id === 'apiex:export-old').state,
/histórica/,
);
assert.equal(
index.find((entry) => entry.id === 'swot').state,
'Revisão necessária',
);
assert.match(
index.find((entry) => entry.id === 'land:land-a').state,
/não é título DUAT/,
);
const report = createInvestorDossierReport(input, '2026-11-13T12:00:00Z');
assert.match(report.version, /case-a.*v9.*contexto v3.*op-a/);
assert.match(
report.sections.find((s) => s.id === 'execution').rows[1].value,
/Por iniciar.*versão 4/,
);
assert.match(
report.sections.find((s) => s.id === 'market').rows[0].value,
/Hipótese/,
);
assert.match(
report.sections.find((s) => s.id === 'sources').paragraphs.join(' '),
/Entrega externa registada.*não equivale a aprovação/,
);
assert.equal(JSON.stringify(input), before);
});
void test('cross-dossier or cross-opportunity preparation is rejected and an ambiguous scope requires selection', () => {
const input = fixture();
input.preparation_report.case_id = 'case-b';
assert.throws(() => createInvestorDossierReport(input), /outro dossier/);
input.preparation_report.case_id = 'case-a';
input.preparation_report.opportunity = 'op-b';
assert.throws(() => createInvestorDossierReport(input), /oportunidade/);
input.context_opportunity = null;
assert.throws(() => investorDossierReportIndex(input), /Seleccione/);
});
void test('financial results from a different opportunity do not enter the consolidated report or its index', () => {
const input = fixture();
input.results = [
{
id: 'other',
context_opportunity: 'op-b',
financial_snapshot: 'OTHER-PRIVATE-INPUTS',
draft_id: 'other-draft',
draft_version: 8,
recorded_at: '2026-11-13',
engine: 'unknown',
currency: 'MZN',
},
];
assert.ok(
!JSON.stringify(createInvestorDossierReport(input)).includes(
'OTHER-PRIVATE-INPUTS',
),
);
assert.ok(
!investorDossierReportIndex(input).some(
(entry) => entry.id === 'finance:other',
),
);
assert.match(
createInvestorDossierReport(input).sections.find(
(s) => s.id === 'finance',
).paragraphs[0],
/Nenhum resultado/,
);
});
void test('the consolidated report reuses loan, break-even, financing and budget outputs with currency and historical state', () => {
const input = fixture();
const financial_snapshot = serializeFinancialToolsSnapshot({
budgetPeriod: 'Mensal',
loan: {
principal: '100000',
rate: '0',
months: '12',
rateType: 'nominal',
periodMonths: '1',
gracePeriods: '0',
upfrontFee: '0',
recurringFee: '0',
},
breakEven: { fixed: '1000', price: '100', variable: '50' },
funding: { total: '100000', secured: '30000' },
incomes: [{ label: 'Receita QA', amount: '100' }],
expenses: [{ label: 'Custo QA', amount: '50' }],
});
input.results = [
{
id: 'finance-a',
context_opportunity: 'op-a',
context_revision: 2,
context_state: 'review_required',
financial_snapshot,
draft_id: 'draft-a',
draft_version: 5,
recorded_at: '2026-11-12',
engine: 'financial-calculators:v1',
currency: 'MZN',
},
];
const report = createInvestorDossierReport(input);
const rows = report.sections.find(
(section) => section.id === 'finance',
).rows;
assert.equal(
rows.find(
(item) => item.field === 'Financiamento — Diferença por financiar',
).value,
'70000.00 MZN',
);
assert.equal(
rows.find(
(item) => item.field === 'Ponto de equilíbrio — Unidades estimadas',
).value,
'20',
);
assert.equal(
rows.find(
(item) => item.field === 'Orçamento — Saldo operacional estimado',
).value,
'50.00 MZN',
);
assert.ok(
rows.some((item) => item.field.startsWith('Empréstimo educativo')),
);
assert.match(rows[0].classification, /Histórico/);
assert.equal(input.results[0].financial_snapshot, financial_snapshot);
});
void test('the existing Word and print exporters retain dossier identity, pending stages, source and history, escaping active HTML', async () => {
const report = createInvestorDossierReport(
fixture(),
'2026-11-13T12:00:00Z',
);
const html = await createProjectDossierPrintHtml(report, [], false);
assert.ok(!html.includes('<script>alert(1)</script>'));
assert.match(html, /case-a/);
assert.match(html, /Fonte QA/);
assert.match(html, /export-old/);
assert.ok(html.includes('a'.repeat(64)));
const docx = await generateProjectDossierDocx(report, [], false);
const zip = await JSZip.loadAsync(await docx.arrayBuffer());
const xml = await zip.file('word/document.xml').async('string');
assert.match(xml, /case-a/);
assert.match(xml, /Licenciamento prévio/);
assert.match(xml, /Por iniciar/);
assert.match(xml, /Fonte QA/);
assert.match(xml, /export-old/);
});
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import { createInvestorStageReport } from '../../resources/js/lib/investor-dossier-report.ts';
import {
createProjectDossierPrintHtml,
generateProjectDossierDocx,
} from '../../resources/js/lib/project-dossier-report.ts';
const input = {
case: {
id: 'synthetic-stage-dossier',
title: 'Ensaio sintético',
version: 3,
},
context_opportunity: null,
opportunity_options: [],
investment_context: {
revision: 1,
fields: { project_title: 'Fábrica sintética', province: 'Sofala' },
},
phases: { 1: 'Preparação sintética' },
results: [],
documents: [],
steps: Array.from({ length: 19 }, (_, i) => ({
code: `S${String(i + 1).padStart(2, '0')}`,
title: `Etapa sintética ${i + 1}`,
phase: 1,
version: i + 2,
state: 'preparing',
reason: `Fundamento sintético exclusivo ${i + 1}.`,
detail: { recorded_at: '2026-11-13' },
})),
};
void test('all 19 stage reports can be consulted and exported without inventing outcomes or changing source data', async () => {
const before = JSON.stringify(input);
for (const step of input.steps) {
const model = createInvestorStageReport(
input,
step.code,
'2026-11-13T15:00:00Z',
);
assert.match(model.title, new RegExp(step.code));
const execution = model.sections.find((s) => s.id === 'execution');
assert.equal(execution.rows.length, 1);
assert.ok(execution.rows[0].value.includes(step.reason));
assert.ok(
model.sections
.find((s) => s.id === 'summary')
.rows.every((r) => r.field.startsWith(step.code)),
);
assert.ok(
!model.sections.some((s) => s.id === 'finance') ||
step.code === 'S03',
);
const html = await createProjectDossierPrintHtml(model, [], true);
assert.ok(html.includes(step.reason));
assert.ok(html.includes('synthetic-stage-dossier'));
const blob = await generateProjectDossierDocx(model, [], true);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const xml = await zip.file('word/document.xml').async('string');
assert.ok(xml.includes(step.reason));
assert.ok(xml.includes('não cria documentos'));
}
assert.equal(JSON.stringify(input), before);
assert.throws(
() => createInvestorStageReport(input, 'S99'),
/não encontrada/,
);
assert.throws(
() =>
createInvestorStageReport(
{ ...input, opportunity_options: ['a', 'b'] },
'S01',
),
/Seleccione/,
);
});
void test('exported limitations retain literal text and cannot inject active HTML', async () => {
const model = createInvestorStageReport(input, 'S08');
model.limitations.push('Limite <script>alert(1)</script> & origem');
const html = await createProjectDossierPrintHtml(model, [], true);
assert.ok(
html.includes(
'Limite &lt;script&gt;alert(1)&lt;/script&gt; &amp; origem',
),
);
assert.ok(!html.includes('<script>alert(1)</script>'));
const zip = await JSZip.loadAsync(
await (await generateProjectDossierDocx(model, [], true)).arrayBuffer(),
);
assert.ok(
(await zip.file('word/document.xml').async('string')).includes(
'Limite &lt;script&gt;alert(1)&lt;/script&gt; &amp; origem',
),
);
});
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {plan} from './helpers/irpc-pec-obligation-case.mjs';
import {combinedCase} from './helpers/irpc-investment-combination-case.mjs';
import {calculatePecObligation,validatePecObligation} from '../../resources/js/lib/irpc-pec-obligation.ts';
import {recordAnnual,saveAnnualBook,annualBook,annualCurrent,annualHtml} from '../../resources/js/lib/irpc-annual-settlement.ts';
import {calculatePecPlan,newPecPlan} from '../../resources/js/lib/irpc-pec-plan.ts';
const f=n=>({numerator:String(n),denominator:'1'});
const sample=()=>{const p={...plan(),previousTurnover:'0.00'};return {...p,lastAmount:{version:1,subjectNuit:p.subjectNuit,year:'2024',amount:'42500.25',reference:'SYNTHETIC final PEC amount; latest applicable annual ordinary MZN reference',confirmed:'yes'}}};
const calc=p=>calculatePecObligation(p,p.subjectNuit,p.year,'normal');
test('documented latest PEC amount is retained without percentage, caps or a second PPC deduction',()=>{
const p=sample(),before=JSON.stringify(p),m=calc(p);
assert.deepEqual(m.total,{numerator:'170001',denominator:'4'});assert.equal(m.percentage,null);assert.equal(m.limited,null);assert.equal(m.lastAmountApplied,true);assert.equal(m.requiresLastAmount,true);
assert.equal(m.paymentExecuted,false);assert.equal(m.annualDeductionCreated,false);assert.equal(JSON.stringify(p),before);
p.lastAmount.amount='0';assert.deepEqual(calc(p).total,f(0));
});
test('absent stale unconfirmed unsupported or malformed historical references never determine the estimate',()=>{
for(const changes of [{confirmed:''},{reference:''},{subjectNuit:'999999999'},{year:'2026'},{year:'2019'},{amount:''},{amount:'-1'},{amount:'NaN'}]){const p=sample();Object.assign(p.lastAmount,changes);assert.equal(calc(p).total,null,JSON.stringify(changes));}
const p=sample();delete p.lastAmount;assert.equal(calc(p).total,null);
assert.throws(()=>validatePecObligation({...sample(),lastAmount:{...sample().lastAmount,amount:null}}));
});
test('ordinary positive turnover and activity start do not use or erase a saved historical reference',()=>{
const p=sample();p.previousTurnover='10000000';assert.deepEqual(calc(p).total,f(40000));assert.equal(calc(p).lastAmountApplied,false);assert.equal(p.lastAmount.amount,'42500.25');
p.activityStartYear='2026';assert.deepEqual(calc(p).total,f(0));assert.equal(calc(p).lastAmountApplied,false);
});
test('native annual recovery and report retain history and require reconfirming cash assumptions after the estimate changes',()=>{
const {d,b}=combinedCase();b.pecObligation={...sample(),subjectNuit:b.subjectNuit,lastAmount:{...sample().lastAmount,subjectNuit:b.subjectNuit}};
const prior=recordAnnual(d,b,'2026-11-13T21:00:00Z');
const changed={...prior,pecObligation:{...prior.pecObligation,lastAmount:{...prior.pecObligation.lastAmount,amount:'50000'}}};
assert.equal(annualCurrent(d,changed),false);
d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,changed,'2026-11-13T21:01:00Z'));
const recovered=annualBook(d);assert.equal(annualCurrent(d,recovered),true);assert.equal(recovered.history.length,1);assert.deepEqual(recovered.result.model.arithmeticBalance,f(138));
assert.match(annualHtml(d),/PEC — último montante documentado/);assert.match(annualHtml(d),/50000/);
const cash={...newPecPlan(),mode:'three',june:'42500.25',august:'0',october:'0',reference:'SYNTHETIC prior cash hypothesis'};
const cm=calculatePecPlan(cash,calc(recovered.pecObligation).total,false,'2026');assert.ok(cm.pending.length);
});
test('unknown turnover cannot be bypassed and a documented correction to zero reuses the historical reference once',()=>{
const p=sample();p.previousTurnover='';assert.equal(calc(p).total,null);
p.previousTurnover='10000000';p.correction={version:1,apply:'yes',originalTurnover:p.previousTurnover,revisedTurnover:'0.00',subjectNuit:p.subjectNuit,turnoverYear:'2025',reference:'SYNTHETIC corrected sales to zero'};
assert.equal(calc(p).requiresLastAmount,true);assert.equal(calc(p).lastAmountApplied,true);assert.deepEqual(calc(p).total,{numerator:'170001',denominator:'4'});
p.correction.reference='';assert.equal(calc(p).total,null);
p.correction.apply='no';assert.deepEqual(calc(p).total,f(40000));assert.equal(calc(p).lastAmountApplied,false);
});
test('excess documented PPC covers the PEC estimate without refund credit or duplicate annual deduction',()=>{
for(const [paid,expected] of [['50000','0'],['60000','10000'],['50000.01','0.01']]){
const p={...plan(),previousPpcPaid:paid};const m=calc(p);assert.deepEqual(m.total,f(0));assert.equal(m.pending.length,0);
if(expected==='0')assert.equal(m.ppcExcess,null);else assert.ok(m.ppcExcess);
assert.equal(m.paymentExecuted,false);assert.equal(m.annualDeductionCreated,false);
}
const {d,b}=combinedCase();b.pecObligation={...plan(),subjectNuit:b.subjectNuit,previousPpcPaid:'60000'};
d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,b,'2026-11-13T21:20:00Z'));
const recovered=annualBook(d);assert.equal(annualCurrent(d,recovered),true);assert.deepEqual(recovered.result.model.arithmeticBalance,f(138));assert.match(annualHtml(d),/PEC — PPC acima da base limitada/);assert.match(annualHtml(d),/não cria reembolso/);
});
@@ -53,7 +53,6 @@ test('unknown turnover is distinct from zero; no turnover requires29(5),excess P
{ previousTurnover: '' },
{ previousTurnover: '0' },
{ previousPpcPaid: '' },
{ previousPpcPaid: '60000' },
{ scope: 'other' },
{ subjectNuit: '999999999' },
{ previousYear: '2024' },
@@ -18,6 +18,7 @@ function load(file, page) {
const module = { exports: {} };
const aliases = new Proxy({ default: Empty }, { get: (target, name) => name in target ? target[name] : Empty });
compileFunction(code, ['require', 'module', 'exports'])((name) => {
if (name.endsWith('.css')) return {};
if (name === 'lucide-react') return aliases;
if (name === '@inertiajs/react') return {
Link: 'a', Head: Empty, usePage: () => page,
+288
View File
@@ -0,0 +1,288 @@
"""Read-only INE XLSX adapter. Uses labels/relationships, never executes formulas/macros."""
import argparse
from collections import defaultdict
from decimal import Decimal, InvalidOperation
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import unicodedata
import xml.etree.ElementTree as ET
from zipfile import ZipFile
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
REL = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
FORMAT = "investmoz-demography/1"
def normalized(value):
return " ".join("".join(c for c in unicodedata.normalize("NFKD", value.casefold()) if not unicodedata.combining(c)).split())
def sheets(path):
with ZipFile(path) as archive:
if len(archive.infolist()) > 2000 or sum(i.file_size for i in archive.infolist()) > 150_000_000:
raise ValueError("Workbook exceeds safe expanded size")
if any("vbaProject" in i.filename or "externalLinks/" in i.filename for i in archive.infolist()):
raise ValueError("Macros/external links are not supported")
strings = []
if "xl/sharedStrings.xml" in archive.namelist():
strings = ["".join(x.itertext()) for x in ET.fromstring(archive.read("xl/sharedStrings.xml")).findall("s:si", NS)]
relationships = {x.attrib["Id"]: x.attrib["Target"] for x in ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))}
for sheet in ET.fromstring(archive.read("xl/workbook.xml")).find("s:sheets", NS):
target = relationships[sheet.attrib[REL]]
member = target.lstrip("/") if target.startswith("/") else str(PurePosixPath("xl") / target)
rows = []
for row in ET.fromstring(archive.read(member)).findall("s:sheetData/s:row", NS):
cells = {}
for cell in row:
value = cell.findtext("s:v", "", NS)
kind = cell.attrib.get("t")
if kind == "s":
value = strings[int(value)]
elif kind == "inlineStr":
value = "".join(cell.find("s:is", NS).itertext())
if value or cell.find("s:f", NS) is not None:
cells[re.sub(r"\d", "", cell.attrib["r"])] = {"value": value, "cell": cell.attrib["r"], "invalid": kind == "e", "formula": cell.findtext("s:f", None, NS)}
if cells:
rows.append(cells)
yield sheet.attrib["name"], rows
def age_group(label):
label = normalized(label).replace("–", "-").replace(" ", "")
if label == "total":
return "all"
match = re.search(r"(\d+)-(\d+)", label)
if match:
return match[1] + "-" + match[2]
if "65oumais" in label:
return "65+"
if re.fullmatch(r"\d+\+", label):
return label
return None
def population_columns(rows):
"""Two-level area/sex headers, with merged area headings carried forward."""
area_row = next((r for r in rows if any(normalized(c["value"]) in ("urbana", "populacao urbana") for c in r.values())), {})
sex_row = next((r for r in rows if any(normalized(c["value"]) == "homens" for c in r.values())), {})
result, area = {}, "all"
for column, cell in sorted(sex_row.items(), key=lambda x: (len(x[0]), x[0])):
heading = normalized(area_row.get(column, {}).get("value", ""))
if "urbana" in heading:
area = "urban"
elif "rural" in heading:
area = "rural"
elif heading in ("total", "populacao total"):
area = "all"
sex = {"total": "all", "homens": "male", "mulheres": "female"}.get(normalized(cell["value"]))
if sex:
result[column] = (sex, area)
return result
def parse(path, baseline_year=None):
path = Path(path)
report = {"format": FORMAT, "file_name": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"source": "Instituto Nacional de Estatística de Moçambique (INE)", "publication_version": None,
"records": [], "issues": [], "sheets": [], "territories": [], "years": [], "territory_headers": {}}
seen = {}
def issue(code, sheet, cell="", **extra):
report["issues"].append(dict(code=code, sheet=sheet, cell=cell, **extra))
def add(sheet, cell, province, territory, level, year, indicator, sex="all", area="all", age="all", unit="pessoas", kind="projected"):
if cell and cell.get("formula") is not None and cell["value"] == "":
issue("invalid_numeric_or_formula", sheet, cell["cell"], reason="formula_without_numeric_cache")
return
if not cell or cell["value"] == "":
issue("missing_value", sheet, territory=territory, indicator=indicator, age_group=age, sex=sex, residence=area)
return
raw = cell["value"]
try:
value = Decimal(raw)
if cell["invalid"] or not value.is_finite() or value < 0:
raise InvalidOperation
except InvalidOperation:
issue("invalid_numeric_or_formula", sheet, cell["cell"])
return
if cell.get("formula") is not None:
issue("cached_formula_value", sheet, cell["cell"], formula=cell["formula"])
row = dict(province=province, territory=territory, level=level, year=year, indicator=indicator, sex=sex,
residence=area, age_group=age, unit=unit, data_kind=kind, original_value=raw,
numeric_value=format(value, "f"), sheet=sheet, cell=cell["cell"])
key = tuple(row[k] for k in ("province", "territory", "level", "year", "indicator", "sex", "residence", "age_group"))
if key in seen:
issue("duplicate_value" if seen[key] == raw else "conflicting_value", sheet, cell["cell"])
return
seen[key] = raw
report["records"].append(row)
workbook = list(sheets(path))
annual = any(re.fullmatch(r"20\d{2}", name) for name, _ in workbook)
province = None
for sheet, rows in workbook:
before = len(report["records"])
if annual:
if not re.fullmatch(r"20\d{2}", sheet):
report["sheets"].append(dict(name=sheet, records=0, state="index"))
continue
year = int(sheet)
columns = population_columns(rows)
if len(columns) != 9:
issue("unsupported_population_headers", sheet)
continue
territory = None
for row in rows:
label = next(iter(row.values()))["value"]
title = re.search(r"segundo idade\.\s*(.+),\s*(20\d{2})\s*$", label, re.I)
if title:
if int(title[2]) != year:
issue("title_year_mismatch", sheet, next(iter(row.values()))["cell"])
territory = None
continue
place = title[1].strip()
prefix = r"^(?:Distrito Municipal\s+|(?:Distrito|Cidade) (?:de|da)\s+)"
level = "district" if re.match(prefix, place, re.I) else "province"
territory = re.sub(prefix, "", place, flags=re.I)
if level == "province":
if province and normalized(province) != normalized(territory):
raise ValueError("Multiple provinces in annual workbook")
province = territory
if province:
header_key = "|".join([province, territory, level])
report["territory_headers"].setdefault(header_key, [])
if place not in report["territory_headers"][header_key]:
report["territory_headers"][header_key].append(place)
continue
age = age_group(row["A"]["value"]) if "A" in row else None
if territory and age and province:
for column, (sex, area) in columns.items():
add(sheet, row.get(column), province, territory, level, year, "population", sex, area, age)
else:
name = normalized(sheet)
if name.startswith("populacao_") or "grupo etario" in name:
columns = population_columns(rows)
is_age = "grupo etario" in name
explicit = [int(c["value"]) for r in rows[:2] for c in r.values() if re.fullmatch(r"20\d{2}", c["value"])]
year = explicit[0] if explicit else baseline_year
if not year:
issue("reference_year_required", sheet)
continue
if not explicit:
issue("year_from_import_metadata", sheet, year=year)
province = None
for row in rows[2:]:
if row.get("A"):
province = row["A"]["value"].strip()
age = age_group(row.get("B", {}).get("value", "")) if is_age else "all"
if province and age:
for column, (sex, area) in columns.items():
add(sheet, row.get(column), province, province, "province", year, "population", sex, area, age, kind="unspecified")
elif "vitais" in name:
years = re.findall(r"20\d{2}", " ".join(c["value"] for r in rows[:3] for c in r.values()))
if len(set(years)) != 1:
issue("reference_year_required", sheet)
continue
# Indicator names derive from the actual two-row headings. Units absent in the source remain unspecified.
heading = rows[1]
active = ""
for column, cell in sorted(rows[2].items(), key=lambda x: (len(x[0]), x[0])):
if column == "A":
continue
if column in heading:
active = normalized(heading[column]["value"])
detail = normalized(cell["value"])
if "esperanca" in active:
indicator, unit = "life_expectancy", "anos"
elif "infantil" in active:
indicator, unit = "infant_mortality", "unspecified"
else:
indicator = {"mortalidade": "crude_death_rate", "natalidade": "crude_birth_rate", "fecundidade": "fertility_rate", "exponencial": "population_growth_rate"}.get(detail)
unit = "unspecified"
if not indicator:
issue("unsupported_vital_header", sheet, cell["cell"])
continue
if unit == "unspecified":
issue("unit_not_stated", sheet, cell["cell"], indicator=indicator)
sex = {"homens": "male", "mulheres": "female"}.get(detail, "all")
for row in rows[3:]:
if "A" in row:
p = row["A"]["value"].strip()
add(sheet, row.get(column), p, p, "province", int(years[0]), indicator, sex, unit=unit, kind="unspecified")
else:
issue("unpopulated_indicator_catalogue" if not any(re.fullmatch(r"\d+(\.\d+)?", c["value"]) for r in rows for c in r.values()) else "unsupported_sheet", sheet)
report["sheets"].append(dict(name=sheet, records=len(report["records"]) - before))
reconcile(report)
report["territories"] = sorted({(r["province"], r["territory"], r["level"]) for r in report["records"]})
report["years"] = sorted({r["year"] for r in report["records"]})
report["record_count"] = len(report["records"])
report["issue_counts"] = dict(sorted(__import__("collections").Counter(i["code"] for i in report["issues"]).items()))
if not report["records"]:
raise ValueError("No supported demographic data found")
return report
def reconcile(report):
groups = defaultdict(dict)
territorial = defaultdict(dict)
ages = defaultdict(dict)
for r in report["records"]:
if r["indicator"] != "population":
continue
base = (r["province"], r["territory"], r["level"], r["year"], r["age_group"])
groups[base][(r["sex"], r["residence"])] = r
ages[base[:4] + (r["sex"], r["residence"])][r["age_group"]] = r
if r["age_group"] == r["sex"] == r["residence"] == "all":
territorial[(r["province"], r["year"])][(r["level"], r["territory"])] = r
def check(total, parts, code):
if total and all(parts):
delta = Decimal(total["numeric_value"]) - sum(Decimal(p["numeric_value"]) for p in parts)
# Binary Excel numeric storage leaves sub-micro-person residues; retain originals,
# but do not report these as demographic inconsistencies.
if abs(delta) > Decimal("0.000001"):
report["issues"].append(dict(code=code, sheet=total["sheet"], cell=total["cell"], delta=str(delta), territory=total["territory"], year=total["year"]))
for rows in groups.values():
for area in ("all", "urban", "rural"):
check(rows.get(("all", area)), [rows.get(("male", area)), rows.get(("female", area))], "sex_total_mismatch")
for sex in ("all", "male", "female"):
check(rows.get((sex, "all")), [rows.get((sex, "urban")), rows.get((sex, "rural"))], "residence_total_mismatch")
for rows in ages.values():
detail = [v for k, v in rows.items() if k != "all"]
if detail:
check(rows.get("all"), detail, "age_total_mismatch")
for rows in territorial.values():
provinces = [v for (kind, _), v in rows.items() if kind == "province"]
districts = [v for (kind, _), v in rows.items() if kind == "district"]
if provinces and districts:
check(provinces[0], districts, "district_total_mismatch")
if __name__ == "__main__":
cli = argparse.ArgumentParser()
cli.add_argument("file")
cli.add_argument("--baseline-year", type=int)
cli.add_argument("--output", required=True)
cli.add_argument("--records-output")
cli.add_argument("--preparation-receipt")
args = cli.parse_args()
if args.preparation_receipt and not args.records_output:
cli.error('--preparation-receipt requires --records-output')
result = parse(args.file, args.baseline_year)
if args.records_output:
record_path = Path(args.records_output)
with record_path.open('w', encoding='utf-8', newline='\n') as target:
for record in result.pop('records'):
target.write(json.dumps(record, ensure_ascii=False, separators=(',', ':'))+'\n')
result['records_file_sha256'] = hashlib.sha256(record_path.read_bytes()).hexdigest()
Path(args.output).write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
if args.preparation_receipt:
receipt = dict(sha256=result['sha256'], baseline_year=args.baseline_year,
parser_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
preview_sha256=hashlib.sha256(Path(args.output).read_bytes()).hexdigest(),
records_sha256=result['records_file_sha256'], record_count=result['record_count'])
Path(args.preparation_receipt).write_text(json.dumps(receipt, separators=(',', ':')), encoding='utf-8')
print(json.dumps({k: v for k, v in result.items() if k not in ("records", "issues")}, ensure_ascii=False))
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {combinedCase} from '../tests/frontend/helpers/irpc-investment-combination-case.mjs';
import {plan} from '../tests/frontend/helpers/irpc-pec-obligation-case.mjs';
import {calculatePecObligation} from '../resources/js/lib/irpc-pec-obligation.ts';
import {recordAnnual,saveAnnualBook,annualBook,annualCurrent,annualHtml} from '../resources/js/lib/irpc-annual-settlement.ts';
import {createProjectDossierReport} from '../resources/js/lib/project-dossier-report.ts';
const out=new URL('../outputs/irpc-pec-last-amount-20261113/',import.meta.url);fs.mkdirSync(out,{recursive:true});
for(const name of ['documented','missing','positive','excess']){
if(process.argv[2]==='generate'){
const {d,b}=combinedCase();const prior=recordAnnual(d,b,'2026-11-13T21:10:00Z');
prior.pecObligation={...plan(),subjectNuit:b.subjectNuit,previousTurnover:['positive','excess'].includes(name)?'10000000':'0',previousPpcPaid:name==='excess'?'60000':'10000',lastAmount:{version:1,subjectNuit:b.subjectNuit,year:'2024',amount:'42500.25',reference:name==='missing'?'':'SYNTHETIC final PEC already determined;latest applicable annual MZN reference',confirmed:'yes'}};
d.modelo_a_snapshot=saveAnnualBook(d,recordAnnual(d,prior,'2026-11-13T21:11:00Z'));
fs.writeFileSync(new URL(name+'.json',out),JSON.stringify(d));
}else{
const d=JSON.parse(fs.readFileSync(new URL('recovered-'+name+'.json',out),'utf8')),b=annualBook(d),m=calculatePecObligation(b.pecObligation,b.subjectNuit,b.year,b.assessment);
assert.equal(annualCurrent(d,b),true);assert.equal(b.history.length,1);assert.deepEqual(b.result.model.arithmeticBalance,{numerator:'138',denominator:'1'});
const expected=name==='missing'?null:name==='excess'?{numerator:'0',denominator:'1'}:name==='positive'?{numerator:'40000',denominator:'1'}:{numerator:'170001',denominator:'4'};assert.deepEqual(m.total,expected);
if(name==='excess') {assert.deepEqual(m.ppcExcess,{numerator:'10000',denominator:'1'});}
assert.equal(m.lastAmountApplied,name==='documented');assert.equal(m.paymentExecuted,false);assert.equal(m.annualDeductionCreated,false);
const html=annualHtml(d),report=createProjectDossierReport(d,{selectedSections:new Set(['finance']),generatedAt:'2026-11-13T21:12:00Z'});
assert.match(html,/PEC — último montante documentado/);assert.match(JSON.stringify(report),/PEC — último montante documentado/);
fs.writeFileSync(new URL(name+'-report.html',out),html);fs.writeFileSync(new URL(name+'-verification.json',out),JSON.stringify({synthetic:true,nativeApiRecovery:true,annualCurrent:true,historyPreserved:true,annualBalanceUnchanged:true,report:true,model:m},null,2));
}
}
console.log(process.argv[2]==='generate'?'Generated four synthetic cases':'Verified four recovered cases and dossier reports');
+23
View File
@@ -0,0 +1,23 @@
<?php
$root = dirname(__DIR__);
if (($argv[1] ?? '') !== 'test' || basename($root) !== 'portal-investidor') {
throw new RuntimeException('Explicit isolated QA test operation required');
}
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:'.base64_encode(str_repeat('Q', 32)),
'APP_CONFIG_CACHE' => $root.'/outputs/irpc-pec-last-amount-20261113/no-config.php', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'investmoz_demography_test', 'DB_USERNAME' => 'demography_qa', 'DB_PASSWORD' => '', 'DB_URL' => '', 'DATABASE_URL' => '',
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', 'SUITECRM_ENABLED' => 'false'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=investmoz_demography_test', 'demography_qa', '');
$identity = $pdo->query('select current_database(),inet_server_port()')->fetch(PDO::FETCH_NUM);
if ($identity !== ['investmoz_demography_test', 55441] && $identity !== ['investmoz_demography_test', '55441']) {
throw new RuntimeException('Isolated QA identity required');
}
$argv = ['phpunit', '-c', 'phpunit.investmoz.xml', '--do-not-cache-result', 'tests/Feature/IrpcPecLastAmountDraftTest.php'];
$_SERVER['argv'] = $argv;
$_SERVER['argc'] = count($argv);
require $root.'/vendor/autoload.php';
require $root.'/vendor/phpunit/phpunit/phpunit';