Compare commits

...
Author SHA1 Message Date
nala 5f4eb5cfaa Record authorized adaptive path deployment and enabled destination state 2026-10-22 10:18:38 +02:00
nala 042ffbde44 Preserve legacy draft shape when no compatible context is inherited
tests / ci (push) Canceled after 0s
2026-10-22 10:14:46 +02:00
nala 064296e1fb Record current v4 report proof and adaptive path integration dependencies
tests / ci (push) Canceled after 0s
2026-10-22 10:06:08 +02:00
nala a1fbda06ad Wire grounded private investment preparation into QA dossier path 2026-10-22 10:00:23 +02:00
nala 52fbb1468a Close context increment review and ground first adaptive investment path 2026-10-22 09:25:14 +02:00
nala a76369eb86 Preserve dossier context and expose contextual applicability in QA 2026-10-22 09:16:15 +02:00
nala 57fc914821 Merge verified IVA monthly projection and Modelo A interface into homologation
tests / ci (push) Canceled after 0s
2026-10-21 14:34:47 +02:00
paulomassigue f3ec686f8c diminuicao do tamanho do footer
tests / ci (push) Canceled after 0s
2026-10-21 13:16:35 +02:00
paulomassigue d3fe8a02ec Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-10-21 12:46:54 +02:00
paulomassigue f5b1d67ee5 remocao de inicio em descoberta 2026-10-21 12:46:26 +02:00
paulomassigue 90179149c7 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 2026-10-21 12:42:05 +02:00
paulomassigue 364be835fe correcao de conflitos 2026-10-21 12:41:18 +02:00
paulomassigue 480effa5fc separacao de sobre nos no dropdow Tempo 2026-10-21 12:36:08 +02:00
37 changed files with 3435 additions and 800 deletions
+7
View File
@@ -39,3 +39,10 @@ Referenciar estas orientações nos requisitos correspondentes da matriz existen
- Preservar o ficheiro original, a proveniência, o identificador exacto, a edição/data, o hash e o histórico. Não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência.
- Para fontes fiscais, inventariar também as fontes não analisadas e os diplomas/artigos sem correspondência documental. Parâmetros ausentes ou ambiguidades permanecem indeterminados; não usar zero nem a taxa mais recente como substituto.
- Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária. Testes e migrações devem usar exclusivamente uma base QA isolada com guarda contra a base activa.
## Execução das fontes e decisões — orientação permanente da Tempo, 22/10/2026
A legislação, os formulários e os procedimentos oficiais disponibilizados são fontes válidas para implementar os requisitos. As decisões e procedimentos da Tempo orientam a sua utilização no portal. Traduzir essas fontes em campos, regras, documentos, cálculos e percursos executáveis com rastreabilidade, sem exigir nova validação jurídica genérica ou criar aprovadores, restrições ou condições por interpretação do Codex.
Uma divergência concreta deve identificar os trechos, períodos e comportamento afectados; prosseguir com os restantes requisitos. Uma decisão de implementação em falta deve ser apresentada especificamente à Tempo, sem a substituir por regra inventada. Rever e remover bloqueios anteriores sem fundamento numa fonte aplicável ou decisão expressa da Tempo, preservando dados e histórico. A rastreabilidade explica a execução e não constitui uma autoridade adicional. Mantêm-se os limites autorizados de QA e das alterações no activo.
@@ -170,8 +170,10 @@ class InvestmentSubmissionController extends Controller
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
@@ -190,11 +192,12 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
@@ -33,7 +33,8 @@ class StoreLandAssistanceCaseRequest extends FormRequest
return [
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
'payload' => ['required', 'array:support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload' => ['required', 'array:context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
'payload.request_description' => ['nullable', 'string', 'max:4000'],
'payload.project_reference' => ['nullable', 'string', 'max:160'],
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'monthly_iva_settings', 'monthly_iva_snapshot', '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',
'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', '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',
@@ -72,6 +72,14 @@ class StoreSimulationFormDraftRequest extends FormRequest
'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.context_applicability_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.published_iva_base' => ['nullable', 'string', 'max:40'],
'economic_snapshot.published_iva_currency' => ['nullable', 'string', 'max:3'],
'economic_snapshot.published_iva_date' => ['nullable', 'string', 'max:10'],
'economic_snapshot.published_iva_scope' => ['nullable', 'string', 'in:yes,no'],
'economic_snapshot.monthly_iva_settings' => ['nullable', 'string', 'max:2000'],
'economic_snapshot.monthly_iva_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
+118 -24
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\SimulationFormDraft;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
@@ -12,6 +13,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Context belongs to the existing encrypted dossier. Sources remain read-only. */
@@ -21,7 +23,7 @@ final class InvestorContext
public const SOURCE = 'investor-context';
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'];
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'currency', 'expected_start_date', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'investor_origin', 'person_type', 'entity_stage', 'tax_residency_country', 'fiscal_residency_country', 'activity_id', 'project_type', 'maturity_stage', 'special_zone_id', 'investment_regime', ...PrivateInvestmentPreparation::FIELDS];
public function __construct(private readonly VirtualDesk $desk) {}
@@ -34,8 +36,26 @@ final class InvestorContext
{
$payload = $this->desk->decode($case->payload);
return $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
$context = $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
'fields' => ['project_title' => $case->title], 'provenance' => ['project_title' => ['kind' => 'dossier', 'id' => $case->id, 'version' => $case->version]], 'review_steps' => []];
// Explicit dossier anchor only: never the account's most recent simulation or another owner's draft.
if (($case->simulation_id ?? null) && request()->user()?->id === (int) $case->user_id) {
$draft = SimulationFormDraft::whereKey($case->simulation_id)->where('owner_user_id', $case->user_id)->whereNull('deleted_at')->first();
if ($draft) {
$values = [...SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), ...SimulationFormDraft::decryptSnapshot($draft->economic_snapshot)];
foreach (Arr::only($values, self::FIELDS) as $key => $value) {
if (is_scalar($value) && (string) $value !== '' && (! array_key_exists($key, $context['fields']) || ($context['provenance'][$key]['kind'] ?? null) === 'linked_simulation')) {
$context['fields'][$key] = (string) $value;
$context['provenance'][$key] = ['kind' => 'linked_simulation', 'id' => $draft->id, 'version' => $draft->version_no, 'sha256' => $draft->snapshot_sha256];
}
}
}
}
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
return $context;
}
public function dependencyHash(array $selection): string
@@ -53,17 +73,20 @@ final class InvestorContext
$documents = [];
$visibility = 'public';
$state = null;
$zoneAnalysis = null;
if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
$o = Opportunity::with('sector')->findOrFail((int) $id);
$published = Opportunity::published()->whereKey($o->id)->exists();
if (!$published) {
if (! $published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
$visibility = 'private';
$state = app(PortalProfileAccess::class)->state($o);
}
if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
$draft = !$published ? app(OpportunityManagement::class)->values($o) : [];
if (request()->user()) {
$documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
}
$draft = ! $published ? app(OpportunityManagement::class)->values($o) : [];
$title = $draft['canonical']['official_title'] ?? $o->title;
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version;
@@ -122,19 +145,28 @@ final class InvestorContext
$fields = $this->territoryFields($id);
} elseif ($kind === 'park') {
abort_unless(Str::isUuid($id), 422);
$p = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id);
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id);
$title = $p->name;
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
$version = $p->version_no;
$zoneAnalysis = ['conditions' => app(InvestmentZoneOperationalDetails::class)->publicData($p)['conditions'] ?? [],
'source' => $p->legal_source_id ? DB::table('investment_legal_sources')->where('id', $p->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'valid_from', 'valid_until']) : null,
'recognition' => 'undetermined', 'executable_benefit' => false];
$href = '/zonas/'.$id;
if ($p->primary_territory_id) {
$fields = $this->territoryFields($p->primary_territory_id);
$fields = [...$fields, ...$this->territoryFields($p->primary_territory_id)];
}
} else {
abort(422, 'Tipo de referência inválido.');
}
$fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== '');
$record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields];
if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
if ($zoneAnalysis !== null) {
$record['analysis'] = $zoneAnalysis;
}
if ($documents || $visibility === 'private') {
$record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
}
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
}
@@ -170,7 +202,11 @@ final class InvestorContext
}
if (request()->user()) {
$private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get();
foreach ($private as $o) if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
foreach ($private as $o) {
if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
}
}
}
if (config('existing-business.enabled')) {
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
@@ -179,7 +215,7 @@ final class InvestorContext
}
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'parks' => app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->orderBy('name')->get(['id', 'name', 'version_no'])];
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
}
public function current(User $actor): array
@@ -232,10 +268,21 @@ final class InvestorContext
private function input(array $input): array
{
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', '_token']), 422, 'Campo de controlo desconhecido.');
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
return Validator::make($input, ['version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.');
$inputRules = [
'document_links' => 'sometimes|array:'.implode(',', PrivateInvestmentPreparation::DOCUMENTS),
'document_links.*' => 'nullable|uuid',
];
foreach (app(PrivateInvestmentPreparation::class)->questions() as $question) {
$inputRules['declarations.'.$question['key']] = isset($question['options']) ? ['nullable', Rule::in(array_keys($question['options']))] : ['nullable', 'regex:/^\d+(?:\.\d+)?$/D', 'numeric', 'min:0', 'max:999999999999999999'];
}
return Validator::make($input, [...$inputRules, 'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
}
@@ -247,6 +294,15 @@ final class InvestorContext
abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão atual.');
app(InvestorPath::class)->view($actor, $id);
$old = $this->state($case);
$links = $old['preparation_documents'] ?? [];
foreach ($data['document_links'] ?? [] as $key => $document) {
if ($document) {
abort_unless(DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.id', $document)->whereNull('d.deleted_at')->exists(), 422, 'Seleccione um documento já associado a este dossier; os dados anteriores foram preservados.');
$links[$key] = $document;
} else {
unset($links[$key]);
}
}
$selection = $old['selection'];
foreach ($data['selection'] as $slot => $key) {
if ($key === null || $key === '') {
@@ -299,7 +355,7 @@ final class InvestorContext
}
// Remove inherited subordinate geography only when its source was actually replaced.
foreach ($fields as $key => $value) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector'], true) && isset($provenance[$key]['key'])) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector', 'special_zone_id', 'investment_regime'], true) && isset($provenance[$key]['key'])) {
unset($fields[$key],$provenance[$key]);
}
}
@@ -314,14 +370,32 @@ final class InvestorContext
$changed[] = $slot;
}
}
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection);
$changedFields = array_column($replaced, 'field');
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
$calculationInputsChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
$payload = $this->desk->decode($case->payload);
$results = $payload['investor_path']['results'] ?? [];
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
$steps = $dependencyChanged ? array_values(array_unique([...(array_intersect($changed, ['opportunity', 'sector']) ? ['S02', 'S03', 'S04', 'S08', 'S12', 'S13', 'S14', 'S15', 'S17'] : []), ...(array_intersect($changed, ['territory', 'park']) ? ['S08', 'S09', 'S10', 'S11', 'S17'] : [])])) : [];
if (array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...PrivateInvestmentPreparation::FIELDS]))) {
$steps = array_values(array_unique([...$steps, 'S02', 'S03', 'S08', 'S12', 'S13', 'S17']));
}
if ($privateConditionsChanged) {
$steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07']));
}
$privatePreparation = null;
if (app(PrivateInvestmentPreparation::class)->available()) {
$docs = $this->preparationDocuments($id);
$beforePreparation = app(PrivateInvestmentPreparation::class)->assess($old, $docs);
$afterPreparation = app(PrivateInvestmentPreparation::class)->assess(['fields' => $fields, 'selection' => $selection, 'preparation_documents' => $links], $docs);
$privatePreparation = ['before' => $beforePreparation, 'after' => $afterPreparation];
}
$plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $dependencyChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
'preparation_documents' => $links, 'private_investment' => $privatePreparation,
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $calculationInputsChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'calculation_inputs_changed' => $calculationInputsChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
'notice' => 'Não confirma incentivos, licenças, disponibilidade de terra, reserva ou autorização. Submissões e decisões anteriores mantêm o conteúdo enviado.'];
$hash = hash_hmac('sha256', json_encode(Arr::sortRecursive([$actor->id, $data, $plan]), JSON_THROW_ON_ERROR), config('app.key'));
@@ -349,7 +423,7 @@ final class InvestorContext
$before = $this->state($case);
$plan = $this->preview($actor, $id, $input);
abort_unless(hash_equals($plan['preview_hash'], $control['preview_hash']), 409, 'O dossier ou uma fonte mudou desde a prévia; compare novamente.');
if (! $plan['changed_dimensions'] && ! $plan['updated'] && empty($input['declarations']) && empty($input['replace_fields'])) {
if (! $plan['changed_dimensions'] && ! $plan['updated'] && empty($input['declarations']) && empty($input['replace_fields']) && empty($input['document_links'])) {
$result = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => $before['revision'], 'changed' => false, 'replayed' => false];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'correlation_id' => $control['operation_key'], 'action' => 'context_unchanged', 'data_classification' => 'restricted', 'after_data' => json_encode(['case_id' => $id, 'request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
@@ -361,9 +435,9 @@ final class InvestorContext
foreach ($plan['review_steps'] as $step) {
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
}
$context = ['revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash($plan['selection']), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
if ($plan['dependency_changed']) {
$context = ['preparation_documents' => $plan['preparation_documents'], 'preparation_snapshot' => $plan['private_investment']['after'] ?? $before['preparation_snapshot'] ?? null, 'revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
if ($plan['calculation_inputs_changed']) {
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
}
@@ -384,6 +458,8 @@ final class InvestorContext
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$path = app(InvestorPath::class)->view($actor, $id);
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
$simulatorRevision = null;
if ((int) $case->user_id === $actor->id && $case->simulation_id) {
@@ -396,18 +472,36 @@ final class InvestorContext
}
}
return ['simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']),
$preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($ctx, $this->preparationDocuments($id)) : null;
$ctx['preparation_assessment'] = $preparation;
return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']),
'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'],
'prefill' => $this->prefill($ctx), 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
}
public function preparationDocuments(string $id): array
{
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all();
}
public function prefill(array $ctx): array
{
$f = $ctx['fields'];
$sim = Arr::only($f, ['project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'path' => $ctx['path_applicability'] ?? null,
'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
'submission' => array_filter(['sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
}
}
+8 -3
View File
@@ -136,7 +136,7 @@ final class InvestorPath
$documents = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $id)->whereNull('d.deleted_at')
->when(! $owner && ! $manager, fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', $requests->pluck('id'))))
->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status']);
->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256']);
// Only the private draft owner can receive the financial snapshot/result. Dossier access alone is insufficient.
$results = (int) $case->user_id === $actor->id ? ($path['results'] ?? []) : [];
$next = collect($steps)->first(fn ($step) => $step['preparation_allowed'] && ! in_array($step['state'], ['completed', 'approved', 'waiting_external', 'submitted', 'reviewing'], true));
@@ -145,12 +145,17 @@ final class InvestorPath
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id) : null;
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
$preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) {
$next = ['code' => 'S06', 'title' => $preparation['next'], 'action' => 'Preparar', 'href' => '/investimentos/'.$id.'/contexto/editar#preparation-questions'];
}
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $path['fact_reasons'] ?? [],
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => '#'.$next['code']] : null,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? $context : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
@@ -0,0 +1,189 @@
<?php
namespace App\Services;
use Brick\Math\BigDecimal;
/** Adapter of the dossier's declared context to preparation of a private-investment request. No tax or entitlement decision. */
final class PrivateInvestmentPreparation
{
public const VERSION = 'private-investment-preparation-2026-v1';
public const SOURCE = 'https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf';
public const HASH = 'ba1e9eaabfec5d0c4cb2d9f2444a6860f7a90f222034764197829be402d408ed';
public const FIELDS = ['investment_form', 'investment_preparation', 'investment_activity_scope', 'implementation_mode', 'capital_origin', 'site_tenure', 'project_concession', 'significant_implications', 'land_extension', 'forest_concession', 'forest_concession_hectares', 'total_investment', 'area_required', 'area_unit'];
public const DOCUMENTS = ['identity', 'registration', 'name_reservation', 'site_plan', 'lease', 'duat', 'representation_license', 'feasibility', 'financial_capacity', 'foreign_capital'];
public function available(): bool
{
return (bool) config('investor-path.private_preparation', false);
}
public function questions(): array
{
$tri = ['unknown' => 'Por determinar', 'yes' => 'Sim', 'no' => 'Não'];
return [
['key' => 'investment_preparation', 'label' => 'Pretende preparar o pedido de garantias/incentivos ao investimento privado?', 'options' => $tri, 'source' => 'Lei 8/2023, artigo 2'],
['key' => 'investment_activity_scope', 'label' => 'Natureza efectiva da actividade do projecto', 'options' => ['unknown' => 'Por determinar', 'general' => 'Outra actividade económica', 'extractive' => 'Prospecção, pesquisa ou produção extractiva', 'processing' => 'Processamento industrial mineiro/petrolífero', 'public_nonprofit' => 'Investimento público ou exclusivamente social/sem fins lucrativos'], 'source' => 'Lei 8/2023, artigos 2 e 22'],
['key' => 'investment_form', 'label' => 'Tipo deste investimento', 'options' => ['unknown' => 'Por determinar', 'direct' => 'Directo', 'indirect' => 'Indirecto'], 'source' => 'Lei 8/2023, artigo 14(b); Regulamento, artigo 13', 'help' => 'O limiar monetário do artigo 13 aplica-se ao investimento directo. A forma indirecta não é convertida silenciosamente em directa.'],
['key' => 'implementation_mode', 'label' => 'Modalidade da empresa implementadora', 'options' => ['unknown' => 'Por determinar', 'domestic_entity' => 'Empresa constituída/em constituição em Moçambique', 'foreign_representation' => 'Representação comercial estrangeira'], 'source' => 'Regulamento 8/2024, artigo 9(2)'],
['key' => 'capital_origin', 'label' => 'Origem dos capitais/recursos deste investimento', 'options' => ['unknown' => 'Por determinar', 'national' => 'Nacionais', 'external' => 'Provenientes do exterior', 'mixed' => 'Mistos'], 'source' => 'Lei 8/2023, glossário; Regulamento, artigos 4–6'],
['key' => 'site_tenure', 'label' => 'Fundamento declarado da utilização das instalações', 'options' => ['unknown' => 'Por determinar', 'lease' => 'Arrendamento', 'duat' => 'DUAT'], 'source' => 'Regulamento, artigos 9(1)(d) e 14(1)(d)'],
['key' => 'project_concession', 'label' => 'O empreendimento é PPP ou concessão empresarial?', 'options' => $tri, 'source' => 'Lei 8/2023, artigo 22(1)(b)(ii)'],
['key' => 'significant_implications', 'label' => 'Existem previsíveis implicações económicas, ambientais, de segurança ou saúde pública que exijam autorização?', 'options' => $tri, 'source' => 'Lei 8/2023, artigo 22(1)(b)(i)', 'help' => 'Declare o enquadramento conhecido; o nome do sector não responde a esta pergunta. Sem fundamento suficiente, mantenha por determinar.'],
['key' => 'land_extension', 'label' => 'O projecto requer extensão de terra?', 'options' => $tri, 'source' => 'Lei 8/2023, artigo 22(1)(b)(iii)'],
['key' => 'forest_concession', 'label' => 'O projecto requer concessão florestal?', 'options' => $tri, 'source' => 'Lei 8/2023, artigo 22(1)(b)(iv)'],
['key' => 'forest_concession_hectares', 'label' => 'Área da concessão florestal (hectares)', 'type' => 'number', 'when' => ['field' => 'forest_concession', 'equals' => 'yes'], 'source' => 'Lei 8/2023, artigo 22(1)(b)(iv)'],
['key' => 'total_investment', 'label' => 'Valor total do investimento na moeda do projecto', 'type' => 'number', 'source' => 'Regulamento, artigo 13(2)'],
['key' => 'area_required', 'label' => 'Área de terra requerida', 'type' => 'number', 'when' => ['field' => 'land_extension', 'equals' => 'yes'], 'source' => 'Lei 8/2023, artigo 22(1)(b)(iii)'],
['key' => 'area_unit', 'label' => 'Unidade da área requerida', 'options' => ['ha' => 'Hectares', 'm2' => 'Metros quadrados'], 'when' => ['field' => 'land_extension', 'equals' => 'yes'], 'source' => 'Conversão de unidade exacta; não é benefício ou condição adicional'],
];
}
private function number(array $f, string $key): ?BigDecimal
{
$value = $f[$key] ?? '';
return is_scalar($value) && preg_match('/^\d+(?:\.\d+)?$/D', (string) $value) ? BigDecimal::of((string) $value) : null;
}
public function assess(array $context, array $documents = []): array
{
$f = $context['fields'] ?? [];
$missing = [];
$answer = function (string $key) use ($f, &$missing): ?bool {
if (! in_array($f[$key] ?? '', ['yes', 'no'], true)) {
$missing[] = $key;
return null;
}
return $f[$key] === 'yes';
};
$enabled = ($f['investment_preparation'] ?? '') === 'yes';
$date = $f['expected_start_date'] ?? '';
$period = preg_match('/^2026-\d{2}-\d{2}$/D', $date) === 1;
$scope = $f['investment_activity_scope'] ?? 'unknown';
$covered = in_array($scope, ['general', 'processing'], true);
$mode = 'indeterminate';
$reasons = [];
if ($enabled) {
if (! $date) {
$missing[] = 'expected_start_date';
}
if ($scope === 'unknown' || $scope === '') {
$missing[] = 'investment_activity_scope';
}
if (empty($f['currency'])) {
$missing[] = 'currency';
}
if ($covered && $period) {
$triggers = [];
if ($scope === 'processing') {
$triggers['processing'] = true;
$reasons[] = 'Processamento industrial mineiro/petrolífero: Lei 8/2023, artigo 22(1)(b)(v).';
}
$amount = $this->number($f, 'total_investment');
$triggers['amount'] = $amount && ($f['currency'] ?? '') === 'MZN' && ($f['investment_form'] ?? '') === 'direct' ? $amount->isGreaterThan('32000000000') : null;
if (! in_array($f['investment_form'] ?? '', ['direct', 'indirect'], true)) {
$missing[] = 'investment_form';
}
if (($f['investment_form'] ?? '') === 'indirect') {
$reasons[] = 'Investimento indirecto: a classificação pela dimensão e o encaminhamento monetário não são determinados por este lote. As restantes condições documentadas continuam avaliadas.';
}
if (! $amount) {
$missing[] = 'total_investment';
}
if (($f['currency'] ?? '') !== 'MZN') {
$reasons[] = 'A condição monetária não é determinada noutra moeda: não se fez conversão.';
}
foreach (['project_concession', 'significant_implications'] as $key) {
$triggers[$key] = $answer($key);
}
$land = $answer('land_extension');
if ($land === true) {
$area = $this->number($f, 'area_required');
$unit = $f['area_unit'] ?? '';
if (! $area) {
$missing[] = 'area_required';
}if (! in_array($unit, ['ha', 'm2'], true)) {
$missing[] = 'area_unit';
}
$triggers['land_extension'] = $area && in_array($unit, ['ha', 'm2'], true) ? $area->isGreaterThanOrEqualTo($unit === 'ha' ? '10000' : '100000000') : null;
} else {
$triggers['land_extension'] = $land;
}
$forest = $answer('forest_concession');
if ($forest === true) {
$area = $this->number($f, 'forest_concession_hectares');
if (! $area) {
$missing[] = 'forest_concession_hectares';
}$triggers['forest_concession'] = $area ? $area->isGreaterThan('100000') : null;
} else {
$triggers['forest_concession'] = $forest;
}
foreach (['amount' => 'Valor superior a 32 mil milhões MZN — R13(2), R21(2)(a)', 'project_concession' => 'PPP/concessão empresarial — Lei 8/2023, art. 22(1)(b)(ii), R21(2)(b)', 'significant_implications' => 'Implicações significativas declaradas — Lei 8/2023, art. 22(1)(b)(i), R21(1)', 'land_extension' => 'Extensão de terra igual/superior a 10 mil hectares — Lei 8/2023, art. 22(1)(b)(iii), R21(2)(c)', 'forest_concession' => 'Concessão florestal superior a 100 mil hectares — Lei 8/2023, art. 22(1)(b)(iv), R21(2)(d)'] as $condition => $description) {
if (($triggers[$condition] ?? null) === true) {
$reasons[] = $description.'.';
}
}
$mode = in_array(true, $triggers, true) ? 'authorization' : (in_array(null, $triggers, true) ? 'indeterminate' : 'registration');
$reasons[] = $mode === 'authorization' ? 'Foi identificada uma condição de autorização; as restantes condições desconhecidas não foram presumidas negativas.' : ($mode === 'registration' ? 'As condições de autorização deste lote foram declaradas negativas e o montante não ultrapassa o limite.' : 'Falta resolver pelo menos uma condição de autorização.');
}
foreach (['implementation_mode', 'capital_origin', 'site_tenure', 'entity_stage'] as $key) {
if (empty($f[$key]) || $f[$key] === 'unknown') {
$missing[] = $key;
}
}
}
if ($mode === 'authorization' && in_array(true, array_intersect_key($triggers, array_flip(['amount', 'project_concession', 'land_extension', 'forest_concession'])), true)) {
$missing = array_values(array_diff($missing, ['investment_form', 'project_concession', 'significant_implications', 'land_extension', 'forest_concession', 'forest_concession_hectares', 'area_required', 'area_unit', 'total_investment']));
}
$base = $enabled && $covered && $period ? true : null;
if (! $enabled || in_array($scope, ['extractive', 'public_nonprofit'], true) || ($date && ! $period)) {
$base = false;
}
$rules = [
['identity', 'Identificação do investidor', $base, 'S06', 'R9(1)(a) / R14(1)(a)', 41, 'Identificação do proponente do pedido.'],
['registration', 'Certidão de registo comercial', $base === true ? (($f['entity_stage'] ?? '') === 'existing' ? true : (($f['entity_stage'] ?? '') === 'being_formed' ? false : null)) : $base, 'S05', 'R9(1)(b) / R14(1)(b)', 41, 'Alternativa para empresa já registada.'],
['name_reservation', 'Reserva da denominação social', $base === true ? (($f['entity_stage'] ?? '') === 'being_formed' ? true : (($f['entity_stage'] ?? '') === 'existing' ? false : null)) : $base, 'S05', 'R9(1)(b) / R14(1)(b)', 41, 'Alternativa para empresa em constituição.'],
['site_plan', 'Planta ou esboço do local', $base, 'S06', 'R9(1)(c) / R14(1)(c)', 42, 'Local de implantação identificado; não certifica disponibilidade.'],
];
foreach (['lease' => 'Contrato de arrendamento', 'duat' => 'Título DUAT'] as $key => $label) {
$rules[] = [$key, $label, $base === true ? (($f['site_tenure'] ?? 'unknown') === 'unknown' ? null : ($f['site_tenure'] ?? '') === $key) : $base, 'S06', 'R9(1)(d) / R14(1)(d)', 42, 'Alternativa correspondente à utilização declarada das instalações.'];
}
$rules[] = ['representation_license', 'Licença de representação comercial', $base === true ? (($f['implementation_mode'] ?? 'unknown') === 'unknown' ? null : ($f['implementation_mode'] ?? '') === 'foreign_representation') : $base, 'S06', 'R9(2)', 42, 'Só para a modalidade representação comercial estrangeira; não decorre da nacionalidade.'];
foreach (['feasibility' => 'Estudo de viabilidade técnica e económico-financeira', 'financial_capacity' => 'Comprovação da capacidade financeira'] as $key => $label) {
$rules[] = [$key, $label, $base === true ? ($mode === 'indeterminate' ? null : $mode === 'authorization') : $base, 'S02', 'R14(1)–(2)', 45, 'Integra a preparação do pedido de autorização.'];
}
$rules[] = ['foreign_capital', 'Prova de realização/registo do aporte externo', $base === true ? (($f['capital_origin'] ?? 'unknown') === 'unknown' ? null : in_array($f['capital_origin'] ?? '', ['external', 'mixed'], true)) : $base, 'S06', 'R5(1)–(5)', 39, 'Preparação específica do aporte externo; formalidades cambiais concretas não calculadas.'];
$links = $context['preparation_documents'] ?? [];
$requirements = [];
foreach ($rules as [$key,$label,$applies,$stage,$article,$page,$reason]) {
$doc = isset($links[$key]) ? collect($documents)->firstWhere('document_id', $links[$key]) : null;
$requirements[] = ['key' => $key, 'label' => $label, 'state' => $applies === false ? 'not_applicable' : ($applies === null ? 'indeterminate' : ($doc ? 'associated' : 'missing')), 'required' => $applies === true, 'document_id' => $links[$key] ?? null, 'document' => $doc, 'stage' => $stage, 'reason' => $reason, 'source' => ['reference' => $article, 'url' => self::SOURCE.'#page='.$page, 'sha256' => self::HASH, 'period' => '2026; Regulamento desde 07/03/2024'], 'legal_verification' => 'not_inferred'];
}
$authoritySource = ['reference' => 'R13(1)', 'url' => self::SOURCE.'#page=45'];
$zone = $context['selection']['park'] ?? null;
$amount = $this->number($f, 'total_investment');
$authority = 'Por determinar neste lote';
if ($base === true && $mode === 'registration' && $amount && ($f['currency'] ?? '') === 'MZN') {
$special = in_array($zone['fields']['investment_regime'] ?? '', ['zee', 'zfi'], true);
$authority = $special ? ($amount->isLessThanOrEqualTo('6500000000') ? 'APIEX — condicionado ao enquadramento ZEE/ZFI efectivamente reconhecido' : 'Ministro que superintende Finanças') : ($amount->isLessThanOrEqualTo('3500000000') ? 'Governador da Província' : ($amount->isLessThanOrEqualTo('6500000000') ? 'APIEX' : 'Ministro que superintende Finanças'));
}
if ($base === true && $mode === 'authorization') {
$council = array_intersect_key($triggers, array_flip(['amount', 'project_concession', 'land_extension', 'forest_concession']));
$authority = in_array(true, $council, true) ? 'Conselho de Ministros' : (in_array(null, $council, true) ? 'Autorização identificada; completar as condições em falta para distinguir a entidade decisória' : 'Ministro que superintende Finanças; eventual submissão ao Conselho de Ministros nos termos do R21(3)');
$authoritySource = ['reference' => 'R21(1)–(3)', 'url' => self::SOURCE.'#page=50'];
}
$labels = array_column($this->questions(), 'label', 'key') + ['currency' => 'Moeda do projecto', 'expected_start_date' => 'Data prevista de início', 'entity_stage' => 'Situação da entidade'];
$missingLabels = array_map(fn ($key) => $labels[$key] ?? $key, array_values(array_unique($missing)));
return ['version' => self::VERSION, 'enabled' => $enabled, 'period_covered' => $period, 'scope_covered' => $covered, 'mode' => $mode, 'reason' => $reasons, 'missing_fields' => array_values(array_unique($missing)), 'missing_labels' => $missingLabels, 'questions' => $this->questions(), 'requirements' => $requirements, 'authority' => $authority, 'authority_source' => $authoritySource, 'next' => $base === false ? 'Rever o âmbito/período; este lote não cobre o pedido' : ($mode === 'indeterminate' ? 'Completar condições do enquadramento' : ($mode === 'authorization' ? 'Preparar estudo e documentos do pedido de autorização' : 'Preparar documentos do pedido de mero registo')), 'calculation_notice' => 'O regime procedimental não altera a taxa de IVA. Reutilize a simulação geral apenas no seu âmbito e período; benefícios, apuramento final e conversão cambial não são presumidos.', 'source' => ['url' => self::SOURCE, 'sha256' => self::HASH], 'notice' => 'Preparação informativa baseada em declarações; documentos associados não constituem validação jurídica, certificado ou submissão à Administração.'];
}
}
+5 -4
View File
@@ -250,7 +250,8 @@ class VirtualDesk
* @return array<string,mixed> */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code'])],
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse'])],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
foreach ($definition['fields'] as $key => $field) {
@@ -388,7 +389,7 @@ class VirtualDesk
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403);
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor, $request), 403);
}
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
@@ -412,13 +413,13 @@ class VirtualDesk
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id), 403);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
$current = $workflow[$data['stage']];
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
+1
View File
@@ -2,6 +2,7 @@
return [
// Explicit opt-in after authenticated installation; never infer activation from schema/ready.
'private_preparation' => env('PRIVATE_INVESTMENT_PREPARATION_ENABLED', false),
'enabled' => env('INVESTOR_PATH_ENABLED', false),
'definition_id' => '61000919-0000-4000-8000-000000000001',
];
@@ -1611,5 +1611,395 @@
"new_rules_published": false,
"consumer_activation_changed": false,
"proof_directory": "outputs/modelo-a-20261021"
},
"contextual_continuity_qa_20261022": {
"branch": "codex/project-context-continuity-QA",
"base": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"scope": "Primeiro incremento de continuidade e explicação contextual; adaptação jurídica integral não encerrada",
"status": "QA, revisão; sem integração main ou alteração fiscal activa",
"http_tests": {
"tests": 4,
"assertions": 74,
"transaction_rollback": true
},
"frontend_tests": 8,
"typescript": "passed",
"build": "passed",
"visual_evidence_directory": "outputs/context-continuity-20261022",
"same_project_new_session": true,
"deliberate_edit_preserved": true,
"second_project_isolated": true,
"fiscal_execution_id": "ae401465-834f-4761-9406-5af060c499fb",
"new_executions_today": 1,
"reused_proofs": [
"13f68f1b: recuperação/exportadores/PDF",
"83d2f97d e 226fd308: precisão/versões/históricos",
"3f5430c3: cálculo mensal",
"0b0cf761: correspondência parcial Modelo A"
],
"limitations": [
"Catálogo visual QA sintético; ZEE/ZFI apenas controlos HTTP sintéticos revertidos, sem elegibilidade real demonstrada",
"Condições jurídicas por sector/actividade/regime e benefícios executáveis não concluídos",
"Outros três consumidores usam origem/reconciliador comum; não houve demonstração visual integral de cada processo",
"Sem regra fiscal nova, publicação, activação ou integração em main",
"Edge interrompeu a inspecção após navegação de regresso; endereço e gravação confirmados por inventário/serviço, visual final não observada. Limitação da ferramenta."
],
"return_evidence_id": "43317c22-50c8-40cf-98e4-e5d4d108c372",
"B_recovered_context_revision": 2
},
"context_review_and_adaptive_path_preparation_20261022": {
"reviewed_commit": "a76369eb",
"review_status": "complete within accepted increment scope; no integration executed",
"last_return_equivalent_evidence": "43317c22-50c8-40cf-98e4-e5d4d108c372",
"accepted_AB_repeated": false,
"next_lot": "Preparação de pedido de investimento privado: condição, fonte/período, formulário, percurso, cálculo e lacuna",
"next_lot_status": "documented, not implemented or demonstrated end-to-end",
"source_url": "https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf",
"source_sha256": "ba1e9eaabfec5d0c4cb2d9f2444a6860f7a90f222034764197829be402d408ed",
"new_fiscal_rule_activation": false,
"next_acceptance": "Only synthetic C condition transitions and dossier/report links; reuse accepted context and IVA evidence"
},
"private_investment_executable_20261022": {
"base": "52fbb146",
"branch": "codex/private-investment-path-executable-QA",
"status": "implemented and verified in isolated QA; not integrated",
"source_version": "private-investment-preparation-2026-v1",
"orientation": "Tempo 22/10: official sources execute without generic additional legal approval; permanent AGENTS updated",
"qa_url": "http://127.0.0.1:18580/investimentos/4133311b-5426-4cec-900d-1f1484484083",
"persistence": {
"case_id": "4133311b-5426-4cec-900d-1f1484484083",
"case_version": 8,
"context_revision": 4,
"mode": "registration",
"association": {
"lease": "e5ce142a-1178-46a2-b039-f187139c62cd"
},
"documents": 1,
"context_events": 4,
"draft_id": "beba1dec-fc05-4820-b099-24952c5ab36c",
"draft_version": 4,
"draft_sha256": "4136e82a3b9420ab8f980ec0d7bda6dc2a67b568c18535b5ba0ecc80efd37e37",
"saved_context_revision": 3,
"saved_preparation_mode": "authorization",
"currency": "MZN",
"date": "2026-01-01",
"preparation_version": "private-investment-preparation-2026-v1"
},
"new_checks": {
"feature_tests": 3,
"assertions": 96,
"frontend_delta_tests": 1,
"typescript": "passed",
"build": "passed"
},
"evidence_sha256": {
"C-authorization-preview.png": "311c9f14ac601429666ded55c5906aa8ab018b5d31a1f101b4fb32697adf0817",
"C-final-directness-preview.png": "2037958e2879fb184cac1c4aa07ddc367417dbd1541d979c4e653a63918cdaca",
"C-final-path.png": "0c3c0900d861724b6def3f5850bd6c6c0f65dfee7cb01d6bb7c72c8bf645645a",
"C-persistence-v3.json": "b8faad9a5d4d9174a489a7b1096104f3a91645201b221b1ad7ece4a217180e00",
"C-persistence.json": "6b6120c88f41cff43b185905fdfec992ca76b359d0eab6bc1df02dee064c780d",
"C-recovered-path.png": "c734d119b0f766d8600892713be473d1c1875cb6ff4474b6fceabef702a3490b",
"C-report-check.json": "217b56b89041e6c8c05907af48c48dadf4f8316a2cce0f9195daf828258cb503",
"C-report.docx": "28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce"
},
"report": {
"path": "C:\\Mac\\Home\\Downloads\\relatorio-investmoz-sintetico-qa-c-pedido-de-investimento-privado.docx",
"size": 16674,
"sha256": "28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce"
},
"tool_limitation": "Download observation timed out and browser debugger detached; completed DOCX found in Downloads, verified before any retry; no repeated export",
"reused": "Accepted A/B, decimal calculation, CSV, exporters and native PDF pagination",
"main_integrated": false,
"homologation_changed": false,
"fiscal_rule_published_or_activated": false,
"limitations": [
"Only 2026 private investment preparation, documented registration/authorization conditions",
"No administrative submission or entitlement decision",
"No full sector licensing, foreign-currency equivalence or fiscal benefits engine",
"ZEE/ZFI routing predicate uses QA references; no real zone visual recognition proof",
"Dossier attachment association does not approve content; synthetic C fixture pending scan not delivered as seed",
"Final C context v4 registration differs from frozen draft/report context v3 authorization; no silent rewrite or repeated export."
],
"historical_report_persistence": {
"case_id": "4133311b-5426-4cec-900d-1f1484484083",
"case_version": 7,
"context_revision": 3,
"mode": "authorization",
"association": {
"lease": "e5ce142a-1178-46a2-b039-f187139c62cd"
},
"documents": 1,
"context_events": 3,
"draft_id": "beba1dec-fc05-4820-b099-24952c5ab36c",
"draft_version": 4,
"draft_sha256": "4136e82a3b9420ab8f980ec0d7bda6dc2a67b568c18535b5ba0ecc80efd37e37",
"saved_context_revision": 3,
"saved_preparation_mode": "authorization",
"currency": "MZN",
"date": "2026-01-01",
"preparation_version": "private-investment-preparation-2026-v1"
},
"directness_control": "Lei 8/2023 art14(b), R13: direct declared before using direct-investment monetary threshold; indirect/unknown unresolved only for dependent results; PPP independent condition remains executable"
},
"private_investment_report_alignment_20261022": {
"functional_revision": "a1fbda06",
"status": "targeted missing evidence completed; no functional code changes",
"current_persistence": {
"case_id": "4133311b-5426-4cec-900d-1f1484484083",
"case_version": 8,
"context_revision": 4,
"mode": "registration",
"association": {
"lease": "e5ce142a-1178-46a2-b039-f187139c62cd"
},
"documents": 1,
"context_events": 4,
"draft_id": "beba1dec-fc05-4820-b099-24952c5ab36c",
"draft_version": 5,
"draft_sha256": "ba824fe0362ca87b301da55298122796f44fdfb11507830b3fe8f54cf88d132d",
"saved_context_revision": 4,
"saved_preparation_mode": "registration",
"currency": "MZN",
"date": "2026-01-01",
"preparation_version": "private-investment-preparation-2026-v1"
},
"reports": {
"historic_v3": {
"path": "C:\\Mac\\Home\\Downloads\\relatorio-investmoz-sintetico-qa-c-pedido-de-investimento-privado.docx",
"sha256": "28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce",
"bytes": 16674,
"rows": [
[
"Contexto do investimento",
"Revisão 3; sem ZEE, ZFI ou parque seleccionado",
"Contexto versionado; não é reconhecimento jurídico. Referência do mesmo dossier no momento da gravação"
],
[
"Preparação do investimento privado",
"Pedido de autorização",
"Declarações e requisitos de preparação; sem aprovação jurídica. Contexto congelado v3; private-investment-preparation-2026-v1"
],
[
"Estudo de viabilidade técnica e económico-financeira",
"Documento por associar",
"Preparação informativa — simulação, sem submissão à Administração. Integra a preparação do pedido de autorização. R14(1)–(2); https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf#page=45"
],
[
"Comprovação da capacidade financeira",
"Documento por associar",
"Preparação informativa — simulação, sem submissão à Administração. Integra a preparação do pedido de autorização. R14(1)–(2); https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf#page=45"
]
]
},
"current_v4": {
"path": "C:\\Mac\\Home\\Downloads\\relatorio-investmoz-sintetico-qa-c-pedido-de-investimento-privado (1).docx",
"sha256": "a2849427f4a91fa6ab78e433be8b919baca80a0f4f452a4546a5b169b921d753",
"bytes": 16678,
"rows": [
[
"Contexto do investimento",
"Revisão 4; sem ZEE, ZFI ou parque seleccionado",
"Contexto versionado; não é reconhecimento jurídico. Referência do mesmo dossier no momento da gravação"
],
[
"Preparação do investimento privado",
"Pedido de mero registo",
"Declarações e requisitos de preparação; sem aprovação jurídica. Contexto congelado v4; private-investment-preparation-2026-v1"
],
[
"Estudo de viabilidade técnica e económico-financeira",
"Não aplicável; histórico preservado",
"Preparação informativa — simulação, sem submissão à Administração. Integra a preparação do pedido de autorização. R14(1)–(2); https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf#page=45"
],
[
"Comprovação da capacidade financeira",
"Não aplicável; histórico preservado",
"Preparação informativa — simulação, sem submissão à Administração. Integra a preparação do pedido de autorização. R14(1)–(2); https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf#page=45"
]
]
}
},
"proof_sha256": {
"C-report-HISTORICO-contexto-v3.docx": "28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce",
"C-report-contexto-v4.docx": "a2849427f4a91fa6ab78e433be8b919baca80a0f4f452a4546a5b169b921d753",
"C-report-v3-v4-comparison.json": "722154fd38b2915ec087f804abe66bc4c98c6eff2c4403d8e670d65d257e3446",
"C-persistence-v4-draft5.json": "a10505a52c553b4c0e03ece59d20d5a0a20204ae694bcf7174ee0574272ec1c4",
"C-new-report-context-v4.png": "b0cf159e6be5a457efdc46eb5e273895c43010caa9727e3b75668f916aa11f8d"
},
"historic_label": "C-report-HISTORICO-contexto-v3.docx; original bytes unchanged, embedded Contexto congelado v3",
"current_label": "C-report-contexto-v4.docx; embedded Contexto congelado v4; registration; study/capacity not applicable",
"accepted_tests_repeated": false,
"integration_dependencies": [
"a76369eb common continuity, required ancestor of a1fbda06",
"52fbb146 documentary intermediate, no executable delta",
"a1fbda06 adaptive private-investment functionality",
"Current branch includes ancestors; do not cherry-pick a1fbda06 alone without a76369eb/equivalent changes"
],
"integration_procedure": "After Tempo evaluation/decision, fetch current main, merge preserving concurrent changes and review actual conflicts. a76369eb can integrate independently first; shared ancestry avoids duplicate application. No reset or force-push main. Confirm deployed revision and only changed destination behavior. PRIVATE_INVESTMENT_PREPARATION_ENABLED is separate from tax publication/activation.",
"remote_main_read": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"main_integrated": false,
"fiscal_rules_changed": false
},
"adaptive_private_path_homologation_20261022": {
"authorized_by": "Tempo, user authorization 22/10/2026",
"previous_main": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"authorized_ancestry": [
"a76369eb",
"52fbb146",
"a1fbda06",
"064296e1"
],
"integration": "fast-forward, no conflicts or concurrent commits observed at push; no forced update",
"first_attempt": {
"status": "failed",
"commit": "064296e1fbf7b17f932df23c7b3ee5508719dec5",
"error": "Verification command failed: node (see protected build log)",
"active_release_preserved": true,
"requested_commits": [
"064296e1fbf7b17f932df23c7b3ee5508719dec5"
],
"finished_at": "2026-10-22T08:11:54.420414+00:00"
},
"compatibility_fix": {
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"cause": "Empty inheritance metadata appended when restoring a legacy draft with no compatible source",
"change": "Return original draft when both compatible source and prior inheritance metadata are absent",
"targeted_tests": "9 inheritance controls + exact failing legacy partition/recovery test, all passed"
},
"installed": {
"status": "installed",
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"release": "/var/www/investmoz-homologacao/releases/042ffbde44cac27704d6f8b7ec25b525a42e3575-6dfae60c",
"previous_release": "/var/www/investmoz-homologacao/releases/57fc914821d0cefa02caa80a74b3d42e663f2742-f563f9a7",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"042ffbde44cac27704d6f8b7ec25b525a42e3575"
],
"finished_at": "2026-10-22T08:16:16.903501+00:00"
},
"http": {
"/build/revision.json": {
"status": 200,
"body": {
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"branch": "main",
"built_at": "2026-10-22T08:16:14.331119+00:00",
"manifest_sha256": "655eef6e6a5e71c5138c68465a5b2ffc36d2cadeff96e6b35682dbfd19d42e79"
}
},
"/api/v1/fiscal-consumer": {
"status": 200,
"body": {
"data": {
"enabled": true,
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31",
"message": "IVA geral sobre base líquida disponível; enquadramento declarado."
}
}
},
"/investimentos": {
"status": 302,
"location": "https://teste-investidor.tempo.co.mz/login"
},
"/simulador-fiscal": {
"status": 200,
"body": "HTML available"
},
"checked_utc": "2026-10-22T08:17:20.250804+00:00"
},
"configuration_before": {
"read_only": true,
"utc": "2026-10-22T08:16:34.265947+00:00",
"revision": {
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"branch": "main",
"built_at": "2026-10-22T08:16:14.331119+00:00",
"manifest_sha256": "655eef6e6a5e71c5138c68465a5b2ffc36d2cadeff96e6b35682dbfd19d42e79"
},
"live": "/var/www/investmoz-homologacao/releases/042ffbde44cac27704d6f8b7ec25b525a42e3575-6dfae60c",
"env_path": "/var/www/html/teste-investidor.tempo.co.mz/.env",
"env_sha256": "7cdaba591bb5787485a349c24db2fd7c8279436da0eb31502e44db2fffa21d03",
"env_mode": "0o640",
"uid": 1001,
"gid": 33,
"private_preparation_env": "unset",
"config": {
"private_preparation": false,
"investor_path": true,
"fiscal_consumer": {
"enabled": true,
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31",
"message": "IVA geral sobre base líquida disponível; enquadramento declarado."
},
"preparation_version": "private-investment-preparation-2026-v1"
}
},
"configuration_enable": {
"utc": "2026-10-22T08:16:49.343216+00:00",
"revision": {
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"branch": "main",
"built_at": "2026-10-22T08:16:14.331119+00:00",
"manifest_sha256": "655eef6e6a5e71c5138c68465a5b2ffc36d2cadeff96e6b35682dbfd19d42e79"
},
"changed": true,
"env_before_sha256": "7cdaba591bb5787485a349c24db2fd7c8279436da0eb31502e44db2fffa21d03",
"env_after_sha256": "a6b4e9064cf6dbb2d945b53418a6c9252f9c736ff3fde4016b95b74885a9ee0f",
"backup": "/var/lib/investmoz-deploy/backups/private-preparation-20261022/env-7cdaba591bb5787485a349c24db2fd7c8279436da0eb31502e44db2fffa21d03.private",
"backup_bytes_verified": true,
"only_key_changed": "PRIVATE_INVESTMENT_PREPARATION_ENABLED",
"mode_preserved": true,
"owner_preserved": true,
"config": {
"private_preparation": true,
"investor_path": true,
"fiscal_consumer": {
"enabled": true,
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31",
"message": "IVA geral sobre base líquida disponível; enquadramento declarado."
},
"preparation_version": "private-investment-preparation-2026-v1"
},
"fiscal_configuration_changed": false,
"database_operations": false,
"recovery": "Restore previous key value only against recorded post-change hash, rebuild config cache; preserve subsequent changes and all data"
},
"configuration_after": {
"read_only": true,
"utc": "2026-10-22T08:17:21.883131+00:00",
"revision": {
"commit": "042ffbde44cac27704d6f8b7ec25b525a42e3575",
"branch": "main",
"built_at": "2026-10-22T08:16:14.331119+00:00",
"manifest_sha256": "655eef6e6a5e71c5138c68465a5b2ffc36d2cadeff96e6b35682dbfd19d42e79"
},
"live": "/var/www/investmoz-homologacao/releases/042ffbde44cac27704d6f8b7ec25b525a42e3575-6dfae60c",
"env_path": "/var/www/html/teste-investidor.tempo.co.mz/.env",
"env_sha256": "a6b4e9064cf6dbb2d945b53418a6c9252f9c736ff3fde4016b95b74885a9ee0f",
"env_mode": "0o640",
"uid": 1001,
"gid": 33,
"private_preparation_env": "true",
"config": {
"private_preparation": true,
"investor_path": true,
"fiscal_consumer": {
"enabled": true,
"coverage_start": "2026-01-01",
"coverage_end": "2026-12-31",
"message": "IVA geral sobre base líquida disponível; enquadramento declarado."
},
"preparation_version": "private-investment-preparation-2026-v1"
}
},
"effective_procedural_version": "private-investment-preparation-2026-v1",
"new_fiscal_rules_published_or_activated": false,
"qa_fixtures_copied": false,
"accepted_scenarios_repeated_manually": false,
"limitations": "No new scenario executed in homologation and no real dossier or personal data created. QA functional evidence reused; destination verification limited to revision, configuration and entry routes. Sector/fiscal exclusions remain explicit."
}
}
@@ -2018,3 +2018,136 @@ Cotejo dirigido: Lei22/2022 art.17-A/aditamento e eficácia; Lei10/2025 arts.18/
Provas novas apenas do incremento:3testes frontend (agendas, zero/falta, sem IVA/dedução fabricados e relatório sem mutar resultado), TypeScript/build, Edge de consulta após recuperação e biblioteca idempotente/scan. Avaliador, selecção publicada, precisão, históricos e exportadores aceites reutilizados. DossierQA e fixtureIVA v4 continuam disponíveis para revisão; este incremento não calcula novamente. Provas privadas outputs/modelo-a-20261021; recibo e inventário existentes actualizados. Branch codex/iva-modelo-a-reconciliation-QA, parent3f5430c3, separada de main; homologação/regra real/interruptor inalterados.
Saldo explícito: **reconciliação e primeiro incremento de interface concluídos, apuramento completo não implementado**. Próximo lote: classificação por operação e origem prevista/efectiva, cadastro fundamentado de âmbito/dedução e ligação a totais14–18 propagando indeterminação; 8–16h cadastro/consolidação e20–36h código/aceitação, total28–52h condicionado ao âmbito/consolidação, sem créditos anteriores/substituições/pagamento/reembolso ou simulador integral. O documento contém o delta e critérios concretos. Integração do código, publicação e activação continuam operações distintas; esta preparação não executa nenhuma no destino.
### Continuidade e adaptação contextual — primeiro incremento QA, 22/10/2026
Instrução expressa da Tempo de 22/10: o contexto do investimento é transversal ao percurso, formulários, documentos e simulações, mesmo sem IA. Base Git e revisão servida confirmadas em leitura: `57fc914821d0cefa02caa80a74b3d42e663f2742`. Trabalho isolado em `codex/project-context-continuity-QA`, sem integração ou publicação/activação fiscal no destino.
Causas identificadas no código e reprodução dirigida: `InvestorContext` só distribuía título, descrição, sector e geografia; o preenchimento comum copiava apenas campos vazios sem distinguir herança de edição deliberada; o IVA pontual mantinha moeda/data/base em estado local que desaparecia ao desmontar o ecrã. A projecção empresarial sobrepunha a moeda do projecto à moeda de origem do plano ao apresentar/calcular/exportar. No ensaio anterior, projecto guardado em MZN e selector IVA vazio. A primeira tentativa de abertura encontrou uma sessão expirada, confirmada pela leitura sem autenticação e resolvida pelo início de sessão normal; não foi classificada como perda do dossier.
Correcção comum: consolidar exclusivamente o rascunho explicitamente ligado ao mesmo dossier e proprietário; conservar origem, versão e hash. Não procurar o último projecto da conta. Herança persistida com assinaturas de alteração por campo e âmbito dossier/consumidor; não são tokens de autorização nem prova jurídica. Actualizar valores ainda herdados, preservar alterações deliberadas e campos apagados; manter a hierarquia geográfica e os restantes dados das parcelas. O armazenamento do navegador passa a incluir conta e projecto. Consumidores do padrão: simulador, apoio à terra, Balcão e manifestação de investimento; esta última recebe também a moeda compatível. Campos sem correspondência semântica não são copiados. Nacionalidade, residência e país de constituição não são equiparados.
O IVA pontual guarda as entradas com o rascunho. A exploração fiscal, oportunidade e componente pontual reutilizam a moeda compatível; a projecção mensal reutiliza o plano e o início previsto. Não se infere exigibilidade fiscal a partir da data prevista de início, nem residência jurídica a partir do país. USD é conservado e explicado como não suportado pela versão MZN. Uma divergência entre moeda guardada no plano e moeda do projecto é apresentada, sem conversão ou reclassificação. O plano conserva a moeda de origem também na apresentação e nos exportadores existentes. Vendas, zero/falta e snapshots de IVA continuam a usar o motor publicado e os históricos aceites; nenhum imposto mensal fixo é introduzido.
Adaptação deste incremento: o editor existente passa a permitir moeda, início previsto, origem do investidor, tipo/situação da entidade e residência fiscal declarada, sempre pela prévia/confirmar existente. A prévia continua a identificar dados/documentos conservados, resultados e etapas afectados; alterações apenas ao título/descrição não invalidam a aplicabilidade. O selector territorial reutiliza o catálogo público `InvestmentZones` para parques, ZEE e ZFI, preservando versão/hash, fontes e condições públicas já divulgadas no complemento existente. A mesma síntese aparece no contexto e simulador e segue para o relatório do dossier. Os ramos de terra/financiamento reutilizam a definição publicada, factos, estados e razões de `InvestorPath`; não foi criado outro avaliador nem activado benefício.
Escolher uma zona abre a análise específica: referência consultada, condições documentadas ou em falta, período/fonte cadastrados quando disponíveis e reconhecimento por determinar. O benefício e o cenário condicionado permanecem indeterminados quando não existe ligação executável às condições/reconhecimento. O IVA disponível é identificado como cenário geral sem benefício; não representa automaticamente o regime escolhido nem apuramento final. Reutilizam-se `InvestorContext`, `InvestmentZones`, `InvestmentZoneOperationalDetails`, `InvestorPath`/definição publicada, resolvedor/avaliador fiscal e snapshots existentes. A escolha técnica de exigir uma nova confirmação genérica dos mesmos dados em cada formulário foi retirada por instrução da Tempo; permissões, declarações, submissões formais, revisões independentes aprovadas e campos autorizados de pedidos devolvidos mantêm os controlos existentes.
Provas novas: 4 testes HTTP/74 asserções, em transacções revertidas na base isolada `simulator_fiscal_delta_20261021_test`/loopback55441; 8 testes frontend de herança, edição/apagamento, isolamento, parcelas, moeda divergente e contexto no relatório; TypeScript/build. Percurso Edge: A nacional/MZN e residência fiscal ZA, vendas guardadas de quatro meses → cálculo mensal → gravação → edição deliberada do título → recarregamento → terminar sessão/iniciar sessão → recuperação e relatório pelos dados efectivamente guardados. Resultado `ae401465-834f-4761-9406-5af060c499fb`, uma única execução nova no dia; Janeiro1000.03/IVA160.0048, Fevereiro2000.06/320.0096, Marçozero e Abrilsem informação. O modelo de relatório recuperado conserva os valores exactos e não altera o snapshot. B estrangeiro/USD, primeiro divergência explícita planoMZN/projectoUSD, depois declaração manual USD no plano, IVA recusado sem conversão; selecção de parque pela prévia e análise específica. Os testes de ZEE/ZFI usam apenas metadados sintéticos revertidos: não demonstram elegibilidade real. Provas privadas em `outputs/context-continuity-20261022`; não entregar fixtures ou credenciais.
Limites concretos: este é o primeiro incremento utilizável de continuidade e explicação contextual, não a conclusão da adaptação de todos os sectores/regimes. O catálogo QA consultado contém parques sintéticos; a ligação jurídica completa de requisitos/documentos/licenças e benefícios às condições de cada actividade/regime não está demonstrada. Condições sem estrutura/relação executável continuam explicitamente em falta; nenhuma alegação nova de direito ou benefício é feita. Não houve nova validação jurídica nem revalidação genérica de credibilidade das fontes oficiais aceites em08/10. Não repetir cálculo decimal/exportadores/paginação já aceites. A demonstração visual foi feita no percurso e simulador; os outros três consumidores receberam o reconciliador comum, sem se afirmar um ensaio visual integral de cada processo.
Revisão: abrir o simulador QA com o dossier A `92d0836c-0ffb-4e07-a7f5-0e8ca3f594b9` ou B `1872412f-251a-4f6d-bf18-70388e71a16a`; usar a conta sintética QA existente, nunca actores sintéticos na homologação. O router privado de QA usa a instalação já existente do percurso, activada apenas neste ensaio isolado; não há migration ou nova exigência de funções. Entregar a candidata para revisão pelo processo Git existente, preservando alterações concorrentes. Uma integração futura exige rever o delta relativamente a main e o estado de instalação/configuração do percurso no destino; não publicar regras ou alterar interruptores fiscais como consequência desta correcção. Preservar todos os históricos na suspensão ou reversão de código.
Fecho da observação deste incremento: o regresso A ao dossier gravou a evidência financeira `43317c22-50c8-40cf-98e4-e5d4d108c372`, rascunho `cf4dec46-c96c-4aa6-aa9c-1a8d6ecb1bea` v8, contexto v1, com hash preservado. A ligação de inspecção do Edge interrompeu-se depois da navegação; o inventário mostrou o endereço do mesmo dossier e a leitura autenticada do serviço confirmou a gravação. A parte visual final do regresso não foi observada e fica como limitação da ferramenta, não falha do simulador. B recuperou contexto v2 e gravou rascunho v6, mantendo USD e sem execução fiscal. O relatório gerado destes dados recuperados conserva a versão do parque e benefício indeterminado. O enriquecimento automático do rascunho privado é exclusivo do proprietário autenticado; o acesso de gestão ao dossier não concede por esta alteração acesso ao rascunho privado da conta.
### Revisão de a76369eb e primeiro percurso adaptativo fundamentado — v1, 22/10/2026
**Revisão encerrada no âmbito de a76369eb, sem integração.** O último regresso tem prova equivalente: destino do mesmo dossier observado pelo inventário do navegador e evidência `43317c22-50c8-40cf-98e4-e5d4d108c372` confirmada no serviço, ligada ao rascunho v8/contexto v1 e ao hash `a21fa5779bf8b8df366f16683c8a9bb9817a6d2921c7384f85df02135307fa13`. A observação visual final continuou limitada pela ferramenta. Não se repetiram A/B, cálculos, exportações ou nova sessão; reutilizam-se as provas aceites. Leitura dirigida do delta: isolamento por proprietário/dossier, edições/apagamentos, origem monetária, controlo de escrita e ausência de publicação fiscal. Não foi identificada nova preparação técnica que condicione a decisão de integração deste âmbito. A adaptação jurídica seguinte é independente.
**Percurso delimitado preparado:** preparação de um pedido de investimento privado para empreendimento económico em Moçambique, com actividade concreta declarada, instalação identificada e operações mensais enquadráveis no consumidor IVA geral existente. Trata a preparação do pedido de investimento; não se apresenta como licenciamento sectorial completo, certificado emitido, direito a incentivo ou submissão à Administração. Investidor nacional/estrangeiro, actividade, modalidade e localização são condições distintas. A saída é formulário orientado, etapas/documentos aplicáveis e simulação identificada pelo seu âmbito. Não se escolhe “Turismo” ou uma ZEE para presumir requisitos ou benefícios.
**Fonte primária aplicada:** compilação oficial APIEX “Legislação de Investimentos em Moçambique”, exemplar obtido de https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf — 6467655 bytes, SHA256 `ba1e9eaabfec5d0c4cb2d9f2444a6860f7a90f222034764197829be402d408ed`. Contém Lei8/2023 (pp.8–32), Decreto8/2024/Regulamento (pp.36–59), CBF (pp.64–92). Aceitação oficial de08/10 mantida; não há exigência de segunda autenticação ou aprovação jurídica da Tempo. Inventários existentes de604 registos/exemplares/anexos e9901 caminhos do acervo reutilizados antes da selecção; cópia privada, texto extraído e citações em `outputs/adaptive-path-20261022`. Não importar/publicar automaticamente. As pendências históricas genéricas de nova certificação nos levantamentos de28/09 e03/10 não são gates deste lote.
**Período:** Decreto8/2024, artigo5 do diploma preambular, indica aplicação desde a publicação de7/03/2024; Lei8/2023, artigo32, indica90dias após publicação. O lote proposto cobre datas de projecto em2026 e preserva estes inícios, exemplar e versão. Não foi encontrada alteração nas consultas oficiais dirigidas desta execução; isso não certifica inexistência de actos posteriores. A compilação de2025 não constitui consolidação universal de2026. Na ligação executável, uma fonte/versão sem cobertura temporal não autoriza fallback para a versão mais recente. Os artigos citados são do **Regulamento anexo** quando identificado “R”; “L” designa a Lei8/2023. Rastrear esta distinção evita confundir os dois artigos9 ou o artigo5 preambular com o do Regulamento.
| Condição | Fonte e período | Efeito no formulário | Efeito no percurso | Efeito no cálculo | Informação em falta / estado da ligação |
| --- | --- | --- | --- | --- | --- |
| Projecto económico em Moçambique que procura garantias/incentivos; actividade efectiva conhecida | L2(1)–(3), pp.8–9; período2026 deste lote | Reutilizar localização e actividade; perguntar a finalidade jurídica apenas se ausente | S02 identifica cobertura do procedimento; actividades excluídas recebem encaminhamento próprio | Não inferir benefício ou exclusão de IVA a partir deste âmbito | Falta ligação executável da cobertura ao perfil; legislação sectorial especial fica fora deste lote |
| Capital/recursos do exterior ou nacionais; pessoa, residência e constituição separadas | L3/anexo, pp.28–29; R4–6, pp.38–40; período2026 | Reutilizar os dados declarados; pedir origem/forma do aporte quando faltar, sem transformar nacionalidade em classificação do capital | Acrescentar preparação da prova/registo do aporte externo quando aplicável | Nenhuma mudança automática de taxa IVA ou conversão; transferências cambiais não calculadas | Falta dado estruturado de aporte e ligação à documentação; requisitos cambiais concretos não abrangidos |
| Implementadora em constituição ou já registada | R9(1)(b), p.41; R14(1)(b), p.45; desde7/03/2024, recorte2026 | Mostrar a alternativa documental correspondente, sem exigir novo preenchimento da entidade | S05/S06: reserva de denominação ou certidão comercial, com estado declarado/associado por conferir | Não altera IVA | Falta associar categorias documentais existentes à alternativa e ao estado do cadastro |
| Implantação em instalações arrendadas ou com DUAT | R9(1)(c)–(d), pp.41–42; R14(1)(c)–(d), p.46; recorte2026 | Reutilizar local; perguntar apenas a modalidade/evidência ainda ausente | S06 identifica esboço/planta e alternativa contrato/DUAT; sem converter ausência de título em ausência de direito | Não atribui taxa, custo fundiário ou benefício | Falta modalidade estruturada e associação ao documento; sem decidir titularidade/disponibilidade |
| Pedido por representação comercial estrangeira, distinto de qualquer investidor estrangeiro | R9(2)–(3), p.42; recorte2026 | Pergunta de representação apenas quando a implantação declarada o permita; depois referência da licença e documento | S06 acrescenta licença de representação e identifica formalidades documentais pertinentes | Sem alteração da taxa geral | Falta modalidade e origem documental estruturadas; não exigir licença a todo investidor estrangeiro |
| Nenhuma condição de autorização; ou dimensão/actividade/terra que a exija | L22(1)(b)–(2), pp.20–21; R13(2), R14 e R21, pp.45–50; recorte2026 | Reutilizar montante/moeda/actividade/área; perguntar apenas PPP/concessão, implicações ou dados ainda desconhecidos | Seleccionar preparação de mero registo ou autorização; na segunda, S02/S06 acrescentam estudo de viabilidade e capacidade financeira | Comparação do montante exige MZN ou equivalência documentada; não converter nem inventar taxa; IVA permanece componente separada | Falta ligar os predicados e evidências ao perfil. “Não sei” não equivale a “não”. Limite de32mil milhões MZN só resolve a condição de montante, não todas as restantes |
| Localização geral versus ZEE/ZFI com referência e âmbito documentados | R13(1)(a)–(b), p.45; R10(4), p.43; recorte2026 | Reutilizar zona/versionamento; pedir reconhecimento/condições pertinentes em falta | Diferenciar entidade de encaminhamento e articulação aplicável; S08/S17 conservam evidências e pendências | Sem regra executável do benefício, mostrar resultado especial indeterminado e cenário geral separado | a76369eb já apresenta referência/limite; faltam ligações jurídicas de encaminhamento e condições. Parque industrial não equivale automaticamente a ZEE/ZFI |
| Operação mensal interna geral, base líquida, MZN e período coberto pela versão publicada | Regra IVA já fundamentada/autorizada; Lei22/2022 art.17(1), fontes/limites no recibo existente; recorte fiscal2026 | Reutilizar vendas/mês/moeda; perguntar apenas pressupostos fiscais ainda ausentes | Ligar cálculo e relatório à mesma versão de contexto/regra | Consumidor existente calcula por versão servida; zero difere de falta; preço com IVA incluído não vira base líquida | Ligação mensal já demonstrada. Dedução, reembolso, saldo final e benefício especial continuam fora |
| Mudança de título/contacto ou outra condição irrelevante para a regra aplicada | Decisão Tempo22/10 e contratos existentes; escolha técnica de dependências identificada | Preservar a edição e preencher o que continuar compatível | Não reiniciar revisões/etapas sem dependência relevante | Não criar outra execução nem mudar taxa/base | Prova de título e testes de dependências já aceites; estender por dependência, não por “qualquer alteração” |
**Diferença útil que a implementação deste lote deve demonstrar, sem repetir A/B:** um caso C exclusivamente sintético, empresa em constituição, actividade económica não extractiva declarada, instalações arrendadas, capital emMZN, período2026. Primeiro completar as condições de enquadramento; depois a preparação de mero registo mostra a alternativa reserva de denominação/arrendamento. Alterar a condição de autorização para verdadeira troca o resultado procedimental, os campos necessários e os documentos para estudo/capacidade financeira. Com condição desconhecida, a selecção fica indeterminada e pede apenas essa informação. Alterar para representação comercial estrangeira acrescenta a licença pertinente; mudar apenas nacionalidade não produz esse efeito. Alterar de localização geral para ZEE/ZFI documentada altera o encaminhamento e as verificações, sem conceder benefício. O IVA geral continua matematicamente igual quando a mudança não afecta a sua regra; mudar vendas/base/moeda/período altera ou impede o cálculo conforme a cobertura existente. Não forçar um resultado numérico diferente quando a lei não o determina.
**Cadastro e código separados:** o conteúdo jurídico acima está preparado, com artigos, exemplar e início temporal. Falta cadastrar as correspondências versionadas campo/condição/etapa/documento e ligar a sua execução ao perfil existente. Usar o mesmo dossier, prévia/confirmar, definições/questionários versionados e suporte condicional `when` já utilizado nos formulários; reutilizar resolvedor temporal, avaliação e aritmética exacta nas interfaces em que sejam compatíveis, com adaptador explícito para os predicados procedimentais, sem criar outro motor nem armazenar procedimentos como imposto. O contexto já tem sector/actividade/localização/moeda/período/entidade; as respostas de modalidade e evidências ainda não ligadas devem ser acrescentadas ao mesmo contexto cifrado, com origem e alterações deliberadas. Reutilizar vínculos documentais/históricos e permissões existentes. Não alterar a definição publicada dos19passos nem activar outra regra fiscal para executar este cadastro.
**Estado desta entrega:** revisão de a76369eb concluída no âmbito aceite; **percurso seguinte fundamentado e delimitado, ainda não implementado/demonstrado de ponta a ponta**. A tabela não é prova de funcionalidade nem declara licenciamento sectorial completo. Não integrar este documento como se instalasse o percurso. Próxima execução técnica concreta: ligar as condições acima, persistir decisões/documentos e verificar apenas as transições de C, recuperação e relatório; conservar as provas aceites de contexto e IVA. Código, publicação de parâmetros/regras e activação continuam operações separadas. Não há nova autorização organizacional ou validação jurídica da Tempo presumida.
### Percurso de investimento privado executável em QA — 22/10/2026
Implementação do delta de `52fbb146`, na branch `codex/private-investment-path-executable-QA`. A integração independente de `a76369eb` não foi repetida nem condicionada por este lote. Não houve escrita na homologação, integração em main, publicação ou activação de regras fiscais.
**Orientação permanente aplicada:** decisão expressa da Tempo de 22/10, registada também em AGENTS.md: legislação, modelos e procedimentos oficiais disponibilizados são a base aceite para implementar; decisões da Tempo orientam a implementação. Não existe nova aprovação jurídica genérica, aprovador adicional ou validação de credibilidade como condição para executar este percurso. Dados concretamente em falta são identificados por campo e não anulam os restantes requisitos. Associação documental não equivale a reconhecimento administrativo; esta distinção é informativa, sem bloquear a gravação ou a preparação.
**Ligação executável:** `PrivateInvestmentPreparation` é o adaptador delimitado das condições procedimentais já documentadas para o contexto cifrado do dossier. Correspondência `private-investment-preparation-2026-v1`, exemplar APIEX/hash anteriormente registados, recorte de início previsto em 2026. Reutiliza campos conhecidos do mesmo projecto/rascunho, permissões, vínculos documentais, prévia assinada, controlo de concorrência, chave idempotente, eventos antes/depois e os 19 passos existentes. Não é outro motor fiscal, catálogo paralelo de benefícios ou novo processo de aprovação. O resolvedor fiscal publicado e a aritmética decimal existentes continuam responsáveis pelos seus cálculos; não se armazenaram predicados procedimentais como impostos.
No dossier normal, «Preparar o pedido de investimento privado» abre o editor de contexto. Valores já declarados ficam recuperados e editáveis, sem nova confirmação por ecrã. Dados condicionais de área só aparecem para extensão de terra/concessão florestal. A prévia mostra ramo antes/depois, documentos novos/inactivos, passos afectados e resultados que precisam de revisão. Apenas a confirmação grava uma revisão; cancelamento conserva o estado anterior. Os requisitos correspondentes aparecem nas etapas S02/S05/S06 e o próximo passo acompanha mero registo/autorização. As dependências formais da definição publicada não foram substituídas por novos bloqueios.
**Fundamento executado:** Lei 8/2023 artigos 2 e 22; Regulamento aprovado pelo Decreto 8/2024 artigos 5, 9, 13, 14 e 21, conforme a tabela de 52fbb146 acima. Na interface, R designa este Regulamento. As exigências têm razão, artigo, período e ligação ao exemplar oficial. As alternativas certidão/reserva e DUAT/arrendamento não são cumulativas. Representação estrangeira determina a licença correspondente; nacionalidade ou residência, isoladamente, não a determinam. Aporte externo tem origem declarada separadamente da nacionalidade. Montante >32 mil milhões MZN, terra >=10 mil ha e concessão florestal >100 mil ha usam comparação decimal exacta, sem conversão cambial presumida. R21 distingue Finanças/Conselho de Ministros conforme as condições efectivamente conhecidas; uma condição positiva suficiente não espera pelas restantes para identificar o pedido de autorização.
| Comparação nova, exclusivamente QA | Campos e documentos | Próximo passo / efeito no cálculo | Prova |
| --- | --- | --- | --- |
| C: empresa em constituição, actividade geral, 1.000.000 MZN, 01/01/2026, modalidade doméstica, capital nacional, instalações arrendadas; condições de autorização negativas | Reserva de denominação, identificação, planta e arrendamento; não exige certidão, DUAT, licença de representação ou estudo/capacidade de autorização | Mero registo; encaminhamento Governador. Nenhuma taxa IVA alterada | UI → prévia → confirmar; contexto v1 |
| C: associar suporte sintético já existente ao arrendamento | Mesmo documento associado por ID; não cria cópia nem o aprova | Conserva o pedido; contexto v2 | UI; um vínculo em desk_documents |
| C: PPP/concessão empresarial passa a sim, modalidade passa a representação estrangeira e instalações a DUAT | Entram estudo/capacidade, licença de representação e DUAT; arrendamento deixa de ser exigido, conservando o documento anterior | Autorização, Conselho de Ministros; S02/S05/S06/S07 para revisão. Esta mudança meramente procedimental não invalida os inputs do IVA | Prévia visual antes de confirmar; contexto v3 recuperado; PNG C-authorization-preview |
| C: recuperação e simulador | MZN, data, título, contexto v3 e preparação por autorização recuperados sem reintrodução; arrendamento inactivo visível no histórico | Mesmo âmbito fiscal geral; nenhuma execução fiscal nova pedida neste ensaio | Dossier normal, simulador e rascunho ligado; leitura persistida e relatório Word |
| Regresso autorização → mero registo; entidade em constituição → existente | Estudo/capacidade/licença deixam de ser exigidos; arrendamento anterior volta associado; certidão substitui reserva, sem apagar histórico | Ramo e requisitos recalculados | Controlo HTTP transaccional dirigido, não repetição visual A/B |
| ZEE/ZFI versus parque industrial | Encaminhamento APIEX condicionado ao enquadramento reconhecido; parque não é convertido em ZEE/ZFI | Nenhum benefício ou taxa especial concedidos | Predicados com referências sintéticas de QA; não prova de elegibilidade de uma zona real |
**Provas novas:** três testes feature, 96 asserções, exclusivamente na base `simulator_fiscal_delta_20261021_test`, loopback 55441, com transacções revertidas e guarda do alvo. Cobrem limiares exactos, desconhecidos, exclusões/período, não inferência por nacionalidade, alternativas e preservação de vínculo, recuperação, idempotência, prévia sem escrita, adulteração, documento de outro dossier recusado e consumidor desligado, incluindo edição independente do título sem perder snapshot ou vínculos. Um teste frontend confere somente o novo conteúdo congelado do relatório. TypeScript e build passaram. Não foram repetidos cálculos decimais, exportadores completos, paginação PDF, CSV ou ensaios A/B aceites.
**Recibo C da fase por autorização (histórico v3):** dossier `4133311b-5426-4cec-900d-1f1484484083`; contexto v3; rascunho `beba1dec-fc05-4820-b099-24952c5ab36c`, v4, SHA256 `4136e82a3b9420ab8f980ec0d7bda6dc2a67b568c18535b5ba0ecc80efd37e37`. Um documento sintético `e5ce142a-1178-46a2-b039-f187139c62cd`, sem pretensão de autenticidade oficial ou reconhecimento; mantém quarentena/scan pendente, sem contornar o carregamento/antimalware. A fixture apenas disponibiliza metadados privados para observar associação, não ensaia novamente uploads e não faz parte de dados/migrações de entrega. Três eventos de mudança do contexto; uma associação documental. C não contém projecção de vendas nem novo resultado fiscal; as provas anteriores dessa componente são reutilizadas.
**Relatório efectivamente transferido:** `relatorio-investmoz-sintetico-qa-c-pedido-de-investimento-privado.docx`, 16.674 bytes, SHA256 `28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce`. Conferido o novo conteúdo: pedido de autorização, versão, fundamentos R14 e exigências não aplicáveis com histórico preservado. Não se repetiu a verificação de paginação/exportadores. Os anexos do dossier não são inseridos silenciosamente no pacote; mantém-se a selecção explícita existente. O relatório identifica a preparação/simulação e não alega submissão ou aceitação administrativa.
**Limitação da ferramenta:** a observação do evento de download excedeu o tempo e a extensão perdeu a ligação. O ficheiro terminou e foi encontrado em Transferências antes de qualquer repetição; conteúdo/hash conferidos localmente. Não se repetiu a exportação nem se classificou o incidente como falha do simulador. A prévia de impacto e recuperação já tinham prova visual; a recepção do ficheiro é prova distinta.
**Limites concretos:** este lote prepara o pedido de garantias/incentivos de investimento privado, não executa submissão administrativa, licenciamento completo por sector ou decisão de elegibilidade. Prospecção/pesquisa/produção extractiva, investimento público/exclusivamente social sem fins lucrativos e períodos fora de 2026 não são cobertos por este adaptador; processamento industrial tem o ramo de autorização documentado. A equivalência de montante noutra moeda não é determinada sem dados/fundamento cambial aplicáveis; condições positivas independentes continuam executáveis. O catálogo visual deste QA contém apenas parques sintéticos, não uma ZEE/ZFI real elegível: encaminhamento especial conferido no predicado, sem declarar prova visual de reconhecimento real. Benefícios territoriais, deduções, reembolsos e apuramento final do IVA continuam indisponíveis; o cálculo geral já autorizado conserva o seu âmbito.
**Utilização e integração:** abrir `http://127.0.0.1:18580/investimentos/4133311b-5426-4cec-900d-1f1484484083` na conta QA habitual. Usar «Comparar e alterar contexto», editar apenas a condição relevante, comparar e confirmar; para documentos, usar o Balcão do mesmo dossier e associar os existentes. QA habilita `investor-path.private_preparation=true` no router privado. A configuração de entrega `PRIVATE_INVESTMENT_PREPARATION_ENABLED` fica desligada por defeito; a homologação não foi alterada. Integração eventual pelo processo Git existente: rever branch/código e limites, incorporar sobre main actualizado preservando alterações concorrentes, build e conferir apenas o delta no destino; habilitação deste percurso é uma configuração separada, não publicação/activação fiscal. Suspender a configuração oculta o novo consumidor e recusa novas escritas específicas, sem eliminar contexto, documentos, eventos, rascunhos ou relatórios congelados. Sem migração ou seed de fixtures no destino. Nenhuma nova ronda de preparação técnica foi identificada para este recorte; revisão/decisão de integração continuam separadas.
**Distinção final conferida, sem nova validação genérica:** Lei 8/2023 artigo 14(b) distingue investimento directo/indirecto; R13 refere expressamente investimento directo nos limiares monetários. Acrescentado o dado «Tipo deste investimento», separado da origem/nacionalidade. Sem esse dado, a via dependente da dimensão permanece indeterminada; para forma indirecta este lote não determina dimensão/encaminhamento monetário, mas continua a avaliar as condições independentes documentadas. PPP/concessão positiva continua a determinar autorização e Conselho de Ministros, sem esperar por esse campo irrelevante à decisão já suficiente. A interface foi conferida: retirar a condição PPP pede exactamente o tipo em falta; declarar directo resolve mero registo e retira estudo/capacidade financeira. Comparação antes de confirmar em `C-final-directness-preview.png` e percurso recuperado em `C-final-path.png`.
Estado final C: dossier v8, contexto **v4**, investimento directo declarado, PPP não, pedido de **mero registo**; quatro eventos de contexto e um documento/vínculo conservados. A modalidade estrangeira/DUAT conservou a edição deliberada. O rascunho v4 e o Word já transferido mantêm o contexto **v3 por autorização**, com hash acima: são prova histórica congelada e não o relatório da revisão v4. Não houve reescrita silenciosa nem reexportação. Ao abrir o simulador, o mecanismo comum de continuidade disponibiliza a revisão actual para o próximo rascunho/relatório; as provas anteriores deste mecanismo continuam reutilizadas.
### Correspondência do novo relatório com o contexto v4 e dependências Git — 22/10/2026
Verificação dirigida solicitada pela Tempo após a avaliação do resumo de `a1fbda06`; nenhuma alteração funcional necessária. A prova anterior só demonstrava o Word congelado v3. Nesta continuação abriu-se o simulador pelo link normal do mesmo dossier C (incluindo o rascunho ligado), recuperou-se automaticamente o contexto v4 e gerou-se **uma** saída Word nova. Não se repetiram cálculos, CSV, paginação, anexos ou ensaios A/B aceites; não houve nova validação jurídica, integração ou alteração fiscal.
| Elemento | Word histórico v3 | Novo Word do contexto v4 |
| --- | --- | --- |
| Identificação dentro do documento | «Revisão 3» e «Contexto congelado v3» | «Revisão 4» e «Contexto congelado v4» |
| Pedido | Autorização | Mero registo |
| Estudo de viabilidade | Documento por associar | Não aplicável; histórico preservado |
| Capacidade financeira | Documento por associar | Não aplicável; histórico preservado |
| Bytes / SHA256 | 16.674 / `28cfe4b75116c892d3e29fde389de7d067b456761756bab732af625178ee69ce` | 16.678 / `a2849427f4a91fa6ab78e433be8b919baca80a0f4f452a4546a5b169b921d753` |
| Cópia identificada para revisão | `outputs/private-path-executable-20261022/C-report-HISTORICO-contexto-v3.docx` | `outputs/private-path-executable-20261022/C-report-contexto-v4.docx` |
O original v3 conserva exactamente os bytes/hash anteriores; a cópia está explicitamente designada **HISTÓRICO**. A revisão congelada no próprio Word identifica o contexto a que pertence, sem o transformar no estado actual. O novo ficheiro foi encontrado em Transferências após o clique de exportação e conferido por leitura das células efectivamente geradas; recibo em `C-report-v3-v4-comparison.json`. O rascunho passou a v5, SHA256 `ba824fe0362ca87b301da55298122796f44fdfb11507830b3fe8f54cf88d132d`, com contexto v4 e preparação por mero registo; dossier v8/contexto v4 e um documento mantidos. Prova visual de recuperação em `C-new-report-context-v4.png`. Não se altera a validade histórica da prova v3.
**Dependências para integração após a avaliação da Tempo:** ancestralidade confirmada `57fc9148 → a76369eb → 52fbb146 → a1fbda06`. `a76369eb` fornece a continuidade comum do contexto/edições e é dependência funcional de `a1fbda06`; pode ser integrado independentemente. `52fbb146` é a preparação documental intermédia, sem código executável. A branch `codex/private-investment-path-executable-QA` inclui essa ancestralidade e a presente prova documental; não aplicar apenas o diff de `a1fbda06` num destino sem a continuidade ou alteração equivalente. Se a76369eb já tiver sido integrado, o merge normal reconhece a ancestralidade e não o reaplica como um segundo desenvolvimento.
Procedimento: após a decisão, recuperar o main actual, rever a comparação e fazer integração Git que preserve as alterações concorrentes; resolver apenas conflitos efectivos, sem reset/force-push de main. A leitura desta continuação encontrou main em `57fc914821d0cefa02caa80a74b3d42e663f2742`; é fotografia do estado, não autorização para ignorar commits posteriores. Conferir a revisão servida e só o delta no destino. A habilitação `PRIVATE_INVESTMENT_PREPARATION_ENABLED` é configuração distinta; não publica nem activa regras fiscais. Nesta execução a branch permanece separada, sem integração/habilitação na homologação. Limitações sectoriais/fiscais explícitas mantêm âmbito próprio e não bloqueiam este recorte concluído.
### Integração autorizada do percurso adaptativo — 22/10/2026
A Tempo autorizou a implementação após o fecho do recorte. Main remoto foi actualizado por avanço directo de `57fc9148` para `064296e1`, preservando a sequência `a76369eb → 52fbb146 → a1fbda06 → 064296e1`; não havia alteração concorrente ou conflito no momento do envio. A cadeia automática recusou a primeira tentativa no teste frontend «server draft snapshots split private identity from economic values»: 188/189 passaram. A versão servida anterior `57fc9148` foi conservada, conforme o mecanismo de recuperação; não se contornou a verificação.
Causa concreta: o adaptador de continuidade acrescentava `compatible_fields_snapshot` vazio na recuperação de um rascunho legado sem nenhuma origem compatível. Correcção delimitada na branch `codex/adaptive-delivery-compatibility-QA`: devolver o rascunho original quando não há origem compatível nem metadados de herança anteriores. A herança com origem/metadados continua executada, preservando edições e limites por dossier. Nove controlos dirigidos da herança e o teste exacto da partição/recuperação passaram; não se reabriram os cálculos, relatórios ou cenários aceites. A correcção deve seguir o mesmo processo automático; não altera regras fiscais, esquema ou dados activos. Recibos da tentativa e da disponibilização final em `outputs/investmoz-adaptive-path-delivery-20261022`.
### Percurso adaptativo disponibilizado na homologação — 22/10/2026, 10:17 Maputo
Autorização expressa da Tempo executada. Main passou de `57fc9148` por avanço directo para `064296e1`, conservando a ancestralidade `a76369eb → 52fbb146 → a1fbda06 → 064296e1`. O remoto não tinha alterações concorrentes adicionais no momento da integração; não houve conflito, reset ou envio forçado. A primeira verificação automática recusou o novo metadado vazio na recuperação legada e conservou a release anterior; a causa e a correcção dirigida constam da secção anterior. A correcção `042ffbde44cac27704d6f8b7ec25b525a42e3575` foi ensaiada em branch e integrada por avanço directo, sem suprimir o teste que detectou a incompatibilidade.
**Revisão efectivamente servida:** `042ffbde44cac27704d6f8b7ec25b525a42e3575`, branch main, build de 22/10/2026 às 10:16:14 Maputo; instalação automática concluída às 10:16:16. Manifesto SHA256 `655eef6e6a5e71c5138c68465a5b2ffc36d2cadeff96e6b35682dbfd19d42e79`. Conferida em `https://teste-investidor.tempo.co.mz/build/revision.json` e no recibo protegido da cadeia. A cadeia correu as verificações próprias; manualmente só foram repetidos os dez controlos afectados pela incompatibilidade. Zero migrations e zero importações no destino, sem copiar fixtures QA.
**Funcionalidade utilizável:** `https://teste-investidor.tempo.co.mz/investimentos` (login normal); abrir ou criar o dossier legítimo do investimento e usar «Preparar o pedido de investimento privado» / «Comparar e alterar contexto». Estão disponíveis as perguntas condicionais, fundamentos, comparação antes de confirmação, requisitos/documentos e próximos passos do lote `private-investment-preparation-2026-v1`, com os limites de 2026 já documentados. A conferência do destino limitou-se à revisão, configuração efectiva e entradas: investimentos redirecciona ao login (302), simulador responde 200. Não se criaram dossiers/contas reais ou sintéticas no destino nem se repetiram os cenários funcionais QA aceites.
**Habilitação concreta, distinta de fiscalidade:** a configuração do percurso `PRIVATE_INVESTMENT_PREPARATION_ENABLED` estava ausente/false; passou a true mediante a sessão SSH habitual autenticada pelo utilizador. Antes da escrita foram conferidos revisão servida e hash da configuração; cópia protegida dos bytes anteriores verificada, alteração limitada a essa chave, proprietário e modo0640 preservados, cache de configuração reconstruída e workers notificados de reinício gracioso. Hash .env anterior `7cdaba591bb5787485a349c24db2fd7c8279436da0eb31502e44db2fffa21d03`; posterior `a6b4e9064cf6dbb2d945b53418a6c9252f9c736ff3fde4016b95b74885a9ee0f`. A leitura posterior confirmou `investor-path.enabled=true` e `private_preparation=true`. A cópia de recuperação permanece privada em `/var/lib/investmoz-deploy/backups/private-preparation-20261022/`; não contém uma publicação de parâmetros e não segue para Git.
**Estado fiscal preservado:** nenhuma publicação/activação de nova regra e nenhuma operação na base activa. O consumidor IVA geral previamente autorizado manteve a mesma disponibilidade, cobertura2026 e mensagem antes/depois. As exclusões sectoriais, forma indirecta/dimensão monetária não determinada, benefícios territoriais e apuramento fiscal completo continuam explícitos e não bloqueiam o lote procedimental concluído.
**Suspensão/recuperação:** desligar somente a chave do novo percurso e reconstruir cache/reiniciar graciosamente os workers mantém dossiers, documentos, respostas, versões, relatórios e históricos. Para reverter configuração, usar o valor anterior apenas depois de conferir o hash actual e preservar alterações posteriores; não substituir todo o .env sem conferir concorrência. A reversão de código/build segue o procedimento de releases já existente, sem restaurar dados antigos. A release anterior foi efectivamente conservada na primeira tentativa recusada. Recibo completo local em `outputs/investmoz-adaptive-path-delivery-20261022/delivery-receipt.json`; a presente actualização documental fica na branch de registo, sem novo push main ou nova disponibilização desnecessária.
File diff suppressed because one or more lines are too long
+5 -409
View File
@@ -7,413 +7,8 @@
@custom-variant dark (&:is(.dark *));
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
.tempo-header {
position: relative;
z-index: 40;
background: #090909;
color: #fff;
}
.tempo-header-inner {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: 72px;
column-gap: 0.5rem;
align-items: center;
max-width: 1440px;
margin-inline: auto;
padding-inline: 0.75rem;
}
.tempo-logo-link {
grid-area: 1 / 1;
display: inline-flex;
width: fit-content;
align-items: center;
flex-shrink: 0;
border-radius: 0.25rem;
}
.tempo-header-logo {
width: auto;
height: 36px;
object-fit: contain;
}
.tempo-primary-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
min-width: 0;
border-top: 1px solid #ffffff1a;
}
.tempo-primary-navigation > ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.25rem 0.5rem;
padding-block: 0.75rem;
}
.tempo-primary-navigation .tempo-nav-trigger {
font-size: 0.9375rem;
padding-inline: 0.5rem;
}
.tempo-nav-trigger {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
white-space: nowrap;
color: #eee;
transition:
color 0.2s,
background 0.2s;
}
.tempo-nav-trigger:hover,
.tempo-nav-trigger[data-state='open'] {
background: #ffffff0d;
color: #fff;
}
.tempo-nav-trigger[data-current-group] {
color: #fff;
box-shadow: inset 0 -2px #ed1c24;
}
.tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg);
}
.tempo-primary-navigation .tempo-nav-trigger > svg {
transform: none;
flex-shrink: 0;
}
.tempo-section-bar {
border-top: 1px solid #ffffff14;
background: inherit;
}
.tempo-section-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.75rem;
max-width: 1440px;
margin-inline: auto;
padding: 0 0.75rem 0.5rem;
}
.tempo-section-navigation {
grid-area: 1 / 1 / auto / -1;
min-width: 0;
}
.tempo-section-navigation > ul {
display: flex;
justify-content: center;
gap: 0.25rem;
}
.tempo-section-navigation .tempo-nav-trigger {
justify-content: center;
min-height: 44px;
gap: 0.25rem;
padding: 0.5rem 0.375rem;
border-radius: 0;
font-size: 0.8125rem;
letter-spacing: normal;
}
@media (min-width: 640px) {
.tempo-section-navigation .tempo-nav-trigger {
min-height: 48px;
gap: 0.5rem;
padding-inline: 0.75rem;
font-size: 1rem;
}
}
@media (min-width: 768px) {
.tempo-section-inner {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
padding: 0.25rem 1.25rem;
}
.tempo-section-navigation {
grid-area: 1 / 2;
}
.tempo-section-inner .tempo-panel-summary {
grid-area: 1 / 1;
}
.tempo-section-inner .tempo-interest-button {
grid-area: 1 / 3;
}
}
.tempo-header-extras {
display: none;
grid-area: 3 / 1 / auto / -1;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
padding-block: 0.75rem;
border-top: 1px solid #ffffff1a;
}
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
}
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
}
.tempo-header-utilities {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.tempo-utility-button {
display: flex;
min-height: 40px;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #e2e8f0;
}
.tempo-utility-button:hover {
background: #ffffff10;
}
.tempo-header-social {
display: flex;
width: 30px;
height: 40px;
align-items: center;
justify-content: center;
color: var(--social-color);
transition:
transform 0.2s,
opacity 0.2s;
border-radius: 0.4rem;
}
.tempo-header-social:hover {
transform: translateY(-2px);
opacity: 0.8;
}
.tempo-login-button {
display: inline-flex;
min-height: 40px;
align-items: center;
gap: 0.5rem;
border: 1px solid #ffffff50;
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
color: #fff;
transition:
background 0.2s,
border-color 0.2s;
}
.tempo-login-button:hover {
background: #ed1c24;
border-color: #ed1c24;
}
.tempo-login-button > span {
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
}
.tempo-header-actions {
grid-area: 1 / 2;
display: flex;
min-width: 0;
align-items: center;
}
.tempo-interest-button {
display: inline-flex;
grid-area: 2 / 2;
justify-self: end;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0.5rem 0.65rem;
border: 1px solid #ed1c24;
border-radius: 0.4rem;
background: #ed1c24;
color: white;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
}
.tempo-interest-button:hover {
background: #c9151c;
}
.tempo-panel-summary {
grid-area: 2 / 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.1rem;
min-height: 44px;
font-size: 0.8125rem;
line-height: 1.4;
color: #fff;
text-underline-offset: 3px;
}
.tempo-panel-summary:hover {
text-decoration: underline;
}
.tempo-panel-count {
color: #cbd5e1;
font-size: 0.6875rem;
}
.tempo-header-toggle {
grid-area: 1 / 3;
display: inline-flex;
width: 44px;
height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid #ffffff33;
border-radius: 0.4rem;
color: #fff;
cursor: pointer;
}
.tempo-header-toggle:hover,
.tempo-header-toggle[aria-expanded='true'] {
background: #ffffff10;
}
@media (min-width: 640px) {
.tempo-header-inner {
padding-inline: 1.25rem;
}
.tempo-header-logo {
height: 44px;
}
.tempo-primary-navigation > ul {
display: flex;
justify-content: space-between;
}
}
@media (min-width: 1024px) {
.tempo-header-inner {
grid-template-rows: 88px;
padding-inline: 1.5rem;
}
.tempo-header-logo {
height: 56px;
}
}
@media (min-width: 1280px) {
/* Shared columns keep each portal area below its institutional link. */
.tempo-header-layout {
display: grid;
grid-template-columns: auto repeat(4, max-content) minmax(0, 1fr) auto;
column-gap: 0.25rem;
max-width: 1440px;
margin-inline: auto;
padding-inline: 2rem;
}
.tempo-header-inner {
grid-column: 1 / -1;
grid-template-columns: subgrid;
min-height: 88px;
column-gap: normal;
max-width: none;
margin: 0;
padding: 0;
}
.tempo-logo-link {
margin-right: 0.75rem;
}
.tempo-header-logo {
height: 64px;
}
.tempo-header[data-expanded] .tempo-primary-navigation,
.tempo-primary-navigation {
display: grid;
grid-area: 1 / 2 / 2 / 6;
grid-template-columns: subgrid;
border: 0;
}
.tempo-primary-navigation > ul {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
padding: 0;
}
.tempo-nav-trigger {
justify-content: center;
padding-inline: 0.875rem;
font-size: 1.125rem;
letter-spacing: 0.025em;
}
.tempo-primary-navigation .tempo-nav-trigger {
padding-inline: 0.625rem;
font-size: 1.125rem;
}
.tempo-header-extras {
display: flex;
grid-area: 1 / 6;
justify-self: end;
flex-shrink: 0;
padding: 0 0.75rem;
border-top: 0;
border-inline: 1px solid #ffffff1a;
}
.tempo-header-utilities {
flex-wrap: nowrap;
gap: 0.5rem;
}
.tempo-header-social {
width: 26px;
}
.tempo-header-toggle {
display: none;
}
.tempo-header-actions {
grid-area: 1 / 7;
}
.tempo-section-bar {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.tempo-section-inner {
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
max-width: none;
margin: 0;
padding-inline: 0;
}
.tempo-section-navigation {
display: grid;
grid-area: 1 / 2 / 2 / 5;
grid-template-columns: subgrid;
}
.tempo-section-navigation > ul {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
}
.tempo-section-navigation .tempo-nav-trigger {
padding-inline: 0.625rem;
}
.tempo-primary-navigation .tempo-nav-trigger,
.tempo-section-navigation .tempo-nav-trigger {
justify-content: flex-start;
}
.tempo-interest-button {
padding-inline: 0.85rem;
font-size: 0.8125rem;
}
.tempo-section-inner .tempo-interest-button {
grid-area: 1 / 6 / 2 / -1;
}
}
.tempo-header :is(a, button):focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@import './tempo-header.css';
.tempo-dropdown {
z-index: 60;
border: 1px solid #e2e8f0;
@@ -441,7 +36,7 @@
outline: none;
}
.tempo-footer {
margin-top: 2rem;
margin-top: 1.5rem;
background: #050505;
color: #fff;
}
@@ -475,7 +70,8 @@
.tempo-contact-link {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
min-height: 40px;
transition: color 0.2s;
}
.tempo-contact-link:hover {
+321
View File
@@ -0,0 +1,321 @@
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
.tempo-header {
position: relative;
z-index: 40;
background: #090909;
color: #fff;
border-bottom: 1px solid #ffffff14;
}
.tempo-header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: 71px;
align-items: center;
column-gap: 0.5rem;
max-width: 1600px;
margin-inline: auto;
padding-inline: 0.75rem;
}
.tempo-logo-link {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
width: 112px;
max-width: 100%;
border-radius: 0.25rem;
}
.tempo-header-logo {
width: 100%;
height: auto;
object-fit: contain;
}
.tempo-primary-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
min-width: 0;
border-top: 1px solid #ffffff1a;
padding-block: 0.75rem;
}
.tempo-primary-navigation > ul {
display: grid;
gap: 0.25rem;
}
.tempo-nav-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
width: 100%;
min-height: 44px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: #e2e8f0;
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
white-space: nowrap;
transition:
color 0.2s,
background 0.2s;
}
.tempo-nav-trigger:hover,
.tempo-nav-trigger[data-state='open'] {
background: #ffffff0d;
color: #fff;
}
.tempo-nav-trigger[data-current-group] {
color: #fff;
background: #ffffff08;
box-shadow: inset 0 -2px #ed1c24;
}
.tempo-nav-trigger > svg {
flex-shrink: 0;
transition: transform 0.2s;
}
.tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg);
}
.tempo-header-extras {
display: none;
grid-area: 3 / 1 / auto / -1;
min-width: 0;
padding-block: 0.75rem;
border-top: 1px solid #ffffff1a;
}
.tempo-header-utilities {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.tempo-utility-button {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.4rem;
border-radius: 0.5rem;
color: #e2e8f0;
font-size: 0.8125rem;
}
.tempo-utility-button:hover {
background: #ffffff10;
}
.tempo-header-actions {
grid-area: 1 / 2;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.tempo-login-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
padding: 0.5rem 0.625rem;
border: 1px solid #ffffff40;
border-radius: 0.5rem;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
transition:
background 0.2s,
border-color 0.2s;
}
.tempo-login-button:hover {
background: #ffffff10;
border-color: #ffffff80;
}
.tempo-login-button > span {
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
}
.tempo-panel-summary {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 0.5rem;
color: #e2e8f0;
}
.tempo-panel-summary:hover {
background: #ffffff10;
color: #fff;
}
.tempo-panel-count {
position: absolute;
top: 0;
right: -0.125rem;
min-width: 17px;
padding-inline: 0.2rem;
border: 2px solid #090909;
border-radius: 999px;
background: #ed1c24;
color: #fff;
font-size: 0.5625rem;
line-height: 1.4;
text-align: center;
}
.tempo-interest-button {
display: none;
grid-area: 4 / 1 / auto / -1;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 44px;
margin-bottom: 1rem;
padding: 0.5rem 0.875rem;
border: 1px solid #ed1c24;
border-radius: 0.5rem;
background: #ed1c24;
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
}
.tempo-interest-button:hover {
background: #c9151c;
}
.tempo-header-toggle {
grid-area: 1 / 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid #ffffff33;
border-radius: 0.5rem;
color: #fff;
cursor: pointer;
}
.tempo-header-toggle:hover,
.tempo-header-toggle[aria-expanded='true'] {
background: #ffffff10;
}
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
}
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
}
.tempo-header[data-expanded] .tempo-interest-button {
display: inline-flex;
}
.tempo-header :is(a, button):focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.tempo-submenu {
max-width: min(
calc(100vw - 1.5rem),
var(--radix-dropdown-menu-content-available-width)
);
}
@media (min-width: 640px) {
.tempo-header-inner {
grid-template-columns: minmax(0, 1fr) auto auto auto;
column-gap: 0.75rem;
padding-inline: 1.5rem;
}
.tempo-logo-link {
width: 132px;
}
.tempo-interest-button {
display: inline-flex;
grid-area: 1 / 3;
margin-bottom: 0;
}
.tempo-header-toggle {
grid-area: 1 / 4;
}
}
@media (min-width: 1280px) {
.tempo-header-inner {
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
column-gap: 0.25rem;
padding-inline: 1.25rem;
}
.tempo-logo-link {
width: 100px;
}
.tempo-login-button > span {
max-width: 3rem;
}
.tempo-primary-navigation,
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
grid-area: 1 / 2;
border: 0;
padding: 0;
justify-self: center;
}
.tempo-primary-navigation > ul {
display: flex;
align-items: center;
gap: 0;
}
.tempo-nav-trigger {
justify-content: center;
padding-inline: 0.25rem;
font-size: 1.125rem;
letter-spacing: 0.025em;
}
.tempo-header-extras,
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
grid-area: 1 / 3;
padding: 0 0.5rem 0 0;
border: 0;
border-right: 1px solid #ffffff1a;
}
.tempo-header-utilities {
flex-wrap: nowrap;
}
.tempo-header-actions {
grid-area: 1 / 4;
}
.tempo-interest-button {
grid-area: 1 / 5;
}
.tempo-header-toggle {
display: none;
}
}
@media (min-width: 1440px) {
.tempo-header-inner {
column-gap: 0.75rem;
padding-inline: 2rem;
}
.tempo-nav-trigger {
padding-inline: 0.375rem;
font-size: 1.125rem;
}
.tempo-logo-link {
width: 132px;
}
.tempo-login-button > span {
max-width: 5rem;
}
.tempo-header-extras {
padding-right: 0.75rem;
}
.tempo-primary-navigation > ul {
gap: 0.125rem;
}
}
@media (min-width: 1536px) {
.tempo-header-inner {
column-gap: 1rem;
}
.tempo-nav-trigger {
padding-inline: 0.75rem;
}
.tempo-login-button > span {
max-width: 6rem;
}
}
+27 -58
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { InvestmentContextAssessment } from './investment-context-assessment';
import { Link } from '@inertiajs/react';
import {
contextApi,
contextFieldNames,
type ContextView,
} from '@/lib/investor-context';
import { inheritCompatibleFields } from '@/lib/context-inheritance';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
@@ -12,6 +14,7 @@ type Props = {
enabled?: boolean;
readOnly?: boolean;
allowedFields?: string[];
persistInheritance?: boolean;
boundRevision?: number;
onApply: (patch: Record<string, unknown>, replace: boolean) => void;
onReview?: (revision: number, reviewed: boolean) => void;
@@ -40,17 +43,16 @@ export function DossierPrefill(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
const filled = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setView(null);
setError('');
void contextApi<ContextView>(`/investimentos/${contextId}/contexto`)
.then((v) => {
if (!stopped) {
setView(v);
setReviewed(false);
latest.current.onReview?.(v.context.revision, false);
}
})
.catch((e) => {
@@ -61,44 +63,20 @@ export function DossierPrefill(props: Props) {
};
}, [contextId, retry]);
useEffect(() => {
if (!view || !enabled || readOnly || !view.can_write) return;
const generation = `${contextId}:${view.context.revision}:${consumer}`;
if (filled.current === generation) return;
if (!view || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
const source = view.prefill[consumer] ?? {};
const now = latest.current;
const patch: Record<string, unknown> = {};
const conflict = geo.some(
(k) =>
!empty(now.values[k]) &&
String(now.values[k]) !== text(source[k]),
);
for (const [k, v] of Object.entries(source)) {
if (
(now.allowedFields && !now.allowedFields.includes(k)) ||
(geo.includes(k) && conflict)
)
continue;
if (k === 'parcels') {
const old = now.values.parcels as
| Record<string, unknown>[]
| undefined;
if (
(!old?.length || geo.every((g) => empty(old[0]?.[g]))) &&
Array.isArray(v) &&
v[0] &&
Object.keys(v[0]).length
)
patch[k] = v;
} else if (empty(now.values[k]) && !empty(v)) patch[k] = v;
}
filled.current = generation;
if (Object.keys(patch).length) {
now.onApply(patch, false);
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
now.onApply({...inherited.patch, ...(now.persistInheritance === false ? {} : {context_reuse:inherited.metadata})}, true);
setMessage(
'Campos vazios preenchidos a partir do dossier. Reveja-os; os campos já preenchidos foram preservados.',
'Dados compatíveis reutilizados do mesmo dossier. Os valores herdados acompanham o contexto; as suas alterações locais foram preservadas.',
);
}
}, [view, enabled, readOnly, contextId, consumer]);
setReviewed(true);
now.onReview?.(view.context.revision, true);
}, [view, enabled, readOnly, contextId, consumer, props.values]);
if (!contextId) return null;
if (error)
return (
@@ -126,7 +104,7 @@ export function DossierPrefill(props: Props) {
),
);
const differences = Object.keys(source).filter(
(k) => text(props.values[k]) !== text(source[k]),
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
@@ -152,6 +130,7 @@ export function DossierPrefill(props: Props) {
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
</p>
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
@@ -254,7 +233,17 @@ export function DossierPrefill(props: Props) {
const patch: Record<string, unknown> = {};
for (const k of selected)
patch[k] = source[k] ?? '';
props.onApply(patch, true);
const scope = `dossier:${contextId}:${consumer}`;
let raw = String(props.values.context_reuse ?? '');
try {
const metadata = JSON.parse(raw);
if(metadata.scope === scope) {
for(const key of selected) delete metadata.inherited?.[key];
raw=JSON.stringify(metadata);
}
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
setSelected([]);
setReviewed(false);
props.onReview?.(view.context.revision, false);
@@ -267,27 +256,7 @@ export function DossierPrefill(props: Props) {
</button>
</details>
)}
{!readOnly &&
view.can_write &&
props.onReview &&
view.context.revision > 0 && (
<label className="block">
<input
type="checkbox"
checked={reviewed}
onChange={(e) => {
setReviewed(e.target.checked);
props.onReview?.(
view.context.revision,
e.target.checked,
);
}}
/>{' '}
Conferi os dados e pressupostos deste formulário para o
contexto v{view.context.revision}; os valores diferentes
que conservei são declarações deste processo.
</label>
)}
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
@@ -0,0 +1,232 @@
import {
PreparationSummary,
type Preparation,
} from '@/components/private-investment-preparation';
import { Link } from '@inertiajs/react';
type Snapshot = {
preparation?: Preparation | null;
context_revision: number;
path?: {
definition_version: number;
steps: Array<{
code: string;
title: string;
applicability: string;
reason: string;
state: string;
context_review_required?: boolean;
}>;
};
zone?: {
title: string;
href: string;
version: number | string;
fields?: Record<string, string>;
analysis?: {
conditions?: Array<{
id: string;
label: string;
knowledge: string;
source_reference?: string;
valid_from?: string;
valid_until?: string;
}>;
source?: {
title: string;
url: string;
diploma?: string;
article?: string;
valid_from?: string;
valid_until?: string;
} | null;
};
} | null;
fields?: Record<string, string>;
};
export function InvestmentContextAssessment({
raw,
contextId,
}: {
raw: unknown;
contextId?: string | null;
}) {
let snapshot: Snapshot | null = null;
try {
const value = JSON.parse(String(raw ?? ''));
if (value.version === 1) snapshot = value;
} catch {
/* Missing context remains explicit. */
}
if (!snapshot) return null;
const fields = snapshot.fields ?? {};
const zone = snapshot.zone;
const regime = zone?.fields?.investment_regime ?? fields.investment_regime;
const names: Record<string, string> = {
zee: 'ZEE',
zfi: 'ZFI',
industrial_park: 'Parque industrial',
};
return (
<section
className="space-y-2 rounded border border-amber-400 p-3"
aria-label="Aplicabilidade ao investimento"
>
<h3 className="font-semibold">
Aplicabilidade ao investimento · contexto v
{snapshot.context_revision}
</h3>
<p>
Origem do investidor:{' '}
{{
national: 'nacional',
foreign: 'estrangeiro',
mixed: 'misto',
}[fields.investor_origin] ?? 'informação em falta'}
. Residência fiscal declarada:{' '}
{fields.tax_residency_country || 'informação em falta'}. Estas
condições são distintas; uma não determina a outra.
</p>
{zone ? (
<>
<p>
<strong>
Potencialmente aplicável — análise específica de{' '}
{names[regime ?? ''] ?? 'zona seleccionada'}.
</strong>{' '}
<Link className="underline" href={zone.href}>
{zone.title} · versão {zone.version} · consultar
ficha e fundamentos
</Link>
.
</p>
<p>
A selecção identifica a referência consultada; não
confirma o reconhecimento da entidade, actividade
autorizada, licença ou acesso a benefícios. Consulte as
condições documentadas na ficha e reúna a autorização ou
evidência correspondente no dossier.
</p>
{zone.analysis?.source && (
<p>
Fundamento cadastrado:{' '}
<a
className="underline"
href={zone.analysis.source.url}
>
{zone.analysis.source.diploma ||
zone.analysis.source.title}{' '}
{zone.analysis.source.article}
</a>
. Período cadastrado:{' '}
{zone.analysis.source.valid_from ||
'início por indicar'}{' '}
a{' '}
{zone.analysis.source.valid_until ||
'sem termo indicado'}
. A cobertura do investimento exige correspondência
com estas condições.
</p>
)}
{zone.analysis?.conditions?.length ? (
<ul className="list-disc pl-5">
{zone.analysis.conditions.map((condition) => (
<li key={condition.id}>
<strong>{condition.label}</strong>:{' '}
{condition.knowledge === 'documented'
? 'condição documentada; cumprimento pelo investimento por confirmar'
: condition.knowledge === 'disputed'
? 'condição com informação divergente'
: 'informação em falta'}
.{' '}
{condition.source_reference && (
<>
Fundamento:{' '}
{condition.source_reference}.
</>
)}{' '}
{condition.valid_from && (
<>Desde {condition.valid_from}.</>
)}{' '}
{condition.valid_until && (
<>Até {condition.valid_until}.</>
)}
</li>
))}
</ul>
) : (
<p>
Condições específicas: informação estruturada em
falta nesta referência. A ficha e os documentos
continuam consultáveis; não se presume cumprimento.
</p>
)}
<p role="status">
Benefício fiscal deste enquadramento: indeterminado. Não
existe neste consumidor uma regra executável ligada ao
reconhecimento e condições da zona. No simulador, o IVA
disponível corresponde apenas ao cenário geral sem
benefício, sobre vendas declaradas compatíveis; não
simula o regime especial. O cenário com benefício e o
apuramento final estão indisponíveis.
</p>
</>
) : (
<p>
Enquadramento geral de preparação: sem ZEE, ZFI ou parque
seleccionado. Isso não confirma a elegibilidade para uma
operação fiscal. O cálculo mantém o âmbito e as exclusões da
versão publicada.
</p>
)}
<p>
Confirmado neste quadro significa apenas dado recuperado do
dossier; declarações e referências não são decisões jurídicas.
Requisitos específicos não documentados permanecem
indeterminados.
</p>
{snapshot.path && (
<details>
<summary>
Passos adaptados pelo percurso existente · versão{' '}
{snapshot.path.definition_version}
</summary>
<ul className="list-disc pl-5">
{snapshot.path.steps.map((step) => (
<li key={step.code}>
{step.code} — {step.title}:{' '}
{step.context_review_required
? 'informação alterada; rever fundamento'
: step.applicability ===
'not_needed_for_preparation'
? 'não aplicável à preparação declarada'
: step.applicability === 'undetermined'
? 'informação em falta'
: ['completed', 'approved'].includes(
step.state,
)
? 'preparação confirmada no percurso; não é decisão fiscal'
: 'potencialmente aplicável'}
. {step.reason}
</li>
))}
</ul>
</details>
)}
{snapshot.preparation?.enabled && (
<PreparationSummary
data={snapshot.preparation}
caseId={contextId ?? undefined}
/>
)}
{contextId && (
<Link
className="underline"
href={`/investimentos/${contextId}/contexto/editar`}
>
Alterar contexto e comparar impacto antes de guardar
</Link>
)}
</section>
);
}
+308 -235
View File
@@ -3,14 +3,16 @@ import { Link, router, usePage } from '@inertiajs/react';
import {
ChevronDown,
ArrowUpRight,
LayoutDashboard,
LogOut,
Menu,
UserRound,
X,
} from 'lucide-react';
import { useEffect, useId, useRef, useState } from 'react';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { useIsMobile } from '@/hooks/use-mobile';
import { useNavigationContext } from '@/components/navigation-context';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import { tempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import {
TempoLanguageSelector,
TempoWeather,
@@ -52,6 +54,7 @@ export function PortalGroupedMenu() {
scopedAdministration?: {label:string;href:string}[];
}>();
const { t, language, requestCount } = useTempoUi();
const mobile = useIsMobile();
const navigation = useNavigationContext();
const groups = portalNavigation({
context: navigation.view
@@ -72,24 +75,34 @@ export function PortalGroupedMenu() {
const currentItem = currentNavigationItem(groups, url);
const [loggingOut, setLoggingOut] = useState(false);
const [expanded, setExpanded] = useState(false);
const institutionalId = useId();
const navigationId = useId();
const toolsId = useId();
const interestId = useId();
const toggle = useRef<HTMLButtonElement>(null);
useEffect(() => router.on('navigate', () => setExpanded(false)), []);
const items = (children: PortalNavItem[]) =>
children.map((item) =>
item.children ? (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
mobile ? (
<Fragment key={item.id}>
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500">
{t(item.label)}
</DropdownMenuLabel>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</Fragment>
) : (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown tempo-submenu max-h-[min(70vh,34rem)] w-72 overflow-y-auto p-2"
>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
) : (
<DropdownMenuItem
key={item.id}
@@ -111,6 +124,8 @@ export function PortalGroupedMenu() {
(group) =>
!['home', 'discovery', 'realization', 'support'].includes(group.id),
);
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
const institutionalCurrent = url.split('?')[0] === '/contacto';
return (
<header
className="tempo-header"
@@ -120,245 +135,303 @@ export function PortalGroupedMenu() {
if (
event.key === 'Escape' &&
expanded &&
!event.defaultPrevented
!event.defaultPrevented &&
!(
event.target instanceof Element &&
event.target.closest('[role="menu"]')
)
) {
setExpanded(false);
toggle.current?.focus();
}
}}
>
<div className="tempo-header-layout">
<div className="tempo-header-inner">
<Link
href="/"
aria-label="InvesteMoz — início"
className="tempo-logo-link"
>
<PortalProductIdentity />
</Link>
<nav
id={institutionalId}
aria-label="Navegação institucional"
className="tempo-primary-navigation"
>
<ul>
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/' || undefined
}
>
{t('Início')}
</Link>
</li>
<li>
<a
href="https://revista.tempo.co.mz/"
target="_blank"
rel="noopener noreferrer"
className="tempo-nav-trigger"
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
>
{t('Revista Tempo')}
<ArrowUpRight
size={14}
aria-hidden="true"
/>
</a>
</li>
{[
['/sobre', 'Sobre nós'],
['/contacto', 'Contacto'],
].map(([href, label]) => (
<li key={href}>
<Link
href={href}
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === href
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === href ||
undefined
}
>
{t(label)}
</Link>
</li>
))}
</ul>
</nav>
<div id={toolsId} className="tempo-header-extras">
<div className="tempo-header-utilities">
<div className="flex items-center gap-1">
<TempoWeather />
<TempoLanguageSelector />
</div>
<TempoSocialLinks />
</div>
</div>
<div className="tempo-header-actions">
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name ||
t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() =>
setLoggingOut(false)
<div className="tempo-header-inner">
<Link
href="/"
aria-label="Tempo — início"
className="tempo-logo-link"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="tempo-header-logo"
/>
</Link>
<nav
id={navigationId}
aria-label="Áreas do portal"
className="tempo-primary-navigation"
>
<ul>
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/' || undefined
}
>
{t('Início')}
</Link>
</li>
{groups
.filter((group) =>
[
'discovery',
'realization',
'support',
].includes(group.id),
)
.map((group) => (
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
}
>
<LogOut
size={16}
{t(group.label)}
<ChevronDown
size={14}
aria-hidden="true"
/>
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</div>
<button
ref={toggle}
type="button"
className="tempo-header-toggle"
aria-label={
expanded
? 'Fechar menu e ferramentas'
: 'Abrir menu e ferramentas'
}
aria-expanded={expanded}
aria-controls={`${institutionalId} ${toolsId}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<X size={22} aria-hidden="true" />
) : (
<Menu size={22} aria-hidden="true" />
)}
</button>
</div>
<div className="tempo-section-bar">
<div className="tempo-section-inner">
<Link
href="/investidor/manifestacoes"
className="tempo-panel-summary"
>
<span>{t('O meu painel')}</span>
<span
className="tempo-panel-count"
aria-live="polite"
aria-atomic="true"
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(group.children ?? [])}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
<li>
<Link
href="/sobre"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/sobre'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/sobre' || undefined
}
>
{requestCount}{' '}
{t(
requestCount === 1
? 'pedido recebido'
: 'pedidos recebidos',
)}
</span>
</Link>
<nav
aria-label="Áreas do portal"
className="tempo-section-navigation"
>
<ul>
{groups
.filter((group) =>
[
'discovery',
'realization',
'support',
].includes(group.id),
)
.map((group) => (
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
{t('Sobre nós')}
</Link>
</li>
<li>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
institutionalCurrent || undefined
}
>
Tempo
<ChevronDown size={14} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-64 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuItem
asChild
className="tempo-menu-item"
>
<a
href="https://revista.tempo.co.mz/"
target="_blank"
rel="noopener noreferrer"
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
>
{t('Revista Tempo')}
<ArrowUpRight
size={14}
aria-hidden="true"
/>
</a>
</DropdownMenuItem>
{[['/contacto', 'Contacto']].map(
([href, label]) => (
<DropdownMenuItem
key={href}
asChild
className="tempo-menu-item"
>
<Link
href={href}
aria-current={
url.split('?')[0] ===
href
? 'page'
: undefined
}
>
{t(group.label)}
<ChevronDown
size={14}
{t(label)}
</Link>
</DropdownMenuItem>
),
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-slate-500">
Revista Tempo
</DropdownMenuLabel>
{tempoSocialLinks.map(
({ name, href, icon: Icon }) => (
<DropdownMenuItem
key={name}
asChild
className="tempo-menu-item"
>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Revista Tempo — ${name}`}
>
<Icon
size={16}
aria-hidden="true"
/>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={8}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(
group.children ?? [],
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</nav>
<Link
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
</Link>
{name}
<ArrowUpRight
size={12}
aria-hidden="true"
/>
</a>
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
</ul>
</nav>
<div id={toolsId} className="tempo-header-extras">
<div className="tempo-header-utilities">
<div className="flex items-center gap-1">
<TempoWeather />
<TempoLanguageSelector />
</div>
</div>
</div>
<div className="tempo-header-actions">
<Link
href="/investidor/manifestacoes"
className="tempo-panel-summary"
aria-label={panelLabel}
title={panelLabel}
>
<LayoutDashboard size={19} aria-hidden="true" />
<span className="tempo-panel-count" aria-hidden="true">
{requestCount}
</span>
</Link>
<span
className="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{panelLabel}
</span>
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name || t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
>
<LogOut size={16} aria-hidden="true" />
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</div>
<Link
id={interestId}
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
<ArrowUpRight size={15} aria-hidden="true" />
</Link>
<button
ref={toggle}
type="button"
className="tempo-header-toggle"
aria-label={
expanded
? 'Fechar menu e ferramentas'
: 'Abrir menu e ferramentas'
}
aria-expanded={expanded}
aria-controls={`${navigationId} ${toolsId} ${interestId}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<X size={22} aria-hidden="true" />
) : (
<Menu size={22} aria-hidden="true" />
)}
</button>
</div>
</header>
);
@@ -0,0 +1,457 @@
import { Link } from '@inertiajs/react';
import { card, input } from '@/pages/investment/shared';
export type PreparationDocument = {
document_id: string;
file_name: string;
status: string;
malware_scan_status: string;
};
export type Preparation = {
version: string;
enabled: boolean;
mode: string;
period_covered: boolean;
scope_covered: boolean;
reason: string[];
missing_fields: string[];
missing_labels?: string[];
authority: string;
next: string;
calculation_notice: string;
notice: string;
source: { url: string; sha256: string };
authority_source: { reference: string; url: string };
questions: Array<{
key: string;
label: string;
type?: string;
options?: Record<string, string>;
when?: { field: string; equals: string };
source: string;
help?: string;
}>;
requirements: Array<{
key: string;
label: string;
state: string;
required: boolean;
document_id: string | null;
document: PreparationDocument | null;
stage: string;
reason: string;
source: { reference: string; url: string; period: string };
}>;
};
const states: Record<string, string> = {
not_applicable: 'Não aplicável neste contexto',
indeterminate: 'Informação em falta para decidir',
missing: 'Documento por associar',
associated: 'Documento associado — conteúdo por conferir',
};
export const preparationMode = (mode: string) =>
({
authorization: 'Pedido de autorização',
registration: 'Pedido de mero registo',
indeterminate: 'Enquadramento por determinar',
})[mode] ?? mode;
export function PreparationSummary({
data,
caseId,
}: {
data: Preparation | null | undefined;
caseId?: string;
}) {
if (!data) return null;
return (
<section
id="private-investment-preparation"
className={card + ' space-y-3'}
aria-label="Preparação adaptada do investimento privado"
>
<h2 className="text-xl font-bold">
Preparar o pedido de investimento privado
</h2>
{!data.enabled ? (
<p>
Use os dados deste dossier para identificar o pedido e os
documentos aplicáveis. A preparação é opcional e não atribui
benefícios.
</p>
) : (
<>
<p>
<strong>{preparationMode(data.mode)}</strong> · recorte
de 2026 ·{' '}
{data.version.replace(
'private-investment-preparation-',
'',
)}
</p>
<p>{data.next}</p>
{data.missing_fields.length > 0 && (
<p>
Informação em falta:{' '}
{(
data.missing_labels ??
data.missing_fields.map(
(key) =>
data.questions?.find(
(q) => q.key === key,
)?.label ??
(
{
currency: 'Moeda do projecto',
expected_start_date:
'Data prevista de início',
entity_stage:
'Situação da entidade',
} as Record<string, string>
)[key] ??
key,
)
).join('; ')}
.
</p>
)}
{!data.period_covered && (
<p>
Este incremento cobre pedidos com início previsto em
2026. Uma data em falta ou fora desse recorte não
permite determinar o pedido por este lote; os
restantes trabalhos do dossier continuam
disponíveis.
</p>
)}
{!data.scope_covered && (
<p>
Declare a actividade efectiva. Prospecção,
pesquisa/produção extractiva e investimento público
ou exclusivamente social/sem fins lucrativos seguem
enquadramentos próprios, fora deste lote.
</p>
)}
{data.reason.map((reason) => (
<p key={reason}>{reason}</p>
))}
<p>
Encaminhamento indicativo: {data.authority}.{' '}
<a
className="underline"
href={data.authority_source.url}
>
{data.authority_source.reference}
</a>
</p>
<ul className="space-y-3">
{data.requirements
.filter((r) => r.state !== 'not_applicable')
.map((r) => (
<li key={r.key} className="rounded border p-3">
<strong>{r.label}</strong> —{' '}
{states[r.state]}.{' '}
{r.document?.file_name && (
<span>
Ficheiro: {r.document.file_name}
.{' '}
</span>
)}
<p>
{r.reason}{' '}
<a
className="underline"
href={r.source.url}
>
Ver fundamento: {r.source.reference}
</a>{' '}
· {r.source.period}.
</p>
{caseId && (
<Link
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-documents`}
>
Rever dados ou associar documento do
dossier
</Link>
)}
</li>
))}
</ul>
<details>
<summary>
Exigências que não se aplicam — histórico preservado
</summary>
{data.requirements
.filter((r) => r.state === 'not_applicable')
.map((r) => (
<p key={r.key}>
{r.label}: {r.reason}{' '}
{r.document_id && (
<>
Associação anterior mantida
{r.document?.file_name
? `: ${r.document.file_name}`
: ''}
.
</>
)}{' '}
<a
className="underline"
href={r.source.url}
>
{r.source.reference}
</a>
</p>
))}
</details>
<p>{data.calculation_notice}</p>
<p>{data.notice}</p>
</>
)}
<a className="underline" href={data.source.url}>
Consultar o exemplar oficial e os fundamentos
</a>
{caseId && (
<p>
<Link
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}
>
Completar apenas os dados em falta ou editar declarações
</Link>
</p>
)}
</section>
);
}
export function PreparationEditor({
data,
fields,
changes,
documents,
links,
onChange,
onLink,
disabled,
}: {
data: Preparation;
fields: Record<string, string>;
changes: Record<string, string>;
documents: PreparationDocument[];
links: Record<string, string | null>;
onChange: (key: string, value: string) => void;
onLink: (key: string, value: string | null) => void;
disabled: boolean;
}) {
const values = { ...fields, ...changes };
const active = values.investment_preparation === 'yes';
const excluded = ['extractive', 'public_nonprofit'].includes(
values.investment_activity_scope,
);
const applicable = (q: Preparation['questions'][number]) =>
!q.when || values[q.when.field] === q.when.equals;
// Keep a field in place while the user edits it; collapse only recovered answers.
const known = (q: Preparation['questions'][number]) =>
!!fields[q.key] && fields[q.key] !== 'unknown';
const render = (q: Preparation['questions'][number]) => (
<label className="grid gap-1" key={q.key} htmlFor={'prep-' + q.key}>
{q.label}
{q.options ? (
<select
id={'prep-' + q.key}
className={input}
disabled={disabled}
value={values[q.key] ?? ''}
onChange={(e) => onChange(q.key, e.target.value)}
>
<option value="">Por indicar</option>
{Object.entries(q.options).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
) : (
<input
id={'prep-' + q.key}
className={input}
disabled={disabled}
type="text"
inputMode="decimal"
value={values[q.key] ?? ''}
onChange={(e) => onChange(q.key, e.target.value)}
/>
)}
<span className="text-sm">
{q.help} Fundamento: {q.source}.
</span>
</label>
);
const questions = data.questions.filter(
(q) =>
q.key !== 'investment_preparation' &&
applicable(q) &&
(!excluded || q.key === 'investment_activity_scope'),
);
const needed = questions.filter(
(q) =>
!known(q) &&
(!data.enabled ||
data.mode !== 'authorization' ||
data.missing_fields.includes(q.key)),
);
return (
<section
id="preparation-questions"
className={card + ' space-y-4'}
aria-label="Dados do pedido de investimento privado"
>
<h2 className="text-xl font-bold">
Dados em falta para preparar o pedido
</h2>
<p>
Valores conhecidos são reutilizados. As alterações só são
gravadas depois da comparação de impacto. Nacionalidade e
residência não determinam a origem do capital nem a modalidade
da empresa.
</p>
{render(data.questions[0])}
{active && (
<>
{needed.map(render)}
<details>
<summary>
Dados já declarados — consultar ou alterar
</summary>
<div className="space-y-3">
{questions.filter(known).map(render)}
</div>
</details>
<p>
Data, moeda e situação da entidade usam as declarações
acima.{' '}
{data.missing_fields.filter((k) =>
[
'currency',
'expected_start_date',
'entity_stage',
].includes(k),
).length > 0 &&
'Complete esses campos em falta antes de comparar.'}
</p>
<div id="preparation-documents" className="space-y-3">
<h3 className="font-bold">
Associar documentos já existentes no dossier
</h3>
<p>
Carregue documentos pelo Balcão Virtual do mesmo
dossier. A associação não aprova o conteúdo nem
substitui a análise do pedido.
</p>
{data.requirements
.filter((r) => r.required)
.map((r) => (
<label className="grid gap-1" key={r.key}>
{r.label}
<select
className={input}
disabled={disabled}
value={
Object.hasOwn(links, r.key)
? (links[r.key] ?? '')
: (r.document_id ?? '')
}
onChange={(e) =>
onLink(
r.key,
e.target.value || null,
)
}
>
<option value="">Por associar</option>
{documents.map((doc) => (
<option
key={doc.document_id}
value={doc.document_id}
>
{doc.file_name} ·{' '}
{{
quarantined:
'Em quarentena',
active: 'Disponível',
deleted: 'Removido',
}[doc.status] ??
'Estado por conferir'}
</option>
))}
</select>
<span>
{r.reason}{' '}
<a
className="underline"
href={r.source.url}
>
{r.source.reference}
</a>
</span>
</label>
))}
</div>
</>
)}
<p>
Compare o impacto para actualizar o enquadramento, as perguntas
e as exigências. Uma exigência que deixe de ser aplicável
conserva o documento anterior no histórico.
</p>
</section>
);
}
export function PreparationImpact({
before,
after,
}: {
before: Preparation;
after: Preparation;
}) {
return (
<div
className="space-y-2 rounded border border-amber-400 p-3"
aria-label="Impacto no pedido de investimento"
>
<h3 className="font-bold">Impacto no pedido e nos documentos</h3>
<p>
{preparationMode(before.mode)} → {preparationMode(after.mode)}.
</p>
<p>Próximo passo: {after.next}.</p>
<ul className="list-disc pl-5">
{after.requirements
.filter(
(r) =>
before.requirements.find((b) => b.key === r.key)
?.state !== r.state,
)
.map((r) => (
<li key={r.key}>
{r.label}:{' '}
{
states[
before.requirements.find(
(b) => b.key === r.key,
)?.state ?? 'indeterminate'
]
}{' '}
→ {states[r.state]}.{' '}
{r.document_id && 'Vínculo documental preservado.'}
</li>
))}
</ul>
<p>{after.calculation_notice}</p>
<PreparationSummary data={after} />
</div>
);
}
@@ -95,7 +95,7 @@ function MoneyInput({
}
export function SimulationBusinessPlan({
currency,
currency: projectCurrency,
snapshot,
onCurrencyChange,
onSnapshotChange,
@@ -253,8 +253,10 @@ export function SimulationBusinessPlan({
const plan = workingPlan ??
restoreBusinessPlanSnapshot(snapshot) ?? {
...emptyPlan,
currency,
currency: projectCurrency,
};
// A project edit is not a conversion or permission to relabel stored sales.
const currency = plan.currency || projectCurrency;
const update = (
key: keyof BusinessFinancialPlanInput,
value:
@@ -858,6 +860,7 @@ export function SimulationBusinessPlan({
<summary className="cursor-pointer text-lg font-semibold">
Plano financeiro empresarial (pressupostos editáveis)
</summary>
{plan.currency && projectCurrency && plan.currency !== projectCurrency && <p role="alert">A projecção guardada está em {plan.currency}; o projecto está em {projectCurrency}. Os valores e a moeda de origem foram preservados. Reveja o enquadramento monetário antes de os reutilizar; não foi feita conversão.</p>}
{snapshotError && (
<p
role="alert"
+6 -3
View File
@@ -1,5 +1,6 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields } from '@/lib/context-inheritance';
import {
PublicDocumentContent,
type PublicDocument,
@@ -778,7 +779,7 @@ function SearchChoice({
export function SimulationForm() {
const { auth } = usePage<{
auth: { user: { email_verified_at?: string | null } | null };
auth: { user: { id: number; email_verified_at?: string | null } | null };
}>().props;
const pageUrl = usePage().url;
const draftContext = useMemo(() => {
@@ -790,11 +791,12 @@ export function SimulationForm() {
};
}, [pageUrl]);
const contextId = draftContext.context;
const storageScope = contextId
const projectScope = contextId
? `:case:${contextId}`
: draftContext.requestedDraft
? `:draft:${draftContext.requestedDraft}`
: '';
const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`;
const draftKey = legacyDraftKey + storageScope;
const draftCreateKey = legacyDraftCreateKey + storageScope;
const draftIdKey = legacyDraftIdKey + storageScope;
@@ -806,6 +808,7 @@ export function SimulationForm() {
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [ready, setReady] = useState(false);
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
const [contextReview, setContextReview] = useState({
revision: 0,
reviewed: false,
@@ -3611,7 +3614,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && <SimulationPublishedIva draft={draft} onChange={change} busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -1,3 +1,4 @@
import { InvestmentContextAssessment } from './investment-context-assessment';
import { useEffect, useRef, useState } from 'react';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
@@ -31,7 +32,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
return () => { active = false; };
}, [retry]);
useEffect(() => {
if (!settings.auto || !input || busy || !available || completed || attempt.current === key) return;
if (!settings.auto || !input || input.currency !== 'MZN' || busy || !available || completed || attempt.current === key) return;
const request = input;
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
const timer = window.setTimeout(() => {
@@ -56,6 +57,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
const snapshot = record?.execution.result_snapshot;
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
return <section className="space-y-3 rounded border border-emerald-700 p-4" aria-label="IVA mensal das vendas projectadas">
<InvestmentContextAssessment raw={draft.context_applicability_snapshot} />
<h3 className="font-semibold">IVA mensal das vendas projectadas</h3>
<p>{source}</p><p>Declare se os preços projectados são sem IVA ou com IVA incluído. Nenhuma taxa é fixada neste formulário; o servidor selecciona a versão publicada.</p>
<label className="block">Primeiro mês da projecção<input type="month" value={settings.start} min="2026-01" max="2026-12" onChange={(event) => saveSettings({start:event.target.value})} className="block rounded border p-2" /></label>
@@ -63,6 +65,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<label className="block"><input type="checkbox" checked={settings.declared} onChange={(event) => saveSettings({declared:event.target.checked})} /> Declaro que as vendas projectadas são tributáveis à taxa geral interna e não incluem as operações excluídas deste lote.</label>
<p className="text-sm">Sem importações, taxas reduzidas, isenções, regimes especiais, deduções, reembolsos ou apuramento final. Valores com IVA incluído permanecem indeterminados neste lote; não se aplica a taxa ao preço bruto.</p>
<p className="text-sm">{monthlyIvaCashTreatment}</p>
{!!draft.currency && draft.currency !== "MZN" && <p role="alert">Moeda do projecto: {String(draft.currency)}. A regra disponível aceita apenas MZN. As vendas e a moeda são preservadas, sem conversão; o IVA mensal não pode ser determinado por esta versão.</p>}
{inputError && <p role="alert">{inputError}</p>}
{available === null && !error && <p role="status">A consultar a disponibilidade da versão publicada…</p>}
{available === false && <p role="status">A versão publicada de IVA está indisponível neste ambiente para novas execuções. Os pressupostos e históricos mantêm-se.</p>}
@@ -10,16 +10,22 @@ export type PublishedIvaInput = MonthlyIvaInput | {
scope_declared: boolean;
};
export function SimulationPublishedIva({ busy, onCalculate }: {
export function SimulationPublishedIva({ busy, onCalculate, draft, onChange }: {
draft: Record<string, unknown>;
onChange: (field: string, value: string) => void;
busy: boolean;
onCalculate: (input: PublishedIvaInput) => Promise<unknown>;
}) {
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 base = String(draft.published_iva_base ?? '');
const date = String(draft.published_iva_date ?? '');
const currency = String(draft.published_iva_currency ?? '');
const declared = draft.published_iva_scope === 'yes';
const setBase = (value: string) => onChange('published_iva_base', value);
const setDate = (value: string) => onChange('published_iva_date', value);
const setCurrency = (value: string) => onChange('published_iva_currency', value);
const setDeclared = (value: boolean) => onChange('published_iva_scope', value ? 'yes' : 'no');
const [error, setError] = useState('');
useEffect(() => {
let active = true;
@@ -58,12 +64,13 @@ export function SimulationPublishedIva({ busy, onCalculate }: {
<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">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>{currency && currency !== "MZN" && <option value={currency}>{currency} — moeda declarada</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>
{currency && currency !== "MZN" && <p role="alert">Moeda: {currency}. A versão disponível aceita apenas MZN. O valor declarado foi preservado; não foi feita conversão. Este cálculo permanece indisponível para esta moeda.</p>}
{error && <p role="alert">{error}</p>}
</section>;
}
+22 -15
View File
@@ -10,14 +10,16 @@ const navigation = [
export function TempoFooter() {
const { t, language } = useTempoUi();
const { portalInstitutionalContactEmail } = usePage<{ portalInstitutionalContactEmail?: string | null }>().props;
const { portalInstitutionalContactEmail } = usePage<{
portalInstitutionalContactEmail?: string | null;
}>().props;
return (
<footer
className="tempo-footer border-t border-white/5"
lang={language}
>
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 py-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-16 lg:px-12">
<div className="space-y-8">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-6 py-6 md:grid-cols-2 lg:grid-cols-5 lg:gap-8 lg:px-12">
<div className="space-y-4">
<Link
href="/"
aria-label="InvesteMoz — início"
@@ -37,9 +39,9 @@ export function TempoFooter() {
</address>
</div>
</div>
<div className="space-y-8">
<div className="space-y-4">
<h2 className="tempo-footer-heading">{t('Contactos')}</h2>
<div className="space-y-5">
<div className="space-y-2">
{[
['+258868010880', '+258 86 801 0880'],
['+258833219644', '+258 83 321 9644'],
@@ -58,21 +60,26 @@ export function TempoFooter() {
</a>
))}
<a
href={portalInstitutionalContactEmail ? `mailto:${portalInstitutionalContactEmail}` : '/apoio'}
href={
portalInstitutionalContactEmail
? `mailto:${portalInstitutionalContactEmail}`
: '/apoio'
}
className="tempo-contact-link"
>
<span className="tempo-contact-icon">
<Mail size={16} aria-hidden="true" />
</span>
<strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
{portalInstitutionalContactEmail ?? t('Apoio ao investidor')}
<strong className="min-w-0 text-[13px] leading-5 font-medium [overflow-wrap:anywhere]">
{portalInstitutionalContactEmail ??
t('Apoio ao investidor')}
</strong>
</a>
</div>
</div>
<nav aria-label="Navegação do rodapé" className="space-y-8">
<nav aria-label="Navegação do rodapé" className="space-y-4">
<h2 className="tempo-footer-heading">{t('Navegação')}</h2>
<ul className="space-y-4">
<ul className="space-y-2">
{navigation.map(([href, label]) => (
<li key={href}>
<Link href={href} className="tempo-footer-link">
@@ -86,9 +93,9 @@ export function TempoFooter() {
))}
</ul>
</nav>
<nav aria-label="Informação legal" className="space-y-8">
<nav aria-label="Informação legal" className="space-y-4">
<h2 className="tempo-footer-heading">Legal</h2>
<ul className="space-y-4">
<ul className="space-y-2">
{[
[
'politica-de-privacidade',
@@ -111,7 +118,7 @@ export function TempoFooter() {
))}
</ul>
</nav>
<div className="space-y-8">
<div className="space-y-4">
<h2 className="tempo-footer-heading">Social</h2>
<p className="text-xs leading-relaxed font-medium text-slate-300">
{t(
@@ -121,8 +128,8 @@ export function TempoFooter() {
<TempoSocialLinks footer />
</div>
</div>
<div className="border-t border-white/10 py-2">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 px-6 text-center text-[10px] tracking-[0.2em] text-slate-400 md:flex-row md:text-left md:text-xs">
<div className="border-t border-white/10 py-1.5">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-1 px-6 text-center text-[10px] tracking-[0.1em] text-slate-400 md:flex-row md:text-left md:text-[11px]">
<p>
© {new Date().getFullYear()}{' '}
<span className="text-[#ed1c24]">Tempo</span>.{' '}
+68
View File
@@ -0,0 +1,68 @@
import { restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
const empty = (value: unknown) => value == null || value === '' || (Array.isArray(value) && value.length === 0);
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)]));
return value;
}
// Change tracking only, never an authorization, integrity or legal-acceptance token. No personal values in metadata.
function signature(value: unknown): string {
const text=JSON.stringify(canonical(value)) ?? 'undefined';let hash=14695981039346656037n;
for(let index=0;index<text.length;index++) hash=BigInt.asUintN(64,(hash^BigInt(text.charCodeAt(index)))*1099511628211n);
return `${text.length}:${hash.toString(16)}`;
}
export function inheritCompatibleFields(scope:string, values:Record<string,unknown>, source:Record<string,unknown>, raw:unknown, allowed?:string[]): {patch:Record<string,unknown>; metadata:string; inherited:string[]; conflicts:string[]} {
let previous:Record<string,string>={};
try { const saved=JSON.parse(String(raw ?? ''));if(saved.version===1 && saved.scope===scope && saved.inherited && typeof saved.inherited==='object') previous=saved.inherited; } catch { /* Legacy inputs are retained, not overwritten. */ }
const next={...previous};const patch:Record<string,unknown>={};const inherited:string[]=[];const conflicts:string[]=[];
const geo=['province_id','district_id','admin_post_id','locality_id'];
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
const vacant=key==='parcels' ? geo.every(field=>empty((current as Array<Record<string,unknown>>)?.[0]?.[field])) : empty(current);
if ((geo.includes(key) && geoConflict) || (!owned && (!vacant || key in previous))) {
if(signature(current)!==signature(value)) conflicts.push(key);
continue;
}
if(key==='parcels') {
const parcels=current as Array<Record<string,unknown>>|undefined;
if(!owned && parcels?.length && geo.some(field=>!empty(parcels[0]?.[field]))) {conflicts.push(key);continue;}
}
if(signature(track(current))!==signature(track(value))) {
if(key==='parcels') {
const parcels=(current as Array<Record<string,unknown>>) ?? [];
patch[key]=[{...parcels[0],...Object.fromEntries(geo.map(field=>[field,(value as Array<Record<string,unknown>>)[0]?.[field] ?? '']))},...parcels.slice(1)];
} else patch[key]=value;
}
next[key]=signature(track(value));inherited.push(key);
}
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
if(plan?.currency) source.currency=plan.currency;
const currency=draft.currency || source.currency;
if(currency) {source.fiscal_currency=currency;source.opportunity_currency=currency;source.published_iva_currency=currency;}
if(draft.tax_residency_country) source.fiscal_residency_country=draft.tax_residency_country;
if(draft.fiscal_reference_date) source.published_iva_date=draft.fiscal_reference_date;
let settings:Record<string,unknown>={start:'',basis:'unknown',declared:false,auto:false};
let settingsValid=!draft.monthly_iva_settings;
try {const saved=JSON.parse(String(draft.monthly_iva_settings ?? ''));if(saved && !Array.isArray(saved) && typeof saved.start==='string' && ['net','included','unknown'].includes(saved.basis) && typeof saved.declared==='boolean' && typeof saved.auto==='boolean') {settings=saved;settingsValid=true;}} catch { /* Invalid stored inputs remain intact. */ }
const start=String(draft.expected_start_date ?? '');
if(/^\d{4}-\d{2}-\d{2}$/.test(start)) source.monthly_iva_start=start.slice(0,7);
else if(/^\d{4}$/.test(String(draft.fiscal_period)) && draft.fiscal_periodicity==='monthly' && /^(0?[1-9]|1[0-2])$/.test(String(draft.fiscal_month))) source.monthly_iva_start=`${draft.fiscal_period}-${String(draft.fiscal_month).padStart(2,'0')}`;
if(!settingsValid) delete source.monthly_iva_start;
// Legacy drafts without a compatible source need no inheritance marker or autosave churn.
if (!Object.keys(source).length && !draft.compatible_fields_snapshot) return draft;
const values={...draft,monthly_iva_start:settings.start ?? ''};
const patch=inheritCompatibleFields('simulator-compatible-fields',values,source,draft.compatible_fields_snapshot);
const next:Record<string,unknown>={...draft,...patch.patch,compatible_fields_snapshot:patch.metadata};
if('monthly_iva_start' in patch.patch) next.monthly_iva_settings=JSON.stringify({...settings,start:patch.patch.monthly_iva_start});
delete next.monthly_iva_start;
return JSON.stringify(next)===JSON.stringify(draft) ? draft : next as T;
}
+36
View File
@@ -1,3 +1,7 @@
import type {
Preparation,
PreparationDocument,
} from '@/components/private-investment-preparation';
function csrfToken(): string {
return decodeURIComponent(
document.cookie
@@ -56,6 +60,8 @@ export type DossierContext = {
dependency_hash?: string;
};
export type ContextView = {
private_investment?: Preparation | null;
preparation_documents?: PreparationDocument[];
simulator_context_revision?: number | null;
case: { id: string; title: string; version: number };
context: DossierContext;
@@ -73,6 +79,7 @@ export type ContextView = {
notice: string;
};
export type ContextPlan = {
private_investment?: { before: Preparation; after: Preparation } | null;
preview_hash: string;
version: number;
revision: number;
@@ -119,6 +126,20 @@ export async function contextApi<T>(
return data as T;
}
export const contextFieldNames: Record<string, string> = {
investment_form: 'Tipo do investimento',
investment_preparation: 'Preparação do pedido',
investment_activity_scope: 'Natureza da actividade',
implementation_mode: 'Modalidade da empresa',
capital_origin: 'Origem dos capitais',
site_tenure: 'Utilização das instalações',
project_concession: 'PPP ou concessão empresarial',
significant_implications: 'Implicações significativas',
land_extension: 'Extensão de terra',
forest_concession: 'Concessão florestal',
forest_concession_hectares: 'Área florestal em hectares',
total_investment: 'Valor total do investimento',
area_required: 'Área de terra requerida',
area_unit: 'Unidade da área',
title: 'Título do investimento',
territory_id: 'Localização documentada',
location_source: 'Origem da localização',
@@ -134,4 +155,19 @@ export const contextFieldNames: Record<string, string> = {
province: 'Província',
district: 'Distrito',
location: 'Localização',
currency: 'Moeda do projecto',
person_type: 'Tipo de pessoa',
fiscal_period: 'Ano fiscal',
fiscal_periodicity: 'Periodicidade',
fiscal_month: 'Mês fiscal',
fiscal_residency_country: 'País de residência fiscal declarado',
activity_id: 'Actividade',
project_type: 'Modalidade do investimento',
maturity_stage: 'Fase do investimento',
expected_start_date: 'Início previsto',
investor_origin: 'Origem do investidor',
entity_stage: 'Situação da entidade',
tax_residency_country: 'Residência fiscal declarada',
investment_regime: 'Enquadramento territorial consultado',
special_zone_id: 'ZEE, ZFI ou parque',
};
+1
View File
@@ -21,6 +21,7 @@ export function monthlySalesFromDraft(draft: Record<string, unknown>): { amounts
if (draft.business_plan_snapshot) {
const plan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot));
if (!plan) throw new Error('Não foi possível recuperar a projecção de vendas. Os dados guardados não foram substituídos.');
if (draft.currency && plan.currency && draft.currency !== plan.currency) throw new Error(`A projecção de vendas está em ${plan.currency}, mas o projecto está em ${String(draft.currency)}. Reveja a moeda da projecção; os valores não foram convertidos nem reclassificados.`);
return {amounts: monthlyProjectedSales(plan), source: 'Vendas por produto e mês do plano empresarial; mesmos pressupostos e representação monetária da projecção de vendas.'};
}
if (draft.cashflow_snapshot) {
-1
View File
@@ -444,7 +444,6 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
id: 'discovery',
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'idea-entry',
'Partir de uma ideia',
+106 -10
View File
@@ -277,6 +277,79 @@ function makeSections(
}
}
if (draft.context_applicability_snapshot) {
try {
const context = JSON.parse(
String(draft.context_applicability_snapshot),
);
if (context.version === 1) {
const section = sections.get('sources');
const zone = context.zone;
section?.rows.push({
field: 'Contexto do investimento',
value: `Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,
origin: 'Referência do mesmo dossier no momento da gravação',
classification:
'Contexto versionado; não é reconhecimento jurídico',
});
section?.paragraphs.push(
zone
? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.'
: 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.',
);
const preparation = context.preparation;
if (preparation?.enabled) {
const modes: Record<string, string> = {
authorization: 'Pedido de autorização',
registration: 'Pedido de mero registo',
indeterminate: 'Enquadramento por determinar',
};
section?.rows.push({
field: 'Preparação do investimento privado',
value: modes[preparation.mode] ?? 'Por determinar',
origin: `Contexto congelado v${context.context_revision}; ${preparation.version}`,
classification:
'Declarações e requisitos de preparação; sem aprovação jurídica',
});
section?.paragraphs.push(
preparation.next,
preparation.calculation_notice,
preparation.notice,
);
for (const requirement of preparation.requirements ?? [])
section?.rows.push({
field: requirement.label,
value:
requirement.state === 'not_applicable'
? 'Não aplicável; histórico preservado'
: requirement.state === 'associated'
? `Associado: ${requirement.document?.file_name ?? 'documento do dossier'}; conteúdo por conferir`
: requirement.state === 'missing'
? 'Documento por associar'
: 'Informação em falta para decidir',
origin: `${requirement.reason} ${requirement.source?.reference ?? ''}; ${requirement.source?.url ?? ''}`,
classification:
'Preparação informativa — simulação, sem submissão à Administração',
});
}
if (zone?.href)
section?.rows.push({
field: 'Ficha e fundamentos da zona',
value: zone.href,
origin: `Versão consultada ${zone.version}`,
classification:
'Referência consultável; condições por verificar',
});
}
} catch {
sections
.get('sources')
?.paragraphs.push(
'Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.',
);
}
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
// Questionnaire schema identifiers remain stored; they do not help the
// reader assess the project and are not a business report field.
@@ -346,9 +419,23 @@ 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 &&
['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) {
@@ -423,13 +510,22 @@ function makeSections(
},
{
field: 'Fórmula e valores usados',
value:
['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: ['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',
value: [
'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: [
'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',
},
{
+2 -1
View File
@@ -1,3 +1,4 @@
import { inheritSimulatorFields } from './context-inheritance.ts';
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
@@ -1530,7 +1531,7 @@ export function restoreSimulationDraft(
}
}
}
return draft as SimulationDraft;
return inheritSimulatorFields(draft as SimulationDraft);
}
export function simulationDraftFingerprint(draft: SimulationDraft): string {
+1
View File
@@ -135,6 +135,7 @@ export default function RequestPage({
<DossierPrefill
contextId={dossier.id}
consumer="desk"
persistInheritance={request.status !== 'returned'}
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
+183 -5
View File
@@ -1,3 +1,8 @@
import {
PreparationEditor,
PreparationImpact,
} from '@/components/private-investment-preparation';
import { InvestmentContextAssessment } from '@/components/investment-context-assessment';
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@@ -35,6 +40,7 @@ export default function ContextEditor({
}) {
const navigation = useNavigationContext();
const [view, setView] = useState(initial);
const [preparation, setPreparation] = useState(initial.private_investment);
const [options, setOptions] = useState(initialOptions);
const [query, setQuery] = useState('');
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(() =>
@@ -48,6 +54,9 @@ export default function ContextEditor({
const [declarations, setDeclarations] = useState<Record<string, string>>(
{},
);
const [documentLinks, setDocumentLinks] = useState<
Record<string, string | null>
>({});
const [replace, setReplace] = useState<string[]>([]);
const [plan, setPlan] = useState<ContextPlan | null>(null);
const [busy, setBusy] = useState(false);
@@ -58,6 +67,32 @@ export default function ContextEditor({
string,
unknown
> | null>(null);
const displayValue = (
field: string,
value: string | null | undefined,
): string => {
if (value === null || value === undefined || value === '')
return 'não informado';
const question = view.private_investment?.questions.find(
(q) => q.key === field,
);
return (
question?.options?.[value] ??
(
{
being_formed: 'Em constituição',
existing: 'Existente',
national: 'Nacional',
foreign: 'Estrangeiro',
mixed: 'Misto',
yes: 'Sim',
no: 'Não',
unknown: 'Por determinar',
} as Record<string, string>
)[value] ??
value
);
};
const selected = (slot: Slot) =>
Object.hasOwn(changes, slot)
? (changes[slot] ?? '')
@@ -76,6 +111,7 @@ export default function ContextEditor({
selection: changes,
declarations,
replace_fields: replace,
document_links: documentLinks,
};
try {
const result = await contextApi<ContextPlan>(
@@ -84,6 +120,9 @@ export default function ContextEditor({
);
setRequestBody(body);
setPlan(result);
setPreparation(
result.private_investment?.after ?? view.private_investment,
);
} catch (e) {
setError(
e instanceof Error ? e.message : 'Não foi possível comparar.',
@@ -110,9 +149,11 @@ export default function ContextEditor({
`/investimentos/${view.case.id}/contexto`,
);
setView(next);
setPreparation(next.private_investment);
navigation.refresh();
setChanges({});
setDeclarations({});
setDocumentLinks({});
setReplace([]);
setPlan(null);
setOperation(createOperationKey());
@@ -131,6 +172,10 @@ export default function ContextEditor({
}
return (
<Frame title="Contexto do investimento">
<InvestmentContextAssessment
contextId={view.case.id}
raw={view.prefill.simulator?.context_applicability_snapshot}
/>
<ContextDocuments source={view.context.selection.opportunity} />
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
@@ -286,7 +331,7 @@ export default function ContextEditor({
</select>
</label>
<label className="grid min-w-0 gap-1">
Parque consultado
ZEE, ZFI ou parque consultado
<select
className={input + ' max-w-full min-w-0'}
disabled={busy}
@@ -294,7 +339,7 @@ export default function ContextEditor({
onChange={(e) => change('park', e.target.value)}
>
<option value="">
Sem parque no contexto atual
Sem ZEE, ZFI ou parque seleccionado
</option>
{options.parks.map((p) => (
<option key={p.id} value={'park:' + p.id}>
@@ -335,6 +380,107 @@ export default function ContextEditor({
Estes campos pertencem ao processo. Alterá-los não muda
o cadastro da oportunidade nem o perfil da organização.
</p>
<p>
Origem do investidor, residência fiscal e constituição
da entidade são declarações distintas. A localização não
confirma benefícios. A prévia identifica o impacto antes
de guardar.
</p>
{[
['currency', 'Moeda do projecto', null],
[
'expected_start_date',
'Data prevista de início',
null,
],
[
'investor_origin',
'Origem do investidor',
[
['national', 'Nacional'],
['foreign', 'Estrangeiro'],
['mixed', 'Misto'],
],
],
[
'person_type',
'Tipo de entidade',
[
['singular', 'Pessoa singular'],
['collective', 'Pessoa colectiva'],
['consortium', 'Consórcio'],
],
],
[
'entity_stage',
'Situação da entidade',
[
['existing', 'Existente'],
['being_formed', 'Em constituição'],
],
],
[
'tax_residency_country',
'País de residência fiscal declarado (ISO de duas letras)',
null,
],
].map(([key, label, choices]) => (
<label className="grid gap-1" key={String(key)}>
{String(label)}
{Array.isArray(choices) ? (
<select
className={input}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
>
<option value="">Por indicar</option>
{choices.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
) : (
<input
className={input}
type={
key === 'expected_start_date'
? 'date'
: 'text'
}
maxLength={
key === 'currency'
? 3
: key === 'tax_residency_country'
? 2
: 10
}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
/>
)}
</label>
))}
{['project_title', 'project_summary'].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
@@ -387,6 +533,30 @@ export default function ContextEditor({
</label>
))}
</details>
{view.private_investment && (
<PreparationEditor
data={preparation ?? view.private_investment}
fields={view.context.fields}
changes={declarations}
documents={view.preparation_documents ?? []}
links={documentLinks}
disabled={busy || !view.can_write}
onChange={(key, value) => {
setDeclarations({
...declarations,
[key]: value,
});
setPlan(null);
}}
onLink={(key, value) => {
setDocumentLinks({
...documentLinks,
[key]: value,
});
setPlan(null);
}}
/>
)}
<button
className={button}
type="button"
@@ -443,10 +613,15 @@ export default function ContextEditor({
x.field
] ?? x.field}
:{' '}
{x.before ??
'não informado'}{' '}
{displayValue(
x.field,
x.before,
)}{' '}
→{' '}
{x.after ?? 'não informado'}
{displayValue(
x.field,
x.after,
)}
</p>
))}
{Object.values(plan.selection).map(
@@ -506,6 +681,9 @@ export default function ContextEditor({
</tbody>
</table>
</div>
{plan.private_investment && (
<PreparationImpact {...plan.private_investment} />
)}
<p>{plan.notice}</p>
<div className="flex flex-wrap gap-3">
<button
+31
View File
@@ -1,3 +1,7 @@
import {
PreparationSummary,
type Preparation,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import type { DossierContext } from '@/lib/investor-context';
import FintechInvestment, {
@@ -27,6 +31,7 @@ type Step = {
source_paragraph: number;
};
type Path = {
private_investment?: Preparation | null;
fintech: FintechView | null;
investment_context: DossierContext | null;
zone_contexts?: {
@@ -180,6 +185,28 @@ function Stage({ step, path }: { step: Step; path: Path }) {
independente.
</label>
)}
{path.private_investment?.enabled && (
<div className="space-y-2">
{path.private_investment.requirements
.filter((r) => r.stage === step.code)
.map((r) => (
<p className="text-sm" key={r.key}>
<strong>{r.label}:</strong>{' '}
{r.state === 'not_applicable'
? 'não exigido neste contexto; histórico preservado'
: r.state === 'associated'
? 'documento associado; conteúdo por conferir'
: r.state === 'missing'
? 'documento por associar'
: 'aplicabilidade por determinar'}
. {r.reason}{' '}
<a className="underline" href={r.source.url}>
{r.source.reference}
</a>
</p>
))}
</div>
)}
{step.reason && (
<p className="text-sm">Fundamento: {step.reason}</p>
)}
@@ -415,6 +442,10 @@ export default function InvestmentPath(path: Path) {
<ContextDocuments
source={path.investment_context?.selection.opportunity}
/>
<PreparationSummary
data={path.private_investment}
caseId={path.case.id}
/>
{path.investment_context && (
<section className="space-y-2 rounded border border-emerald-300 bg-emerald-50 p-4">
<h2 className="font-semibold">
@@ -0,0 +1,103 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\InvestmentZones;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentContextContinuityTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
private function dossier(User $user, string $title): string
{
return $this->actingAs($user)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
public function test_same_dossier_context_reuses_linked_draft_and_preserves_account_and_project_boundaries(): void
{
$owner = User::factory()->create();
$a = $this->dossier($owner, 'A');
$b = $this->dossier($owner, 'B');
foreach ([[$a, 'MZN', 'national'], [$b, 'USD', 'foreign']] as [$id,$currency,$origin]) {
$this->actingAs($owner)->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => ['investor_origin' => $origin, 'tax_residency_country' => 'ZA', 'person_type' => 'collective'], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $id, 'currency' => $currency, 'expected_start_date' => '2026-01-01']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$v = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$v->assertJsonPath('prefill.simulator.currency', $currency)->assertJsonPath('prefill.simulator.investor_origin', $origin)->assertJsonPath('prefill.simulator.tax_residency_country', 'ZA');
$this->assertSame('linked_simulation', $v->json('context.provenance.currency.kind'));
}
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$a.'/contexto')->assertForbidden();
$this->actingAs($owner)->getJson('/investimentos/'.$a.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'MZN');
}
public function test_context_change_is_previewed_versioned_and_idempotent_without_changing_the_source_draft(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Impacto');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'USD', 'investor_origin' => 'foreign', 'tax_residency_country' => 'MZ'], 'replace_fields' => []];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertNotEmpty($plan['updated']);
$this->assertTrue($plan['dependency_changed']);
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'USD')->assertJsonPath('prefill.simulator.tax_residency_country', 'MZ');
}
public function test_zone_selection_reuses_existing_reference_and_impact_workflow_without_granting_a_benefit(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Zonas QA');
$template = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->firstOrFail();
foreach (['zee', 'zfi', 'industrial_park'] as $category) {
// Synthetic catalogue metadata, rolled back. Never a legal source or published rule in homologation.
$zone = $template->replicate();
$zone->id = (string) Str::uuid();
$zone->code = 'SINT-CONTEXT-'.Str::random(12);
$zone->name = 'SINTÉTICO QA '.$category;
$zone->category = $category;
$zone->legal_regime = $category === 'industrial_park' ? 'pending' : $category;
$zone->source_evidence = [['source_type' => 'government', 'url' => 'https://example.test/synthetic-zone', 'supports' => 'Synthetic test only; no legal or eligibility conclusion']];
$zone->save();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => 'park:'.$zone->id]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertSame($category, $plan['fields']['investment_regime']);
$this->assertContains('S17', $plan['review_steps']);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$after->assertJsonPath('prefill.simulator.investment_regime', $category);
$snapshot = json_decode($after->json('prefill.simulator.context_applicability_snapshot'), true);
$this->assertSame($zone->id, $snapshot['zone']['id']);
$this->assertArrayNotHasKey('benefit_rate', $snapshot);
}
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => null]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_regime', 'general')->assertJsonPath('prefill.simulator.special_zone_id', '');
}
public function test_a_title_edit_does_not_invalidate_applicability_or_ask_for_irrelevant_reviews(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Título original');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['project_title' => 'Título revisto']])
->assertOk()->assertJsonPath('dependency_changed', false)->assertJsonPath('review_steps', []);
}
}
@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PrivateInvestmentPreparation;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class PrivateInvestmentPreparationTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
private function declarations(): array
{
return ['investment_form' => 'direct', 'investment_preparation' => 'yes', 'investment_activity_scope' => 'general', 'implementation_mode' => 'domestic_entity', 'capital_origin' => 'national', 'site_tenure' => 'lease', 'project_concession' => 'no', 'significant_implications' => 'no', 'land_extension' => 'no', 'forest_concession' => 'no', 'total_investment' => '1000000', 'currency' => 'MZN', 'expected_start_date' => '2026-01-01', 'entity_stage' => 'being_formed'];
}
private function dossier(User $user): string
{
return $this->actingAs($user)->postJson('/investimentos', ['title' => 'SINTÉTICO C — percurso adaptativo', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
private function save(string $id, array $declarations, array $links = []): array
{
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => $declarations, 'document_links' => $links];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
// The preview is read-only.
$this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.revision', $view['context']['revision']);
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
return $plan;
}
public function test_boundaries_unknown_conditions_and_scope_are_not_silently_presumed(): void
{
$service = app(PrivateInvestmentPreparation::class);
$assess = fn (array $changes = []) => $service->assess(['fields' => [...$this->declarations(), ...$changes]]);
$this->assertSame('registration', $assess(['total_investment' => '32000000000'])['mode']);
$this->assertSame('authorization', $assess(['total_investment' => '32000000000.0001'])['mode']);
$this->assertSame('authorization', $assess(['land_extension' => 'yes', 'area_required' => '10000', 'area_unit' => 'ha'])['mode']);
$this->assertSame('registration', $assess(['forest_concession' => 'yes', 'forest_concession_hectares' => '100000'])['mode']);
$this->assertSame('authorization', $assess(['forest_concession' => 'yes', 'forest_concession_hectares' => '100000.01'])['mode']);
$this->assertSame('indeterminate', $assess(['significant_implications' => 'unknown'])['mode']);
$this->assertSame('indeterminate', $assess(['currency' => 'USD'])['mode']);
$this->assertSame('indeterminate', $assess(['investment_form' => 'unknown'])['mode']);
$this->assertSame('indeterminate', $assess(['investment_form' => 'indirect'])['mode']);
$this->assertSame('authorization', $assess(['investment_form' => 'indirect', 'project_concession' => 'yes'])['mode']);
foreach ([['investment_activity_scope' => 'extractive'], ['expected_start_date' => '2027-01-01']] as $changes) {
$result = $assess($changes);
$this->assertSame('indeterminate', $result['mode']);
$this->assertFalse(collect($result['requirements'])->contains('required', true));
}
$this->assertSame('registration', $assess(['investor_origin' => 'foreign', 'tax_residency_country' => 'ZA'])['mode']);
$this->assertFalse(collect($assess(['investor_origin' => 'foreign'])['requirements'])->firstWhere('key', 'representation_license')['required']);
foreach (['zee', 'zfi'] as $regime) {
$zone = $service->assess(['fields' => $this->declarations(), 'selection' => ['park' => ['fields' => ['investment_regime' => $regime]]]]);
$this->assertStringContainsString('APIEX', $zone['authority']);
$this->assertStringContainsString('condicionado', $zone['authority']);
$this->assertArrayNotHasKey('benefit_rate', $zone);
}
$park = $service->assess(['fields' => $this->declarations(), 'selection' => ['park' => ['fields' => ['investment_regime' => 'industrial_park']]]]);
$this->assertSame('Governador da Província', $park['authority']);
}
public function test_context_changes_adapt_requirements_and_preserve_association_history_and_results(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner);
$doc = (string) Str::uuid();
DB::table('documents')->insert(['id' => $doc, 'file_name' => 'SINTETICO-C-QA.txt', 'media_type' => 'text/plain', 'storage_key' => 'synthetic-test-no-download', 'sha256' => hash('sha256', 'synthetic'), 'status' => 'quarantined']);
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $doc, 'category' => 'support', 'source_reference' => 'Synthetic QA only', 'linked_by' => $owner->id]);
$initial = $this->save($id, $this->declarations(), ['lease' => $doc]);
$this->assertSame('registration', $initial['private_investment']['after']['mode']);
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
$this->assertSame('associated', collect($view['private_investment']['requirements'])->firstWhere('key', 'lease')['state']);
$this->assertLessThan(20000, mb_strlen($view['prefill']['simulator']['context_applicability_snapshot']));
$this->assertArrayNotHasKey('investment_preparation', $view['prefill']['simulator']);
$personalKeys = ['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage'];
$this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => Arr::only($view['prefill']['simulator'], $personalKeys), 'economic_snapshot' => ['purpose' => 'simulate_only', ...Arr::except($view['prefill']['simulator'], $personalKeys)]], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$plan = $this->save($id, ['project_concession' => 'yes', 'implementation_mode' => 'foreign_representation', 'site_tenure' => 'duat']);
$this->assertFalse($plan['calculation_inputs_changed'], 'Procedural changes do not change the inputs of the existing IVA calculation.');
$this->assertContains('S06', $plan['review_steps']);
$after = $plan['private_investment']['after'];
$this->assertSame('authorization', $after['mode']);
$this->assertSame('Conselho de Ministros', $after['authority']);
foreach (['feasibility', 'financial_capacity', 'representation_license', 'name_reservation', 'duat'] as $key) {
$this->assertTrue(collect($after['requirements'])->firstWhere('key', $key)['required']);
}
$lease = collect($after['requirements'])->firstWhere('key', 'lease');
$this->assertSame('not_applicable', $lease['state']);
$this->assertSame($doc, $lease['document_id']);
$this->assertSame(1, DB::table('desk_documents')->where('case_id', $id)->count());
$back = $this->save($id, ['project_concession' => 'no', 'implementation_mode' => 'domestic_entity', 'site_tenure' => 'lease', 'entity_stage' => 'existing']);
$this->assertTrue(collect($back['private_investment']['after']['requirements'])->firstWhere('key', 'registration')['required']);
$this->assertFalse(collect($back['private_investment']['after']['requirements'])->firstWhere('key', 'name_reservation')['required']);
$this->assertSame('associated', collect($back['private_investment']['after']['requirements'])->firstWhere('key', 'lease')['state']);
$this->assertSame('not_applicable', collect($back['private_investment']['after']['requirements'])->firstWhere('key', 'feasibility')['state']);
$events = DB::table('desk_events')->where('case_id', $id)->where('action', 'investment_context_changed')->count();
$this->assertSame(3, $events);
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('private_investment.mode', 'registration');
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
$this->actingAs($owner)->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('private_investment.mode', 'registration');
config(['investor-path.private_preparation' => false]);
$this->save($id, ['project_title' => 'Edição independente durante suspensão']);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('private_investment', null)->assertJsonPath('context.preparation_snapshot.mode', 'registration')->assertJsonPath('context.preparation_documents.lease', $doc);
}
public function test_foreign_document_and_tampered_preview_cannot_be_committed_and_disabled_feature_does_not_accept_writes(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner);
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => $this->declarations()];
$this->postJson('/investimentos/'.$id.'/contexto/previa', [...$body, 'document_links' => ['identity' => (string) Str::uuid()]])->assertUnprocessable();
$other = $this->dossier($owner);
$foreignDocument = (string) Str::uuid();
DB::table('documents')->insert(['id' => $foreignDocument, 'file_name' => 'OUTRO-DOSSIER-SINTETICO-QA.txt', 'media_type' => 'text/plain', 'storage_key' => 'synthetic-no-download', 'sha256' => hash('sha256', 'other synthetic'), 'status' => 'quarantined']);
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $other, 'document_id' => $foreignDocument, 'category' => 'support', 'source_reference' => 'Synthetic isolated QA', 'linked_by' => $owner->id]);
$this->postJson('/investimentos/'.$id.'/contexto/previa', [...$body, 'document_links' => ['identity' => $foreignDocument]])->assertUnprocessable();
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'declarations' => [...$body['declarations'], 'currency' => 'USD'], 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertConflict();
$this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.revision', 0);
config(['investor-path.private_preparation' => false]);
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertConflict();
$this->postJson('/investimentos/'.$id.'/contexto/previa', [...$body, 'declarations' => 'invalid'])->assertUnprocessable();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('private_investment', null);
}
}
@@ -0,0 +1,69 @@
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
import assert from 'node:assert/strict';
test('legacy draft without a compatible source keeps its shape and identity',()=>{
const legacy={activity_id:'legacy-activity',infrastructure_needs:['energia']};
assert.equal(inheritSimulatorFields(legacy),legacy);
assert.equal(Object.hasOwn(inheritSimulatorFields(legacy),'compatible_fields_snapshot'),false);
});
import test from 'node:test';
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};
const changed=inheritCompatibleFields('dossier:A:simulator',saved,{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(changed.patch,{currency:'USD',project_title:'A2'});
const edited=inheritCompatibleFields('dossier:A:simulator',{...saved,currency:'EUR',project_title:''},{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(edited.patch,{});assert.deepEqual(edited.conflicts,['currency','project_title']);
const recovered=JSON.parse(JSON.stringify({...saved,currency:'EUR',project_title:''}));
assert.deepEqual(inheritCompatibleFields('dossier:A:simulator',recovered,{currency:'USD',project_title:'A2'},recovered.context_reuse).patch,{});
assert.ok(!first.metadata.includes('MZN'));assert.ok(!first.metadata.includes('project_title":"A'));
});
test('a different dossier cannot take ownership of existing values using another dossier metadata',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN'}, null);
const second=inheritCompatibleFields('dossier:B:simulator',first.patch,{currency:'USD'},first.metadata);
assert.deepEqual(second.patch,{});assert.deepEqual(second.conflicts,['currency']);
});
test('a local geographic override protects the entire hierarchy',()=>{
const first=inheritCompatibleFields('A',{}, {province_id:'p1',district_id:'d1'},null);
const update=inheritCompatibleFields('A',{...first.patch,province_id:'p-local'},{province_id:'p2',district_id:'d2'},first.metadata);
assert.deepEqual(update.patch,{});
});
test('compatible currency and date reuse survives backend partition and new session recovery without invented tax scope',()=>{
const initial=inheritSimulatorFields({currency:'USD',expected_start_date:'2026-03-01',purpose:'simulate_only'});
assert.equal(initial.fiscal_currency,'USD');assert.equal(initial.published_iva_currency,'USD');assert.equal(initial.opportunity_currency,'USD');
assert.equal(JSON.parse(initial.monthly_iva_settings).start,'2026-03');assert.equal(JSON.parse(initial.monthly_iva_settings).declared,false);
assert.equal(initial.published_iva_date,undefined);
assert.equal(inheritSimulatorFields({expected_start_date:'2026-01-01',monthly_iva_settings:'[invalid stored input'}).monthly_iva_settings,'[invalid stored input');
const saved=partitionSimulationDraft(initial,true);const recovered=restoreSimulationDraft(saved.economic_snapshot,saved.personal_snapshot);
assert.equal(recovered.published_iva_currency,'USD');assert.strictEqual(inheritSimulatorFields(recovered),recovered);
const changed=inheritSimulatorFields({...recovered,currency:'EUR'});assert.equal(changed.fiscal_currency,'EUR');
const override=inheritSimulatorFields({...changed,fiscal_currency:'MZN',published_iva_currency:''});assert.equal(override.fiscal_currency,'MZN');assert.equal(override.published_iva_currency,'');
});
test('land consumer reuses geography inside an empty parcel and preserves all other parcel data',()=>{
const first=inheritCompatibleFields('land', {parcels:[{area:'12',province_id:''},{area:'20',province_id:'other'}]}, {parcels:[{province_id:'P',district_id:'D'}]},null);
assert.equal(first.patch.parcels[0].area,'12');assert.equal(first.patch.parcels[0].district_id,'D');assert.equal(first.patch.parcels[1].province_id,'other');
const changed=inheritCompatibleFields('land',{parcels:[{...first.patch.parcels[0],area:'13'},first.patch.parcels[1]]},{parcels:[{province_id:'P2'}]},first.metadata);
assert.equal(changed.patch.parcels[0].area,'13');assert.equal(changed.patch.parcels[0].district_id,'');
});
test('clearing an inherited zone reference clears only the inherited value, preserving a deliberate override',()=>{
const first=inheritCompatibleFields('A',{}, {special_zone_id:'zone'},null);
assert.equal(inheritCompatibleFields('A',first.patch,{special_zone_id:''},first.metadata).patch.special_zone_id,'');
assert.deepEqual(inheritCompatibleFields('A',{special_zone_id:'local'},{special_zone_id:''},first.metadata).patch,{});
});
test('conflicting saved sales currency is explained instead of relabelled or converted',()=>{
const fields=['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
const plan={...Object.fromEntries(fields.map(key=>[key,'0'])),currency:'MZN',periods:4,depreciationLifeMonths:60,products:[{label:'QA',monthlyUnits:'1',unitPrice:'100',unitVariableCost:'0'}]};
assert.throws(()=>monthlySalesFromDraft({currency:'USD',business_plan_snapshot:serializeBusinessPlanSnapshot(plan)}),/projecção de vendas está em MZN.*projecto está em USD/);
});
test('report preserves consulted zone version and makes unsupported benefit explicit without inventing a fiscal result',()=>{
const context={version:1,context_revision:3,zone:{title:'Referência QA',version:7,href:'/zonas/qa'},fields:{currency:'USD'}};
const draft={project_title:'QA',currency:'USD',context_applicability_snapshot:JSON.stringify(context)};
const saved=JSON.stringify(draft);const report=createProjectDossierReport(draft,{selectedSections:new Set(['sources'])});
assert.match(JSON.stringify(report),/Referência QA, versão 7/);assert.match(JSON.stringify(report),/Benefício fiscal da zona indeterminado/);assert.equal(JSON.stringify(draft),saved);
});
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
test('report retains the frozen preparation and an inactive historical document without claiming approval or a tax benefit', () => {
const preparation = {enabled:true,version:'private-investment-preparation-2026-v1',mode:'registration',next:'Preparar documentos do pedido de mero registo',calculation_notice:'O regime procedimental não altera a taxa de IVA.',notice:'Sem submissão à Administração.',requirements:[{label:'Estudo de viabilidade',state:'not_applicable',document_id:'synthetic-old-document',source:{reference:'R14',url:'https://example.test/official-source'},reason:'Apenas para autorização.'}]};
const snapshot = JSON.stringify({version:1,context_revision:3,preparation});
const report = createProjectDossierReport({context_applicability_snapshot:snapshot},{selectedSections:new Set(['sources'])});
const sources=report.sections.find(s=>s.id==='sources');
assert.equal(sources.rows.find(r=>r.field==='Preparação do investimento privado').value,'Pedido de mero registo');
assert.match(sources.rows.find(r=>r.field==='Estudo de viabilidade').value,/Não aplicável; histórico preservado/);
assert.ok(sources.paragraphs.includes(preparation.calculation_notice));
preparation.mode='authorization';
assert.equal(JSON.parse(snapshot).preparation.mode,'registration');
});
+389
View File
@@ -0,0 +1,389 @@
// Isolated UI fixture: real header/providers, synthetic Inertia props, no database.
// Requires the local Vite server. No requests to Laravel or external services.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { portalNavigation } from '../resources/js/lib/portal-navigation.ts';
const root = process.cwd();
const dir = path.join(root, 'outputs/header-navbar-20261021');
fs.mkdirSync(dir, { recursive: true });
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.HEADER_QA_PLAYWRIGHT || 'playwright');
const { expect } = require(
(process.env.HEADER_QA_PLAYWRIGHT || 'playwright') + '/test',
);
const base = process.env.HEADER_QA_VITE || 'http://127.0.0.1:5173';
assert.equal(new URL(base).hostname, '127.0.0.1');
const before = process.argv.includes('--before');
if (before && fs.existsSync(path.join(dir, 'before-results.json'))) {
throw new Error(
'The original baseline already exists and must be preserved.',
);
}
fs.writeFileSync(
path.join(dir, 'fixture.html'),
`<!doctype html>
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>InvesteMoz · revisão do header</title></head><body><div id="app"></div>
<script type="module" src="/outputs/header-navbar-20261021/fixture.tsx"></script></body></html>`,
);
fs.writeFileSync(
path.join(dir, 'fixture.tsx'),
`
import React from 'react';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import { PortalGroupedMenu } from '../../resources/js/components/portal-grouped-menu';
import { TempoUiProvider, useTempoUi } from '../../resources/js/components/tempo-brand';
import '../../resources/css/app.css';
const role = new URLSearchParams(location.search).get('role') || 'guest';
const props = {
auth: { user: role === 'guest' ? null : { id: 1, name: 'Investidor de demonstração com nome extenso', email: 'qa@example.invalid' }, canManagePortal: role === 'manager' },
existingBusinessEnabled: true, businessReviewUrl: role === 'manager' ? '/management/negocios' : null,
educationEnabled: true, aiEnabled: true, legacyContentEnabled: true,
};
function Fixture() {
const ui = useTempoUi();
return <><PortalGroupedMenu /><main style={{padding:'48px 24px',maxWidth:1200,margin:'auto'}}>
<p style={{color:'#b91c1c',fontSize:12,fontWeight:600,letterSpacing:2}}>INVESTEMOZ · REVISÃO LOCAL</p>
<h1 style={{fontSize:36,fontWeight:600,margin:'16px 0'}}>O seu próximo investimento começa aqui</h1>
<p style={{color:'#64748b'}}>Navegação real do portal com dados fictícios para testar a apresentação.</p>
<button data-test="increment" onClick={ui.registerSubmittedInterest}>Simular pedido no contador</button>
</main></>;
}
await createInertiaApp({page:{component:'fixture',props,url:'/',version:null},resolve:()=>Fixture,
setup({el,App,props}) {createRoot(el).render(<TempoUiProvider><App {...props}/></TempoUiProvider>);}});
`,
);
const browser = await chromium.launch({
headless: true,
...(process.env.HEADER_QA_CHROMIUM
? { executablePath: process.env.HEADER_QA_CHROMIUM }
: {}),
});
const results = [];
const errors = [];
try {
for (const [width, role, language] of before
? [
[1440, 'guest', 'pt'],
[390, 'guest', 'pt'],
]
: [
[1280, 'manager', 'fr'],
[1280, 'manager', 'pt'],
[1440, 'manager', 'fr'],
[320, 'guest', 'pt'],
[320, 'investor', 'pt'],
[390, 'guest', 'pt'],
[390, 'manager', 'pt'],
[768, 'investor', 'pt'],
[1024, 'manager', 'pt'],
[1440, 'guest', 'pt'],
[1920, 'investor', 'en'],
]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
});
await context.addInitScript(
(lang) => localStorage.setItem('tempo-navigation-language', lang),
language,
);
await context.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin === base && url.pathname.startsWith('/images/')) {
const asset = path.resolve(root, 'public', `.${url.pathname}`);
if (
asset.startsWith(
path.join(root, 'public', 'images') + path.sep,
) &&
fs.existsSync(asset)
) {
return route.fulfill({ path: asset });
}
}
if (url.origin === base || url.protocol === 'data:')
return route.continue();
return route.abort();
});
const page = await context.newPage();
page.on('pageerror', (e) => {
errors.push(e.message);
console.error(e.message);
});
await page.goto(
`${base}/outputs/header-navbar-20261021/fixture.html?role=${role}`,
{ waitUntil: 'domcontentloaded', timeout: 60000 },
);
const header = page.locator('.tempo-header');
await header.waitFor();
await page.screenshot({
path: path.join(
dir,
`${before ? 'before' : 'after'}-${width}-${role}-${language}.png`,
),
});
if (before) {
results.push({
width,
height: await header.evaluate(
(el) => el.getBoundingClientRect().height,
),
});
await context.close();
continue;
}
assert.equal(
await header.locator('nav').count(),
1,
'One navigation landmark in header',
);
assert.equal(await header.locator('.tempo-section-bar').count(), 0);
const collapsedHeight = await header.evaluate(
(el) => el.getBoundingClientRect().height,
);
assert.ok(collapsedHeight <= 80, `Header height ${collapsedHeight}`);
if (width < 1280) {
const toggle = header.locator('.tempo-header-toggle');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
await page.screenshot({
path: path.join(
dir,
`expanded-${width}-${role}-${language}.png`,
),
});
await page.keyboard.press('Escape');
assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
await expect(toggle).toBeFocused();
await toggle.click();
}
const nav = header.locator('nav');
if (width >= 1280) {
const boxes = await Promise.all(
[
'.tempo-logo-link',
'.tempo-primary-navigation',
'.tempo-header-extras',
'.tempo-header-actions',
'.tempo-interest-button',
].map((selector) => header.locator(selector).boundingBox()),
);
for (let i = 1; i < boxes.length; i++)
assert.ok(
boxes[i - 1].x + boxes[i - 1].width <= boxes[i].x + 1,
`Header elements overlap at ${width}px: ${JSON.stringify(boxes)}`,
);
}
assert.deepEqual(
await nav
.locator(':scope > ul > li')
.evaluateAll((nodes) =>
nodes.map(
(node) =>
node
.querySelector('a,button')
?.getAttribute('href') ||
node.textContent.trim(),
),
),
language === 'pt'
? [
'/',
'Descoberta',
'Realização',
'Suporte',
'/sobre',
'Tempo',
]
: language === 'fr'
? [
'/',
'Découvrir',
'Investir',
'Assistance',
'/sobre',
'Tempo',
]
: ['/', 'Discover', 'Invest', 'Support', '/sobre', 'Tempo'],
);
assert.equal(await nav.locator('a[href="/sobre"]').count(), 1);
assert.equal(
await nav
.locator('a')
.first()
.evaluate((el) => getComputedStyle(el).fontSize),
width >= 1280 ? '18px' : '16px',
);
const destinations = new Set(
await header
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
),
);
async function inspectMenu(menu) {
await menu.waitFor();
menu = page.locator(`[id="${await menu.getAttribute('id')}"]`);
try {
await expect
.poll(async () => {
const box = await menu.boundingBox();
return (
!!box &&
box.x >= 0 &&
box.x + box.width <= width + 1
);
})
.toBe(true);
} catch (error) {
console.error(
JSON.stringify({
width,
role,
menu: await menu.getAttribute('aria-labelledby'),
box: await menu.boundingBox(),
}),
);
await page.screenshot({
path: path.join(dir, 'submenu-failure.png'),
});
throw error;
}
for (const href of await menu
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
))
destinations.add(href);
const subIds = await menu
.locator('[role="menuitem"][aria-haspopup="menu"]')
.evaluateAll((nodes) => nodes.map((node) => node.id));
for (const subId of subIds) {
const sub = page.locator(`[id="${subId}"]`);
await sub.focus();
await page.keyboard.press('ArrowRight');
await expect(sub).toHaveAttribute('aria-expanded', 'true');
await inspectMenu(page.locator('[role="menu"]').last());
await page.keyboard.press('ArrowLeft');
await expect(sub).toBeFocused();
}
}
for (const trigger of await nav
.locator(':scope > ul > li > button')
.all()) {
await trigger.focus();
await page.keyboard.press('Enter');
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
const menu = page.locator('[role="menu"]').last();
await inspectMenu(menu);
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
}
await header
.getByRole('button', { name: 'Tempo', exact: true })
.click();
for (const href of [
'/contacto',
'https://revista.tempo.co.mz/',
'https://www.facebook.com/revistatempo.mz/',
'https://www.instagram.com/revistatempo.mz/',
'https://www.linkedin.com/company/revistatempo/',
'https://www.youtube.com/@RevistaTempoMz/',
]) {
assert.equal(
await page.locator(`[role="menu"] a[href="${href}"]`).count(),
1,
href,
);
}
await page.keyboard.press('Escape');
assert.ok(
await header
.getByRole('button', { name: 'Idioma da navegação' })
.isVisible(),
);
assert.ok(
await header
.getByRole('button', { name: 'Temperatura em Maputo' })
.isVisible(),
);
assert.ok(
await header.locator('a[href="/manifestar-interesse"]').isVisible(),
);
const dashboard = header.locator('a[href="/investidor/manifestacoes"]');
assert.ok(await dashboard.isVisible());
await page.locator('[data-test="increment"]').click();
assert.match(await dashboard.getAttribute('aria-label'), /1 /);
if (role !== 'guest') {
await header.locator('.tempo-login-button').click();
await inspectMenu(page.locator('[role="menu"]').last());
assert.ok(
await page
.locator('[data-test="portal-logout-button"]')
.isVisible(),
);
const hasManagement = await page
.getByRole('menu')
.getByText('Gestão', { exact: true })
.count();
assert.equal(hasManagement > 0, role === 'manager');
await page.keyboard.press('Escape');
} else {
assert.ok(await header.locator('a[href="/login"]').isVisible());
}
const catalog = portalNavigation({
authenticated: role !== 'guest',
manager: role === 'manager',
businesses: true,
businessReviewUrl:
role === 'manager' ? '/management/negocios' : null,
education: true,
ai: true,
legacyContent: true,
});
function checkDestinations(groups) {
for (const group of groups) {
if (group.children) checkDestinations(group.children);
else if (group.href)
assert.ok(
destinations.has(group.href),
`Preserved destination: ${group.href}`,
);
}
}
checkDestinations(
role === 'guest'
? catalog.filter((group) => group.id !== 'account')
: catalog,
);
const overflow = await header.evaluate((el) =>
Array.from(el.querySelectorAll('a,button,img'))
.filter((n) => {
const r = n.getBoundingClientRect();
return r.width && (r.x < -1 || r.right > innerWidth + 1);
})
.map((n) => n.textContent),
);
assert.deepEqual(overflow, []);
results.push({
width,
role,
language,
collapsedHeight,
destinations: destinations.size,
result: 'passed',
});
await context.close();
}
assert.deepEqual(errors, []);
fs.writeFileSync(
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
JSON.stringify({ results, errors }, null, 2),
);
console.log(JSON.stringify({ results, errors }));
} finally {
await browser.close();
}