Compare commits

...
Author SHA1 Message Date
nala bd5166bd11 docs: record authorised fiscal rule publication and consumer activation 2026-10-21 12:57:40 +02:00
nala 4a4d121275 fix: allow authorised fiscal maintenance by the same responsible person
tests / ci (push) Canceled after 0s
2026-10-21 12:39:07 +02:00
nala 226fd308dd feat: connect published IVA consumer to normal simulator in QA
tests / ci (push) Canceled after 0s
2026-10-21 11:59:50 +02:00
nala 2ced7b790c docs: delimit published IVA consumer and three rollout operations 2026-10-21 11:39:17 +02:00
nala d186f94206 feat: demonstrar percurso IVA QA e consolidar revisao 2026-10-21 09:40:49 +02:00
nala 83d2f97d82 feat: preparar lote IVA exacto em QA sem activacao 2026-10-21 09:22:11 +02:00
nala 37085b40c1 docs: fundamentar base IVA e delimitar lacuna de arredondamento 2026-10-21 09:03:01 +02:00
nala 325d58b694 docs: preparar ficha fiscal IVA v1 sem activacao 2026-10-21 08:57:56 +02:00
nala 0fafd348e7 docs: delimitar entrega de planeamento e propor primeiro lote fiscal 2026-10-21 08:47:07 +02:00
nala 13f68f1bd8 fix: concluir recuperacao de exportacoes e consolidar candidata QA
tests / ci (push) Canceled after 0s
2026-10-21 08:43:12 +02:00
nala c22003a3da fix: recover simulator cashflow and verify QA continuity and report assumptions 2026-10-20 22:00:48 +02:00
nala a690c665fb fix: validate simulator dates and reconcile QA calculations reports and language 2026-10-20 21:00:52 +02:00
nala 59ab4fe956 fix: validar datas fiscais e iniciar revisao integrada do simulador 2026-10-20 20:03:40 +02:00
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
nala 0e7d51f18f fix: concluir apresentacao publica dos negocios e zonas
tests / ci (push) Canceled after 0s
2026-10-20 19:00:32 +02:00
nala fd341b627e fix: harmonizar pesquisa e navegacao dos catalogos em QA 2026-10-20 18:53:41 +02:00
nala 1350b4df73 autenticacao 1
tests / ci (push) Canceled after 0s
2026-10-20 17:51:31 +02:00
131 changed files with 23199 additions and 10829 deletions
+5
View File
@@ -4,6 +4,11 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
# Separate from publication. Bind only an explicitly authorised published rule version.
INVESTMOZ_IVA_CONSUMER_ENABLED=false
INVESTMOZ_IVA_CONSUMER_RULE_ID=
INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
/** Explicit registration: never run during migration, deployment or document import. */
class RegisterFiscalQaDraft extends Command
{
protected $signature = 'fiscal:qa-register-iva {actor : Existing QA editor ID} {source : Existing accepted legal source UUID}';
protected $description = 'Cadastrar apenas o rascunho IVA do primeiro lote numa base QA isolada, sem publicação';
public function handle(SimulationTaxRuleGovernance $governance): int
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 403, 'O cadastro deste lote só é permitido na base QA isolada.');
$actor = User::findOrFail($this->argument('actor'));
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
$payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
app(SimulationTaxParameterResolver::class)->validateParameters($payload['parameters']);
$existing = $governance->listing($actor, ['rule_code' => $payload['rule_code']])->getCollection()
->firstWhere('rule_version', 1);
if ($existing) {
foreach ($payload as $field => $value) {
$stored = $existing->attributesToArray()[$field] ?? null;
abort_unless($stored == $value, 409, 'O cadastro existente difere desta ficha; não será sobrescrito.');
}
abort_unless($existing->status === 'draft', 409, 'A versão existente já não é um rascunho.');
$this->line(json_encode(['id' => $existing->id, 'status' => 'draft', 'reused' => true], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$rule = $governance->create($actor, $payload);
$this->line(json_encode(['id' => $rule->id, 'status' => $rule->status, 'reused' => false], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
@@ -36,6 +36,7 @@ class OpportunityController extends Controller
$term = $validated['q'];
$query->where(fn ($q) => $q
->whereLike('title', "%{$term}%")
->orWhereLike('description', "%{$term}%")
->orWhereLike('summary', "%{$term}%")
->orWhereLike('reference', "%{$term}%")
->orWhereLike('province', "%{$term}%")
@@ -54,7 +55,7 @@ class OpportunityController extends Controller
}
if (! empty($validated['maturity_level'])) {
$query->where('maturity_level', $validated['maturity_level']);
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
@@ -37,11 +37,17 @@ class SimulationController extends Controller
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
$validated = $request->validated();
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
$organizationId = $data['organization_id'] ?? null;
$organizationId = $validated['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared']));
$input['scope_declared'] = (bool) $input['scope_declared'];
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
} else {
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0')];
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
}
$simulation = $result['simulation'];
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
if ($actorId === null) {
@@ -27,6 +27,7 @@ class InvestmentDiscoveryController extends Controller
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -53,7 +54,7 @@ class InvestmentDiscoveryController extends Controller
if (! empty($filters['geo_location_id'])) {
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id']));
}
foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation'] as $field) {
if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) {
return $query->whereRaw('false')->get();
}
@@ -75,9 +76,9 @@ class InvestmentDiscoveryController extends Controller
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
}
public function references(InvestmentCatalogue $catalogue): JsonResponse
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->references());
return response()->json($catalogue->references(false, $catalogue->filters($request)));
}
public function detail(Request $request, string $slug, InvestmentCatalogue $catalogue): Response
@@ -106,6 +107,7 @@ class InvestmentDiscoveryController extends Controller
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -131,7 +133,7 @@ class InvestmentDiscoveryController extends Controller
{
$filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
@@ -40,6 +40,7 @@ class InvestmentLegalSourceController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]);
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentLegalSource;
use App\Services\SimulationTaxGovernanceAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit fiscal curation of an identified official source; not an independent legal opinion. */
final class SimulationFiscalSourceController extends Controller
{
public function store(Request $request): JsonResponse
{
SimulationTaxGovernanceAccess::requireRole($request->user(), 'editors');
$data = $request->validate([
'title' => ['required', 'string', 'max:255'], 'url' => ['required', 'url:https', 'max:2000'],
'diploma' => ['required', 'string', 'max:255'], 'article' => ['required', 'string', 'max:255'],
'competent_entity' => ['required', 'string', 'max:255'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'sha256' => ['required', 'regex:/^[a-f0-9]{64}$/D'],
'citation' => ['required', 'string', 'min:10', 'max:2000'],
'official_origin' => ['required', 'string', 'min:10', 'max:2000'],
]);
return DB::transaction(function () use ($request, $data): JsonResponse {
$actor = $request->user();
$acceptance = ['scope' => 'fiscal_parameters', 'basis' => 'Tempo-official-sources-20261008',
'responsible_user_id' => $actor->id, 'sha256' => $data['sha256'], 'citation' => $data['citation'],
'official_origin' => $data['official_origin'], 'accepted_at' => now()->toISOString(),
'legal_opinion' => false, 'independent_review' => false];
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $actor->id, 'validator_id' => null,
'validation_status' => 'pending', 'validated_at' => null, 'version_no' => 1, 'fiscal_acceptance' => $acceptance]);
DB::table('audit_events')->insert(['source_system' => 'simulation-fiscal-maintenance',
'entity_type' => 'investment_legal_sources', 'entity_id' => $source->id, 'actor_user_id' => $actor->id,
'action' => 'official_source_acceptance', 'after_data' => json_encode([
'source' => $source->attributesToArray(), 'basis' => $acceptance,
'sha256' => hash('sha256', $source->toJson()),
], JSON_THROW_ON_ERROR)]);
return response()->json($source->fresh(), 201);
});
}
}
@@ -2,17 +2,51 @@
namespace App\Http\Controllers;
use App\Services\SimulationFiscalQaPreview;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use InvalidArgumentException;
class SimulationTaxRuleGovernanceController extends Controller
{
public function qaReview(Request $request, string $id, SimulationTaxRuleGovernance $governance): Response
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$rule = $governance->show($request->user(), $id);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
return Inertia::render('management/fiscal-qa-review', ['rule' => [
'id' => $rule->id, 'code' => $rule->rule_code, 'version' => $rule->rule_version,
]]);
}
public function qaPreview(Request $request, string $id, SimulationFiscalQaPreview $preview): JsonResponse
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$input = $request->validate([
'base' => ['required', 'string', 'max:40', 'regex:/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D'],
'reference_date' => ['required', 'date_format:Y-m-d'],
'currency' => ['required', 'in:MZN'],
'operation' => ['required', 'in:internal_general_net'],
]);
$result = $preview->create($request->user(), $id, $input, $key);
$simulation = $result['simulation'];
return response()->json(['data' => $simulation, 'replayed' => $result['replayed']], $result['replayed'] ? 200 : 202)
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$filters = $request->validate([
@@ -1,31 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SimulationTaxGovernanceAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Services\ScopedPortalAdministration;
final class EnsureScopedPortalAdministration
{
public function handle(Request $request, Closure $next): Response
{
$actor=$request->user();abort_unless($actor,403);
$scope=app(ScopedPortalAdministration::class);
$path=$request->path();
$interests=$request->is('management/interests','management/interests/*','api/v1/investment/admin/interests/*');
// Exact areas of P1; unrelated management, fiscal rules and private records keep their gate.
$catalogue=$request->is('management/economic-references/lot-01','management/economic-references/lot-01/*',
'management/education','management/education/*','management/investment/references','management/investment/references/*','api/v1/investment/admin/references','api/v1/investment/admin/references/*',
'management/investment/zones','management/investment/zones/*','api/v2/investment/admin/zones','api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes','management/balcao/atribuicoes/*','management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path==='management/balcao-install');
$allowed=$interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor));
abort_unless($allowed || ($actor->can('manage-portal') && !app(\App\Services\PortalProfileAccess::class)->has($actor,'auditor')),403,'Acesso reservado ao âmbito já designado.');
$response=$next($request);
if (!$request->isMethod('GET') && $response->getStatusCode()<400) {
app(\App\Services\PortalProfileAccess::class)->audit($actor,'scoped_administration',[
'method'=>$request->method(),'route'=>$request->route()?->uri(),'scope'=>$interests?'assigned-interest':'catalogue-administration']);
$actor = $request->user();
abort_unless($actor, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$catalogue = $request->is('management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
$allowed = ($fiscal && SimulationTaxGovernanceAccess::maintainer($actor)) || ($interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)));
abort_unless($allowed || ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')), 403, 'Acesso reservado ao âmbito já designado.');
$response = $next($request);
if (! $request->isMethod('GET') && $response->getStatusCode() < 400) {
app(PortalProfileAccess::class)->audit($actor, 'scoped_administration', [
'method' => $request->method(), 'route' => $request->route()?->uri(), 'scope' => $fiscal ? 'fiscal-maintenance' : ($interests ? 'assigned-interest' : 'catalogue-administration')]);
}
return $response;
}
}
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -71,6 +71,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_execution_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_risk_responses_snapshot' => ['nullable', 'string', 'max:30000'],
@@ -11,10 +11,47 @@ class StoreSimulationRequest extends FormRequest
return true;
}
public function messages(): array
{
return [
'required' => 'Preencha o campo :attribute. As entradas foram preservadas.',
'string' => 'Indique :attribute em formato textual.',
'regex' => 'Indique uma base decimal positiva ou zero, com ponto decimal e sem separadores de milhares.',
'max' => 'O campo :attribute excede o tamanho permitido.',
'in' => 'A opção indicada em :attribute não pertence a este cálculo.',
'date_format' => 'Indique uma data de exigibilidade válida.',
'boolean' => 'Indique se declara o enquadramento da operação.',
'prohibited' => 'O campo :attribute não pode ser definido pelo cliente neste cálculo.',
];
}
public function attributes(): array
{
return ['base' => 'base tributável', 'currency' => 'moeda', 'reference_date' => 'data de exigibilidade',
'operation' => 'operação', 'scope_declared' => 'declaração de enquadramento', 'scenario_kind' => 'tipo de cálculo',
'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução'];
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
if ($this->input('scenario_kind') === 'fiscal_rule_published') {
return [
'scenario_kind' => ['required', 'in:fiscal_rule_published'],
'base' => ['required', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/'],
'currency' => ['required', 'in:MZN'],
'reference_date' => ['required', 'date_format:Y-m-d'],
'operation' => ['required', 'in:internal_general_net'],
'scope_declared' => ['required', 'boolean'],
'organization_id' => ['nullable', 'uuid', 'exists:organizations,id'],
'rate' => ['prohibited'], 'reduction' => ['prohibited'],
'rule_id' => ['prohibited'], 'rule_version' => ['prohibited'],
'fiscal_preview' => ['prohibited'],
];
}
return [
'scenario_kind' => ['sometimes', 'in:custom_planning'],
'base' => ['required', 'numeric', 'min:0'],
'rate' => ['required', 'numeric', 'between:0,100'],
'reduction' => ['required', 'numeric', 'between:0,100'],
+47 -22
View File
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Models\Simulation;
use App\Services\SimulationExecution;
use App\Support\IsolatedQaDatabase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -62,29 +63,39 @@ class RunSimulation implements ShouldQueue
throw new \RuntimeException('Simulation has no scenario.');
}
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
$resultId = (string) Str::uuid();
$engineVersion = 'portal-arithmetic-v1+simulation-v'.$simulation->version_no;
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value,
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1',
'contract_version' => 1,
'context' => [
'reference_date' => substr((string) $scenario->created_at, 0, 10),
'currency' => null,
'currency_fraction_digits' => null,
'scenario_id' => (string) $scenario->id,
'scenario_version' => (int) $scenario->version_no,
'engine_version' => $engineVersion,
'ruleset_version' => null,
],
'result' => [
'status' => 'calculated',
'value' => (string) $row->value,
'unit' => null,
'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.',
'missing_inputs' => [],
'source_ids' => [],
]];
if (in_array($input['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)) {
if ($input['scenario_kind'] === 'fiscal_rule_qa_preview') {
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
}
$result = $input['fiscal_preview'];
$engineVersion = $result['context']['engine_version'];
$result['context']['scenario_id'] = (string) $scenario->id;
$result['context']['scenario_version'] = (int) $scenario->version_no;
} else {
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'scenario_kind' => 'custom_planning', 'indicative_total' => $row->value,
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-arithmetic-v1',
'contract_version' => 1,
'context' => [
'reference_date' => substr((string) $scenario->created_at, 0, 10),
'currency' => null,
'currency_fraction_digits' => null,
'scenario_id' => (string) $scenario->id,
'scenario_version' => (int) $scenario->version_no,
'engine_version' => $engineVersion,
'ruleset_version' => null,
],
'result' => [
'status' => 'calculated',
'value' => (string) $row->value,
'unit' => null,
'explanation' => 'Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.',
'missing_inputs' => [],
'source_ids' => [],
]];
}
$json = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
$resultHash = hash('sha256', $canonicalResult);
@@ -96,11 +107,25 @@ class RunSimulation implements ShouldQueue
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'succeeded', 'finished_at' => now(), 'updated_at' => now()]);
DB::table('simulation_reports')->insert(['id' => (string) Str::uuid(), 'simulation_id' => $simulation->id, 'version_no' => $scenario->version_no,
'status' => 'ready', 'report_snapshot' => $json, 'report_sha256' => $resultHash, 'created_at' => now(), 'updated_at' => now()]);
$sourceJson = json_encode($input, JSON_THROW_ON_ERROR);
$sourceJson = json_encode(isset($result['fiscal_provenance']) ? [
'base' => $result['base'], 'currency' => $result['currency'],
'reference_date' => $result['context']['reference_date'],
'context' => $result['fiscal_provenance']['context'], 'scope_status' => $result['fiscal_provenance']['scope_status'],
] : $input, JSON_THROW_ON_ERROR);
$canonicalSource = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$sourceJson])->canonical;
DB::table('simulation_result_sources')->insertOrIgnore(['id' => (string) Str::uuid(), 'result_id' => $resultId,
'source_type' => 'user_input', 'source_id' => $scenario->id, 'source_version' => (string) $scenario->version_no,
'source_snapshot' => $sourceJson, 'source_sha256' => hash('sha256', $canonicalSource), 'created_at' => now()]);
if (isset($result['fiscal_provenance'])) {
$provenance = $result['fiscal_provenance'];
$provenanceJson = json_encode($provenance, JSON_THROW_ON_ERROR);
$canonicalProvenance = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$provenanceJson])->canonical;
DB::table('simulation_result_sources')->insert([
'id' => (string) Str::uuid(), 'result_id' => $resultId, 'source_type' => $input['scenario_kind'] === 'fiscal_rule_published' ? 'tax_rule_published' : 'tax_rule_draft',
'source_id' => $provenance['rule']['id'], 'source_version' => (string) $provenance['rule']['rule_version'],
'source_snapshot' => $provenanceJson, 'source_sha256' => hash('sha256', $canonicalProvenance), 'created_at' => now(),
]);
}
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $simulation->owner_user_id,
'action' => 'simulation.completed', 'correlation_id' => $simulation->id, 'data' => json_encode(['result_sha256' => hash('sha256', $json), 'attempt' => $job->attempts + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
}, 3);
+5
View File
@@ -16,4 +16,9 @@ class InvestmentLegalSource extends Model
use HasUuids;
protected $guarded = ['id'];
protected function casts(): array
{
return ['fiscal_acceptance' => 'array'];
}
}
+89 -11
View File
@@ -18,6 +18,7 @@ class InvestmentCatalogue
public function filters(Request $request): array
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
'opportunity_id' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'],
@@ -40,6 +41,17 @@ class InvestmentCatalogue
'publication_state' => ['nullable', 'in:published'],
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
app(InvestmentGeography::class)->selection($filters);
$aliases = ['park' => 'park_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id', 'project' => 'project_id'];
$alias = $aliases[$filters['association_type'] ?? ''] ?? null;
if ($alias && ! empty($filters['association_target']) && empty($filters['association_purpose']) && empty($filters['association_state'])) {
if (! empty($filters[$alias]) && $filters[$alias] !== $filters['association_target']) {
throw \Illuminate\Validation\ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
}
if (\Illuminate\Support\Str::isUuid($filters['association_target'])) {
$filters[$alias] = $filters['association_target'];
unset($filters['association_type'], $filters['association_target']);
}
}
return $filters;
}
@@ -51,6 +63,7 @@ class InvestmentCatalogue
public function query(array $filters): Builder
{
$query = Opportunity::published()->with('sector:id,name,slug');
$associated = $this->publicAssociatedIds($filters);
if (($filters['territory_basis'] ?? 'all') === 'direct') {
$query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id')));
} elseif (($filters['territory_basis'] ?? 'all') === 'context') {
@@ -92,15 +105,17 @@ class InvestmentCatalogue
}
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) {
if (! empty($filters[$field])) {
$query->whereIn('opportunities.id', app(InvestmentZones::class)->links()
$legacy = app(InvestmentZones::class)->links()->where('zl.association_type', '<>', 'proximity')
->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id')
->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $category)
->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id'));
->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id');
$query->where(fn ($matches) => $matches->whereIn('opportunities.id', $legacy)
->orWhereIn('opportunities.id', $associated[$field] ?? []));
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('description', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))
->orWhereExists(fn ($g) => $g->selectRaw('1')->from('geo_locations as search_location')->whereNull('search_location.deleted_at')->where('search_location.data_classification', 'public')
->where('search_location.name', 'ilike', $term)->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
@@ -116,11 +131,21 @@ class InvestmentCatalogue
if (! empty($filters['sector'])) {
SectorOrganization::constrain($query, $filters['sector']);
}
foreach (['province', 'district', 'maturity_level'] as $field) {
foreach (['province', 'district'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['preparation'])) {
OpportunityMaturity::constrainSearch($query, $filters['preparation']);
} elseif (! empty($filters['maturity_level'])) {
// Legacy links predate the eight-stage catalogue: retain their original meaning.
OpportunityMaturity::constrainSearch($query, match ((int) $filters['maturity_level']) {
7 => 'stage:implementation',
8 => 'stage:operational',
default => 'historical:'.$filters['maturity_level'],
});
}
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
if (! empty($filters[$foreign])) {
$locationIds = $relation === 'locations'
@@ -141,13 +166,15 @@ class InvestmentCatalogue
continue;
}
if (in_array($relation, ['clusters', 'corridors'], true)) {
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')
$query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')
->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])
->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign)));
->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign)))
->orWhereIn('opportunities.id', $associated[$foreign] ?? []));
} else {
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign)
$query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign)
->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at')
->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)));
->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)))
->orWhereIn('opportunities.id', $associated[$foreign] ?? []));
}
}
}
@@ -187,7 +214,9 @@ class InvestmentCatalogue
* @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
{
$path = app(InvestmentGeography::class)->selection($territoryFilters);
$result = ['classification' => app(OpportunityClassification::class)->options(),
'preparationOptions' => OpportunityMaturity::searchOptions(),
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
'categories' => OpportunityManagement::CATEGORIES,
'options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
@@ -205,18 +234,67 @@ class InvestmentCatalogue
$query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign));
}
}
if (! $management && $path !== [] && $relation !== 'locations') {
$dossiers = InvestmentPortfolioController::visible();
app(InvestmentGeography::class)->constrainDossiersToTerritories($dossiers,
app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
$query->whereIn('id', $dossiers->whereNotNull($foreign)->select($foreign));
}
$result[$relation] = $query->orderBy('name')->get(['id', 'name']);
}
$zones = app(InvestmentZones::class)->visible();
if (! $management && $territoryFilters !== []) {
$zones = app(InvestmentZones::class)->search($territoryFilters);
// Opportunity words/modalities are not names/categories of zones.
$zones = app(InvestmentZones::class)->search(\Illuminate\Support\Arr::only($territoryFilters,
['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id']));
}
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
if (! $management && ($path !== [] || ! empty($territoryFilters['sector']))) {
$scope = \Illuminate\Support\Arr::only($territoryFilters, ['sector','province_id','district_id','admin_post_id','locality_id','include_national']);
$available = ['chain' => [], 'activity' => []];
$this->query($scope)->reorder()->chunkById(200, function ($records) use (&$available) {
foreach ($records as $record) {
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
if (isset($available[$row['target_type']])) $available[$row['target_type']][] = $row['target_id'];
}
foreach (['subsector_id','economic_activity_id'] as $key) {
$id = $record->workflow_context['publication']['taxonomy'][$key] ?? null;
if ($id) $available['activity'][] = $id;
}
}
}, 'opportunities.id', 'id');
foreach ($available as $kind => $ids) {
$result['classification']['targets'][$kind] = collect($result['classification']['targets'][$kind])->whereIn('id', $ids)->values();
}
}
return $result;
}
private function publicAssociatedIds(array $filters): array
{
$kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'],
'cluster_id' => ['cluster'], 'corridor_id' => ['corridor'], 'project_id' => ['project']];
$selected = array_filter(array_intersect_key($filters, $kinds));
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $key => $category) {
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) unset($selected[$key]);
}
if ($selected === []) return [];
$ids = [];
Opportunity::published()->select('opportunities.*')->chunkById(200, function ($records) use ($selected, $kinds, &$ids) {
foreach ($records as $record) {
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') continue;
foreach ($selected as $field => $target) {
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id;
}
}
}
});
return $ids;
}
private function publicStructureIds(string $relation, string $foreign): \Illuminate\Database\Query\Builder
{
$table = self::RELATIONS[$relation][1];
@@ -329,11 +407,11 @@ class InvestmentCatalogue
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")])
->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null,
'url' => '/oportunidades/'.$area->slug])->all();
'url' => '/oportunidades/'.$area->slug.($filters === [] ? '' : '?'.http_build_query($filters))])->all();
$located = 0;
foreach ($records as $record) {
$properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name,
'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug];
'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
+32 -3
View File
@@ -35,7 +35,7 @@ final class LegacyContent
}
/** @return array<string, mixed> */
public function article(string $slug = 'agronegocio'): array
public function article(string $slug = 'agronegocio', bool $includeRelated = true): array
{
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
@@ -80,13 +80,26 @@ final class LegacyContent
}
preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks);
$alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]);
$relatedArticles = array_values(array_filter($this->related($identity['group'], $slug), fn ($item) => !in_array($item['href'], $alreadyLinked, true)));
$relatedArticles = [];
if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) {
if (in_array($item['href'], $alreadyLinked, true)) continue;
try {
$relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) {
if ($error->getStatusCode() !== 404) throw $error;
}
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
'href' => '/'.$slug, 'html' => $localHtml,
'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml),
'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null,
'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null,
'has_opportunities' => count($opportunities) > 0,
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
'group' => config('legacy_content.groups.'.$identity['group']),
'related_articles' => $relatedArticles,
@@ -101,6 +114,22 @@ final class LegacyContent
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
public static function excerpt(string $html): string
{
$plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo')
&& str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) {
return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320);
}
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Resolve a library identity, preserving the already stored source URLs and versions. */
/** @return array<string, mixed> */
public function articleForResource(int $resourceId): array
+1 -1
View File
@@ -383,7 +383,7 @@ class OpportunityClassification
$matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']);
}
if (! empty($filters['subsector_id'])) {
$matches = $matches && (($record->workflow_context['publication']['taxonomy']['subsector_id'] ?? '') === $filters['subsector_id'] || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
$matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id','economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
}
if ($matches) {
$ids[] = $record->id;
+26
View File
@@ -24,6 +24,32 @@ final class OpportunityMaturity
public const LEGACY_LABELS = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
/** Public selection is explicit; historical numbers never stand for current stages. */
public static function searchOptions(): array
{
$options = [];
foreach (self::STAGES as $code => $label) {
$options[] = ['value' => 'stage:'.$code, 'label' => $label, 'group' => 'Etapas de preparação'];
}
foreach (self::LEGACY_LABELS as $level => $label) {
$options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas'];
}
return $options;
}
public static function constrainSearch(\Illuminate\Database\Eloquent\Builder $query, string $selection): void
{
[$kind, $value] = explode(':', $selection, 2);
if ($kind === 'stage') {
$query->where('workflow_context->publication->maturity->catalogue_version', self::VERSION)
->where('workflow_context->publication->maturity->stage', $value);
} else {
$query->where(fn ($q) => $q->where('workflow_context->publication->maturity->catalogue_version', self::LEGACY)
->orWhere(fn ($old) => $old->whereNull('workflow_context->publication->maturity->catalogue_version')->whereNull('workflow_context->publication->maturity->stage')))
->where('maturity_level', (int) $value);
}
}
/** @param array<string,mixed> $data
* @return array<string,mixed> */
public function normalize(array $data): array
+21 -13
View File
@@ -37,19 +37,7 @@ final class SectorInvestment
public function publicView(Sector $sector): array
{
$query = SectorOrganization::opportunities($sector);
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug);
$articles[] = ['title' => $a['title'], 'href' => $a['href'], 'image' => $a['image'], 'sha256' => $a['sha256'], 'scope' => $slug === 'tech-fintech' && $sector->slug !== 'fintech' ? 'Especialização Fintech; não representa todo o setor.' : 'Conteúdo educativo relacionado; não é uma oportunidade concreta.'];
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
$articles = $this->guides($sector);
$relations = [];
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $table => $foreign) {
$ids = DB::table('opportunity_'.$table)->whereIn('opportunity_id', (clone $query)->select('id'))->pluck($foreign);
@@ -74,6 +62,26 @@ final class SectorInvestment
'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null];
}
/** Published guides for an explicit sector; never inferred from proximity. */
public function guides(?Sector $sector): array
{
if (! $sector) return [];
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
return $articles;
}
public function attach(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate();
+39 -6
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Jobs\RunSimulation;
use App\Models\Simulation;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -17,15 +18,39 @@ final class SimulationExecution
*/
public function create(array $data, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
{
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
$scope = $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous');
$scopeHash = hash('sha256', $scope);
$keyHash = hash('sha256', strtolower($key));
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'scenario_kind' => 'custom_planning', 'organization_id' => $organizationId];
return $this->createPayload($payload, $actorId, $organizationId, $token, $key);
}
public function createFiscalQaPreview(array $snapshot, int $actorId, string $key): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
abort_unless(($snapshot['scenario_kind'] ?? null) === 'fiscal_rule_qa_preview'
&& in_array($snapshot['result']['status'] ?? null, ['calculated', 'indeterminate'], true)
&& ($snapshot['presentation']['rounding_applied'] ?? null) === false, 422);
return $this->createPayload(['scenario_kind' => 'fiscal_rule_qa_preview', 'fiscal_preview' => $snapshot, 'organization_id' => null],
$actorId, null, null, $key);
}
public function createPublishedFiscal(array $input, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
{
$payload = ['scenario_kind' => 'fiscal_rule_published', 'fiscal_input' => $input, 'organization_id' => $organizationId];
return $this->createPayload($payload, $actorId, $organizationId, $token, $key,
fn (): array => app(SimulationPublishedFiscal::class)->admit($input));
}
private function createPayload(array $payload, ?int $actorId, ?string $organizationId, ?string $token, string $key, ?callable $admit = null): array
{
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
$scopeHash = hash('sha256', $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous'));
$keyHash = hash('sha256', strtolower($key));
ksort($payload);
$payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload): array {
return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload, $admit): array {
$this->setContext($actorId, $tokenHash);
DB::select('select pg_advisory_xact_lock(261001, hashtext(?))', [$scopeHash.$keyHash]);
$receipt = DB::table('simulation_idempotency')->where('scope_hash', $scopeHash)->where('key_hash', $keyHash)->first();
@@ -37,6 +62,10 @@ final class SimulationExecution
return ['simulation' => Simulation::with(['scenarios', 'reports'])->whereKey((string) $receipt->simulation_id)->firstOrFail(), 'token' => null, 'replayed' => true];
}
if ($admit !== null) {
$payload['fiscal_preview'] = $admit();
}
$plainToken = $actorId === null ? $token : null;
$tokenHash = $actorId === null ? hash_hmac('sha256', $plainToken, (string) config('app.key')) : null;
$this->setContext($actorId, $tokenHash);
@@ -44,7 +73,9 @@ final class SimulationExecution
$simulation = Simulation::create([
'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId,
'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1,
'input_snapshot' => $payload, 'calculation_snapshot' => ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
'input_snapshot' => $payload, 'calculation_snapshot' => in_array($payload['scenario_kind'], ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true)
? ['engine' => $payload['fiscal_preview']['context']['engine_version'], 'mode' => $payload['fiscal_preview']['fiscal_provenance']['mode'], 'arithmetic' => 'exact_decimal_no_rounding']
: ['engine' => 'portal-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
]);
$scenarioId = (string) Str::uuid();
DB::table('simulation_scenarios')->insert([
@@ -85,6 +116,8 @@ final class SimulationExecution
return DB::transaction(function () use ($id, $scenarioId, $data, $request): Simulation {
$this->setContext($request->user()?->id, $this->tokenHash($request));
$simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
abort_if(in_array($simulation->input_snapshot['scenario_kind'] ?? null, ['fiscal_rule_qa_preview', 'fiscal_rule_published'], true), 422,
'Crie uma nova pré-visualização fiscal; este snapshot conserva a regra e as entradas originais.');
$this->requireMatch($request, $simulation);
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first();
abort_if($scenario === null, 404);
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
/** Exact operation-level IVA calculation against an explicit QA draft; never a payment/return total. */
final class SimulationFiscalQaPreview
{
public function create(User $actor, string $ruleId, array $input, string $key): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
return DB::transaction(function () use ($actor, $ruleId, $input, $key): array {
$rule = app(SimulationTaxRuleGovernance::class)->show($actor, $ruleId);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422,
'Este percurso preserva o valor exacto e não aplica arredondamento automático.');
$source = InvestmentLegalSource::findOrFail($rule->legal_source_id);
$version = $rule->attributesToArray();
$version['source'] = $source->attributesToArray();
$date = $input['reference_date'];
$context = ['operation' => $input['operation'], 'currency' => $input['currency']];
$missing = [];
$resolution = [];
$evaluation = null;
if ($date < '2026-01-01' || $date > '2026-12-31') {
$missing[] = 'demonstrated_coverage';
} else {
$resolution = app(SimulationTaxParameterResolver::class)->resolveDraftForQa([$version], 'IVA', $date, $date, $context);
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
if (($rate['status'] ?? null) !== 'resolved' || ($rate['unit'] ?? null) !== 'percent') {
$missing[] = 'eligible_parameter';
} else {
$evaluation = app(SimulationTaxRuleExpression::class)->evaluate($rule->rule_definition,
['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]);
}
}
$snapshot = app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing);
return app(SimulationExecution::class)->createFiscalQaPreview($snapshot, $actor->id, $key);
});
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Brick\Math\BigRational;
/** Shared snapshot representation; eligibility is resolved by each admission adapter. */
final class SimulationFiscalSnapshot
{
public function build(array $version, array $input, array $resolution, ?array $evaluation, array $missing, bool $published = false): array
{
$date = $input['reference_date'];
$context = ['operation' => $input['operation'], 'currency' => $input['currency']];
$exact = ($evaluation['status'] ?? null) === 'determinate'
? (string) BigRational::of($evaluation['result'])->toBigDecimal()
: null;
if ($exact === null && $missing === []) {
$missing = $evaluation['missing'] ?? ['eligible_parameter'];
}
$snapshot = [
'contract_version' => 1, 'scenario_kind' => $published ? 'fiscal_rule_published' : 'fiscal_rule_qa_preview',
'base' => $input['base'], 'currency' => $input['currency'], 'indicative_total' => $exact,
'legal_interpretation' => false, 'formula_version' => 'investmoz.tax.v1',
'context' => [
'reference_date' => $date, 'currency' => $input['currency'], 'currency_fraction_digits' => 2,
'scenario_id' => null, 'scenario_version' => 1, 'engine_version' => $published ? 'fiscal-published-v1' : 'fiscal-draft-preview-v1',
'ruleset_version' => $version['rule_code'].':'.$version['rule_version'],
],
'result' => [
'status' => $exact === null ? 'indeterminate' : 'calculated', 'value' => $exact, 'unit' => 'MZN',
'explanation' => $exact === null
? 'IVA indeterminado: o contexto, a fonte ou o período não permitem resolver o parâmetro deste lote QA.'
: 'IVA calculado com precisão decimal exacta, sem arredondamento. Ensaio QA de regra em rascunho; enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.',
'missing_inputs' => $missing, 'source_ids' => [(string) $version['legal_source_id']],
],
'presentation' => ['value' => $exact, 'currency' => $input['currency'], 'rounding_applied' => false, 'purpose' => 'operation_tax_not_payment'],
// Exact rational steps supplement the decimal result; no float conversion.
'intermediate' => $evaluation,
'fiscal_provenance' => [
'mode' => $published ? 'published_version' : 'qa_draft_only', 'scope_status' => ($input['scope_declared'] ?? true) ? 'user_declared' : 'undetermined', 'context' => $context,
'rounding_policy' => $version['rounding_policy'], 'resolution' => $resolution,
'rule' => $version, 'source_acceptance' => 'Fonte oficial aceite pela decisão Tempo de 08/10/2026; não constitui aprovação jurídica desta operação ou publicação da regra.',
],
];
if ($published) {
$snapshot['result']['explanation'] = $exact === null
? 'IVA indeterminado: falta contexto, fonte ou parâmetro elegível para esta operação.'
: 'IVA calculado pela versão publicada identificada, sem arredondamento. Enquadramento declarado, não validado. Não é saldo de declaração nem valor a pagar.';
$snapshot['fiscal_provenance']['admitted_at'] = now()->toISOString();
$snapshot['fiscal_provenance']['source_acceptance'] = 'Fonte oficial aceite pela Tempo em 08/10/2026; não constitui validação jurídica desta operação.';
}
return $snapshot;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services;
final class SimulationFiscalSourceAcceptance
{
public static function eligible(array $source, ?string $hash = null): bool
{
if (($source['validation_status'] ?? null) === 'validated') {
return true;
}
$basis = $source['fiscal_acceptance'] ?? null;
return ($source['validation_status'] ?? null) === 'pending' && is_array($basis)
&& ($basis['scope'] ?? null) === 'fiscal_parameters'
&& ($basis['basis'] ?? null) === 'Tempo-official-sources-20261008'
&& preg_match('/^[a-f0-9]{64}$/D', (string) ($basis['sha256'] ?? '')) === 1
&& ($hash === null || $basis['sha256'] === $hash);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Admission freezes eligibility without granting the public consumer governance write access. */
final class SimulationPublishedFiscal
{
public function availability(): array
{
$enabled = (bool) config('simulation-fiscal-consumer.enabled');
$eligible = $enabled && $this->boundRule()->where('status', 'published')->exists();
return ['enabled' => $eligible, 'coverage_start' => '2026-01-01', 'coverage_end' => '2026-12-31',
'message' => $eligible ? 'IVA geral sobre base líquida disponível; enquadramento declarado.'
: 'O cálculo de IVA geral sobre base líquida está indisponível para novas execuções. Os históricos mantêm-se acessíveis.'];
}
private function boundRule(): Builder
{
if (! Str::isUuid((string) config('simulation-fiscal-consumer.rule_id')) || config('simulation-fiscal-consumer.rule_version') < 1) {
return SimulationTaxRuleVersion::query()->whereRaw('false');
}
return SimulationTaxRuleVersion::query()->whereKey(config('simulation-fiscal-consumer.rule_id'))
->where('rule_version', config('simulation-fiscal-consumer.rule_version'))
->where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->where('component', 'IVA')
->where('scope_key', 'mz.internal-general-net.mzn');
}
public function admit(array $input): array
{
// Called inside createPayload, after idempotent replay, never by the worker.
abort_unless(DB::transactionLevel() > 0, 500);
abort_unless(config('simulation-fiscal-consumer.enabled'), 503,
'O cálculo de IVA está temporariamente indisponível. Os dados e históricos foram preservados.');
DB::select('select pg_advisory_xact_lock_shared(261021, hashtext(?))', [(string) config('simulation-fiscal-consumer.rule_id')]);
$rule = $this->boundRule()->first();
abort_unless($rule && $rule->status === 'published', 503,
'A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.');
abort_if($input['reference_date'] < '2026-01-01' || $input['reference_date'] > '2026-12-31', 422,
'Este cálculo abrange apenas exigibilidade em 2026. As entradas foram preservadas.');
abort_if(collect($rule->rule_definition['nodes'] ?? [])->contains(fn (array $node): bool => ($node['op'] ?? null) === 'round'), 422,
'A versão seleccionada não é compatível com o cálculo exacto deste consumidor.');
DB::select('select pg_advisory_xact_lock_shared(261022, hashtext(?))', [(string) $rule->legal_source_id]);
$source = InvestmentLegalSource::whereKey($rule->legal_source_id)->firstOrFail();
// Publish calculation provenance, not private governance notes or staff identifiers.
$version = array_intersect_key($rule->attributesToArray(), array_flip([
'id', 'rule_code', 'rule_version', 'component', 'scope_key', 'status', 'valid_from', 'valid_until',
'legal_source_id', 'source_version_no', 'source_sha256', 'rule_definition', 'parameters', 'rounding_policy',
]));
$version['source'] = array_intersect_key($source->attributesToArray(), array_flip([
'id', 'title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no',
]));
$resolution = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA',
$input['reference_date'], $input['reference_date'], ['currency' => $input['currency'], 'operation' => $input['operation']]);
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
$missing = [];
$evaluation = null;
if (! ($input['scope_declared'] ?? false)) {
$missing[] = 'operation_scope';
}
if (($rate['status'] ?? null) !== 'resolved' || ($rate['unit'] ?? null) !== 'percent'
|| ($rate['rule_id'] ?? null) !== (string) $rule->id
|| (int) ($rate['rule_version'] ?? 0) !== (int) $rule->rule_version) {
$missing[] = 'eligible_parameter';
}
if ($missing === []) {
$evaluation = app(SimulationTaxRuleExpression::class)->evaluate($rule->rule_definition,
['base_tributavel' => $input['base']], ['iva_general_rate' => $rate['value']]);
}
return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true);
}
}
+10 -1
View File
@@ -8,15 +8,24 @@ final class SimulationTaxGovernanceAccess
{
private const ROLES = ['editors', 'reviewers', 'approvers', 'publishers'];
public static function maintainer(User $user): bool
{
return $user->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($user, 'auditor') && ($user->can('manage-portal')
|| app(ScopedPortalAdministration::class)->administrative($user));
}
public static function requireRole(User $user, string $role): void
{
abort_unless(in_array($role, self::ROLES, true), 500, 'Função de governação desconhecida.');
abort_unless(in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true),
abort_unless(self::maintainer($user) || in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true),
403, 'O utilizador não está designado para esta função de governação fiscal.');
}
public static function requireAnyRole(User $user): void
{
if (self::maintainer($user)) {
return;
}
foreach (self::ROLES as $role) {
if (in_array(strtolower($user->email), config('simulation-tax-governance.'.$role, []), true)) {
return;
+33 -13
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Support\IsolatedQaDatabase;
use Brick\Math\BigRational;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
@@ -16,32 +17,32 @@ final class SimulationTaxParameterResolver
public function validateParameters(array $parameters): void
{
if ($parameters === [] || count($parameters) > 100) {
throw new InvalidArgumentException('Provide between 1 and 100 versioned parameters.');
throw new InvalidArgumentException('Indique entre 1 e 100 parâmetros versionados.');
}
foreach ($parameters as $code => $parameter) {
if (! is_string($code) || ! preg_match('/^[A-Z][A-Z0-9._-]{1,63}$/', $code) || ! is_array($parameter)
|| array_diff(array_keys($parameter), ['value', 'unit', 'conditions']) !== []
|| array_diff(['value', 'unit', 'conditions'], array_keys($parameter)) !== []) {
throw new InvalidArgumentException('Each parameter requires only value, unit, and conditions.');
throw new InvalidArgumentException('Cada parâmetro deve conter apenas valor, unidade e condições.');
}
if ((! is_string($parameter['value']) && ! is_int($parameter['value'])) || (string) $parameter['value'] === ''
|| ! is_string($parameter['unit']) || ! preg_match('/^[a-z][a-z0-9._-]{0,31}$/', $parameter['unit'])
|| ! is_array($parameter['conditions'])) {
throw new InvalidArgumentException('Invalid parameter value, unit, or conditions.');
throw new InvalidArgumentException('Reveja o valor, a unidade e as condições do parâmetro.');
}
$isNumericValue = preg_match('/^-?\d+(?:\.\d+)?$/', (string) $parameter['value']) === 1;
if (in_array($parameter['unit'], ['percent', 'mzn', 'amount', 'days', 'count', 'mzn_per_unit', 'per_unit'], true) && ! $isNumericValue) {
throw new InvalidArgumentException('Numeric parameter units require a plain decimal value.');
throw new InvalidArgumentException('Esta unidade requer um valor numérico decimal.');
}
if ($isNumericValue) {
$value = BigRational::of((string) $parameter['value']);
if ($value->isNegative() || ($parameter['unit'] === 'percent' && $value->compareTo(100) > 0)) {
throw new InvalidArgumentException('Numeric parameters cannot be negative; percentages must be between 0 and 100.');
throw new InvalidArgumentException('Os valores não podem ser negativos; as percentagens devem estar entre 0 e 100.');
}
}
foreach ($parameter['conditions'] as $condition => $expected) {
if (! is_string($condition) || ! is_string($expected)) {
throw new InvalidArgumentException('Conditions must be a flat map of string assumptions.');
throw new InvalidArgumentException('Indique as condições numa lista simples de nomes e valores em texto.');
}
}
}
@@ -59,7 +60,7 @@ final class SimulationTaxParameterResolver
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
if ($end < $start || $start->diff($end)->days > 365) {
throw new InvalidArgumentException('Invalid effective period.');
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
}
$rows = DB::table('simulation_tax_rule_versions as rule')
@@ -68,7 +69,9 @@ final class SimulationTaxParameterResolver
->where('rule.status', 'published')
->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
->where('source.validation_status', 'validated')
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
->whereColumn('source.version_no', 'rule.source_version_no')
->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
@@ -77,7 +80,7 @@ final class SimulationTaxParameterResolver
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
'rule.source_version_no', 'rule.source_sha256',
'source.title as source_title', 'source.diploma as source_diploma', 'source.article as source_article',
'source.url as source_url', 'source.validation_status as source_validation_status',
'source.url as source_url', 'source.validation_status as source_validation_status', 'source.fiscal_acceptance',
'source.version_no as source_current_version', 'source.valid_from as source_valid_from',
'source.valid_until as source_valid_until',
])
@@ -110,6 +113,7 @@ final class SimulationTaxParameterResolver
'article' => $row->source_article,
'url' => $row->source_url,
'validation_status' => $row->source_validation_status,
'fiscal_acceptance' => is_array($row->fiscal_acceptance) ? $row->fiscal_acceptance : json_decode($row->fiscal_acceptance ?? 'null', true),
'version_no' => (int) $row->source_current_version,
'valid_from' => (string) $row->source_valid_from,
'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until,
@@ -126,15 +130,31 @@ final class SimulationTaxParameterResolver
* @return list<array<string,mixed>>
*/
public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
{
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'published');
}
/** Explicit draft preview. Never used by the published-parameter API. */
public function resolveDraftForQa(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'draft');
}
private function resolveWithStatus(array $versions, string $component, string $periodStart, string $periodEnd, array $context, string $requiredStatus): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
if ($end < $start || $start->diff($end)->days > 365) {
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
}
$groups = [];
foreach ($versions as $version) {
if (($version['component'] ?? null) !== $component
|| ($version['status'] ?? null) !== 'published'
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| ($version['status'] ?? null) !== $requiredStatus
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->overlaps($version, $start, $end)
|| ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end)
@@ -232,7 +252,7 @@ final class SimulationTaxParameterResolver
|| ($version['component'] ?? null) !== $component
|| ($version['scope_key'] ?? null) !== $scopeKey
|| ! $this->covers($version, $date)
|| ($version['source']['validation_status'] ?? null) !== 'validated'
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|| ! $this->covers($version['source'], $date)
|| ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) {
@@ -315,7 +335,7 @@ final class SimulationTaxParameterResolver
{
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new InvalidArgumentException('Effective date must use YYYY-MM-DD.');
throw new InvalidArgumentException('Indique uma data de aplicação válida no formato ano-mês-dia.');
}
return $parsed;
+14 -14
View File
@@ -95,16 +95,16 @@ final class SimulationTaxRuleExpression
private function topologicalOrder(array $definition): array
{
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
throw new InvalidArgumentException('Unsupported expression definition.');
throw new InvalidArgumentException('O formato da expressão não é suportado.');
}
$nodes = $definition['nodes'];
if ($nodes === [] || count($nodes) > 100) {
throw new InvalidArgumentException('Expression must contain between 1 and 100 nodes.');
throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.');
}
foreach ($nodes as $id => $node) {
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
throw new InvalidArgumentException('Invalid expression node.');
throw new InvalidArgumentException('Reveja o elemento indicado na expressão.');
}
$op = $node['op'];
$allowedFields = match ($op) {
@@ -112,40 +112,40 @@ final class SimulationTaxRuleExpression
'literal' => ['op', 'value'],
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
'round' => ['op', 'args', 'scale', 'mode'],
default => throw new InvalidArgumentException('Expression operation is not allowed.'),
default => throw new InvalidArgumentException('Esta operação não é suportada na expressão.'),
};
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
throw new InvalidArgumentException('Expression node has missing or unsupported fields.');
throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.');
}
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
throw new InvalidArgumentException('Invalid input or parameter key.');
throw new InvalidArgumentException('Reveja o identificador da entrada ou do parâmetro.');
}
if ($op === 'literal') {
$this->number($node['value']);
}
if (isset($node['args'])) {
if (! is_array($node['args'])) {
throw new InvalidArgumentException('Invalid expression arguments.');
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
}
$count = count($node['args']);
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
throw new InvalidArgumentException('Invalid expression arguments.');
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
}
}
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
throw new InvalidArgumentException('Invalid explicit rounding configuration.');
throw new InvalidArgumentException('Reveja a configuração de arredondamento.');
}
}
if (! array_key_exists($definition['result'], $nodes)) {
throw new InvalidArgumentException('Expression result references an unknown node.');
throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.');
}
$state = [];
$order = [];
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
if (($state[$id] ?? 0) === 1) {
throw new InvalidArgumentException('Expression dependency graph contains a cycle.');
throw new InvalidArgumentException('A expressão contém uma dependência circular.');
}
if (($state[$id] ?? 0) === 2) {
return;
@@ -153,7 +153,7 @@ final class SimulationTaxRuleExpression
$state[$id] = 1;
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
throw new InvalidArgumentException('Expression references an unknown node.');
throw new InvalidArgumentException('A expressão refere um elemento inexistente.');
}
$visit($dependency);
}
@@ -191,13 +191,13 @@ final class SimulationTaxRuleExpression
if ($op === 'min' || $op === 'max') {
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
}
throw new InvalidArgumentException('Expression operation is not allowed.');
throw new InvalidArgumentException('Esta operação não é suportada na expressão.');
}
private function number(mixed $value): BigRational
{
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d+(?:\.\d+)?$/', (string) $value)) {
throw new InvalidArgumentException('Numbers must be plain decimal strings or integers.');
throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.');
}
return BigRational::of((string) $value);
+9 -8
View File
@@ -65,6 +65,8 @@ final class SimulationTaxRuleGovernance
return DB::transaction(function () use ($actor, $id, $version, $action, $evidence): SimulationTaxRuleVersion {
$this->setContext($actor);
// Serialize public admission with publication/suspension, without widening RLS.
DB::select('select pg_advisory_xact_lock(261021, hashtext(?))', [$id]);
$rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id);
$this->matchVersion($rule, $version);
$next = ['version_no' => $version + 1];
@@ -78,15 +80,12 @@ final class SimulationTaxRuleGovernance
break;
case 'review':
case 'reject':
abort_unless((int) $rule->author_id !== $actor->id, 403, 'O autor não pode rever a própria regra.');
abort_unless($rule->status === 'submitted', 422, 'A regra tem de estar submetida para revisão.');
$this->sourceStillValid($rule);
$next += ['status' => $action === 'review' ? 'reviewed' : 'rejected', 'reviewer_id' => $actor->id,
'review_evidence' => $evidence, 'reviewed_at' => now()];
break;
case 'approve':
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id, 403,
'Autor e revisor não podem aprovar a mesma regra.');
abort_unless($rule->status === 'reviewed', 422, 'A regra tem de estar revista antes da aprovação.');
$this->sourceStillValid($rule);
$next += ['status' => 'approved', 'approver_id' => $actor->id,
@@ -94,9 +93,6 @@ final class SimulationTaxRuleGovernance
break;
case 'publish':
abort_unless($rule->status === 'approved', 422, 'A regra tem de estar aprovada antes da publicação.');
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id
&& (int) $rule->approver_id !== $actor->id, 403,
'Autor, revisor e aprovador não podem publicar a mesma regra.');
$this->sourceStillValid($rule);
$this->assertNoPublishedOverlap($rule);
$next += ['status' => 'published', 'publisher_id' => $actor->id,
@@ -155,7 +151,11 @@ final class SimulationTaxRuleGovernance
$source = InvestmentLegalSource::query()->whereKey($data['legal_source_id'])->firstOrFail();
abort_unless(preg_match('/^[a-f0-9]{64}$/D', (string) $data['source_sha256']) === 1, 422,
'Indique o SHA-256 do exemplar oficial efectivamente revisto.');
abort_unless($source->validation_status === 'validated', 422, 'A fonte jurídica ainda não foi validada.');
abort_unless(SimulationFiscalSourceAcceptance::eligible($source->attributesToArray(), $data['source_sha256']), 422,
'A fonte ainda não está aceite para este cadastro fiscal.');
abort_if(is_array($source->fiscal_acceptance)
&& ($source->fiscal_acceptance['sha256'] ?? null) !== $data['source_sha256'], 422,
'O hash da regra deve corresponder ao exemplar registado na aceitação da fonte fiscal.');
abort_unless($source->valid_from <= $data['valid_from']
&& ($source->valid_until === null || ($data['valid_until'] !== null && $source->valid_until >= $data['valid_until']))
&& ($data['valid_until'] !== null || $source->valid_until === null), 422,
@@ -167,7 +167,8 @@ final class SimulationTaxRuleGovernance
private function sourceStillValid(SimulationTaxRuleVersion $rule): void
{
$source = InvestmentLegalSource::query()->lockForUpdate()->find($rule->legal_source_id);
abort_unless($source !== null && $source->validation_status === 'validated'
abort_unless($source !== null && SimulationFiscalSourceAcceptance::eligible($source->attributesToArray(), $rule->source_sha256)
&& (! is_array($source->fiscal_acceptance) || ($source->fiscal_acceptance['sha256'] ?? null) === $rule->source_sha256)
&& (int) $source->version_no === (int) $rule->source_version_no
&& $source->valid_from <= $rule->valid_from->format('Y-m-d')
&& ($source->valid_until === null || ($rule->valid_until !== null && $source->valid_until >= $rule->valid_until->format('Y-m-d')))
+24 -1
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\AuditProfileDecision;
use App\Http\Middleware\EnsureHomologationAccess;
use App\Http\Middleware\EnsureSimulationRecoveryCookie;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
@@ -30,7 +31,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]);
$middleware->web(append: [
\App\Http\Middleware\EnsureHomologationAccess::class,
EnsureHomologationAccess::class,
PortalProfileSession::class,
ProtectPrivilegedIdentity::class,
AuditProfileDecision::class,
@@ -68,6 +69,28 @@ return Application::configure(basePath: dirname(__DIR__))
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->respond(function (Response $response, Throwable $exception, Request $request): Response {
if (($request->is('api/*') || $request->expectsJson()) && $response->getStatusCode() >= 400) {
$body = json_decode((string) $response->getContent(), true);
$genericMessages = [
'Not Found' => 'O registo não foi encontrado ou não está acessível nesta conta.',
'Not found.' => 'O registo não foi encontrado ou não está acessível nesta conta.',
'Forbidden' => 'Esta conta não tem acesso a esta operação.',
'Forbidden.' => 'Esta conta não tem acesso a esta operação.',
'Unauthenticated.' => 'Entre na sua conta para continuar esta operação.',
'CSRF token mismatch.' => 'A sessão expirou. Entre novamente antes de repetir a operação.',
'Too Many Attempts.' => 'Foram feitas várias tentativas. Aguarde um pouco e tente novamente.',
'Server Error' => 'Não foi possível concluir a operação. Tente novamente.',
];
if (is_array($body) && is_string($body['message'] ?? null) && isset($genericMessages[$body['message']])) {
$body['message'] = $genericMessages[$body['message']];
$response->setContent(json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
} elseif (is_array($body) && is_string($body['message'] ?? null)
&& $response->getStatusCode() === 404 && str_starts_with($body['message'], 'The route ')
&& str_ends_with($body['message'], ' could not be found.')) {
$body['message'] = $genericMessages['Not Found'];
$response->setContent(json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
}
if ($request->is('api/*')) {
$response->headers->set(
'X-Correlation-ID',
+1 -1
View File
@@ -78,7 +78,7 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'pt'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
// Publication and consumer exposure are separate Tempo decisions.
'enabled' => env('INVESTMOZ_IVA_CONSUMER_ENABLED', false),
'rule_id' => env('INVESTMOZ_IVA_CONSUMER_RULE_ID'),
'rule_version' => (int) env('INVESTMOZ_IVA_CONSUMER_RULE_VERSION', 0),
];
+3 -2
View File
@@ -1,8 +1,9 @@
<?php
return [
// These lists are deliberately empty by default. A portal manager and MFA are
// also required; governance roles never activate rules in the calculation engine.
// Optional specific assignments. Existing authorised administration can maintain
// fiscal parameters with MFA, without four people or four email designations.
// Rule publication never activates the calculation consumer by itself.
'editors' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_EDITORS', '')))))),
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_REVIEWERS', '')))))),
'approvers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TAX_RULE_APPROVERS', '')))))),
@@ -0,0 +1,68 @@
<?php
use App\Support\IsolatedQaDatabase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new LogicException('Ensaie esta correcção apenas numa base QA isolada. A aplicação no destino exige decisão, backup/restauro e acção autenticada.');
}
// Only fiscal rule maintenance changes. Opportunity/zone workflow checks remain intact.
DB::statement('ALTER TABLE simulation_tax_rule_versions DROP CONSTRAINT tax_rule_stage_evidence_check, DROP CONSTRAINT tax_rule_approval_check, DROP CONSTRAINT tax_rule_publication_check');
DB::statement("ALTER TABLE simulation_tax_rule_versions
ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)),
ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)),
ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
DB::statement('ALTER TABLE investment_legal_sources ADD COLUMN fiscal_acceptance jsonb NULL');
DB::statement("ALTER TABLE investment_legal_sources ADD CONSTRAINT investment_legal_source_fiscal_acceptance_check CHECK (
fiscal_acceptance IS NULL OR COALESCE((
fiscal_acceptance->>'scope'='fiscal_parameters' AND
fiscal_acceptance->>'basis'='Tempo-official-sources-20261008' AND
fiscal_acceptance->>'responsible_user_id'=owner_id::text AND
fiscal_acceptance->>'sha256' ~ '^[a-f0-9]{64}$' AND
fiscal_acceptance->>'legal_opinion'='false' AND fiscal_acceptance->>'independent_review'='false' AND
length(trim(fiscal_acceptance->>'citation'))>=10 AND
length(trim(fiscal_acceptance->>'official_origin'))>=10
), false))");
DB::statement('DROP POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions');
DB::statement("CREATE POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions FOR SELECT USING (
status='published' AND EXISTS (SELECT 1 FROM investment_legal_sources source
WHERE source.id=simulation_tax_rule_versions.legal_source_id
AND (source.validation_status='validated' OR (source.validation_status='pending'
AND source.fiscal_acceptance->>'scope'='fiscal_parameters'
AND source.fiscal_acceptance->>'basis'='Tempo-official-sources-20261008'
AND source.fiscal_acceptance->>'sha256'=simulation_tax_rule_versions.source_sha256))
AND source.version_no=simulation_tax_rule_versions.source_version_no
AND source.valid_from<=simulation_tax_rule_versions.valid_from
AND (source.valid_until IS NULL OR (simulation_tax_rule_versions.valid_until IS NOT NULL AND source.valid_until>=simulation_tax_rule_versions.valid_until))
AND (simulation_tax_rule_versions.valid_until IS NOT NULL OR source.valid_until IS NULL)))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new LogicException('Reversão permitida apenas em QA isolado.');
}
$used = DB::table('investment_legal_sources')->whereNotNull('fiscal_acceptance')->exists()
|| DB::table('simulation_tax_rule_versions')->whereRaw('reviewer_id=author_id OR approver_id=author_id OR approver_id=reviewer_id OR publisher_id=author_id OR publisher_id=reviewer_id OR publisher_id=approver_id')->exists();
if ($used) {
throw new LogicException('Reversão recusada: conservar os cadastros e históricos legítimos; desactivar novas execuções em vez de apagar registos.');
}
DB::statement('ALTER TABLE simulation_tax_rule_versions DROP CONSTRAINT tax_rule_stage_evidence_check, DROP CONSTRAINT tax_rule_approval_check, DROP CONSTRAINT tax_rule_publication_check');
DB::statement("ALTER TABLE simulation_tax_rule_versions
ADD CONSTRAINT tax_rule_stage_evidence_check CHECK(status NOT IN ('reviewed','rejected','approved','published','suspended','archived') OR (reviewer_id IS NOT NULL AND reviewer_id<>author_id AND submitted_at IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_evidence))>=20)),
ADD CONSTRAINT tax_rule_approval_check CHECK(status NOT IN ('approved','published','suspended','archived') OR (approver_id IS NOT NULL AND approver_id<>author_id AND approver_id<>reviewer_id AND approved_at IS NOT NULL AND length(trim(approval_evidence))>=20)),
ADD CONSTRAINT tax_rule_publication_check CHECK(status NOT IN ('published','suspended','archived') OR (publisher_id IS NOT NULL AND publisher_id<>author_id AND publisher_id<>reviewer_id AND publisher_id<>approver_id AND published_at IS NOT NULL AND length(trim(publication_evidence))>=20))");
DB::statement('DROP POLICY simulation_tax_rule_versions_public_read ON simulation_tax_rule_versions');
(require __DIR__.'/2026_10_03_190000_allow_public_read_of_published_tax_parameters.php')->up();
DB::statement('ALTER TABLE investment_legal_sources DROP CONSTRAINT investment_legal_source_fiscal_acceptance_check');
DB::statement('ALTER TABLE investment_legal_sources DROP COLUMN fiscal_acceptance');
}
};
+38
View File
@@ -0,0 +1,38 @@
{
"rule_code": "IVA-GERAL-BASE-LIQUIDA",
"rule_version": 1,
"component": "IVA",
"scope_key": "mz.internal-general-net.mzn",
"valid_from": "2023-01-01",
"valid_until": null,
"source_sha256": "ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53",
"rule_definition": {
"language": "investmoz.tax.v1",
"nodes": {
"base": {"op": "input", "key": "base_tributavel"},
"rate": {"op": "parameter", "key": "iva_general_rate"},
"hundred": {"op": "literal", "value": "100"},
"product": {"op": "multiply", "args": ["base", "rate"]},
"intermediate": {"op": "divide", "args": ["product", "hundred"]}
},
"result": "intermediate"
},
"parameters": {
"IVA_GENERAL_RATE": {
"value": "16",
"unit": "percent",
"conditions": {"operation": "internal_general_net", "currency": "MZN"}
}
},
"rounding_policy": {
"status": "not_applied",
"precision": "exact_decimal",
"method": "none",
"ties": "not_applicable_without_rounding",
"aggregation": "single_declared_taxable_base",
"purpose": "operation_tax_not_payment",
"evidence": "Orientação expressa Tempo: calcular este lote IVA sem arredondamento automático; ficha v1.2",
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31"
}
}
@@ -0,0 +1,100 @@
# IVA-GERAL-BASE-LIQUIDA — ficha documental v1.2
21/10/2026. AUD-SIM-INT02 na matriz existente. Proposta inicial `0fafd348`; v1 em `325d58b6`, v1.1 em `37085b40`. Essas versões ficam preservadas em Git. Esta versão aplica a orientação posterior expressa da Tempo: **prosseguir neste lote IVA com precisão decimal exacta, sem introduzir arredondamento automático; separar valor calculado de apresentação monetária**. Retira a falta de uma política genérica de arredondamento como condição bloqueante do cálculo. Não estende a decisão a todos os impostos.
**Estado:** cadastro e cálculo experimental em QA, regra em rascunho, sem publicação/activação ou integração em `main`. Não equivale a conclusão do simulador fiscal, emissão de factura, declaração ou determinação de saldo a pagar. A Tempo declarou não ter conferido a ficha local nem validado juridicamente as conclusões transcritas; não se lhe atribui essa validação.
## Âmbito e contrato
Um único percurso: base tributável líquida já identificada × taxa geral /100, em MZN explícito, para operação interna abrangida pela taxa geral. Janela demonstrada do lote:01/01/2026–31/12/2026, delimitação técnica que não cria caducidade legal em31/12. Não decide automaticamente incidência, sujeito passivo, localização, isenção ou regime especial. Contexto declarado é rotulado como tal; uma rubrica orçamental não se transforma automaticamente em base fiscal.
Excluídos: extracção de preço com IVA incluído, taxas reduzidas, isenções/listas, importações, digital/não residentes, dedução/crédito/reembolso, incentivos e outros impostos. Permanecem no inventário documental existente, sem omissão nem alegação de cobertura deste lote.
| Campo | Estado actual |
|---|---|
| Regra | `IVA-GERAL-BASE-LIQUIDA`, versão1, estado `draft`; cadastro explícito apenas em QA. |
| Taxa / unidade | `16` / `percent`, vínculo Lei22/2022, art.17(1), exemplar/hash abaixo. |
| Vigência cadastrada | Desde01/01/2023, sem termo artificial; cobertura ensaiada2026 separada da vigência. |
| Entradas | Base decimal como texto, MZN explícito, data de exigibilidade declarada, contexto `internal_general_net`. Zero explícito é válido; ausência não é zero. |
| Valor calculado | Decimal exacto obtido pelo avaliador racional existente; sem conversão para float, truncagem ou redução para duas casas. |
| Apresentação | Valor decimal preservado como texto e moeda separados. Duas casas da unidade monetária não impõem arredondamento ao cálculo. Ecrã/CSV/dossier reproduzem o valor guardado. |
| Arredondamento | `not_applied`; nenhum nó `round` neste percurso. Não se inventa precisão, empate ou método fiscal. |
| Indeterminação efectiva | Fonte/versão/contexto incompatível, parâmetro ausente ou pedido fora da cobertura demonstrada. A falta de arredondamento não torna indeterminado o resultado deste percurso. |
| Limites do resultado | Cálculo QA sobre regra em rascunho e enquadramento declarado; não é obrigação validada nem saldo a pagar. |
## Fontes reutilizadas e disposições documentadas
Fontes oficiais aceites pela decisão Tempo de08/10/2026; sem nova certificação genérica de proveniência. A identificação de âmbito, período e dispositivo é aplicação da fonte aceite. Reutilizar [análise primária](primary-text-review-2025-2026.md), [registo de fontes](official-source-register.md) e [reforma fiscal](fiscal-reform-2026-review.md). A aceitação da fonte não activa regra nem valida juridicamente uma operação concreta.
| Exemplar | Identificação e integridade |
|---|---|
| [Lei13/2016 / CIVA republicado](source-archive/Lei-13-2016-CIVA-BR-156-10-suplemento.pdf) | BR156,10.º suplemento,30/12/2016;32páginas,2.127.554bytes; SHA256 `B90DC7C14ECEA2E378C4A62E8E5A47D54A5644C457BAEB8B8053B92FD2A5710B`. Leitura visual dirigida PDF6–8,13–14,17–18; entrada em vigor no art.4/PDF5. Não se afirma análise integral de anexos. |
| [Lei22/2022 republicada](source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf) | BR250,2.º suplemento;18páginas,1.834.284bytes; SHA256 `EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53`. Art.17(1),PDF3/BR2252-(27), taxa16%; art.6 da Lei,PDF4, eficácia01/01/2023. |
| [BR248, suplemento1/2025](source-archive/BR-248-I-Suplemento-1-2025.pdf) |21páginas,440.018bytes; SHA256 `CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251`. Lei10/2025,PDF12–15; eficácia01/01/2026. |
| [BR16/2008 / Decreto7/2008](source-archive/BR-16-I-Serie-2008-04-16.pdf) |16/04/2008;28páginas,2.267.865bytes; SHA256 `FC373FF5637EDF75EC6A9595DEC8763A8503D4EE786E3B7D8F1C742EAE9D80EE`. Regulamento IVA,PDF1–2; página2 conferida visualmente. O BR inclui outros impostos, que não são fundamento deste lote. |
| [BR89/2017 / rectificação CIVA](source-archive/BR-89-I-Serie-2017-06-08.pdf) | Aviso relativo a art.6(5)c/d,14(2)c e anexos de isenção; análise anterior reutilizada. Não encerra consolidação das listas de isenção. |
| Disposição exacta | Conteúdo documentado / consequência |
|---|---|
| CIVA art.1(1)(a), PDF6, BR1426-(264) | Transmissões de bens e prestações de serviços onerosas no território nacional, nos termos do art.6, por sujeito passivo nessa qualidade. Exigir identificação desse contexto; importações ficam fora. |
| Art.2(1)(a), PDF6 | Qualidade do sujeito passivo ligada às condições de residência/estabelecimento e actividade independente previstas. Perfil de investidor não prova sujeição. |
| Arts.3(1),4(1), PDF6–7 | Transferência onerosa de bens corpóreos e noção geral de serviço. Casos assimilados/especiais não entram implicitamente na fórmula. |
| Art.6(1), PDF7, BR1426-(265) | Para bens, localização no início do transporte/expedição ou quando postos à disposição, sem transporte. Serviços seguem art.6(3) e excepções subsequentes; não basta localização do prestador para todos os serviços. |
| Art.15(1), PDF13, BR1426-(271) | Base geral: contraprestação obtida ou a obter, sem prejuízo das bases especiais do n.º2. Não equivale a qualquer receita de planeamento. |
| Art.15(5)(a)/(b), PDF14, BR1426-(272) | Inclusão de outros impostos/direitos/taxas, excepto o próprio IVA, e despesas acessórias debitadas, nas condições previstas. |
| Art.15(6)(a)–(d), PDF14 | Exclusões condicionadas: indemnizações judiciais indicadas, descontos/abatimentos/bónus, pagamentos em nome/conta de terceiros registados adequadamente e embalagens retornáveis. Não classificar só pelo nome da rubrica. |
| Art.17(2), PDF14; art.7(1)(a)/(b), PDF8 | Taxa vigente na exigibilidade; facto gerador/exigibilidade diferem por operação e condições. A criação da simulação não substitui a data fiscal. Adiantamentos e prestações continuadas requerem enquadramento próprio. |
| Art.27(5)(c)/(d), PDF17, BR1426-(275) | Separação do preço líquido/elementos da base, taxa e montante do imposto. Lei10/2025 preserva essas alíneas com «[…]», alterando o n.º10. |
| Regulamento, arts.4/5, Decreto7/2008,PDF1–2 | Remete matéria colectável para arts.15/16 e apuramento devido para arts.18–22 do Código. IVA da operação não equivale ao saldo após deduções. |
Cadeia temporal documentada: Lei13/2016 efectiva em01/01/2017; a taxa histórica17% do exemplar não é usada para2026. Lei22/2022 altera art.17(1) para16% desde01/01/2023 e acrescenta art.15(10) para taxa reduzida, mantendo os números gerais consultados. Lei10/2025, efectiva01/01/2026, não lista arts.15/17 entre os alterados. Esta conclusão documental não é atribuída como validação jurídica ao utilizador e não certifica todos os regimes/excepções.
## Precisão do cálculo e exigências contextuais
Art.34 do CIVA, Lei10/2025/PDF14, regula extracção de base de valores com imposto incluído, com divisor116 e arredondamento para unidade mais próxima. Art.9(1)(b) do regulamento/Decreto7/2008,PDF2/p.100, trata do valor do meio de pagamento por cheque ou vale de correio e arredondamento por excesso para meticais nesse exemplar histórico. **Nenhum é aplicado como regra geral de arredondamento deste cálculo sobre base líquida.** A vigência consolidada e exigências de formatos de pagamento/declaração serão determinadas quando esses consumidores forem abrangidos.
A [comunicação oficial de30/09/2026](https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/) anuncia alterações regulamentares; não fornece articulado publicado e eficácia por disposição. A pesquisa dirigida em fontes oficiais não identificou uma exigência geral que obrigue a arredondar este percurso. Ausência de correspondência não prova inexistência de instruções específicas. Não há questão pendente à Tempo como pré-condição deste cálculo. Se um futuro formato exigir precisão, apresentar uma única questão contextual, identificando o formato, precisão, método, empates e agregação; não generalizar a outros cálculos.
Nota de rastreabilidade, sem reabrir pontos: o exemplar BR54 de06/04/2017 consultado anteriormente não continha a alteração regulamentar procurada. O [catálogo INM](https://www.inm.gov.mz/pt-br/produtos?page=124) identifica Decreto8/2017 no BR50 de30/03/2017. O articulado e alterações pertinentes continuam a ser correspondências documentais a completar quando necessárias ao consumidor; não são bloqueio genérico do cálculo exacto deste lote.
## Exemplos e provas dirigidas
Pressupostos explícitos: operação interna onerosa à taxa geral, base já apurada, sem isenção/redução/base especial, data coberta2026 e MZN. Não são classificações jurídicas de operações reais.
| Base | IVA calculado exacto | Apresentação / significado |
|---|---|---|
|100|16|`16` e moeda `MZN`, separados; não é saldo a pagar. |
|0,03|0,0048|Conservar todas as casas no armazenamento, ecrã e exportação. Não substituir por0,00. |
|0|0|Zero explicitamente declarado, distinto de entrada ausente. |
|9007199254740993,0003|1441151880758558,880048|Preservar sem converter para float JavaScript/PHP. |
|100 + transporte10 − desconto5|16,8 sobre base105|Apenas se verificadas as condições de inclusão/exclusão do art.15. |
Os testes do novo consumidor cobrem registo repetido sem duplicação, rascunho excluído da API publicada, resultado exacto, snapshot/relatório conservados após edição da regra e recuperação noutra sessão de autenticação, acesso negado a outra conta, moeda obrigatória, incompatibilidade da fonte, cobertura e recusa fora de QA. A nova sessão provada é API/autenticação, não alegação de novo ensaio browser. A conta PostgreSQL QA ultrapassa RLS: recusas demonstram a política da aplicação, não nova prova de RLS.
## Implementação, limites e integração
Reutilizados `SimulationTaxRuleGovernance`, `SimulationTaxParameterResolver`, `SimulationTaxRuleExpression` e o armazenamento existente de simulações, fontes, histórico e relatórios. Não há motor novo, migration ou alteração da base activa. `fiscal:qa-register-iva` exige alvo QA isolado, editor e referência de fonte já existente; confere hash e cadastra apenas rascunho, de forma idempotente. Não corre em deploy/importação. O artefacto versionado é `database/qa/iva-general-net-v1.json`.
Pré-visualização administrativa `POST /api/v2/investment/admin/tax-rules/{id}/qa-preview`, com `Idempotency-Key`, exige os controlos administrativos existentes e alvo QA isolado. Não muda a API de parâmetros publicados para aceitar rascunhos. O snapshot congela regra, fonte/versão/hash, contexto declarado, data, passos racionais, decimal calculado, apresentação sem arredondamento e limitações. Alteração da regra não reescreve o histórico. Uma nova entrada produz nova execução; um snapshot fiscal não é convertido por edição numa simulação de planeamento.
O cadastro persistido usa clone `simulator_fiscal_delta_20261021_test` e referência/actores sintéticos explicitamente identificados; não representa revisão jurídica por uma pessoa real nem atribuição persistente de papéis da Tempo. O recibo privado regista IDs, estado draft, zero regras publicadas e hash do relatório. A conta técnica e os ficheiros de prova privados não são material de deploy.
Planeamento personalizado mantém identificação própria. O resultado deste lote é apresentado como cálculo QA com regra em rascunho, não estimativa percentual introduzida pelo utilizador nem regra fiscal activada. As provas aceites de13f68f1b/valores/CSV/paginação não são repetidas. A entrega anterior permanece independente e concluída; não integra esta branch automaticamente.
## Estimativa e revisão
As15–26h anteriores eram condicionais e incluíam o trabalho de cadastro e integração que agora avançou em QA. O esclarecimento de uma política genérica de arredondamento deixa de integrar o caminho crítico deste lote. O saldo deve usar os resultados técnicos registados na adenda do manual existente, distinguindo trabalho já executado, revisão e qualquer consumidor futuro de pagamento/declaração. Não são horas medidas, promessa de activação nem estimativa do simulador fiscal integral. Publicação, activação e integração em `main` continuam separadas e não foram executadas.
## Revisão consolidada de83d2f97d
**Controlo de manutenção corrigido emQA,21/10/2026:** por decisão expressa da Tempo, a mesma administração autorizada pode cadastrar a fonte e concluir as etapas da regra, comMFA, responsável, evidência e histórico. A obrigação técnica de quatro pessoas não tinha fundamento aprovado identificado para este cadastro fiscal. Foi retirada apenas deste fluxo; o novo fundamento de aceitação da fonte oficial não é parecer jurídico nem revisão independente. Âmbito, exclusões, pressupostos e identificação do resultado como estimativa sobre base declarada mantêm-se. O manual consolidado contém a origem da condição, as6provas dirigidas/86asserções e o procedimento actualizado; não se reensaiou o cálculo nem se publicou ou activou a regra real nesta correcçãoQA.
**Actualização técnica de21/10/2026 — delta2ced7b79 implementado em QA:** o consumidor por versão publicada está ligado ao simulador normal, com activação desligada por omissão, selecção da regra no servidor e gravação/recuperação/relatório pelo histórico existente. O mesmo manual regista a demonstração pelo Edge, indeterminação sem declaração de âmbito, repetição sem duplicação, suspensão antes do processamento da fila, conclusão com versão congelada e recusa de novas execuções. Oito testes dirigidos/75asserções e dois testes frontend novos passaram, além deTypeScript/build/Pint. Mantêm-se integralmente o âmbito2026, exclusões, fontes e precisão desta ficha; não se repete a fundamentação jurídica nem se lhe atribui validação da Tempo. O procedimento único foi actualizado para integração, publicação da versão real e activação, decisões ainda não executadas na homologação. A fixture publicada exclusivamente no clone QA foi suspensa após o ensaio; os históricos foram preservados. As notas abaixo sobre ausência do consumidor descrevem o estado anterior à implementação deste delta.
**Disponibilização no simulador existente:** a proposta única «Consumidor IVA no simulador da homologação», no mesmo manual, delimita o código ainda necessário e as três operações distintas: integração com consumidor desligado, publicação da versão pela governação existente e activação da ligação à versão autorizada. O percurso QA aceite não equivale a esse consumidor público. Mantêm-se âmbito2026, exclusões, precisão exacta e históricos; suspensão impede novas admissões e preserva execuções já admitidas. Esta proposta não executa nenhuma das três operações nem amplia a cobertura fiscal.
O [mesmo manual de rastreabilidade](../unification/traceability-and-manual.md) consolida âmbito/período, operações excluídas/indeterminadas e os procedimentos concretos de integração, publicação, activação e desactivação, preservando históricos. Identifica também que83d2f97d não contém um consumidor fiscal público: a publicação torna parâmetros elegíveis na API existente, mas não transforma o percurso QA em cálculo público activado.
A única lacuna adicional encontrada foi o percurso completo pela interface. Acrescentou-se a páginaQA protegida `/management/investment/tax-rules/{id}/qa-review`, ligada às APIs existentes de cálculo/gravação, recuperação e relatório imutável. **Exemplo observado no Edge:** base1000,03MZN e exigibilidade declarada21/10/2026 → IVA calculado160,0048 → execução`fa6d17f2-5f47-4cb6-b8cb-40850c12505f` gravada → recarga da página → recuperação do mesmoID no histórico → relatório`d13e2ad5-3ba8-4d01-9d30-5815496da774`, mesmo valor/contexto/proveniência e hash igual ao resultado guardado. Uma execução persistida; regra continua emdraft, zero publicadas. Não prova aplicabilidade fiscal a qualquer operação nem saldo a pagar.
A ferramenta perdeu inicialmente a ligação ao navegador e permaneceu sem inventário apesar das reactivações; essa limitação ficou registada, sem a atribuir ao simulador. A ligação foi depois restabelecida e concluiu-se apenas a parte não observada, com autenticação/segundo factor normais, sem contornar guardas. As provas aceites de83d2f97d são reutilizadas;1teste novo de rota/17asserções,TypeScript/build e a observação browser cobrem o delta. Não se atribui ao utilizador conferência local ou validação jurídica. Não há nova preparação genérica identificada para este recorte; integração e activação continuam separadas.
@@ -0,0 +1,281 @@
{
"base": "59ab4fe95635",
"branch": "codex/simulator-integrated-review",
"scope": "QA only; no main merge or remote change",
"source_files": [
{
"path": "config/app.php",
"sha256": "a01f552093570f8744f7b874392824c15b5a6b49d61759273a658e8660a1d240"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "347c340bcc4f5adc7965bd66cef1f357d98b6c271fdcd35206a3a086e59ad34d"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "a3473a391634c32fa8c53ecb2528b4de08f145ec0284b50f3bbf89aa97e5cf24"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "1294586bfd452e6430aa1fcb71a512cbf64177e529a416ac28dfab954839664b"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "f57861a36e310a892d55a16be6d06fecd48763defe11011036df8a3a33a57d6d"
},
{
"path": "lang/pt.json",
"sha256": "c21fa0b63a3729e001f7d4ba891a23890bcfcfd718137d99a7c6c18d3be1a235"
},
{
"path": "lang/pt/auth.php",
"sha256": "d32cc31b75d4056ef561baf501d9cbf71ec136c7f63bef635c0c3e31b9a2357d"
},
{
"path": "lang/pt/validation.php",
"sha256": "bb95fe0f683cd6d8a6d0475dc322c71bce58197b952220e70ac74f5e97cdcf60"
},
{
"path": "resources/js/components/appearance-tabs.tsx",
"sha256": "d53f48b0883713bf786fdf4b7877e03638bcf2e707b64ea6b8559501bfc8c4c7"
},
{
"path": "resources/js/components/delete-user.tsx",
"sha256": "2f94b4dc6edf20bf9e00940d4d7e7b5da31bdf560f26b84efd65672bcee585b8"
},
{
"path": "resources/js/components/manage-passkeys.tsx",
"sha256": "8f3e8970d3b82deba721ac27e57718e2e7dcecc67b1e78cb6404c475c9f9c62e"
},
{
"path": "resources/js/components/manage-two-factor.tsx",
"sha256": "35fea615cf3a0454448057b0ef3137e24c21df4de30a58fce04cb847eb89eba1"
},
{
"path": "resources/js/components/passkey-item.tsx",
"sha256": "a307c5ef209e24f3f913f77e355a4d04a1433f59f520ad45ad40112111ab4677"
},
{
"path": "resources/js/components/passkey-register.tsx",
"sha256": "e7cb9d853eb943fd0899bc8530378eddbf941f9334a77bcbb7693d2e29d99470"
},
{
"path": "resources/js/components/passkey-verify.tsx",
"sha256": "6db387920437ab39cd734a2af9bec7e64ae6469c0f1b368d536b275e57cbea37"
},
{
"path": "resources/js/components/simulation-fiscal-status.tsx",
"sha256": "540dc6addbec0be79114526f7d27f53942291b0c7be05a2608a515498970d3e0"
},
{
"path": "resources/js/components/simulation-form.tsx",
"sha256": "1b2d3a917e354eef7ccdb47201fb7e515ea646d45099beb9b15798c80e1dd2db"
},
{
"path": "resources/js/components/two-factor-recovery-codes.tsx",
"sha256": "9384ae3987a58ae89018f2a997dec7faebec4f51f516ebbdc2b337c8e757f00f"
},
{
"path": "resources/js/components/two-factor-setup-modal.tsx",
"sha256": "8586833e10310dbca8a097df80b86f40638f874170b7668fa476f0d7017a311b"
},
{
"path": "resources/js/components/ui/dialog.tsx",
"sha256": "abeb6df2254f0bf5f73de1c9b5aeb4e667afe8ac0319e8b9aa099a612cc70b8d"
},
{
"path": "resources/js/components/ui/sheet.tsx",
"sha256": "a525cc98beaf974e0dfe6cda1c5da1101798b6843ea355a758b3c5df5235f506"
},
{
"path": "resources/js/components/user-menu-content.tsx",
"sha256": "9cb79c2a999bb363bd9d4fc1869b672c474b088eff23fca6e9f588ecae00d3ce"
},
{
"path": "resources/js/hooks/use-two-factor-auth.ts",
"sha256": "16d9434379531f1994096e4d1fcfd778b2c829602cce5731d602a25ea9c0073f"
},
{
"path": "resources/js/layouts/settings/layout.tsx",
"sha256": "78ea66c1eb6cad907e23de9c81a4d309a127f251355f04771f72eb5689609a4e"
},
{
"path": "resources/js/lib/project-dossier-document.ts",
"sha256": "d0a66ca759e8710c1a3900bcd27cc7f0506587c782ab798c49703396c1fb31d6"
},
{
"path": "resources/js/lib/project-dossier-report.ts",
"sha256": "6c5f52362a6218048f321b41211bfd328fa1e19ea4ab45fd1424932130482c8d"
},
{
"path": "resources/js/lib/simulation-form.ts",
"sha256": "40df1a9b6939b1910d12045835e981d6e47d4c1d534775631ac2f6b655a5e2bd"
},
{
"path": "resources/js/pages/auth/forgot-password.tsx",
"sha256": "6cdedca491d5a05dad0083fb637cd618d0b290bd6b48b98e7c6163dc0d740166"
},
{
"path": "resources/js/pages/auth/login.tsx",
"sha256": "8468308dacd26bd94ee41683684dcb7719386a1a53196ed90496ca406b1b94da"
},
{
"path": "resources/js/pages/auth/reset-password.tsx",
"sha256": "f3561726ae5d7ee682d968a295bbf60ec9f8cbabba1721c33f48bafd80d1b20a"
},
{
"path": "resources/js/pages/auth/two-factor-challenge.tsx",
"sha256": "0fd14dff2f0a572f4b99905d0fb152baa476eaa55a221998b67da85fa4787610"
},
{
"path": "resources/js/pages/auth/verify-email.tsx",
"sha256": "ffc3220d0ee6cc243e8e08ea46f8b1637794626fa45d7aeaa77f54203692864d"
},
{
"path": "resources/js/pages/portal/services.tsx",
"sha256": "9369add7c012c1e8a9e217b46be55e2927382bb8f18b90973d3b1f4199442d9b"
},
{
"path": "resources/js/pages/settings/appearance.tsx",
"sha256": "638367104837b028aeeb49b4cad1d2c9a44a92d1b9aebe71c31995605e0479b4"
},
{
"path": "resources/js/pages/settings/profile.tsx",
"sha256": "c599b1ca523ece69c3f5d3fe480a9f2ce4c323e97afb42095f61f2ddb7d4ba7b"
},
{
"path": "resources/js/pages/settings/security.tsx",
"sha256": "39264620329657af975f990a50d657c42186ead57458cd83040aa07f9cd78444"
},
{
"path": "tests/frontend/project-dossier-report.test.mjs",
"sha256": "fcdcabfc659f2f4ca0d5a51e592719d53d51420271c7cbe350176b48cef3fc97"
},
{
"path": "tests/frontend/simulation-form.test.mjs",
"sha256": "294a1b996d8131771ebaca85d2ff0e44bb6afd62e460073b7535486f13ae3a6b"
}
],
"evidence": [
{
"path": "outputs/simulator-integrated-review/effective-parameter-inventory.json",
"sha256": "aeec1251ef5dbbf7ae547af9f1ebcd2575aaa6c251ef65b37c6c31b8cf371acc"
},
{
"path": "outputs/simulator-integrated-review/independent-controls-proof.json",
"sha256": "47ad49b2136fd74c801b7a5d057c891732d820fb6a5002ccf08c71a2ffabf575"
},
{
"path": "outputs/simulator-integrated-review/visible-language-inventory.json",
"sha256": "5f1f50236ae492c8428a9528af350900e4f2c44c387fe8f23ebce0e4f1b14b3a"
},
{
"path": "outputs/simulator-integrated-review/dynamic-language-inventory.json",
"sha256": "fe3246f570c333c2c59e9fe2acb0ec01078a158a46e53d37087ec59d419578a5"
},
{
"path": "outputs/simulator-integrated-review/portuguese-validation-proof.json",
"sha256": "b7a454cdc1e934faada40b683ff2b7a6edce73aebfd22ba34e5da17a1854b0a1"
},
{
"path": "outputs/simulator-integrated-review/http-reports-proof.json",
"sha256": "9d37fbee89f7dc556ff9496f6b0980e1c9626790eb50a41a499cf23e2f27fe5f"
},
{
"path": "outputs/simulator-integrated-review/date-field-error.jpg",
"sha256": "6a792c09c61857a0a064a56d3d61b47fc2adc1d6c8bcf627161f6fa77c7b099d"
},
{
"path": "outputs/simulator-integrated-review/stale-result.jpg",
"sha256": "84b37c41ba47d44f52979e83dbc3426184f5cbfee6d38e7c7d4caa3be225320b"
},
{
"path": "outputs/simulator-integrated-review/tax-lookup-error.jpg",
"sha256": "d3f366690fa3acbda45bc021ade90dc003cea7dc0517c4d2932935548dbb40d0"
},
{
"path": "outputs/simulator-integrated-review/positivo.docx",
"sha256": "61b1ea80476922aa1be8c29f9a757b389c3ecc8f0e75f414a29eddd5b37ecefd"
},
{
"path": "outputs/simulator-integrated-review/positivo-model.json",
"sha256": "fc8df79e7ce81e4a475b99f5ea07763c1cee140bc9dcb24550ed26ce412e4e0d"
},
{
"path": "outputs/simulator-integrated-review/checked-positivo/positivo.pdf",
"sha256": "60d655b33fcc27dbbaae40a0739c1ebeb9b366df9b00621a6536feecd60da452"
},
{
"path": "outputs/simulator-integrated-review/negativo.docx",
"sha256": "f266cff1a1f9f63449703cc5ef51a7f4d2d7bf94230be70ae382bc89af8139bd"
},
{
"path": "outputs/simulator-integrated-review/negativo-model.json",
"sha256": "6aff37f66695e1d2a7441bf7b361f7980bc10f6951f915713f55a01e5b00da48"
},
{
"path": "outputs/simulator-integrated-review/checked-negativo/negativo.pdf",
"sha256": "3a2577194bc6c9e7234b650e69f3795f6cef6aef41d62658839c0790989a8f5c"
},
{
"path": "outputs/simulator-integrated-review/nulo.docx",
"sha256": "efb83aa9044e35515880e40641d6bea52c6ea6f81289c3abc9d3bdbc76a9ba26"
},
{
"path": "outputs/simulator-integrated-review/nulo-model.json",
"sha256": "94bbc6cf72cc613ebcd5ab9e9c074220b2f50dfb5b03959e92a7c84032a86750"
},
{
"path": "outputs/simulator-integrated-review/checked-nulo/nulo.pdf",
"sha256": "c93e989a7af230e56f5d01766a4e386161e6fbbfa99341d5bfa0569bafe9e18e"
},
{
"path": "outputs/simulator-integrated-review/incompleto.docx",
"sha256": "026f0ff1877ed5f23359d220e3223a319c213e742ff9704d81812fc2ed3ae20e"
},
{
"path": "outputs/simulator-integrated-review/incompleto-model.json",
"sha256": "748b9cecde2acc99d7e2bbfd16188bf9f9ac0ab411ad850ba687c6e1f2e36cf5"
},
{
"path": "outputs/simulator-integrated-review/checked-incompleto/incompleto.pdf",
"sha256": "0daac41c80ae131df6d66b7b542353a61a804d02d2e77769b9e5cc2bc9cfbc88"
},
{
"path": "outputs/simulator-integrated-review/completed-calculation.jpg",
"sha256": "7180ff0f536e2e377c2a28b85574ec7c7fefcce5eed28ed08f07628b16870293"
}
],
"checks": {
"targeted_frontend": 34,
"typescript": "passed",
"php_lint": "passed",
"build": "passed",
"reports_pages": {
"positivo": 6,
"negativo": 6,
"nulo": 6,
"incompleto": 5
},
"tax_lookup_retry": "503 then successful lookup; IVA; 2026-01-01 through 2026-12-31; indeterminate parameters",
"arithmetic_ui": "100*16/100*(1-25/100)=12; base change removes result",
"build_manifest_sha256": "af96a5f5682dad7a741d0ee8b302d8dc7f4d08a1806bc000265bd2602e5bfc61",
"terminal_status_ui": "200*16/100*(1-25/100)=24; completed notice visible; awaiting-calculation notice absent"
},
"remaining_hours": [
6,
10
],
"limitations": [
"No published fiscal rule versions in QA; no legal tax validation",
"Authenticated cross-dossier and new-session frontend delta still pending",
"Parameter-version change and additional business-plan/cash-flow controls pending",
"PDF examples are DOCX rendering; native Edge print and final UI download comparison pending",
"Dynamic backend/catalogue messages and directed save/export error checks remain open"
],
"deployment": {
"authorised_now": false,
"migrations": [],
"locale_on_future_deploy": "APP_LOCALE=pt; preserve existing secrets"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
# Governação de versões de regras fiscais
## Correcção de controlo — decisão expressa da Tempo,21/10/2026
A manutenção dos parâmetros fiscais reutiliza a administração já autorizada, com autenticação/MFA, fonte, versão, responsável e histórico. Uma mesma pessoa pode concluir `submit` → `review` → `approve` → `publish`; esses estados preservam o percurso técnico e os eventos, sem afirmar independência pessoal ou parecer jurídico. As quatro listas específicas de permissões permanecem opcionais. A utilização do simulador pelo investidor conserva as permissões e os limites próprios, separados da manutenção.
A obrigação de actores distintos foi encontrada no serviço fiscal e nosCHECKs da migration de03/10/2026, incorporados em `e65cb269` (06/10/2026), e descrita como implementação em `simulator-evolution-progress.md`. Não foi localizada uma decisão expressa da Tempo ou requisito aplicável que a imponha ao cadastro fiscal. A origem identificada é escolha de implementação; a autoria ou integração do commit não demonstra aprovação dessa regra organizacional. A decisão actual da Tempo manda removê-la neste fluxo, sem alterar a independência acordada nas oportunidades, perfis ou outros processos.
A correcçãoQA retira apenas as comparações de identidade entre autor/revisor/aprovador/publicador dos controlos fiscais; mantém campos obrigatórios, evidência, datas, etapas, versão optimista, fonte, bloqueios, RLS e eventos imutáveis. Para cadastrar uma fonte oficial neste percurso existe `POST /api/v2/investment/admin/tax-rule-sources`, sob a mesma administração autenticada. Recebe os metadados existentes mais `sha256`, `citation` e `official_origin`; regista a aceitação da fonte oficial segundo a decisão Tempo de08/10/2026, o responsável real e a indicação explícita de que não é parecer jurídico nem revisão independente. O hash da regra tem de coincidir com o exemplar cadastrado. A fonte permanece pending no estado geral: a aceitação fiscal não a valida para outros módulos. A projecção pública e o resolvedor fiscal reconhecem esse fundamento e o hash correspondente, sem alterar a fórmula. A fonte não activa nem publica uma regra automaticamente. A validação normal de fontes para os outros processos mantém o seu controlo; não se alteraram os workflows de oportunidades/zonas/perfis.
Migration de correcção: `2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php`, protegida contra execução fora deQA isolado. Acrescenta metadados de aceitação fiscal à fonte e corrige osCHECKs que impediam o actor único. Foi ensaiada apenas emQA, com6testes/86asserções do controlo alterado, sem cálculo, build ou repetição dos testes aritméticos/exportadores. O manual consolidado regista provas e procedimento actualizado das três operações. Os estados históricos abaixo descrevem o incremento original e não reabrem as provas já aceites da candidata226fd308.
## Âmbito deste incremento
Foi criado um fluxo administrativo para registar uma versão declarativa de regra e a sua referência jurídica. O fluxo existe separado do motor de cálculo. Nenhum endpoint de simulação consulta `simulation_tax_rule_versions`; `published` significa apenas publicada no registo interno de governação, não activa no produto nem aprovada para aconselhamento fiscal.
@@ -0,0 +1,84 @@
{
"branch": "codex/educational-content-cards",
"base": "0e7d51f18fb424857fdaad48498a2fc43c044749",
"scope": "New QA delta; separate Tempo review before main",
"files": [
{
"path": "app/Http/Controllers/InvestmentDiscoveryController.php",
"sha256": "693d1217ead894747c266a7295774b26a8b987f8faf06003ed60a6d3e15aca6c"
},
{
"path": "app/Services/LegacyContent.php",
"sha256": "e856ba2a042e971933a401fd9544a50f6bc5c1541c28062719d840a8277da7b8"
},
{
"path": "app/Services/SectorInvestment.php",
"sha256": "a0ee1c9bf864ef765ed70fc1bcdc839781d0bed7d705d991c801bfaa9bf7e4a0"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "bbbb79e84cae659d1921dfa4440e3fcc192521e0976cace0fa2694828219ec00"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "420369f562508f68ea50e78d386721d6b62fa730893c4e6ccd06817f4dfb8a27"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "d77e4931e8f1a0fcbf0f1d80be83a54b22682ffdc0286852d3ad167652056156"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "757d457a295eb9a516abd1eec6002a52aa1d9a35faea291046e3c99681a1cdcb"
},
{
"path": "resources/js/components/educational-article-card.tsx",
"sha256": "03a298406702cd3211c44fad1fdf79d455ad029d997d417a416f450d6d420b6e"
},
{
"path": "resources/js/components/educational-guides.tsx",
"sha256": "2a2a52e50694e336f05544d51fc776a3bbc2b06d2ab61a1b3cdf1d261afca474"
},
{
"path": "resources/js/components/sector-entry.tsx",
"sha256": "39004abf04f096b987d852d834ce9b27fdf557d2283db3b5868678d3ff0bff1a"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"sha256": "ced5e8d4b2910aeeaeb709d6ff0eb2d492f96d9b671d6ff832eea66d022a1f70"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"sha256": "719b3c008887676dd0b69ffe48bd5a64426de92362d92da8ad39412a578c51bc"
},
{
"path": "resources/js/pages/portal/legacy.tsx",
"sha256": "dcd8462e940614f5935efa19d9c9444fe3432fbc704e9c05144508d5c944c90d"
},
{
"path": "tests/Feature/EducationalGuidesTest.php",
"sha256": "aa9e3f62890f1d75f639ad4e7e05713c18112ec96a7fd49c864e5e007807d3cf"
}
],
"build_manifest_sha256": "89c3ce0c67d9faf8cb3c5f7e9d74b3ad5d0ad70dff81ca2e37ca5cac7a471620",
"migrations": [],
"imports": [],
"publication": false,
"remote_operations": [],
"ai": false,
"evidence": {
"backend": "1 test, 8 assertions; exact isolated QA guard and transactions",
"types": "passed",
"build": "passed",
"desktop_width": 2048,
"mobile_width": 390,
"mobile_document_width": 375,
"keyboard": "visible focus and Enter opened guide",
"visual_data": "Read-only copy of existing public Tourism article/image; not synthetic",
"real_library": "Normal sector -> local image 25 and source summary -> normal article /turismo -> library resource 10; existing isolated QA; no import"
},
"limitations": [
"Tempo visual review pending before main integration; prior accepted dossier proof retained, not repeated"
],
"rollback": "Restore previous sources/build through existing deployment chain, preserving database/uploads/permissions and later data"
}
@@ -0,0 +1,155 @@
{
"branch": "codex/opportunity-search",
"base_commit": "1350b4df73e70ab35a28d923402ded7d836ade38",
"date": "2026-10-20",
"scope": "QA only; review before main integration",
"qa_url": "http://127.0.0.1:18570/oportunidades",
"files": [
{
"path": "app/Http/Controllers/Api/V1/OpportunityController.php",
"sha256": "04fd696ce73fae8660491976781ef1bd96898a30af96363460427e7a9d139fe1"
},
{
"path": "app/Http/Controllers/InvestmentDiscoveryController.php",
"sha256": "a7301ce6c6285d8ec374b7013b8f0a0bb9951da83625acc47da223d2c4275bbf"
},
{
"path": "app/Services/InvestmentCatalogue.php",
"sha256": "4b44e18ca12c890e0220913c972ca6092e29f8975dfbc6d292b579b95f1c5e21"
},
{
"path": "app/Services/OpportunityClassification.php",
"sha256": "f9a24067807fa620142839ab9873dde465457fcfc61f501d1c8309534a6da175"
},
{
"path": "app/Services/OpportunityMaturity.php",
"sha256": "1cd73acb486d8e3ffa52c7419e48b9d69b450bb343f04664cd1a2b51e3317246"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "5596aa8edbbeff34a13613ec11f00f49c62e76a8e2185e5762d972fc70b667e7"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "5d1de3418c5b021a0520bc6494a6a5efcef665ad1d20a45b11ae6aa702bedb01"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "303b53346af2dad908f9d40d02c14a8f245155897dacf8b2538cdaae5a92bea7"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "4bbf47f7b5bfe54d2dd3b891eca3c04555329e92064c3bc6722a60c2f8eab4ce"
},
{
"path": "resources/js/components/catalogue-filter-context.tsx",
"sha256": "b155d5e812b52fc52677cb9a8d44068f64821732c90a82dabb521eed44af047e"
},
{
"path": "resources/js/components/catalogue-navigation.tsx",
"sha256": "88eccc6a9c2803fd26b678081f5da244a9c4ac9227664e7d0f1b57050b437239"
},
{
"path": "resources/js/components/country-select.tsx",
"sha256": "53ef3742e4c55668d0ad85139be742405f5d1524196b3b1b3f3700c7bc311742"
},
{
"path": "resources/js/components/investment-map-panel.tsx",
"sha256": "cfdb99b7dfa405d740f68cecef2b0df38488d2f63dc623da47a000037f0ec01e"
},
{
"path": "resources/js/components/investment-territory.tsx",
"sha256": "35cef961efb51709cba271556f5e7c717b673ebbca480b680e37654c38cde324"
},
{
"path": "resources/js/components/opportunity-classification.tsx",
"sha256": "fb4d843c8c056f10c34baa5ab9f2ba324e9796456a20455303b7a3b0747ee6cd"
},
{
"path": "resources/js/components/opportunity-search-filters.tsx",
"sha256": "85e48e41537a5932e0398b7bc4bddd5322431ae517a91de2d53e83ef7927e54d"
},
{
"path": "resources/js/components/search-feedback.tsx",
"sha256": "23ed85878e5b55a40e01928236f870f6dff4c2a7e19547beaa81a0a7d59c9c06"
},
{
"path": "resources/js/components/search-state.tsx",
"sha256": "45cc210783625bcf96712064a0e7dd97f5681c499cdf60cedbe0e4127cba7aab"
},
{
"path": "resources/js/lib/catalogue-context.ts",
"sha256": "c499ebda27b7672b55ea3e3da98c63c7d2d2cd490dcde77df3cc38e09792af73"
},
{
"path": "resources/js/lib/portal-navigation.ts",
"sha256": "0d3bae6d726538b064cd992e6d433245321951c18198de5db540993232e6daaf"
},
{
"path": "resources/js/pages/investment/business-marketplace.tsx",
"sha256": "f45a56b250bbb5da19613c6b7ad94ba89ea501929f97636c797d92b4a2251b51"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"sha256": "9ea5742950e2737bebb7bd7f04ee24129c7f9917c1986cf070a3e282e7a22981"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"sha256": "c40926782a183fcde27415e0eda276f73189f071ba6ce715e79d007eb6388634"
},
{
"path": "resources/js/pages/investment/shared.tsx",
"sha256": "f71d13f0e0b5b0e152cf262f02b9e0770a58d32472ac878aa0adbca53d2edf3f"
},
{
"path": "resources/js/pages/investment/zone.tsx",
"sha256": "8cd64fb62af5e2d4582c46298568429e61e45247fca1b072287ad35ae07d76db"
},
{
"path": "resources/js/pages/investment/zones.tsx",
"sha256": "5fecb308c3800f6e2ee266fd8559d2b14bea3214f2ffbeb4b651b86f2bed7ea7"
},
{
"path": "resources/js/pages/portal/services.tsx",
"sha256": "6f26cace5d1808f6d563d0d8b0ebed6b44748abed7205e465d9658f8b9cb2d17"
},
{
"path": "resources/js/pages/portfolio/index.tsx",
"sha256": "2662cfe05c387cb876f8c352bd83a5a1d68056c615333e238ecb4e7b5489eb00"
},
{
"path": "resources/js/pages/portfolio/shared.tsx",
"sha256": "27b9d1c8dbced48532e7a57ce184810a32e3dea7cdc3a37a549dc7fbe8c04426"
},
{
"path": "resources/js/pages/portfolio/show.tsx",
"sha256": "51883858d09c6b07f281426fa9ec149c4de9f12286cc88b285caee01e892c892"
},
{
"path": "tests/Feature/OpportunitySearchTest.php",
"sha256": "b2d57a838849a7d1c9e5b43c3f471d3e5d78c59a7cdb8e0f81f74de5d886d7d3"
},
{
"path": "tests/Support/OpportunitySearchFixture.php",
"sha256": "46804da2f403c031ad206211db07f41f3f20dbf4f789f838e278e3f241d021f1"
},
{
"path": "tests/frontend/portal-navigation.test.mjs",
"sha256": "f19ff2d17a385ab1f28c65f69233a13d88d6b8b6ec7285848fcd2a945b883ff9"
}
],
"build_manifest_sha256": "da35948622e0754e4ecc25247b8926dfcafa63c4fcb78a94da91a3c6e8b47451",
"migrations": [],
"data_operations": [],
"remote_operations": [],
"new_dependencies": [],
"permissions_changes": false,
"proofs": "outputs/opportunity-search-20261020/receipt.json",
"pending": [
"Tempo review before main integration",
"Natural-language interpretation not implemented; evidence_only remains separate",
"Eight contractual gaps and P7 unchanged"
],
"rollback": "Restore previous source/build using existing release chain; keep database, uploads, permissions and later data.",
"hash_basis": "Git index bytes; line endings normalized by repository attributes. Manifest itself excluded."
}
@@ -1286,3 +1286,678 @@ Interfaces QA: [atribuir interesses](http://127.0.0.1:18555/management/interests
| P6 | Conferência do delta de conteúdos/consumidores após P3–P5; reutilizar provas actuais. Vínculo com dossier legítimo quando disponível. | 2–4 h |
**Saldo de disponibilização do produto existente: 11–20 h**, condicionado ao lote P4 e às pré-condições do destino; não inclui espera por decisão/acesso nem desenvolvimento dos contratos especializados. Os 27–46 h são referência inicial do conjunto, não saldo. Não há base para estimar de forma responsável em bloco o desenvolvimento das oito modalidades: concluir por modalidade exige contrato explícito, âmbito sem acesso indevido, gravação/recuperação e submissão/revisão compatíveis. Recuperação e cessão exigem primeiro identificar a operação concreta; não constituem automaticamente novos módulos. **P7 permanece fechado e separado**, sem bloquear P1–P6. Incrementos 36–39 e instalação remota continuam para decisão expressa. Importação privada e publicação mantêm-se separadas.
## P1/P2 — prova complementar de actualização, 20/10/2026
Mantém-se a revisão QA `6508c44b9fc1` (56 fontes/177 assets, hashes conferidos). Origem `167b5db97653`; não reconstruir nem repetir provas aceites. Pela interface e login/MFA sintético: interesse actualizado de Recebido para Em análise, nota preservada no histórico após recarregar; pedido do Balcão actualizado de Submetido para Em análise pelo analista atribuído, resposta fundamentada e histórico recuperados. Reutilizadas as provas de atribuição, recuperação noutra sessão e recusa de outros registos. Zero alterações remotas, importações, publicações ou grants reais. Contas sintéticas novamente retiradas da allowlist HTTP.
Limite concreto: o agendamento por calendário não foi certificado — a entrada automatizada não actualizou o estado React e o validador existente recusou a falta de prazo. Esse erro ainda aparece em inglês. Não inferir falha de gravação do estado/nota, nem declarar o calendário aprovado. A relação entre próxima acção e prazo já existia; não foi criada exigência organizacional.
P1: 13 das 21 modalidades reconciliadas, oito lacunas de contrato/âmbito individualizadas na tabela anterior; não encerrar todos os contratos especializados. P2: composição QA concluída; sem nova alteração de código. P7 assistência fechada e separada. Saldo P1–P6: 11–20 h para disponibilização do recorte existente (P3 3–5, P4 4–8 condicionado ao lote, P5 2–3, P6 2–4); desenvolvimento especializado sem estimativa fechada e sem bloquear os restantes componentes. O delta de actualização agora concluído não justifica repetir nem voltar a somar horas de P1/P2. Manifesto actualizado apenas nas provas, mesma revisão de fontes.
## P3/P4 preparados e proposta P5/P6 — 20/10/2026
Revisão QA `3fd1f15bd01c`, sucessora de `6508c44b9fc1`: 56 fontes e 177 assets do build preservado. Duas fontes PHP corrigidas: mensagens do prazo em português e limite funcional no MNE reutilizando OpportunityCanonicalContract::deliveryBlockers. ExistingBusinessIntake não consultava esse limite; agora submissão e publicação de financiamento/concessão/fornecimento não são habilitadas por correspondência de acesso. A preparação permanece guardável. Cinco modalidades verificadas por transacção/rollback: capital_increase, financial_investor, financing_request, concession_opportunity, supply_opportunity. Nenhum módulo experimental foi ligado. As seis categorias base mantêm os bloqueios anteriores. As oito lacunas contratuais e P7 permanecem separados.
Calendário: mensagem de falta de prazo traduzida; edição por teclado, gravação e recuperação de 21/10/2026 com a próxima acção passaram no navegador QA. Não se alterou o componente de data: o preenchimento automático isolado não actualizava o estado React, enquanto a edição por teclado o actualiza. Prova anterior de actualização e limites reutilizada; sem repetir importações, scans, restauros ou percursos aceites. Sintaxe PHP passou; hashes dos 177 assets preservados, sem recompilação desnecessária por alteração exclusivamente PHP.
P3, manifesto privado: 30 fichas com identidade e hash actual do QA técnico, secções da fonte e relações; dois originais identificados por SHA256, versão e correspondência por bytes no clone do destino. As fichas continuam privadas; IDs de actores/organizações QA não são transportáveis. Definições project-change e project-follow-up extraídas de config/virtual-desk.php, incluindo campos e hash, comparadas com as versões do clone. Não substituir project-preparation v5 nem reassociar pedidos existentes. Nenhuma importação foi executada. O manifesto de identidades não é ainda um payload de aplicação autenticada.
P4, lista nominal: 27 entradas de referência (quatro edições de catálogo, 12 sectores e 11 actividades) e 24 referências territoriais com origem/identidade/versão e correspondências no clone. São inventário para reconciliação; **zero referências novas autorizadas para escrita e zero geometrias novas ou substituídas incluídas**. Não transportar catálogos de exemplo ou fonte pendente apenas por existirem localmente. Referências existentes são reutilizáveis por identidade/edição; divergências ficam por item e nunca se resolve por coincidência de ID local. A lista completa está no MANIFESTO-P4-REFERENCIAS-GEOMETRIAS.json. A base é um clone local, não prova fresca do servidor.
| Ordem P5/P6 | Operação e condição verificável | Reversão / limite |
|---|---|---|
| 1 — preflight por leitura | Conferir revisão/hash dos ficheiros nominais, schema usado por P1/P2 e P3, flags, operador/MFA, scanner Unix, worker e armazenamento. Comparar cada identidade/edição/hash dos manifestos com o destino actual. Parar apenas a operação divergente e apresentar diferença. | Não executar pacote de migrations. Esta candidata contém zero migrations. Não usar o clone como prova de estado remoto fresco. |
| 2 — backup fresco | Suspender escritas HTTP, tarefas agendadas e consumo de filas no recorte durante cópia consistente da base e ficheiros privados; registar inventário, hashes, UID/GID/modos. Incluir configuração e chaves de desencriptação no envelope protegido com recuperação fora do perfil Windows. Retomar só após fecho consistente. | Reutilizar prova aceite de restauro; nesta execução não criar backup nem repetir restauro. Recuperação total é contingência, não reversão normal. |
| 3 — código/build | Guardar cópia anterior por caminho/hash; aplicar apenas payload da revisão identificada, incluindo um build; efectuar limpeza/recriação de caches compatível com destino e processo existente. | Repor só ficheiros ainda com hash aplicado. Ficheiro modificado posteriormente gera conflito para reconciliação; nunca sobrescrever. |
| 4 — dados privados P3 | Produzir prévia fresca pelo importador existente; reusar originais por hash, confirmar scan real no destino, resolver custódia/permissões por identidade autorizada, criar apenas fichas inexistentes e referências compatíveis. Registar journal de IDs/criação/hash/versão/execução. | Não publicar recursos nem oportunidades; preservar edições posteriores. Sem apagar ficheiros partilhados, histórico ou relações. Corrigir/desactivar só registos deste lote pela recuperação suportada; versões usadas por dossiers permanecem. |
| 5 — serviços internos | VirtualDeskInstallationController, fluxo autenticado de instalação: registar apenas project-change/project-follow-up ausentes; comparar hashes se já existirem. Versionar sem substituir versões anteriores. Activação interna do serviço deve constar da decisão de disponibilização; não equivale a publicação de conteúdo. | Conservar versões referidas por pedidos posteriores. Retirar da escolha para novos pedidos apenas por mecanismo suportado; não eliminar versões ou pedidos. Se esse mecanismo não estiver disponível, conservar definição instalada e reverter só apresentação/código. |
| 6 — referências P4 | Nesta prévia não há operação nova de escrita de geometrias/catálogos. Revalidar correspondências e reutilizar destino. Nova edição real exige identificação nominal e revisão expressa do manifesto. | Nenhuma geometria existente é substituída. Não converter desconhecido em confirmado. |
| 7 — verificações do delta | Entrada autorizada; guardar/recuperar próxima acção e data; limites MNE sem activar contratos; fichas privadas/originais e duas definições recuperáveis; pesquisa/filtros/contexto apenas onde alterados. Vínculo positivo com dossier legítimo quando existir. | Provas aceites reutilizadas; não criar processo fictício no destino. Se faltar dossier legítimo, registar essa única prova pendente, sem bloquear os restantes componentes. |
| 8 — fecho | Retomar escritas/worker e entregar estado por componente com URLs, alterações nominais e impedimentos. Conservar manifesto de execução e reversão. | Reversão normal conserva dados posteriores e estruturas/definições aditivas utilizadas, sem down migrations nem dump antigo sobre dados recentes. |
**Saldo delimitado revisto: 7–13 h** — P3 2–4 h (staging privado/resolução de identidades/aplicação nominal); P4 1–2 h (reconciliação fresca da lista, sem novas geometrias); P5 2–3 h; P6 2–4 h. O calendário, tradução e protecção do limite funcional foram concluídos nesta passagem e estão contabilizados como trabalho realizado, não como horas adicionais futuras. Esta faixa substitui 11–20 h para este recorte delimitado; não cobre concluir todas as modalidades nem um futuro lote geográfico não identificado. Não inclui espera por acesso/decisão. P1/P2 ficam preparados em QA com os limites acima; P3/P4 preparados por leitura, não importados. Instalação, incrementos 36–39 e activação interna das duas definições precisam da decisão expressa identificada. Sem alterações remotas ou publicação nesta execução.
## Pacote executável P3/P4/P5/P6 — c11a0c74766c, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote c11a0c74766c, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O novo adaptador passou sintaxe/integridade/bindings, sem repetir importação; aplicação desse adaptador ainda não ensaiada. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 82dab3719ffc, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 82dab3719ffc, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 312965f2c653, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 312965f2c653, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Execução autorizada 312965f2c653 em homologação — 2026-10-20T10:01:37Z
Autorização expressa da Tempo nesta conversa; aplicada exclusivamente em teste-investidor.tempo.co.mz. 56 fontes/177 assets (233 caminhos) conferidos; backup fresco consistente de 171 tabelas, privados, fontes/build e configuração, seis artefactos cifrados e recuperação existente preservada. Não foi repetido restauro aceite.
30 fichas privadas, 30 localizações, 31 suportes, 11 relações; dois originais internos com scan real limpo (uma tentativa cada), sem publicar. project-change/project-follow-up v1 internos activados; project-preparation v5 preservado. Reutilizadas 27 referências/25 territórios; zero escritas de catálogos/geometrias, migrations ou permissões. 160 tabelas integralmente iguais; registos anteriores nas tabelas acrescentadas conferidos por hash; 361 ficheiros anteriores fora do delta intactos. HTTP/fila activos. Recibo e acessos: outputs/investmoz-revisao-integrada-QA-20261019/p1-p2/modalidades-e-interface/RECIBO-EXECUCAO-312965.json.
Saldo: execução delimitada P1–P6 concluída; não somar outra vez as 4–7 h já executadas. Mantêm-se vínculo positivo com dossier legítimo, dados em falta/revisão independente, oito lacunas contratuais, capacidades por desenvolver, P7 e SuiteCRM. Reversão disponível só da apresentação: manter 56 fontes compatíveis, ambos os builds, dados, definições usadas e histórico. Não repor dump antigo sobre dados posteriores. As questões de autenticação prematura e diferenças visuais/login continuam separadas, com análise retomada; sem alteração remota adicional.
### Retoma separada de autenticação e apresentação após 312965
Diagnóstico apenas em clone isolado: com a reserva ligada, GET /documentos devolve 302 para login; desligada só no processo QA, /documentos e /oportunidades devolvem 200 anónimo, enquanto /management/portal-services e /minhas-oportunidades continuam 302. A reserva global tem origem na decisão expressa da Tempo, não constitui uma nova obrigação pública de MFA e não foi desactivada no destino. Questão permanece aberta para distinguir claramente reserva do ambiente e percurso público.
Manifestos de build local principal e QA instalada são diferentes; app.tsx/layout AuthSimple iguais, login.tsx difere no reaproveitamento do email. O ecrã local já aberto conserva título Laravel e rodapé anterior; não foi recarregada ou alterada a base local activa. Comparação visual por composição/configuração/sessão continua aberta. Nenhuma correcção adicional de código foi incluída silenciosamente no pacote. Prova: RETOMADA-AUTH-VISUAL-QA.json na mesma pasta do recibo; sem novo backlog.
## Consulta pública e coerência visual em QA — b0fdd083ca56, 20/10/2026
Origem: pedido expresso da Tempo de 20/10/2026. A reserva do ambiente permanece obrigatória; a dependência de uma identidade do portal para qualquer consulta era escolha de implementação. O middleware global confundia ambas. Modo `perimeter` preparado e demonstrado apenas no QA loopback: autorização do ambiente pelo servidor, consulta pública sem conta e autenticação nas operações identificadas. Ausência de autorização e cabeçalho HTTP forjado recusados. O modo `reviewers` continua como valor predefinido compatível até configurar uma reserva independente no destino por decisão expressa. Não basta activar a flag nem aceitar um cabeçalho do cliente.
Base visual: composição aprovada 3fd1f15bd01c, preservando início, navegação, editorial e rodapé. Builds diferentes e conteúdo/configuração em execução explicam a divergência observada com o local; não se copiou `.env` ou segredos. Login/registo ainda continham textos ingleses e logótipo Laravel. QA utiliza a marca Tempo existente, textos portugueses, formulário sem salto de foco e aviso da reserva. A ligação de entrada na ficha pública passa pelo fluxo existente `/participar/entrar`, conservando URL/contexto.
Provas: 13 fronteiras de acesso e seis verificações adicionais passaram; TypeScript e build com código 0. No navegador: consulta anónima → rascunho sintético → autenticação → mesma oportunidade, nome, país, sector e mensagem recuperados. Paragem antes do envio final; zero manifestações desta conta. Registo normal por HTTP em QA, sem confirmação artificial de email/MFA e sem email externo. Gestão privada continua protegida; a conta de participação não recebe permissões administrativas. Imagens antes/depois de início, login e registo guardadas no mesmo conjunto de provas. Não repetir importação, recibos completos, scans ou restauros.
Revisão QA `b0fdd083ca56`: 10 fontes e 177 ficheiros de build no manifesto `MANIFESTO-PUBLICO-VISUAL-QA.json`; pacote sem ambiente, chaves, actores, dados ou harness. QA: http://127.0.0.1:18561/ ; /oportunidades ; /login ; /register. Só acessível nesta máquina. Homologação remota mantém 312965f2c653; não instalada esta correcção e nenhuma abertura externa. Para futura disponibilização será necessário definir e conferir o perímetro independente, antes de activar `perimeter`. Oito lacunas contratuais, vínculo documental positivo ao dossier e P7 separados. Reversão desta preparação: restaurar as dez fontes/build anteriores e manter o modo reservado anterior; nenhum schema ou dado de negócio alterado por este delta.
## Proposta consolidada após reconciliação dos programadores — 423bd512d8c5, 20/10/2026
Preservada b0fdd083ca56 integralmente por hash, sem a renomear nem substituir. É revisão de conteúdo do pacote, não commit Git. Origin/main foi consultado nesta passagem: 507387502c6ef887433e8e9b5cf20ea39d4f17d4, com dois commits posteriores ao HEAD local a128c5a1. A nova composição mantém a aplicação aprovada 3fd1f15bd01c/312965 e acrescenta 97a08df2 (cabeçalho/menu responsivo, institucional/contacto, rodapé e formulário) e 50738750 (compilação/carregamento diferido dos exportadores). As alterações de 14ad2f60, cb438b2b, becb211f e 9bc450e8 já estavam incorporadas na base e foram conservadas. 6e57d1f6 e a128c5a1 são documentação/reconciliação, não implementação de autenticação.
Inventário: 760 ficheiros runtime do Git presentes, 677 exactos após normalizar finais de linha e 83 sobreposições QA enumeradas. Presença não comprova funcionamento de todos os componentes. Cinco conflitos de composição resolvidos; preservados menus novos, editorial, email institucional configurável, acesso/contexto e corpo dos exportadores aceites. A refactorização dos programadores só deslocou o corpo de geração; foi conservada com o corpo QA aceite. Ferramentas de arranque local e respectivo comando/composer dev-check não precisam de instalação no servidor. Não aplicar checkout/reset/pull nem substituir uma árvore inteira.
Identidade: a expressão anterior «marca Tempo» referia o logótipo da proprietária usado na autenticação, não autorização para mudar a identidade do produto. A composição agora identifica explicitamente InvesteMoz, propriedade da Tempo, Lda.; reutiliza o logótipo existente apenas para a proprietária. Não foi inventado outro logótipo de produto nem substituído um exemplar aprovado. Não foi localizado outro logótipo InvesteMoz entre os recursos públicos do repositório.
Reserva exacta: Nginx HTTPS com auth_basic independente, ficheiro protegido /etc/nginx/private/investmoz-homologation.htpasswd, snippet nominal e marcador FastCGI INVESTMOZ_HOMOLOGATION_ACCESS=granted só após autorização do ambiente. INV...HOMOLOGATION_ONLY=true mantido e ACCESS_MODE=perimeter. Document root /var/www/html/teste-investidor.tempo.co.mz/public, socket /run/php/php8.4-fpm.sock, vhost /etc/nginx/sites-available/teste-investidor.tempo.co.mz.conf. Configuração completa e hashes no pacote. /deploy e assets igualmente reservados; compatibilizar o cliente dos programadores antes da troca. Credenciais independentes por revisor autorizado, transmitidas por via protegida, sem reutilizar palavras-passe do portal ou alterar papéis/MFA.
Novas provas: oito casos HTTPS/Nginx/PHP-FastCGI passaram (401 sem autorização, 200 para convidado do portal autorizado no ambiente, 302 na operação privada), três passos da reversão da reserva e quatro casos do contacto com transporte simulado. Navegador: abrir/fechar/Escape do menu, navegação para contacto, erro do formulário e identidade em início/login/registo. Build/TypeScript passaram. Reutilizadas as 19 provas b0 e anteriores; sem nova importação, publicação, scan ou restauro. Clone Windows não comprova por si a instalação Linux. Última evidência remota continua a captura aceite de 20/10/2026; tentativa SSH desta passagem não autenticou (verificação da chave do host), sem ignorar essa verificação. Preflight fresco obrigatório antes da execução, com comparação também dos fontes que alimentam o build e detecção de ficheiros adicionais. Qualquer divergência bloqueia a troca e deve ser reconciliada.
Proposta única 423bd512d8c5: 40 fontes/recursos estáticos e 187 ficheiros de build, configurações e preflight somente leitura; zero migrations/importação/publicação. Reversão deste delta por ficheiro/hash e sequência da reserva, mantendo as 56 fontes compatíveis e dados introduzidos por 312965 e posteriores. Não é reposição integral da aplicação; não repor dump antigo. Oito lacunas contratuais, vínculo positivo ao dossier, geometrias e P7 separados. Estimativa operacional 90–150 minutos com preflight limpo e credenciais prontas, sem desenvolvimento das modalidades pendentes. Sem alterações remotas nesta passagem.
## Preflight SSH e compatibilidade da reserva — 20/10/2026
Revisão 423bd512d8c5 preservada, ZIP SHA256 43b68aaf44daec2db0251f43a8ae254f038fbc0bb0157839327190f578d5d1af. Esta leitura substitui a limitação SSH do registo anterior: 669 verificações do preflight exacto passaram, incluindo 665 entradas de fontes; 125 ficheiros de dependências/base adicionais coincidem com a base aceite. HEAD remoto 9bc450e8; origin/main permanece 50738750. Build servido e vhost coincidem com as pré-condições do manifesto. Não apareceu conflito novo de código dos programadores.
Preservar um comando adicional de diagnóstico idêntico à base aceite, 149 auxiliares gerados (11 diferenças de comentários e três auxiliares antigos sem algumas rotas; nenhuma operação nova desconhecida) e 566 recursos adicionais do build. Não apagar nem regenerar no servidor. A aplicação recebe o build QA já aceite, não uma compilação remota.
Compatibilidade /deploy: ensaio isolado com o webhook efectivo confirmou 401 com HMAC válido mas sem credencial ambiental; 403 com credencial ambiental e HMAC inválido; 202 com ambos válidos, despacho interceptado, zero comandos reais e zero chamadas remotas. Nginx QA reposto. Não repetir as provas funcionais aceites.
O script remoto já recusa árvore Git alterada antes de fetch/reset/migrations; o estado failed resulta dessa guarda. A primeira leitura por palavras-chave sobre reset era incompleta: nenhuma guarda nova é necessária. Conservar script/serviço/HMAC. Não chamar /deploy para instalar esta revisão: o fluxo genérico inclui migrations automáticas, fora deste manifesto nominal de zero migrations.
Execução proposta: após decisão, backup fresco consistente; criar credenciais ambientais por via protegida; configurar no webhook Gitea existente Authorization Header = Basic <base64(utilizador:segredo ambiental)> ANTES de ligar a reserva, sem mudar URL/eventos/ramo/Secret HMAC; conferir novamente as pré-condições; aplicar nominalmente 40 fontes/recursos e 187 ficheiros de build; configuração Nginx exacta com /deploy igualmente reservado. Não usar Test Delivery ou push para testar, pois pode disparar o deploy genérico. O cabeçalho é configuração operacional, não credencial de conta do portal, e não entra em chat, URL ou recibo.
Avaliadores introduzem credencial ambiental e navegam no conteúdo público sem conta InvesteMoz. Operações identificadas continuam a exigir login/políticas/MFA aplicáveis. A futura abertura pública retirará a reserva por decisão própria, sem retirar a protecção privada ou a assinatura/guarda de /deploy. Identidade preservada: InvesteMoz, propriedade da Tempo, Lda.
Reversão: repor selectivamente o delta por hash e a reserva anterior, e só depois o cabeçalho anterior do cliente, sem alterar o Secret HMAC. Preservar alterações posteriores, 56 fontes compatíveis, dados, relações, definições e assets. Não é reposição integral da aplicação nem restauro de dump. Nenhuma alteração remota efectuada. Execução continua sujeita à decisão da Tempo; estimativa operacional 90–150 min com acessos prontos, excluindo esperas/divergências. Oito lacunas contratuais, vínculo documental legítimo, geometrias e P7 separados.
Provas: RECIBO-PREFLIGHT-423bd512d8c5.json, PREFLIGHT-EXACTO-423bd512d8c5.json e PROVA-COMPATIBILIDADE-DEPLOY-QA.json na pasta da mesma proposta. O manifesto/ZIP congelados não foram alterados; este recibo actualiza o preflight e a ordem operacional.
## Execução autorizada 423bd512d8c5 — acesso pendente em 20/10/2026
A Tempo autorizou a execução nominal e a reserva independente. O preflight fresco passou (669 verificações). Staging protegido contém as mesmas 40 fontes/recursos e 187 ficheiros de build, sem alterar o ZIP. Dois hashes APR1 ambientais preparados em staging, sem activar a reserva. Credencial de Nala preparada em ficheiro local privado, restrito ao titular Windows/SYSTEM; nenhum valor em relatórios ou repositório.
Impedimento: o Gitea recusa a autenticação e o Authorization Header do webhook existente ainda não foi configurado. Não activar a reserva antes desta dependência. Pedido de autenticação/configuração local pendente; não falta nova autorização. Aplicação anterior preservada, sem manutenção, instalação, backup consistente iniciado, migrations, importação ou publicação. Retomar com acesso Gitea, backup fresco consistente e execução nominal já autorizada. Provas de consulta sem conta, conta simples e retoma por realizar após instalação. Disponibilização automática por Git não implementada e separada. Estado: ESTADO-EXECUCAO-AUTORIZADA-423.json na pasta da mesma proposta.
## Disponibilização nominal 423bd512d8c5 — 20/10/2026
Executada exclusivamente na homologação reservada por autorização expressa da Tempo. Gitea: utilizador confirmou gravação do Authorization Header; presença Basic e Secret HMAC conferida sem expor valores. Não foi chamado /deploy, Test Delivery ou fluxo Git genérico. Disponibilização automática por Git não implementada e separada.
Backup fresco consistente concluído: manutenção HTTP, fila suspensa, bloqueios SHARE nas 171 tabelas públicas, hashes dos ficheiros estáveis antes/depois. Seis artefactos cifrados e conferidos: base, ficheiros privados/públicos de armazenamento e configuração/chaves da aplicação, fontes/build, configuração de servidor, ACL Linux e metadados. Envelope portátil de recuperação existente preservado, independente do perfil Windows; proprietário/grupo/permissões/ACL/xattrs registados. Não repetido o restauro integral aceite. Cópia anterior do delta e diário de hashes sob custódia root no destino.
Aplicadas 40 fontes/recursos e 187 ficheiros de build, exactamente pelos hashes do pacote congelado. Manifesto efectivamente servido: 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. HEAD Git permanece 9bc450e8 e não identifica sozinho a composição servida. Restantes fontes, comando adicional, 149 auxiliares gerados e todos os assets anteriores não substituídos pelo manifesto preservados (741 ficheiros antigos inventariados, zero perdas/alterações imprevistas). Vhost/snippet correspondem aos bytes do pacote; apenas cinco chaves nominais alteradas; restantes valores .env e proprietário/permissões preservados. Nginx validado antes de reload; PHP-FPM/scanner/base não reiniciados. Manutenção terminada e fila no estado anterior activo. Migrations registadas 88 antes/depois; zero migrations, importação, publicação, alterações de contas/grants/MFA.
13 verificações HTTPS do delta passaram: sem credencial ambiental, início/documentos/build e /deploy recusados (401); com credencial ambiental e sem conta do portal, início, documentos, oportunidades, sobre e contacto 200; áreas privadas redireccionam para login; manifesto HTTP igual ao instalado. Navegador: utilizador concluiu a entrada ambiental; início e os 29 documentos abrem com Entrar visível, sem conta InvesteMoz; Continuar no meu Balcão redireccionou para login. Retoma após login real e consulta com conta simples aguardam a sessão apropriada; não certificar esses dois passos pela prova anónima. Sem actores sintéticos ou impersonação no destino.
Credencial ambiental entregue por ficheiro local privado, restrito ao titular Windows/SYSTEM; não consta do repositório, Page ou recibos. Abertura pública continua separada e retirará a reserva por decisão própria, mantendo protecções privadas e /deploy.
Reversão aprovada: modo reviewers enquanto reserva independente permanece; repor selectivamente apresentação/rotas e configuração deste delta apenas se os hashes ainda coincidirem; preservar alterações posteriores; só depois repor reserva/cabeçalho anteriores. Manter 56 fontes compatíveis, dados/relações/definições e assets posteriores. Não é reposição integral nem restauro de dump. Oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git separados. Instalação concluída; saldo desta execução: 15–30 min de verificação com sessão real adequada, excluindo esperas; nenhum novo desenvolvimento incluído. Provas e recibo: RECIBO-EXECUCAO-423bd512d8c5.json e PROVA-HTTP/CONFIGURACAO/PRESERVACAO-DESTINO-423.json na mesma pasta da proposta.
## Conferência da sessão real 423bd512d8c5 — 20/10/2026
Depois da entrada ambiental, o navegador apresentou início e biblioteca com 29 documentos sem conta do portal. Continuar no meu Balcão conduziu a /login. O titular concluiu a autenticação real e regressou automaticamente a /balcao-virtual/painel, com o título Balcão Virtual — acompanhamento e conta visível Osmane Nala. A página inicial voltou a abrir nessa sessão autenticada, sem pedir nova credencial. Esta evidência encerra a retoma no delta; não é prova de uma conta simples, pois a conta usada tem funções já autorizadas.
Não pedir novas contas para encerrar a instalação nem certificar a conta simples sem a observar. Única conferência específica restante deste recorte: consulta com uma conta simples real adequada, 5–10 min quando disponível, excluindo esperas. Não houve criação de conta, impersonação ou alteração de MFA/grants. Credencial ambiental, conta InvesteMoz e conta Gitea são independentes; para o trabalho corrente no portal, usar nala@tempo.co.mz. Não voltar a pedir autenticação nesta passagem.
Os seis directórios novos da instalação foram alinhados com o proprietário do repositório (mario:www-data, 0755), comprovadamente ausentes no arquivo fresco; permissões dos directórios existentes intactas. Sem alteração do pacote ou revisão. A sessão SSH encerrou pelo limite anterior; não foi reaberta apenas para actualizar o diário operacional remoto. Recibo final e provas actualizados localmente e na mesma Page. Revisão efectivamente servida continua 423bd512d8c5. Git automático, oito lacunas contratuais, dossier legítimo, geometrias e P7 separados.
## Consulta pública sem autenticação ambiental — 20/10/2026
Decisão expressa da Tempo: retirar a autenticação ambiental das páginas públicas, preservando login, permissões e MFA das operações privadas e a protecção própria de /deploy. Esta decisão substitui a exigência de credencial ambiental para consultar os conteúdos públicos nesta homologação; não publica novos conteúdos nem altera contas ou funções.
Executado apenas o delta de configuração: removida a reserva Basic global do vhost; Basic mantido explicitamente na localização exacta /deploy com o mesmo htpasswd, e HMAC/Secret/cabeçalho Gitea preservados. Mantidos noindex/nofollow/noarchive e private/no-store no Nginx. INVESTMOZ_HOMOLOGATION_ONLY=false desactiva a reserva global do portal e a indicação desactualizada de ambiente reservado. O modo anterior permanece configurado mas inactivo para eventual reversão; marcador FastCGI de acesso ambiental retirado. Nenhuma alteração de código/build: revisão servida 423bd512d8c5, manifesto SHA256 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. Apenas reload Nginx após nginx -t; sem manutenção, migrations, imports, publicação ou reinício de base/fila/scanner.
Backup fresco apenas da configuração alterada, com hashes revalidados e metadados Linux, cifrado sob a custódia e envelope portátil existentes. Valores secretos não constam dos recibos. Cópias anteriores root-only no destino. Reversão: restaurar primeiro o vhost anterior condicionado aos hashes, validar/recarregar para repor a barreira ambiental; depois repor a flag anterior com conferência de conflitos/cache. Preservar edições posteriores e dados; não restaurar base antiga nem build.
16 verificações HTTPS passaram com CookieJar novo e sem Authorization na consulta: início, pesquisa /oportunidades?q=agronegocio (termo recuperado), documentos, ficha pública /documentos/13, artigo /agronegocio, sobre e contacto respondem 200, com componentes públicos e utilizador do portal ausente. Seis áreas privadas (/management/portal-services, /management/profile-install, /minhas-oportunidades, /investimentos, /investidor/manifestacoes e /balcao-virtual/painel) redireccionam para o login do portal sem desafio ambiental. /deploy continua 401 sem Basic e 403 com Basic válido mas HMAC inválido em JSON válido; serviço de deploy não disparado. Uma primeira sondagem com corpo vazio produziu 400 (JSON inválido); corrigida somente a sondagem negativa, sem alterar a aplicação ou repetir as restantes provas aceites.
A ausência do bloqueio é distinta de apenas esconder uma caixa do navegador: os componentes públicos efectivamente renderizam sem formulário de login e a pesquisa conserva o termo. Login num formulário InvesteMoz continua correcto nas operações privadas; se surgir na consulta pública, é falha a registar. Conferência visual em janela InPrivate nova solicitada ao utilizador, ainda pendente neste registo. Não declarar concluída essa parte pela prova HTTP isolada.
Recibo: RECIBO-CONFIGURACAO-PUBLICA-20261020.json; prova: PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json, na pasta da mesma proposta. Oito lacunas contratuais, conta simples específica, dossier legítimo, geometrias, P7 e disponibilização automática por Git continuam separados. Não houve novas contas, concessões, desactivação de MFA ou publicação adicional.
## Fecho da consulta pública sem credencial — 20/10/2026
O utilizador confirmou em janela Edge InPrivate nova: início, pesquisa /oportunidades?q=agronegocio e documentos abrem sem qualquer pedido de credenciais («ja funciona nestes 3»). Confirmação visual manual, distinta da prova automática HTTPS. A prova automática foi alargada apenas aos três consumidores adicionais afectados — zonas, sector Turismo e legislação — e passou em 19 verificações totais. Não repetidos scans, restauros, importações ou percursos anteriores.
Este delta de configuração fica concluído. Consulta pública não requer credencial ambiental nem conta InvesteMoz; seis operações privadas verificadas continuam a pedir login do portal, com permissões/MFA inalterados. /deploy conserva Basic + HMAC, com recusas 401/403 comprovadas e sem disparar o serviço. Prova de limites confirma que apenas /deploy contém Basic, o antigo include global e marcador FastCGI foram removidos, outros valores .env e proprietário/permissões estão intactos, webhook inalterado. Revisão de código/build continua 423bd512d8c5. Documentação anterior que exige credencial para a consulta pública fica superada por esta decisão expressa da Tempo.
Recibo e prova finais: RECIBO-CONFIGURACAO-PUBLICA-20261020.json e PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json. Sem pendência neste recorte. Conta simples específica, oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git mantêm os saldos separados anteriores. Não se declara a aplicação inteira concluída por este ajuste.
## Git main automático — 20/10/2026 — 8cf820ef
Disponibilização automática activa em main; revisão servida 8cf820ef497218d9adebe6a6a022b4c80d694dc3. Push real recebido pelo webhook às 17:28:44 e instalado às 17:29:56 (Maputo), sem despacho manual. Preservadas composição 423bd512d8c5 e alterações dos programadores até 02d4e6b2; conflito do menu resolvido mantendo layout novo, identidade InvesteMoz e administração delimitada. Build, TypeScript, 47 testes PHP, 156 frontend e 3106 asserções de schema passaram; nove testes Linux do executor e ensaio do webhook também passaram. Demonstração visual: Consultar oportunidades. 171 tabelas e 88 migrations permanecem; zero migrations no destino, importações, publicação ou novos grants. .env com bytes/proprietário/modo intactos; armazenamento partilhado e 869 assets anteriores preservados. Consulta pública 200 sem credenciais, gestão 302 para login e /deploy 401 sem Basic. Fila serial, recusa de drift e de alterações de migrations, releases separadas, logs/recibos protegidos e reversão de código/build com dados posteriores conservados. Oito lacunas contratuais, dossier legítimo, geometrias, conta simples específica e P7 mantêm saldo separado.
Procedimento dos programadores: trabalhar numa branch, rever segundo as designações da Tempo e integrar em `main`. O webhook existente dispara a verificação e compilação da revisão completa. Confirmar o commit em https://teste-investidor.tempo.co.mz/build/revision.json. Uma falha conserva a versão servida e deixa recibo/log protegido; não editar a pasta servida para a contornar.
O executor genérico com migrations automáticas foi substituído. O contrato nominal de 88 fontes de migrations fica protegido fora do Git; uma alteração exige compatibilidade/decisão específica. A CI usa PostGIS oficial fixado por digest, apenas em loopback 55441, base descartável `*_test` e identidade conferida. Não usa contas ou dados da aplicação. A guarda nominal e os testes de lifecycle existentes são preservados; não se contornam as instalações autenticadas.
Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/deploy/investmoz-pipeline.py --config /etc/investmoz-deploy/delivery.json --rollback` como operador existente. Conferem-se hashes e saúde; a release anterior fica servida e o worker recebe reinício gracioso. Mantêm-se base, uploads, configuração e dados posteriores. Não é restauro integral. O checkout físico anterior e a cópia cifrada de configuração/metadados Linux permanecem disponíveis; a recuperação portátil existente foi reutilizada. Não foi repetido o restauro integral aceite.
As correcções do ensaio limitaram-se à composição: diagnóstico mais recente dos programadores, teste de exemplo sem migração inútil, fixtures unitárias independentes das flags do destino, expectativas dos menus/relatórios já aceites e guarda do cookie fora do navegador, com prova de CSRF preservado. Nenhuma nova regra organizacional.
Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN.json; PROVA-ENSAIO-COMPLETO.json; PROVA-CONTROLO-FINAL-LINUX.json; PROVA-GITEA-EVENTO-E-INSTALACAO.json; PROVA-VISUAL-GIT-MAIN.png; deploy/homologacao/README.md; /build/revision.json. Manual versionado: `deploy/homologacao/README.md`. Esta entrega encerra o fluxo automático, não os restantes componentes da aplicação. As alterações documentais anteriores no checkout local foram preservadas.
## Pesquisa transversal QA — 20/10/2026
Origem: instruções expressas da Tempo nesta conversa. A classificação multidimensional, dados, permissões, histórico e URLs mantêm-se. Os ajustes de interface/consulta são implementação do pedido, não novas regras organizacionais. Branch `codex/opportunity-search`, base `1350b4df`, QA HTTP `http://127.0.0.1:18570`. Não integrar em `main` antes da revisão: a integração desencadeia disponibilização automática.
**Causa reproduzida de 11 versus 0:** em `/oportunidades?sector=turismo` no destino, o selector provincial apresentava 11 opções através de `SearchState` como “11 resultados encontrados”, enquanto o paginador devolvia zero oportunidades públicas. São universos diferentes. Agora o primeiro diz “Província: 11 opções disponíveis”; o catálogo usa apenas o total do seu paginador. Não foram alterados dados para obter concordância. Foram ainda retirados o resumo global duplicado desta página, sugestões com critérios incompletos e resultados de parcerias que tinham pesquisa própria.
| Ecrã/URL | Problema | Correcção | Prova | Pendência/limite |
|---|---|---|---|---|
| Oportunidades — /oportunidades | Filtros duplicados, códigos internos, maturidades cruzadas, contagens concorrentes e mapa em grelha. | Filtro único, Mais filtros, preparação explícita, moeda obrigatória para montantes, pedidos canceláveis, estados e contexto únicos. | 6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado. | Revisão visual da Tempo; NLP separado. |
| Negócios — /negocios | Classificação técnica visível, estados de auditoria e contagem territorial sem âmbito. | Sector básico, critérios especializados recolhidos em Mais filtros, estado de auditoria retirado do controlo público, contagem identificada e filtros removíveis. | HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados. | Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote. |
| Sectores — /sectores; /sectores/{slug} | Navegação relacionada dispersa e contagens genéricas sem nome do universo. | Contagem identificada como Sectores; navegação comum preserva a entrada por sector e Fintech como especialização. | Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação. | Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro. |
| Localizações — /oportunidades?view=map | Mapa e contagem de oportunidades podiam usar universos diferentes sem explicação. | Mesmos filtros e permissões; locais com coordenadas e oportunidades sem coordenadas apresentados separadamente; fichas de contexto identificadas. | Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas. | Nenhuma coordenada ou geometria criada. |
| Carteira — /carteira | Menu local repetido, ausência de retorno explícito e de distinção entre ficha e pesquisa associada. | Contagem Fichas da carteira, chips removíveis, menu local redundante retirado, Consultar ficha e Ver oportunidades associadas; retorno com query local. | HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação. | Provas anteriores de edição/publicação conservadas; não repetidas. |
| Zonas e parques — /zonas; /zonas/{id} | Destinos repetidos, vazio atribuído indevidamente a falta de cadastro, retorno sem filtros. | Destinos relacionados recolhidos; vazio neutro; consultar ficha separado de oportunidades associadas; retorno e exportação preservam filtros. | HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno. | Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha. |
| Clusters — /clusters → /carteira?kind=cluster | Mesmo padrão da carteira; não é um catálogo independente de oportunidades. | Reutiliza carteira, contagem nomeada e tipo de ficha removível; URL anterior preservada. | Redireccionamento verificado no teste PHP; consumidor comum inspeccionado. | Nenhuma equivalência entre implantação e associação criada. |
| Corredores — /corredores → /carteira?kind=corridor | Mesmo padrão da carteira e risco de confundir ficha territorial com oportunidade. | Reutiliza carteira e ligações com finalidade explícita; URL anterior preservada. | Redireccionamento verificado no teste PHP; consumidor comum inspeccionado. | Nenhuma alteração de relações ou dados. |
| Educação — /educacao | Herda formulário e contagens concorrentes das oportunidades. | Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido. | HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns. | Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões. |
| Biblioteca — /documentos | Dois resumos de contagem, filtros activos sem remoção individual, controlos sem opções e rodapé local repetido. | Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum. | HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado. | Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva. |
| Legislação — /legislacao | Mesmo padrão da biblioteca; não deve expor um universo concorrente. | Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes. | HTTP/Inertia: resources.total=0; consumidor e query pública partilhados. | Situação jurídica, consulta e cálculo continuam independentes. |
### Utilização
1. Em oportunidades, preencher palavras, sector, modalidade e localização até ao nível conhecido. Usar “Mais filtros” para estruturas, actividades e cadeias. As opções territoriais não são a contagem das oportunidades.
2. Para limites financeiros, escolher a moeda. Sem moeda, a pesquisa pede esse campo; não assume MZN nem converte montantes. Preparação tem etapas actuais e classificações históricas separadas.
3. Retirar um critério pelo seu botão/ligação ou usar “Limpar filtros”. Alterar o pai territorial limpa os descendentes incompatíveis. Abrir a ficha e usar “Voltar à pesquisa” recupera os critérios.
4. “Consultar ficha” consulta a estrutura territorial/documental; “Ver oportunidades associadas” pesquisa vínculos publicados. Proximidade ou o mesmo distrito não demonstram pertença. No mapa, registos sem coordenadas continuam identificados na lista, sem posições inventadas.
5. Durante o pedido aparece “A pesquisar…”. Um erro apresenta “Tentar novamente” e preserva critérios; não aparece como resultado vazio. Pedidos anteriores são cancelados/ignorados ao mudar os filtros. Os estados têm `status`/`alert` e `aria-live`.
### Compatibilidade e provas
- A pesquisa antiga `maturity_level=1..6` conserva a classificação histórica, sem misturar a posição com a etapa actual. As posições 7/8, exclusivas das oito etapas, correspondem explicitamente a implementação/operacional. Novas selecções usam código de etapa ou classificação histórica explícita, sem reescrever os registos.
- Balama real, ID `ed4d2fba-95d5-42e3-8ca7-1f520de1036a`: leitura pública do destino devolveu zero oportunidades associadas. Na cópia QA, essa ficha real está em rascunho; não foi publicada. A prova positiva usa um parque e oportunidades **SINTÉTICO QA**, incluindo associação preliminar, proximidade, território comum, outro parque e rascunho privado. Só a associação pública correspondente entra no filtro.
- QA isolado `opportunity_search_20261020_test`, PostgreSQL local porta 55441; guarda impede testes na base activa. Testes transaccionais revertem apenas fixtures próprias; não foi executado `migrate:fresh`.
- Seis testes PHP, 112 asserções: associação, confidencialidade, modalidades, maturidades distintas, moeda/combinações/vazio, referências, lista/exportação e HTTP dos consumidores/aliases. Seis testes frontend: navegação, flags, destinos únicos, quatro entradas e retorno local com query. Build e TypeScript conferidos no recibo final.
- Navegador: consulta → ficha → retorno; parque isolado; validação de moeda → vazio; falha HTTP sintética de um único pedido → nova tentativa bem-sucedida; Inhambane/Vilankulo → Gaza limpa distrito e postos. A falha existe apenas no router privado do servidor QA, fora do pacote.
- Móvel: dimensão real 390 px, largura de conteúdo 375 px, sem transbordo horizontal; teclado abre/fecha Mais filtros e Descoberta. A capacidade automática de viewport não aplicou a dimensão; a prova utilizou o modo responsivo do navegador. Não se atribui essa limitação à aplicação.
- Antes/depois e estados: artefactos locais `opportunity-search-artifacts/{before-search.png,before-search-ax.txt,qa-after.png,qa-mobile.png,qa-error.png,qa-zero.png,backend-tests.log}`. As capturas do destino e QA usam acervos diferentes; os totais entre ambientes não são uma comparação de dados.
- Inventário de reutilização de `investment-territory.tsx`: oportunidades, negócios, carteira, zonas, educação, análise educativa, formulário/recursos/editor de oportunidades, estruturas, associações territoriais, contexto do investimento, pedido do Balcão, terra/DUAT, formulários públicos e assistência existente. A alteração comum identifica o âmbito da contagem; não demonstra de novo edição/autorização desses consumidores privados. `CountrySelect` também identifica Países; `InvestmentMapPanel` identifica o universo cartográfico.
- “Catatálogo” não foi encontrado nas fontes actuais inspeccionadas. O título servido usa “Catálogo”. O menu local redundante da carteira que exibia “InvestMoz” foi retirado; a identidade comum continua InvesteMoz, propriedade da Tempo.
### Assistência à pesquisa — saldo separado
`config/investmoz_ai.php`, `AiGateway` e `AssistiveRules` mostram pesquisa lexical/evidências e regras sobre campos estruturados, com `model=null`, sem pesquisa semântica. Não interpretam a frase “turismo em Inhambane até 500 mil USD” como filtros. `evidence_only` não comprova NLP. Nenhum serviço externo, custo ou envio de dados foi activado.
Incremento ainda necessário: interpretação delimitada de palavras/sector/geografia/montante/moeda; prévia editável dos filtros; pergunta apenas quando ambíguo; confirmação antes de executar exactamente a query manual; ausência de resultados sem alargamento silencioso. Estimativa de engenharia: 6–10 h para um interpretador local delimitado e casos de ambiguidade/testes; compreensão aberta com modelo externo exige proposta e decisão próprias. Esta estimativa não inclui as oito lacunas contratuais nem P7 e não bloqueia as correcções manuais.
### Integração e reversão
Rever a branch e o manifesto nominal; reconciliar novos commits dos programadores antes da integração. Após decisão da Tempo, a cadeia Git existente compila a revisão aprovada de fontes e build. Esta execução não envia para `main`, não escreve no servidor e não altera conteúdos, schema, contas, papéis, MFA ou configuração.
Não há migrations, importação ou publicação neste pacote. A reversão repõe as fontes/build da revisão anterior através da cadeia existente, preservando a base, uploads, referências, permissões e dados posteriores. Os helpers de contexto só transportam query local; não aceitam URL externa como destino de retorno. O manifesto lista fontes e hash do build QA; não inclui `.env`, credenciais, dumps, vendor/node_modules ou fixtures persistentes.
Conferência final dos consumidores: negócios também conferido no navegador com pesquisa vazia; retirado o resumo local repetido, conservando o resumo partilhado. Fintech é identificado como especialização do Mercado Financeiro também nesse selector. Os cartões de zonas traduzem categoria, desenvolvimento, disponibilidade e estado de actividade, preservando os códigos e significados persistidos. A vista combinada não repete na lista de não localizados a ficha que já aparece na lista principal.
## Guias educativos relacionados — 20/10/2026
Delta independente de `0e7d51f18fb4`, que já foi integrado e confirmado na homologação. Este incremento permanece na branch `codex/educational-content-cards`, em QA, sujeito a revisão própria. Origem: pedido expresso da Tempo; nenhuma nova regra organizacional.
O componente `EducationalArticleCard` usa imagem pública autorizada, título original, resumo da edição apresentada, autoria e «Ler o guia». Turismo descreve hotelaria, ecoturismo, infraestruturas e referências regionais presentes no artigo, sem repetir metas financeiras como promessa. Outros artigos usam um excerto do texto efectivo. A apresentação não altera originais, fontes, autoria, direitos, versões, publicação ou scan. A imagem continua sujeita aos filtros públicos existentes. A acção secundária só surge quando a consulta de oportunidades públicas do sector tem resultados; não se infere disponibilidade por menção no artigo.
Consumidores: sector (`SectorEntry`), exploração editorial e leituras relacionadas (`portal/legacy`), catálogo de oportunidades com sector explícito (incluindo vista territorial/mapa) e ficha de oportunidade com sector. Um filtro de localização sem sector não determina um artigo; não se inventaram associações territoriais. Nos restantes catálogos territoriais não foi encontrado um cartão editorial equivalente a substituir. Não se alteraram cartões de anúncios, documentos ou entidades que não sejam recomendações de artigos.
Após a leitura, mantém-se «Iniciar ou continuar uma ideia», com `/investimentos/a-partir-de/{slug}` e o contexto autorizado do dossier quando existente. Não há novo percurso, conta ou privilégio. As provas anteriores de gravação documental mantêm-se; não foram repetidas.
Provas novas: `EducationalGuidesTest`, isolado por guarda QA, transacções e armazenamento fictício do teste, 1 teste / 8 asserções (excerto, autoria, destino, percurso, ausência de resultados, fonte não publicada e scan pendente). TypeScript, sintaxe PHP e build passaram. Capturas a 2048 px e 390 px; a 390 px, conteúdo com 375 px, sem transbordo horizontal, foco visível e Enter no botão «Ler o guia». Imagem real carregada com 900 px de largura natural. A prévia HTTP `http://127.0.0.1:18570/qa/guias/turismo` usa uma cópia por leitura do artigo/imagem já públicos no destino; não usa texto ou imagem sintéticos nem escreve registos. O router privado e a cópia ficam fora do Git. O destino não foi alterado.
Limitação: o artigo real não existe na base QA actual. A prévia permite conferir cartão e leitura; não comprova recuperação desse artigo num dossier autenticado nessa base. O URL original do percurso foi preservado e conferido pelo teste. Não se importaram dados ou fabricaram resultados para preencher essa lacuna. Capturas em `outputs/educational-guides-20261020/desktop.jpg` e `mobile.jpg` (ficheiros locais de prova).
Gestão: editar e publicar pelos ecrãs existentes da biblioteca/editorial; o resumo acompanha a edição apresentada. A acção de oportunidades é calculada pelos registos publicados, não pelo editor. Disponibilização futura: revisão própria e integração em `main` pelo processo automático, após decisão. Sem migrations, importação ou publicação neste delta. Reversão pelas fontes/build anteriores, conservando base, ficheiros, permissões e dados posteriores. IA, oito lacunas contratuais e P7 separados.
### Library integration evidence supplement - 20/10/2026
Functional code remains 3258a25f7659. Existing QA database legacy_trio_20261015_test and its original local storage were selected by an isolated loopback harness; no import, publication or application route override. Normal /sectores/turismo rendered the source-derived summary and image /documentos/25/view (HTTP 200; 900px natural width). Keyboard Enter on Ler o guia opened normal /turismo; the article links to library resource /documentos/10 (source SHA256 8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72). Thus the real-library integration limitation of the earlier copied preview is resolved. URLs: http://127.0.0.1:18572/sectores/turismo and /turismo. Evidence: outputs/educational-guides-20261020/real-library-receipt.json, real-library-sector.jpg and real-library-guide.jpg. No dossier path/context change; previous accepted dossier proof retained, not repeated. Tempo visual review remains pending before main integration. AI excluded. This supplement changes evidence/documentation only, not functional code.
## Revisão integrada do simulador — diagnóstico inicial, 20/10/2026
Branch `codex/simulator-integrated-review`, base `e69ab1499fe99196db240c90d149b7ee46292576`. Pedido expresso da Tempo: cálculos e isolamento primeiro; continuidade e resultados a seguir; português transversal. Apenas QA, sem regras fiscais presumidas, activação de parâmetros, alterações reais ou integração em main.
O código separa cálculo aritmético exploratório (`SimulationExecution`/`RunSimulation`, `custom_planning`, `legal_interpretation=false`, moeda não definida), resolução fiscal versionada (`SimulationTaxParameterResolver`, vigência/condições, estados indeterminados) e planeamento financeiro (`financial-calculators`, `business-financial-plan`, `monthly-cashflow`, ponte fiscal). Não os apresentar como um único motor fiscal integralmente validado. A ausência de moeda no cenário aritmético é explícita no contrato; falta conferir a explicação nos seus consumidores, não atribuir MZN por presunção.
| ID | Prioridade / problema ou verificação | Causa / evidência | Acção e prova nova necessária | Estado |
| --- | --- | --- | --- | --- |
| AUD-SIM-INT01 | P0 — data fiscal inválida interrompe validação | `getFiscalPeriodRange` chamava `toISOString` sobre data inválida | Guarda `Number.isFinite`; datas 2026-99-99, 2026-00-01, dia zero, 30 Fevereiro e vazio devolvem null | Corrigido em QA; testes dirigidos passaram |
| AUD-SIM-INT02 | P0 — fiabilidade e dados fiscais | Resolutor existente conserva período, condições, unidade e versão; sem correspondência devolve valor null | Conferir acervo e parâmetros efectivos QA; cruzar casos independentes, distinguir desconhecido/zero; não activar por publicação | Verificação delimitada pendente, não erro comprovado |
| AUD-SIM-INT03 | P0 — isolamento e recuperação | Âmbito cliente por case/draft, cookies anónimos e RLS/políticas/testes existentes | Reconciliar provas recentes de contexto e conta; verificar só lacunas de nova sessão/projectos diferentes sem repetir os casos aceites | Não há fuga comprovada; verificação pendente |
| AUD-SIM-INT04 | P1 — invalidar e conservar resultados | Fingerprints e versões existentes; resultados/snapshots separados do formulário | Conferir alteração de entradas/parâmetros, histórico imutável e destino dossier correcto | Verificação pendente |
| AUD-SIM-INT05 | P1 — unidades, moeda e cálculos independentes | Aritmética SQL numeric; ferramentas com montantes exactos; falta reconciliação integral do acervo actual | Livro de controlo independente para resultados positivos/negativos/zero/incompletos, sem pressupor incentivos por localização | Verificação pendente |
| AUD-SIM-INT06 | P1 — relatórios e recuperação de exportações | Relatórios/snapshots e exportações já existem; provas anteriores em simulations/ e manual | Conferir exemplos efectivamente gerados, coerência e paginação; reutilizar exemplos aceites compatíveis | Exemplos desta revisão ainda não entregues |
| AUD-SIM-INT07 | P1 — mensagens e operações | APIs com idempotência e concorrência; mensagens e falhas por consumidor carecem de inventário final | Validar/calcular/guardar/exportar com confirmação e dados preservados; conferir erros concretos | Verificação pendente |
| AUD-SIM-INT08 | P1 — português transversal | Exportação apresentava nominal/effective; páginas de segurança e spinner em inglês | Tipo de taxa traduzido na saída, confirmação de palavra-passe/segurança e indicador de carregamento traduzidos; códigos internos preservados | Primeira correcção QA; restante inventário aberto |
Inventário de idioma observado: segurança/confirmação, perfil, reposição de palavra-passe, confirmação de email, MFA, chaves de acesso e recuperação. Pesquisa textual em menus, consumidores de investimento, gestão e componentes comuns também devolve identificadores internos; essas ocorrências são candidatos a inspecção, não erros de tradução automaticamente demonstrados. Não traduzir chaves, contratos/API, dados históricos nem nomes de fontes.
Provas reutilizáveis identificadas: `SimulationExecutionTest`, `SimulationDraftContinuityTest`, `SimulationFormDraftTest`, testes de regras/resolução, contratos e fingerprints frontend; relatórios e capturas `docs/investmoz/simulations/` e a secção recente de apresentação no manual. Não correr RefreshDatabase em bases com acervo. As provas documentadas de Outubro antigo não certificam por si só o estado de todas as versões actuais.
Esforço inicial para este recorte: 18–30 h: dados/cálculos 6–10; isolamento/continuidade 5–8; relatórios/erros 4–7; idioma 3–5. Estimativa de engenharia, não tempo já executado nem desenvolvimento fiscal novo. Reestimar após a reconciliação das provas e parâmetros efectivos; fontes/catálogos realmente ausentes ficam separados e não impedem cálculos independentes que disponham dos dados necessários. Nenhuma nova função organizacional exigida.
Correcções iniciais verificadas: 38 testes frontend dirigidos passaram, TypeScript passou. Sem ensaio remoto, SQL de alteração, novas migrations ou mudança do motor fiscal. A revisão integral permanece aberta. Roteiro Tempo, após completar as verificações: escolher contexto → preencher/rever dados → calcular → guardar/recuperar → alterar e recalcular → comparar e exportar. Os relatórios finais e a matriz problema/causa/correcção/prova/pendência serão complementados nesta mesma secção, sem outro backlog.
### Simulador integrado — saldo da verificação QA, 20/10/2026
Continuação de `59ab4fe95635`, exclusivamente na branch `codex/simulator-integrated-review`, baseada no editorial `e69ab149`. Não houve integração em main, instalação remota, migrations, importação ou activação de parâmetros. A base utilizada foi `opportunity_search_20261020_test`, porta 55441, com guarda de isolamento. Os exemplos são sintéticos e explicitamente identificados.
| Item | Resultado e causa/correcção | Prova e saldo concreto |
| --- | --- | --- |
| AUD-SIM-INT01 | Data pontual inválida passa a produzir erro em `fiscal_reference_date`, sem período alternativo. | Testes de vazio, data impossível e ano incompatível; navegador mostrou mensagem junto ao campo, `aria-invalid` e descrição associada. Encerrado este delta. |
| AUD-SIM-INT02 | Inventário efectivo: zero versões de regras fiscais na base QA. As seis fontes territoriais existentes não fornecem taxas ao motor. | Consulta IVA/2026 devolveu indeterminado, nunca zero. Fórmula executada é apenas base × taxa/100 × (1−redução/100), `portal-arithmetic-v1`, valores declarados, sem moeda, regra fiscal ou interpretação legal. A data de execução não é período de vigência fiscal. Fontes oficiais mantêm a aceitação; não se pediu nova autenticação da proveniência. Parâmetros fiscais ausentes ficam indisponíveis, não estimados pela aplicação. |
| AUD-SIM-INT03 | Remontagem do formulário por conta/contexto/rascunho para não reutilizar estado de outro âmbito. Recuperação do rascunho QA observada após reload. | Provas backend anteriores de isolamento mantidas; falta a conferência positiva do delta frontend com dois dossiers e nova sessão autenticada. Reload não foi contado como nova sessão. |
| AUD-SIM-INT04 | Alterar uma entrada retira o resultado actual e mostra aviso de recálculo; não escreve sobre snapshots históricos. | Navegador calculou 12 e, ao alterar base 100→200, retirou o resultado e mostrou o aviso. Versões/hashes backend preservados. Falta concluir a conferência positiva do histórico/contexto no consumidor autenticado e da mudança de versão de parâmetros. |
| AUD-SIM-INT05 | Controlos independentes por livro de caixa, Decimal e recorrência mensal/bissecção para o crédito. | Saldo mensal +100/−100/0; excedente de fundos −200; equilíbrio 11 unidades; crédito sem juros 100 por mês; nominal 12%/ano, 1000/12 meses: 88,85 na primeira prestação; campo vazio recusado. Cenário personalizado 100×16%×75%=12 confirmado no servidor. São controlos matemáticos, não validação de taxas legais. Restam controlos delimitados de cash-flow/plano de negócio. |
| AUD-SIM-INT06 | Resumo do relatório passou a apresentar os resultados financeiros do mesmo modelo de detalhe. Secção sem anexos ficou compacta, eliminando uma página quase vazia. | Quatro exemplos completos pelo exportador real: positivo, negativo, nulo (6 páginas cada), incompleto (5). Todas as páginas DOCX renderizadas conferidas; testes comparam resumo/detalhe. PDF disponibilizado é a renderização do DOCX, não prova da impressão nativa do Edge. Falta essa impressão e a comparação final ecrã/download pela interface. |
| AUD-SIM-INT07 | Removidos requisitos de residência/documentação que não entram no cenário percentual personalizado; mantida validação específica de base/taxa/redução. Consulta fiscal tem nova tentativa e ignora respostas abortadas. Estado de espera é substituído pelo estado terminal do cálculo. | Cenário personalizado calculou sem esses campos; erro 503 sintético na consulta mostrou mensagem e Tentar novamente; repetição preservou IVA/2026 e devolveu indeterminado por falta de parâmetro. Falhas de gravação/exportação ainda carecem de conferência dirigida dos consumidores afectados. |
| AUD-SIM-INT08 | Traduzidos textos de autenticação, perfil, preferências, MFA/chaves, diálogos, notificações e validações comuns, preservando códigos e mecanismos. | Inventário de 4519 textos/atributos JSX literais em todos os TSX; três candidatos restantes são português com a palavra continue. Isto não cobre todas as mensagens dinâmicas do backend ou do catálogo. Inglês explicitamente seleccionável no formulário bilingue e títulos de fontes preservados. Falta classificar o inventário dinâmico e conferir mensagens residuais em execução. |
Operação de idioma para uma eventual disponibilização: usar `APP_LOCALE=pt` na configuração nominal. O fallback de `config/app.php` passou a pt, mas não substitui um valor explícito do ambiente; não copiar `.env` QA. Foram adicionados `lang/pt/auth.php`, `lang/pt/validation.php` e `lang/pt.json`; as regras comuns cobertas não equivalem ao catálogo completo de validações Laravel.
Complemento do inventário: os 77 candidatos de literais TS/TSX restantes foram classificados como classes CSS internas ou traduções explicitamente seleccionáveis (incluindo menus, formulário bilingue e assistência, cuja activação não foi alterada). Foram ainda corrigidos o breadcrumb de segurança e as três mensagens de falha no carregamento de QR/chave/códigos. O fecho desse levantamento estático não certifica todas as mensagens produzidas por backend, conteúdo e serviços em execução; esse é o saldo de idioma ainda aberto.
Provas novas: 34 testes dirigidos de formulário/relatório passaram; TypeScript, build e lint PHP dos ficheiros de idioma/configuração passaram. Mantidas as provas anteriores não afectadas de contratos, APIs, isolamento e versões; nenhum RefreshDatabase executado sobre o acervo QA. Artefactos locais: `outputs/simulator-integrated-review/` (inventário efectivo, controlos independentes, modelos dos quatro relatórios, DOCX/HTML e `checked-*` com PDF/páginas conferidas). As pastas anteriores `render-*`/`final-*` são intermédias e não são as versões entregues.
Exemplos para revisão por HTTP na aplicação QA local, sem `file:`: `http://127.0.0.1:18570/qa/relatorios/simulador/positivo.pdf`, `negativo.pdf`, `nulo.pdf`, `incompleto.pdf`, e os mesmos nomes com extensão `.docx`. Estes endereços são uma whitelist do router privado de QA, exclusivamente loopback; não são rotas públicas de produção. Ficheiros não sintéticos e configuração privada não são servidos por esta whitelist.
**Estimativa restante: 6–10 h**, já descontado o trabalho acima e as provas aceites; não são as 18–30 h iniciais. Distribuição: continuidade autenticada/histórico e mudança de parâmetros 2–3 h; controlos adicionais delimitados 1–2 h; impressão Edge e comparação ecrã/download 1–2 h; mensagens dinâmicas/falhas de gravação/exportação e fecho 2–3 h. Não inclui cadastrar regras fiscais ausentes nem decidir taxas/incentivos sem fundamento. Falta de dados fiscais não bloqueia os controlos aritméticos disponíveis. A revisão integral continua aberta nos pontos precisos desta tabela.
### Continuação delimitada do simulador — controlo operacional e fontes
Base `a690c665fb4e`, mesma branch QA. Clone exclusivo `simulator_review_delta_20261020_test`, loopback 55441, aplicação `http://127.0.0.1:18576`. A base anterior e as contas reais foram preservadas. Duas contas e dois dossiers SINTÉTICOS QA foram preparados apenas neste clone; sem funções de gestão e sem transporte para o destino. O sinalizador local `INVESTOR_PATH_ENABLED=true` habilitou a capacidade já existente neste clone; não representa configuração aplicada remotamente.
| Item existente | Resultado desta continuação | Saldo |
| --- | --- | --- |
| AUD-SIM-INT01 | Prova anterior de data inválida preservada; código não alterado neste ponto. | Concluído o delta anterior; sem repetição. |
| AUD-SIM-INT02 | Confirmado zero de regras de qualquer estado na base QA original, com visibilidade administrativa sem ocultação por RLS; biblioteca contém 30 documentos. Inventariados 9901 caminhos do acervo antes da selecção: 32 candidatos por nome fiscal e três originais versionados no repositório, estes com hash actual conferido. Existem análises de textos e correspondências preparadas em `primary-text-review-2025-2026.md`, `official-source-register.md`, `fiscal-source-references-2026-10-06.csv` e comando de arquivo existente. Não se identificou um conjunto serializado de parâmetros fiscais reais nos seeders/configuração pesquisados. | Não confundir ausência do cadastro executável QA com ausência de fontes/trabalho documental. Leitura actual da homologação aguarda autenticação SSH local; não se afirmou que o destino também está vazio. Os binários do acervo montado não foram todos relidos: enumeração de caminhos não comprova conteúdo/versão. |
| AUD-SIM-INT03 | A e B recuperaram títulos próprios; B conservou a edição após erro/repetição. Logout e novo login normais, depois recuperação de A e B, conferidos no navegador. Recusa de outra conta: 403 nos dois dossiers e 404 no resultado, pelo serviço/política utilizados pelo controlador. | Concluída a conferência delimitada. O papel PostgreSQL deste clone é superutilizador: esta prova de recusa é da aplicação, não nova certificação de RLS. Provas anteriores de RLS não privilegiado mantidas. |
| AUD-SIM-INT04 | Corrigida a abertura de histórico que carregava mas não mostrava o resultado no percurso de pequeno negócio. Resultado seleccionado agora visível e explicitamente separado das entradas do dossier. Navegador abriu 24 e 12; três snapshots mantiveram hashes/versões originais após a utilização. | Versões sintéticas exclusivamente em memória do resolvedor: 7,5% até Junho e 9,25% a partir de Julho, fonte e código QA que não representam legislação. Versão histórica conservada; transição dentro do período fica indeterminada; versão da fonte incompatível deixa de resolver. Nada foi registado/publicado na base ou no destino. É prova do resolvedor, não de execução fiscal integrada. |
| AUD-SIM-INT05 | Livro independente de três meses: resultado antes de impostos 225 MZN, fundo de maneio 130, usos 730, caixa inicial após usos 470 e caixa final 638. Distintos reconhecimento/pagamento de juros, capital, compras, despesas e cobranças. Tesouraria com mês de imposto desconhecido mantém total indeterminado; caixa conhecida 210, sem somar crédito/pedido/reconhecimento de IVA como dinheiro. | Controlos passaram. Pressupostos completos no teste `simulator-independent-review.test.mjs`; não são previsões ou taxas legais. |
| AUD-SIM-INT06 | CSV e Word efectivamente descarregados pelos botões normais do dossier B, não por gerador autónomo. Prestação 88,85, juros 66,19 e saldo 100 coincidem com o ecrã. DOCX usa vírgula decimal; CSV/ecrã usam ponto. Word de seis páginas integralmente conferido após renderização, com pressupostos e campos desconhecidos explícitos. | Impressão nativa Edge aberta, aguardando gravação de `SINTETICO-QA-Edge-continuacao.pdf` pelo utilizador; o diálogo não é acessível à automação. Não contar o PDF renderizado do DOCX como essa prova. |
| AUD-SIM-INT07 | Corrigido Abrir cálculo: pequeno negócio abre a etapa financeira existente, em vez de procurar uma etapa fiscal ausente. Falha única 503 de gravação preservou edição; botão Guardar agora repete efectivamente a escrita, com fila serializada e rechecagem da impressão digital para evitar gravação redundante. Recuperação posterior confirmou a edição. | Exportação normal Word/CSV comprovada. Provas automáticas aceites de limites de anexos preservadas e regressão do exportador passou. A tentativa interactiva com anexo sintético de 16 MiB foi impedida pela permissão de URLs de ficheiro da extensão, antes de o ficheiro entrar na aplicação; não foi registada como falha do portal nem como prova de recusa pela interface. |
| AUD-SIM-INT08 | Mensagem dinâmica Not found observada foi tratada no respondedor comum: mensagens genéricas de ausência/acesso/sessão/tentativas/servidor em português, preservando HTTP e correlação. Traduzidas as mensagens de validação de parâmetros/expressões que o controlador de governação apresenta nos campos, sem alterar DSL, códigos ou limites. | Conferência do 404 por pedido real ao kernel passou; mensagens das validações revistas no código/controlador. Inventário estático anterior e traduções intencionalmente seleccionáveis mantidos. Não se certificou cada conteúdo dinâmico do catálogo nem uma sessão administrativa de governação. |
**Pressupostos do crédito de controlo:** capital 1000 MZN; prazo 12 meses; periodicidade mensal (12 prestações); taxa nominal anual hipotética 12%, dividida por 12, resultando em 1% por mês; sem carência, comissão inicial ou periódica. Montantes com duas casas decimais, juros/prestação com metade para cima; saldo residual ajustado na última prestação. Primeira prestação 88,85; última 88,84; juros totais 66,19; total pago 1066,19 MZN. O CSV contém o cronograma completo; o Word apresenta prestação inicial, juros e receita de cálculo. O controlo anterior de 100 por mês usa capital 1200 MZN, 12 meses, periodicidade mensal, taxa nominal anual zero, sem carência/comissões, mesmo arredondamento, total 1200. Estes pressupostos foram acrescentados às saídas e à prova; nenhum produto de crédito foi confirmado.
**Reconciliação fiscal:** os três originais já versionados (BR 248/2025, republicação Lei 22/2022 e BR 89/2017), as análises e relações documentais são trabalho reutilizável. Não pedir novamente documentos já disponíveis nem reabrir a credibilidade das fontes oficiais aceites pela Tempo em 08/10. As referências antigas a reautenticação genérica ficam ultrapassadas por essa decisão; continuam a ser tarefas distintas identificar artigo, âmbito, período e parâmetro. A biblioteca documental não é a tabela de parâmetros. O repositório contém governação, DSL e resolvedor temporal; `RunSimulation` executa a aritmética personalizada e não consome essas fórmulas fiscais. Mesmo encontrar parâmetros no destino não comprovaria cálculo fiscal integrado. Essa ligação funcional não foi implementada silenciosamente neste recorte.
Artefactos em `outputs/simulator-continuation-20261020/`: `interface-comparison.json`, `interface-financial.csv`, `interface-report.docx`, seis páginas em `docx-render/`, provas de A/B e nova sessão, `history-preservation-proof.json`, `fiscal-corpus-inventory.json` e `local-rule-visibility.json`. Credenciais, dump e fixtures privadas ficam excluídos do Git. O script `read-fiscal-remote.py` preparado só faz leitura administrativa e `BEGIN READ ONLY`/SELECT/ROLLBACK; ainda não foi executado por faltar autenticar a ligação. Nenhum teste ou dado sintético foi enviado à homologação.
**Esforço restante do recorte: 1–3 h após os acessos/ficheiro necessários**, para recolha remota, conferência do PDF nativo e fecho das limitações de interface. O tempo de espera do utilizador não é esforço de programação. Não são as 6–10 h anteriores nem as 18–30 h iniciais. Não inclui implementar o motor fiscal integrado ou activar parâmetros reais. As pendências de acesso e impressão não bloqueiam a revisão dos resultados QA já utilizáveis.
**Achado adicional de continuidade, identificado antes da correcção:** o componente de tesouraria mantinha os seus pressupostos apenas no estado React; não recebia nem devolvia snapshot ao rascunho. Foi acrescentado `cashflow_snapshot` versionado ao JSON económico existente e ao validador do pedido, sem migration. O componente recupera os campos e o modo de imposto; guarda apenas após edição e não converte vazios em zero. Snapshots desconhecidos/inválidos não geram resultados alternativos e mostram aviso. O relatório consolidado e o CSV do dossier passam a incluir pressupostos, valores conhecidos/indeterminados e meses; o resumo destaca a tesouraria quando existir snapshot. Dossiers antigos sem esse campo mantêm a apresentação anterior — as seis páginas do DOCX de B já conferidas não tinham esse campo e não foram reemitidas desnecessariamente.
Provas adicionais: teste HTTP com dois dossiers, logout lógico/nova sessão, recuperação exacta e recusa por outra conta passou (8 asserções), em transacção revertida no clone. Quatro controlos frontend dirigidos passaram, incluindo recuperação de mês vazio, valor inválido para correcção, relatório sem zero inventado e pressupostos completos do crédito. As 10 verificações do exportador afectado passaram; teste temporal sintético passou (7 asserções); erro HTTP em português passou (3). TypeScript/build e sintaxe PHP passaram. A conferência visual do novo componente de tesouraria com sessão real QA permanece por executar depois de fechar a impressão do Edge; não foi substituída por uma prova unitária.
**Saldo revisto após esse achado: 2–4 h de verificação delimitada**, já descontado o desenvolvimento concluído: leitura remota 0,5–1 h; PDF nativo 0,5–1 h; continuidade visual/saída da tesouraria recém-corrigida 0,5–1 h; consolidação das provas e limitações 0,5–1 h. Exclui espera por autenticação/ficheiro e a integração funcional futura das regras fiscais. Esta estimativa substitui 1–3 h acima por uma divergência concreta; não retoma as 6–10 h nem as 18–30 h originais.
### Retoma da conferência visual de tesouraria — 21/10/2026
[Retoma 21/10/2026] QA 18576, clone simulator_review_delta_20261020_test: sessão sintética renovada por login normal; gravação autenticada e recarga recuperaram saldo 123, receita 900, custos 600, calendário mensal 10/vazio/0, crédito IVA estimado 500. Caixa conhecida 3713 MZN; total indeterminado desde mês 2; crédito estimado excluído da caixa. Prova visual cashflow-recovered-20261021.png e snapshot .txt em outputs/simulator-continuation-20261020. Não certifica recuperação destes novos valores após outro login, CSV/DOCX ou PDF nativo. Ligação de controlo do navegador perdida durante espera de download; não atribuir falha à aplicação. Leitura remota aguarda identificação da ligação SSH; zero alterações no destino.
### Leitura fiscal remota e saída nativa — 21/10/2026
[Leitura SSH 21/10/2026 08:17 Maputo] Zero regras em simulation_tax_rule_versions na homologação, incluindo todos os estados por leitura administrativa sem ocultação RLS. Seis referências em investment_legal_sources; não são parâmetros executáveis. QA também tinha zero regras. BEGIN READ ONLY/SELECT/ROLLBACK; zero escritas. Prova homologation-fiscal-readonly.json. O git HEAD registado é do checkout consultado, não certifica a revisão do build servido. A ausência de cadastro não é ausência de fontes oficiais nem reabre a aceitação da Tempo de 08/10. Não implementa a ligação funcional ao motor fiscal.
[Exportação nativa 21/10/2026] Saída actual aberta pelo botão do portal: revisão 4, 12 meses, MZN, saldo inicial123/receita900/custos600, calendário10/vazio/0, caixa conhecida3713 e total indeterminado coincidem com o ecrã. Crédito estimado500 identificado como pressuposto, não somado à caixa. Prova portal-print-current-20261021.txt. Relatório de20/10/revisão2 preservado, sem o confundir com o actual. Downloads: sem novo CSV na pasta habitual ou nos temporários locais conferidos; isso não demonstra falha nem cancelamento. Histórico de downloads bloqueado pela política da ferramenta; caminho do ficheiro solicitado antes de repetir. PDF nativo aberto, mas paginação/legibilidade aguardam o ficheiro efectivamente guardado pelo Edge; conversão DOCX/PDF anterior não conta como essa prova. Perda da ligação e bloqueio de controlo durante impressão são limitações da ferramenta, sem falha do simulador demonstrada.
### Ficheiros de Transferências recuperados — 21/10/2026
[Fecho da verificação dos ficheiros 21/10/2026] Transferências Windows redireccionada para \\Mac\Home\Downloads; CSV concluído08:12:46, PDF nativo08:17:19. Sem repetir downloads. Os12 registos mensais do CSV coincidem exactamente com o snapshot do ecrã após correspondência de colunas; PDF revisão4 conserva valores mensais, MZN,12meses,123/900/600, crédito estimado500 não caixa e caixa conhecida3713. Os10 PNG do PDF nativo foram inspeccionados: defeito confirmado na linha repetida de rodapé, sobreposta ao corpo perto do topo de páginas, incluindo2,6e10. Conteúdo/valores conferidos; paginação/legibilidade não aceites até correcção e nova prova nativa. Não é conversão DOCX/PDF. Perda de ligação continua limitação da ferramenta e não causou falha do download; sobreposição é defeito distinto demonstrado no PDF. Provas: download-native-inspection-20261021.json, portal-cashflow-20261021.csv, portal-native-20261021.pdf e native-pdf-render/. A indicação anterior de ficheiros pendentes fica superada por esta prova. Sem alteração funcional nem escrita remota.
### Correcção do rodapé do PDF nativo em QA — 21/10/2026
[Correcção do rodapé nativo QA 21/10/2026] Origem: pedido expresso da Tempo nesta conversa; solução de implementação, sem regra organizacional nova. Causa em createProjectDossierPrintHtml (project-dossier-document.ts): footer position:fixed/bottom:-14mm sobrepunha a área fragmentada de impressão. Substituído na impressão por caixas @page @bottom-left/@bottom-right, dentro da margem inferior reservada20mm, com identificação/revisão e número de página/total. Rodapé normal de ecrã conservado; sem diminuir letras do corpo (10,5pt), tabelas, cabeçalhos ou origens (9pt), nem retirar conteúdo. Mesmo dossier B/revisão4 regenerado pelo botão do portal QA18576 e guardado no Edge, não por conversão DOCX. 10→10 páginas A4; comparação automática do corpo por página, descontando apenas hora de emissão/rodapé/contador, coincide integralmente. Inspecção visual dos10 PNG: sem sobreposição, cabeçalhos de tabela repetidos, quebras/linhas preservadas, última página íntegra e numeração1/10→10/10. Rodapés medidos inteiramente na margem inferior. Ecrã QA conferido após build; estilos screen e geradores DOCX/ZIP inalterados por comparação de fontes. 10 testes do exportador, TypeScript e build passaram; escape da revisão na nova string CSS verificado. Reutilizadas provas de valores e CSV aceites; não repetido download/aritmética. Provas: native-footer-fix-20261021.json; portal-native-fixed-20261021.pdf; native-footer-before-after-20261021.jpg; native-pdf-fixed-render/; screen-after-print-fix-20261021.png. Fechado exclusivamente o defeito de rodapé neste relatório QA; limitações e restantes critérios de AUD-SIM-INT06 mantêm âmbito próprio. Relato anterior de sobreposição fica superado neste delta pela comparação antes/depois. Branch codex/simulator-integrated-review separada de main, sem integração, escrita remota ou disponibilização activa; revisão prevista mantém-se pendente.
### Saldo consolidado dos oito itens — 21/10/2026
Reconciliação a pedido expresso da Tempo. O fecho do rodapé limita-se à apresentação deste relatório e baseia-se nas provas apresentadas; o utilizador declarou não ter conferido a imagem nem o PDF pelos caminhos locais. Não é fecho da revisão integral do simulador. Reutilizadas as provas aceites, sem repetir10 páginas, valores,CSV ou consulta SSH.
| Item existente | Estado no recorte | Saldo / limite |
|---|---|---|
| AUD-SIM-INT01 | Concluído no recorte | Nenhum delta de validação de data por demonstrar; prova anterior preservada. |
| AUD-SIM-INT02 | Parcial | Leitura remota concluída: zero regras de todos os estados na homologação, tal como QA; seis referências jurídicas não são parâmetros executáveis. Falta cadastro fundamentado por componente/âmbito/período e ligação funcional ao motor fiscal. Fontes e análises existentes reutilizáveis; aceitação oficial da Tempo de08/10 preservada. Não activar taxas nem importar/publicar por esta reconciliação. |
| AUD-SIM-INT03 | Concluído no recorte | A/B, logout/novo login, recuperação e recusa pela aplicação demonstrados anteriormente. A tesouraria tem prova API de nova sessão e prova visual de recuperação por recarga. Não atribuir ao navegador nova sessão dos valores de tesouraria, nem nova certificação RLS; limites expressos conservados. |
| AUD-SIM-INT04 | Concluído no recorte demonstrado | Histórico24/12 visível, hashes preservados e versões temporais sintéticas do resolvedor conferidas. Execução fiscal integrada com parâmetros reais pertence ao saldo de02 e não foi demonstrada por estes testes. |
| AUD-SIM-INT05 | Concluído nos controlos delimitados | Controlos independentes de caixa/plano/crédito, zero/desconhecido e moedas preservados. Não são validação de taxas legais nem certificação de toda a contabilidade. |
| AUD-SIM-INT06 | Concluído na apresentação e comparação deste relatório | Rodapé do PDF nativo fechado em QA segundo provas apresentadas;10 páginas/valores/CSV não repetidos. Tempo declarou que não abriu imagem/PDF pelos caminhos locais: registar aceitação das provas, não revisão visual directa do utilizador. Outras provas de relatório conservadas; falhas interactivas de exportação/anexos mantêm saldo em07. |
| AUD-SIM-INT07 | Parcial | Gravação503→nova tentativa→recuperação e exportações normais demonstradas. Faltam falhas dirigidas de geração Word/ZIP/PDF e retoma; teste interactivo de limite de anexo não ocorreu por bloqueio da ferramenta antes do envio. Nova leitura do código: três catch apresentam cause.message para Error, podendo mostrar texto técnico sem tradução. Sem nova falha de execução alegada. |
| AUD-SIM-INT08 | Parcial | Inventário estático,404 real e traduções de validação preservados. Classificar/apresentar em português os erros dinâmicos das bibliotecas de exportação (cause.message, três consumidores) e conferir estados residuais/governação delimitados; não certificar todos os textos dinâmicos do catálogo. |
**Estimativa restante deste recorte:1–2h de engenharia**, excluindo espera por interacção necessária da ferramenta: 07/08 agrupados0,75–1,5h para tratamento e conferência dirigida de erros dinâmicos/retoma/anexos; 0,25–0,5h para reconciliação final das provas. Não somar o mesmo erro duas vezes por aparecer em07e08. Substitui o saldo anterior2–4h porque leitura remota, apresentação nativa e comparação da tesouraria já foram executadas. Não inclui cadastrar/aplicar regras fiscais reais, integrar fórmulas no motor ou certificar todo o catálogo. O esforço para02 integral permanece por dimensionar por componente, âmbito, período e correspondência documental; não há estimativa de conclusão integral obtida apenas deste1–2h.
Próxima execução delimitada: corrigir a apresentação de excepções brutas nos três exportadores, conferir falha/retoma e os limites interactivos que faltam, preservando os relatórios/CSV já aceites. Branch codex/simulator-integrated-review mantida separada de main até à revisão consolidada. Nenhuma nova concessão, regra fiscal, migration ou escrita remota.
### Candidata consolidada da revisão do simulador — 21/10/2026
Origem: pedido expresso da Tempo para fechar apenas falhas dirigidas de exportação/anexos, apresentação das mensagens e consolidação. As 1–2 h anteriores respeitavam a esse recorte, não à implementação fiscal integral. A branch `codex/simulator-integrated-review` permanece separada de `main`; não integrar antes da revisão consolidada porque a cadeia existente disponibiliza automaticamente a revisão integrada.
**Separação funcional preservada.** Os cálculos de planeamento usam valores declarados pelo utilizador, com origem e pressupostos identificados. O relatório não os transforma em resultados fiscais. Falta de imposto/parâmetro continua indeterminada, não zero; crédito/pedido/reconhecimento de IVA não se torna dinheiro recebido. A correcção de falhas de exportação e do rodapé não encerra a componente fiscal.
**Implementação deste delta.** O exportador apresenta mensagens em português, identifica a operação e a referência de diagnóstico e mantém as selecções para nova tentativa pelo mesmo botão. A excepção original é registada na consola técnica local com operação/referência; não há novo envio ao servidor, telemetria nem registo de conteúdo do dossier/anexos. A consola é um diagnóstico local, não uma auditoria persistente no servidor. Uma guarda síncrona recusa outra execução enquanto a primeira está activa, mesmo antes da actualização do estado React; após erro a guarda é libertada. Preparação dos anexos é atómica: falhas/limites não substituem a lista existente nem as legendas. A selecção repetida do mesmo nome com os mesmos bytes mantém uma entrada; bytes diferentes com o mesmo nome e nomes diferentes continuam distintos. Esta identidade é escolha de implementação local, não regra de cadastro/publicação documental. Os limites de quantidade/tamanho já existentes não foram alterados.
**Provas reutilizadas.** Data inválida; A/B e nova sessão; recusa de outra conta; histórico24/12 e hashes; resolvedor temporal sintético; controlos financeiros independentes; Word/CSV e respectivos valores; PDF nativo10 páginas/rodapé/última página; inventários/documentos e leitura SSH sem escrita. A Tempo aceitou o fecho do rodapé segundo as provas apresentadas e declarou que não abriu a imagem/PDF pelos caminhos locais. Não lhe atribuir inspecção visual directa. Os testes temporais sintéticos não representam legislação. A prova de recusa do clone com superutilizador é da aplicação; não é uma nova certificação de RLS. Não repetir as provas aceites apenas para gerar uma nova lista de resultados.
**Ensaio dirigido isolado.** `http://127.0.0.1:18576/qa/export-recovery` usa o componente real e os geradores reais, com uma falha única de biblioteca injectada no módulo de ensaio, sem alteração das rotas de produção. Os controlos de falha e os ficheiros sintéticos ficam em `outputs/`, fora do Git e do pacote. Os contadores referem-se a chamadas/generações; para ZIP contam a geração interna de Word e a preparação do arquivo, não duas transferências. No ensaio de nova tentativa de PDF é omitida apenas a impressão automática, para observar a janela com conteúdo e não repetir a prova nativa aceite. Nenhuma dessas injecções entra na candidata funcional.
**Procedimento de integração após revisão.** Fixar o commit candidato e manifesto de fontes; obter o `main` remoto actualizado e reconciliar apenas os deltas dos programadores que afectem estas fontes/consumidores. Rever o diff e conferir no ambiente QA a revisão exacta a integrar; repetir apenas verificações afectadas por conflitos ou alterações posteriores. Este delta não contém migrations, cadastro de regras, importação, publicação de documentos, contas, permissões ou alterações de MFA/configuração. Após decisão da Tempo, integrar pelo processo Git existente; confirmar o recibo da cadeia e a revisão efectivamente servida em `/build/revision.json`, com consulta pública e login privado conferidos nos consumidores afectados. Uma falha deve conservar a release anterior; não editar a pasta servida para contornar a cadeia. A reversão usa o mecanismo de release anterior de código/build, preservando base, anexos, configuração e dados posteriores; não repor um dump antigo. Se uma futura entrega incluir regras/dados activos, exigir o procedimento específico já aprovado de backup/restauro e acção autenticada; esse trabalho não está incluído aqui.
### Diagnóstico fiscal separado da candidata — 21/10/2026
| Camada | Existente e comprovado | Saldo efectivo |
|---|---|---|
| Fontes | Inventário anterior de9901 caminhos e32 candidatos por nome; três originais versionados: BR248/I/Suplemento1/2025, republicação Lei22/2022 no BR250/2.ºSuplemento e BR89/2017. Análises em `primary-text-review-2025-2026.md`, `official-source-register.md`, `fiscal-reform-2026-review.md` e referências em `fiscal-source-references-2026-10-06.csv`. | Reutilizar conteúdo, hashes, versões e citações já disponíveis; não pedir novamente esses documentos. A enumeração de caminhos não equivale à leitura de todos os binários. Distinguir fontes já lidas de fontes só inventariadas e alterações/anexos ainda sem correspondência. |
| Parâmetros executáveis | Leitura administrativa de todos os estados confirmou zero linhas em `simulation_tax_rule_versions` tanto na QA como na homologação. A homologação tem seis referências em `investment_legal_sources`, ligadas a estruturas territoriais; não são taxas/fórmulas prontas. | Cadastrar por componente: código/versão, diploma/artigo/versão/hash, âmbito e condições, início/fim de aplicação, valor/unidade, bases/limites/exclusões, fórmula e arredondamento fundamentados. Os campos necessários variam por regra; não preencher lacunas por zero ou pela taxa mais recente. |
| Governação e consulta | Modelo versionado, controlador de transições, API de resolução por componente/período/contexto, selecção de publicados e compatibilidade de versão da fonte; resolução temporal e indeterminação já ensaiadas. | Reutilizar estes serviços; conferir as correspondências documentais e a operação com parâmetros reais quando estes existirem. Não criar aprovações, nomeações ou uma nova exigência organizacional por escolha técnica. A Tempo define os papéis e as regras. |
| Expressões | `SimulationTaxRuleExpression` já valida e avalia a DSL `investmoz.tax.v1` com aritmética racional, arredondamento e valores em falta. | Não reconstruir o avaliador. Verificar se cada regra fundamentada cabe na linguagem existente e estender apenas operações concretamente necessárias. |
| Execução fiscal integrada | `RunSimulation` executa `base × taxa/100 × (1−redução/100)`, identifica `custom_planning`, `legal_interpretation=false`, `ruleset_version=null` e moeda não indicada nesse cenário. Consulta/DSL não são consumidas por este fluxo como motor fiscal. | Desenvolver orquestração explícita de componente/período/âmbito → regras elegíveis → entradas e parâmetros → avaliação → resultado e explicação com fontes/versões imutáveis. Tratar ambiguidade/transição/ausência como indeterminação. Manter o cenário de planeamento separado, sem o relabelar como imposto calculado. |
| Resultado, dossier e relatório | Snapshots, histórico e exportadores existentes podem transportar resultados e proveniência; relatórios actuais identificam pressupostos/estimativas. | Integrar o resultado fiscal com identificação própria de regra, período, unidade/moeda, base, passos, arredondamento e lacunas. Guardar reprodução histórica e demonstrar casos de referência por regra, isolamento e não reaplicação de uma versão actual a um resultado antigo. |
**Componentes a parametrizar, não taxas inventadas.** O acervo já analisado identifica famílias de alterações para ICE (Lei7/2025/tabela), ISPC (Lei9/2025), IVA (republicação Lei22/2022 e Lei10/2025, além da consolidação/rectificação aplicável), IRPS (Lei11/2025) e IRPC (Lei12/2025). A API também contempla INSS, retenções e direitos aduaneiros. Isto é inventário de domínios, não prova de cobertura documental completa nem autorização para aplicar uma taxa genérica. Para ICE/aduaneiro são relevantes código/unidade/tabela e período; para IVA, operação, incidência/exclusões/dedução e estado de reembolso; para ISPC, elegibilidade/limites e obrigações; para IRPS/IRPC, categoria, residência/estabelecimento, base, retenções e regimes específicos. Prazos, taxas, anexos e incentivos só entram com correspondência documental aplicável.
A aceitação das fontes oficiais determinada pela Tempo em08/10 prevalece sobre pedidos históricos genéricos de reconfirmação de autenticidade ou de um fiscalista adicional. Identificar o artigo, âmbito, período, alterações/revogação e parâmetros é aplicação/rastreabilidade da fonte aceite, não segunda validação da sua credibilidade. A biblioteca documental, a situação jurídica e a utilização em cálculo são estados independentes; importar ou publicar documentos nunca activa cálculo fiscal.
**Desenvolvimento fiscal necessário:** correspondência documental e cadastro versionado; adaptação concreta de expressões quando necessária; orquestração do cálculo; contrato de resultado/proveniência/histórico; ligação à interface/relatórios e casos de referência por componente/período. Nenhuma dessas frentes é encerrada por corrigir crédito, orçamento, tesouraria, falhas de exportação ou rodapé. O esforço integral deve ser dimensionado por regras/componentes e lacunas documentais identificadas; não cabe na estimativa de1–2h do recorte de exportação.
### Fecho do recorte de exportações e saldo da candidata — 21/10/2026
| Item existente | Saldo consolidado da candidata | Prova / limite |
|---|---|---|
| AUD-SIM-INT01 | Concluído no recorte | Validação de data: prova anterior reutilizada. |
| AUD-SIM-INT02 | Parcial; implementação fiscal integral aberta | Zero regras remotas/QA; fontes, cadastro e integração discriminados no diagnóstico separado. |
| AUD-SIM-INT03 | Concluído nas provas delimitadas | A/B/nova sessão e recusas anteriores; tesouraria com API/nova sessão e UI/recarga, sem alegar uma nova prova browser da mesma sessão. Limite RLS mantido. |
| AUD-SIM-INT04 | Concluído no recorte demonstrado | Histórico/snapshots e resolvedor sintético reutilizados; integração real fiscal permanece em02. |
| AUD-SIM-INT05 | Concluído nos controlos delimitados | Controlos independentes e provas financeiras aceites reutilizados; sem validação legal de impostos. |
| AUD-SIM-INT06 | Concluído na apresentação/comparação deste relatório | Rodapé nativo aceito segundo provas; sem repetir10páginas/valores/CSV; sem atribuir inspecção local ao utilizador. |
| AUD-SIM-INT07 | Concluído no recorte dirigido QA |3falhas de geração/retoma, anexo16MB recusado com lista conservada, re-selecção sem duplicação e ZIP íntegro; injecção sintética fora da entrega. |
| AUD-SIM-INT08 | Concluído nos consumidores afectados | Mensagens portuguesas/diagnóstico técnico separado; limites de catálogo/governação mantidos, sem certificação global. |
Esforço restante do recorte de engenharia de exportações:0h; revisão consolidada e eventual integração continuam pendentes. As1–2h anteriores foram estimativa deste recorte; não eram estimativa da implementação fiscal integral. Não fechar a revisão integral do simulador nem AUD-SIM-INT02 por este incremento. Word/PDF/ZIP foram ensaiados no componente real com falhas sintéticas de geração de um invólucro QA privado; ficheiros de67bytes e16MiB seleccionados manualmente pelo utilizador, estados observados directamente pelo Codex. A primeira recusa encontrou lista vazia e não foi usada como prova de preservação; a sequência posterior demonstrou lista válida→recusa16MB mantendo1→re-selecção válida mantendo1. ZIP real verificado por bytes, entrada única, título e secção. Leitura transitória falhada e concorrência demonstradas nos testes dirigidos, não por avaria real do sistema de ficheiros. A limitação anterior da selecção automática não foi contornada: a selecção normal foi manual. Os detalhes técnicos permanecem em diagnóstico local; não foi criado serviço de recolha remoto.
Provas actuais em outputs/simulator-continuation-20261020/: export-word-error-20261021.*, export-pdf-error-20261021.txt, export-pdf-retry-20261021.txt, export-zip-error-20261021.*, export-zip-retry-20261021.txt, attachment-limit-preserved-20261021.*, attachment-duplicate-retry-20261021.*, export-zip-content-proof-20261021.json e export-recovery-candidate-20261021.json. O procedimento de integração e diagnóstico fiscal imediatamente anteriores integram esta mesma candidata e o mesmo registo; sem matriz/backlog adicional.
### Identificação da entrega e conferência do limite — 21/10/2026
A entrega funcional `13f68f1b` identifica-se como **melhorias de planeamento financeiro, recuperação, exportação e idioma**. Está pronta para revisão de integração, não representa a conclusão do simulador fiscal. Não houve integração em `main` nem disponibilização activa. A Tempo confirmou o fecho em QA segundo o relatório e declarou não ter conferido o registo local; não atribuir essa conferência ao utilizador.
Conferência somente por leitura, sem repetir ensaios: em `c22003a3`, anterior à candidata, `project-dossier-report.ts` já definia `MAX_DOSSIER_ATTACHMENT_BYTES = 15 * 1024 * 1024` (15.728.640 bytes), quantidade10 e total50MiB. A candidata conserva esses valores. O ficheiro recusado tem16.777.216 bytes (16MiB), superior ao limite preexistente de15MiB; não foi introduzido limite inferior. A interface histórica escreve «MB», mas a implementação usa unidades binárias. A origem é uma escolha de implementação já existente; esta leitura não inventa uma aprovação organizacional específica para esse número. O limite2MiB de incorporação de texto é separado do limite por anexo: conteúdos maiores podem seguir como originais no ZIP, dentro dos15MiB. A nova preparação/deduplicação chama a mesma validação por anexo e não transforma2MiB num bloqueio de selecção.
### Proposta do primeiro lote fiscal delimitado — 21/10/2026
**Proposta para revisão, sem cadastro, publicação ou activação nesta passagem.** Um único percurso inicial de IVA geral sobre base líquida já identificada, em MZN, para operações internas abrangidas pela taxa geral. Janela de cobertura/ensaio do lote:01/01/2026–31/12/2026. Esta janela não cria uma data de entrada em vigor ou caducidade legal: os metadados das regras devem reproduzir as datas dos diplomas aplicáveis. Não criar artificialmente uma expiração em31/12/2026. A cobertura do primeiro lote é escolha de implementação proposta, não uma nova restrição organizacional da Tempo.
**Regra candidata, ainda não cadastrada:** `IVA-GERAL-BASE-LIQUIDA`, identificador provisório para a fórmula base tributável × taxa geral/100. A análise primária já existente identifica16% no art.17(1) da republicação da Lei22/2022, com efeitos desde01/01/2023, e documenta que a Lei10/2025 não altera esse artigo. Reutilizar essa conclusão e as citações/versões existentes, sem nova validação genérica da credibilidade da fonte. O lote não decide automaticamente se uma pessoa/operação está sujeita, isenta, reduzida ou excluída. A aplicação à operação exige um âmbito identificado e evidência/declaração de entrada rotulada; se o âmbito não estiver determinado, a saída é indeterminada. Um cálculo por fórmula com fonte verificada e âmbito apenas declarado deve ser identificado dessa forma, sem o apresentar como obrigação fiscal validada.
| Elemento | Existente e reutilizável | Trabalho do lote |
|---|---|---|
| Fonte da taxa | `source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf`, art.17(1); análise em `primary-text-review-2025-2026.md` e adenda de taxa reconciliada em `fiscal-reform-2026-review.md`. | Recuperar exemplar/hash/edição/citação já registados e ligar ao cadastro. A taxa identificada não está tecnicamente cadastrada como parâmetro executável. |
| Cadeia para2026 | `source-archive/BR-248-I-Suplemento-1-2025.pdf`, alterações IVA/Lei10/2025 e vigência documentadas nas análises; aviso BR89/2017 e referências da consolidação disponíveis. | Aplicar as correspondências de âmbito/período que afectem esta regra. Não declarar que todos os binários do acervo foram analisados. Os diplomas/anexos sem correspondência continuam no inventário existente. Não reconstruir uma consolidação completa de todos os regimes para implementar apenas esta regra. |
| Parâmetros e entradas | Valor nominal16% já fundamentado no acervo; modelo aceita valor/unidade/condições; resolução temporal existente. | Cadastrar código/versão, unidade`percent`, condições de operação e vínculo documental. Determinar base tributável, âmbito concreto, período e moeda de entrada. Não presumir MZN nem base fiscal pela simples existência de um valor de planeamento. |
| Arredondamento e unidade de apuramento | O avaliador já suporta operações racionais e arredondamentos explícitos. | Mapear a política aplicável à liquidação sobre base líquida, a precisão e se o arredondamento incide por linha/documento/apuramento. Não transportar automaticamente a regra do art.34 para este percurso, nem assumir HALF_UP/duas casas como regra fiscal. Até esse ponto estar fundamentado, não publicar a regra nem apresentar um total fiscal final arredondado; conservar resultado exacto/intermédio e lacuna explícita. |
| Períodos/versões | O resolvedor verifica período, âmbito e compatibilidade da versão da fonte. | Preservar a vigência efectiva do diploma; limitar a cobertura demonstrada deste lote a2026. Pedido fora dessa cobertura identifica «fora da cobertura demonstrada do lote», sem afirmar que a lei deixou de vigorar ou usar zero como substituto. |
**Fora deste lote:** extracção de IVA de preço já incluído (art.34), taxas reduzidas, isenções/listas de bens, importações/serviços digitais/não residentes, dedução/crédito/reembolso, incentivos territoriais, ISPC, IRPS, IRPC, ICE, INSS, retenções e direitos aduaneiros. Estes domínios permanecem no acervo/registo existente e não são omitidos por ficarem fora desta primeira implementação. Não confundir consulta documental disponível com disponibilidade de cálculo.
**Cadastro e aplicação documental — estimativa4–8h.** Reutilizar as análises/fontes, preparar a ficha nominal da regra e parâmetro, transcrever vínculos/versionamento/condições e política de arredondamento aplicável, rever fronteiras temporais e casos de referência e cadastrar como rascunho apenas em QA pelo mecanismo existente quando a execução do lote for autorizada. O intervalo inclui a aplicação documental ao percurso delimitado; se a política ou o âmbito permanecerem ambíguos no acervo, registar a lacuna e parar apenas esse resultado, sem criar um valor. A espera por um elemento documental concreto não está incluída. As fontes oficiais continuam aceites pela decisão da Tempo; não se introduz novo fiscalista ou nova certificação genérica como condição.
**Desenvolvimento de código e aceitação técnica — estimativa12–20h.** Reutilizar `SimulationTaxRuleGovernance`, `SimulationTaxParameterResolver` e `SimulationTaxRuleExpression`; não reconstruir cadastro, transições, DSL ou resolvedor. Acrescentar a orquestração explícita do percurso fiscal: componente/período/âmbito→resolução existente→parâmetros e entradas→avaliação existente→resultado com explicação e proveniência. Separar o cenário fiscal do `custom_planning` de `RunSimulation`. Ligar o resultado ao dossier, histórico e exportadores existentes, com regra/versão/fonte/período/moeda/entradas/passos/arredondamento/estado de validação do âmbito. Acrescentar tratamento de lacunas e casos de referência dirigidos. Não se prevê um novo motor ou uma migration por princípio; confirmar necessidade a partir do contrato do resultado, sem alargar o schema preventivamente.
**Total de engenharia proposto16–28h para este lote**, sem dupla contagem entre cadastro e código e sem incluir espera, revisão consolidada, activação no destino ou implementação fiscal integral. Pressupõe que o acervo disponível permita fundamentar âmbito e arredondamento deste percurso e que os mecanismos existentes continuem compatíveis. Divergência concreta é relatada antes de alargar o lote. As1–2h anteriores pertenciam exclusivamente ao recorte de exportação já concluído.
Critérios de aceitação deste primeiro lote:
1. Ficha e parâmetro correspondem ao exemplar/artigo/versão/hash e vigência aplicáveis; condições e arredondamento têm fundamento identificado. Não publicar enquanto faltarem estes elementos. O cadastro em rascunho não é activação.
2. No clone QA isolado, resolver a regra para contexto/período cobertos e recusar aplicação ambígua ou fora de cobertura, com lacuna expressa, sem zero/taxa de outra versão como substituto. Transição dentro de um período e fonte incompatível mantêm o comportamento temporal já existente.
3. Casos independentes sobre bases que gerem resultados exactos e casos fraccionários/fronteiras da política de arredondamento correspondem à regra cadastrada; unidades/moeda não são inferidas. Zero explícito e ausência de valor continuam distintos.
4. O resultado fiscal identifica regra/versão/fonte/artigo/período, entradas, passos, unidade/moeda, arredondamento e âmbito comprovado ou apenas declarado. A expressão «verificado» não se estende a uma operação cuja sujeição foi apenas declarada. Planeamento manual permanece disponível e claramente distinto.
5. Guardar/recuperar o resultado no dossier e numa nova sessão mantém o snapshot original; mudar regra/entrada não reescreve o histórico. Outra conta não obtém entradas ou resultados privados. Reutilizar os mecanismos e provas de isolamento, ensaiando apenas o novo consumidor fiscal.
6. Ecrã e relatório apresentam o mesmo resultado/proveniência/lacunas e distinguem o cenário fiscal de estimativas do utilizador. Reutilizar o exportador e a prova aceite de paginação; conferir só as novas linhas fiscais e os deltas de layout se existirem, sem repetir os cálculos de planeamento/CSV aceites.
7. Publicação de regra, exposição do percurso e eventual disponibilização no destino são acções separadas, pelos mecanismos e papéis existentes definidos pela Tempo. Nenhum parâmetro é activado por importação de documento ou integração deste documento de proposta em Git.
Esta proposta fica ligada a AUD-SIM-INT02 na matriz existente. Não cria uma matriz ou backlog alternativos. A apresentação da proposta não autoriza, por si, a integração de13f68f1b em`main`.
### Actualização posterior — integração autorizada e ficha fiscal v1
A Tempo autorizou posteriormente a integração de `13f68f1b`. O commit foi integrado em `main` e a revisão servida na homologação confirmou esse identificador em21/10/2026. As menções anteriores a integração pendente descrevem o estado histórico da candidata. A entrega continua identificada como melhorias de planeamento financeiro, recuperação, exportação e idioma, sem encerrar a implementação fiscal.
A [ficha documental IVA-GERAL-BASE-LIQUIDA v1](../simulations/ficha-IVA-GERAL-BASE-LIQUIDA-v1.md) desenvolve separadamente a proposta `0fafd348`: fundamento da taxa e período, limites do art.34, exemplar digitalizado recuperado do inventário, parâmetros por cadastrar, lacunas de enquadramento/arredondamento, integração e critérios de aceitação. É rascunho documental sem cadastro ou activação. AUD-SIM-INT02 mantém-se parcial; os outros sete saldos aceites não são reabertos. Não foram repetidas provas aceites nem alterados cálculos, dados ou regras. A branch fiscal permanece separada de `main`.
**Adenda fiscal v1.1 — 21/10/2026:** a mesma ficha regista a leitura visual dirigida do CIVA/Lei13/2016, arts.1–4,6–7,15,17 e27, e a consulta do regulamento/Decreto7/2008. Enquadramento geral da base documentado, com composição, exclusões, localização e data de exigibilidade; não constitui classificação automática de operações. O arredondamento do art.34 refere-se à extracção de base com IVA incluído e o art.9(1)(b) do regulamento ao meio de pagamento cheque/vale; nenhum foi convertido em regra geral de arredondamento sobre base líquida. Permanecem por esclarecer precisão/modo/empates/agregação e a correspondência regulamentar pertinente para2026. A ficha identifica a questão documental concreta e exemplos aritméticos, sem inventar norma. Estimativa restante condicional: cadastro/aplicação documental3–6h + código/aceitação12–20h =15–26h, excluindo espera, revisão e activação. Fontes locais arquivadas com hash coincidente; não se atribui conferência ao utilizador. Nenhuma regra activada; AUD-SIM-INT02 continua parcial e a entrega13f68f1b mantém-se independente.
### Decisão posterior da Tempo e execução fiscal QA v1.2 — 21/10/2026
**Estado actual; substitui a condição bloqueante e a estimativa da adenda v1.1.** A Tempo determinou prosseguir neste lote sem arredondamento automático, preservando precisão decimal e distinguindo valor calculado de apresentação monetária. A ausência de uma política genérica de arredondamento deixa de bloquear o cálculo do IVA sobre base líquida. Isto não é regra universal para todos os impostos. Exigências de precisão de um formato de pagamento/declaração serão identificadas nesse consumidor, sem as generalizar. A ficha existente foi consolidada emv1.2; versões anteriores permanecem emGit. Não há questão adicional à Tempo como pré-condição deste cálculo nem validação jurídica atribuída ao utilizador.
**Cadastro:** artefacto `database/qa/iva-general-net-v1.json`; comando explícito `fiscal:qa-register-iva {actor} {source}`, com guarda da base QA isolada, papel existente de editor e vínculo a fonte cadastrada. Confere SHA256 do exemplar e usa a governação existente para criar apenas `draft`. Repetição conserva o mesmoID/versão; divergência não sobrescreve o cadastro. Não corre em deploy, importação ou migration. O clone persistido é `simulator_fiscal_delta_20261021_test`,127.0.0.1:55441. A referência de fonte e os actores usados no ensaio são fixtures sintéticas identificadas; não constituem revisão jurídica por pessoa real nem atribuição persistente de papéis organizacionais.
**Cálculo e proveniência:** `SimulationFiscalQaPreview` usa o resolvedor temporal e avaliador existentes, com percurso explícito de rascunho QA que não altera a elegibilidade da API de parâmetros publicados. O resultado guarda contexto declarado, fonte/versão/hash, regra/versão/DSL, passos racionais e decimal exacto; apresentação guarda valor e moeda separadamente, com `rounding_applied=false`. Não há float, truncagem, `toFixed` ou nó`round` neste percurso. Exemplos dirigidos:0,03×16%=0,0048;9007199254740993,0003×16%=1441151880758558,880048; zero explícito conserva0. Fonte/versão incompatível e fora da cobertura2026 continuam indeterminados, sem taxa substituta/zero. A data declarada de exigibilidade alimenta a resolução, sem expiração legal artificial em31/12/2026.
**Histórico e relatórios:** armazenamento existente de simulações, fontes de resultado e relatórios; sem schema novo. Entradas declaradas e proveniência da regra são fontes separadas. Snapshot/relatório não são reavaliados quando a regra é editada. Nova execução usa novo registo; a mesma chave e parâmetros reutiliza a execução. O ensaio persistido actual tem simulação`842e05f9-2b6e-4853-8ff5-81650633f6c4`, relatório`49cac0bd-f49c-4613-a8f0-1ee5c285cf81`, hash`8ed8155a56fdf98ad26e2f01435644e062f27ca6b067ce3553415e974261c687`; resultado/relatório iguais e relatório anterior conservado. Zero regras publicadas. Ecrã e linhas do dossier/CSV distinguem cálculo exacto QA, contexto declarado, estimativas do utilizador e saldo a pagar, que não é calculado neste lote.
**Verificação deste incremento:**5testes feature/51asserções;7testes do resolvedor/25asserções;3testes frontend;TypeScript,build ePint aprovados. Os testes feature correm com transacções no clone dedicado `simulator_fiscal_checks_20261021_test`, separado do cadastro persistido. A primeira tentativa de base vazia encontrou uma migration antiga que exige instalação nominal; não se contornou essa guarda nem se alterou o activo: usou-se cópia do schema QA existente e transacções, sem novas migrations. Cobertura inclui não publicação, indeterminação efectiva, idempotência, precisão fraccionária/grande, moeda obrigatória, recusa de nó de arredondamento, recusa foraQA, snapshot após edição, recuperação por nova autenticação API e recusa por outra conta.
Conferência visual dirigida no Edge do componente React real renderizado emSSR e do HTML produzido por `createProjectDossierPrintHtml` apenas para as novas linhas fiscais:0.0048, base0.03, taxa16, MZN e rótulos legíveis, sem corte visível. O invólucro de inspecção suprimiu apenas a abertura automática da janela de impressão numa cópia privada do HTML; não alterou o exportador. Não é prova de novo PDF nativo guardado nem novo percurso browser de histórico no formulário completo. API/histórico/relatório imutável e os modelos usados pelo ecrã/CSV/dossier têm provas dirigidas. A conta técnica QA ultrapassaRLS; recusas demonstram política da aplicação, não nova certificaçãoRLS. Provas aceites das10páginas, valores, CSV e recuperação/exportações de13f68f1b reutilizadas, sem repetição.
Provas privadas em`outputs/simulator-continuation-20261020/`: `fiscal-qa-receipt-v2.json`, `fiscal-qa-tests.json`, `fiscal-qa-build.log`, `fiscal-qa-snapshot.json`, `fiscal-component-review.html` e `fiscal-report-review.html`. Página/servidor temporários de inspecção encerrados. O recibo versionado desta continuação recebe a mesma reconciliação; a matriz existente recebe apenas adenda emAUD-SIM-INT02. Não se cria backlog paralelo.
**Saldo e estimativa:** o recorte agora autorizado — cadastro versionado, precisão/proveniência, resolução temporal QA, histórico e dados de relatório — está implementado e verificado nos limites acima;0h de engenharia identificada restante nesse recorte, revisão não incluída. As15–26h eram estimativa condicional anterior que incluía trabalho agora executado, não horas medidas nem estimativa ainda integralmente por consumir. AUD-SIM-INT02 permanece parcial para disponibilização fiscal de produto e cobertura integral; os outros sete itens não são reabertos. Pagamento, declaração, classificação automática e outros impostos exigem âmbito/estimativa próprios. Não declarar o simulador fiscal concluído.
**Integração:** commit desta candidata fica apenas na branch `codex/simulator-integrated-review`, descendente de0fafd348, sem push/merge emmain. A autorização anterior de integração de13f68f1b não é reutilizada para esta candidata fiscal. Após revisão consolidada, uma decisão específica de integração continua separada de cadastro/publicação de regras. Esta preparação não muda o destino, papéis activos ou dados activos. Não retirar a guarda QA ou mudar o ambiente do activo para contornar a separação; preparar o consumidor de produto e a sua disponibilização apenas no âmbito que vier a ser autorizado.
### Revisão consolidada de83d2f97d — âmbito, demonstração e operação
**Pedido da Tempo:** consolidar a revisão, reutilizando provas aceites e demonstrando apenas o percurso completo pela interface que faltava. O exemplo aritmético não prova incidência nem aplicabilidade a qualquer operação; a Tempo não conferiu o ficheiro local nem validou juridicamente as conclusões transcritas. Esta revisão mantém essa distinção.
| Elemento da regra | Âmbito documentado / fronteira |
|---|---|
| Operação e base | Operação interna onerosa por sujeito passivo nessa qualidade, localização aplicável e base líquida já apurada: CIVA arts.1(1)(a),2,6 e15. Condições gerais e composição da base estão na ficha v1.2, com PDF/página/artigo. O código não classifica automaticamente uma pessoa ou operação. |
| Taxa e período |16% no art.17(1) da Lei22/2022 republicada, eficácia01/01/2023; data de exigibilidade relevante nos arts.7 e17(2). Lei10/2025 efectiva01/01/2026 não altera art.17 na cadeia lida. A janela demonstrada do lote é2026; não é caducidade legal em31/12/2026. Isto é conclusão documental nos limites da cadeia consultada, não validação jurídica atribuída ao utilizador. |
| Excluído do primeiro lote | Preço já com imposto incluído/art.34, taxas reduzidas, isenções/listas, bases especiais, importações, digital/não residentes, deduções, crédito/reembolso, incentivos e outros impostos. Não se transforma base orçamental em base tributável. |
| Indeterminado | Operação sem enquadramento identificado; fonte/versão/parâmetro/contexto incompatível ou período fora da cobertura demonstrada. O enquadramento apenas declarado permanece declarado. Ausência de arredondamento automático não torna indeterminado o valor exacto deste percurso. |
| Valor versus apresentação | Decimal exacto guardado e reproduzido como texto, sem float, truncagem ou arredondamento implícito; moeda separada. Não é valor a pagar ou saldo de declaração. Precisão de um formato de pagamento/declaração será determinada nesse contexto, sem regra universal. |
**Lacuna concreta encontrada na revisão:**83d2f97d disponibilizava API QA e componente/relatório, mas não um ecrã de introdução das entradas que permitisse observar a sequência inteira. Acrescentou-se apenas a página protegida `/management/investment/tax-rules/{id}/qa-review`, exclusiva do alvo QA isolado, sob os mesmos controlos administrativos, autenticação/MFA e consulta de governação. Chama a API QA já existente, grava no histórico existente, recupera sem recalcular e lê o relatório guardado pelo seuID/hash. Não cria motor, schema, fonte ou regra nova e não expõe um cálculo fiscal público.
**Exemplo completo efectivamente observado pela interface:** base`1000.03`, moedaMZN seleccionada explicitamente, exigibilidade declarada`2026-10-21` e declaração de operação interna à taxa geral sem excepção. Valor calculado`160.0048`, sem arredondamento; não prova de sujeição. Sequência observada: preencher → «Calcular e gravar no histórico QA» → execução`fa6d17f2-5f47-4cb6-b8cb-40850c12505f` concluída → recarregar a página, limpando o estado local/entradas → «Carregar histórico» → «Recuperar [mesmoID]» → «Consultar relatório guardado» → mesmo valor, base, moeda, data, regra/versão, fonte e contexto declarado. Relatório`d13e2ad5-3ba8-4d01-9d30-5815496da774`, SHA256`2e5e260f47ee5c849a34453fad248009e607ef8679dc56b7a1c58730f13e5c3b`, igual ao hash do resultado guardado. Cálculo e gravação pertencem à mesma operação transaccional existente; não se inventa um segundo botão de gravação. A conta de ensaio ficou com uma única execução persistida.
O ensaio correu no Edge, no clone`simulator_fiscal_delta_20261021_test`, porta local18579, com conta sintética própria e sessão/cookie separados. Durante a primeira autenticação a ligação da ferramenta perdeu-se. As duas reactivações comunicadas pelo utilizador não repuseram imediatamente navegadores no inventário, mesmo após reiniciar a sessão da ferramenta. **Limitação da ferramenta; não falha do simulador.** Mais tarde o inventário voltou a expor o Edge e retomou-se apenas o que faltava, por interacção DOM documentada. Foram observados início de sessão e segundo factor normais, entradas, cálculo/gravação, recarga, recuperação e relatório acima. A primeira edição de data não estava consolidada no controlo; a recusa não gerou execução no histórico. Conferiu-se histórico vazio antes da retoma, concluiu-se a edição da data por teclado e submeteu-se uma única execução concluída. Nenhuma guarda ou autenticação foi contornada. As provas anteriores de83d2f97d são reutilizadas; esta demonstração fecha somente a lacuna browser que faltava.
O novo ecrã passouTypeScript/build e1teste dirigido de rota/17asserções: redireccionamento para autenticação, página/props correctos para perfil autorizado, recusa de outro perfil e recusa foraQA. Mensagens de falha são portuguesas e o detalhe técnico fica na consola interna. O primeiro ensaio de rota antecedeu o build da página nova e encontrou ausência da entrada no manifest; após o build o teste passou. Não se reabriram os15testes aceites de83d2f97d, nem valores/CSV/paginação anteriores. **A única lacuna adicional desta revisão ficou demonstrada.** Provas privadas: `fiscal-ui-journey-receipt-20261021.json`, `fiscal-ui-complete-20261021.txt`, `fiscal-ui-complete-20261021.jpg` e `fiscal-ui-report-20261021.jpg`, na mesma pasta deoutputs. Conferência da BD somente por leitura para o novo exemplo: uma execução da conta, igualdade de hashes, regra emdraft, zero regras publicadas. Esta prova é de relatório guardado na interface; não se alega nova impressão/PDF nativo ou nova sessão browser, que não foram pedidos/repetidos.
#### Procedimento de integração — decisão de código
1. A Tempo revê83d2f97d e o delta desta revisão, identificando o commit exacto que autoriza integrar. A autorização anterior de13f68f1b não cobre estes commits. Esta consolidação inclui a demonstração browser concluída e as limitações expressas, sem a converter em validação de aplicabilidade fiscal.
2. Conferir a ancestralidade e o delta contra`origin/main`; a consulta remota desta revisão confirmoumain em13f68f1b. Se continuar ancestral da candidata, integrar por avanço directo após autorização; se houver alterações posteriores, resolver apenas os conflitos reais e verificar os deltas afectados. Não reexecutar o conjunto aceite sem motivo concreto.
3. Enviar a referência autorizada a`main` desencadeia disponibilização automática na homologação. Conferir a revisão efectivamente servida e a disponibilidade do portal. Não executar cadastro fiscal, transição de regra ou migration por fazer merge do código. Este delta não tem migration.
4. Guardar os identificadores da decisão, commit, build servido e resultado da disponibilização no mesmo registo. Para reversão de código, preservar o contrato de leitura dos snapshots e históricos; não eliminar tabelas/registos nem reescrever resultados antigos. As guardasQA permanecem activas neste consumidor.
#### Publicação e activação — decisões distintas da integração
1. Cadastro no destino exige autorização específica para escrita, backup/restauro confirmado e acção autenticada. Usar a API de governação existente com a referência real da fonte aceite e actores designados; não copiar as fixtures sintéticas QA para o destino nem mudar o ambiente do activo para executar o comandoQA.
2. A governação existente usa `POST /api/v2/investment/admin/tax-rules/{id}/{action}`, com`version_no` actual e`evidence`: `submit`→`review`→`approve`→`publish`, pelos papéis já configurados. As verificações actuais de autoria/revisão/aprovação/publicação, versão da fonte, vigência e sobreposição conservam-se; não se introduz nesta revisão um novo papel ou uma certificação genérica da fonte. Corrigir uma versão após revisão faz-se por nova versão e histórico, sem edição retroactiva de uma publicada.
3. **Efeito real da publicação:** a versão passa a ser elegível no resolvedor/API de parâmetros publicados. Isso pode disponibilizar o parâmetro aos consumidores dessa API; não é um simples rótulo documental. Ainda assim, não liga por si este cálculo QA ao motor público nem valida incidência de uma operação.
4. **Limite concreto de83d2f97d:** o consumidor novo é QA, exige regra em`draft` e guarda de base isolada. O job público de planeamento não consome automaticamente uma regra publicada como cálculo fiscal. Portanto, não há neste commit um interruptor de activação do lote em produção. Uma decisão futura de activação deve identificar o consumidor de produto, regra/versão, âmbito/período, comportamento de indeterminação e data de exposição. Não remover a guardaQA nem publicar a regra para simular que esse consumidor já existe. A publicação de documento, o cadastro, o merge e a activação não se confundem.
#### Desactivação e preservação histórica
- Para uma regra já publicada, a transição autenticada`.../{id}/suspend`, com versão corrente e evidência, altera o estado para`suspended`. O resolvedor existente selecciona apenas`published`, pelo que deixa de escolher essa versão para novas resoluções. Arquivo posterior usa`archive` quando aplicável. Não se ensaiaram estas transições sobre a regra real deste lote, que continua emrascunho; reutiliza-se o comportamento documentado/testado da governação existente.
- Suspender não apaga snapshots, relatórios, fontes ou eventos. A consulta de uma execução antiga lê o snapshot preservado, não volta a resolver com a taxa actual. Uma correcção ou substituição gera nova versão/execução com proveniência própria; nunca substitui silenciosamente o resultado antigo.
- Não existe transição genérica de`suspended` para`published` no fluxo actual. Uma retoma deve seguir uma versão elegível e as transições previstas, sem actualização directa de estado porSQL.
- Para parar este ensaio QA de rascunho basta encerrar o servidor de ensaio ou retirar a sua exposição no ambienteQA; não se publica/suspende artificialmente o rascunho para o desligar. Os registos persistidos mantêm-se para consulta. Qualquer futuro consumidor público deve definir a sua desactivação de novas execuções preservando essa leitura histórica.
**Saldo:**83d2f97d conserva o fecho técnico QA aceite; revisão consolidada com âmbito, exclusões, procedimento e percurso de interface demonstrados no recorte definido. Não há preparação técnica adicional identificada para fechar esta revisão; decisões de integração e publicação/activação permanecem distintas. AUD-SIM-INT02 continua parcial para produto fiscal integral; não declarar o simulador fiscal concluído. Sem merge, publicação, activação ou alteração do activo.13f68f1b mantém-se independente.
### Proposta única — consumidor IVA no simulador da homologação (21/10/2026)
**Conclusão da leitura do código em d186f942:** falta um delta de produto delimitado; integrar apenas83d2f97d/d186f942 e publicar uma regra não disponibiliza o percurso fiscal ao utilizador do simulador. A revisão QA anterior permanece concluída. Esta proposta identifica trabalho novo de ligação ao produto, sem reabrir as provas aceites, executar integração ou activar regras.
**Funcionalidade proposta:** na etapa fiscal do simulador existente, escolher «IVA geral sobre base líquida», indicar base tributável decimal, MZN, data de exigibilidade e declarar o contexto de operação interna à taxa geral sem excepções. Calcular e gravar pela execução existente; recuperar a execução e consultar/exportar o seu relatório preservado. Mostrar versão/fonte, contexto declarado e valor exacto separado da apresentação monetária. Não usar automaticamente a base orçamental nem a taxa introduzida no planeamento. A consulta do histórico mantém os requisitos actuais de conta e correio verificado; a execução/consulta individual mantém os controlos actuais de proprietário, organização ou token anónimo, conforme o percurso já suportado, sem prometer histórico de conta ao anónimo.
#### Delta exacto de código a implementar na branch separada
| Ponto actual verificado | Alteração delimitada / reutilização |
|---|---|
| `simulation-form.tsx::runSimulation` termina quando o modo não é `custom_planning`; a consulta de parâmetros não calcula. | Ligar o consumidor IVA à mesma etapa, validação, recuperação de falhas, idempotência, acompanhamento e histórico. Expor disponibilidade vinda do servidor; manter o planeamento personalizado identificado separadamente. |
| `StoreSimulationRequest`/`SimulationController::store` recebem base/taxa/redução. | Acrescentar contrato discriminado para o consumidor, com base decimal textual, moeda, data e contexto declarado. Reutilizar autorização de organização, identidade/token e limites existentes. O cliente não escolhe taxa, definição ou fonte executável; pedidos fora do contrato são recusados com mensagem clara e entradas preservadas. |
| `SimulationFiscalQaPreview`, `createFiscalQaPreview` e ramo de `RunSimulation` exigem QA isolado e regra emdraft. | Criar adaptador para regra publicada, reutilizando o resolvedor temporal e avaliador existentes e o núcleo decimal exacto. Conservar as guardas e o percurso QA. Resolver no servidor, conferir a versão autorizada e congelar regra/definição/fonte/parâmetro/contexto no snapshot; gravar através da mesma transacção, fila, idempotência e histórico. Não aceitar snapshot fiscal fornecido pelo cliente. |
| `resolvePublishedForPeriod` existe, mas a resposta de parâmetro não fornece um snapshot executável completo. | Recuperar no servidor a versão seleccionada pelo resolvedor e a definição correspondente, verificando elegibilidade publicada e correspondência temporal no mesmo limite transaccional da admissão. Não reconstruir o resolvedor nem inferir a regra pela taxa mais recente. |
| Não foi identificado interruptor deste consumidor nos serviços/configuração fiscal. | Acrescentar configuração específica, desligada por omissão, com consumidor, ID/versão autorizada e cobertura2026. Aplicar a verificação no servidor e expor o estado ao formulário. É escolha técnica proposta para executar as decisões separadas da Tempo, não novo papel organizacional. Não reutilizar o interruptor de outro módulo nem mudarAPP_ENV/guardasQA. |
| `fiscal-qa-preview.ts`, CSV e dossier reconhecem apenas `fiscal_rule_qa_preview`; rótulos dizemQA/draft. | Suportar explicitamente o novo tipo de snapshot publicado nos mesmos componentes/relatórios/exportações, preservando rótulos e leitura dos snapshots QA antigos. Garantir valor exacto textual e proveniência; nenhuma migração de resultados históricos ou arredondamento monetário silencioso. |
Modelos, políticas, governação, tabelas de execução/relatório e contrato genérico de resultados já existentes são reutilizados. Não se identifica necessidade de nova tabela fiscal ou migration para esta ligação; configuração e novo discriminador de cenário são o delta proposto. Novas entradas geram nova execução, sem converter uma execução fiscal antiga num cenário percentual editável.
#### As três operações da mesma proposta
1. **Integração do código.** Implementar o delta acima na branch fiscal, incluindo leitura histórica compatível e controlo desligado. A candidata a integrar terá de incluir esse delta: d186f942, isoladamente, ainda é QA. Após revisão da referência exacta pela Tempo, integrar emmain e confirmar a revisão servida pela disponibilização automática da homologação. Não cadastrar/publicar regra no arranque nem nas migrations. Resultado observável: código instalado, consumidor indisponível para novas execuções enquanto desligado; restantes percursos continuam disponíveis. Reversão por código compatível com os snapshots existentes, sem apagar dados.
2. **Publicação da versão da regra.** Com autorização de escrita no destino, backup/restauro confirmado e actores autenticados já designados, cadastrar a versão com a fonte real aceite, citações e metadados da ficha; não copiarUUIDs, utilizadores ou estados artificiais do QA. Usar os endpoints existentes de cadastro e `.../tax-rules/{id}/{action}`, com `version_no` e `evidence`, na sequência `submit` → `review` → `approve` → `publish`, pelos papéis existentes. Registar o ID/versão real publicado. Publicar torna o parâmetro elegível na API existente, inclusive para quem já a consulte; não habilita o novo consumidor. Não exigir nova certificação genérica da fonte oficial.
3. **Activação do consumidor.** Por decisão específica da Tempo, configurar a ligação ao ID/versão publicado autorizado e cobertura2026, habilitar o consumidor na homologação e aplicar a configuração efectiva do servidor. Confirmar estado disponível na etapa fiscal e executar somente a prova da ligação nova: entrada no simulador normal → versão publicada correcta → gravação → recuperação/relatório, mais recusa fora do âmbito e desligamento. Reutilizar as provas aceites de aritmética decimal, CSV, recuperação e paginação. Registar decisão, commit/build, regra/versão, configuração e momento da activação. Resultado entregue à Tempo: funcionalidade utilizável no portal, com esses limites; não apenas código instalado.
Estas operações são autorizáveis separadamente dentro desta única proposta. A autorização de13f68f1b não autoriza esta integração fiscal, publicação ou activação. Não foram executadas nesta análise. A sequência é código compatível desligado → versão publicada → consumidor ligado; não se atribui activação à mera publicação.
#### Âmbito, indeterminação e suspensão
Reutiliza-se integralmente o âmbito fundamentado da ficha v1.2: IVA geral16% sobre base líquida já identificada de operação interna, MZN, exigibilidade na cobertura demonstrada01/01/2026–31/12/2026. A taxa tem eficácia documental desde01/01/2023; o fim da cobertura2026 é limite do primeiro consumidor, não revogação/caducidade da norma. Enquadramento declarado é mostrado como declarado; o cálculo não certifica incidência. Fonte/versão/parâmetro/contexto insuficiente, conflito temporal ou falta de enquadramento produzem resultado indeterminado, nunca zero/taxa actual como substituto. Fora da cobertura ou consumidor desligado, explicar a indisponibilidade para nova execução e preservar as entradas.
Excluem-se preço com IVA incluído, taxas reduzidas, isenções/listas, bases especiais, importações, digital/não residentes, deduções/crédito/reembolso, incentivos, apuramento/declaração/pagamento e outros impostos. Não ampliar cobertura nem presumir arredondamento. Valor calculado exacto e apresentação monetária permanecem distintos.
**Suspensão operacional proposta:** desligar a configuração impede novas admissões pelo servidor e informa o formulário, sem bloquear recuperação, relatórios ou exportação de execuções já guardadas. **Suspensão da regra existente:** acção autenticada `suspend`, versão corrente e evidência; o resolvedor deixa de a seleccionar. Sem troca silenciosa por outra versão. A admissão deve fixar atomicamente a elegibilidade e o snapshot; execuções já admitidas conservam essa versão e podem concluir, identificadas pelo momento de admissão, mesmo se a fila terminar depois da suspensão. Novos pedidos ficam impedidos; repetição idempotente de pedido já admitido recupera a mesma execução. Este contrato de concorrência é escolha técnica explícita desta proposta, a verificar no delta, não comportamento já demonstrado pelo código QA.
Históricos não são recalculados, apagados ou renomeados como se tivessem usado a versão nova. A suspensão fica no registo de governação, sem alterar retroactivamente o conteúdo/hash do relatório. Retoma exige versão publicada elegível e ligação/activação autorizada; não existe transição directa de suspended parapublished no fluxo actual. Não eliminar a leitura de snapshots fiscais ao reverter código.
**Verificação nova estritamente necessária:** contrato público e permissões existentes; impossibilidade de executar desligado ou com versão não publicada; correspondência temporal/âmbito e precisão na ligação; cenário publicado na interface/histórico/relatório/exportação; suspensão versus admissão concorrente e repetição sem duplicação; preservação da leitura histórica e do modo personalizado. Não repetir as10páginas, valores/CSV ou ensaios QA aceites. Não se cria nova matriz/backlog; AUD-SIM-INT02 conserva o estado parcial do simulador fiscal integral e referencia esta delimitação. As fontes e provas existentes sustentam a proposta; não se atribui ao utilizador conferência local ou validação jurídica.
### Consumidor publicado implementado no simulador normal — fecho QA de21/10/2026
**Autorização:** instrução expressa da Tempo para implementar2ced7b79 em QA, sem nova proposta, integração emmain, publicação de regra real ou activação na homologação. O delta descrito acima está agora implementado. O fecho é da ligação deste único consumidor; mantém âmbito/exclusões da ficha v1.2 e não encerra o simulador fiscal integral.
**Percurso utilizável:** `/simulador-fiscal` → percurso habitual → etapa «Operação fiscal» → «IVA geral sobre base líquida» → base decimal textual, moeda explicitamente seleccionada, exigibilidade e declaração de contexto → «Calcular IVA e gravar». O resultado aparece no próprio simulador e no histórico existente. Pode recuperar-se sem recalcular; relatório, referência/hash e exportações usam o mesmo snapshot. Não é necessário acesso à administração fiscal para calcular. A consulta de parâmetros e o planeamento personalizado conservam os seus percursos e rótulos próprios. A base de orçamento e a taxa manual não são usadas como entradas implícitas deste IVA.
#### Implementação e fronteiras efectivas
- `StoreSimulationRequest` e `SimulationController::store` distinguem `fiscal_rule_published` do planeamento. O servidor recebe apenas entradas declaradas, não taxa/regra/versão/snapshot executável fornecidos pelo cliente; valida formato decimal textual, MZN, data e contexto. Falta de declaração de enquadramento grava indeterminação com valor nulo, nunca zero. Campos inválidos/período fora2026 são recusados com mensagens portuguesas e entradas preservadas.
- `SimulationPublishedFiscal` selecciona o ID/versão configurado no servidor, exige estado publicado e âmbito do lote, reutiliza `SimulationTaxParameterResolver` e `SimulationTaxRuleExpression`, e congela a proveniência antes da fila. `SimulationFiscalSnapshot` partilha a representação decimal com o adaptador QA, cujas guardas não foram removidas. Não há regra fiscal activada por importação ou arranque.
- `SimulationExecution` consulta a chave idempotente antes de verificar nova admissão. Assim, desligamento/suspensão não impedem recuperar o mesmo pedido já admitido. Nova chave exige consumidor ligado e versão publicada elegível; mudar entradas com a mesma chave dá conflito. A interface conserva a chave do pedido fiscal concluído para que repetir o mesmo cálculo não crie uma execução adicional. Novas entradas produzem nova chave/execução.
- Bloqueios consultivos transaccionais partilhados na admissão e exclusivos nas transições de governação/fonte serializam suspensão/revogação e admissão. Não se usa `SELECT FOR SHARE` na regra pública: poderia exigir uma política de alteração, contrariando as permissões existentes. A decisão de usar bloqueios consultivos é escolha de implementação, não nova exigência da Tempo. Não se alteraram políticas/grants nem se acrescentaram papéis de produto.
- `RunSimulation` processa o snapshot fiscal já admitido, sem resolver outra taxa/versão e sem reconsultar a activação. Guarda resultado, relatório e proveniência nos modelos existentes. Notas internas de revisão e identificadores dos funcionários ficam fora da proveniência devolvida pelo novo consumidor. Snapshots QA antigos conservam os seus rótulos/estado originais.
- O leitor de resultados do simulador foi adaptado ao contrato fiscal: o leitor aritmético anterior marcava o novo snapshot como inconsistente e desactivava exportação/conversão. Agora reconhece valor fiscal e indeterminação coerentes, e recusa divergência entre valor calculado e apresentação. CSV e dossier encaminham o novo tipo pelos componentes existentes; não se reescreveram os exportadores nem se repetiram provas aceites de paginação/valores.
- Configuração nova e desligada por omissão: `INVESTMOZ_IVA_CONSUMER_ENABLED=false`, `INVESTMOZ_IVA_CONSUMER_RULE_ID=` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0`, documentadas em `.env.example`. `/api/v1/fiscal-consumer` informa a disponibilidade sem cache; o servidor continua a exigir autorização na execução, independentemente do estado previamente visto no ecrã. A interface actualiza disponibilidade após uma tentativa, preservando entradas quando encontra suspensão. Configuração incompleta não escolhe outra regra automaticamente.
#### Provas novas, sem reabrir o conjunto aceite
Oito testes dirigidos deAPI/serviço,75asserções, passaram no clone isolado `simulator_fiscal_checks_20261021_test`, com transacções revertidas: ligação publicada, identidade/propriedade, token anónimo, idempotência/conflito, histórico/relatório/hash, contexto/fonte insuficientes, contrato/cobertura, suspensão/desligamento e trabalhador congelado, planeamento ainda disponível, configuração inválida e bloqueios de admissão. Um papel PostgreSQL temporário sem superutilizador/bypassRLS comprovou execução com a política pública de leitura, sem acesso aos eventos da governação ou alteração da regra; papel/grants de ensaio foram revertidos com a transacção. Uma segunda ligação comprovou que os bloqueios exclusivos da regra/fonte não atravessam a transacção de admissão. Dois testes frontend dirigidos ao novo tipo publicado passaram: encaminhamento coerente para ecrã/CSV/dossier e aceitação da indeterminação/recusa da apresentação divergente. TypeScript, build ePint passaram. Aviso do build sobre optimização opcional de fontes/fontaine não impediu a compilação.
**Observação no Edge, formulário normal:** porta18580, clone `simulator_fiscal_delta_20261021_test`, conta sintética de utilizador comum167, sem papéis fiscais administrativos. A fixture publicada é a versão sintética2 de `IVA-GERAL-BASE-LIQUIDA`, ID`01a15ddc-b0c5-727f-bb24-d806a69bd1f5`, para não substituir o rascunho QA anterior; fonte/actores sintéticos só neste clone. A governação existente foi usada na preparação da fixture. Não houve publicação na homologação nem declaração de aprovação jurídica da regra real.
1. Observou-se inicialmente o consumidor desligado e o botão indisponível. Habilitou-se somente a fixture local guardada contra qualquer outra base.
2. Base1000.03MZN, exigibilidade2026-10-21, sem declaração de contexto: execução`53663785-2e5d-4be2-8a05-c0c96a57896d`, resultado indeterminado, entradas conservadas e relatório guardado. A mesma base/data/moeda com declaração criou`5fa778f1-413e-4643-8c67-1b93ab290da7`, valor160.0048. Repetir o botão manteve essas duas execuções, sem terceira duplicada.
3. Reteve-se somente a fila QA dedicada `fiscal-consumer-qa-20261021`. A base1001.03 admitiu`be1e8399-ea95-4aa7-9ac0-7fa392932ac1` às09:52:11.782758UTC. Suspendeu-se a fixture pela governação às09:52:16UTC, ainda com essa execução emqueued e snapshot publicado congelado. O trabalhador real `queue:work database --queue=fiscal-consumer-qa-20261021 --once --tries=1 --timeout=30` concluiu-a depois da suspensão, com160.1648 e a mesma versão2. Nenhuma outra fila foi consumida.
4. Nova entrada1002.03 foi recusada: «A versão autorizada de IVA não está disponível para novas execuções. Os históricos foram preservados.» A base1002.03, data, moeda e declaração continuaram no ecrã; não houve quarta execução. Após recarga completa, recuperaram-se pelo histórico tanto o resultado calculado como o indeterminado. Ambos mostraram o snapshot original, sem alertas de inconsistência, com CSV disponível. O ficheiro não foi descarregado novamente; a implementação e as provas aceites do exportador foram reutilizadas.
5. Conferência por leitura: exactamente3execuções da conta, todascompleted; hashes de resultado/relatório iguais nas três; zero trabalhos pendentes na fila dedicada. O relatório da execução que concluiu após suspensão é`5e416a48-db07-449b-8eae-aa51a1538f04`, SHA256`997f026665e586c662ae574fd2ff7f62e0254819adb26aea920575e47e0490a3`. A fixture permanece suspensa e a configuração local foi reposta emdesligado; os históricos ficam acessíveis.
Provas privadas em `outputs/simulator-continuation-20261020`: `published-consumer-tests.txt`, `published-consumer-journey-receipt.json`, `published-consumer-calculated-ui.txt/.png`, `published-consumer-suspended-ui.txt`, `published-consumer-recovered-ui.txt/.png` e `published-consumer-indeterminate-recovered-ui.txt`. O primeiro acesso ao separador antecedeu o arranque do servidor local e apresentou ligação recusada; após arranque normal prosseguiu-se. Não é evidência de falha do simulador. Não se repetiram as10páginas do PDF nativo nem o conjunto aceite de83d2f97d/13f68f1b. A aritmética do exemplo demonstra a ligação e preservação do resultado, não a aplicabilidade fiscal a qualquer operação.
#### Procedimento único das três operações — agora executável após decisão
1. **Integrar código:** a Tempo identifica a referência exacta desta candidata na branch `codex/simulator-integrated-review`. Integrar emmain apenas após decisão; o envio desencadeia disponibilização automática na homologação. Manter `INVESTMOZ_IVA_CONSUMER_ENABLED=false`; não executar fixture, comandoQA ou cadastro fiscal no deploy. Confirmar build servido e aplicação; aplicar o procedimento habitual de recarga da configuração e reinício de trabalhadores persistentes, se existirem, antes de activar o novo tipo de trabalho. Não há migration neste delta. A leitura dos snapshots deve permanecer em qualquer versão de reversão; desligar o consumidor é preferível a voltar a um build que desconheça os históricos novos.
2. **Publicar versão real:** com autorização de escrita, backup/restauro confirmado e acção autenticada, reutilizar a fonte real aceite e os actores/papéis existentes. Cadastrar a versão fundamentada e usar `POST /api/v2/investment/admin/tax-rules/{id}/{action}`, com `version_no` e `evidence`, em `submit` → `review` → `approve` → `publish`. RegistarID e `rule_version` publicados. Não copiar a fixture/versão2/actoresUUID deste ensaio para homologação. Se a fonte já estiver cadastrada correctamente, reutilizá-la; não exigir nova certificação da credibilidade oficial. A publicação disponibiliza parâmetros naAPI existente, mas o consumidor continua desligado.
3. **Activar consumidor:** por decisão específica da Tempo, preencher `INVESTMOZ_IVA_CONSUMER_RULE_ID=<ID real autorizado>` e `INVESTMOZ_IVA_CONSUMER_RULE_VERSION=<rule_version autorizada>`; só então definir `INVESTMOZ_IVA_CONSUMER_ENABLED=true`. Aplicar a configuração pelo procedimento operacional existente (`php artisan config:cache` quando se usar cache de configuração; recarregar processos persistentes conforme o alojamento). Confirmar `/api/v1/fiscal-consumer` e a etapa fiscal do simulador, registando decisão, commit/build, ID/versão e instante de exposição. Não usar `version_no` de concorrência como se fosse `rule_version`. O servidor recusa outra versão em vez de escolher a mais recente silenciosamente.
**Suspender/desactivar:** repor o interruptor emfalse e aplicar a configuração impede novas admissões; para retirar a elegibilidade da própria regra usar a transição existente `suspend`, com versão corrente/evidência e actor autorizado. Pedidos já admitidos concluem pelo snapshot; repetição idempotente recupera-os. Histórico/relatório/exportação continuam acessíveis sem novo cálculo, publicação ou activação. Retoma segue uma versão publicada elegível e activação explícita, nuncaSQL directo sobre o estado suspenso. A janela2026 mantém-se um limite de cobertura, não data de caducidade legal.
**Saldo:** ligação funcional do primeiro consumidor fechada em QA, pronta para revisão e decisão sobre as três operações; sem outra preparação genérica identificada. AUD-SIM-INT02 permanece parcial para cobertura fiscal integral. As estimativas anteriores não constituem horas ainda por executar deste delta já concluído. Main local continua em13f68f1b; sem merge/push, publicação real ou activação na homologação. A Tempo não conferiu os ficheiros locais nem validou juridicamente as fontes nesta execução.
### Disponibilização autorizada pela Tempo —21/10/2026, execução em curso
A Tempo autorizou a implementação após o fecho da candidata226fd308 e a identificação das três operações. A integração foi executada por avanço directo de13f68f1b para `226fd308dd45d2805ef4678b20ca4398d7fef6c6`, seguida de envio aorigin/main. A disponibilização automática foi conferida através da camada HTTP autenticada: `/build/revision.json` serve essa revisão emmain, build de21/10/2026 às10:07:12.300278UTC, manifest SHA256 `d524c957dc95e92fcb6eb0d802b9df845743466bd51e398a485bd5ba3d7e3d8f`. `/api/v1/fiscal-consumer` responde200 com `enabled:false` e cobertura2026. A primeira leitura sem autenticação da camada HTTP foi recusada; a conferência seguinte utilizou o acesso já disponibilizado, sem divulgar credenciais. Não se repetiram provas aceites.
**Saldo das operações:** integração e disponibilização do código concluídas; publicação da versão real e activação ainda não executadas. A versão a cadastrar/publicar é `IVA-GERAL-BASE-LIQUIDA`, `rule_version:1`, da ficha documentalv1.2, vinculada ao exemplar da Lei22/2022 com hash `ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53`; oID real do destino ainda não foi obtido. Não usar a fixtureQA deversão2 ou os seus actores. A autorização de disponibilização não é apresentada como validação jurídica do utilizador.
**Dependência operacional concreta:** a sessão SSH anterior terminou comfalha de ligação; autenticação não interactiva no destino foi recusada. Pediu-se a reabertura do mecanismo existente `outputs/investmoz-git-delivery-20261020/resume-git-session.py`, com autenticação exclusivamente na consola local. Está preparado um inventário remoto somente por leitura para conferir os papéis/actores existentes, fonte, versão e estado do consumidor. Ainda não foi submetido, por falta da sessão autenticada. Backup/restauro e acção autenticada da governação serão confirmados antes de escrever dados fiscais ou alterar a activação. Não há nova pendência de fundamentação, nova ronda QA ou pedido de autorização para as operações já autorizadas.
Recibo privado da integração em `outputs/investmoz-iva-disponibilizacao-20261021/integration-receipt.json` no checkout principal; o recibo consolidado existente replica o estado. Nenhuma escrita fiscal, importação da fixture ou activação no destino foi executada nesta passagem. A continuação começa na autenticação remota e nas duas operações restantes, sem voltar a integrar ou preparar a candidata.
### Correcção QA da manutenção fiscal por um responsável — decisão Tempo de21/10/2026
**Origem e decisão:** a obrigação de quatro pessoas foi localizada em `SimulationTaxRuleGovernance::transition` e nos trêsCHECKs de `2026_10_03_170000_create_simulation_tax_rule_governance.php`, incorporados por `e65cb269` (06/10/2026, «varias alteracoes»). A nota de `simulator-evolution-progress.md` descreve «autor distinto de revisor, aprovador e publicador» como implementação. Não foi encontrada no acervo consultado uma decisão expressa da Tempo ou um requisito aplicável que imponha essa separação ao cadastro fiscal. Classificação: **escolha de implementação**, não regra organizacional aprovada. A autoria do commit não equivale à aprovação da condição. A Tempo agora instruiu expressamente a sua remoção neste fluxo e indicou `nala@tempo.co.mz` como responsável. Não se criam quatro designações nem uma excepção artificial limitada à primeira regra; a administração autorizada pode manter os parâmetros fiscais, sem extensão à independência acordada noutros processos.
**O que ficou corrigido em QA:**
- A administração existente, incluindo o perfil administrativo já autorizado comMFA recente, pode cadastrar/manter parâmetros sem preencher quatro listas específicas. As listas existentes continuam disponíveis para atribuições específicas. Utilizadores comuns e perfis apenas de auditoria não adquirem manutenção fiscal; autenticação, verificação de email eMFA permanecem. O investidor continua a utilizar o simulador pelas permissões próprias de execução/consulta; a manutenção não é requisito de utilização.
- O mesmo responsável pode percorrer `draft` → `submitted` → `reviewed` → `approved` → `published`, conservando osID reais em cada etapa. Retiraram-se as comparações de identidade no serviço fiscal e nos respectivosCHECKs; mantiveram-se evidência, datas, estado, versão, coerência da fonte, unicidade/sobreposição, bloqueios, RLS e eventos imutáveis. Não há alegação de revisão independente quando o actor é o mesmo.
- Como o destino não tem a fonte deste lote cadastrada, o percurso fiscal permite registá-la em `POST /api/v2/investment/admin/tax-rule-sources`: metadados existentes, origem oficial identificada, citação eSHA256. Regista a aceitação oficial já decidida pela Tempo em08/10/2026, responsável e momento, com `legal_opinion:false` e `independent_review:false`. O estado geral da fonte permanece `pending`; o fundamento `fiscal_acceptance` permite a sua utilização apenas na governação/resolução fiscal. Assim, não fica automaticamente validada para zonas ou outros processos, nem certifica juridicamente uma operação ou atribui parecer à Tempo. A regra deve referenciar o mesmo hash e versão. Cadastro de fonte não publica regra nem activa consumidor.
- A mesma pessoa pode concluir essa curadoria fiscal sem inventar outro actor para «validar» a fonte oficial aceite. A condição normal da fonte fora deste percurso, as relações/validações de zonas, oportunidades e perfis mantêm-se. O novo fundamento explícito `fiscal_acceptance` fica na fonte e na auditoria; o endpoint normal de zonas não permite marcar esse fundamento. Não se alteraram os fluxos com independência já acordada.
**Esquema:** `2026_10_21_130000_correct_fiscal_maintenance_actor_constraints.php` foi ensaiada apenas na base QA isolada. O delta é explícito: retirar desigualdades de identidade dos trêsCHECKs fiscais; acrescentar o fundamento de aceitação fiscal à fonte; registar a responsabilidade única na aceitação fiscal da fonte; ajustar apenas a projecção pública/resolução fiscal para reconhecer esse fundamento com hash e versão correspondentes. A condição normal de validação independente da fonte não foi removida e o seu estado continua pending. A guarda recusa execução da migration foraQA. Nenhuma constraint de oportunidades, zonas, perfis ou outra tabela foi modificada. A reversão só é permitida em QA sem registos dependentes; quando existirem cadastros legítimos, recusa apagá-los ou voltar a impor uma condição que os invalide. Desligar novas execuções preserva os históricos.
**Verificação estritamente do controlo alterado:**6testes/86asserções passaram em `simulator_fiscal_checks_20261021_test`, em transacções revertidas. Cobrem administração existente por lista e por perfil/MFA recente, sem quatro designações; curadoria da fonte, cadastro e quatro etapas pelo mesmo actor; responsabilidade/hashes dos eventos; consulta e suspensão; consumidor ainda desligado; recusa de investidor, auditor, ausência/expiração deMFA, falta de evidência, hash/versão de fonte incoerente e versão optimista errada; imutabilidade; compatibilidade das constraints não fiscais; reversão vazia e recusa de reversão com dados dependentes; guarda contra alvo nãoQA. Não se executou uma simulação: a contagem de execuções foi preservada. Não houve testes novos de cálculo, decimal, exportadores, PDF, TypeScript ou build, pois esse código não mudou. Pint foi conferido nos ficheirosPHP do delta. Prova privada: `outputs/simulator-continuation-20261020/fiscal-maintenance-actor-tests.txt`. Os testes antigos de governação foram ajustados para retirar a expectativa de uma proibição agora expressamente revogada, preservando os casos de permissões, versões e histórico.
O esquema foi depois aplicado, apenas pelo caminho desta migration, ao clone persistente `simulator_fiscal_delta_20261021_test`, mantendo a guarda de isolamento. Prova por leitura antes/depois:2versões de regra,7eventos de governação,11resultados,11relatórios e8fontes com as mesmas contagens e hashes de conteúdo. Na comparação da fonte exclui-se apenas a coluna nova, que ficaNULL nos registos anteriores; nenhum conteúdo ou snapshot histórico foi reescrito. Prova privada `fiscal-maintenance-schema-proof.json` na mesma pasta. O clone QA está, assim, com o controlo aplicado para revisão; a base activa e os seus actores não foram alterados.
**Estado real do destino:** a dificuldade de introduzir o comandoSSH foi resolvida abrindo directamente a consola habitual; a autenticação foi feita pelo utilizador e a sessão ficou pronta. A leitura do vhost identificou o directório servido `/var/www/html/teste-investidor.tempo.co.mz/current`, que aponta para a release226fd308; a cópia anterior conservada na raiz não é o consumidor servido. Foi confirmado por leitura que `nala@tempo.co.mz` existe (ID15), email verificado, MFA configurado e perfil de superutilizador existente; não foi criada conta ou alterado perfil. Não se confunde matrículaMFA com sessão autenticada recente: as acções de cadastro continuam a exigir a autenticação normal do responsável.
O backup/restauro anterior à escrita ficou comprovado em21/10/2026 às10:18:00UTC: cópia consistente por snapshot de `teste_investidor`, arquivo privado `/var/backups/investmoz/iva-rollout-20261021_101750/database.dump`, SHA256 `4e972c028fbe70c347c518ba86fe7e293093b7c81278ac4d64a077bf3e5325b7`,4.584.693bytes; restauro isolado em `iva_rollout_20261021_101750_test`, acesso público à base revogado. Contagens e hashes canónicos das171tabelas coincidiram. A configuração anterior ficou protegida na mesma pasta. Isto comprova backup/restauro, sem ensaiar ou escrever os parâmetros no activo. A primeira validação local do cabeçalho do arquivo usava texto inadequado e foi corrigida para o formatoPGDMP; a segunda operação completou o restauro e a comparação. Não é falha do simulador.
#### Procedimento actualizado das três operações, sem quatro responsáveis obrigatórios
1. **Código e controlo de esquema:** a revisão226fd308 já está integrada e servida. A presente correcção de governação fica na branch QA para revisão; não foi enviada amain. O delta inclui o esquema acima, cuja aplicação no destino deve acompanhar a referência exacta do código, por operação técnica autenticada e combackup/restauro actualizado conforme o estado nesse momento. A migration protegida é o ensaioQA, não um comando a executar contra a base activa. Conservar os históricos e o consumidor desligado durante a aplicação do controlo; não usar o deploy para cadastrar/publicar dados. Não voltar a integrar226fd308 nem repetir as provas de cálculo.
2. **Fonte e versão fundamentada:** o responsável real indicado pela Tempo autentica-se normalmente, comMFA. Reutiliza a fonte aceite se já existir; caso contrário, cadastra-a pelo endpoint fiscal acima, com exemplar/hash/citação reais. Cadastra `IVA-GERAL-BASE-LIQUIDA`, `rule_version:1`, conforme fichav1.2 e âmbito/exclusões2026. A mesma conta autorizada conclui `submit` → `review` → `approve` → `publish`, sempre com `version_no` corrente e evidência fiel: manutenção técnica autorizada, não parecer jurídico nem revisão independente. Registar oID real; não usar a fixtureQA deversão2 nem contas sintéticas. Não é preciso criar quatro designações.
3. **Activação do consumidor:** permanece separada da publicação. Aplicar a ligação aoID e `rule_version` reais autorizados e o interruptor de activação pelo procedimento já definido, conservando cálculo sobre base declarada como estimativa, sem apuramento final do imposto a pagar. Conferir somente a revisão/regra efectivamente servidas e o delta no destino. Desactivar o consumidor ou suspender a regra recusa novas admissões, preservando as já admitidas, históricos e relatórios.
**Saldo actual:** controlo corrigido e verificado emQA, fontes e funcionamento do cálculo não reabertos; sem nova publicação ou activação na homologação. A referência servida continua226fd308 e o consumidor desligado. A correcção não foi aplicada ao esquema activo. A indicação do responsável e a autorização operacional não são tratadas como conferência de ficheiros locais ou validação jurídica da Tempo. Esta actualização substitui a indicação anterior de que a simples falta de quatro listas configuradas bloqueava obrigatoriamente a disponibilização.
### Execução autorizada da correcção fiscal — 21/10/2026
A Tempo autorizou incorporar4a4d1212 e concluir apenas as operações pendentes. A correcção foi integrada emmain; a equipa integrou entretanto d3fe8a02, que inclui4a4d1212 e conserva o mesmo código fiscal. A revisão efectivamente servida, conferida em `/build/revision.json`, é `d3fe8a02ec7f5dcd01984c4f4a1eba18b5a0cf19`. Não se reinstalou226fd308.
Backup/restauro actualizado: `/var/backups/investmoz/iva-rollout-20261021_104253`, dumpSHA256 `b37bf4aad0abd839bc81d70adf802535ad7738fabe75fda61d74f39dfc11e74b`; restauro isolado `iva_rollout_20261021_104253_test`,171tabelas com contagens e hashes coincidentes. A operação técnica autenticada aplicou exclusivamente oDDL revisto, com registos/históricos e constraints alheias preservados. A migration protegida não foi executada na base activa nem teve a guarda enfraquecida. O contrato do executor foi actualizado apenas para essa migration, após registo da compatibilidade; a primeira disponibilização conservou a release anterior ao encontrar a alteração de esquema. A retoma usou o mesmo executor e não passou a executar migrations no destino.
Publicação e activação concluídas conforme recibo. O consumidor está activo. Não foram repetidas provas aceites do cálculo, precisão, exportadores ouPDF, nem importada a fixtureQA. A autenticação da infraestrutura não é identidade administrativa do portal. A conta Christiana/example.test reportada pelo utilizador pertence ao ensaio local anterior e não é usada no destino.
Mantém-se o procedimento único: **1)** código/controlo do esquema, agora integrados; **2)** cadastro das fontes reais com hash/citação e da versão1 `IVA-GERAL-BASE-LIQUIDA`, seguido das etapas autenticadas pelo mesmo responsável autorizado; **3)** activação separada, ligando o consumidor aoID e versão publicados. Cobertura2026, operação interna geral sobre base líquida declarada emMZN; excluídos preços comIVA incluído, taxas reduzidas, isenções, regimes especiais, importações, digitais não residentes, deduções/créditos/reembolsos, incentivos, pagamento/declaração e outros impostos. O resultado é estimativa deIVA sobre a base declarada, não apuramento final a pagar.
Para suspender, desligar `INVESTMOZ_IVA_CONSUMER_ENABLED` e reiniciar graciosamente os workers pelo mecanismo existente, ou suspender a versão publicada pela governação autenticada, com `version_no` corrente e evidência. Novas admissões são recusadas; as já admitidas concluem com snapshot congelado, mantendo históricos e relatórios. Não apagar fontes, versões ou execuções para reverter a exposição. A autorização e o cadastro não constituem conferência local dos ficheiros pelo utilizador nem validação jurídica atribuída à Tempo. O recibo JSON existente regista a revisão, a regra e o estado efectivamente observados.
**Recibo final das três operações:** código4a4d1212 integrado e servido dentro ded3fe8a02; três fontes oficiais reais cadastradas comversão1, hashes e citações; regra `IVA-GERAL-BASE-LIQUIDA`, **rule_version1**, ID `01a15e1a-5338-700f-8a45-f87707d696a7`, estado `published`, controlo optimista `version_no5`; autor/revisor/aprovador/publicador reais: conta indicada `nala@tempo.co.mz`, ID15. A sessão normal comMFA foi efectuada pelo utilizador numa página temporária de apoio ligada apenas a127.0.0.1; não se guardaram palavra-passe, códigoMFA ou cookies. Não há alegação de revisão independente ou parecer jurídico. A aceitação fiscal das fontes tem o fundamentoTempo08/10/2026; o estado geral permanecepending, sem validação automática noutros processos.
Activação separada aplicada em21/10/2026 às10:55:58UTC, alterando apenas as três chaves do consumidor. Ligação exacta aoID acima, rule_version1 eenabled=true; restante configuração preservada. `/api/v1/fiscal-consumer` confirmouenabled=true porHTTP200; a leitura do resolvedor, sem executar cálculo, confirmouIVA_GENERAL_RATE=16percent, mesmoID/versão/fonte no contextoMZN/internal_general_net de2026. Cinco eventos de governação; contagens de resultados/relatórios preservadas, sem simulações sintéticas no destino. O restauro de segurança e a configuração anterior estão conservados no arquivo privado identificado no recibo.
Suspensão concreta: configurar `INVESTMOZ_IVA_CONSUMER_ENABLED=false` e executar o reinício gracioso existente `php8.4 artisan queue:restart` na releaseactual como operador autorizado; ou `POST /api/v2/investment/admin/tax-rules/01a15e1a-5338-700f-8a45-f87707d696a7/suspend`, com `version_no:5` enquanto corrente e evidência verdadeira de pelo menos20caracteres, pela administração autenticada/MFA. Não repetir a suspensão se o estado/versão já tiver mudado. Conservar a ligação e todos os registos; futuras retomas respeitam o estado da regra e o procedimento de versão aplicável, sem escolher automaticamente outra regra.
File diff suppressed because one or more lines are too long
@@ -2806,3 +2806,203 @@ Interfaces QA: [atribuir interesses](http://127.0.0.1:18555/management/interests
| P6 | Conferência do delta de conteúdos/consumidores após P3–P5; reutilizar provas actuais. Vínculo com dossier legítimo quando disponível. | 2–4 h |
**Saldo de disponibilização do produto existente: 11–20 h**, condicionado ao lote P4 e às pré-condições do destino; não inclui espera por decisão/acesso nem desenvolvimento dos contratos especializados. Os 27–46 h são referência inicial do conjunto, não saldo. Não há base para estimar de forma responsável em bloco o desenvolvimento das oito modalidades: concluir por modalidade exige contrato explícito, âmbito sem acesso indevido, gravação/recuperação e submissão/revisão compatíveis. Recuperação e cessão exigem primeiro identificar a operação concreta; não constituem automaticamente novos módulos. **P7 permanece fechado e separado**, sem bloquear P1–P6. Incrementos 36–39 e instalação remota continuam para decisão expressa. Importação privada e publicação mantêm-se separadas.
## P1/P2 — prova complementar de actualização, 20/10/2026
Mantém-se a revisão QA `6508c44b9fc1` (56 fontes/177 assets, hashes conferidos). Origem `167b5db97653`; não reconstruir nem repetir provas aceites. Pela interface e login/MFA sintético: interesse actualizado de Recebido para Em análise, nota preservada no histórico após recarregar; pedido do Balcão actualizado de Submetido para Em análise pelo analista atribuído, resposta fundamentada e histórico recuperados. Reutilizadas as provas de atribuição, recuperação noutra sessão e recusa de outros registos. Zero alterações remotas, importações, publicações ou grants reais. Contas sintéticas novamente retiradas da allowlist HTTP.
Limite concreto: o agendamento por calendário não foi certificado — a entrada automatizada não actualizou o estado React e o validador existente recusou a falta de prazo. Esse erro ainda aparece em inglês. Não inferir falha de gravação do estado/nota, nem declarar o calendário aprovado. A relação entre próxima acção e prazo já existia; não foi criada exigência organizacional.
P1: 13 das 21 modalidades reconciliadas, oito lacunas de contrato/âmbito individualizadas na tabela anterior; não encerrar todos os contratos especializados. P2: composição QA concluída; sem nova alteração de código. P7 assistência fechada e separada. Saldo P1–P6: 11–20 h para disponibilização do recorte existente (P3 3–5, P4 4–8 condicionado ao lote, P5 2–3, P6 2–4); desenvolvimento especializado sem estimativa fechada e sem bloquear os restantes componentes. O delta de actualização agora concluído não justifica repetir nem voltar a somar horas de P1/P2. Manifesto actualizado apenas nas provas, mesma revisão de fontes.
## P3/P4 preparados e proposta P5/P6 — 20/10/2026
Revisão QA `3fd1f15bd01c`, sucessora de `6508c44b9fc1`: 56 fontes e 177 assets do build preservado. Duas fontes PHP corrigidas: mensagens do prazo em português e limite funcional no MNE reutilizando OpportunityCanonicalContract::deliveryBlockers. ExistingBusinessIntake não consultava esse limite; agora submissão e publicação de financiamento/concessão/fornecimento não são habilitadas por correspondência de acesso. A preparação permanece guardável. Cinco modalidades verificadas por transacção/rollback: capital_increase, financial_investor, financing_request, concession_opportunity, supply_opportunity. Nenhum módulo experimental foi ligado. As seis categorias base mantêm os bloqueios anteriores. As oito lacunas contratuais e P7 permanecem separados.
Calendário: mensagem de falta de prazo traduzida; edição por teclado, gravação e recuperação de 21/10/2026 com a próxima acção passaram no navegador QA. Não se alterou o componente de data: o preenchimento automático isolado não actualizava o estado React, enquanto a edição por teclado o actualiza. Prova anterior de actualização e limites reutilizada; sem repetir importações, scans, restauros ou percursos aceites. Sintaxe PHP passou; hashes dos 177 assets preservados, sem recompilação desnecessária por alteração exclusivamente PHP.
P3, manifesto privado: 30 fichas com identidade e hash actual do QA técnico, secções da fonte e relações; dois originais identificados por SHA256, versão e correspondência por bytes no clone do destino. As fichas continuam privadas; IDs de actores/organizações QA não são transportáveis. Definições project-change e project-follow-up extraídas de config/virtual-desk.php, incluindo campos e hash, comparadas com as versões do clone. Não substituir project-preparation v5 nem reassociar pedidos existentes. Nenhuma importação foi executada. O manifesto de identidades não é ainda um payload de aplicação autenticada.
P4, lista nominal: 27 entradas de referência (quatro edições de catálogo, 12 sectores e 11 actividades) e 24 referências territoriais com origem/identidade/versão e correspondências no clone. São inventário para reconciliação; **zero referências novas autorizadas para escrita e zero geometrias novas ou substituídas incluídas**. Não transportar catálogos de exemplo ou fonte pendente apenas por existirem localmente. Referências existentes são reutilizáveis por identidade/edição; divergências ficam por item e nunca se resolve por coincidência de ID local. A lista completa está no MANIFESTO-P4-REFERENCIAS-GEOMETRIAS.json. A base é um clone local, não prova fresca do servidor.
| Ordem P5/P6 | Operação e condição verificável | Reversão / limite |
|---|---|---|
| 1 — preflight por leitura | Conferir revisão/hash dos ficheiros nominais, schema usado por P1/P2 e P3, flags, operador/MFA, scanner Unix, worker e armazenamento. Comparar cada identidade/edição/hash dos manifestos com o destino actual. Parar apenas a operação divergente e apresentar diferença. | Não executar pacote de migrations. Esta candidata contém zero migrations. Não usar o clone como prova de estado remoto fresco. |
| 2 — backup fresco | Suspender escritas HTTP, tarefas agendadas e consumo de filas no recorte durante cópia consistente da base e ficheiros privados; registar inventário, hashes, UID/GID/modos. Incluir configuração e chaves de desencriptação no envelope protegido com recuperação fora do perfil Windows. Retomar só após fecho consistente. | Reutilizar prova aceite de restauro; nesta execução não criar backup nem repetir restauro. Recuperação total é contingência, não reversão normal. |
| 3 — código/build | Guardar cópia anterior por caminho/hash; aplicar apenas payload da revisão identificada, incluindo um build; efectuar limpeza/recriação de caches compatível com destino e processo existente. | Repor só ficheiros ainda com hash aplicado. Ficheiro modificado posteriormente gera conflito para reconciliação; nunca sobrescrever. |
| 4 — dados privados P3 | Produzir prévia fresca pelo importador existente; reusar originais por hash, confirmar scan real no destino, resolver custódia/permissões por identidade autorizada, criar apenas fichas inexistentes e referências compatíveis. Registar journal de IDs/criação/hash/versão/execução. | Não publicar recursos nem oportunidades; preservar edições posteriores. Sem apagar ficheiros partilhados, histórico ou relações. Corrigir/desactivar só registos deste lote pela recuperação suportada; versões usadas por dossiers permanecem. |
| 5 — serviços internos | VirtualDeskInstallationController, fluxo autenticado de instalação: registar apenas project-change/project-follow-up ausentes; comparar hashes se já existirem. Versionar sem substituir versões anteriores. Activação interna do serviço deve constar da decisão de disponibilização; não equivale a publicação de conteúdo. | Conservar versões referidas por pedidos posteriores. Retirar da escolha para novos pedidos apenas por mecanismo suportado; não eliminar versões ou pedidos. Se esse mecanismo não estiver disponível, conservar definição instalada e reverter só apresentação/código. |
| 6 — referências P4 | Nesta prévia não há operação nova de escrita de geometrias/catálogos. Revalidar correspondências e reutilizar destino. Nova edição real exige identificação nominal e revisão expressa do manifesto. | Nenhuma geometria existente é substituída. Não converter desconhecido em confirmado. |
| 7 — verificações do delta | Entrada autorizada; guardar/recuperar próxima acção e data; limites MNE sem activar contratos; fichas privadas/originais e duas definições recuperáveis; pesquisa/filtros/contexto apenas onde alterados. Vínculo positivo com dossier legítimo quando existir. | Provas aceites reutilizadas; não criar processo fictício no destino. Se faltar dossier legítimo, registar essa única prova pendente, sem bloquear os restantes componentes. |
| 8 — fecho | Retomar escritas/worker e entregar estado por componente com URLs, alterações nominais e impedimentos. Conservar manifesto de execução e reversão. | Reversão normal conserva dados posteriores e estruturas/definições aditivas utilizadas, sem down migrations nem dump antigo sobre dados recentes. |
**Saldo delimitado revisto: 7–13 h** — P3 2–4 h (staging privado/resolução de identidades/aplicação nominal); P4 1–2 h (reconciliação fresca da lista, sem novas geometrias); P5 2–3 h; P6 2–4 h. O calendário, tradução e protecção do limite funcional foram concluídos nesta passagem e estão contabilizados como trabalho realizado, não como horas adicionais futuras. Esta faixa substitui 11–20 h para este recorte delimitado; não cobre concluir todas as modalidades nem um futuro lote geográfico não identificado. Não inclui espera por acesso/decisão. P1/P2 ficam preparados em QA com os limites acima; P3/P4 preparados por leitura, não importados. Instalação, incrementos 36–39 e activação interna das duas definições precisam da decisão expressa identificada. Sem alterações remotas ou publicação nesta execução.
## Pacote executável P3/P4/P5/P6 — c11a0c74766c, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote c11a0c74766c, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O novo adaptador passou sintaxe/integridade/bindings, sem repetir importação; aplicação desse adaptador ainda não ensaiada. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 82dab3719ffc, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 82dab3719ffc, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 312965f2c653, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 312965f2c653, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Execução autorizada 312965f2c653 em homologação — 2026-10-20T10:01:37Z
Autorização expressa da Tempo nesta conversa; aplicada exclusivamente em teste-investidor.tempo.co.mz. 56 fontes/177 assets (233 caminhos) conferidos; backup fresco consistente de 171 tabelas, privados, fontes/build e configuração, seis artefactos cifrados e recuperação existente preservada. Não foi repetido restauro aceite.
30 fichas privadas, 30 localizações, 31 suportes, 11 relações; dois originais internos com scan real limpo (uma tentativa cada), sem publicar. project-change/project-follow-up v1 internos activados; project-preparation v5 preservado. Reutilizadas 27 referências/25 territórios; zero escritas de catálogos/geometrias, migrations ou permissões. 160 tabelas integralmente iguais; registos anteriores nas tabelas acrescentadas conferidos por hash; 361 ficheiros anteriores fora do delta intactos. HTTP/fila activos. Recibo e acessos: outputs/investmoz-revisao-integrada-QA-20261019/p1-p2/modalidades-e-interface/RECIBO-EXECUCAO-312965.json.
Saldo: execução delimitada P1–P6 concluída; não somar outra vez as 4–7 h já executadas. Mantêm-se vínculo positivo com dossier legítimo, dados em falta/revisão independente, oito lacunas contratuais, capacidades por desenvolver, P7 e SuiteCRM. Reversão disponível só da apresentação: manter 56 fontes compatíveis, ambos os builds, dados, definições usadas e histórico. Não repor dump antigo sobre dados posteriores. As questões de autenticação prematura e diferenças visuais/login continuam separadas, com análise retomada; sem alteração remota adicional.
### Retoma separada de autenticação e apresentação após 312965
Diagnóstico apenas em clone isolado: com a reserva ligada, GET /documentos devolve 302 para login; desligada só no processo QA, /documentos e /oportunidades devolvem 200 anónimo, enquanto /management/portal-services e /minhas-oportunidades continuam 302. A reserva global tem origem na decisão expressa da Tempo, não constitui uma nova obrigação pública de MFA e não foi desactivada no destino. Questão permanece aberta para distinguir claramente reserva do ambiente e percurso público.
Manifestos de build local principal e QA instalada são diferentes; app.tsx/layout AuthSimple iguais, login.tsx difere no reaproveitamento do email. O ecrã local já aberto conserva título Laravel e rodapé anterior; não foi recarregada ou alterada a base local activa. Comparação visual por composição/configuração/sessão continua aberta. Nenhuma correcção adicional de código foi incluída silenciosamente no pacote. Prova: RETOMADA-AUTH-VISUAL-QA.json na mesma pasta do recibo; sem novo backlog.
## Consulta pública e coerência visual em QA — b0fdd083ca56, 20/10/2026
Origem: pedido expresso da Tempo de 20/10/2026. A reserva do ambiente permanece obrigatória; a dependência de uma identidade do portal para qualquer consulta era escolha de implementação. O middleware global confundia ambas. Modo `perimeter` preparado e demonstrado apenas no QA loopback: autorização do ambiente pelo servidor, consulta pública sem conta e autenticação nas operações identificadas. Ausência de autorização e cabeçalho HTTP forjado recusados. O modo `reviewers` continua como valor predefinido compatível até configurar uma reserva independente no destino por decisão expressa. Não basta activar a flag nem aceitar um cabeçalho do cliente.
Base visual: composição aprovada 3fd1f15bd01c, preservando início, navegação, editorial e rodapé. Builds diferentes e conteúdo/configuração em execução explicam a divergência observada com o local; não se copiou `.env` ou segredos. Login/registo ainda continham textos ingleses e logótipo Laravel. QA utiliza a marca Tempo existente, textos portugueses, formulário sem salto de foco e aviso da reserva. A ligação de entrada na ficha pública passa pelo fluxo existente `/participar/entrar`, conservando URL/contexto.
Provas: 13 fronteiras de acesso e seis verificações adicionais passaram; TypeScript e build com código 0. No navegador: consulta anónima → rascunho sintético → autenticação → mesma oportunidade, nome, país, sector e mensagem recuperados. Paragem antes do envio final; zero manifestações desta conta. Registo normal por HTTP em QA, sem confirmação artificial de email/MFA e sem email externo. Gestão privada continua protegida; a conta de participação não recebe permissões administrativas. Imagens antes/depois de início, login e registo guardadas no mesmo conjunto de provas. Não repetir importação, recibos completos, scans ou restauros.
Revisão QA `b0fdd083ca56`: 10 fontes e 177 ficheiros de build no manifesto `MANIFESTO-PUBLICO-VISUAL-QA.json`; pacote sem ambiente, chaves, actores, dados ou harness. QA: http://127.0.0.1:18561/ ; /oportunidades ; /login ; /register. Só acessível nesta máquina. Homologação remota mantém 312965f2c653; não instalada esta correcção e nenhuma abertura externa. Para futura disponibilização será necessário definir e conferir o perímetro independente, antes de activar `perimeter`. Oito lacunas contratuais, vínculo documental positivo ao dossier e P7 separados. Reversão desta preparação: restaurar as dez fontes/build anteriores e manter o modo reservado anterior; nenhum schema ou dado de negócio alterado por este delta.
## Proposta consolidada após reconciliação dos programadores — 423bd512d8c5, 20/10/2026
Preservada b0fdd083ca56 integralmente por hash, sem a renomear nem substituir. É revisão de conteúdo do pacote, não commit Git. Origin/main foi consultado nesta passagem: 507387502c6ef887433e8e9b5cf20ea39d4f17d4, com dois commits posteriores ao HEAD local a128c5a1. A nova composição mantém a aplicação aprovada 3fd1f15bd01c/312965 e acrescenta 97a08df2 (cabeçalho/menu responsivo, institucional/contacto, rodapé e formulário) e 50738750 (compilação/carregamento diferido dos exportadores). As alterações de 14ad2f60, cb438b2b, becb211f e 9bc450e8 já estavam incorporadas na base e foram conservadas. 6e57d1f6 e a128c5a1 são documentação/reconciliação, não implementação de autenticação.
Inventário: 760 ficheiros runtime do Git presentes, 677 exactos após normalizar finais de linha e 83 sobreposições QA enumeradas. Presença não comprova funcionamento de todos os componentes. Cinco conflitos de composição resolvidos; preservados menus novos, editorial, email institucional configurável, acesso/contexto e corpo dos exportadores aceites. A refactorização dos programadores só deslocou o corpo de geração; foi conservada com o corpo QA aceite. Ferramentas de arranque local e respectivo comando/composer dev-check não precisam de instalação no servidor. Não aplicar checkout/reset/pull nem substituir uma árvore inteira.
Identidade: a expressão anterior «marca Tempo» referia o logótipo da proprietária usado na autenticação, não autorização para mudar a identidade do produto. A composição agora identifica explicitamente InvesteMoz, propriedade da Tempo, Lda.; reutiliza o logótipo existente apenas para a proprietária. Não foi inventado outro logótipo de produto nem substituído um exemplar aprovado. Não foi localizado outro logótipo InvesteMoz entre os recursos públicos do repositório.
Reserva exacta: Nginx HTTPS com auth_basic independente, ficheiro protegido /etc/nginx/private/investmoz-homologation.htpasswd, snippet nominal e marcador FastCGI INVESTMOZ_HOMOLOGATION_ACCESS=granted só após autorização do ambiente. INV...HOMOLOGATION_ONLY=true mantido e ACCESS_MODE=perimeter. Document root /var/www/html/teste-investidor.tempo.co.mz/public, socket /run/php/php8.4-fpm.sock, vhost /etc/nginx/sites-available/teste-investidor.tempo.co.mz.conf. Configuração completa e hashes no pacote. /deploy e assets igualmente reservados; compatibilizar o cliente dos programadores antes da troca. Credenciais independentes por revisor autorizado, transmitidas por via protegida, sem reutilizar palavras-passe do portal ou alterar papéis/MFA.
Novas provas: oito casos HTTPS/Nginx/PHP-FastCGI passaram (401 sem autorização, 200 para convidado do portal autorizado no ambiente, 302 na operação privada), três passos da reversão da reserva e quatro casos do contacto com transporte simulado. Navegador: abrir/fechar/Escape do menu, navegação para contacto, erro do formulário e identidade em início/login/registo. Build/TypeScript passaram. Reutilizadas as 19 provas b0 e anteriores; sem nova importação, publicação, scan ou restauro. Clone Windows não comprova por si a instalação Linux. Última evidência remota continua a captura aceite de 20/10/2026; tentativa SSH desta passagem não autenticou (verificação da chave do host), sem ignorar essa verificação. Preflight fresco obrigatório antes da execução, com comparação também dos fontes que alimentam o build e detecção de ficheiros adicionais. Qualquer divergência bloqueia a troca e deve ser reconciliada.
Proposta única 423bd512d8c5: 40 fontes/recursos estáticos e 187 ficheiros de build, configurações e preflight somente leitura; zero migrations/importação/publicação. Reversão deste delta por ficheiro/hash e sequência da reserva, mantendo as 56 fontes compatíveis e dados introduzidos por 312965 e posteriores. Não é reposição integral da aplicação; não repor dump antigo. Oito lacunas contratuais, vínculo positivo ao dossier, geometrias e P7 separados. Estimativa operacional 90–150 minutos com preflight limpo e credenciais prontas, sem desenvolvimento das modalidades pendentes. Sem alterações remotas nesta passagem.
## Preflight SSH e compatibilidade da reserva — 20/10/2026
Revisão 423bd512d8c5 preservada, ZIP SHA256 43b68aaf44daec2db0251f43a8ae254f038fbc0bb0157839327190f578d5d1af. Esta leitura substitui a limitação SSH do registo anterior: 669 verificações do preflight exacto passaram, incluindo 665 entradas de fontes; 125 ficheiros de dependências/base adicionais coincidem com a base aceite. HEAD remoto 9bc450e8; origin/main permanece 50738750. Build servido e vhost coincidem com as pré-condições do manifesto. Não apareceu conflito novo de código dos programadores.
Preservar um comando adicional de diagnóstico idêntico à base aceite, 149 auxiliares gerados (11 diferenças de comentários e três auxiliares antigos sem algumas rotas; nenhuma operação nova desconhecida) e 566 recursos adicionais do build. Não apagar nem regenerar no servidor. A aplicação recebe o build QA já aceite, não uma compilação remota.
Compatibilidade /deploy: ensaio isolado com o webhook efectivo confirmou 401 com HMAC válido mas sem credencial ambiental; 403 com credencial ambiental e HMAC inválido; 202 com ambos válidos, despacho interceptado, zero comandos reais e zero chamadas remotas. Nginx QA reposto. Não repetir as provas funcionais aceites.
O script remoto já recusa árvore Git alterada antes de fetch/reset/migrations; o estado failed resulta dessa guarda. A primeira leitura por palavras-chave sobre reset era incompleta: nenhuma guarda nova é necessária. Conservar script/serviço/HMAC. Não chamar /deploy para instalar esta revisão: o fluxo genérico inclui migrations automáticas, fora deste manifesto nominal de zero migrations.
Execução proposta: após decisão, backup fresco consistente; criar credenciais ambientais por via protegida; configurar no webhook Gitea existente Authorization Header = Basic <base64(utilizador:segredo ambiental)> ANTES de ligar a reserva, sem mudar URL/eventos/ramo/Secret HMAC; conferir novamente as pré-condições; aplicar nominalmente 40 fontes/recursos e 187 ficheiros de build; configuração Nginx exacta com /deploy igualmente reservado. Não usar Test Delivery ou push para testar, pois pode disparar o deploy genérico. O cabeçalho é configuração operacional, não credencial de conta do portal, e não entra em chat, URL ou recibo.
Avaliadores introduzem credencial ambiental e navegam no conteúdo público sem conta InvesteMoz. Operações identificadas continuam a exigir login/políticas/MFA aplicáveis. A futura abertura pública retirará a reserva por decisão própria, sem retirar a protecção privada ou a assinatura/guarda de /deploy. Identidade preservada: InvesteMoz, propriedade da Tempo, Lda.
Reversão: repor selectivamente o delta por hash e a reserva anterior, e só depois o cabeçalho anterior do cliente, sem alterar o Secret HMAC. Preservar alterações posteriores, 56 fontes compatíveis, dados, relações, definições e assets. Não é reposição integral da aplicação nem restauro de dump. Nenhuma alteração remota efectuada. Execução continua sujeita à decisão da Tempo; estimativa operacional 90–150 min com acessos prontos, excluindo esperas/divergências. Oito lacunas contratuais, vínculo documental legítimo, geometrias e P7 separados.
Provas: RECIBO-PREFLIGHT-423bd512d8c5.json, PREFLIGHT-EXACTO-423bd512d8c5.json e PROVA-COMPATIBILIDADE-DEPLOY-QA.json na pasta da mesma proposta. O manifesto/ZIP congelados não foram alterados; este recibo actualiza o preflight e a ordem operacional.
## Execução autorizada 423bd512d8c5 — acesso pendente em 20/10/2026
A Tempo autorizou a execução nominal e a reserva independente. O preflight fresco passou (669 verificações). Staging protegido contém as mesmas 40 fontes/recursos e 187 ficheiros de build, sem alterar o ZIP. Dois hashes APR1 ambientais preparados em staging, sem activar a reserva. Credencial de Nala preparada em ficheiro local privado, restrito ao titular Windows/SYSTEM; nenhum valor em relatórios ou repositório.
Impedimento: o Gitea recusa a autenticação e o Authorization Header do webhook existente ainda não foi configurado. Não activar a reserva antes desta dependência. Pedido de autenticação/configuração local pendente; não falta nova autorização. Aplicação anterior preservada, sem manutenção, instalação, backup consistente iniciado, migrations, importação ou publicação. Retomar com acesso Gitea, backup fresco consistente e execução nominal já autorizada. Provas de consulta sem conta, conta simples e retoma por realizar após instalação. Disponibilização automática por Git não implementada e separada. Estado: ESTADO-EXECUCAO-AUTORIZADA-423.json na pasta da mesma proposta.
## Disponibilização nominal 423bd512d8c5 — 20/10/2026
Executada exclusivamente na homologação reservada por autorização expressa da Tempo. Gitea: utilizador confirmou gravação do Authorization Header; presença Basic e Secret HMAC conferida sem expor valores. Não foi chamado /deploy, Test Delivery ou fluxo Git genérico. Disponibilização automática por Git não implementada e separada.
Backup fresco consistente concluído: manutenção HTTP, fila suspensa, bloqueios SHARE nas 171 tabelas públicas, hashes dos ficheiros estáveis antes/depois. Seis artefactos cifrados e conferidos: base, ficheiros privados/públicos de armazenamento e configuração/chaves da aplicação, fontes/build, configuração de servidor, ACL Linux e metadados. Envelope portátil de recuperação existente preservado, independente do perfil Windows; proprietário/grupo/permissões/ACL/xattrs registados. Não repetido o restauro integral aceite. Cópia anterior do delta e diário de hashes sob custódia root no destino.
Aplicadas 40 fontes/recursos e 187 ficheiros de build, exactamente pelos hashes do pacote congelado. Manifesto efectivamente servido: 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. HEAD Git permanece 9bc450e8 e não identifica sozinho a composição servida. Restantes fontes, comando adicional, 149 auxiliares gerados e todos os assets anteriores não substituídos pelo manifesto preservados (741 ficheiros antigos inventariados, zero perdas/alterações imprevistas). Vhost/snippet correspondem aos bytes do pacote; apenas cinco chaves nominais alteradas; restantes valores .env e proprietário/permissões preservados. Nginx validado antes de reload; PHP-FPM/scanner/base não reiniciados. Manutenção terminada e fila no estado anterior activo. Migrations registadas 88 antes/depois; zero migrations, importação, publicação, alterações de contas/grants/MFA.
13 verificações HTTPS do delta passaram: sem credencial ambiental, início/documentos/build e /deploy recusados (401); com credencial ambiental e sem conta do portal, início, documentos, oportunidades, sobre e contacto 200; áreas privadas redireccionam para login; manifesto HTTP igual ao instalado. Navegador: utilizador concluiu a entrada ambiental; início e os 29 documentos abrem com Entrar visível, sem conta InvesteMoz; Continuar no meu Balcão redireccionou para login. Retoma após login real e consulta com conta simples aguardam a sessão apropriada; não certificar esses dois passos pela prova anónima. Sem actores sintéticos ou impersonação no destino.
Credencial ambiental entregue por ficheiro local privado, restrito ao titular Windows/SYSTEM; não consta do repositório, Page ou recibos. Abertura pública continua separada e retirará a reserva por decisão própria, mantendo protecções privadas e /deploy.
Reversão aprovada: modo reviewers enquanto reserva independente permanece; repor selectivamente apresentação/rotas e configuração deste delta apenas se os hashes ainda coincidirem; preservar alterações posteriores; só depois repor reserva/cabeçalho anteriores. Manter 56 fontes compatíveis, dados/relações/definições e assets posteriores. Não é reposição integral nem restauro de dump. Oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git separados. Instalação concluída; saldo desta execução: 15–30 min de verificação com sessão real adequada, excluindo esperas; nenhum novo desenvolvimento incluído. Provas e recibo: RECIBO-EXECUCAO-423bd512d8c5.json e PROVA-HTTP/CONFIGURACAO/PRESERVACAO-DESTINO-423.json na mesma pasta da proposta.
## Conferência da sessão real 423bd512d8c5 — 20/10/2026
Depois da entrada ambiental, o navegador apresentou início e biblioteca com 29 documentos sem conta do portal. Continuar no meu Balcão conduziu a /login. O titular concluiu a autenticação real e regressou automaticamente a /balcao-virtual/painel, com o título Balcão Virtual — acompanhamento e conta visível Osmane Nala. A página inicial voltou a abrir nessa sessão autenticada, sem pedir nova credencial. Esta evidência encerra a retoma no delta; não é prova de uma conta simples, pois a conta usada tem funções já autorizadas.
Não pedir novas contas para encerrar a instalação nem certificar a conta simples sem a observar. Única conferência específica restante deste recorte: consulta com uma conta simples real adequada, 5–10 min quando disponível, excluindo esperas. Não houve criação de conta, impersonação ou alteração de MFA/grants. Credencial ambiental, conta InvesteMoz e conta Gitea são independentes; para o trabalho corrente no portal, usar nala@tempo.co.mz. Não voltar a pedir autenticação nesta passagem.
Os seis directórios novos da instalação foram alinhados com o proprietário do repositório (mario:www-data, 0755), comprovadamente ausentes no arquivo fresco; permissões dos directórios existentes intactas. Sem alteração do pacote ou revisão. A sessão SSH encerrou pelo limite anterior; não foi reaberta apenas para actualizar o diário operacional remoto. Recibo final e provas actualizados localmente e na mesma Page. Revisão efectivamente servida continua 423bd512d8c5. Git automático, oito lacunas contratuais, dossier legítimo, geometrias e P7 separados.
## Consulta pública sem autenticação ambiental — 20/10/2026
Decisão expressa da Tempo: retirar a autenticação ambiental das páginas públicas, preservando login, permissões e MFA das operações privadas e a protecção própria de /deploy. Esta decisão substitui a exigência de credencial ambiental para consultar os conteúdos públicos nesta homologação; não publica novos conteúdos nem altera contas ou funções.
Executado apenas o delta de configuração: removida a reserva Basic global do vhost; Basic mantido explicitamente na localização exacta /deploy com o mesmo htpasswd, e HMAC/Secret/cabeçalho Gitea preservados. Mantidos noindex/nofollow/noarchive e private/no-store no Nginx. INVESTMOZ_HOMOLOGATION_ONLY=false desactiva a reserva global do portal e a indicação desactualizada de ambiente reservado. O modo anterior permanece configurado mas inactivo para eventual reversão; marcador FastCGI de acesso ambiental retirado. Nenhuma alteração de código/build: revisão servida 423bd512d8c5, manifesto SHA256 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. Apenas reload Nginx após nginx -t; sem manutenção, migrations, imports, publicação ou reinício de base/fila/scanner.
Backup fresco apenas da configuração alterada, com hashes revalidados e metadados Linux, cifrado sob a custódia e envelope portátil existentes. Valores secretos não constam dos recibos. Cópias anteriores root-only no destino. Reversão: restaurar primeiro o vhost anterior condicionado aos hashes, validar/recarregar para repor a barreira ambiental; depois repor a flag anterior com conferência de conflitos/cache. Preservar edições posteriores e dados; não restaurar base antiga nem build.
16 verificações HTTPS passaram com CookieJar novo e sem Authorization na consulta: início, pesquisa /oportunidades?q=agronegocio (termo recuperado), documentos, ficha pública /documentos/13, artigo /agronegocio, sobre e contacto respondem 200, com componentes públicos e utilizador do portal ausente. Seis áreas privadas (/management/portal-services, /management/profile-install, /minhas-oportunidades, /investimentos, /investidor/manifestacoes e /balcao-virtual/painel) redireccionam para o login do portal sem desafio ambiental. /deploy continua 401 sem Basic e 403 com Basic válido mas HMAC inválido em JSON válido; serviço de deploy não disparado. Uma primeira sondagem com corpo vazio produziu 400 (JSON inválido); corrigida somente a sondagem negativa, sem alterar a aplicação ou repetir as restantes provas aceites.
A ausência do bloqueio é distinta de apenas esconder uma caixa do navegador: os componentes públicos efectivamente renderizam sem formulário de login e a pesquisa conserva o termo. Login num formulário InvesteMoz continua correcto nas operações privadas; se surgir na consulta pública, é falha a registar. Conferência visual em janela InPrivate nova solicitada ao utilizador, ainda pendente neste registo. Não declarar concluída essa parte pela prova HTTP isolada.
Recibo: RECIBO-CONFIGURACAO-PUBLICA-20261020.json; prova: PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json, na pasta da mesma proposta. Oito lacunas contratuais, conta simples específica, dossier legítimo, geometrias, P7 e disponibilização automática por Git continuam separados. Não houve novas contas, concessões, desactivação de MFA ou publicação adicional.
## Fecho da consulta pública sem credencial — 20/10/2026
O utilizador confirmou em janela Edge InPrivate nova: início, pesquisa /oportunidades?q=agronegocio e documentos abrem sem qualquer pedido de credenciais («ja funciona nestes 3»). Confirmação visual manual, distinta da prova automática HTTPS. A prova automática foi alargada apenas aos três consumidores adicionais afectados — zonas, sector Turismo e legislação — e passou em 19 verificações totais. Não repetidos scans, restauros, importações ou percursos anteriores.
Este delta de configuração fica concluído. Consulta pública não requer credencial ambiental nem conta InvesteMoz; seis operações privadas verificadas continuam a pedir login do portal, com permissões/MFA inalterados. /deploy conserva Basic + HMAC, com recusas 401/403 comprovadas e sem disparar o serviço. Prova de limites confirma que apenas /deploy contém Basic, o antigo include global e marcador FastCGI foram removidos, outros valores .env e proprietário/permissões estão intactos, webhook inalterado. Revisão de código/build continua 423bd512d8c5. Documentação anterior que exige credencial para a consulta pública fica superada por esta decisão expressa da Tempo.
Recibo e prova finais: RECIBO-CONFIGURACAO-PUBLICA-20261020.json e PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json. Sem pendência neste recorte. Conta simples específica, oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git mantêm os saldos separados anteriores. Não se declara a aplicação inteira concluída por este ajuste.
## Git main automático — 20/10/2026 — 8cf820ef
Disponibilização automática activa em main; revisão servida 8cf820ef497218d9adebe6a6a022b4c80d694dc3. Push real recebido pelo webhook às 17:28:44 e instalado às 17:29:56 (Maputo), sem despacho manual. Preservadas composição 423bd512d8c5 e alterações dos programadores até 02d4e6b2; conflito do menu resolvido mantendo layout novo, identidade InvesteMoz e administração delimitada. Build, TypeScript, 47 testes PHP, 156 frontend e 3106 asserções de schema passaram; nove testes Linux do executor e ensaio do webhook também passaram. Demonstração visual: Consultar oportunidades. 171 tabelas e 88 migrations permanecem; zero migrations no destino, importações, publicação ou novos grants. .env com bytes/proprietário/modo intactos; armazenamento partilhado e 869 assets anteriores preservados. Consulta pública 200 sem credenciais, gestão 302 para login e /deploy 401 sem Basic. Fila serial, recusa de drift e de alterações de migrations, releases separadas, logs/recibos protegidos e reversão de código/build com dados posteriores conservados. Oito lacunas contratuais, dossier legítimo, geometrias, conta simples específica e P7 mantêm saldo separado.
Procedimento dos programadores: trabalhar numa branch, rever segundo as designações da Tempo e integrar em `main`. O webhook existente dispara a verificação e compilação da revisão completa. Confirmar o commit em https://teste-investidor.tempo.co.mz/build/revision.json. Uma falha conserva a versão servida e deixa recibo/log protegido; não editar a pasta servida para a contornar.
O executor genérico com migrations automáticas foi substituído. O contrato nominal de 88 fontes de migrations fica protegido fora do Git; uma alteração exige compatibilidade/decisão específica. A CI usa PostGIS oficial fixado por digest, apenas em loopback 55441, base descartável `*_test` e identidade conferida. Não usa contas ou dados da aplicação. A guarda nominal e os testes de lifecycle existentes são preservados; não se contornam as instalações autenticadas.
Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/deploy/investmoz-pipeline.py --config /etc/investmoz-deploy/delivery.json --rollback` como operador existente. Conferem-se hashes e saúde; a release anterior fica servida e o worker recebe reinício gracioso. Mantêm-se base, uploads, configuração e dados posteriores. Não é restauro integral. O checkout físico anterior e a cópia cifrada de configuração/metadados Linux permanecem disponíveis; a recuperação portátil existente foi reutilizada. Não foi repetido o restauro integral aceite.
As correcções do ensaio limitaram-se à composição: diagnóstico mais recente dos programadores, teste de exemplo sem migração inútil, fixtures unitárias independentes das flags do destino, expectativas dos menus/relatórios já aceites e guarda do cookie fora do navegador, com prova de CSRF preservado. Nenhuma nova regra organizacional.
Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN.json; PROVA-ENSAIO-COMPLETO.json; PROVA-CONTROLO-FINAL-LINUX.json; PROVA-GITEA-EVENTO-E-INSTALACAO.json; PROVA-VISUAL-GIT-MAIN.png; deploy/homologacao/README.md; /build/revision.json. Manual versionado: `deploy/homologacao/README.md`. Esta entrega encerra o fluxo automático, não os restantes componentes da aplicação. As alterações documentais anteriores no checkout local foram preservadas.
## Pesquisa transversal QA — 20/10/2026
Inventário finito AUD-SEARCH-T01–T11 na mesma matriz/CSV e comparação no manual. Correcções manuais na branch `codex/opportunity-search`, sem integração/remoto/publicação. Assistência NLP ainda não implementada: interpretador e prévia/confirmar da mesma query manual, 6–10 h para recorte local delimitado; modelo externo sujeito a decisão. As oito lacunas contratuais e P7 mantêm-se separados. Revisão Tempo pendente; as provas comuns não encerram requisitos de gestão ou publicação não alterados.
## Guias educativos relacionados — delta QA após 0e7d51f18fb4
Verificação complementar concluída: código funcional `3258a25f7659` ligado ao acervo QA existente `legacy_trio_20261015_test`, sem importação. Página normal `/sectores/turismo` apresenta imagem local `/documentos/25/view` (HTTP 200; 900 px naturais) e resumo; «Ler o guia» abre `/turismo`, ligado ao exemplar real `/documentos/10`. A limitação de integração com biblioteca indicada na primeira prévia fica superada por esta prova. Mantém-se a prova anterior do dossier, pois código/contexto não mudaram. Falta apenas revisão visual da Tempo antes de integrar. IA fora do lote.
AUD-GUIDE-CARD-20261020: componente comum com imagem autorizada, título, resumo do artigo publicado, autoria e «Ler o guia». Reutilizado em sectores, exploração editorial e leituras relacionadas; recomendado no catálogo por sector explícito e nas fichas de oportunidades. Sem associação territorial inferida. Botão secundário apenas com oportunidades públicas. QA: teste isolado 1/8, TypeScript/build e exemplos computador/móvel. Falta decisão sobre integração deste novo delta; a prévia visual de Turismo usa uma cópia por leitura do conteúdo público, sem importação. Recuperação documental num dossier com este acervo não demonstrada, pois o artigo não existe na base QA actual. Percurso e regras anteriores preservados; IA fora do lote.
## Integrated simulator review - QA 20/10/2026
AUD-SIM-INT01 through AUD-SIM-INT08 are recorded in the same matrix and manual. Initial diagnosis and targeted date/Portuguese corrections only; full review remains open. Initial effort 18-30 h, subject to discounting accepted evidence and completed work. No main integration, real-data modification or tax parameter activation.
Actualização QA de 20/10/2026: os mesmos oito itens foram reconciliados no manual e nas duas tabelas existentes. Data com mensagem junto ao campo; inventário dos parâmetros efectivos (zero regras fiscais QA); controlos matemáticos independentes; aviso de recálculo; quatro relatórios reais do exportador com 6/6/6/5 páginas; consulta fiscal com erro/nova tentativa e traduções comuns verificados. O formulário personalizado já não exige contexto fiscal que não envia ao cálculo. Restam continuidade autenticada entre dois dossiers/nova sessão e histórico, mudança de parâmetros, controlos delimitados de cash-flow/plano, impressão nativa Edge/comparação pela interface e mensagens dinâmicas/falhas dirigidas de gravação/exportação. Estimativa restante **6–10 h**, distinta das 18–30 h iniciais. Sem main, servidor remoto, importação ou activação fiscal. Exemplos PDF são renderizações do DOCX; não certificam impressão do Edge. Ver o saldo detalhado AUD-SIM-INT01–08 no mesmo manual.
Continuação delimitada: dois dossiers e nova entrada conferidos no navegador; histórico agora visível no percurso de pequeno negócio, hashes preservados; Abrir cálculo encaminha para a etapa financeira; repetição de gravação após 503 corrigida e conferida; controlos independentes de tesouraria/plano passaram. CSV e Word descarregados pela interface coincidem nos valores comuns e apresentam a receita completa do crédito. Seis páginas do DOCX conferidas; PDF nativo ainda aguarda gravação pelo utilizador. QA tem zero regras com visibilidade completa, mas existem três originais fiscais versionados e análises/referências reutilizáveis; não se concluiu ausência na homologação sem autenticação SSH. Versões sintéticas testadas apenas em memória, sem cadastro/activação. Encontrada e corrigida perda dos pressupostos da tesouraria: snapshot no JSON existente, recuperação por dossier e relatório, sem migration; testes HTTP/frontend passaram, falta conferência visual deste novo delta. Saldo **2–4 h** após acessos/ficheiro, detalhado no mesmo manual; integração do motor fiscal permanece separada e não foi implementada. Sem main ou intervenção remota.
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"The provided two factor authentication code was invalid.": "O código de autenticação está incorrecto ou expirou. Introduza um novo código da sua aplicação autenticadora.",
"The provided two factor recovery code was invalid.": "O código de recuperação está incorrecto ou já foi utilizado.",
"The provided password is incorrect.": "A palavra-passe está incorrecta.",
"The provided password does not match your current password.": "A palavra-passe indicada não corresponde à palavra-passe actual."
}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'failed' => 'O email ou a palavra-passe não correspondem aos nossos registos.',
'password' => 'A palavra-passe está incorrecta.',
'throttle' => 'Foram feitas demasiadas tentativas. Tente novamente dentro de :seconds segundos.',
];
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'required' => 'O campo :attribute é obrigatório.',
'required_if' => 'O campo :attribute é obrigatório quando :other tem o valor :value.',
'present' => 'O campo :attribute deve estar presente.',
'filled' => 'Preencha o campo :attribute.',
'numeric' => 'O campo :attribute deve conter um número.',
'integer' => 'O campo :attribute deve conter um número inteiro.',
'decimal' => 'O campo :attribute deve ter :decimal casas decimais.',
'string' => 'O campo :attribute deve conter texto.',
'array' => 'O campo :attribute deve conter uma lista.',
'boolean' => 'O campo :attribute deve indicar sim ou não.',
'email' => 'Introduza um endereço de correio electrónico válido em :attribute.',
'uuid' => 'A referência indicada em :attribute não é válida.',
'in' => 'Seleccione uma opção válida em :attribute.',
'exists' => 'A referência seleccionada em :attribute não foi encontrada.',
'unique' => 'O valor indicado em :attribute já está registado.',
'confirmed' => 'A confirmação de :attribute não corresponde ao valor introduzido.',
'current_password' => 'A palavra-passe actual está incorrecta.',
'date' => 'Introduza uma data válida em :attribute.',
'date_format' => 'Introduza uma data válida para :attribute.',
'before_or_equal' => 'A data em :attribute deve ser anterior ou igual a :date.',
'after_or_equal' => 'A data em :attribute deve ser posterior ou igual a :date.',
'accepted' => 'Confirme o campo :attribute para continuar.',
'between' => ['numeric' => 'O valor de :attribute deve estar entre :min e :max.', 'string' => 'O campo :attribute deve ter entre :min e :max caracteres.', 'array' => 'O campo :attribute deve ter entre :min e :max elementos.', 'file' => 'O ficheiro :attribute deve ter entre :min e :max KB.'],
'min' => ['numeric' => 'O valor de :attribute deve ser igual ou superior a :min.', 'string' => 'O campo :attribute deve ter pelo menos :min caracteres.', 'array' => 'O campo :attribute deve ter pelo menos :min elementos.', 'file' => 'O ficheiro :attribute deve ter pelo menos :min KB.'],
'max' => ['numeric' => 'O valor de :attribute não pode exceder :max.', 'string' => 'O campo :attribute não pode exceder :max caracteres.', 'array' => 'O campo :attribute não pode exceder :max elementos.', 'file' => 'O ficheiro :attribute não pode exceder :max KB.'],
'attributes' => ['base' => 'base de cálculo', 'rate' => 'taxa', 'reduction' => 'redução', 'email' => 'email', 'password' => 'palavra-passe', 'current_password' => 'palavra-passe actual', 'economic_snapshot.fiscal_reference_date' => 'data da operação fiscal'],
];
+3 -3
View File
@@ -12,9 +12,9 @@ export default function AppearanceToggleTab({
const { appearance, updateAppearance } = useAppearance();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
{ value: 'light', icon: Sun, label: 'Claro' },
{ value: 'dark', icon: Moon, label: 'Escuro' },
{ value: 'system', icon: Monitor, label: 'Sistema' },
];
return (
@@ -0,0 +1,140 @@
import { Link, usePage } from '@inertiajs/react';
const labels: Record<string, string> = {
q: 'Pesquisa',
query: 'Pesquisa',
sector: 'Sector',
kind: 'Tipo de ficha',
modality: 'Modalidade',
category: 'Categoria',
physical_category: 'Tipo de espaço',
legal_regime: 'Regime',
development_status: 'Desenvolvimento',
availability_status: 'Disponibilidade',
province_id: 'Província',
district_id: 'Distrito',
admin_post_id: 'Posto administrativo',
locality_id: 'Localidade',
geo_location_id: 'Localização',
territory_id: 'Território',
province: 'Província',
activity: 'Actividade',
document_type: 'Tipo de documento',
topic: 'Tema',
issuer: 'Entidade emissora',
language: 'Idioma',
legal_status: 'Situação documental',
source_confirmation: 'Fonte',
stage: 'Etapa',
module: 'Área',
association_type: 'Associação',
association_target: 'Referência',
association_purpose: 'Relação',
association_state: 'Confirmação',
chain_id: 'Cadeia de valor',
subsector_id: 'Actividade',
authority_id: 'Entidade',
};
const territoryKeys = [
'province_id',
'district_id',
'admin_post_id',
'locality_id',
];
const values: Record<string, string> = {
corridor: 'Corredores',
cluster: 'Clusters',
project: 'Projectos âncora',
zee: 'ZEE',
zfi: 'ZFI',
industrial_park: 'Parque industrial',
investment_project: 'Projecto de investimento',
company_sale: 'Venda de empresa',
company_purchase: 'Compra de empresa',
real_estate: 'Imobiliário',
land_right: 'Direitos fundiários',
turismo: 'Turismo',
agronegocio: 'Agronegócio',
fintech: 'Fintech — especialização do Mercado Financeiro',
'mercado-financeiro': 'Mercado Financeiro',
legislation: 'Legislação',
procedure: 'Procedimento oficial',
official_form: 'Formulário oficial',
guide: 'Guia',
study: 'Estudo',
unknown: 'Por confirmar',
pt: 'Português',
en: 'Inglês',
confirmed: 'Confirmada',
pending: 'Por confirmar',
};
/** Name each active criterion without exposing IDs or taxonomy codes. */
export default function CatalogueFilterContext() {
const page = usePage();
const filters = page.props.filters as Record<string, unknown> | undefined;
if (!filters) return null;
const active = Object.entries(filters).filter(
([key, value]) =>
!['page', 'per_page', 'sort', 'view', 'publication_state'].includes(
key,
) &&
value !== '' &&
value != null &&
value !== false &&
(!Array.isArray(value) || value.length),
);
if (!active.length) return null;
const describe = (key: string, value: unknown): string => {
const path = page.props.territorySelection as
| { id: string; name: string }[]
| undefined;
const place = path?.find((item) => item.id === value)?.name;
if (place) return place;
if (typeof value !== 'string') return 'seleccionado';
return (
values[value] ??
(['q', 'query', 'province', 'topic', 'issuer'].includes(key)
? value
: 'seleccionado')
);
};
const href = (remove?: string) => {
const url = new URL(page.url, 'https://portal.invalid');
const keys = remove
? [
remove,
...territoryKeys
.slice(territoryKeys.indexOf(remove) + 1)
.filter(() => territoryKeys.includes(remove)),
]
: Object.keys(filters);
for (const key of keys) {
url.searchParams.delete(key);
url.searchParams.delete(`${key}[]`);
}
url.searchParams.delete('page');
return url.pathname + url.search;
};
return (
<nav
aria-label="Filtros activos"
className="my-3 flex flex-wrap items-center gap-3"
>
<span className="font-semibold">Filtros activos:</span>
{active.map(([key, value]) => (
<Link
key={key}
href={href(key)}
className="rounded border px-3 py-2 underline"
aria-label={`Remover ${labels[key] ?? 'critério'}`}
>
{labels[key] ?? 'Critério seleccionado'}:{' '}
{describe(key, value)} ×
</Link>
))}
<Link href={href()} className="underline">
Limpar filtros
</Link>
</nav>
);
}
@@ -73,34 +73,32 @@ export default function CatalogueNavigation({
],
};
return (
<nav
className="my-4 grid gap-3 rounded-xl border bg-white p-4 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Explorar dentro do catálogo"
>
{Object.entries(sections).map(([title, items]) => (
<section key={title}>
<h2 className="mb-2 font-semibold">{title}</h2>
<ul className="space-y-2">
{items.map(([label, href]) => (
<li key={label}>
<Link
className="underline"
href={withContext(href)}
>
{label}
</Link>
</li>
))}
</ul>
</section>
))}
<p className="text-sm sm:col-span-2 lg:col-span-4">
Filtros e vistas reutilizam os catálogos existentes.
Comprar/vender continua no consumidor /negocios; não duplica
anúncios nem unifica contagens. Selecionar um parque ou projeto
específico usa os filtros existentes; a ficha relacionada não é
uma oportunidade.
</p>
</nav>
<details className="my-4 rounded-xl border bg-white p-4">
<summary className="cursor-pointer font-semibold">
Catálogos relacionados
</summary>
<nav
className="my-4 grid gap-3 rounded-xl border bg-white p-4 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Explorar dentro do catálogo"
>
{Object.entries(sections).map(([title, items]) => (
<section key={title}>
<h2 className="mb-2 font-semibold">{title}</h2>
<ul className="space-y-2">
{items.map(([label, href]) => (
<li key={label}>
<Link
className="underline"
href={withContext(href)}
>
{label}
</Link>
</li>
))}
</ul>
</section>
))}
</nav>
</details>
);
}
+8 -1
View File
@@ -50,7 +50,14 @@ export default function CountrySelect({
onChange={(event) => setSearch(event.target.value)}
placeholder="Pesquisar nome ou código do país"
/>
{search.trim() && <p role="status" aria-live="polite">{matches.length ? `${matches.length} resultados encontrados` : "Não foram encontrados resultados para esta pesquisa"}</p>}
{search.trim() && (
<p role="status" aria-live="polite">
Países:{' '}
{matches.length
? `${matches.length} opções disponíveis`
: 'Nenhuma opção encontrada'}
</p>
)}
<select
disabled={disabled}
id={id ?? generatedId}
+20 -13
View File
@@ -23,14 +23,16 @@ export default function DeleteUser() {
<div className="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
title="Eliminar conta"
description="Eliminar a sua conta e os recursos associados"
/>
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p>
<p className="text-sm">
Please proceed with caution, this cannot be undone.
{' '}
Esta operação é irreversível. Confirme os dados antes de
continuar.{' '}
</p>
</div>
@@ -40,18 +42,20 @@ export default function DeleteUser() {
variant="destructive"
data-test="delete-user-button"
>
Delete account
{' '}
Eliminar conta{' '}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
Are you sure you want to delete your account?
{' '}
Pretende eliminar a sua conta?{' '}
</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources
and data will also be permanently deleted. Please
enter your password to confirm you would like to
permanently delete your account.
{' '}
Ao eliminar a conta, os recursos e dados associados
também serão eliminados permanentemente. Introduza a
sua palavra-passe para confirmar.{' '}
</DialogDescription>
<Form
@@ -70,14 +74,15 @@ export default function DeleteUser() {
htmlFor="password"
className="sr-only"
>
Password
{' '}
Palavra-passe{' '}
</Label>
<PasswordInput
id="password"
name="password"
ref={passwordInput}
placeholder="Password"
placeholder="Palavra-passe"
autoComplete="current-password"
/>
@@ -92,7 +97,8 @@ export default function DeleteUser() {
resetAndClearErrors()
}
>
Cancel
{' '}
Cancelar{' '}
</Button>
</DialogClose>
@@ -105,7 +111,8 @@ export default function DeleteUser() {
type="submit"
data-test="confirm-delete-user-button"
>
Delete account
{' '}
Eliminar conta{' '}
</button>
</Button>
</DialogFooter>
@@ -0,0 +1,73 @@
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
title: string;
href: string;
summary: string;
author?: string;
image: { href: string; alt: string } | null;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
};
export default function EducationalArticleCard({
article,
}: {
article: EducationalArticle;
}) {
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
return (
<article className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm">
{article.image && (
<img
className="aspect-[16/9] w-full object-cover"
src={article.image.href}
alt={article.image.alt}
loading="lazy"
width={800}
height={450}
/>
)}
<div className="flex flex-1 flex-col items-start gap-4 p-5 sm:p-6">
<p className="text-sm font-medium text-emerald-800">
Guia sectorial
</p>
<h3 className="text-2xl leading-tight font-bold">
<Link href={article.href} className={focus}>
{article.title}
</Link>
</h3>
{article.summary && (
<p className="text-base leading-relaxed text-slate-700">
{article.summary}
</p>
)}
{article.author && (
<p className="text-sm text-slate-600">
Por {article.author}
</p>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-5 gap-y-3 pt-2">
<Link
href={article.href}
className={`inline-flex min-h-11 items-center rounded-lg bg-emerald-900 px-5 py-3 font-semibold text-white hover:bg-emerald-800 ${focus}`}
>
Ler o guia
</Link>
{article.has_opportunities &&
article.opportunities_href && (
<Link
href={article.opportunities_href}
className={`inline-flex min-h-11 items-center py-2 font-semibold text-emerald-900 underline underline-offset-4 ${focus}`}
>
{article.opportunities_label ??
'Ver oportunidades'}
</Link>
)}
</div>
</div>
</article>
);
}
@@ -0,0 +1,27 @@
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export default function EducationalGuides({
articles,
}: {
articles: EducationalArticle[];
}) {
if (!articles.length) return null;
return (
<section
aria-label="Guias para preparar o investimento"
className="space-y-5"
>
<h2 className="text-2xl font-bold">Descubra onde investir</h2>
<div className="grid gap-6 md:grid-cols-2">
{articles.map((article) => (
<EducationalArticleCard
key={article.href}
article={article}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,24 @@
import { fiscalQaRows } from "@/lib/fiscal-qa-preview";
export default function FiscalQaPreview({ snapshot }: { snapshot: unknown }) {
const rows = fiscalQaRows(snapshot);
if (rows.length === 0) return null;
return (
<section
aria-label="Resultado fiscal preservado"
className="my-3 rounded border border-amber-700 bg-amber-50 p-3"
>
<h5 className="font-semibold">
{rows[0][1]} — cálculo exacto sem arredondamento
</h5>
<dl className="mt-2 space-y-2 text-sm">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="font-medium">{label}</dt>
<dd className="break-all">{value}</dd>
</div>
))}
</dl>
</section>
);
}
@@ -24,7 +24,9 @@ export default function InvestmentMapPanel({
useEffect(() => {
const controller = new AbortController();
const params = new URLSearchParams(JSON.parse(key));
setPoints(null); setError(''); setMissing([]);
setPoints(null);
setError('');
setMissing([]);
setAreas([]);
fetch(`/api/v1/investment/map?${params}`, {
signal: controller.signal,
@@ -46,13 +48,21 @@ export default function InvestmentMapPanel({
);
})
.catch(() => {
if (!controller.signal.aborted) setError('Não foi possível concluir a pesquisa.');
if (!controller.signal.aborted)
setError('Não foi possível concluir a pesquisa.');
});
return () => controller.abort();
}, [key, retry]);
return (
<section className="space-y-3">
<SearchState loading={points === null && !error} error={error} total={(points?.length ?? 0) + missing.length} retry={() => setRetry(value => value + 1)} />
<SearchState
scope="Registos consultados pelo mapa"
countLabel="registos, incluindo os que não têm coordenadas"
loading={points === null && !error}
error={error}
total={(points?.length ?? 0) + missing.length}
retry={() => setRetry((value) => value + 1)}
/>
<InteractiveInvestmentMap points={points} areas={areas} />
<TerritoryGeometryPreview
territoryId={
@@ -113,9 +113,7 @@ export function ProvinceSelect({
})
.catch(() => {
if (!controller.signal.aborted)
setError(
'Não foi possível concluir a pesquisa.',
);
setError('Não foi possível concluir a pesquisa.');
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
@@ -130,7 +128,13 @@ export function ProvinceSelect({
return (
<>
<SearchState loading={loading} error={error} total={options.length} retry={() => setRetry(value => value + 1)} />
<SearchState
scope="Localizações"
loading={loading}
error={error}
total={options.length}
retry={() => setRetry((value) => value + 1)}
/>
<input
className={className}
aria-label={`Pesquisar ${label}`}
@@ -300,9 +304,7 @@ export function TerritoryLevel({
setMore(Boolean(data.next_page_url));
} catch {
if (!controller.signal.aborted)
setError(
'Não foi possível concluir a pesquisa.',
);
setError('Não foi possível concluir a pesquisa.');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -314,7 +316,15 @@ export function TerritoryLevel({
}, [index, parent, page, enabled, endpoint, retry, search]);
return (
<div className="space-y-2">
{enabled && <SearchState loading={loading} error={error} total={options.length} retry={() => setRetry(value => value + 1)} />}
{enabled && (
<SearchState
scope={labels[index]}
loading={loading}
error={error}
total={options.length}
retry={() => setRetry((value) => value + 1)}
/>
)}
<label htmlFor={id} className="font-medium">
{labels[index]}
</label>
+5 -4
View File
@@ -17,9 +17,10 @@ const EmptyState = () => {
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl">
<KeyRound className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">No passkeys yet</p>
<p className="font-medium"> Ainda não existem chaves de acesso </p>
<p className="text-muted-foreground mt-1 text-sm">
Add a passkey to sign in without a password
{' '}
Adicione uma chave de acesso para entrar sem palavra-passe{' '}
</p>
</div>
);
@@ -47,8 +48,8 @@ export default function ManagePasskeys(props: Props) {
<div className="space-y-6">
<Heading
variant="small"
title="Passkeys"
description="Manage your passkeys for passwordless sign-in"
title="Chaves de acesso"
description="Gerir as chaves de acesso para entrar sem palavra-passe"
/>
<div className="border-border overflow-hidden rounded-lg border">
+16 -13
View File
@@ -48,15 +48,15 @@ export default function ManageTwoFactor(props: Props) {
<div className="space-y-6">
<Heading
variant="small"
title="Two-factor authentication"
description="Manage your two-factor authentication settings"
title="Autenticação de dois factores"
description="Gerir a autenticação de dois factores"
/>
{twoFactorEnabled ? (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-muted-foreground text-sm">
You will be prompted for a secure, random pin during
login, which you can retrieve from the TOTP-supported
application on your phone.
{' '}
Ao entrar, será solicitado o código temporário da
aplicação autenticadora do seu telefone.{' '}
</p>
<div className="relative inline">
@@ -67,7 +67,9 @@ export default function ManageTwoFactor(props: Props) {
type="submit"
disabled={processing}
>
Disable 2FA
{' '}
Desactivar autenticação de dois
factores{' '}
</Button>
)}
</Form>
@@ -82,17 +84,16 @@ export default function ManageTwoFactor(props: Props) {
) : (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-muted-foreground text-sm">
When you enable two-factor authentication, you will be
prompted for a secure pin during login. This pin can be
retrieved from a TOTP-supported application on your
phone.
{' '}
Depois de activar a autenticação de dois factores, use o
código temporário da aplicação autenticadora para
entrar.{' '}
</p>
<div>
{hasSetupData ? (
<Button onClick={() => setShowSetupModal(true)}>
<ShieldCheck />
Continue setup
<ShieldCheck /> Continuar configuração{' '}
</Button>
) : (
<Form
@@ -101,7 +102,9 @@ export default function ManageTwoFactor(props: Props) {
>
{({ processing }) => (
<Button type="submit" disabled={processing}>
Enable 2FA
{' '}
Activar autenticação de dois
factores{' '}
</Button>
)}
</Form>
@@ -831,7 +831,11 @@ export function ClassificationSummary({
<h2 className="text-xl font-bold">
Associações territoriais e produtivas
</h2>
<p>{help}</p>
<p>
As relações preliminares permanecem por confirmar. Uma
associação não comprova disponibilidade, incentivos ou direitos
concedidos.
</p>
{Object.entries({
territory: 'Enquadramento territorial',
production: 'Organização produtiva',
@@ -867,10 +871,7 @@ export function ClassificationSummary({
</p>
))
) : (
<p>
Sem informação tipada publicada nesta dimensão;
consulte também as relações anteriores abaixo.
</p>
<p>Sem associações publicadas neste grupo.</p>
)}
</section>
))}
@@ -0,0 +1,480 @@
import { Field, input, button, groupLabels } from '@/pages/investment/shared';
import type { References } from '@/pages/investment/shared';
import { TerritoryLevel, type TerritoryOption } from './investment-territory';
export const modalityLabels: Record<string, string> = {
investment_project: 'Projecto de investimento',
real_estate: 'Imobiliário',
land_rights: 'DUAT e direitos fundiários',
business_sale: 'Venda de negócio',
business_partnership: 'Parceria empresarial',
franchise: 'Franquia',
concession: 'Concessão',
financing: 'Financiamento',
supply: 'Fornecimento',
other: 'Outra modalidade',
};
type Values = Record<string, string | boolean | number[]>;
const labels: Record<string, string> = {
q: 'O que procura',
sector: 'Sector',
category: 'Modalidade da oportunidade',
preparation: 'Estado de preparação',
sort: 'Ordenar',
min_amount: 'Investimento mínimo',
max_amount: 'Investimento máximo',
currency: 'Moeda',
before: 'Data-alvo até',
cluster_id: 'Cluster',
corridor_id: 'Corredor',
project_id: 'Projecto âncora',
zee_id: 'ZEE',
zfi_id: 'ZFI',
park_id: 'Parque industrial',
chain_id: 'Cadeia de valor',
subsector_id: 'Actividade',
include_national: 'Incluir âmbito nacional',
education: 'Educação',
province: 'Província indicada',
district: 'Distrito indicado',
dimension: 'Grupo de oportunidades',
territory_basis: 'Localização considerada',
geo_location_id: 'Localização adicional',
maturity_level: 'Classificação histórica',
association_type: 'Associação herdada',
association_target: 'Referência herdada',
association_purpose: 'Relação indicada',
association_state: 'Critério herdado',
authority_id: 'Entidade',
publication_state: 'Critério herdado',
};
export default function OpportunitySearchFilters({
data,
references,
path,
change,
territory,
submit,
clear,
errors,
}: {
data: Values;
references: References;
path: TerritoryOption[];
change: (values: Values) => void;
territory: (path: TerritoryOption[]) => void;
submit: () => void;
clear: () => void;
errors: Record<string, string>;
}) {
const select = (
key: string,
choices: { value: string; label: string }[],
hint?: string,
) => (
<Field key={key} label={labels[key]}>
<select
className={input}
value={String(data[key] ?? '')}
onChange={(e) =>
change({
[key]: e.target.value,
...(key === 'sector'
? { subsector_id: '', chain_id: '' }
: {}),
})
}
>
<option value="">Todos</option>
{choices.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{hint && <p className="text-sm text-slate-600">{hint}</p>}
</Field>
);
const zoneChoices = (category: string) =>
(references.zones ?? [])
.filter((z) => z.category === category)
.map((z) => ({ value: z.id, label: z.name }));
const choicesFor = (key: string) => {
if (key === 'sector')
return references.sectors.map((s) => ({
value: s.slug,
label:
s.slug === 'fintech'
? 'Fintech — especialização do Mercado Financeiro'
: s.name,
}));
if (key === 'category')
return (references.categories ?? []).map((c) => ({
value: c,
label: modalityLabels[c] ?? 'Outra modalidade',
}));
if (key === 'preparation') return references.preparationOptions ?? [];
if (key === 'dimension')
return [
{ value: 'cluster', label: 'Clusters' },
{ value: 'corridor', label: 'Corredores' },
{ value: 'zee', label: 'ZEE' },
{ value: 'zfi', label: 'ZFI' },
];
if (['zee_id', 'zfi_id', 'park_id'].includes(key))
return zoneChoices(
(
{
zee_id: 'zee',
zfi_id: 'zfi',
park_id: 'industrial_park',
} as Record<string, string>
)[key],
);
const group = (
{
cluster_id: 'clusters',
corridor_id: 'corridors',
project_id: 'projects',
} as const
)[key as 'cluster_id'];
if (group)
return references[group].map((e) => ({
value: e.id,
label: e.name,
}));
if (key === 'chain_id' || key === 'subsector_id')
return (
references.classification?.targets[
key === 'chain_id' ? 'chain' : 'activity'
] ?? []
).map((e) => ({ value: String(e.id), label: e.label }));
return [];
};
const remove = (key: string) => {
if (key === 'sector')
change({ sector: '', subsector_id: '', chain_id: '' });
else if (key === 'facets') change({ facets: [] });
else change({ [key]: typeof data[key] === 'boolean' ? false : '' });
};
const active = Object.entries(data).filter(
([k, v]) =>
![
'view',
'sort',
'province_id',
'district_id',
'admin_post_id',
'locality_id',
].includes(k) &&
v !== '' &&
v !== false &&
v !== null &&
(!Array.isArray(v) || v.length > 0) &&
!(k === 'territory_basis' && v === 'all'),
);
return (
<form
className="space-y-5 rounded-xl border bg-white p-4 sm:p-6"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
aria-label="Pesquisar oportunidades"
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Field label="O que procura">
<input
className={input}
value={String(data.q)}
placeholder="Palavras do título ou da descrição"
onChange={(e) => change({ q: e.target.value })}
/>
</Field>
{select('sector', choicesFor('sector'))}
{select('category', choicesFor('category'))}
</div>
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<legend className="mb-2 font-semibold">Localização</legend>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) =>
territory([
...path.slice(0, index),
...(value ? [value] : []),
])
}
/>
))}
</fieldset>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="Estado de preparação">
<select
className={input}
value={String(data.preparation ?? '')}
onChange={(e) =>
change({
preparation: e.target.value,
maturity_level: '',
})
}
>
<option value="">Todos os estados</option>
{[
'Etapas de preparação',
'Classificações históricas',
].map((group) => (
<optgroup key={group} label={group}>
{(references.preparationOptions ?? [])
.filter((o) => o.group === group)
.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
))}
</select>
<p className="text-sm text-slate-600">
Os registos históricos são apresentados separadamente e
conservam a classificação indicada na ficha.
</p>
</Field>
{(['min_amount', 'max_amount'] as const).map((key) => (
<Field key={key} label={labels[key]}>
<input
className={input}
type="number"
min="0"
step="0.01"
value={String(data[key])}
onChange={(e) => change({ [key]: e.target.value })}
/>
</Field>
))}
{select(
'currency',
['MZN', 'USD', 'EUR', 'ZAR'].map((c) => ({
value: c,
label: c,
})),
'Seleccione uma moeda para comparar montantes. Não se efectuam conversões cambiais.',
)}
</div>
<details>
<summary className="cursor-pointer rounded border p-3 font-semibold focus-visible:outline-2">
Mais filtros
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[
'corridor_id',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'project_id',
'subsector_id',
'chain_id',
].map((key) => select(key, choicesFor(key)))}
<Field label="Data-alvo até">
<input
className={input}
type="date"
value={String(data.before)}
onChange={(e) => change({ before: e.target.value })}
/>
</Field>
</div>
<p className="my-3 text-sm">
Estes filtros procuram associações documentadas. Estar perto
de um parque ou no mesmo distrito não constitui associação,
nem confirma incentivos ou disponibilidade.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={Boolean(data.include_national)}
onChange={(e) =>
change({ include_national: e.target.checked })
}
/>
Incluir oportunidades de âmbito nacional
</label>
<details className="mt-3">
<summary className="cursor-pointer font-medium">
Outras características
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
'education',
'infrastructure',
'labour',
'qualification',
'support',
'impact',
'partnership',
]
.filter((group) =>
references.options.some(
(o) => o.group === group,
),
)
.map((group) => (
<fieldset
key={group}
className="rounded border p-3"
>
<legend>{groupLabels[group]}</legend>
{references.options
.filter((o) => o.group === group)
.map((option) => (
<label
key={option.id}
className="flex gap-2 py-1"
>
<input
type="checkbox"
checked={
Array.isArray(
data.facets,
) &&
data.facets.includes(
option.id,
)
}
onChange={(e) => {
const selected =
Array.isArray(
data.facets,
)
? data.facets
: [];
change({
facets: e.target
.checked
? [
...selected,
option.id,
]
: selected.filter(
(id) =>
id !==
option.id,
),
});
}}
/>
{option.label}
</label>
))}
</fieldset>
))}
</div>
</details>
</details>
<section
aria-label="Filtros activos"
className="flex flex-wrap gap-2"
>
{path.map((node, index) => (
<button
className="rounded border px-3 py-2"
type="button"
key={node.id}
aria-label={`Remover localização ${node.name}`}
onClick={() => territory(path.slice(0, index))}
>
{node.name} ×
</button>
))}
{active.flatMap(([key, value]) =>
key === 'facets' && Array.isArray(value)
? value.map((id) => (
<button
key={`facet-${id}`}
type="button"
className="rounded border px-3 py-2"
onClick={() =>
change({
facets: value.filter((x) => x !== id),
})
}
>
{' '}
{references.options.find((o) => o.id === id)
?.label ?? 'Critério adicional'}{' '}
×
</button>
))
: [
<button
key={key}
type="button"
className="rounded border px-3 py-2 text-left"
aria-label={`Remover ${labels[key] ?? 'critério adicional'}`}
onClick={() => remove(key)}
>
{labels[key] ?? 'Critério adicional'}:{' '}
{typeof value === 'boolean'
? 'Sim'
: (choicesFor(key).find(
(o) => o.value === String(value),
)?.label ??
([
'q',
'min_amount',
'max_amount',
'currency',
'before',
'province',
'district',
].includes(key)
? String(value)
: 'seleccionado'))}{' '}
×
</button>,
],
)}
</section>
{Object.entries(errors).map(([key, value]) => (
<p key={key} role="alert">
{value}
</p>
))}
<div className="flex flex-wrap items-end gap-4">
{select('sort', [
{ value: 'recent', label: 'Mais recentes' },
{ value: 'title', label: 'Título' },
{
value: 'amount_asc',
label: 'Montante crescente, por moeda',
},
{
value: 'amount_desc',
label: 'Montante decrescente, por moeda',
},
])}
<button className={button}>Pesquisar</button>
<button
type="button"
className="rounded border px-4 py-2 underline"
onClick={clear}
>
Limpar filtros
</button>
</div>
</form>
);
}
+9 -9
View File
@@ -43,13 +43,14 @@ export default function PasskeyItem({ passkey, onDelete }: Props) {
)}
</div>
<p className="text-muted-foreground text-sm">
Added {passkey.created_at_diff}
{' '}
Adicionada em {passkey.created_at_diff}
{passkey.last_used_at_diff && (
<>
<span className="text-muted-foreground/50 mx-1">
/
</span>
Last used {passkey.last_used_at_diff}
</span>{' '}
Última utilização {passkey.last_used_at_diff}
</>
)}
</p>
@@ -64,19 +65,18 @@ export default function PasskeyItem({ passkey, onDelete }: Props) {
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove</span>
<span className="sr-only"> Remover </span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Remove passkey</DialogTitle>
<DialogTitle>Remover chave de acesso</DialogTitle>
<DialogDescription>
Are you sure you want to remove the "{passkey.name}"
passkey? You will no longer be able to use it to sign
in.
Pretende remover a chave de acesso «{passkey.name}»?
Deixará de poder usá-la para entrar.
</DialogDescription>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="secondary"> Cancelar </Button>
</DialogClose>
<Button
variant="destructive"
+11 -7
View File
@@ -59,7 +59,8 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
if (!isSupported) {
return (
<div className="text-muted-foreground text-sm">
Passkeys are not supported in this browser.
{' '}
Este navegador não suporta chaves de acesso.{' '}
</div>
);
}
@@ -67,7 +68,8 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
if (!showForm) {
return (
<Button variant="outline" onClick={() => setShowForm(true)}>
Add passkey
{' '}
Adicionar chave de acesso{' '}
</Button>
);
}
@@ -78,18 +80,19 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
className="border-border bg-muted/50 space-y-4 rounded-lg border p-4"
>
<div className="grid gap-2">
<Label htmlFor="passkey-name">Passkey name</Label>
<Label htmlFor="passkey-name"> Nome da chave de acesso </Label>
<Input
id="passkey-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., MacBook Pro, iPhone"
placeholder="Por exemplo, MacBook Pro ou iPhone"
className="border-foreground/20 mt-1 block w-full"
autoFocus
/>
<p className="text-muted-foreground text-xs">
A name helps you identify this passkey later.
{' '}
O nome permite identificar esta chave mais tarde.{' '}
</p>
</div>
@@ -97,10 +100,11 @@ export default function PasskeyRegistration({ onSuccess }: Props) {
<div className="flex gap-2">
<Button type="submit" disabled={isLoading || !name.trim()}>
{isLoading ? 'Registering...' : 'Register passkey'}
{isLoading ? 'Registering...' : 'Registar chave de acesso'}
</Button>
<Button type="button" variant="ghost" onClick={handleCancel}>
Cancel
{' '}
Cancelar{' '}
</Button>
</div>
</form>
+3 -3
View File
@@ -51,8 +51,8 @@ export default function PasskeyVerify({
>
{isLoading ? <Spinner /> : <KeyRound className="h-4 w-4" />}
{isLoading
? (loadingLabel ?? 'Authenticating...')
: (label ?? 'Sign in with a passkey')}
? (loadingLabel ?? 'A autenticar…')
: (label ?? 'Entrar com uma chave de acesso')}
</Button>
{error && (
<InputError message={error} className="text-center" />
@@ -65,7 +65,7 @@ export default function PasskeyVerify({
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
{separator ?? 'Or continue with email'}
{separator ?? 'Ou continuar com email'}
</span>
</div>
</div>
+133 -100
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import {
DossierExportGate,
dossierExportFailure,
prepareDossierAttachments,
} from '@/lib/dossier-export-state';
import {
createProjectDossierPrintHtml,
createProjectDossierReport,
dossierReportSections,
generateProjectDossierDocx,
generateProjectDossierPackage,
validateDossierAttachments,
type DossierAttachment,
type DossierReference,
type DossierCurrentCalculation,
@@ -57,6 +61,8 @@ export function ProjectDossierExport({
const [attachments, setAttachments] = useState<DossierAttachment[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [notice, setNotice] = useState('');
const exportGate = useRef(new DossierExportGate());
const selected = useMemo(
() => attachments.filter((item) => item.include),
[attachments],
@@ -66,20 +72,33 @@ export function ProjectDossierExport({
[selectedSections],
);
function addFiles(files: FileList | null): void {
async function addFiles(files: FileList | null): Promise<void> {
if (!files?.length) return;
const incoming = Array.from(files);
const combined = [...attachments.map((item) => item.file), ...incoming];
const problem = validateDossierAttachments(combined);
if (problem) {
setError(problem);
return;
}
setAttachments((current) => [
...current,
...incoming.map((file) => ({ file, include: true, caption: '' })),
]);
setError('');
await exportGate.current.run(async () => {
setBusy('attachments');
setNotice('');
try {
const result = await prepareDossierAttachments(
attachments,
incoming,
);
if (result.problem) {
setError(result.problem);
return;
}
setAttachments(result.attachments);
setError('');
if (result.duplicateCount)
setNotice(
'Os ficheiros que já estavam na lista foram mantidos uma única vez.',
);
} catch (cause) {
setError(dossierExportFailure('attachments', cause));
} finally {
setBusy('');
}
});
}
function toggleSection(id: DossierReportSectionId, checked: boolean): void {
@@ -118,99 +137,103 @@ export function ProjectDossierExport({
}
async function exportWord(): Promise<void> {
setBusy('word');
setError('');
try {
const report = model();
const blob = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
download(
blob,
`relatorio-investmoz-${filenameStem(report.title)}.docx`,
);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível gerar o relatório Word.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
setBusy('word');
setError('');
try {
const report = model();
const blob = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
download(
blob,
`relatorio-investmoz-${filenameStem(report.title)}.docx`,
);
} catch (cause) {
setError(dossierExportFailure('word', cause));
} finally {
setBusy('');
}
});
}
async function exportPackage(): Promise<void> {
setBusy('package');
setError('');
try {
const report = model();
const docx = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
const archive = await generateProjectDossierPackage(
docx,
selected,
selectedSections.has('annexes'),
);
download(
archive,
`relatorio-investmoz-${filenameStem(report.title)}-anexos.zip`,
);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar o pacote de anexos.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
setBusy('package');
setError('');
try {
const report = model();
const docx = await generateProjectDossierDocx(
report,
selected,
selectedSections.has('annexes'),
);
const archive = await generateProjectDossierPackage(
docx,
selected,
selectedSections.has('annexes'),
);
download(
archive,
`relatorio-investmoz-${filenameStem(report.title)}-anexos.zip`,
);
} catch (cause) {
setError(dossierExportFailure('package', cause));
} finally {
setBusy('');
}
});
}
async function exportPdf(): Promise<void> {
const printWindow = window.open('about:blank', '_blank');
if (!printWindow) {
setError(
'O browser bloqueou a janela de impressão. Permita pop-ups para abrir a versão PDF.',
);
return;
}
setBusy('pdf');
setError('');
try {
const html = await createProjectDossierPrintHtml(
model(),
selected,
selectedSections.has('annexes'),
);
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
} catch (cause) {
printWindow.close();
setError(
cause instanceof Error
? cause.message
: 'Não foi possível preparar a versão para PDF.',
);
} finally {
setBusy('');
}
await exportGate.current.run(async () => {
const printWindow = window.open('about:blank', '_blank');
if (!printWindow) {
setError(
'O browser bloqueou a janela de impressão. Permita pop-ups para abrir a versão PDF.',
);
return;
}
setBusy('pdf');
setError('');
try {
const html = await createProjectDossierPrintHtml(
model(),
selected,
selectedSections.has('annexes'),
);
printWindow.document.open();
printWindow.document.write(html);
printWindow.document.close();
} catch (cause) {
printWindow.close();
setError(dossierExportFailure('pdf', cause));
} finally {
setBusy('');
}
});
}
function exportCsv(): void {
const blob = new Blob([exportProjectDossierCsv(draft)], {
type: 'text/csv;charset=utf-8',
async function exportCsv(): Promise<void> {
await exportGate.current.run(async () => {
setBusy('csv');
setError('');
try {
const blob = new Blob([exportProjectDossierCsv(draft)], {
type: 'text/csv;charset=utf-8',
});
download(
blob,
`dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`,
);
} catch (cause) {
setError(dossierExportFailure('csv', cause));
} finally {
setBusy('');
}
});
download(
blob,
`dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`,
);
}
const totalBytes = attachments.reduce(
@@ -261,6 +284,7 @@ export function ProjectDossierExport({
>
<input
type="checkbox"
disabled={Boolean(busy)}
checked={selectedSections.has(section.id)}
onChange={(event) =>
toggleSection(
@@ -298,9 +322,10 @@ export function ProjectDossierExport({
id="project-report-files"
type="file"
multiple
disabled={Boolean(busy)}
accept="image/jpeg,image/png,text/csv,text/plain,.csv,.tsv,.txt,.pdf,.doc,.docx,.xlsx,.odt"
onChange={(event) => {
addFiles(event.currentTarget.files);
void addFiles(event.currentTarget.files);
event.currentTarget.value = '';
}}
/>
@@ -322,6 +347,7 @@ export function ProjectDossierExport({
<input
aria-label={`Incluir ${item.file.name}`}
type="checkbox"
disabled={Boolean(busy)}
checked={item.include}
onChange={(event) =>
updateAttachment(index, {
@@ -340,6 +366,7 @@ export function ProjectDossierExport({
<button
type="button"
className="rounded border px-2 py-1 text-sm"
disabled={Boolean(busy)}
onClick={() => removeAttachment(index)}
>
Remover
@@ -352,6 +379,7 @@ export function ProjectDossierExport({
<input
className="rounded border px-3 py-2"
maxLength={300}
disabled={Boolean(busy)}
value={item.caption}
onChange={(event) =>
updateAttachment(index, {
@@ -372,6 +400,11 @@ export function ProjectDossierExport({
em zero. O relatório identifica pressupostos, estimativas e
referências sem as apresentar como resultados confirmados.
</p>
{notice && (
<p role="status" className="text-sm">
{notice}
</p>
)}
{error && (
<p role="alert" className="text-sm text-red-800">
{error}
@@ -414,7 +447,7 @@ export function ProjectDossierExport({
type="button"
className="rounded border px-4 py-2"
disabled={Boolean(busy)}
onClick={exportCsv}
onClick={() => void exportCsv()}
>
Exportar CSV complementar
</button>
+169 -26
View File
@@ -1,14 +1,45 @@
import { router, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import CatalogueFilterContext from './catalogue-filter-context';
// Shared feedback for existing Inertia search consumers; form state is never reset.
const collections = ['results','records','resources','partnerships','opportunities','interests','investors','leads','articles','services','items','entries','documents','requests','batches','users','assignments','zones','matches','businesses'];
const collections = [
'results',
'records',
'resources',
'partnerships',
'opportunities',
'interests',
'investors',
'leads',
'articles',
'services',
'items',
'entries',
'documents',
'requests',
'batches',
'users',
'assignments',
'zones',
'matches',
'businesses',
];
function count(props: Record<string, unknown>): number | null {
if (props.demands && props.capacities) return Number((props.demands as {total:number}).total) + Number((props.capacities as {total:number}).total);
if (props.plan && Array.isArray((props.plan as {rows:unknown[]}).rows)) return (props.plan as {rows:unknown[]}).rows.length;
if (props.demands && props.capacities)
return (
Number((props.demands as { total: number }).total) +
Number((props.capacities as { total: number }).total)
);
if (props.plan && Array.isArray((props.plan as { rows: unknown[] }).rows))
return (props.plan as { rows: unknown[] }).rows.length;
if (typeof props.total === 'number') return props.total;
if (typeof props.match === 'boolean' && Array.isArray(props.articles)) return props.match ? props.articles.length : 0;
if (typeof props.match === 'boolean' && Array.isArray(props.articles))
return props.match ? props.articles.length : 0;
for (const key of collections) {
const value = props[key] as {total?:number; meta?:{total?:number}; data?:unknown[]} | unknown[] | undefined;
const value = props[key] as
| { total?: number; meta?: { total?: number }; data?: unknown[] }
| unknown[]
| undefined;
if (!value) continue;
if (Array.isArray(value)) return value.length;
if (typeof value.total === 'number') return value.total;
@@ -19,28 +50,140 @@ function count(props: Record<string, unknown>): number | null {
}
export default function SearchFeedback() {
const page = usePage();
const [state, setState] = useState<'idle'|'loading'|'error'>('idle');
const latest = useRef<{url: string; data: Record<string, unknown>} | null>(null);
const handledLocally = page.component === 'investment/catalogue';
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
const latest = useRef<{
url: string;
data: Record<string, unknown>;
} | null>(null);
const active = useRef(false);
const searchable = page.props.filters !== undefined || ['portal/legacy','desk/catalogue','investment/zones','partnerships/catalogue','education/index','management/territory-geometry-import'].includes(page.component);
const total = searchable ? count(page.props) : null;
const searchable =
page.props.filters !== undefined ||
[
'portal/legacy',
'desk/catalogue',
'investment/zones',
'partnerships/catalogue',
'education/index',
'management/territory-geometry-import',
].includes(page.component);
// A page may contain several independent catalogues. Never infer a public
// result universe by whichever collection happens to occur first.
const descriptors: Record<string, [string, string]> = {
'portfolio/index': ['records', 'Fichas da carteira'],
'investment/zones': ['records', 'Zonas e parques'],
'investment/business-marketplace': ['results', 'Anúncios de negócios'],
'portal/legacy': ['articles', 'Artigos'],
};
const descriptor =
page.component === 'portal/services'
? page.props.section === 'documents' ||
page.props.section === 'legislation'
? ['resources', 'Documentos']
: page.props.section === 'sectors'
? ['sectors', 'Sectores']
: null
: descriptors[page.component];
const total = descriptor
? page.component === 'portal/legacy' && page.props.match === false
? 0
: count({ results: page.props[descriptor[0]] })
: searchable
? count(page.props)
: null;
const label = descriptor?.[1];
useEffect(() => {
const off = [router.on('before', event => {
const visit = event.detail.visit;
if (visit.method !== 'get') { active.current = false; return; }
const data = visit.data as Record<string, unknown>;
const url = visit.url;
const isSearch = total !== null || ['q','query','search','filter','status','sector'].some(key => key in data || url.searchParams.has(key));
active.current = isSearch;
if (isSearch) { latest.current = {url: url.toString(), data}; setState('loading'); }
}), router.on('success', () => { if (active.current) { active.current = false; setState('idle'); } }),
router.on('error', () => { if (active.current) setState('error'); }),
router.on('httpException', event => { if (active.current) { event.preventDefault(); setState('error'); } }),
router.on('networkError', event => { if (active.current) { event.preventDefault(); setState('error'); } })];
return () => off.forEach(remove => remove());
}, [total]);
if (handledLocally) return;
const off = [
router.on('before', (event) => {
const visit = event.detail.visit;
if (visit.method !== 'get') {
active.current = false;
return;
}
const data = visit.data as Record<string, unknown>;
const url = visit.url;
const isSearch =
total !== null ||
['q', 'query', 'search', 'filter', 'status', 'sector'].some(
(key) => key in data || url.searchParams.has(key),
);
active.current = isSearch;
if (isSearch) {
latest.current = { url: url.toString(), data };
setState('loading');
}
}),
router.on('success', () => {
if (active.current) {
active.current = false;
setState('idle');
}
}),
router.on('error', () => {
if (active.current) setState('error');
}),
router.on('httpException', (event) => {
if (active.current) {
event.preventDefault();
setState('error');
}
}),
router.on('networkError', (event) => {
if (active.current) {
event.preventDefault();
setState('error');
}
}),
];
return () => off.forEach((remove) => remove());
}, [total, handledLocally]);
if (handledLocally) return null;
if (total === null && state === 'idle') return null;
return <aside data-search-feedback role={state === 'error' ? 'alert' : 'status'} aria-live="polite" aria-atomic="true" className="sticky top-0 z-50 border-b bg-white p-3 text-center text-slate-950 shadow">
{state === 'loading' ? 'A pesquisar…' : state === 'error' ? <><span>Não foi possível concluir a pesquisa. Os resultados anteriores mantêm-se abaixo. </span><button type="button" className="underline" onClick={() => { const request = latest.current; if (request) router.get(request.url, request.data as never, {preserveState:true, preserveScroll:true}); }}>Tentar novamente</button></> : total === 0 ? 'Não foram encontrados resultados para esta pesquisa' : `${total} resultados encontrados`}
</aside>;
return (
<>
<aside
data-search-feedback
role={state === 'error' ? 'alert' : 'status'}
aria-live="polite"
aria-atomic="true"
className="border-b bg-white p-3 text-center text-slate-950"
>
{label && <strong>{label}: </strong>}
{state === 'loading' ? (
'A pesquisar…'
) : state === 'error' ? (
<>
<span>
Não foi possível concluir a pesquisa. Os resultados
anteriores mantêm-se abaixo.{' '}
</span>
<button
type="button"
className="underline"
onClick={() => {
const request = latest.current;
if (request)
router.get(
request.url,
request.data as never,
{
preserveState: true,
preserveScroll: true,
},
);
}}
>
Tentar novamente
</button>
</>
) : total === 0 ? (
'Não foram encontrados resultados para esta pesquisa'
) : (
`${total} ${total === 1 ? 'resultado encontrado' : 'resultados encontrados'}`
)}
</aside>
{descriptor && <CatalogueFilterContext />}
</>
);
}
+47 -4
View File
@@ -1,5 +1,48 @@
export default function SearchState({loading, error, total, retry}: {loading:boolean; error?:string; total:number; retry:()=>void}) {
return <div role={error ? 'alert' : 'status'} aria-live="polite" aria-atomic="true" className="my-3 rounded border p-3">
{loading ? 'A pesquisar…' : error ? <>Não foi possível concluir a pesquisa. <button type="button" className="underline" onClick={retry}>Tentar novamente</button></> : total === 0 ? 'Não foram encontrados resultados para esta pesquisa' : `${total} resultados encontrados`}
</div>;
export default function SearchState({
loading,
error,
total,
retry,
scope,
countLabel,
}: {
loading: boolean;
error?: string;
total: number;
retry: () => void;
scope?: string;
countLabel?: string;
}) {
return (
<div
role={error ? 'alert' : 'status'}
aria-live="polite"
aria-atomic="true"
className="my-3 rounded border p-3"
>
{scope && <span className="font-medium">{scope}: </span>}
{loading ? (
'A pesquisar…'
) : error ? (
<>
Não foi possível concluir a pesquisa.{' '}
<button type="button" className="underline" onClick={retry}>
Tentar novamente
</button>
</>
) : total === 0 ? (
scope ? (
'Nenhuma opção encontrada'
) : (
'Não foram encontrados resultados para esta pesquisa'
)
) : countLabel ? (
`${total} ${countLabel}`
) : scope ? (
`${total} ${total === 1 ? 'opção disponível' : 'opções disponíveis'}`
) : (
`${total} ${total === 1 ? 'resultado encontrado' : 'resultados encontrados'}`
)}
</div>
);
}
+6 -25
View File
@@ -1,11 +1,9 @@
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export type SectorEntryData = {
articles: {
title: string;
href: string;
image: { href: string; alt: string } | null;
scope: string;
}[];
articles: EducationalArticle[];
relations: {
id: string;
title: string;
@@ -66,9 +64,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
perfil e a modalidade já autorizados; se ainda não tem perfil,
pode preparar a ideia e pedir o perfil de promotor.
</p>
<h2 className="text-xl font-bold">
Conteúdo e possibilidades de negócio
</h2>
<h2 className="text-xl font-bold">Descubra onde investir</h2>
{data.content_notice && <p>{data.content_notice}</p>}
{!data.articles.length && (
<p>
@@ -78,22 +74,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
)}
<div className="grid gap-4 md:grid-cols-2">
{data.articles.map((a) => (
<article
className="min-w-0 rounded border p-4"
key={a.href}
>
{a.image && (
<img
className="mb-3 h-36 w-full object-cover"
src={a.image.href}
alt={a.image.alt}
/>
)}
<Link className="font-semibold underline" href={a.href}>
{a.title}
</Link>
<p>{a.scope}</p>
</article>
<EducationalArticleCard key={a.href} article={a} />
))}
</div>
{data.specialization_href && (
@@ -1,7 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
exportMonthlyCashflowCsv,
projectMonthlyCashflow,
defaultCashflowDraftInputs,
restoreCashflowDraftSnapshot,
serializeCashflowDraftSnapshot,
type CashflowDraftSnapshot,
} from '@/lib/monthly-cashflow';
import {
isSimulationCurrency,
@@ -11,28 +15,38 @@ import {
export function SimulationCashflowPlanner({
currency,
onCurrencyChange,
snapshot,
onSnapshotChange,
}: {
currency: string;
onCurrencyChange: (currency: string) => void;
snapshot?: string;
onSnapshotChange?: (snapshot: string) => void;
}) {
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>('fixed');
const [inputs, setInputs] = useState({
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatRefundExpectedMonth: '',
});
const restored = restoreCashflowDraftSnapshot(snapshot);
const [taxMode, setTaxMode] = useState<'fixed' | 'schedule'>(
restored?.taxMode ?? 'fixed',
);
const [inputs, setInputs] = useState(
restored?.inputs ?? defaultCashflowDraftInputs(),
);
useEffect(() => {
const saved = restoreCashflowDraftSnapshot(snapshot);
setTaxMode(saved?.taxMode ?? 'fixed');
setInputs(saved?.inputs ?? defaultCashflowDraftInputs());
}, [snapshot]);
function update(
next: typeof inputs,
mode: CashflowDraftSnapshot['taxMode'] = taxMode,
) {
setInputs(next);
setTaxMode(mode);
onSnapshotChange?.(serializeCashflowDraftSnapshot(mode, next));
}
const set = (
key: Exclude<keyof typeof inputs, 'monthlyTaxSchedule'>,
value: string,
) => setInputs((current) => ({ ...current, [key]: value }));
) => update({ ...inputs, [key]: value });
const cashflowInput = {
...inputs,
...(taxMode === 'fixed'
@@ -45,6 +59,10 @@ export function SimulationCashflowPlanner({
let projection;
let error = '';
try {
if (snapshot && !restored)
throw new Error(
'Não foi possível recuperar os pressupostos guardados de tesouraria. Reveja os campos antes de voltar a guardar; nenhum resultado foi calculado com dados alternativos.',
);
projection = projectMonthlyCashflow({ ...cashflowInput, currency });
} catch (cause) {
error =
@@ -97,9 +115,11 @@ export function SimulationCashflowPlanner({
role="note"
className="rounded bg-amber-50 p-3 text-sm text-amber-950"
>
Os valores preenchidos ao abrir são exemplos demonstrativos.
Confirme ou altere os pressupostos antes de interpretar os
resultados ou exportar o CSV.
{restored
? 'Pressupostos recuperados do rascunho deste investimento; reveja-os antes de interpretar os resultados ou exportar.'
: snapshot
? 'O rascunho guardado não pôde ser interpretado. Os campos mostram exemplos, que não substituem os dados guardados até os editar.'
: 'Os valores preenchidos ao abrir são exemplos demonstrativos. Confirme ou altere os pressupostos antes de interpretar os resultados ou exportar o CSV.'}
</p>
{projection?.status === 'incomplete' && (
<p
@@ -121,7 +141,7 @@ export function SimulationCashflowPlanner({
type="radio"
name="cashflow-tax-mode"
checked={taxMode === 'fixed'}
onChange={() => setTaxMode('fixed')}
onChange={() => update(inputs, 'fixed')}
/>
Valor mensal fixo
</label>
@@ -130,7 +150,7 @@ export function SimulationCashflowPlanner({
type="radio"
name="cashflow-tax-mode"
checked={taxMode === 'schedule'}
onChange={() => setTaxMode('schedule')}
onChange={() => update(inputs, 'schedule')}
/>
Calendário variável por mês
</label>
@@ -162,16 +182,16 @@ export function SimulationCashflowPlanner({
step="0.01"
value={value}
onChange={(event) =>
setInputs((current) => ({
...current,
update({
...inputs,
monthlyTaxSchedule:
current.monthlyTaxSchedule.map(
inputs.monthlyTaxSchedule.map(
(amount, month) =>
month === index
? event.target.value
: amount,
),
}))
})
}
className="mt-1 w-full rounded border p-2"
/>
@@ -352,6 +352,12 @@ export function SimulationFinancialTools({
/>
{schedule && isSimulationCurrency(currency) && (
<>
<p className="text-sm">
Montantes a duas casas decimais; juros e
prestação arredondados à unidade monetária
mínima, metade para cima. A última prestação
ajusta o saldo residual.
</p>
<p aria-live="polite">
Taxa por período: {schedule.periodicRatePercent}
%; desembolso líquido: {
@@ -66,6 +66,7 @@ export function SimulationFiscalStatus({
}) {
const [parameters, setParameters] = useState<Parameter[]>([]);
const [requestState, setRequestState] = useState('idle');
const [retry, setRetry] = useState(0);
useEffect(() => {
const apiComponent = component
@@ -104,12 +105,14 @@ export function SimulationFiscalStatus({
const payload = (await response.json()) as {
parameters?: Parameter[];
};
if (abort.signal.aborted) return;
setParameters(
Array.isArray(payload.parameters) ? payload.parameters : [],
);
setRequestState('done');
})
.catch((error: unknown) => {
if (abort.signal.aborted) return;
if (
error instanceof DOMException &&
error.name === 'AbortError'
@@ -125,6 +128,7 @@ export function SimulationFiscalStatus({
periodStart,
periodEnd,
JSON.stringify(context ?? {}),
retry,
]);
const salaryViewMessages: Record<string, string> = {
@@ -160,6 +164,16 @@ export function SimulationFiscalStatus({
: `Período consultado: ${periodStart} a ${periodEnd}.`}
</p>
)}
{requestState === 'error' &&
scenarioMode === 'approved_parameters' && (
<button
type="button"
onClick={() => setRetry((value) => value + 1)}
className="rounded border border-amber-900 px-4 py-2 text-amber-950 underline focus-visible:outline-2 focus-visible:outline-offset-2"
>
Tentar novamente
</button>
)}
{scenarioMode === 'custom_planning' && (
<p role="note" className="text-sm">
Modo de planeamento separado: pressupostos do utilizador,
+105 -42
View File
@@ -1,9 +1,11 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import {
PublicDocumentContent,
type PublicDocument,
} from '@/components/public-document-content';
import { DossierPrefill } from '@/components/dossier-prefill';
import { createOperationKey } from '@/lib/operation-key';
import FiscalQaPreview from './fiscal-qa-preview';
import { displayValue } from '@/lib/display-value';
import {
useCallback,
@@ -834,8 +836,10 @@ export function SimulationForm() {
}, [openedTool, step]);
function openDraftTool(tool: 'calculation' | 'fiscal') {
setToolError('');
const index = journeySections.findIndex(
(section) => section.id === 'tax',
const index = journeySections.findIndex((section) =>
tool === 'calculation'
? ['financial', 'tax'].includes(section.id)
: section.id === 'tax',
);
if (index < 0) {
setToolError(
@@ -864,6 +868,10 @@ export function SimulationForm() {
const [isCalculating, setIsCalculating] = useState(false);
const [hasSaveConflict, setHasSaveConflict] = useState(false);
const [result, setResult] = useState<ApiSimulationExecutionV1 | null>(null);
const [calculationStale, setCalculationStale] = useState(false);
useEffect(() => {
if (result) setCalculationStale(false);
}, [result]);
const arithmeticResult = useMemo(
() => readArithmeticSnapshot(result?.result_snapshot),
[result],
@@ -880,6 +888,7 @@ export function SimulationForm() {
() => readVersionedArithmeticReportReference(result),
[result],
);
const isFiscalQaPreview = ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(arithmeticSnapshot?.scenario_kind));
const versionedReportMissing =
arithmeticResult.integrity === 'versioned' &&
arithmeticReportReference === null;
@@ -923,6 +932,7 @@ export function SimulationForm() {
const [businessLinkError, setBusinessLinkError] = useState('');
const [businessLinkBusy, setBusinessLinkBusy] = useState(false);
const [continueLaterSaved, setContinueLaterSaved] = useState(false);
const [saveAttempt, setSaveAttempt] = useState(0);
const calculationInFlightRef = useRef(false);
const draftIdRef = useRef<string | null>(null);
const draftEtagRef = useRef<string | null>(null);
@@ -1039,6 +1049,13 @@ export function SimulationForm() {
: (simulationFieldGroups[current.id] ?? []);
const fields = fieldsForCurrent.filter(
(field) =>
!(
current.id === 'tax' &&
draft.fiscal_scenario_mode === 'custom_planning' &&
!['fiscal_scenario_mode', 'base', 'rate', 'reduction'].includes(
field.key,
)
) &&
(!field.showWhen || field.showWhen(draft)) &&
!(
draft.purpose === 'simulate_only' &&
@@ -1311,6 +1328,7 @@ export function SimulationForm() {
setSaveStatus('A guardar o rascunho no servidor…');
saveQueueRef.current = saveQueueRef.current
.then(async () => {
if (fingerprint === lastSavedFingerprintRef.current) return;
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -1465,6 +1483,7 @@ export function SimulationForm() {
draftCreateKey,
draftIdKey,
draftKey,
saveAttempt,
]);
async function returnToInvestment() {
@@ -1689,6 +1708,7 @@ export function SimulationForm() {
async function saveAndContinueLater() {
if (!draft.purpose) return;
setContinueLaterSaved(false);
setSaveAttempt((attempt) => attempt + 1);
await new Promise((resolve) => window.setTimeout(resolve, 550));
await saveQueueRef.current;
const target = canSavePersonalData ? draft : getAutosaveSnapshot(draft);
@@ -1943,7 +1963,8 @@ export function SimulationForm() {
return next;
});
if (key === 'purpose') setStep(0);
if (key !== 'purpose') setResult(null);
if (result) setCalculationStale(true);
setResult(null);
}
async function clearDraft() {
@@ -2139,15 +2160,15 @@ export function SimulationForm() {
);
}
async function runSimulation() {
async function runSimulation(publishedInput?: PublishedIvaInput) {
if (calculationInFlightRef.current) return;
const validation = validateSimulationDraft(draft, 'tax');
if (draft.fiscal_scenario_mode === 'custom_planning') {
const validation = publishedInput ? {} : validateSimulationDraft(draft, 'tax');
if (!publishedInput && draft.fiscal_scenario_mode === 'custom_planning') {
Object.assign(validation, validateCustomFiscalPlanning(draft));
}
setErrors(validation);
setServerError('');
if (draft.fiscal_scenario_mode !== 'custom_planning') return;
if (!publishedInput && draft.fiscal_scenario_mode !== 'custom_planning') return;
if (Object.keys(validation).length) {
document
.getElementById(`field-${Object.keys(validation)[0]}`)
@@ -2158,7 +2179,7 @@ export function SimulationForm() {
setIsCalculating(true);
try {
const xsrf = csrfToken();
const body = {
const body = publishedInput ?? {
base: draft.base,
rate: draft.rate,
reduction: draft.reduction,
@@ -2266,7 +2287,7 @@ export function SimulationForm() {
}
}
if (
JSON.stringify({
publishedInput || JSON.stringify({
base: draft.base,
rate: draft.rate,
reduction: draft.reduction,
@@ -2274,7 +2295,19 @@ export function SimulationForm() {
)
setResult(simulation ?? null);
if (canSavePersonalData) void loadSimulationHistory();
sessionStorage.removeItem(idempotencyStorageKey);
if (simulation?.status === 'completed') {
setSaveStatus(
'Cálculo concluído. Reveja o resultado e os pressupostos.',
);
} else if (
simulation?.status === 'failed' ||
simulation?.status === 'cancelled'
) {
setSaveStatus(
'O cálculo não foi concluído. Os dados introduzidos foram preservados.',
);
}
if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey);
} catch (cause) {
setServerError(
cause instanceof Error
@@ -2396,6 +2429,16 @@ export function SimulationForm() {
aria-labelledby="simulator-step-title"
aria-busy={!ready}
>
{calculationStale && (
<p
role="status"
className="rounded border border-amber-700 bg-amber-50 p-3 text-amber-950"
>
As entradas foram alteradas. Calcule novamente antes de
guardar ou exportar um novo resultado. Os resultados
anteriores mantêm-se no histórico.
</p>
)}
{!ready && (
<p role="status">
A recuperar o rascunho. Aguarde para editar.
@@ -3415,6 +3458,10 @@ export function SimulationForm() {
(draft.purpose === 'simulate_only' &&
current.id === 'tax')) && (
<SimulationCashflowPlanner
snapshot={String(draft.cashflow_snapshot ?? '')}
onSnapshotChange={(value) =>
change('cashflow_snapshot', value)
}
currency={String(draft.currency ?? '')}
onCurrencyChange={(value) => change('currency', value)}
/>
@@ -3554,6 +3601,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -3651,43 +3699,56 @@ export function SimulationForm() {
O período e as respostas servem para encaminhar uma
exploração informativa. A residência é auto-indicada ou
fica por determinar; não é inferida por nacionalidade ou
dias de presença. Regras de 2026 continuam pendentes de
validação e este formulário não determina cumprimento
dias de presença. Cada cálculo identifica a regra, versão, cobertura e
limitações; este formulário não determina cumprimento
fiscal. Um crédito estimado de IVA não significa pedido
aprovado nem dinheiro disponível. Não carregue
documentos nesta simulação exploratória.
</p>
)}
{current.id === 'tax' && (
{(current.id === 'tax' || result) && (
<div className="space-y-3 border-t pt-4">
<h4 className="font-semibold">
Cenário personalizado de planeamento
{isFiscalQaPreview ? (arithmeticSnapshot?.scenario_kind === 'fiscal_rule_published' ? 'IVA pela versão publicada seleccionada' : 'Pré-visualização fiscal QA seleccionada') : current.id === 'tax'
? 'Cenário personalizado de planeamento'
: 'Resultado histórico seleccionado'}
</h4>
{draft.fiscal_scenario_mode === 'custom_planning' && (
<p
role="note"
className="rounded bg-slate-50 p-3 text-sm"
>
Os valores abaixo são pressupostos que
introduziu para planeamento. O resultado é uma
operação percentual genérica, não uma taxa
legal, imposto devido nem confirmação de
elegibilidade.
{current.id !== 'tax' && result && (
<p>
Este resultado conserva os pressupostos e a
versão da execução seleccionada. Não substitui
as entradas deste dossier.
</p>
)}
{draft.fiscal_scenario_mode === 'custom_planning' && (
<button
type="button"
disabled={isCalculating}
aria-busy={isCalculating}
className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60"
onClick={() => void runSimulation()}
>
{isCalculating
? 'A calcular…'
: 'Validar e calcular no servidor'}
</button>
)}
{current.id === 'tax' &&
draft.fiscal_scenario_mode ===
'custom_planning' && (
<p
role="note"
className="rounded bg-slate-50 p-3 text-sm"
>
Os valores abaixo são pressupostos que
introduziu para planeamento. O resultado é
uma operação percentual genérica, não uma
taxa legal, imposto devido nem confirmação
de elegibilidade.
</p>
)}
{current.id === 'tax' &&
draft.fiscal_scenario_mode ===
'custom_planning' && (
<button
type="button"
disabled={isCalculating}
aria-busy={isCalculating}
className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60"
onClick={() => void runSimulation()}
>
{isCalculating
? 'A calcular…'
: 'Validar e calcular no servidor'}
</button>
)}
{serverError && (
<p role="alert" className="text-red-800">
{serverError}
@@ -3712,7 +3773,7 @@ export function SimulationForm() {
submetido.
</p>
)}
{result.status === 'completed' && (
{result.status === 'completed' && !isFiscalQaPreview && (
<p>
Resultado aritmético indicativo:{' '}
<strong className="break-all">
@@ -3749,9 +3810,10 @@ export function SimulationForm() {
taxas legais, elegibilidade ou obrigação
fiscal.
</p>
{isFiscalQaPreview && <FiscalQaPreview snapshot={result.result_snapshot} />}
{result.status === 'completed' && (
<div className="mt-3 space-y-2 border-t pt-3">
{arithmeticResult.integrity !==
{!isFiscalQaPreview && arithmeticResult.integrity !==
'invalid' && (
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold focus-visible:outline-2">
@@ -4122,8 +4184,7 @@ export function SimulationForm() {
'não identificada',
'não identificada',
)}{' '}
· resultado
aritmético{' '}
· {['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(source.result_snapshot.scenario_kind)) ? 'IVA calculado exacto' : 'resultado aritmético'}{' '}
{displayScalar(
sourceResult.value ??
(sourceResult.integrity ===
@@ -4362,7 +4423,9 @@ export function SimulationForm() {
e, se já escolhido, o cálculo percentual
personalizado. Consultar parâmetros fiscais
apresenta fontes e lacunas; não determina imposto
devido.
devido. Na etapa Operação fiscal encontra também
o cálculo de IVA geral sobre base líquida, quando
activado, com âmbito e versão identificados.
</p>
<button
type="button"
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
export type PublishedIvaInput = {
scenario_kind: 'fiscal_rule_published';
base: string;
currency: 'MZN';
reference_date: string;
operation: 'internal_general_net';
scope_declared: boolean;
};
export function SimulationPublishedIva({ busy, onCalculate }: {
busy: boolean;
onCalculate: (input: PublishedIvaInput) => Promise<void>;
}) {
const [available, setAvailable] = useState(false);
const [message, setMessage] = useState('A consultar disponibilidade do cálculo de IVA…');
const [base, setBase] = useState('');
const [date, setDate] = useState('');
const [currency, setCurrency] = useState('');
const [declared, setDeclared] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
fetch('/api/v1/fiscal-consumer', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) throw new Error();
const payload = await response.json();
if (!active) return;
setAvailable(payload?.data?.enabled === true);
setMessage(typeof payload?.data?.message === 'string' ? payload.data.message : 'Cálculo de IVA indisponível.');
}).catch(() => { if (active) setMessage('Não foi possível consultar a disponibilidade do IVA. As entradas e históricos foram preservados.'); });
return () => { active = false; };
}, []);
async function calculate() {
if (!/^\d+(?:\.\d+)?$/.test(base) || base.length > 40 || !date || currency !== 'MZN') {
setError('Indique uma base líquida decimal válida, a moeda MZN e a data de exigibilidade. As entradas foram preservadas.');
return;
}
setError('');
await onCalculate({ scenario_kind: 'fiscal_rule_published', base, currency: 'MZN', reference_date: date,
operation: 'internal_general_net', scope_declared: declared });
// Refresh after admission/refusal so a suspension is reflected without discarding inputs.
try {
const response = await fetch('/api/v1/fiscal-consumer', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const payload = await response.json();
setAvailable(payload?.data?.enabled === true);
if (typeof payload?.data?.message === 'string') setMessage(payload.data.message);
} catch { /* The execution flow already preserves and reports connection errors. */ }
}
return <section aria-label="IVA geral sobre base líquida" className="space-y-3 rounded border border-emerald-700 p-4">
<h4 className="font-semibold">IVA geral sobre base líquida</h4>
<p role="status">{message}</p>
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
<fieldset disabled={busy} className="space-y-3">
<label className="block">Base tributável líquida (decimal, com ponto)<input aria-label="Base tributável líquida" inputMode="decimal" value={base} onChange={(e) => setBase(e.target.value)} className="block rounded border p-2" /></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option></select></label>
<label className="block">Data de exigibilidade declarada<input aria-label="Data de exigibilidade IVA" type="date" min="2026-01-01" max="2026-12-31" value={date} onChange={(e) => setDate(e.target.value)} className="block rounded border p-2" /></label>
<label className="block"><input type="checkbox" checked={declared} onChange={(e) => setDeclared(e.target.checked)} /> Declaro operação interna à taxa geral, base líquida já apurada e ausência das excepções acima.</label>
<p className="text-sm">Sem esta declaração, o resultado fica indeterminado. O valor calculado conserva a precisão decimal; não é saldo a pagar.</p>
<button type="button" disabled={!available || busy} onClick={() => void calculate()} className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50">{busy ? 'A calcular…' : 'Calcular IVA e gravar'}</button>
</fieldset>
{error && <p role="alert">{error}</p>}
</section>;
}
@@ -56,12 +56,14 @@ export default function TwoFactorRecoveryCodes({
<Card>
<CardHeader>
<CardTitle className="flex gap-3">
<LockKeyhole className="size-4" aria-hidden="true" />
2FA recovery codes
<LockKeyhole className="size-4" aria-hidden="true" />{' '}
Códigos de recuperação{' '}
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
{' '}
Os códigos de recuperação permitem recuperar o acesso se
perder o dispositivo autenticador. Guarde-os num gestor de
palavras-passe seguro.{' '}
</CardDescription>
</CardHeader>
<CardContent>
@@ -76,7 +78,8 @@ export default function TwoFactorRecoveryCodes({
className="size-4"
aria-hidden="true"
/>
{codesAreVisible ? 'Hide' : 'View'} recovery codes
{codesAreVisible ? 'Ocultar' : 'Ver'} códigos de
recuperação{' '}
</Button>
{canRegenerateCodes && (
@@ -92,7 +95,7 @@ export default function TwoFactorRecoveryCodes({
disabled={processing}
aria-describedby="regenerate-warning"
>
<RefreshCw /> Regenerate codes
<RefreshCw /> Gerar novos códigos{' '}
</Button>
)}
</Form>
@@ -112,7 +115,7 @@ export default function TwoFactorRecoveryCodes({
ref={codesSectionRef}
className="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
role="list"
aria-label="Recovery codes"
aria-label="Códigos de recuperação"
>
{recoveryCodesList.length ? (
recoveryCodesList.map((code, index) => (
@@ -127,7 +130,7 @@ export default function TwoFactorRecoveryCodes({
) : (
<div
className="space-y-2"
aria-label="Loading recovery codes"
aria-label="A carregar os códigos de recuperação"
>
{Array.from(
{ length: 8 },
@@ -145,13 +148,15 @@ export default function TwoFactorRecoveryCodes({
<div className="text-muted-foreground text-xs select-none">
<p id="regenerate-warning">
Each recovery code can be used once to
access your account and will be removed
after use. If you need more, click{' '}
{' '}
Cada código permite uma única entrada e
deixa de ser válido após a utilização.
Para obter novos códigos, seleccione{' '}
<span className="font-bold">
Regenerate codes
{' '}
Gerar novos códigos{' '}
</span>{' '}
above.
acima.{' '}
</p>
</div>
</>
@@ -104,7 +104,8 @@ function TwoFactorSetupStep({
<div className="relative flex w-full items-center justify-center">
<div className="bg-border absolute inset-0 top-1/2 h-px w-full" />
<span className="bg-card relative px-2 py-1">
or, enter the code manually
{' '}
ou introduza a chave manualmente{' '}
</span>
</div>
@@ -210,7 +211,8 @@ function TwoFactorVerificationStep({
onClick={onBack}
disabled={processing}
>
Back
{' '}
Voltar{' '}
</Button>
<Button
type="submit"
@@ -219,7 +221,8 @@ function TwoFactorVerificationStep({
processing || code.length < OTP_MAX_LENGTH
}
>
Confirm
{' '}
Confirmar{' '}
</Button>
</div>
</div>
@@ -262,27 +265,27 @@ export default function TwoFactorSetupModal({
}>(() => {
if (twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
title: 'Autenticação de dois factores activada',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
'A autenticação de dois factores está activada. Leia o código QR ou introduza a chave na aplicação autenticadora.',
buttonText: 'Fechar',
};
}
if (showVerificationStep) {
return {
title: 'Verify authentication code',
title: 'Confirmar código de autenticação',
description:
'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
'Introduza o código de seis dígitos da aplicação autenticadora',
buttonText: 'Continuar',
};
}
return {
title: 'Enable two-factor authentication',
title: 'Activar autenticação de dois factores',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
'Para concluir a configuração, leia o código QR ou introduza a chave na aplicação autenticadora.',
buttonText: 'Continuar',
};
}, [twoFactorEnabled, showVerificationStep]);
+1 -1
View File
@@ -63,7 +63,7 @@ function DialogContent({
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only"> Fechar </span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
+1 -1
View File
@@ -72,7 +72,7 @@ function SheetContent({
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only"> Fechar </span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
+1 -1
View File
@@ -6,7 +6,7 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
aria-label="A carregar"
className={cn("size-4 animate-spin", className)}
{...props}
/>
@@ -40,8 +40,7 @@ export function UserMenuContent({ user }: Props) {
prefetch
onClick={cleanup}
>
<Settings className="mr-2" />
Settings
<Settings className="mr-2" /> Definições{' '}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
+12 -3
View File
@@ -55,7 +55,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
setQrCodeSvg(svg);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch QR code']);
setErrors((prev) => [
...prev,
'Não foi possível carregar o código QR.',
]);
setQrCodeSvg(null);
}
}, [submit]);
@@ -68,7 +71,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
setManualSetupKey(key);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
setErrors((prev) => [
...prev,
'Não foi possível carregar a chave de configuração.',
]);
setManualSetupKey(null);
}
}, [submit]);
@@ -79,7 +85,10 @@ export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
const codes = (await submit(recoveryCodes())) as string[];
setRecoveryCodesList(codes);
} catch {
setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
setErrors((prev) => [
...prev,
'Não foi possível carregar os códigos de recuperação.',
]);
setRecoveryCodesList([]);
}
}, [submit]);
+6 -6
View File
@@ -12,17 +12,17 @@ import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [
{
title: 'Profile',
title: 'Perfil',
href: edit(),
icon: null,
},
{
title: 'Security',
title: 'Segurança',
href: editSecurity(),
icon: null,
},
{
title: 'Appearance',
title: 'Aparência',
href: editAppearance(),
icon: null,
},
@@ -34,15 +34,15 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
return (
<div className="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
title="Definições"
description="Gerir o perfil e as definições da conta"
/>
<div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
aria-label="Definições"
>
{sidebarNavItems.map((item, index) => (
<Button
+12
View File
@@ -0,0 +1,12 @@
/** Only a query string is carried; the return destination is always local. */
export function catalogueDetailHref(path: string, query: string): string {
return query
? `${path}?catalogue_query=${encodeURIComponent(query)}`
: path;
}
export function catalogueReturnHref(base: string, pageUrl: string): string {
const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get(
'catalogue_query',
);
return base + (query ? `?${new URLSearchParams(query).toString()}` : '');
}
+89
View File
@@ -0,0 +1,89 @@
import {
validateDossierAttachments,
type DossierAttachment,
} from './project-dossier-report.ts';
export type DossierExportOperation =
| 'word'
| 'package'
| 'pdf'
| 'csv'
| 'attachments';
const messages: Record<DossierExportOperation, string> = {
word: 'Não foi possível gerar o relatório Word.',
package: 'Não foi possível preparar o pacote de anexos.',
pdf: 'Não foi possível preparar a versão para PDF.',
csv: 'Não foi possível preparar o ficheiro CSV.',
attachments: 'Não foi possível ler os anexos seleccionados.',
};
let failureSequence = 0;
export function dossierExportFailure(
operation: DossierExportOperation,
cause: unknown,
log: (message: string, detail: unknown) => void = console.error,
): string {
const reference = `EXP-${Date.now().toString(36)}-${++failureSequence}`;
// Local diagnostics only: never send dossier values or file contents.
log('[InvesteMoz exportação]', { reference, operation, cause });
return `${messages[operation]} Os dados, as secções e os anexos foram mantidos. Tente novamente pelo mesmo botão. Referência: ${reference}.`;
}
/** Synchronous acquisition prevents a second click before React renders busy. */
export class DossierExportGate {
private active = false;
async run(task: () => Promise<void>): Promise<boolean> {
if (this.active) return false;
this.active = true;
try {
await task();
return true;
} finally {
this.active = false;
}
}
}
export async function prepareDossierAttachments(
current: DossierAttachment[],
incoming: File[],
): Promise<{
attachments: DossierAttachment[];
duplicateCount: number;
problem?: string;
}> {
for (const file of incoming) {
const problem = validateDossierAttachments([file]);
if (problem)
return { attachments: current, duplicateCount: 0, problem };
}
const identity = async (file: File) => {
const hash = await crypto.subtle.digest(
'SHA-256',
await file.arrayBuffer(),
);
return `${file.name}:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('')}`;
};
const known = new Set(
await Promise.all(current.map((item) => identity(item.file))),
);
const added: DossierAttachment[] = [];
let duplicateCount = 0;
for (const file of incoming) {
const key = await identity(file);
if (known.has(key)) {
duplicateCount++;
continue;
}
known.add(key);
added.push({ file, include: true, caption: '' });
}
const problem = validateDossierAttachments(
[...current, ...added].map((item) => item.file),
);
return {
attachments: problem ? current : [...current, ...added],
duplicateCount,
problem: problem ?? undefined,
};
}
+8 -1
View File
@@ -468,9 +468,16 @@ export function exportFinancialToolsCsv(
['Pressupostos do empréstimo'],
['Capital', input.loan.principal],
['Taxa anual (%)', input.loan.annualRatePercent],
['Tipo de taxa', input.loan.rateType],
[
'Tipo de taxa',
input.loan.rateType === 'nominal' ? 'Nominal' : 'Efectiva',
],
['Prazo (meses)', input.loan.termMonths],
['Periodicidade (meses)', input.loan.periodMonths],
[
'Arredondamento',
'Montantes a duas casas decimais; juros e prestação arredondados à unidade monetária mínima, metade para cima; última prestação ajusta o saldo residual.',
],
['Carência (períodos)', input.loan.gracePeriods ?? 0],
['Comissão inicial', input.loan.upfrontFee ?? '0'],
['Comissão periódica', input.loan.recurringFee ?? '0'],
+60
View File
@@ -0,0 +1,60 @@
import { isApiSimulationResultSnapshotV1 } from "./simulation-contract.ts";
export function fiscalQaRows(value: unknown): Array<[string, string]> {
if (!isApiSimulationResultSnapshotV1(value)) return [];
const snapshot = value as unknown as Record<string, unknown>;
if (!["fiscal_rule_qa_preview", "fiscal_rule_published"].includes(String(snapshot.scenario_kind))) return [];
const published = snapshot.scenario_kind === "fiscal_rule_published";
const provenance = snapshot.fiscal_provenance as
Record<string, unknown> | undefined;
const rule = provenance?.rule as Record<string, unknown> | undefined;
const source = rule?.source as Record<string, unknown> | undefined;
const parameters = rule?.parameters as
Record<string, Record<string, unknown>> | undefined;
const intermediate = snapshot.intermediate as Record<
string,
unknown
> | null;
const text = (item: unknown) =>
typeof item === "string" || typeof item === "number"
? String(item)
: "não disponível";
return [
["Relatório", published ? "IVA pela versão publicada; ver enquadramento abaixo" : "Pré-visualização fiscal QA de rascunho — sem activação"],
[
"IVA calculado (exacto; não é saldo a pagar)",
value.result.value ?? "Indeterminado",
],
["Explicação", value.result.explanation],
["Base tributável declarada", text(snapshot.base)],
["Taxa cadastrada (%)", text(parameters?.IVA_GENERAL_RATE?.value)],
["Moeda explícita", text(value.context.currency)],
["Data de exigibilidade declarada", value.context.reference_date],
[
"Enquadramento",
provenance?.scope_status === "undetermined" ? "Indeterminado: falta declaração do enquadramento" : "Declarado pelo utilizador; não validado juridicamente",
],
[
"Regra / versão / estado",
`${text(rule?.rule_code)} / ${text(rule?.rule_version)} / ${published ? "publicada no momento da admissão" : "rascunho"}`,
],
[
"Vigência cadastrada",
`${text(rule?.valid_from)} até ${rule?.valid_until === null ? "sem termo indicado" : text(rule?.valid_until)}`,
],
[
"Fonte da taxa",
`${text(source?.diploma)} — ${text(source?.article)}`,
],
["Hash do exemplar da taxa", text(rule?.source_sha256)],
["Representação racional exacta", text(intermediate?.result)],
[
"Arredondamento",
"Não aplicado. Valor calculado preservado sem truncar ou arredondar; apresentação monetária separada.",
],
[
"Limitações",
published ? "Cobertura de 2026; operação interna geral em MZN; sem excepções, deduções, declaração ou saldo a pagar." : "Regra não publicada; cobertura demonstrada em 2026; sem deduções, declaração ou saldo a pagar.",
],
];
}
+68
View File
@@ -1,5 +1,73 @@
import { csvCell } from './csv.js';
export function defaultCashflowDraftInputs() {
return {
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatRefundExpectedMonth: '',
};
}
export type CashflowDraftSnapshot = {
version: 1;
taxMode: 'fixed' | 'schedule';
inputs: ReturnType<typeof defaultCashflowDraftInputs>;
};
/** Preserve editable strings, including unanswered and invalid amounts, without inventing zero. */
export function restoreCashflowDraftSnapshot(
snapshot: string | undefined,
): CashflowDraftSnapshot | null {
if (!snapshot || snapshot.length > 20000) return null;
try {
const parsed = JSON.parse(snapshot);
if (
!parsed ||
parsed.version !== 1 ||
!['fixed', 'schedule'].includes(parsed.taxMode) ||
!parsed.inputs ||
typeof parsed.inputs !== 'object' ||
Array.isArray(parsed.inputs)
)
return null;
const keys = Object.keys(defaultCashflowDraftInputs());
if (Object.keys(parsed.inputs).some((key) => !keys.includes(key)))
return null;
for (const key of keys) {
const value = parsed.inputs[key];
if (key === 'monthlyTaxSchedule') {
if (
!Array.isArray(value) ||
value.length !== 12 ||
value.some(
(item) => typeof item !== 'string' || item.length > 160,
)
)
return null;
} else if (typeof value !== 'string' || value.length > 160)
return null;
}
return { version: 1, taxMode: parsed.taxMode, inputs: parsed.inputs };
} catch {
return null;
}
}
export function serializeCashflowDraftSnapshot(
taxMode: CashflowDraftSnapshot['taxMode'],
inputs: CashflowDraftSnapshot['inputs'],
): string {
return JSON.stringify({ version: 1, taxMode, inputs });
}
export type MonthlyCashflowInput = {
openingCash: string;
monthlyRevenue: string;
+37
View File
@@ -445,6 +445,11 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'idea-entry',
'Partir de uma ideia',
'/investimentos/iniciar',
),
link(
'why',
'Porquê investir em Moçambique',
@@ -457,6 +462,36 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
),
link('sectors', 'Sectores de investimento', '/sectores'),
link('locations', 'Localizações', '/oportunidades?view=map'),
{
id: 'related-catalogues',
label: 'Catálogos relacionados',
children: [
link(
'portfolio',
'Carteira, clusters e corredores',
'/carteira',
),
link('zones', 'Zonas e parques', '/zonas'),
...(access.businesses
? [
link(
'business-catalogue',
'Negócios',
'/negocios',
),
]
: []),
...(access.education
? [
link(
'education-catalogue',
'Oportunidades na educação',
'/educacao',
),
]
: []),
],
},
],
},
{
@@ -487,6 +522,8 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
id: 'support',
label: 'Suporte',
children: [
link('documents', 'Biblioteca de documentos', '/documentos'),
link('legislation', 'Legislação', '/legislacao'),
link(
'services',
'Serviços ao investidor',
+26 -2
View File
@@ -398,6 +398,15 @@ export async function generateProjectDossierDocx(
];
for (const section of model.sections) {
if (section.id === 'annexes') {
if (!attachments.some((item) => item.include)) {
children.push(
new Paragraph({
text: 'Anexos: não foram seleccionados ficheiros.',
spacing: { before: 80, after: 80 },
}),
);
continue;
}
children.push(
new Paragraph({
text: section.title,
@@ -447,7 +456,10 @@ export async function generateProjectDossierDocx(
alignment: AlignmentType.RIGHT,
children: [
new TextRun({
text: 'InvesteMoz · '+model.title.slice(0, 60)+' · privado · página ',
text:
'InvesteMoz · ' +
model.title.slice(0, 60) +
' · privado · página ',
size: 18,
}),
new SimpleField('PAGE'),
@@ -580,6 +592,13 @@ export async function createProjectDossierPrintHtml(
): Promise<string> {
const content: string[] = [];
for (const section of model.sections) {
if (
section.id === 'annexes' &&
!attachments.some((item) => item.include)
) {
content.push('<p>Anexos: não foram seleccionados ficheiros.</p>');
continue;
}
content.push(`<section><h2>${escapeHtml(section.title)}</h2>`);
for (const paragraph of section.paragraphs)
content.push(`<p>${escapeHtml(paragraph)}</p>`);
@@ -640,8 +659,13 @@ export async function createProjectDossierPrintHtml(
content.push('<p>Não há informação fornecida nesta secção.</p>');
content.push('</section>');
}
// Page margin boxes reserve footer space on every printed page. A fixed
// body element can overlap fragmented tables in Chromium's print layout.
const printFooter = JSON.stringify(
`InvesteMoz · Relatório privado · ${model.version}`,
).replaceAll('<', '\\3c ');
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}.report-table th:first-child,.report-table td:first-child{width:22%}.report-table th:last-child,.report-table td:last-child{width:32%}.report-table td:last-child{font-size:9pt;color:#40524d}.annex-table{table-layout:fixed;font-size:9pt}thead{display:table-header-group}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}th{color:#17211f;background:#edf4f1}footer{position:fixed;bottom:-14mm;left:0;right:0;font-size:9pt;color:#40524d}}
@page{size:A4;margin:18mm 17mm 20mm;@bottom-left{content:${printFooter};font:9pt/1.3 Arial,sans-serif;color:#40524d;vertical-align:middle;width:80%}@bottom-right{content:counter(page) " / " counter(pages);font:9pt/1.3 Arial,sans-serif;color:#40524d;vertical-align:middle}}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}.report-table th:first-child,.report-table td:first-child{width:22%}.report-table th:last-child,.report-table td:last-child{width:32%}.report-table td:last-child{font-size:9pt;color:#40524d}.annex-table{table-layout:fixed;font-size:9pt}thead{display:table-header-group}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}th{color:#17211f;background:#edf4f1}footer{display:none}}
</style></head><body><h1>InvesteMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvesteMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
}
+86 -14
View File
@@ -1,4 +1,5 @@
import { displayValue } from './display-value.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import {
buildProjectDossierRows,
type ProjectDossierRow,
@@ -111,29 +112,57 @@ function classification(origin: string): string {
return 'Referência/estado indicado';
}
export function formatDossierReportValue(field: string, value: string, currency = ''): string {
export function formatDossierReportValue(
field: string,
value: string,
currency = '',
): string {
if (field === 'Estado do plano detalhado') {
return value.replace('Snapshot versionado disponível; usar também a exportação do plano financeiro', 'Plano guardado disponível; consulte também a exportação financeira');
return value.replace(
'Snapshot versionado disponível; usar também a exportação do plano financeiro',
'Plano guardado disponível; consulte também a exportação financeira',
);
}
if (field === 'Reconciliação do financiamento') {
const states: Record<string, string> = { shortfall: 'Financiamento insuficiente', surplus: 'Fontes superiores aos usos previstos', balanced: 'Fontes e usos equilibrados', unknown: 'Informação insuficiente', incomplete: 'Informação insuficiente' };
const states: Record<string, string> = {
shortfall: 'Financiamento insuficiente',
surplus: 'Fontes superiores aos usos previstos',
balanced: 'Fontes e usos equilibrados',
unknown: 'Informação insuficiente',
incomplete: 'Informação insuficiente',
};
return states[value] ?? value;
}
if (/^Necessidade \d+$/.test(field)) {
value = value.replace('; gap;', '; Competência em falta;').replace('; unknown;', '; Por confirmar;').replace('; available;', '; Disponível (declarado);');
value = value
.replace('; gap;', '; Competência em falta;')
.replace('; unknown;', '; Por confirmar;')
.replace('; available;', '; Disponível (declarado);');
}
if (/^Marco \d+$/.test(field)) {
value = value.replace('; planned;', '; Planeado;').replace('; in_progress;', '; Em curso;').replace('; completed;', '; Concluído (declarado);');
value = value
.replace('; planned;', '; Planeado;')
.replace('; in_progress;', '; Em curso;')
.replace('; completed;', '; Concluído (declarado);');
}
if (/^(Necessidade|Marco) \d+$/.test(field)) {
value = value.replace(/(custo|planeado|realizado): ([^;]*)(?=;|$)/g, (_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
}).replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
value = value
.replace(
/(custo|planeado|realizado): ([^;]*)(?=;|$)/g,
(_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
},
)
.replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
}
// Presentation only: group the decimal string without converting the amount
// to a JavaScript float or changing the saved financial snapshot.
return value.replace(/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/, (_, sign: string, whole: string, cents: string, currency: string) => `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`);
return value.replace(
/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/,
(_, sign: string, whole: string, cents: string, currency: string) =>
`${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`,
);
}
const rowSection: Record<string, DossierReportSectionId> = {
Projecto: 'project',
@@ -143,6 +172,7 @@ const rowSection: Record<string, DossierReportSectionId> = {
'Empréstimo educativo': 'finance',
'Ponto de equilíbrio': 'finance',
Orçamento: 'finance',
Tesouraria: 'finance',
Equipa: 'team',
'Necessidade de negócio': 'resources',
Financiamento: 'resources',
@@ -214,6 +244,36 @@ function makeSections(
classification: 'Referência da revisão',
},
);
const highlights = new Set([
'Saldo operacional estimado',
'Diferença por financiar',
'Prestação do primeiro período',
'Saldo final total',
'Caixa conhecida antes de imposto em falta',
]);
const budgetPeriod = rows.find(
(row) => row[1] === 'Periodicidade do orçamento',
)?.[2];
for (const [, field, value, origin] of rows.slice(1)) {
if (!highlights.has(field)) continue;
summary.rows.push({
field:
field === 'Saldo operacional estimado' && budgetPeriod
? `${field} — ${budgetPeriod}`
: field === 'Saldo final total'
? 'Saldo final de tesouraria — 12 meses'
: field,
value: absent(
formatDossierReportValue(
field,
value,
text(draft, 'currency'),
),
),
origin,
classification: classification(origin),
});
}
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
@@ -226,9 +286,14 @@ function makeSections(
if (!section) continue;
section.rows.push({
field,
value: absent(formatDossierReportValue(field, value, text(draft, 'currency'))),
value: absent(
formatDossierReportValue(field, value, text(draft, 'currency')),
),
origin,
classification: value.trim() === '' ? 'Informação em falta' : classification(origin),
classification:
value.trim() === ''
? 'Informação em falta'
: classification(origin),
});
}
@@ -280,6 +345,11 @@ function makeSections(
}
const finance = sections.get('finance');
if (finance && ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(currentCalculation?.snapshot?.scenario_kind)) && currentCalculation?.snapshot) {
for (const [field, value] of fiscalQaRows(currentCalculation.snapshot)) {
finance.rows.push({ field, value, origin: 'Snapshot fiscal preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar' });
}
}
if (finance && currentCalculation) {
const snapshot = currentCalculation.snapshot;
const result =
@@ -353,10 +423,12 @@ function makeSections(
{
field: 'Fórmula e valores usados',
value:
snapshot && typeof snapshot.formula_version === 'string'
['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind))
? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.`
: snapshot && typeof snapshot.formula_version === 'string'
? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}`
: 'A fórmula não foi identificada no snapshot; ver explicação da execução',
origin: 'Snapshot guardado; sem aplicação de regra legal',
origin: ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind)) ? (snapshot?.scenario_kind === 'fiscal_rule_published' ? 'Versão publicada na admissão; sem validação jurídica da operação' : 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação') : 'Snapshot guardado; sem aplicação de regra legal',
classification: 'Reutilização do resultado existente',
},
{
+68 -2
View File
@@ -1,5 +1,9 @@
import { displayValue } from './display-value.ts';
import { csvCellWithDelimiter } from './csv.js';
import {
restoreCashflowDraftSnapshot,
projectMonthlyCashflow,
} from './monthly-cashflow.ts';
import {
calculateFinancialTools,
restoreFinancialToolsSnapshot,
@@ -330,7 +334,7 @@ export function buildProjectDossierRows(
[
'Empréstimo educativo',
'Taxa anual pressuposta',
`${finance.loan.rate}% (${finance.loan.rateType})`,
`${finance.loan.rate}% (${finance.loan.rateType === 'nominal' ? 'nominal anual' : 'efectiva anual'})`,
'Declarada pelo utilizador; não é taxa de produto',
],
[
@@ -342,7 +346,7 @@ export function buildProjectDossierRows(
[
'Empréstimo educativo',
'Método e pressupostos substituídos',
`Saldo decrescente; capital ${finance.loan.principal}; taxa ${finance.loan.rate}% ${finance.loan.rateType}; ${finance.loan.months} meses; período ${finance.loan.periodMonths} meses; carência ${finance.loan.gracePeriods}; comissão inicial ${finance.loan.upfrontFee}; comissão periódica ${finance.loan.recurringFee}`,
`Saldo decrescente; capital ${finance.loan.principal} ${currency}; taxa ${finance.loan.rate}% ${finance.loan.rateType === 'nominal' ? 'nominal anual (dividida pelos períodos do ano)' : 'efectiva anual (convertida para o período)'}; prazo ${finance.loan.months} meses; periodicidade ${finance.loan.periodMonths} meses; carência ${finance.loan.gracePeriods} períodos; comissão inicial ${finance.loan.upfrontFee} ${currency}; comissão periódica ${finance.loan.recurringFee} ${currency}. Montantes a duas casas decimais; juros e prestação arredondados à unidade monetária mínima, metade para cima; última prestação ajusta o saldo residual.`,
'Cronograma do simulador; taxa declarada, sem correspondência a produto',
],
[
@@ -604,6 +608,68 @@ export function buildProjectDossierRows(
'Planeado/realizado declarado pelo utilizador',
]);
}
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(
String(draft.cashflow_snapshot),
);
const origin =
'Estimativa de planeamento; pressupostos guardados da ferramenta que podem incluir exemplos iniciais. Não confirma impostos nem recebimentos.';
try {
if (!saved)
throw new Error(
'Pressupostos de tesouraria não recuperáveis; não foram substituídos por valores alternativos.',
);
const currency = String(draft.currency ?? '');
if (!currency)
throw new Error(
'Indique uma moeda para interpretar os montantes de tesouraria.',
);
const input = {
...saved.inputs,
currency,
...(saved.taxMode === 'fixed'
? { monthlyTaxSchedule: undefined }
: { monthlyTaxAssessed: '' }),
};
const projection = projectMonthlyCashflow(input);
const amount = (value: string | null) =>
value === null ? 'Indeterminado' : `${value} ${currency}`;
rows.push([
'Tesouraria',
'Pressupostos e período',
`12 meses; moeda ${currency}; saldo inicial ${saved.inputs.openingCash}; receita mensal ${saved.inputs.monthlyRevenue}; custos mensais ${saved.inputs.monthlyOperatingCosts}; dívida mensal ${saved.inputs.monthlyDebtService}; imposto ${saved.taxMode === 'fixed' ? 'mensal fixo: ' + (saved.inputs.monthlyTaxAssessed || 'não informado') : 'variável por mês (vazio não é zero)'}. Crédito estimado ${saved.inputs.vatCreditEstimated}; pedido ${saved.inputs.vatRefundRequested}; reconhecido ${saved.inputs.vatRefundRecognized}; recebimento previsto ${saved.inputs.vatRefundExpectedReceipt}; mês previsto ${saved.inputs.vatRefundExpectedMonth || 'não informado'}.`,
origin,
]);
rows.push([
'Tesouraria',
'Saldo final total',
amount(projection.endingCash),
origin,
]);
rows.push([
'Tesouraria',
'Caixa conhecida antes de imposto em falta',
amount(projection.endingCashBeforeUnassessedTax),
origin,
]);
for (const row of projection.rows)
rows.push([
'Tesouraria',
`Mês ${row.month}`,
`Imposto assumido: ${amount(row.taxAssessed)}; recebimento de IVA previsto: ${amount(row.vatRefundExpectedReceipt)}; fluxo líquido: ${amount(row.netFlow)}; saldo final total: ${amount(row.closingCash)}.`,
origin,
]);
} catch (cause) {
rows.push([
'Tesouraria',
'Estado da tesouraria',
cause instanceof Error
? cause.message
: 'Tesouraria indeterminada; reveja os pressupostos.',
origin,
]);
}
}
return rows;
}
+37 -2
View File
@@ -1,4 +1,5 @@
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
export type SimulationDraft = Record<string, string | string[]>;
@@ -28,9 +29,10 @@ export function getFiscalPeriodRange(
return { start: `${yearValue}-01-01`, end: `${yearValue}-12-31` };
if (draft.fiscal_periodicity === 'one_off') {
const date = String(draft.fiscal_reference_date ?? '');
const parsed = new Date(`${date}T00:00:00.000Z`);
return /^\d{4}-\d{2}-\d{2}$/.test(date) &&
new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) ===
date &&
Number.isFinite(parsed.getTime()) &&
parsed.toISOString().slice(0, 10) === date &&
date.startsWith(`${yearValue}-`)
? { start: date, end: date }
: null;
@@ -1543,6 +1545,10 @@ export function validateSimulationDraft(
draft: SimulationDraft,
sectionId: string,
): Record<string, string> {
// The separate arithmetic validator checks its three inputs. Fiscal
// eligibility facts are not inputs of this planning-only calculation.
if (sectionId === 'tax' && draft.fiscal_scenario_mode === 'custom_planning')
return {};
const errors: Record<string, string> = {};
for (const field of simulationFieldGroups[sectionId] ?? []) {
if (field.showWhen && !field.showWhen(draft)) continue;
@@ -1593,6 +1599,15 @@ export function validateSimulationDraft(
errors[field.key] =
'Introduza um endereço de correio electrónico válido.';
}
if (
sectionId === 'tax' &&
draft.fiscal_scenario_mode === 'approved_parameters' &&
draft.fiscal_periodicity === 'one_off' &&
!getFiscalPeriodRange(draft)
) {
errors.fiscal_reference_date =
'Introduza uma data válida da operação no ano fiscal seleccionado. Não foi usada uma data alternativa.';
}
if (
sectionId === 'financial' &&
[
@@ -1660,6 +1675,19 @@ export function readArithmeticSnapshot(
}
const result = snapshot as Record<string, unknown>;
const value = result.indicative_total;
if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) {
const presentation = result.presentation as Record<string, unknown> | undefined;
if (isApiSimulationResultSnapshotV1(result) && fiscalQaRows(result).length > 0 &&
result.result.value === value && presentation?.value === value &&
presentation?.rounding_applied === false && result.context.currency === 'MZN' &&
result.result.unit === 'MZN' &&
((result.result.status === 'calculated' && typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)) ||
(result.result.status === 'indeterminate' && value === null && result.result.missing_inputs.length > 0))) {
return { integrity: 'versioned', value: value as string | null,
context: result.context as unknown as Record<string, unknown>, explanation: result.result.explanation };
}
return { integrity: 'invalid', value: null, context: null, explanation: null };
}
if (typeof value !== 'string' || !/^(?:\d+)(?:\.\d+)?$/.test(value)) {
return {
integrity: 'invalid',
@@ -1834,6 +1862,13 @@ export function exportCompletedArithmeticCsv(
);
}
const result = simulation.result_snapshot as Record<string, unknown>;
if (['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(result.scenario_kind))) {
const rows = fiscalQaRows(result);
const report = readVersionedArithmeticReportReference(simulation);
if (!rows.length || !report) throw new Error('A pré-visualização fiscal não corresponde ao relatório preservado.');
return `\uFEFF${[...rows, ['ID do relatório', report.id], ['Hash do relatório', report.report_sha256], ['Data de exportação', exportedAt]]
.map((field) => field.map(csvCell).join(',')).join('\r\n')}`;
}
const decimal = (value: unknown, label: string) => {
if (typeof value !== 'string' || !/^(?:\d+)(?:\.\d+)?$/.test(value))
throw new Error(
+9 -9
View File
@@ -16,7 +16,7 @@ import PasskeyVerify from '@/components/passkey-verify';
export default function ConfirmPassword() {
return (
<>
<Head title="Confirm password" />
<Head title="Confirmar palavra-passe" />
{/* @chisel-passkeys */}
<PasskeyVerify
@@ -24,9 +24,9 @@ export default function ConfirmPassword() {
options: confirmOptions(),
submit: confirmStore(),
}}
label="Confirm with passkey"
loadingLabel="Confirming..."
separator="Or confirm with password"
label="Confirmar com chave de acesso"
loadingLabel="A confirmar…"
separator="Ou confirmar com palavra-passe"
/>
{/* @end-chisel-passkeys */}
@@ -34,11 +34,11 @@ export default function ConfirmPassword() {
{({ processing, errors }) => (
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Palavra-passe</Label>
<PasswordInput
id="password"
name="password"
placeholder="Password"
placeholder="Palavra-passe"
autoComplete="current-password"
autoFocus
/>
@@ -53,7 +53,7 @@ export default function ConfirmPassword() {
data-test="confirm-password-button"
>
{processing && <Spinner />}
Confirm password
Confirmar palavra-passe
</Button>
</div>
</div>
@@ -64,7 +64,7 @@ export default function ConfirmPassword() {
}
ConfirmPassword.layout = {
title: 'Confirm password',
title: 'Confirmar palavra-passe',
description:
'This is a secure area of the application. Please confirm your password before continuing.',
'Esta é uma área protegida. Confirme a sua palavra-passe para continuar.',
};
+10 -6
View File
@@ -12,7 +12,7 @@ import { email } from '@/routes/password';
export default function ForgotPassword({ status }: { status?: string }) {
return (
<>
<Head title="Forgot password" />
<Head title="Recuperar palavra-passe" />
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
@@ -25,7 +25,10 @@ export default function ForgotPassword({ status }: { status?: string }) {
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">
{' '}
Endereço de correio electrónico{' '}
</Label>
<Input
id="email"
type="email"
@@ -46,8 +49,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
>
{processing && (
<LoaderCircle className="h-4 w-4 animate-spin" />
)}
Email password reset link
)}{' '}
Enviar ligação de recuperação{' '}
</Button>
</div>
</>
@@ -64,6 +67,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
}
ForgotPassword.layout = {
title: 'Forgot password',
description: 'Enter your email to receive a password reset link',
title: 'Recuperar palavra-passe',
description:
'Indique o seu email para receber a ligação de recuperação da palavra-passe',
};
+20 -8
View File
@@ -39,15 +39,19 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
Ambiente QA — conta {qaAccount.id}
</p>
<p>
Para os cenários A/B: {qaAccount.email}. Use a password
e o autenticador desta conta. Outros perfis devem usar
as respetivas contas sintéticas QA.
Para os cenários A/B: {qaAccount.email}. Use a
palavra-passe e o autenticador desta conta. Outros
perfis devem usar as respetivas contas sintéticas QA.
</p>
</aside>
)}
{/* @chisel-passkeys */}
<PasskeyVerify label="Entrar com chave de acesso" loadingLabel="A autenticar…" separator="Ou continuar com email" />
<PasskeyVerify
label="Entrar com chave de acesso"
loadingLabel="A autenticar…"
separator="Ou continuar com email"
/>
{/* @end-chisel-passkeys */}
<Form
@@ -68,7 +72,10 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
defaultValue={qaAccount?.email ?? participationIdentity().email}
defaultValue={
qaAccount?.email ??
participationIdentity().email
}
/>
<InputError
message={errors.email ?? pageErrors.email}
@@ -77,7 +84,9 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Palavra-passe</Label>
<Label htmlFor="password">
Palavra-passe
</Label>
{canResetPassword && (
<TextLink
href={request()}
@@ -105,7 +114,9 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Manter sessão iniciada</Label>
<Label htmlFor="remember">
Manter sessão iniciada
</Label>
</div>
<Button
@@ -143,5 +154,6 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
Login.layout = {
title: 'Entrar na sua conta',
description: 'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.',
description:
'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.',
};
+10 -9
View File
@@ -16,7 +16,7 @@ type Props = {
export default function ResetPassword({ token, email, passwordRules }: Props) {
return (
<>
<Head title="Reset password" />
<Head title="Redefinir palavra-passe" />
<Form
{...update.form()}
@@ -43,14 +43,14 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"> Palavra-passe </Label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
placeholder="Palavra-passe"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -58,14 +58,15 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
{' '}
Confirmar palavra-passe{' '}
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={passwordRules}
/>
<InputError
@@ -80,8 +81,8 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
disabled={processing}
data-test="reset-password-button"
>
{processing && <Spinner />}
Reset password
{processing && <Spinner />} Redefinir
palavra-passe{' '}
</Button>
</div>
)}
@@ -91,6 +92,6 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
}
ResetPassword.layout = {
title: 'Reset password',
description: 'Please enter your new password below',
title: 'Redefinir palavra-passe',
description: 'Introduza a nova palavra-passe',
};
@@ -27,18 +27,18 @@ export default function TwoFactorChallenge({
}>(() => {
if (showRecoveryInput) {
return {
title: 'Recovery code',
title: 'Código de recuperação',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
toggleText: 'login using an authentication code',
'Confirme o acesso à conta com um dos seus códigos de recuperação.',
toggleText: 'entrar com um código de autenticação',
};
}
return {
title: 'Authentication code',
title: 'Código de autenticação',
description:
'Enter the authentication code provided by your authenticator application.',
toggleText: 'login using a recovery code',
'Introduza o código apresentado pela sua aplicação autenticadora.',
toggleText: 'entrar com um código de recuperação',
};
}, [showRecoveryInput]);
@@ -55,7 +55,7 @@ export default function TwoFactorChallenge({
return (
<>
<Head title="Two-factor authentication" />
<Head title="Autenticação de dois factores" />
{qaAccount && (
<p
@@ -81,7 +81,7 @@ export default function TwoFactorChallenge({
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
placeholder="Introduza um código de recuperação"
autoFocus={showRecoveryInput}
required
/>
@@ -123,7 +123,8 @@ export default function TwoFactorChallenge({
className="w-full"
disabled={processing}
>
Continue
{' '}
Continuar{' '}
</Button>
<div className="text-muted-foreground text-center text-sm">
+10 -9
View File
@@ -9,12 +9,13 @@ import { send } from '@/routes/verification';
export default function VerifyEmail({ status }: { status?: string }) {
return (
<>
<Head title="Email verification" />
<Head title="Confirmação de email" />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address
you provided during registration.
{' '}
Foi enviada uma nova ligação de confirmação para o email
indicado no registo.{' '}
</div>
)}
@@ -22,15 +23,16 @@ export default function VerifyEmail({ status }: { status?: string }) {
{({ processing }) => (
<>
<Button disabled={processing} variant="secondary">
{processing && <Spinner />}
Resend verification email
{processing && <Spinner />} Reenviar email de
confirmação{' '}
</Button>
<TextLink
href={logout()}
className="mx-auto block text-sm"
>
Log out
{' '}
Terminar sessão{' '}
</TextLink>
</>
)}
@@ -40,7 +42,6 @@ export default function VerifyEmail({ status }: { status?: string }) {
}
VerifyEmail.layout = {
title: 'Email verification',
description:
'Please verify your email address by clicking on the link we just emailed to you.',
title: 'Confirmação de email',
description: 'Confirme o seu email através da ligação que enviámos.',
};
@@ -6,7 +6,11 @@ import {
type TerritoryOption,
} from '@/components/investment-territory';
import { businessModalities } from './business-types';
import { ClassificationSummary, classificationStates, type PublicClassification, type ClassificationOptions } from '@/components/opportunity-classification';
import {
ClassificationSummary,
type PublicClassification,
type ClassificationOptions,
} from '@/components/opportunity-classification';
type PublicBusiness = {
multidimensional?: PublicClassification[];
@@ -57,7 +61,22 @@ export default function BusinessMarketplace({
classificationOptions,
authorities = [],
}: Props) {
const [classification, setClassification] = useState<Record<string, string>>(() => Object.fromEntries(['sector', 'association_type', 'association_target', 'association_purpose', 'association_state', 'chain_id', 'authority_id', 'subsector_id'].map((key) => [key, filters[key] ?? ''])));
const [classification, setClassification] = useState<
Record<string, string>
>(() =>
Object.fromEntries(
[
'sector',
'association_type',
'association_target',
'association_purpose',
'association_state',
'chain_id',
'authority_id',
'subsector_id',
].map((key) => [key, filters[key] ?? '']),
),
);
const [query, setQuery] = useState(filters.q ?? '');
const [modality, setModality] = useState(filters.modality ?? '');
const [territory, setTerritory] = useState<Record<string, string>>({
@@ -124,6 +143,37 @@ export default function BusinessMarketplace({
)}
</select>
</label>
{classificationOptions && (
<label className="grid gap-1">
<span>Sector</span>
<select
className={input}
value={classification.sector}
onChange={(event) =>
setClassification((current) => ({
...current,
sector: event.target.value,
subsector_id: '',
chain_id: '',
}))
}
>
<option value="">Todos</option>
{(classificationOptions.targets.sector ?? []).map(
(option) => (
<option
key={option.id}
value={String(option.id)}
>
{/fintech/i.test(option.label)
? 'Fintech — especialização do Mercado Financeiro'
: option.label}
</option>
),
)}
</select>
</label>
)}
<TerritoryFields
initial={territorySelection}
onChange={(path) =>
@@ -135,39 +185,117 @@ export default function BusinessMarketplace({
})
}
/>
{classificationOptions && <fieldset className="w-full rounded border p-3">
<legend>Classificação multidimensional — filtros combinados</legend>
<p>Critérios diferentes combinam-se por «e». Tipo, referência, finalidade e estado têm de corresponder à mesma relação pública. O setor principal mantém o seu significado.</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{([
['sector', 'Setor principal do anúncio', classificationOptions.targets.sector ?? []],
['association_type', 'Tipo de associação', Object.keys(classificationOptions.types).map((id) => ({ id, label: ({ corridor: 'Corredor', location: 'Localização', cluster: 'Cluster', project: 'Projeto âncora', chain: 'Cadeia de valor', park: 'Parque industrial', zone: 'Regime de zona', sector: 'Setor associado', activity: 'Subsector ou atividade' } as Record<string, string>)[id] ?? id }))],
['association_target', 'Referência da associação', classificationOptions.targets[classification.association_type] ?? []],
['association_purpose', 'Finalidade', Object.entries(classificationOptions.purposes).map(([id, label]) => ({ id, label }))],
['association_state', 'Estado da associação', Object.entries(classificationStates).filter(([id]) => !['archived', 'revoked'].includes(id)).map(([id, label]) => ({ id, label }))],
['chain_id', 'Cadeia de valor', classificationOptions.targets.chain ?? []],
['subsector_id', 'Subsector ou atividade', classificationOptions.targets.activity ?? []],
['authority_id', 'Entidade competente', authorities.map((a) => ({ id: a.id, label: a.label_pt_mz }))],
] as [string, string, { id: string | number; label: string }[]][]).map(([key, label, options]) => <label key={key} className="grid gap-1"><span>{label}</span><select className={input} value={classification[key]} onChange={(e) => setClassification((old) => ({ ...old, [key]: e.target.value, ...(key === 'association_type' ? { association_target: '' } : {}) }))}><option value="">Todos</option>{options.map((o) => <option key={o.id} value={String(o.id)}>{o.label}</option>)}</select></label>)}
</div>
</fieldset>}
{classificationOptions && (
<details className="w-full rounded border p-3">
<summary className="cursor-pointer font-semibold">
Mais filtros
</summary>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(
[
[
'association_type',
'Tipo de associação',
Object.keys(
classificationOptions.types,
).map((id) => ({
id,
label:
(
{
corridor: 'Corredor',
location: 'Localização',
cluster: 'Cluster',
project:
'Projeto âncora',
chain: 'Cadeia de valor',
park: 'Parque industrial',
zone: 'Regime de zona',
sector: 'Setor associado',
activity:
'Subsector ou atividade',
} as Record<string, string>
)[id] ?? id,
})),
],
[
'association_target',
'Referência da associação',
classificationOptions.targets[
classification.association_type
] ?? [],
],
[
'association_purpose',
'Finalidade',
Object.entries(
classificationOptions.purposes,
).map(([id, label]) => ({ id, label })),
],
[
'chain_id',
'Cadeia de valor',
classificationOptions.targets.chain ??
[],
],
[
'subsector_id',
'Subsector ou atividade',
classificationOptions.targets
.activity ?? [],
],
[
'authority_id',
'Entidade competente',
authorities.map((a) => ({
id: a.id,
label: a.label_pt_mz,
})),
],
] as [
string,
string,
{ id: string | number; label: string }[],
][]
).map(([key, label, options]) => (
<label key={key} className="grid gap-1">
<span>{label}</span>
<select
className={input}
value={classification[key]}
onChange={(e) =>
setClassification((old) => ({
...old,
[key]: e.target.value,
...(key === 'association_type'
? { association_target: '' }
: {}),
}))
}
>
<option value="">Todos</option>
{options.map((o) => (
<option
key={o.id}
value={String(o.id)}
>
{o.label}
</option>
))}
</select>
</label>
))}
</div>
</details>
)}
<button className={button}>Pesquisar</button>
</form>
<p role="status">
{results.meta?.total ?? results.data.length} anúncios de
negócios publicados
</p>
{!results.data.length && (
<p role="status">
Não existem oportunidades publicadas com estes critérios.
</p>
)}
<ul className="grid gap-4 md:grid-cols-2">
{results.data.map((record) => (
<li className={card} key={record.id}>
<p>
{businessModalities[record.modality] ??
record.modality}
'Modalidade por indicar'}
</p>
<h2 className="text-xl font-bold">
<Link
@@ -192,7 +320,11 @@ export default function BusinessMarketplace({
{record.amount_max ?? '—'} {record.currency}
</p>
)}
{!!record.multidimensional?.length && <ClassificationSummary rows={record.multidimensional} />}
{!!record.multidimensional?.length && (
<ClassificationSummary
rows={record.multidimensional}
/>
)}
{record.objective && (
<p className="mt-3 line-clamp-3">
{record.objective}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More