Entrega dashboard e correccoes transversais preservando Ofertas instalado
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-15 18:56:39 +02:00
parent 1349789226
commit fb076c174f
117 changed files with 10380 additions and 1905 deletions
@@ -20,11 +20,14 @@ final class DemographyController extends Controller
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid', 'investment_context' => 'nullable|boolean']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if (! empty($data['investment_context']) && count($data['territories'] ?? []) === 1) {
$evidence = $service->investmentContext($data['territories'][0], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
}
if ($request->expectsJson()) {
return response()->json($evidence);
}
@@ -150,31 +150,37 @@ final class FundingProgrammeController extends Controller
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid',
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
$explicitLocation = $request->hasAny(['territory', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
if ($dossier && ! $explicitLocation && empty($filters['territory']) && $dossier['territory']) {
$filters['territory'] = $dossier['territory'];
}
$query = $service->catalogueQuery($filters);
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
$geography = app(InvestmentGeography::class);
$criteriaErrors = [];
$territorySelection = [];
try {
$filters = $geography->interestSelection($filters);
$territorySelection = ! empty($filters['territory']) ? $geography->path($filters['territory']) : [];
$query = $service->catalogueQuery($filters);
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
} catch (\Illuminate\Validation\ValidationException $error) {
if ($request->expectsJson()) throw $error;
$criteriaErrors = $error->errors();
$items = new \Illuminate\Pagination\LengthAwarePaginator([], 0, 20, 1, ['path' => '/fundos', 'query' => $request->query()]);
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
}
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
$items->through(function ($row) use ($service, $filters) {
$record = $service->present($row);
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
});
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
return tap(Inertia::render('funding/index', ['criteriaErrors' => $criteriaErrors, 'territorySelection' => $territorySelection, 'categoryTypes' => FundingInstitutionClassification::CATEGORY_TYPES, 'items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => $territorySelection,
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -183,7 +189,7 @@ final class FundingProgrammeController extends Controller
{
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
if ($dossier && ! $request->has('territory') && $dossier['territory']) {
$request->merge(['territory' => $dossier['territory']]);
}
$record = $service->published($id);
@@ -204,7 +210,7 @@ final class FundingProgrammeController extends Controller
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['q', 'kind', 'sector', 'certainty', 'state', 'include_historical', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'page', 'need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -12,6 +12,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentProfileCommands;
use App\Services\InvestmentWorkspace;
use App\Services\VirtualDesk;
use App\Services\OpportunityManagement;
use App\Services\OrganizationCountry;
use App\Services\Procurement;
@@ -28,6 +29,8 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): \Symfony\Component\HttpFoundation\Response
{
$entry = $request->validate(['dossier' => 'nullable|uuid']);
$continuation = ! empty($entry['dossier']) ? app(VirtualDesk::class)->case($request->user(), $entry['dossier']) : null;
$data = ['actorId' => $request->user()->id, 'organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
@@ -36,6 +39,13 @@ class InvestmentWorkspaceController extends Controller
return $company;
})];
$data['continuation'] = $continuation ? ['id' => $continuation->id, 'title' => $continuation->title, 'href' => '/investimentos/'.$continuation->id.'#entidade-promotora'] : null;
$forms = config('company-comparison.forms');
abort_unless(is_array($forms), 503, 'As formas jurídicas estão temporariamente indisponíveis.');
$data['legalForms'] = [];
foreach ($forms as $code => $form) {
$data['legalForms'][] = ['value' => $code, 'label' => $form['title']];
}
Inertia::encryptHistory();
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
@@ -77,7 +87,8 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,legal_form,sector,role,description'],
'payload.legal_form' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('company-comparison.forms')))],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\Sector;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
@@ -19,6 +20,27 @@ use Inertia\Response;
final class InvestorContextController extends Controller
{
/** Explicit choices only; a title never assigns an activity or amount.
* @return array<string,mixed>
*/
private function ideaEntry(Request $request): array
{
$entry = $request->validate([
'project_title' => 'nullable|string|max:200',
'sector_id' => 'nullable|integer|exists:sectors,id',
'sector' => 'nullable|string|max:100|exists:sectors,slug',
'investment_budget_min' => 'nullable|numeric|min:0',
'investment_budget_max' => ['nullable', 'numeric', 'min:0', Rule::when($request->filled('investment_budget_min'), 'gte:investment_budget_min')],
'currency' => 'nullable|regex:/^[A-Z]{3}$/D',
]);
if (empty($entry['sector_id']) && ! empty($entry['sector'])) {
$entry['sector_id'] = (string) Sector::where('slug', $entry['sector'])->value('id');
}
unset($entry['sector']);
return $entry;
}
/**
* @return array<array-key, mixed>
*/
@@ -82,17 +104,21 @@ final class InvestorContextController extends Controller
public function options(Request $r, InvestorContext $context): JsonResponse
{
$d = $r->validate(['q' => 'nullable|string|max:120']);
$d = $r->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer|exists:sectors,id', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
$filters = array_intersect_key($d, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id']));
if (! empty($d['sector_id'])) {
$filters['sector'] = Sector::whereKey($d['sector_id'])->firstOrFail()->slug;
}
return response()->json($context->options($d['q'] ?? ''));
return response()->json($context->options($d['q'] ?? '', $filters));
}
public function corridorOptions(Request $r): JsonResponse
{
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid', 'include_corridors' => 'nullable|boolean']);
$service = app(InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(),
return response()->json(['corridors' => ($data['include_corridors'] ?? true) ? collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values() : [],
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
}
@@ -101,6 +127,16 @@ final class InvestorContextController extends Controller
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
}
public function supplyAggregate(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($context->saveSupplyAggregate($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
}
public function promoter(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($r->isMethod('GET') ? $context->promoter($r->user(), $id) : $context->setPromoter($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
}
public function edit(Request $r, string $id, InvestorContext $context): Response
{
$data = $context->view($r->user(), $id);
@@ -133,8 +169,8 @@ final class InvestorContextController extends Controller
}
}
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') {
$entryTerritory = ($source['kind'] ?? '') === 'territory' ? $source : null;
if ($r->hasAny(['province_id', 'district_id', 'admin_post_id', 'locality_id'])) {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) {
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
@@ -148,7 +184,7 @@ final class InvestorContextController extends Controller
}
}
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
return Inertia::render('investor-path/context', ['entryIdea' => $this->ideaEntry($r), 'entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
}
public function preview(Request $r, string $id, InvestorContext $context): JsonResponse
@@ -175,7 +211,7 @@ final class InvestorContextController extends Controller
$choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => [...$this->ideaEntry($r), ...$r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id'])], 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response
@@ -37,13 +37,14 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'preparation_step', 'planning_assumptions_snapshot', 'initial_budget_snapshot', 'fiscal_tools_selection', 'planned_funding', 'requested_funding', 'approved_funding', 'planning_loan',
'context_opportunity', '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', 'modelo_a_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',
'project_duration_years', 'existing_project_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id',
'site_known', 'latitude', 'longitude', 'area_required', 'area_unit', 'special_zone_id', 'land_status',
'infrastructure_needs', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
'infrastructure_needs', 'investment_budget_min', 'investment_budget_max', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
'grants', 'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit',
'financing_term_months', 'funds_origin_countries', 'direct_jobs', 'indirect_jobs', 'national_jobs',
'foreign_jobs', 'skills_needed', 'local_content_percentage', 'exports_value', 'import_substitution_value',
@@ -69,6 +70,15 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.purpose' => ['required', 'in:simulate_only,own_investment,seek_investor,seek_partner,present_opportunity,request_facilitation'],
'economic_snapshot.journey_type' => ['nullable', 'in:personal,small_business,company,investor'],
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
'economic_snapshot.preparation_step' => ['nullable', 'in:purpose,market,investment,financial,operating,cashflow,tax,review,promoter,identity,project,location,impact,opportunity,documents,team,scenarios,procedures'],
'economic_snapshot.initial_budget_snapshot' => ['nullable', 'string', 'max:12000'],
'economic_snapshot.planning_assumptions_snapshot' => ['nullable', 'string', 'max:12000'],
'economic_snapshot.fiscal_tools_selection' => ['nullable', 'array', 'max:80'],
'economic_snapshot.fiscal_tools_selection.*' => ['string', 'max:100', 'regex:/^(tax|iva|irpc):[a-z_]+$/'],
'economic_snapshot.planned_funding' => ['nullable', 'numeric', 'min:0'],
'economic_snapshot.requested_funding' => ['nullable', 'numeric', 'min:0'],
'economic_snapshot.approved_funding' => ['nullable', 'numeric', 'min:0'],
'economic_snapshot.planning_loan' => ['nullable', 'in:yes,no,unknown'],
'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'],
@@ -126,6 +136,8 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.publication_authorisation' => ['nullable', 'in:yes,no'],
'economic_snapshot.public_fields_confirmation' => ['nullable', 'in:yes,no'],
'economic_snapshot.validation_acknowledged' => ['nullable', 'in:yes,no'],
'economic_snapshot.investment_budget_min' => ['nullable', 'numeric', 'min:0'],
'economic_snapshot.investment_budget_max' => ['nullable', 'numeric', 'min:0', \Illuminate\Validation\Rule::when($this->filled('economic_snapshot.investment_budget_min'), 'gte:economic_snapshot.investment_budget_min')],
'economic_snapshot.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.opportunity_currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.rate' => ['nullable', 'numeric', 'between:0,100'],
@@ -288,6 +300,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
$allowedLabels = [
'economic_snapshot.selection_labels' => ['journey_type', 'detail_level', 'purpose', 'manifestation_type', 'preferred_language', 'project_type', 'maturity_stage', 'sector_id', 'activity_id',
'customer_validation_status', 'location_assessment_status', 'cost_estimate_status', 'receivables_timing_status', 'funding_status', 'planning_loan',
'province_id', 'district_id', 'admin_post_id', 'locality_id', 'site_known', 'land_status', 'infrastructure_needs',
'area_unit', 'required_documents', 'opportunity_currency',
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
@@ -303,7 +316,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
$labels = is_array($label) ? $label : [$label];
if (! is_string($field) || ! in_array($field, $allowedFields, true) || count($labels) > 100
|| collect($labels)->contains(fn ($value) => ! is_string($value) || mb_strlen($value) > 255)) {
$validator->errors()->add($path, 'Os rótulos de selecção não são válidos.');
$validator->errors()->add($path.'.'.(is_string($field) ? $field : 'draft'), 'Não foi possível guardar a opção apresentada neste campo. Volte a seleccioná-la; os outros dados do rascunho foram preservados.');
break 2;
}
}
+14 -6
View File
@@ -139,7 +139,8 @@ final class CompanyComparison
public function view(User $actor, string $id, array $input): array
{
$case = $this->desk->case($actor, $id);
$saved = $this->desk->decode($case->payload)['company_comparison'] ?? null;
$payload = $this->desk->decode($case->payload);
$saved = $payload['company_comparison'] ?? null;
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
if (! empty($v['revision'])) {
if (array_key_exists('answers', $input)) {
@@ -160,7 +161,7 @@ final class CompanyComparison
return ['id' => $row->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'engine_version' => $snapshot['result']['engine_version']];
})->all();
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
return ['entity_kind' => $payload['investor_path']['context']['promoter_entity']['kind'] ?? ($case->company_id ? 'company' : 'undetermined'), 'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'answers' => $answers, 'result' => $result,
'saved' => $saved, 'history' => $history, 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null,
'historical' => ! empty($v['revision']), 'workingCopy' => array_key_exists('answers', $input)];
@@ -171,7 +172,7 @@ final class CompanyComparison
public function save(User $actor, string $id, array $input): array
{
$this->desk->case($actor, $id, true);
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'continue_preparation' => 'nullable|boolean'])->validate();
$v['version'] = (int) $v['version'];
return DB::transaction(function () use ($actor, $id, $v, $input) {
@@ -180,19 +181,23 @@ final class CompanyComparison
$data = $this->desk->decode($event->payload);
if ($data['operation_key'] === $v['operation_key']) {
$answers = $this->normalize($input, $data['snapshot']['result']['questions']);
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
$hash = hash('sha256', json_encode(! empty($v['continue_preparation']) ? [$v['version'], $answers, 'continue_preparation'] : [$v['version'], $answers], JSON_THROW_ON_ERROR));
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou respostas diferentes. Reabra a comparação.');
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
}
}
$answers = $this->answers($input);
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
$hash = hash('sha256', json_encode(! empty($v['continue_preparation']) ? [$v['version'], $answers, 'continue_preparation'] : [$v['version'], $answers], JSON_THROW_ON_ERROR));
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Conserve as respostas e recupere a versão actual antes de guardar.');
$payload = $this->desk->decode($case->payload);
if (! empty($v['continue_preparation'])) {
abort_if(in_array($payload['investor_path']['context']['promoter_entity']['kind'] ?? '', ['company', 'organization'], true), 422, 'A entidade existente pode continuar a viabilidade sem preparar nova constituição.');
abort_if(($answers['preferred'] ?? 'unknown') === 'unknown', 422, 'Escolha a alternativa que pretende preparar.');
}
$result = $this->evaluate($answers);
$previous = $payload['company_comparison'] ?? null;
if ($previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) {
if (empty($v['continue_preparation']) && $previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) {
return ['saved' => true, 'replayed' => false, 'unchanged' => true, 'revision' => $previous['revision']];
}
$snapshot = ['revision' => (int) ($payload['company_comparison']['revision'] ?? 0) + 1, 'answers' => $answers,
@@ -201,6 +206,9 @@ final class CompanyComparison
$case->version++;
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
if (! empty($v['continue_preparation'])) {
app(InvestorContext::class)->setPromoter($actor, $id, ['version' => $case->version, 'operation_key' => (string) \Illuminate\Support\Str::uuid(), 'kind' => 'being_formed']);
}
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
});
+21 -5
View File
@@ -23,7 +23,7 @@ final class CompanyPreparation
return ['version' => self::VERSION, 'forms' => config('company-comparison.forms'),
'roles' => ['subscriber' => 'Titular, sócio ou accionista', 'administrator' => 'Administrador ou gerente', 'representative' => 'Representante', 'beneficial_owner' => 'Beneficiário efectivo declarado'],
'source' => config('company-comparison.source'),
'template_status' => 'source_pending', 'template_notice' => 'Os sete exemplares referidos no complemento ainda não foram fornecidos. Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.'];
'template_status' => 'source_pending', 'template_notice' => 'Os modelos contratuais não estão disponíveis nesta preparação. Prepare os dados para revisão; esta preparação não é uma minuta contratual, assinatura, registo ou licença.'];
}
/** Reuse only the already-authorized, frozen dossier values, never a fresh master lookup.
@@ -36,8 +36,8 @@ final class CompanyPreparation
$sources['case_proponent'] = ['name' => $payload['name'], 'kind' => 'person', 'reference' => $case->id,
'version' => $payload['reuse_consent']['date'] ?? 'creation', 'origin' => 'Proponente conservado na criação do dossier'];
}
if ($case->company_id && ! empty($payload['company']['name'])) {
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id,
if (($case->company_id || ! empty($payload['investor_path']['context']['promoter_entity']['id'])) && ! empty($payload['company']['name'])) {
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id ?? $payload['investor_path']['context']['promoter_entity']['id'],
'version' => $payload['company_version'] ?? null, 'origin' => 'Declaração de empresa conservada no dossier'];
}
@@ -220,7 +220,7 @@ final class CompanyPreparation
{
$case = $this->desk->case($actor, $id);
$payload = $this->desk->decode($case->payload);
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1', 'form' => ['nullable', Rule::in(array_keys(config('company-comparison.forms')))]])->validate();
$saved = $payload['company_preparation'] ?? null;
if (! empty($v['revision'])) {
$event = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
@@ -228,11 +228,22 @@ final class CompanyPreparation
$saved = $this->desk->decode($event->payload)['snapshot'];
}
$facts = $saved['facts'] ?? $this->facts(['facts' => []]);
$entity = $payload['investor_path']['context']['promoter_entity'] ?? null;
$context = app(InvestorContext::class)->state($case)['fields'];
$existingEntity = in_array($entity['kind'] ?? '', ['company', 'organization'], true);
if (! $saved && empty($v['revision'])) {
foreach (['currency' => 'currency', 'activities' => 'products_services', 'seat' => 'location'] as $target => $source) {
if (empty($facts[$target]) && ! empty($context[$source])) $facts[$target] = $context[$source];
}
$facts['proposed_name'] = $entity['snapshot']['name'] ?? $facts['proposed_name'];
$knownForm = $entity['snapshot']['legal_form'] ?? null;
$facts['form'] = $existingEntity ? $knownForm : ($v['form'] ?? $facts['form']);
}
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']);
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
return ['entity' => $entity, 'proposedForm' => ! $existingEntity ? ($v['form'] ?? null) : null, 'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']),
'definition' => $saved['definition'] ?? $this->definition(), 'facts' => $facts, 'review' => $saved['review'] ?? $this->review($facts), 'saved' => $saved,
'sources' => $this->sources($case), 'history' => $events->take(25)->map(function ($e) {
@@ -265,6 +276,11 @@ final class CompanyPreparation
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam no formulário; recupere a versão antes de guardar.');
$payload = $this->desk->decode($case->payload);
$entity = $payload['investor_path']['context']['promoter_entity'] ?? null;
$registeredForm = $entity['snapshot']['legal_form'] ?? null;
if (in_array($entity['kind'] ?? '', ['company', 'organization'], true) && $registeredForm) {
abort_unless($facts['form'] === $registeredForm, 422, 'A forma jurídica da entidade existente não é alterada nesta preparação. Use o percurso próprio de alteração do cadastro.');
}
$previous = $payload['company_preparation'] ?? null;
$resolved = $this->resolve($facts, $this->sources($case), $previous);
$definition = $this->definition();
+23
View File
@@ -34,6 +34,29 @@ final class DemographicEvidence
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
}
/** Broader administrative evidence stays labelled as context, never local data.
* @return array<string,mixed>
*/
public function investmentContext(string $territory, int $start, int $end, string $sector = 'general', ?string $edition = null): array
{
$path = app(InvestmentGeography::class)->path($territory);
$requested = collect($path)->last();
$result = $this->analyse([], $start, $end, $sector, $edition);
foreach (array_reverse($path) as $node) {
if (! in_array($node->location_type, ['district', 'province'], true)) {
continue;
}
$result = $this->analyse([$node->id], $start, $end, $sector, $edition);
if ($result['state'] === 'available') {
break;
}
}
$result['requested_territory'] = $requested ? ['id' => $requested->id, 'name' => $requested->name, 'level' => $requested->location_type] : null;
$result['broader_context'] = $requested && count($result['territories']) && $requested->id !== $result['territories'][0]['id'];
return $result;
}
/** @param list<string> $territories
* @return array<string,mixed> */
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
@@ -13,6 +13,29 @@ final class FundingInstitutionClassification
public const TYPES = ['commercial_bank' => 'Banco comercial', 'investment_bank' => 'Banco de investimento', 'bank' => 'Banco (tipo não especificado)', 'microbank' => 'Microbanco', 'cooperative' => 'Cooperativa de crédito', 'savings' => 'Organização de poupança e empréstimo', 'investment_manager' => 'Gestora de fundos de investimento', 'investment_company' => 'Sociedade de investimento', 'pension_manager' => 'Gestora de fundos de pensões', 'insurer' => 'Seguradora', 'reinsurer' => 'Resseguradora', 'microinsurer' => 'Micro-seguradora', 'broker' => 'Corretora de seguros', 'agent' => 'Agente de seguros', 'reinsurance_broker' => 'Corretor de resseguro', 'exchange_house' => 'Casa de câmbio', 'electronic_money' => 'Instituição de moeda electrónica', 'interbank' => 'Serviços interbancários', 'supervisor' => 'Supervisor', 'other' => 'Outro tipo documentado'];
// Category/type semantics in the supplied, accepted institutional reference.
public const CATEGORY_TYPES = ['banking' => ['commercial_bank', 'investment_bank', 'bank'], 'credit' => ['microbank', 'cooperative', 'savings'],
'investment' => ['investment_manager', 'investment_company'], 'pensions' => ['pension_manager', 'insurer'], 'insurance' => ['insurer', 'reinsurer', 'microinsurer', 'pension_manager'],
'intermediation' => ['broker', 'agent', 'reinsurance_broker'], 'exchange' => ['exchange_house'], 'payments' => ['electronic_money', 'interbank'], 'supervision' => ['supervisor'], 'other' => ['other']];
/** Structural validity is independent of whether matching records exist.
* @param array<string,mixed> $filters
*/
public function validateFilters(array $filters): void
{
$v = Validator::make($filters, ['category' => ['nullable', Rule::in(array_keys(self::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(self::TYPES))],
'kind' => ['nullable', Rule::in(array_keys(FundingProgrammes::KINDS))], 'location_mode' => 'nullable|in:physical,coverage'])->validate();
$errors = [];
if (! empty($v['category']) && $v['category'] !== 'other' && ! empty($v['institution_type']) && $v['institution_type'] !== 'other' && ! in_array($v['institution_type'], self::CATEGORY_TYPES[$v['category']], true)) {
$errors['institution_type'] = 'O tipo de instituição não pertence à categoria escolhida. Reveja esse critério; isto não é uma pesquisa sem resultados.';
}
if (! empty($v['kind']) && $v['kind'] !== 'institution') {
if (! empty($v['category']) || ! empty($v['institution_type'])) $errors['kind'] = 'Categoria e tipo de instituição aplicam-se à consulta de instituições. Um fundo ou programa é um registo distinto.';
if (($v['location_mode'] ?? '') === 'physical') $errors['location_mode'] = 'Sede e balcões aplicam-se a instituições. Para fundos e programas, escolha a cobertura do financiamento.';
}
if ($errors) throw \Illuminate\Validation\ValidationException::withMessages($errors);
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function normalize(array $input): array
@@ -31,6 +54,7 @@ final class FundingInstitutionClassification
/** @param array<string,mixed> $filters */
public function filter(Builder $query, array $filters): void
{
$this->validateFilters($filters);
foreach (['category' => 'institution_categories', 'institution_type' => 'institution_types'] as $key => $field) {
if (! empty($filters[$key])) {
$query->whereRaw('v.payload->? @> ?::jsonb', [$field, json_encode([$filters[$key]], JSON_THROW_ON_ERROR)]);
+3 -3
View File
@@ -113,7 +113,7 @@ final class FundingInstitutionDetails
public function match(array $product, array $filters): bool
{
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
if ($product['availability'] === 'closed') {
if ($product['availability'] === 'closed' && ($filters['state'] ?? '') !== 'closed') {
return false;
}
if (! $unknown && $product['availability'] === 'unknown') {
@@ -122,7 +122,7 @@ final class FundingInstitutionDetails
if (! empty($filters['need']) && ! in_array($filters['need'], $product['needs'], true) && ! ($unknown && $product['needs'] === [])) {
return false;
}
if (empty($filters['territory'])) {
if (empty($filters['territory']) || ($filters['location_mode'] ?? '') === 'physical') {
return true;
}
if ($product['coverage'] === 'national') {
@@ -150,7 +150,7 @@ final class FundingInstitutionDetails
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
$conditions = ["p->>'availability' <> 'closed'"];
$conditions = [($filters['state'] ?? '') === 'closed' ? "p->>'availability' IN ('documented','closed','unknown')" : "p->>'availability' <> 'closed'"];
$bindings = [];
if (! $unknown) {
$conditions[] = "p->>'availability' <> 'unknown'";
+42 -11
View File
@@ -68,7 +68,17 @@ final class FundingProgrammes
/** @param array<string,mixed> $filters */
public function catalogueQuery(array $filters): Builder
{
Validator::make($filters, ['q' => 'nullable|string|max:150', 'state' => 'nullable|in:open,closed,upcoming,unknown', 'certainty' => 'nullable|in:include_unknown,documented',
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())], 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'include_historical' => 'nullable|boolean'])->validate();
$filters = app(InvestmentGeography::class)->interestSelection($filters);
$query = $this->publicQuery();
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
}
app(FundingInstitutionDetails::class)->applyFilters($query, $filters);
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
if (! empty($filters['sector'])) {
@@ -94,22 +104,43 @@ final class FundingProgrammes
});
}
if (! empty($filters['state'])) {
$today = today()->toDateString();
$state = $filters['state'];
if ($state === 'unknown') {
$query->whereRaw("(jsonb_array_length(v.payload->'windows') = 0 OR EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'unknown'))");
} elseif ($state === 'closed') {
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))", [$today]);
} elseif ($state === 'upcoming') {
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'dated' AND w->>'start_on' > ?)", [$today]);
} else {
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?)", [$today, $today, $today, $today]);
}
[$windowSql, $windowBindings] = $this->windowFilter($filters);
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'windows','[]'::jsonb)) w WHERE ".$windowSql.')', $windowBindings);
}
return $query;
}
/** Window dates and geographical scope concern the same documented window.
* @param array<string,mixed> $filters
* @return array{literal-string,list<mixed>}
*/
private function windowFilter(array $filters): array
{
$today = today()->toDateString();
$state = $filters['state'];
$bindings = [];
if ($state === 'unknown') $sql = "w->>'mode' = 'unknown'";
elseif ($state === 'closed') { $sql = "(w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))"; $bindings = [$today]; }
elseif ($state === 'upcoming') { $sql = "w->>'mode' = 'dated' AND w->>'start_on' > ?"; $bindings = [$today]; }
else { $sql = "w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?"; $bindings = [$today, $today, $today, $today]; }
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$window = ["EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(w->'territory_paths','[]'::jsonb)) t WHERE t @> ?::jsonb)"];
$scopeBindings = [json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR)];
foreach ($path as $id) { $window[] = "w->'territory_ids' @> ?::jsonb"; $scopeBindings[] = json_encode([$id], JSON_THROW_ON_ERROR); }
$parent = ["v.payload->>'coverage'='national'", "v.payload->'territory_ancestor_ids' @> ?::jsonb"];
$scopeBindings[] = json_encode([$filters['territory']], JSON_THROW_ON_ERROR);
foreach ($path as $id) { $parent[] = "v.payload->'territory_ids' @> ?::jsonb"; $scopeBindings[] = json_encode([$id], JSON_THROW_ON_ERROR); }
if (($filters['certainty'] ?? 'include_unknown') === 'include_unknown') $parent[] = "v.payload->>'coverage'='unknown'";
$scope = "(w->>'coverage'='national' OR (w->>'coverage'='territorial' AND (".implode(' OR ', $window).")) OR (w->>'coverage'='programme' AND (".implode(' OR ', $parent).')))';
if (($filters['certainty'] ?? 'include_unknown') === 'include_unknown') $scope = '('.$scope." OR w->>'coverage'='unknown')";
$sql = '('.$sql.') AND '.$scope;
$bindings = [...$bindings, ...$scopeBindings];
}
return [$sql, $bindings];
}
/** @param array<string,mixed> $record
* @param array<string,mixed> $filters
* @return array<string,string> */
+8 -3
View File
@@ -66,11 +66,16 @@ final class InvestmentCorridorContext
*/
public function parks(?string $key, ?string $territory): array
{
if (! $key || ! $territory) {
if (! $territory) {
return [];
}
abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.');
if ($key) {
abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.');
}
$selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id');
if ($selectedPath === []) {
return [];
}
$candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get();
$result = [];
foreach ($candidates as $zone) {
@@ -83,7 +88,7 @@ final class InvestmentCorridorContext
}
foreach ($ids as $link) {
$id = $link->geo_location_id;
if (! $this->compatible($key, $id)) {
if ($key && ! $this->compatible($key, $id)) {
continue;
}
$path = array_column(app(InvestmentGeography::class)->path($id), 'id');
+24
View File
@@ -245,6 +245,30 @@ final class InvestmentGeography
return $path;
}
/** Canonical selection for a place-of-interest filter, including legacy links.
* @param array<string,mixed> $filters
* @return array<string,mixed>
*/
public function interestSelection(array $filters): array
{
$location = \Illuminate\Support\Facades\Validator::make($filters, ['territory' => 'nullable|uuid', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid'])->validate();
if (array_intersect(array_keys(self::FIELDS), array_keys($location))) {
$path = $this->selection($location);
$territory = $path ? $path[array_key_last($path)]->id : null;
if (! empty($location['territory']) && $location['territory'] !== $territory) {
throw ValidationException::withMessages(['territory' => 'O local de interesse não corresponde à hierarquia seleccionada.']);
}
if ($territory) $filters['territory'] = $territory;
else unset($filters['territory']);
}
if (! empty($filters['territory'])) {
$path = $this->path($filters['territory']);
if (! $path) throw ValidationException::withMessages(['territory' => 'Seleccione um local disponível no cadastro.']);
foreach ($path as $node) $filters[$node->location_type.'_id'] = $node->id;
}
return $filters;
}
/** @return list<string> */
public function descendants(string $id, bool $includeNonPublic = false): array
{
@@ -74,7 +74,7 @@ final class InvestmentOpportunityChoices
$old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']);
$origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id);
$corridors = app(InvestmentCorridorContext::class);
$corridor = $choice['corridor_key'] ?? $old['corridor_key'] ?? $corridors->key($record);
$corridor = array_key_exists('corridor_key', $choice) ? $choice['corridor_key'] : ($old['corridor_key'] ?? $corridors->key($record));
$provinces = $corridor ? $corridors->provinces($corridor) : [];
$documented = $record->primary_territory_id;
$territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null);
+132 -17
View File
@@ -24,7 +24,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', '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 const FIELDS = ['promoter_entity_key', 'legal_name', 'legal_form', 'registration_number', 'products_services', 'investment_budget_min', 'investment_budget_max', '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) {}
@@ -51,7 +51,7 @@ final class InvestorContext
$entrySources = Validator::make($input, ['entry_sources' => 'sometimes|array|max:200', 'entry_sources.*' => 'string|max:100'])->validate()['entry_sources'] ?? [];
unset($input['draft_sequence'],$input['entry_sources']);
abort_unless($sequence > 0, 422);
$data = $this->input($input);
$data = $this->input($input, true);
if (isset($data['complements'])) {
app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
}
@@ -69,7 +69,7 @@ final class InvestorContext
abort_unless($ref['kind'] === 'opportunity', 422);
$record = Opportunity::whereKey($ref['id'])->firstOrFail();
app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
$key = array_key_exists('corridor_key', $choice) ? $choice['corridor_key'] : app(InvestmentCorridorContext::class)->key($record);
if (! empty($choice['territory'])) {
$location = $this->reference($choice['territory']);
abort_unless($location['kind'] === 'territory', 422);
@@ -114,6 +114,112 @@ final class InvestorContext
return Schema::hasTable('investor_navigation_preferences');
}
/** Existing identity records only; these choices never change dossier ownership.
* @return array<string,mixed>
*/
public function promoter(User $actor, string $id): array
{
$case = $this->desk->case($actor, $id);
abort_unless((int) $case->user_id === $actor->id, 403);
$payload = $this->desk->decode($case->payload);
$companies = DB::table('investment_company_declarations')->where('user_id', $actor->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($row) {
$f = $this->desk->decode($row->payload);
return ['id' => $row->id, 'version' => $row->version, 'name' => $f['name'] ?? 'Empresa declarada', 'legal_form' => $f['legal_form'] ?? null];
})->all();
return ['version' => $case->version, 'selected' => $payload['investor_path']['context']['promoter_entity'] ?? ($case->company_id ? ['kind' => 'company', 'id' => $case->company_id, 'snapshot' => $payload['company'] ?? []] : ['kind' => 'undetermined']),
'companies' => $companies, 'organizations' => $this->desk->representedOrganizations($actor), 'can_write' => $case->organization_id === null && ! $case->closed_at];
}
/** Attach an authorised identity snapshot while retaining the case owner and ACL.
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function setPromoter(User $actor, string $id, array $input): array
{
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'kind' => 'required|in:company,organization,being_formed,undetermined', 'id' => 'nullable|uuid', 'source_version' => 'nullable|integer|min:1'])->validate();
return DB::transaction(function () use ($actor, $id, $v): array {
$case = $this->desk->case($actor, $id, true, true);
abort_unless((int) $case->user_id === $actor->id && $case->organization_id === null, 403);
$this->desk->assertOpen($case);
$payload = $this->desk->decode($case->payload);
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
foreach ($payload['investor_path']['promoter_history'] ?? [] as $entry) {
if (($entry['operation_key'] ?? null) === $v['operation_key']) {
abort_unless(hash_equals($entry['request_hash'], $hash), 409);
return ['saved' => true, 'replayed' => true, 'continue' => '/investimentos/'.$id];
}
}
abort_unless((int) $case->version === (int) $v['version'], 409, 'O dossier mudou; recupere os dados antes de alterar a entidade.');
$snapshot = [];
$sourceVersion = null;
if ($v['kind'] === 'company') {
$company = DB::table('investment_company_declarations')->where('id', $v['id'] ?? null)->where('user_id', $actor->id)->lockForUpdate()->first();
abort_unless($company !== null, 404);
$snapshot = $this->desk->decode($company->payload);
$sourceVersion = $company->version;
} elseif ($v['kind'] === 'organization') {
DB::table('memberships')->where('organization_id', $v['id'] ?? null)->where('user_id', $actor->id)->whereNull('deleted_at')->lockForUpdate()->get();
abort_unless($this->desk->represents($actor, $v['id'] ?? '', false), 403);
$organization = collect($this->desk->representedOrganizations($actor))->firstWhere('id', $v['id']);
abort_unless($organization !== null, 403);
$snapshot = ['name' => $organization['name'], 'incorporation_country_code' => $organization['country']];
$sourceVersion = $organization['version'];
}
abort_if(isset($v['source_version']) && (int) $v['source_version'] !== (int) $sourceVersion, 409, 'O cadastro da entidade mudou. Recupere a associação antes de continuar.');
$entity = ['kind' => $v['kind'], 'id' => in_array($v['kind'], ['company', 'organization'], true) ? $v['id'] : null, 'source_version' => $sourceVersion, 'snapshot' => $snapshot, 'recorded_at' => now()->toIso8601String()];
$stage = in_array($v['kind'], ['company', 'organization'], true) ? 'existing' : ($v['kind'] === 'being_formed' ? 'being_formed' : '');
$declarations = ['entity_stage' => $stage, 'promoter_entity_key' => $v['kind'].':'.($entity['id'] ?? '').'@'.($sourceVersion ?? '')];
$existingFields = $this->state($case)['fields'];
foreach (['legal_name' => 'name', 'legal_form' => 'legal_form', 'registration_number' => 'registration'] as $field => $key) {
if (empty($existingFields[$field]) && ! empty($snapshot[$key])) $declarations[$field] = $snapshot[$key];
}
$body = ['version' => $case->version, 'selection' => [], 'declarations' => $declarations];
$plan = $this->preview($actor, $id, $body);
$this->commit($actor, $id, [...$body, 'preview_hash' => $plan['preview_hash'], 'confirm' => true, 'operation_key' => $v['operation_key']]);
$current = $this->desk->case($actor, $id, true, true);
$next = $this->desk->decode($current->payload);
$previous = $payload['investor_path']['context']['promoter_entity'] ?? ($case->company_id ? ['kind' => 'company', 'id' => $case->company_id, 'snapshot' => $payload['company'] ?? []] : ['kind' => 'undetermined']);
$next['investor_path']['context']['promoter_entity'] = $entity;
$next['investor_path']['promoter_history'][] = ['before' => $previous, 'after' => $entity, 'operation_key' => $v['operation_key'], 'request_hash' => $hash];
$next['company'] = $snapshot;
$next['company_version'] = $sourceVersion;
DB::table('desk_cases')->where('id', $id)->update(['company_id' => $v['kind'] === 'company' ? $v['id'] : null, 'payload' => $this->desk->encode($next)]);
$this->desk->event($current, $actor, 'investment_promoter_associated', ['before' => $previous, 'after' => $entity, 'ownership_unchanged' => true]);
return ['saved' => true, 'replayed' => false, 'continue' => $v['kind'] === 'being_formed' ? '/investimentos/'.$id.'/preparar-empresa' : '/investimentos/'.$id];
});
}
/** Explicit consultation saved in the existing encrypted dossier, with optimistic locking.
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function saveSupplyAggregate(User $actor, string $id, array $input): array
{
abort_unless(config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges(), 404);
$control = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
return DB::transaction(function () use ($actor, $id, $control): array {
$case = $this->desk->case($actor, $id, true, true);
abort_unless((int) $case->user_id === $actor->id, 403);
$this->desk->assertOpen($case);
$payload = $this->desk->decode($case->payload);
$history = $payload['investor_path']['supply_aggregates'] ?? [];
foreach ($history as $saved) {
if ($saved['operation_key'] === $control['operation_key']) return ['version' => $case->version, 'analysis' => $saved, 'replayed' => true];
}
abort_unless((int) $case->version === (int) $control['version'], 409, 'O dossier mudou. Recupere-o antes de consultar.');
$context = $this->state($case);
abort_if(count($context['opportunity_choices'] ?? []) > 1, 422, 'Esta consulta agregada de QA utiliza apenas o contexto comum de uma ideia própria.');
$f = $context['fields'];
$territory = $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null;
abort_unless($territory, 422, 'Guarde primeiro a localização conhecida no dossier.');
$analysis = [...app(ProactiveSupply::class)->aggregate($actor, $territory), 'context_revision' => $context['revision'], 'recorded_at' => now()->toIso8601String(), 'operation_key' => $control['operation_key']];
$payload['investor_path']['supply_aggregates'] = [...$history, $analysis];
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
$this->desk->event($this->desk->case($actor, $id), $actor, 'investment_supply_aggregate_saved', ['context_revision' => $context['revision'], 'territory_id' => $territory, 'aggregate_only' => true]);
return ['version' => $case->version + 1, 'analysis' => $analysis, 'replayed' => false];
});
}
/**
* @return array<array-key, mixed>
*/
@@ -327,17 +433,18 @@ final class InvestorContext
return $fields;
}
/**
* @return array<array-key, mixed>
/** @param array<string,mixed> $filters
* @return array<array-key,mixed>
*/
public function options(string $query = ''): array
public function options(string $query = '', array $filters = []): array
{
$query = mb_substr(trim($query), 0, 120);
$items = [];
foreach (Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(['id', 'title']) as $o) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity'];
$public = $filters ? app(InvestmentCatalogue::class)->query([...$filters, 'q' => $query]) : Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'));
foreach ($public->orderBy('title')->limit(51)->get(['opportunities.id', 'opportunities.title']) as $o) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity', 'relevance' => $filters ? 'Corresponde aos critérios explicitamente seleccionados.' : 'Correspondência com a pesquisa; confirme a pertinência antes de associar.'];
}
if (request()->user()) {
if (request()->user() && (! $filters || $query !== '')) {
$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)) {
@@ -345,13 +452,13 @@ final class InvestorContext
}
}
}
if (config('existing-business.enabled')) {
if (config('existing-business.enabled') && (! $filters || $query !== '')) {
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
$items[] = ['key' => 'business:'.$o->id, 'title' => $o->public_title, 'kind' => 'business'];
}
}
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
return ['opportunities' => $items, 'countries' => app(CountryCatalogue::class)->all(), 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
}
@@ -414,7 +521,7 @@ final class InvestorContext
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
private function input(array $input): array
private function input(array $input, bool $draft = false): array
{
abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']) !== [], 422, 'Campo de controlo desconhecido.');
@@ -440,8 +547,10 @@ final class InvestorContext
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean',
'version' => 'required|integer|min:1', 'selection' => 'present|array:portfolio,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',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:promoter_entity_key,legal_name,legal_form,registration_number,products_services,investment_budget_min,investment_budget_max,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.promoter_entity_key' => 'nullable|string|max:160', 'declarations.legal_name' => 'nullable|string|max:250', 'declarations.legal_form' => 'nullable|string|max:100', 'declarations.registration_number' => 'nullable|string|max:100',
'declarations.products_services' => 'nullable|string|max:2000', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'declarations.investment_budget_min' => 'nullable|numeric|min:0', 'declarations.investment_budget_max' => ['nullable', 'numeric', 'min:0', ...(! empty($input['declarations']['investment_budget_min']) ? ['gte:declarations.investment_budget_min'] : [])],
'declarations.currency' => $draft ? 'nullable|regex:/^[A-Za-z]{0,3}$/D' : 'nullable|regex:/^[A-Z]{3}$/D', '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();
}
@@ -480,7 +589,9 @@ final class InvestorContext
$choices = app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $choices);
$data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
// Shared fields concern the first identified opportunity; other sites remain separate.
if ($choices !== []) {
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null;
}
if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) {
$data['selection']['park'] = $choices[0]['park']['key'] ?? null;
}
@@ -511,7 +622,7 @@ final class InvestorContext
}
$selection[$slot] = $ref;
}
$corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
$corridor = array_key_exists('corridor_key', $choices[0] ?? []) ? $choices[0]['corridor_key'] : (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey($selection['opportunity']['id'])->firstOrFail()) : null);
if ($corridor && isset($selection['territory'])) {
abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.');
@@ -579,9 +690,12 @@ final class InvestorContext
$fields['special_zone_id'] = $selection['park']['id'] ?? '';
$fields['investment_regime'] = $selection['park']['fields']['investment_regime'] ?? 'general';
}
if (isset($fields['investment_budget_min'], $fields['investment_budget_max']) && $fields['investment_budget_min'] !== '' && $fields['investment_budget_max'] !== '') {
Validator::make(['declarations' => $fields], ['declarations.investment_budget_max' => 'numeric|gte:declarations.investment_budget_min'])->validate();
}
$replaced = [];
foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) {
if (($old['fields'][$key] ?? null) !== ($fields[$key] ?? null)) {
if (($old['fields'][$key] ?? '') !== ($fields[$key] ?? '')) {
$replaced[] = ['field' => $key, 'before' => $old['fields'][$key] ?? null, 'after' => $fields[$key] ?? null];
}
}
@@ -764,7 +878,7 @@ final class InvestorContext
$original = $ctx;
$ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
$f = $ctx['fields'];
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
$sim = Arr::except(Arr::only($f, self::FIELDS), ['promoter_entity_key', 'sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
// Entering this tool from an investment dossier selects the existing investment-analysis route.
// The reconciliation preserves any purpose deliberately chosen in an existing simulator draft.
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
@@ -790,6 +904,7 @@ final class InvestorContext
'version' => 1, 'context_revision' => $ctx['revision'],
'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null,
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
'promoter_entity' => $ctx['promoter_entity'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
// Retain dossier options as provenance when no consumer is selected; scoped fields remain empty.
+4
View File
@@ -162,6 +162,7 @@ final class InvestorPath
}
$preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null;
$supplyEnabled = (int) $case->user_id === $actor->id && config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges();
return ['funding_selections' => ($owner || $manager) ? array_values(array_filter($path['funding_selections'] ?? [], fn ($row) => ($row['opportunity'] ?? null) === $opportunity)) : [], 'preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, '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'],
@@ -171,7 +172,9 @@ final class InvestorPath
'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['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'can_set_promoter' => (int) $case->user_id === $actor->id && $case->organization_id === null && ! $case->closed_at,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'supply_aggregate_enabled' => $supplyEnabled, 'supply_aggregates' => $supplyEnabled ? ($path['supply_aggregates'] ?? []) : [],
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
@@ -324,6 +327,7 @@ final class InvestorPath
'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot,
'context_opportunity' => $opportunity,
'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1',
'preparation_snapshot' => Arr::only($economic, ['total_investment', 'secured_amount', 'planned_funding', 'requested_funding', 'approved_funding', 'planning_loan', 'annual_revenue', 'annual_operating_costs', 'investment_budget_min', 'investment_budget_max', 'initial_budget_snapshot', 'planning_assumptions_snapshot']),
'kind' => 'local_calculation_inputs', 'context_state' => 'current', 'context_revision' => $context['revision'], 'context_dependency_hash' => $context['dependency_hash'] ?? app(InvestorContext::class)->dependencyHash([]), 'recorded_at' => now()->toIso8601String()];
$payload['investor_path']['results'][] = $result;
abort_unless(count($payload['investor_path']['results']) <= 100, 422, 'Limite de evidências financeiras deste dossier atingido.');
+33
View File
@@ -36,6 +36,39 @@ final class ProactiveSupply
});
}
/** No names, contacts, row identifiers or free-text activity descriptions leave this boundary.
* @return array<string,mixed>
*/
public function aggregate(User $actor, string $territory): array
{
abort_unless(config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges(), 404);
$path = app(InvestmentGeography::class)->path($territory);
abort_if($path === [], 422, 'Confirme primeiro a localização do dossier.');
return $this->read($actor, function () use ($path, $territory): array {
$labels = app(EconomicReferenceCatalog::class)->activities()->groupBy('code')->map(fn ($rows) => $rows->pluck('label_pt_mz')->unique()->count() === 1 ? $rows->first()->label_pt_mz : null);
$scope = collect($path)->last();
$query = function (string $id): Builder {
return DB::table('proactive_supply_observations as o')->whereIn('o.territory_id', app(InvestmentGeography::class)->descendants($id));
};
$base = $query($territory);
if (! (clone $base)->exists() && in_array($scope->location_type, ['locality', 'admin_post'], true)) {
$district = collect($path)->firstWhere('location_type', 'district');
if ($district && $query($district->id)->exists()) {
$scope = $district;
$base = $query($district->id);
}
}
$counts = (clone $base)->selectRaw("count(DISTINCT entity_key) FILTER (WHERE duplicate_of IS NULL AND NOT identity_conflict) AS entities, count(*) FILTER (WHERE duplicate_of IS NOT NULL) AS repeated, count(*) FILTER (WHERE identity_conflict) AS uncertain")->first();
$code = "CASE WHEN cae ~ '^[0-9.]{1,10}$' THEN cae ELSE '' END";
$groups = (clone $base)->whereNull('duplicate_of')->where('identity_conflict', false)->selectRaw($code." AS code, count(DISTINCT entity_key) AS total, min(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS first_year, max(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS last_year")
->groupByRaw($code)->orderByRaw($code)->limit(101)->get()->map(fn ($row) => ['code' => $row->code, 'label' => $row->code ? ($labels->get($row->code) ?? 'CAE '.$row->code.' — descrição por confirmar') : 'Actividade por classificar', 'total' => (int) $row->total, 'first_year' => $row->first_year, 'last_year' => $row->last_year])->all();
return ['requested_territory' => ['id' => $territory, 'name' => collect($path)->last()->name], 'scope' => ['id' => $scope->id, 'name' => $scope->name, 'level' => $scope->location_type],
'broader_context' => $scope->id !== $territory, 'entities' => (int) $counts->entities, 'repeated' => (int) $counts->repeated, 'uncertain' => (int) $counts->uncertain,
'groups' => array_slice($groups, 0, 100), 'truncated' => count($groups) > 100, 'coverage_unknown' => true, 'limitations' => self::LIMITATIONS,
'interpretation' => 'Os grupos ajudam a investigar actividades similares e complementares. A relação deve ser fundamentada; não demonstra concorrência efectiva, clientes, capacidade ou receitas. Uma entidade pode constar em vários grupos; não some os grupos.'];
});
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
+10
View File
@@ -47,6 +47,16 @@ class VirtualDesk
return $case->organization_id ? $this->represents($actor, $case->organization_id, $write) : (int) $case->user_id === $actor->id;
}
/** The same effective membership used by dossier access; identity does not depend on country configuration.
* @return list<array<string,mixed>>
*/
public function representedOrganizations(User $actor): array
{
return array_values(DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $actor->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->get(['o.id', 'o.name', 'o.version_no', ...(Schema::hasColumn('organizations', 'incorporation_country_code') ? ['o.incorporation_country_code'] : [])])
->unique('id')->map(fn ($row) => ['id' => $row->id, 'name' => $row->name, 'version' => $row->version_no, 'country' => $row->incorporation_country_code ?? null])->values()->all());
}
public function case(User $actor, string $id, bool $write = false, bool $lock = false): \stdClass
{
$case = DB::table('desk_cases')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
+2
View File
@@ -4,5 +4,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),
// Owner-authorised aggregate experiment: isolated testing database only.
'aggregate_supply_qa_enabled' => env('INVESTOR_AGGREGATE_SUPPLY_QA_ENABLED', false),
'definition_id' => '61000919-0000-4000-8000-000000000001',
];
@@ -1,3 +1,7 @@
## 15/11/2026 — entrega delimitada em preparação
Dashboard existente e correcções transversais: autorizados para homologação. Delta Ofertas: excluído por conferência visual/móvel e interface editorial ainda pendentes. Preservar o módulo instalado em 13497892, sem novas entradas, fotografias L1 ou formulário imobiliário da Etapa B. A entrega é derivada directamente da revisão servida; 142 testes de interface, Wayfinder, build e tipos passaram. As 15 provas transversais aceites são reutilizadas. Backup/restauro actualizado conferiu 219 tabelas e 417 ficheiros. Integração e instalação continuam por executar; ver o mesmo relatório de apresentação/contexto para a delimitação e os recibos posteriores.
## 14/11/2026 — hierarquia Administração/Dashboard corrigida e instalada em 96f51096
Revisão servida **`96f510965f70581a4b41dd94463f598b77dd7cf5`**, instalada pelo executor Git como correcção da organização expressamente pedida pela Tempo. **Administração** reúne os grupos Dashboard, Conteúdos e oportunidades, Cadastros e investimento, Pedidos e acompanhamento e Acessos e configuração. Os grupos sem destinos autorizados são omitidos. **Dashboard** contém as funções dos painéis, com `/dashboard` como visão geral; os cadastros administrativos permanecem nos outros grupos. O circuito editorial conserva nome e URL próprios. Operações administrativas foram retiradas de Mais/Área de trabalho sem perder destinos; funções pessoais mantêm o seu âmbito.
@@ -0,0 +1,226 @@
## Entrega delimitada autorizada — 15/11/2026
**Estado: preparação concluída; integração/instalação ainda não executadas neste registo.** A autorização permite instalar dashboard e correcções transversais; Ofertas só poderia entrar com demonstração integral. Conferidos os recibos existentes: critérios 5/6 da Etapa B têm prova HTTP, mas a interface editorial e o critério 7 visual/móvel continuam não verificados. Por isso, **o delta de Ofertas foi excluído**, sem transformar quatro critérios anteriormente aceites em aceitação integral.
Preparada a entrega no ramo `delivery/dashboard-transversal-20261115`, directamente sobre a revisão servida `13497892`, por selecção do conteúdo compatível de `8806312f`, `8c6ed625` e `7810ffc8`; não por integração integral dos seus antecessores de Ofertas. Preservados os ficheiros próprios de Ofertas, recursos/fotografias, contratos, rotas, mapas privados e navegação na versão instalada. O formulário canónico partilhado com esse módulo também fica preservado. O dashboard existente é mantido; não se presume outro incremento de dashboard ausente do delta.
As dependências partilhadas foram conferidas separadamente. `TerritoryFields` mantém quatro níveis e mapa por defeito; só os consumidores financeiros usam as opções adicionais. Os rótulos de modalidade usados por Ofertas mantêm-se. Fontes/demografia/contexto recebem a correcção comum, sem alterar as permissões ou o contrato privado de Ofertas. O manifesto `outputs/transversal-install-20261115/scope-separation.json` regista as exclusões e os hashes dos ficheiros protegidos, a comparar no destino.
Provas da separação: **142 testes de interface, zero falhas; geração Wayfinder real, compilação normal e tipos aprovados**. O leitor de testes demográficos foi adaptado para resolver o componente partilhado real, sem simular a sua saída. Reutilizadas as 15 provas autenticadas dos percursos transversais cujo código foi preservado. A consulta empresarial agregada mantém a guarda exclusivamente QA e não alarga acesso a dados internos na homologação.
Backup actualizado concluído: 219 tabelas e 417 ficheiros. Restauro isolado conferido: contagens/checksums iguais, rede `none`, base separada na porta 55441, ficheiros e chaves/envelopes MFA recuperáveis. Zero migrações previstas; nenhuma alteração de dados, permissões, publicação ou notificações externas. Janela de instalação reservada pelo lock existente; `main` só pode avançar por fast-forward para a revisão separada, após reconferência.
O histórico QA abaixo conserva o seu âmbito; não demonstra instalação de Ofertas nem autoriza publicação pública.
---
# Apresentação e continuidade das consultas — candidata QA, 15/11/2026
Estado: **candidata QA com execução autenticada do saldo V01–V15; resultados por ecrã/largura abaixo, sujeitos à aceitação da experiência pelo responsável**. Base original `42fba8ba`, ramo `qa/public-context-20261115`. Sem instalação, publicação, alterações de permissões ou escrita em dados operacionais. Os testes posteriores usam exclusivamente a nova base sintética isolada `investor_intent_20261115_test`, em `127.0.0.1:55441`, incluindo a sua estrutura de teste. O trabalho concorrente da pasta principal e a conferência de Ofertas permanecem separados.
## Condição de entrega — conferência dos consumidores afectados
Decisão expressa do responsável: a instalação depende de cobertura consolidada dos ecrãs e percursos afectados. Um componente aprovado não aprova automaticamente os seus consumidores. A autorização existente continua limitada a implementação e validação em QA.
Na mesma matriz, cada consumidor deve conservar o seu estado **alinhado, corrigido, divergente ou não verificado**, com versão, ambiente, evidência e limite da prova. «Corrigido» identifica uma alteração demonstrada; não substitui a aceitação integral do percurso. Sem prova individual suficiente, o estado é «não verificado».
Conferir intenção; etapas e ajuda; próxima acção; reutilização de entidade/localização/moeda/orçamento; selectores e filtros no cliente e servidor; pertinência dos resultados; coerência dos valores; gravação/recuperação/regresso/recarregamento/retoma autenticada; isolamento de contas/separadores/oportunidades; computador e telemóvel; preservação de dados, funcionalidades, permissões e históricos.
As tabelas históricas abaixo descrevem provas dirigidas, não uma declaração de alinhamento de todos os ecrãs. A candidata não pode ser disponibilizada como correcção transversal enquanto existirem divergências ou verificações pendentes deste âmbito. Decisões jurídicas, dependências externas e funcionalidades futuras ficam separadas e não ampliam esta conferência.
Quando a conferência visual autenticada estiver impedida, registar o erro e disponibilizar uma prévia QA à equipa, com os percursos e operações a executar. Renderização sintética, testes automáticos e capturas de componentes não substituem essa prova. Não instalar para obter a conferência sem autorização expressa.
### Conferência dirigida dos quatro percursos — 15/11/2026
Esta passagem actualiza as linhas existentes MASTER-600240dd-0001/0018, MASTER-e1169a77-0026 e INE-DEMO-01; não cria outro inventário. «Corrigido» abaixo refere-se apenas ao comportamento e à evidência indicados. As colunas visuais não herdam a aprovação do componente nem da API.
Tabela de partida, anterior às conferências autenticadas registadas abaixo. **Não usar os seus «não verificado» como pedido para repetir provas entretanto obtidas. O saldo actual é a tabela «Execução do saldo V01–V15»**, que substitui as pendências genéricas desta tabela, sem eliminar o histórico.
| Percurso / ecrã | Estado funcional no recorte | Evidência disponível | Computador autenticado | Telemóvel autenticado / retoma |
| --- | --- | --- | --- | --- |
| Hotel — entrada e editor de contexto (`/investimentos/iniciar`, `/{id}/contexto/editar`) | Corrigido: ideia própria com sector confirmado, orçamento em intervalo e localização independente de anúncios/corredores | `InvestorIntentContinuityTest`, `preparation-intent.test.mjs`, renderização do editor em `public-information.test.mjs` | Não verificado; prévia sintética anterior em 1280 px | Não verificado; prévia sintética anterior em 360 px, sem gravação |
| Hotel — entidade existente no dossier (`/investimentos/{id}#entidade-promotora`) | Corrigido: associação autorizada, versão da fonte, dados recuperados; sem duplicar empresa, alterar dono ou membros | `InvestorPromoterContinuityTest`, incluindo isolamento, fonte alterada e organização com representação revogada | Não verificado | Não verificado |
| Hotel — comparação opcional (`/{id}/forma-juridica`) | Corrigido: guardar alternativa e continuar na preparação da mesma entidade; distinção entre entidade existente e por constituir recuperada do payload | `InvestorPromoterContinuityTest`; PHPStan detectou variável não inicializada, corrigida nesta passagem | Não verificado | Não verificado |
| Hotel — preparar empresa (`/{id}/preparar-empresa`) | Corrigido no servidor: recupera dados, preserva notas e forma deliberada; não altera a forma da empresa constituída | `InvestorPromoterContinuityTest`; regressões `company-preparation` e `company-comparison` | Não verificado | Não verificado |
| Hotel — simulador, percurso assistido | Divergente: a estrutura financeira ainda reúne ferramentas e tratamentos; sete ecrãs assistidos, ajuda por actividade e retoma da etapa ainda não demonstrados | Leitura delimitada de `simulation-form.tsx`: ferramentas e oito blocos fiscais partilham condições `financial`/`tax`. Não classificar essa etapa como alinhada | Não verificado | Não verificado |
| Hotel — calculadora e origem do financiamento | Corrigido no recorte: detecta divergências sem arredondar valores nem assumir desconhecido como zero; escolha explícita de origem; CSV e retorno impedidos com divergência; valores demonstrativos retirados dos novos rascunhos | `planning-finance-context.test.mjs`; tipos; histórico guardado conservado | Não verificado | Não verificado |
| Hotel — guardar e recuperar cenário / devolver ao dossier | Corrigido no recorte API: cenário explicitamente declarado, intervalo separado, mesma evidência recuperada e retorno repetível sem duplicação | `InvestorIntentContinuityTest`: snapshot financeiro exacto, resultado único, moeda e isolamento da leitura do rascunho | Não verificado; não equivale a clicar em guardar/retomar | Não verificado, incluindo retoma após autenticação |
| Hotel — relatório | Não verificado integralmente; testes do gerador preservam intervalo e resultados; não demonstram o relatório completo da sessão autenticada | `investor-dossier-report.test.mjs`; saldos da preparação assistida e períodos fiscais continuam abertos | Não verificado | Não verificado |
| Agronegócio — Matola Rio–Sede / Boane / Maputo Província | Corrigido no recorte API: hierarquia sintética identificada, 1–5 milhões MZN, sem oportunidade, corredor ou total inferido | `InvestorIntentContinuityTest`, dois sectores; todos os dados do ensaio são sintéticos | Não verificado | Não verificado |
| Agronegócio — consulta empresarial agregada no dossier | Corrigido no recorte QA: totais por actividade/localização, âmbito distrital identificado, repetidos e incertos separados; nomes/contactos/origem reservados não transferidos | `InvestorIntentContinuityTest`; flag privada desactivada por defeito e guarda da base QA; histórico por dossier | Não verificado | Não verificado |
| Agronegócio — simulação e retoma | Corrigido no recorte API: valores declarados e snapshot recuperados, resultado devolvido ao mesmo dossier sem duplicação. Percurso assistido completo continua divergente como em Hotel | `InvestorIntentContinuityTest`; teste não substitui utilização do simulador no navegador | Não verificado | Não verificado |
| Instituições — lista (`/fundos`) | Corrigido no recorte: Banca/Banco comercial, incompatibilidade estrutural, vazio válido, desconhecido/documentado, presença/cobertura e janela | `FundingCriteriaCoherenceTest`; consulta partilhada e resposta HTML/API testadas | Não verificado | Não verificado |
| Instituições — filtros de categoria/tipo e alteração de critérios | Corrigido: opções dependentes; preservação dos critérios válidos; resultados anteriores separados até nova pesquisa; incompatibilidade corrigida só por escolha explícita | `funding-journey-coherence.test.mjs`, executa handlers da página real com transporte simulado | Não verificado | Não verificado |
| Instituições — locais de interesse | Corrigido no recorte: distrito sem província não abre consulta nacional; consumidor usa hierarquia comum; servidor rejeita hierarquia contraditória | `FundingCriteriaCoherenceTest`; `territory-search-select.test.mjs`; componente usado no consumidor confirmado, sem presumir prova visual | Não verificado | Não verificado |
| Instituições — detalhe e regresso (`/fundos/{id}`) | Corrigido nesta passagem: retorno preserva pesquisa, sector, estado, certeza, tipo, hierarquia e página; limpeza territorial explícita não herda o dossier | `FundingCriteriaCoherenceTest`: selecção recebida pelo detalhe comparada com todos os critérios enviados | Não verificado | Não verificado |
| Instituições — guardar interesse / continuar dossier | Não verificado neste delta; testes dos filtros não demonstram esta operação | Reutilizar provas anteriores compatíveis; executar selecção e retoma da candidata antes do fecho | Não verificado | Não verificado |
| Demografia — síntese e série (`/localizacoes/demografia`) | Corrigido no recorte: fonte/edição/período separados; originais preservados; grupos incompletos e anos ausentes sem valores inventados | Provas de reconciliação `21ac7eb`, `public-information.test.mjs`; prévia de componentes anterior em computador/360/390 px | Não verificado na página autenticada da candidata | Não verificado integralmente |
| Demografia — contexto do editor/dossier/simulador | Corrigido no recorte API: ausência local recupera distrito com âmbito explícito, sem converter população distrital em local; respostas tardias isoladas | `InvestorIntentContinuityTest` e testes dirigidos do componente | Não verificado individualmente nos três consumidores | Não verificado individualmente nos três consumidores |
| Demografia — continuar para ideia e recuperar | Corrigido na construção do URL; intenção territorial transportada sem filtros comerciais. Utilização completa não verificada | `public-information.test.mjs`, `preparation-intent.test.mjs`; gravação da ideia testada separadamente, não como prova de clique ponta a ponta | Não verificado | Não verificado |
**Recibos desta passagem:** `outputs/investor-intent-20261115/backend.log` — 12 testes / 412 asserções; `frontend.log` — 137 testes; `phpstan.log` — aprovado, zero erros. Os testes de API usam transacções revertidas na base sintética identificada acima. As provas são específicas dos comportamentos descritos, não 149 percursos completos.
**Impedimento visual observado:** inventário do conector devolveu `Browsers: Error: The browser access host is unavailable`. O controlo nativo do Edge funciona e leu o URL da janela separada: `file:///C:/laragon/www/portal-investidor-public-context-qa-20261115/outputs/investor-intent-20261115/editor-preview.html`. Essa prévia declara que não tem autenticação, gravação, consultas nem cartografia real. A sessão do portal com alterações por guardar foi preservada. Não se atribui o impedimento ao portal ou ao navegador; não se repetiram reinícios nem alterações de configuração.
**Saldo que impede a entrega transversal:** concluir a divergência da preparação financeira assistida e o contrato de retoma da etapa; conferir ajuda e pressupostos por actividade, período/cobertura fiscal e relatório no mesmo percurso; executar os ecrãs acima numa sessão QA da candidata em computador e telemóvel, incluindo regresso, recarregamento e nova autenticação. Os testes de isolamento no servidor não demonstram, por si só, isolamento entre separadores na interface. Nenhuma instalação ou aceitação funcional integral resulta desta passagem.
## Execução do saldo V01–V15 — resultados na mesma candidata
Actualização após a interrupção final do controlo Windows. **Instalação suspensa.** Não houve nova tentativa de navegador nesta actualização documental. O erro observado foi: `Computer Use has been stopped for this turn because it could not determine the current browser URL on Windows with enough confidence to enforce policy.` O controlo interrompeu o clique em «Voltar aos fundos e programas»; não foi observada a página de destino. Isto não demonstra erro do portal, perda de filtros ou falha de MFA.
Candidata local: `C:/laragon/www/portal-investidor-public-context-qa-20261115`, base Git `8c6ed625` **mais o delta QA ainda não consolidado em novo commit**; URL `http://127.0.0.3:18715`. Build servido: `build-qa-opportunities/assisted-mobile`, entrada `assets/app-f0MgDyAw.js`; SHA256 do manifesto `709989dd62b65b72eefb4b0394f776adbf0a1e340a7487a2d013170fe4e6cf03`. O SHA base, sozinho, não identifica esta candidata. Conferir esta identidade antes da execução manual; não instalar nem substituir pela homologação. Usar a conta sintética habitual, sem registar credenciais.
**Provas preservadas, fora da repetição:** Hotel com empresa existente, orçamento 2 milhões, mobilizado 1 milhão, diferença 1 milhão, receita anual 7,5 milhões, custo anual 2 milhões, gravação/recarga/retorno e HTML descarregado; escolha explícita de modelo/estimativas; bloqueio de cobertura 2027/2026; edição e continuação móvel de Receitas → Tesouraria. Agronegócio com entidade por constituir, actividade/localização/intervalo recuperados, modelo agrícola e recarga/retorno. Banca/Banco comercial com um resultado compatível, referência/produto/versão conservados no mesmo dossier. Demografia distrital identificada no dossier, incluindo leitura móvel, sem atribuição à localidade. A largura dos selectores financeiros foi corrigida e reconferida a 390 × 844. Limpar província desactivou os níveis inferiores e separou resultados anteriores dos critérios por aplicar. Estas provas não encerram as acções diferentes abaixo.
Cada linha refere-se a requisitos já existentes; **V01–V15 são identificadores de passos de verificação, não novos requisitos ou outra auditoria**. Executar só o saldo, em computador e em 390 × 844 quando a linha indicar ambos. «NÃO VERIFICADO» não equivale a «FALHOU».
| Passo / requisito existente | Ecrã | Acção verificada | Critério esperado | Resultado executado e evidência |
| --- | --- | --- | --- | --- |
| V01 · MASTER-e1169a77-0026 | `/fundos` → `/fundos/{id}` → «Voltar aos fundos e programas» | Na pesquisa A, escolher explicitamente critérios válidos (categoria/tipo, tipo de registo, presença/cobertura, território, sector, necessidade, certeza, janela, históricos, palavra-chave e página quando existirem). Abrir o resultado e clicar no retorno da própria página. Repetir o retorno em largura móvel. | Recuperar exactamente os critérios efectivamente escolhidos e a página aplicável; resultado corresponde à pesquisa A. Nenhum critério novo herdado do dossier ou de outra pesquisa. Critérios sem resultados não são alargados silenciosamente. | Critérios e resultado bancário recuperados no retorno real do detalhe. 1440 px: **APROVADO**, 2026-11-15T15:41:27.944Z, evidência `V01-1440-1794757287944`; 390 px: **APROVADO**, 2026-11-15T15:41:54.875Z, evidência `V01-390-1794757314875` |
| V02 · MASTER-e1169a77-0026 / MASTER-600240dd-0018 | Duas abas de `/fundos`, detalhe e regresso | Manter A no contexto agrícola e abrir B com categoria/localização diferentes, escolhidas explicitamente. Voltar ao detalhe/lista de A e recarregar A; conferir também B. | Cada URL e formulário conserva os seus critérios; não entram tipos, distritos, sector, janela ou contexto de B em A. A selecção guardada no dossier não altera a pesquisa alheia. | Pesquisas A/B conservaram critérios e contextos separados após retorno e recarga. 1440 px: **APROVADO**, 2026-11-15T16:28:25.029Z, evidência `V02-1440-1794760105052`; 390 px: **APROVADO**, 2026-11-15T16:29:13.443Z, evidência `V02-390-1794760153468` |
| V03 · MASTER-e1169a77-0026 | `/fundos`, computador e móvel | Pela interface, tentar uma combinação estruturalmente contraditória e uma combinação válida sem registos; alterar categoria e conferir resolução do tipo incompatível. | Mensagens distintas para combinação inválida e zero resultados válido. Sem banco apresentado como fundo por inferência, sem corretoras como correspondência, sem perda de critérios independentes. | Combinação contraditória distinta do vazio válido; mudança de categoria resolveu apenas o tipo dependente. 1440 px: **APROVADO**, 2026-11-15T15:41:50.001Z, evidência `V03-1440-1794757310001`; 390 px: **APROVADO**, 2026-11-15T15:43:11.560Z, evidência `V03-390-1794757391560` |
| V04 · MASTER-600240dd-0018 | `/simulador-fiscal?context={dossier}` e dossier Hotel/Agronegócio | Guardar a etapa de cada dossier; sair e autenticar normalmente a mesma conta sintética; reabrir cada dossier e continuar. | Recuperar etapa, valores, modelo, ordem das escolhas e contexto próprios; sem exemplos, nova pergunta sobre dados já válidos ou duplicação de resultados. | Logout/login normal recuperou Hotel 7,5 milhões e Agronegócio 2 milhões, respectivos modelos e etapa. 1440 px: **APROVADO**, 2026-11-15T15:45:04.572Z, evidência `V04-1440-1794757504572`; 390 px: **APROVADO**, 2026-11-15T16:15:11.676Z, evidência `V04-390-1794759311711` |
| V05 · MASTER-600240dd-0018 | Hotel e Agronegócio em duas abas; acesso de segunda conta QA autorizada | Editar um pressuposto sintético de A, manter B aberto, regressar/recarregar; usar uma segunda conta QA já autorizada para tentar os URLs privados directos de A. Não criar permissões. | Sem mistura entre dossiers/abas; divergência de versão tratada sem sobrescrever edição deliberada; segunda conta sem acesso a rascunho, relatório ou documento privado alheio. | Edição concorrente recusada com 412 e entrada local conservada; dossiers separados; segunda conta sem acesso ao dossier ou rascunho privados. 1440 px: **APROVADO**, 2026-11-15T16:24:03.041Z, evidência `V05-1440-1794759843064` |
| V06 · MASTER-600240dd-0001 / MASTER-600240dd-0018 | Dossier Hotel sintético adicional → entidade por constituir → `/preparar-empresa` → simulador → relatório | Verificar a variante Hotel com entidade por constituir, usando nova preparação sintética e preservando o Hotel existente com empresa associada. Guardar e retomar dados propostos e continuar até relatório. | Sem cadastro legal fictício, repetição da comparação obrigatória ou transferência do dono; forma escolhida e dados recuperados no mesmo novo dossier; valores do Hotel existente preservados. | Hotel por constituir guardou entidade, pressupostos, recuperação e relatório no mesmo dossier. 1440 px: **APROVADO**, 2026-11-15T16:03:54.766Z, evidência `V06-1440-1794758634766` |
| V07 · MASTER-600240dd-0018 | Agronegócio → Receitas/Tesouraria/Revisão → relatórios do mesmo dossier | Em móvel, recuperar o modelo agrícola e os pressupostos já guardados; concluir revisão e gerar/consultar o relatório agrícola. Não redigitar nem adoptar números novos por rotina. | Modelo agrícola, receita 2 milhões e intervalo 1–5 milhões MZN recuperados; total e financiamento desconhecidos não se tornam zero ou valor único. Relatório pertence ao dossier agrícola, sem pressupostos do Hotel. | Relatório agrícola descarregado com receita 2 milhões, intervalo 1–5 milhões e custos desconhecidos preservados. 1440 px: **APROVADO**, 2026-11-15T15:45:15.049Z, evidência `V07-1440-1794757515049`; 390 px: **APROVADO**, 2026-11-15T15:45:59.234Z, evidência `V07-390-1794757559234` |
| V08 · MASTER-600240dd-0001 | Associação de entidade, comparação jurídica opcional e preparação de empresa | Conferir estes controlos em móvel. Na comparação, recuperar escolha anterior e usar «continuar preparação»; em empresa existente, não alterar a forma jurídica do cadastro. | Controlos utilizáveis, comparação opcional, dados e edições deliberadas preservados; regresso ao dossier/viabilidade executável. | Comparação opcional e entidade utilizáveis em móvel; forma/nome recuperados; cadastro da empresa existente não substituído. 390 px: **APROVADO**, 2026-11-15T16:00:27.433Z, evidência `V08-390-1794758427433` |
| V09 · MASTER-600240dd-0018 | Novo rascunho QA → acção de retorno antes de Financiamento | Verificar a orientação acrescentada «Conferir financiamento», seguir a acção, deixar montantes desconhecidos e guardar/voltar. | Próxima acção executável na etapa correcta, sem exigir inventar montantes; contrato de gravação e versão continua aplicado. | Orientação abriu Financiamento; gravação e retorno concluídos sem inventar montantes desconhecidos. 1440 px: **APROVADO**, 2026-11-15T16:04:46.409Z, evidência `V09-1440-1794758686409` |
| V10 · MASTER-600240dd-0001 | `/investimentos/{id}/preparar-empresa` | Reabrir a preparação guardada e conferir a mensagem sobre modelos, em computador/móvel, sem alterar os dados. | Sem referência interna aos «sete exemplares/complemento»; indisponibilidade de modelos e distinção entre preparação e registo continuam claras; dados anteriores conservados. | Aviso histórico apresentado sem referência aos sete exemplares; nome e sede anteriores preservados. 1440 px: **APROVADO**, 2026-11-15T16:05:10.181Z, evidência `V10-1440-1794758710181`; 390 px: **APROVADO**, 2026-11-15T16:06:43.704Z, evidência `V10-390-1794758803704` |
| V11 · MASTER-600240dd-0018 | Simulador → Enquadramento fiscal → Revisão | Conferir em móvel pesquisa, adição/retirada de uma operação e preservação dos dados. Na revisão, conferir síntese, diferenças reais e próxima acção. Se demonstrar cálculo, escolher explicitamente período coberto e valores sintéticos; não substituir o cenário 2027 guardado. | Só operações escolhidas montadas; restantes pesquisáveis; dados retirados da análise mantidos no histórico; sem códigos internos, exemplos automáticos ou comparação vazio→vazio. Separar rascunho de cálculo guardado e manter coerência com relatório. | Operação fiscal pesquisada, retirada e reposta sem perder entradas; 2027 recuperado; referência técnica fechada no detalhe da versão. 390 px: **APROVADO**, 2026-11-15T16:24:32.637Z, evidência `V11-390-1794759872659` |
| V12 · MASTER-600240dd-0018 | Dossier agrícola → «Consultar e guardar análise agregada» | Abrir/guardar a análise com a localização existente, recarregar e recuperar a mesma evidência; conferir em móvel. | Totais/actividades e âmbito conhecidos ou cobertura desconhecida explícitos; duplicados separados; nenhum nome, contacto ou lista interna exposto; ausência de registos não significa ausência de oferta. | Análise agregada guardada e recuperada com cobertura desconhecida explícita, sem nomes/contactos internos. 1440 px: **APROVADO**, 2026-11-15T15:45:22.425Z, evidência `V12-1440-1794757522425`; 390 px: **APROVADO**, 2026-11-15T15:46:06.294Z, evidência `V12-390-1794757566294` |
| V13 · INE-DEMO-01 | `/localizacoes/demografia` e detalhe de originais; consulta no editor e simulador | Na consulta completa, seleccionar explicitamente Boane QA e 2026–2027; abrir evolução e originais. Conferir os consumidores editor/simulador, em computador e móvel, sem repetir a síntese do dossier. | 12.345→12.600, diferença de período 255 (não taxa anual); fonte/edição/unidade/projecção explícitas, originais preservados. Localidade sem observações não recebe população distrital sem aviso. Anos/parcelas ausentes continuam sem dados. | 12.345→12.600 e diferença de período 255; originais conservados; contexto distrital identificado no editor e simulador; rótulos e selector dependente reconferidos. 1440 px: **APROVADO**, 2026-11-15T16:18:31.360Z, evidência `V13-1440-1794759511387`; 390 px: **APROVADO**, 2026-11-15T16:20:49.768Z, evidência `V13-390-1794759649790`; rótulos: V13-labels-1440-1794759923605, V13-labels-390-1794759934158 |
| V14 · INE-DEMO-01 / MASTER-600240dd-0001 | Demografia → preparar ideia → contexto/dossier → simulador | Usar a acção territorial da consulta, confirmar sector/intenção e guardar/retomar a preparação sintética nos dois tamanhos. No percurso, conferir os selectores de país/moeda/localização efectivamente usados (lista directa, prefixo de três caracteres, acentos e código curto MZ). | Território explícito transportado sem filtros financeiros/comerciais alheios; sector proposto confirmado, anúncio/zona opcionais; hierarquia pode parar no nível conhecido, códigos canónicos e edições mantidos. Continuação no dossier sem perguntas repetidas. | Origem Boane transportada até ao simulador; país por MZ, moeda por prefixo e nível distrital guardados e recuperados. 1440 px: **APROVADO**, 2026-11-15T16:19:56.796Z, evidência `V14-1440-1794759596823`; 390 px: **APROVADO**, 2026-11-15T16:22:14.914Z, evidência `V14-390-1794759734939` |
| V15 · MASTER-600240dd-0001 / MASTER-600240dd-0018 | Página inicial → «Criar investimento» → `/investimentos/iniciar` → editor de contexto | Nos dois tamanhos, entrar pela intenção de criar, escolher sector/localização/intervalo/moeda e guardar uma preparação sintética nova; distinguir da acção «Procurar oportunidades». Conferir regresso e edição do contexto. | Abre preparação da ideia, nunca exige procurar/associar anúncio ou escolher corredor/zona; reutiliza apenas escolhas explícitas e conserva o intervalo. Contexto alterável sem apagar valores deliberados. | Entrada Criar investimento abriu preparação própria sem anúncio ou zona, preservando localização e intervalo orçamental. 1440 px: **APROVADO**, 2026-11-15T15:49:14.009Z, evidência `V15-1440-1794757754009`; 390 px: **APROVADO**, 2026-11-15T15:55:37.546Z, evidência `V15-390-1794758137546` |
### Registo da execução técnica
O Codex executa estas linhas através de Playwright local autorizado, sem depender da recuperação do conector. Para cada V01–V15, os recibos conservam **executante, data/hora, identidade da candidata/build, ecrã/largura, acção efectivamente realizada, resultado observado e evidência**, passando a APROVADO, FALHOU ou mantendo NÃO VERIFICADO com impedimento concreto. A equipa mantém a avaliação de negócio e a aceitação da experiência. Uma captura de um componente não encerra os outros consumidores da linha. Não colocar credenciais, códigos MFA ou dados reais no registo.
Fecho condicionado: não iniciar outra auditoria, refazer provas acima preservadas ou instalar para obter a conferência. Uma falha nova deve indicar causa delimitada e correcção indispensável; verificar apenas os efeitos afectados. **A instalação permanece suspensa até encerrar as divergências de orientação/mensagem e todas as verificações deste saldo**, com aceitação do responsável. Dependências jurídicas ou funcionalidades futuras permanecem fora deste saldo de apresentação/continuidade.
<!-- EXECUCAO-NAVEGADOR-QA-20261115 -->
### Execução técnica do saldo — Playwright local
Executante: Codex, autenticação normal com contas sintéticas, exclusivamente em `http://127.0.0.3:18715` e na base `investor_intent_20261115_test`. Chromium local, controlador Playwright 1.65.1; contextos novos, sem perfil operacional, permissões novas ou controlos de segurança desactivados. Computador 1440 × 1000; móvel **emulado** 390 × 844, sem ensaio em dispositivo físico.
A tabela V01–V15 acima contém o resultado executado por largura. Recibos e capturas: `outputs/investor-intent-20261115/browser-acceptance/*-results.json` e respectivos PNG/TXT. As tentativas anteriores permanecem nos recibos; o estado apresentado usa a última execução de cada passo/largura. Não confundir erros de sincronização do ensaio com defeitos comprovados do produto.
Candidata: ramo `qa/public-context-20261115`, base Git `8c6ed625`. O delta QA e este relatório identificam a revisão preparada para apreciação. Provas iniciais no build `assisted-mobile`; correcções de mensagem em `acceptance-corrections`; build actualmente servido `build-qa-opportunities/acceptance-labels`, manifesto SHA256 `c0c8eb36c0b531613367d4c1c154aa59ab154289b0e459ef57393a3cd3ed8790`. Os recibos posteriores também identificam os assets usados. Nenhuma instalação no activo.
Correcções delimitadas: apresentação de aviso histórico da preparação da empresa sem modificar a definição guardada; erro de leitura do rascunho distingue limite de pedidos/sessão/servidor de edição concorrente, preservando o bloqueio de escrita; comparação demográfica usa o selector pesquisável existente com distrito dependente da província, preservando quatro locais; origem territorial da demografia recupera a hierarquia no editor sem substituir localização guardada; referência técnica do questionário conservada num detalhe fechado do resumo. Regressão territorial dirigida: 1 teste/33 asserções; selector: 1 teste; aviso empresarial: 7 testes. Tipos, lint, PHPStan dirigido e compilação passaram. As provas técnicas aceites anteriores não foram repetidas por rotina.
Saldo executado: 15/15 passos aprovados nas larguras previstas. O ensaio adicional de falha de leitura `V04-recovery-error` comprovou mensagem específica, ausência de gravações durante a falha e recuperação dos valores originais. Os testes exercitam a interface real autenticada; a apreciação de negócio e a autorização de instalação continuam separadas.
**Saldo técnico V01–V15 encerrado nas condições indicadas; instalação não autorizada e continua suspensa.** A avaliação de negócio e a aceitação da experiência cabem ao responsável. Não equivale a aceitação integral do portal nem a teste num dispositivo móvel físico. Limitações jurídicas e futuras funcionalidades continuam fora deste saldo.
<!-- /EXECUCAO-NAVEGADOR-QA-20261115 -->
## Causas e correcções
### Incremento da preparação assistida — evidência do anexo de 15/11/2026
O anexo `7a2b72ba-ccbb-4aad-a55b-3f30739e78c7/Pasted text.txt`, identificado pelo responsável como «Texto colado(7).txt», fica associado à divergência já aberta. SHA256 `489473c1e3fdc69ff7cc786dcedb33184ddd74b4a6c9fb97d99f130ae0a2e943`. O texto mostra oito etapas anteriores, empréstimo demonstrativo, tratamentos indiscriminados e códigos internos. Não se atribui esse ecrã a uma revisão exacta sem evidência da origem.
**Implementação desta passagem, apenas QA:** o percurso simples de investimento passa a oito ecrãs: intenção/entidade/localização; mercado; investimento inicial; financiamento; receitas/custos; tesouraria/cenários; enquadramento fiscal; resultados/próximos passos. Investimento inicial e financiamento ficam separados conforme a sequência do novo anexo; os dois primeiros ecrãs conservam a preparação contextual anterior. O modo avançado e os restantes tipos de percurso conservam as capacidades existentes, sem se presumir a sua aceitação visual por esta alteração.
- `simulationFieldsForSection` serve a apresentação, validação e revisão do percurso simples. Factos financeiros desconhecidos não impedem continuar uma preparação privada; cálculos continuam sujeitos às suas condições. `preparation_step` é gravado no contrato existente e recuperado com o rascunho. Mudança deliberada de percurso reinicia a navegação, conservando os valores.
- O orçamento inicial reutiliza a aritmética decimal existente para instalações, equipamento, preparação e fundo de maneio, sem valores demonstrativos. A soma só substitui o total do cenário por escolha explícita; o intervalo original fica separado. Financiamento previsto, solicitado, aprovado e mobilizado têm campos distintos; os três primeiros não são somados ao montante mobilizado.
- A ajuda de receitas/custos exige escolher o modelo. Hotel usa quartos, preço/noite, ocupação e dias anuais; Agronegócio usa unidades vendáveis, preço e ciclos anuais. São pressupostos editáveis, guardados e adoptados apenas pela acção «Usar». Custos incompletos não se tornam zero. A simulação de empréstimo no percurso simples depende da pergunta «Pretende estudar um empréstimo neste cenário?».
- A etapa de financiamento deixa de montar os componentes fiscais. O percurso simples apresenta as operações fiscais por escolha explícita; o componente de IVA preserva 32 tratamentos e o de IRPC nove operações complementares através do selector pesquisável comum «Adicionar outra operação». Retirar uma operação da análise não elimina os respectivos dados e históricos. Não se deduz aplicabilidade pelo título ou sector.
- O IVA mensal recusa divergência entre ano fiscal e primeiro mês da projecção; mantém a cobertura documentada de 2026. IRPC normal e ISPC trimestral sinalizam divergência entre o ano do enquadramento e o da preparação; a correspondência exige escolha explícita e os validadores de cobertura mantêm-se. A estimativa anual de IRPC não oferece o recorte de 2026 para outro ano. Nenhum ano é alterado automaticamente.
- O histórico distingue rascunho guardado de simulação calculada e guardada. O retorno ao dossier identifica «pressupostos financeiros», em consonância com o tipo de evidência existente. O resultado preserva uma selecção dos pressupostos económicos autorizados, orçamento discriminado e modelo de receitas/custos para o relatório privado, sem copiar identidade ou outros campos pessoais para essa selecção. Filtragem por dossier/oportunidade e exportadores Word/impressão existentes são conservados.
- Códigos do desenvolvimento retirados dos títulos afectados de importações, apuramento de aquisições e estimativa anual; fontes detalhadas de importação ficam no componente comum de evidência. Não se declara revisto todo o texto de todas as operações avançadas.
**Estado de aceitação:** implementado e com provas dirigidas; **não verificado integralmente na interface**. Os testes dos controlos reais de orçamento, ajuda de receitas e selector fiscal usam estado/eventos React e transporte simulado; não equivalem a uma sessão de navegador. Os testes de relatório conferem pressupostos, valores desconhecidos, histórico e exclusão de dados de outra oportunidade.
**Retoma posterior à interrupção por Esc:** provas técnicas preservadas, sem nova execução. A prévia local foi ligada ao build assistido já compilado em `public/build-qa-opportunities/assisted-current`, conservando os assets anteriores; não houve alteração do produto nem instalação no activo nesta retoma. O controlo nativo leu `http://127.0.0.3:18715/login`; a abertura directa do dossier sintético Hotel regressou a esse login. Não foi observada a sessão autenticada que o responsável indicou ter aberto. A enumeração mantém as duas janelas Edge existentes; o Firefox está minimizado e a leitura devolveu `window is minimized; call activate_window, refresh with get_window, then retry get_window_state`. Não foram invocadas APIs não documentadas nem alteradas portas/configurações dos navegadores.
As tentativas de repor a vista normal não permitiram demonstrar as operações do investidor; essa frente foi interrompida sem reinícios adicionais. Foi pedido apenas o URL do painel aberto para identificar a sessão correcta, sem palavras-passe ou códigos. Não se atribui a causa ao portal, ao navegador ou ao MFA. Hotel, Agronegócio, retorno de filtros financeiros e continuidade demográfica mantêm a conferência autenticada/móvel **NÃO VERIFICADA**; a interrupção não reabre nem invalida as provas técnicas aceites.
**Retoma seguinte — acesso recuperado e primeiras operações reais:** foi observado o dossier sintético Hotel autenticado em `http://127.0.0.3:18715/investimentos/6bdc2ac6-2a40-497c-831d-31c9c08372ed`. Pela interface foi pesquisada a «Empresa hoteleira sintética QA», seleccionada e guardada a associação; o resumo do dossier passou a mostrar essa empresa. A acção «Continuar a viabilidade» abriu o simulador do mesmo dossier. Foram observados título, actividade, sector, hierarquia Matola Rio–Sede/Boane/Maputo Província e intervalo de 1–5 milhões MZN recuperados, sem anúncio associado. Foram preenchidos uma descrição sintética e o idioma e utilizada a acção Continuar para chegar a Mercado. As capturas nativas disponíveis na conversa demonstram este recorte em computador, não o percurso integral ou a utilização móvel.
A conferência identificou duas divergências concretas antes do fecho: a navegação focava o cabeçalho geral, afastando as perguntas da etapa; orçamento, parcerias/apoio, contexto e histórico acumulavam-se antes delas. Aplicada correcção localizada em `simulation-form.tsx`: foco no título da etapa com margem para o menu fixo; apresentação progressiva dos blocos acessórios, mantendo os componentes montados, as ligações e os erros de contexto visíveis; ajuda aberta quando uma resposta da etapa é «Não sei». Tipos e lint dirigidos aprovados; reconferência visual desta alteração permanece por executar até observação efectiva. As baterias fiscais e de persistência já aceites não foram repetidas por esta correcção de apresentação.
**Conferência posterior no mesmo QA — Hotel, computador e emulação móvel:** o acesso autenticado foi recuperado sem alterar a segurança. A navegação passou a focar a etapa efectiva; o recarregamento recuperou Mercado, Financiamento e Receitas e custos. «Por determinar» abriu a ajuda da etapa. Pela interface foram introduzidas instalações 1.000.000, equipamentos 500.000, preparação 200.000 e fundo de maneio 300.000 MZN; a soma de 2.000.000 MZN só foi adoptada por botão explícito. O intervalo original de 1–5 milhões permaneceu separado.
Foram encontrados e corrigidos três defeitos demonstráveis: (1) a calculadora conservava um campo mobilizado vazio apesar de o investimento indicar 1.000.000, e mostrava erros de ferramentas não utilizadas; agora acompanha a origem comum quando não há edição conhecida divergente, preservando conflitos reais para escolha explícita; (2) receitas/custos anuais estavam ocultos pela regra de campos avançados; agora são editáveis na etapa pertinente; (3) o retorno ao dossier confundia ausência de oportunidade em campo vazio com `null` normalizado pelo servidor. A comparação canónica passou a tratar esses vazios como equivalentes, sem equiparar desconhecido a zero nem aceitar alterações reais de montante ou oportunidade. A pesquisa fiscal inicialmente vazia passou a explicar que é necessário escolher a componente, em vez de aparentar ausência de operações.
Nova conferência real: investimento 2.000.000, mobilizado 1.000.000 e diferença 1.000.000 MZN coerentes após recarregar; sem erros dos grupos ocultos ou empréstimo demonstrativo. Hotel escolhido explicitamente; 10 quartos × 300 dias × 50% × 5.000 MZN gerou 7.500.000 MZN anuais, adoptados por escolha explícita. Recarregar recuperou os quatro pressupostos, fundamento e total anual. Em emulação Edge **390 × 844**, a interface autenticada apresentou uma coluna; foi editado o custo anual para 2.000.000 e usado Continuar até Tesouraria. Não é prova num aparelho físico nem aprovação móvel de todos os ecrãs. Em 2027, seleccionar IVA à taxa geral apresentou o limite de cobertura de 2026, sem mudar o ano ou calcular silenciosamente.
O botão de retorno, após a correcção do vazio, guardou os pressupostos no **mesmo dossier Hotel**, agora versão 6. A interface preparou e descarregou o HTML consolidado; o ficheiro efectivamente descarregado conserva descrição, empresa/contexto, intervalo, orçamento inicial, receita 7.500.000, custo 2.000.000, mobilizado 1.000.000, diferença 1.000.000 MZN e pressupostos/fundamento. Evidência local: `outputs/investor-intent-20261115/hotel-browser-report.html`. Não foi apresentado imposto calculado fora da cobertura nem aprovada a viabilidade comercial. Recibos dirigidos adicionais: `financial-source-ui.log` (5/5), `visual-followup-tests.log` (8/8), `return-fingerprint-tests.log` (8/8, baterias sobrepostas), tipos e builds incrementais. Capturas nativas disponíveis na conversa; não inventar caminhos de screenshots.
**Saldo desta conferência:** Hotel tem prova autenticada de associação de empresa existente, preparação/estimativa explícita, gravação, recuperação, retorno e geração do relatório, com o recorte móvel descrito. Permanecem NÃO VERIFICADOS o percurso integral com entidade por constituir, Agronegócio na interface, retorno dos filtros financeiros, continuidade demográfica autenticada e retoma após nova autenticação/isolamento entre separadores. Os restantes consumidores da tabela não ficam aprovados por partilharem componentes. A candidata e as correcções continuam exclusivamente em QA, sem instalação autorizada.
**Agronegócio e consultas — continuação da mesma conferência:** no dossier `bd830c95-a3bc-4342-84e2-df8e1dccb614`, seleccionada entidade por constituir; a preparação recuperou sede e actividade e guardou uma denominação sintética, continuando para o simulador. Mantiveram-se sector, hierarquia e intervalo de 1–5 milhões MZN, sem anúncio/zona obrigatórios. O modelo agrícola, escolhido explicitamente, conservou 20.000 unidades × 50 MZN × 2 ciclos = 2.000.000 anuais após recarregar, sem os valores do Hotel. Foram guardados pressupostos no mesmo dossier após conferir Financiamento, deixando total/mobilizado desconhecidos: o intervalo não foi convertido num montante único. A tentativa de retorno antes de percorrer Financiamento expôs a mensagem pouco orientadora «Guarde primeiro as entradas da ferramenta financeira»; permanece divergência de orientação, não uma perda de dados ou autorização para dispensar o contrato do servidor.
Para provas positivas de consulta, acrescentados apenas à base isolada três registos financeiros e duas observações distritais **claramente sintéticos**, com recibo `outputs/investor-intent-20261115/consultation-fixtures.json`; nenhuma conta recebeu permissões persistentes, nenhuma instituição operacional foi publicada ou alterada. A consulta autenticada Banca → Banco comercial, presença física Boane e âmbito documentado apresentou apenas o banco, excluindo corretora/microsseguradora sintéticas. A ligação ao detalhe conserva os filtros na URL de retorno. Pela interface foram escolhidos necessidade Investimento/equipamento e produto, confirmada a conservação privada e aberto o mesmo dossier: instituição, versão consultada 1, produto, categoria e tipo recuperados. O clique efectivo de retorno à lista, combinações vazias/inválidas e a conferência móvel financeira ainda não ficam aprovados por esta prova.
No dossier agrícola foi aberta «Demografia na análise de viabilidade» → «Consultar indicadores do ano actual». Apresentados 12.345 habitantes sintéticos, fonte/2026/projecção e **Boane QA — Distrito**, com aviso explícito de contexto administrativo mais amplo, que não representa a população da localidade. Estrutura etária/rural ausente continuou «Sem dados»; não foram inventados clientes ou rentabilidade. A consulta completa, evolução temporal, continuidade por nova ideia e versão móvel autenticada continuam abertas. Também foi identificada linguagem interna sobre «sete exemplares/complemento» na preparação da empresa, ainda por corrigir no âmbito de apresentação. Estas constatações não encerram a cobertura transversal.
**Prévia autenticada preparada:** servidor de desenvolvimento local em `http://127.0.0.3:18715`, exclusivamente com a base `investor_intent_20261115_test`, guarda de identidade da base e conta/dossiers sintéticos sem novas permissões. O host e cookie próprios separam a sessão das outras prévias locais. Não é instalação na homologação. Não desactivar autenticação, MFA ou avisos do navegador para continuar.
Para a conferência pendente, usar os dossiers sintéticos do Hotel e Agronegócio preparados nessa base: associar empresa existente ou preparar entidade por constituir; confirmar contexto; preencher só pressupostos conhecidos; adoptar orçamento e estimativa; guardar na etapa de receitas; recarregar e retomar a mesma etapa; escolher operações fiscais e testar 2027 sem substituição por 2026; guardar pressupostos no dossier e gerar relatório; repetir em largura móvel. Conferir também lista/detalhe/retorno de fundos e demografia/âmbito/continuar ideia. As quatro linhas da matriz mantêm a aceitação integral aberta até essas provas, sem apagar as provas anteriores ou transformar a prévia em instalação autorizada.
- Proveniência apresentada como conteúdo principal: componente partilhado `EvidenceDetails` para consulta progressiva, mantendo referências, exemplares e hashes no detalhe. Limitações de utilização e períodos aplicáveis permanecem junto do conteúdo afectado.
- Retorno ao catálogo aceitava qualquer parâmetro e podia transportar critérios de outra família: lista explícita por destino, preservação da página válida, múltiplas facetas e alterações deliberadas no destino. Mudar/limpar um nível territorial não recupera descendentes incompatíveis. Não usa armazenamento global nem transfere contexto privado em `catalogue_query`. Menus de consulta não transportam o identificador de dossier para biblioteca, legislação ou outras famílias sem aplicação.
- A reutilização do componente de interesse podia escrever o formulário anterior na chave de outra oportunidade: estado associado à chave do recurso, preservando os formatos anteriores, as edições deliberadas, a autenticação normal e o funcionamento com armazenamento bloqueado.
- O resumo de dossier podia adoptar a escolha efectuada noutro separador: pista por conta em `sessionStorage`, sempre confrontada com a lista autorizada recebida do servidor. Um URL explícito prevalece. Um novo separador sem escolha continua a usar a preferência existente. Uma mudança deliberada recupera a versão actual do servidor antes da escrita normal com controlo de concorrência. Não altera a autoridade do servidor nem os dados do dossier.
- A consulta demográfica podia mostrar a localização anterior e aceitar uma resposta tardia: estado isolado pela localização, cancelamento da consulta e rejeição da resposta após desmontagem. A página de comparação identifica a consulta pelos filtros recebidos do servidor.
## Cobertura demonstrada e limites por família
| Família / consumidores | Alteração verificável | Evidência / excepções |
| --- | --- | --- |
| Catálogo, pesquisa, carteira e zonas | Retorno por família; filtros explícitos, página, facetas, contexto territorial; explicação do estado sem anúncios | Testes de URLs e navegação. Estado vazio distingue anúncios de empresas, recursos e potencial. Não altera o motor de pesquisa nem incorpora os deltas concorrentes de `FederatedSearch` da pasta principal. |
| Localização e demografia, incluindo consulta no catálogo/dossier | Síntese dos totais mais recentes **dentro do período consultado e de cada fonte**; ano, unidade e tipo junto do valor; dimensões, original, ficheiro e hash recuperáveis no detalhe | Renderização real dos componentes e teste de resposta tardia. Não soma faixas etárias, não combina fontes ou tipos estatísticos, não preenche anos ausentes nem infere procura. A síntese conserva advertências e unidades ausentes. **Comparação com `21ac7eb` pendente**, pois o objecto e o bundle não estão disponíveis localmente; não houve cherry-pick dessa candidata. |
| Sectores, Mineração/Energia, âncoras e clusters | Estado sem anúncios explicado; fontes territoriais em detalhe; campos vazios da análise de competitividade omitidos da apresentação | Renderização e testes de filtros de recursos, localização e continuidade no dossier, incluindo Energia e Mineração. Não reescreve descrições editoriais existentes nem altera geometrias, contratos ou relações. |
| Biblioteca e legislação | Identificação/fonte em detalhe; sem bloco de entidade desconhecida; período de aplicação e aviso de utilização visíveis; acções documentais conservadas | Renderização do consumidor comum e regressão dos filtros de biblioteca/legislação. Não altera decisões jurídicas, disponibilização ou acesso aos ficheiros. |
| Dossier, manifestações de interesse e simulador | Escolha por separador/conta, rascunho por oportunidade; fonte técnica do Modelo A em detalhe; cálculos e reutilização preservados | Integração do provider real com transporte simulado, testes de recuperação e autenticação, herança e regressões de Modelo A. Não representa conferência autenticada integral num navegador nem entrega real de email. |
Estas provas cobrem componentes e consumidores identificados, não todos os ecrãs ou todas as combinações de módulos. A distinção entre oferta publicada, empresas existentes, indicadores e hipóteses é preservada; não se utiliza ausência de anúncios como prova de ausência de oferta empresarial ou viabilidade.
## Verificação
Recibos em `outputs/public-context-20261115/`:
- `frontend.xml`: 82 testes, zero falhas, erros ou omissões; inclui componentes renderizados, pesquisa/navegação, biblioteca, recursos sectoriais, interesses, contexto e Modelo A.
- `navigation-context.xml`: 1 teste de integração do provider, com contexto por separador, recarregamento, URL explícito e versão concorrente do servidor. Dados exclusivamente sintéticos; transporte simulado.
- `types.log`: `tsc --noEmit`, aprovado.
- `lint.log`: lint dirigido a todos os ficheiros de produto/teste alterados, aprovado sem avisos.
- `build.log`: compilação QA aprovada. Aviso do plugin de fontes sobre a dependência opcional `fontaine`, com configuração já existente; não foi instalada nem alterada qualquer dependência para o eliminar.
A skill de controlo Windows foi tentada sem reiniciar aplicações. A enumeração devolveu **`Computer Use native pipe is unavailable: failed to connect native pipe: O sistema não conseguiu localizar o ficheiro especificado. (os error 2)`**. Não existem screenshots aceites. Apresentação móvel, foco real, ausência de sobreposição e utilização autenticada destes deltas estão **NÃO VERIFICADOS**. Classes responsivas e testes de renderização não substituem essa conferência.
Conferência manual quando houver prévia da candidata: catálogo → detalhe → voltar, incluindo alteração explícita de província; demografia com duas localizações/períodos e retorno; sector/âncora/cluster com e sem anúncios; biblioteca/legislação com aviso/período; dois dossiers em separadores distintos e recuperação de um interesse. Repetir em computador e janela de 390 × 844, verificando teclado, detalhes, tabelas e botões. Não utilizar a prévia das Ofertas como prova desta candidata: conserva o seu código anterior.
## Compatibilidade e entrega
Pacote Git com delta exclusivo sobre `42fba8ba`, testes e documentação. O manifesto indica SHA, pré-requisito e intersecção de caminhos com as alterações concorrentes detectadas na pasta principal. **Não aplicar uma substituição de pastas nem fazer push para `main`**. Integrar o delta revisto, preservando o trabalho concorrente e as actualizações da matriz/manual. O pacote não inclui credenciais, sessões, dados operacionais, dependências ou uma nova importação demográfica.
As linhas existentes RF-MN-010, RF-GEO-009, INE-DEMO-01, MASTER-471012ab-0017 e M2-d0c06a75-0055 conservam o estado e o saldo anteriores; acrescenta-se apenas esta cobertura verificada. A candidata `21ac7eb`, os deltas concorrentes da pesquisa e a aceitação visual não são declarados integrados ou concluídos.
O saldo acima corresponde à entrega `8806312f`. A actualização abaixo regista a reconciliação posterior; os recibos anteriores conservam-se como histórico. Nenhuma instalação está autorizada por esta entrega.
## Reconciliação do bundle `21ac7eb` com `8806312f`
O exemplar `\\Mac\Home\Downloads\demografia-leitura-21ac7eb.bundle` foi efectivamente recebido e verificado pelo Git. Contém `21ac7eb4fb9c0fd7dfbbf2db7ff8d6ab56249238`, com pré-requisito `13497892`. Não se efectuou cherry-pick integral. O hash do transporte e os recibos desta passagem ficam em `outputs/demography-reconciliation-20261115/`.
| Parte da candidata recebida | Decisão e resultado |
| --- | --- |
| Síntese demográfica | A apresentação progressiva e os indicadores originais de `8806312f` foram preservados. Acrescentou-se uma síntese de população por fonte/edição, sem repetir o total nos cartões dos restantes indicadores. |
| Evolução temporal | Incorporado o helper de apresentação: extremos exactos do período escolhido, mesma unidade e tipo estatístico; diferença absoluta e percentual **do período**, nunca taxa anual. Anos intermédios ausentes permanecem «Sem dados». |
| Distribuição etária e rural | Incorporados os grupos indicados apenas quando existem todas as parcelas únicas e compatíveis. Percentagens rural/urbana exigem reconciliação com o total. Unidades desconhecidas, duplicações, tipos diferentes e parcelas ausentes não geram percentagens inventadas. São derivações para leitura, sem escrita sobre os valores originais ou cálculos fiscais. |
| Interpretação | Incorporadas orientações ligadas aos valores apresentados: verificar acessos/custos/serviços, capacidade instalada e rendimento; a população em idade activa não demonstra disponibilidade de trabalhadores. Não se deduz procura, rentabilidade ou lacuna de mercado. |
| Fontes, decimais e versões | Conservado o componente comum `EvidenceDetails`, tabela dos originais, hash, unidades, advertências e múltiplas fontes separadas. Não foram alterados lotes, dados, documentos ou geometrias. |
| Catálogo | Aproveitada a separação explícita entre estudo territorial e filtros comerciais, com os destinos existentes. Não se duplicou o aviso de ausência de anúncios já tratado pela solução comum. |
| Continuidade da análise | Incorporada a acção de preparar ideia a partir de cada território, transportando apenas `source=territory:ID`; sem importar filtros comerciais. Conservado o isolamento por URL e separador de `8806312f`. |
| Documentação e testes | Não se substituíram o manual nem as matrizes pelos exemplares antigos do bundle. Acrescenta-se esta actualização às referências existentes. Reutilizaram-se os testes dirigidos recebidos, adaptando apenas os imports aos componentes comuns e o carregamento ao mecanismo `compileFunction` já usado no projecto. |
### Prova dirigida adicional e conferência visual
Os testes adicionais verificam conservação dos decimais, grupos incompletos, zero, dimensões duplicadas, unidades/tipos incompatíveis, distribuições incoerentes, anos ausentes, extremos seleccionados, separação de edições, interpretação e ligação territorial. O exemplar de Báruè do bundle confirma os resultados de apresentação sem modificar os originais. Não se repetiram importações ou as baterias técnicas globais aceites.
A captura nativa do Edge recuperou nesta passagem; o conector de abas continua a indicar `The browser access host is unavailable`. A janela existente no portal activo, com alterações por guardar, foi preservada. Abriu-se uma janela separada para `visual-components.html`: componentes reais renderizados com CSS compilado da candidata e dados explicitamente sintéticos, sem autenticação, API operacional ou publicação.
Foram observados em computador e em emulação móvel (390 px e 360 × 844): cartões em uma coluna, leitura da variação temporal, abertura da série anual, ano ausente como «Sem dados» e recuperação do detalhe de fontes/originais. A primeira captura móvel detectou palavras comprimidas na tabela original; foi aplicada largura mínima com deslocamento interno, seguida de nova conferência. As capturas são evidência da **prévia de componentes**, não de aceitação autenticada integral ou de utilização num telefone físico.
Continuam pendentes a conferência autenticada dos percursos completos e a aceitação do responsável. Sem instalação no activo, sem alteração do esquema ou de permissões e sem envio externo. A pendência de obter/comparar o bundle fica encerrada por esta reconciliação selectiva.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
{
"base": "13497892",
"source_candidate": "7810ffc8",
"excluded_candidate_files": [
"docs/investmoz/unification/traceability-and-manual.md",
"docs/investmoz/v2/auditoria-portal/apresentacao-contexto-qa-20261115.md",
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"outputs/demography-reconciliation-20261115/build.log",
"outputs/demography-reconciliation-20261115/frontend.xml",
"outputs/demography-reconciliation-20261115/lint.log",
"outputs/demography-reconciliation-20261115/qa-result.json",
"outputs/demography-reconciliation-20261115/types.log",
"outputs/demography-reconciliation-20261115/visual-components.html",
"outputs/public-context-20261115/build.log",
"outputs/public-context-20261115/final-consumers.xml",
"outputs/public-context-20261115/final-lint.log",
"outputs/public-context-20261115/frontend.xml",
"outputs/public-context-20261115/lint.log",
"outputs/public-context-20261115/navigation-context.xml",
"outputs/public-context-20261115/qa-result.json",
"outputs/public-context-20261115/types.log",
"resources/js/components/offer-particulars.tsx",
"resources/js/components/opportunity-canonical-fields.tsx",
"resources/js/pages/offers/preparation.tsx",
"tests/frontend/offer-rental-flow.test.mjs",
"tests/frontend/opportunity-screen-presentation.test.mjs",
"tests/frontend/portal-navigation.test.mjs"
],
"offers_dependency_files": 60,
"changed_shared_dependencies": [
"resources/js/components/demographic-context.tsx",
"resources/js/components/demographic-evidence.tsx",
"resources/js/components/dossier-navigation-context.tsx",
"resources/js/components/investment-territory.tsx",
"resources/js/components/opportunity-search-filters.tsx",
"resources/js/components/territorial-source-evidence.tsx",
"resources/js/lib/investor-context.ts"
],
"selected_product_files": [
"app/Http/Controllers/DemographyController.php",
"app/Http/Controllers/FundingProgrammeController.php",
"app/Http/Controllers/InvestmentWorkspaceController.php",
"app/Http/Controllers/InvestorContextController.php",
"app/Http/Requests/StoreSimulationFormDraftRequest.php",
"app/Services/CompanyComparison.php",
"app/Services/CompanyPreparation.php",
"app/Services/DemographicEvidence.php",
"app/Services/FundingInstitutionClassification.php",
"app/Services/FundingInstitutionDetails.php",
"app/Services/FundingProgrammes.php",
"app/Services/InvestmentCorridorContext.php",
"app/Services/InvestmentGeography.php",
"app/Services/InvestmentOpportunityChoices.php",
"app/Services/InvestorContext.php",
"app/Services/InvestorPath.php",
"app/Services/ProactiveSupply.php",
"app/Services/VirtualDesk.php",
"config/investor-path.php",
"resources/js/components/acquisition-closure.tsx",
"resources/js/components/catalogue-choice-select.tsx",
"resources/js/components/catalogue-navigation.tsx",
"resources/js/components/cluster-competitiveness-public.tsx",
"resources/js/components/country-select.tsx",
"resources/js/components/currency-select.tsx",
"resources/js/components/demographic-context.tsx",
"resources/js/components/demographic-evidence.tsx",
"resources/js/components/demographic-reading-summary.tsx",
"resources/js/components/dossier-navigation-context.tsx",
"resources/js/components/dossier-prefill.tsx",
"resources/js/components/evidence-details.tsx",
"resources/js/components/fiscal-l3-preparation.tsx",
"resources/js/components/fiscal-operation-picker.tsx",
"resources/js/components/investment-budget-range.tsx",
"resources/js/components/investment-initial-budget.tsx",
"resources/js/components/investment-operating-assistance.tsx",
"resources/js/components/investment-opportunity-choices.tsx",
"resources/js/components/investment-profile-editor.tsx",
"resources/js/components/investment-promoter.tsx",
"resources/js/components/investment-supply-aggregate.tsx",
"resources/js/components/investment-territory.tsx",
"resources/js/components/opportunity-search-filters.tsx",
"resources/js/components/portal-hero.tsx",
"resources/js/components/public-document-content.tsx",
"resources/js/components/published-opportunities-empty.tsx",
"resources/js/components/sector-content.tsx",
"resources/js/components/simulation-business-plan.tsx",
"resources/js/components/simulation-cashflow-planner.tsx",
"resources/js/components/simulation-financial-tools.tsx",
"resources/js/components/simulation-form.tsx",
"resources/js/components/simulation-irpc-annual.tsx",
"resources/js/components/simulation-irpc-normal.tsx",
"resources/js/components/simulation-ispc-quarter.tsx",
"resources/js/components/simulation-miners.tsx",
"resources/js/components/simulation-modelo-a.tsx",
"resources/js/components/simulation-monthly-iva.tsx",
"resources/js/components/simulation-remission-imports.tsx",
"resources/js/components/simulation-returnees.tsx",
"resources/js/components/territorial-source-evidence.tsx",
"resources/js/hooks/use-interest-draft.ts",
"resources/js/lib/catalogue-choice.ts",
"resources/js/lib/catalogue-context.ts",
"resources/js/lib/company-preparation.ts",
"resources/js/lib/demographic-reading.ts",
"resources/js/lib/financial-calculators.ts",
"resources/js/lib/investment-operating-assistance.ts",
"resources/js/lib/investor-context.ts",
"resources/js/lib/investor-dossier-report.ts",
"resources/js/lib/investor-path.ts",
"resources/js/lib/monthly-cashflow.ts",
"resources/js/lib/monthly-iva.ts",
"resources/js/lib/navigation-focus.ts",
"resources/js/lib/planning-finance-context.ts",
"resources/js/lib/preparation-intent.ts",
"resources/js/lib/project-dossier-report.ts",
"resources/js/lib/simulation-api.ts",
"resources/js/lib/simulation-form.ts",
"resources/js/pages/funding/index.tsx",
"resources/js/pages/investment/catalogue.tsx",
"resources/js/pages/investment/company-comparison.tsx",
"resources/js/pages/investment/company-preparation.tsx",
"resources/js/pages/investment/demography.tsx",
"resources/js/pages/investment/profile.tsx",
"resources/js/pages/investor-path/context-start.tsx",
"resources/js/pages/investor-path/context.tsx",
"resources/js/pages/investor-path/show.tsx",
"resources/js/pages/portal/search.tsx",
"resources/js/pages/portfolio/show.tsx",
"resources/js/pages/structures/editor.tsx",
"routes/investor-path.php",
"tests/Feature/FundingCriteriaCoherenceTest.php",
"tests/Feature/InvestorIntentContinuityTest.php",
"tests/Feature/InvestorPromoterContinuityTest.php",
"tests/Fixtures/demography/barue-2026.json",
"tests/frontend/catalogue-context.test.mjs",
"tests/frontend/company-preparation.test.mjs",
"tests/frontend/demographic-reading.test.mjs",
"tests/frontend/financial-calculators.test.mjs",
"tests/frontend/financial-tools-source.test.mjs",
"tests/frontend/fiscal-operation-picker.test.mjs",
"tests/frontend/funding-journey-coherence.test.mjs",
"tests/frontend/helpers/investment-profile-editor.mjs",
"tests/frontend/interest-draft-storage.test.mjs",
"tests/frontend/investment-assistance-controls.test.mjs",
"tests/frontend/investment-assisted-journey.test.mjs",
"tests/frontend/investment-profile-editor.test.mjs",
"tests/frontend/investor-dossier-report.test.mjs",
"tests/frontend/navigation-context-integration.test.mjs",
"tests/frontend/navigation-focus.test.mjs",
"tests/frontend/planning-finance-context.test.mjs",
"tests/frontend/preparation-intent.test.mjs",
"tests/frontend/public-information.test.mjs",
"tests/frontend/simulation-form.test.mjs"
],
"migration_changes": [],
"protected_offers_files": {
"app/Http/Controllers/OfferPublicCatalogueController.php": "04034126a52660e10a6e64207a3aa4fb09a9b03aee045383b240d434477f03ca",
"app/Http/Controllers/PrivateOfferOperationsController.php": "15da904c466be9bb38fa9a673beff55816026caf72569bc4289d399c77f10cce",
"app/Http/Controllers/PrivateOfferPreparationController.php": "32415ac61a948a0bd53f440c728b5e99522017d765910101eb1a7df1d8b6d0d7",
"app/Services/OfferAvailability.php": "72f975c8c01c7a3ccd3cc00d372210b3546dc6bbae1c34d91514b341a7968adf",
"app/Services/OfferPublicCatalogue.php": "7eee7ab9864cc29c2de90b071ff2c3bf3cac093d3adde9e1937ded324b2223c6",
"app/Services/OpportunityResources.php": "e8bcd6f4f5b994b466005aace3601fcfc642550d6ebda227c6de7a498f95f1b5",
"app/Services/PrivateOfferConfidentiality.php": "17e02925c3cfe26a4853db272b6d3c2acd1095efac826371a517a603eaa429ca",
"app/Services/PrivateOfferDetails.php": "1253c5d3cb9e3a2ae422f813afc4eb5ee8ed3859b07e15a3b32b678d3a97a015",
"app/Services/PrivateOfferOperations.php": "1b6032e96712f9d19befe76029a47464c3bb52e4f018d858dcc6f8e605cf0cdc",
"app/Services/PrivateOfferPreparation.php": "442d7aa1093001630bdfc581fde3b9f16e4b5b76a4e8d028ba4b3da41b1cfe4d",
"resources/js/components/interactive-investment-map.tsx": "3a59970e926736a2dfc11f962130f38cc9602a07d994a49fa2fc301a918d8c7d",
"resources/js/components/offer-confidential-access.tsx": "5723e5e8eda7500fe24670e7676749a4f36ad294bd284224cf7b6919675bddc1",
"resources/js/components/offer-editorial-review.tsx": "c519fb77848d8760ea88556184201fbaef27bbf7f9b00adefcff04de191ba81e",
"resources/js/components/offer-navigation.tsx": "4676f4859f7d378ebca988bc5436629f97c0004abe32af6db8a6b40789b48874",
"resources/js/components/offer-operations.tsx": "ce4a32b422c7a8e33dade2002b9565d53b4294c44f051ab06022fa6fcfc5ee4e",
"resources/js/components/offer-particulars.tsx": "ef78a2a493705b6f4828addd0227fc7e91f511a48b2c7b12fbbe35c610fe5de0",
"resources/js/components/offer-private-resources.tsx": "cb2070aeb4fd8ebe4eac28b41501223d2a87a2b1213538ab66c9520ab244d9c9",
"resources/js/components/opportunity-canonical-fields.tsx": "70bba90799e6057024f09fc32b85282c46002b6c8274b970fc80a48a79a20bf5",
"resources/js/lib/offer-editorial.ts": "dc70169aaa746b9ae8063b0312a6601d0347d2927a7c0f4bfc58cfc1f31aae99",
"resources/js/lib/offer-navigation.ts": "94ec55b7bbbc800f57fd10a9b51a49398c6cdd23fa0df635ca20e949426aef13",
"resources/js/lib/portal-navigation.ts": "4fb7cbf0a508cf0964e7ea0dd2023bc00202236bf048f228822d7de6e1f44534",
"resources/js/pages/offers/catalogue.tsx": "a17dd461a9a0e15bf1d8d182f5b0ef5687b9b0c8237ad2d4082701b08ad3bb47",
"resources/js/pages/offers/confidentiality.tsx": "b31d2cd72b55eaa9d04a3e7ff03fcb0d4a18696c403f89f5b791f9dc050c4d68",
"resources/js/pages/offers/preparation-index.tsx": "1cdfe52e4ed8311f52e24caab32d83dc7611213e5b9d3cc477b61c6e6c996276",
"resources/js/pages/offers/preparation.tsx": "7503946ba28e71ff48e6fd955b1ed587494678df433b6a13492dd026b74ce1de",
"resources/js/pages/offers/public-detail.tsx": "387c8a9a0013920bf74c85853362671d76131849508a9b86fc2391622cb0c647",
"resources/js/pages/offers/report.tsx": "91f9e32e88c82b1af984226ae81ede188fddaff395743905b8ab8ce483a8f859",
"routes/web.php": "50dbec32d17dbbd334298eb0bf77585b15d98781c9368fcb3f398642b83b2168"
},
"protected_offers_identical": true,
"offers_excluded_reason": "Etapa B: criterios 5/6 comprovados HTTP; criterio 7 visual/mobile ainda NAO VERIFICADO. Aceitacao anterior 4/7 preservada.",
"shared_dependencies_review": {
"investment-territory": "Defaults unchanged: four levels and map enabled; optional funding-only props.",
"opportunity-search-filters": "Ofertas imports unchanged modalityLabels only.",
"demographic-context/evidence,territorial-source-evidence,dossier-navigation-context,investor-context": "Shared transversal presentation/context; no private Ofertas contract or permission changed."
},
"verification": {
"frontend_tests": 142,
"frontend_failures": 0,
"wayfinder": "passed",
"build": "passed",
"types": "passed",
"accepted_transversal_steps": 15
},
"migrations": 0,
"permissions_changed": false,
"operational_data_changed": false
}
@@ -71,12 +71,14 @@ export function AcquisitionClosure({
);
return (
<details className="rounded border p-3">
<summary>L3-A/B — crédito,fecho e pagamentos por período</summary>
<summary>
IVA — crédito, apuramento e valores documentados por período
</summary>
<p>
Reutiliza resultados congelados de L2. Crédito anterior,saldo
calculado,pagamento registado e transporte são distintos.
Identifique o âmbito fiscal da entidade,sem consolidar
oportunidades implicitamente.
Reutiliza os resultados guardados das aquisições. Crédito
anterior, saldo calculado,pagamento registado e transporte são
distintos. Identifique o âmbito fiscal da entidade,sem
consolidar oportunidades implicitamente.
</p>
<p>
Primeira declaração em prazo,2026/MZN e reporte integral ou
@@ -0,0 +1,70 @@
import { useId, useState } from 'react';
import TerritorySearchSelect from './territory-search-select';
import { matchesChoice } from '@/lib/catalogue-choice';
export type CatalogueChoice = {
value: string;
label: string;
keywords?: string[];
};
export default function CatalogueChoiceSelect({
options,
value,
onChange,
label,
id,
disabled = false,
required = false,
error,
className,
}: {
options: CatalogueChoice[];
value: string;
onChange: (value: string) => void;
label: string;
id?: string;
disabled?: boolean;
required?: boolean;
error?: string;
className?: string;
}) {
const generated = useId();
const [search, setSearch] = useState('');
const selected = options.find((o) => o.value === value);
return (
<TerritorySearchSelect
id={id ?? generated}
label={label}
search={search}
onSearch={setSearch}
selected={
value
? {
id: value,
name:
selected?.label ??
`${value} — valor anterior; confirme a selecção`,
}
: undefined
}
options={options
.filter((o) =>
matchesChoice(search, [
o.value,
o.label,
...(o.keywords ?? []),
]),
)
.map((o) => ({ id: o.value, name: o.label }))}
onChange={(o) => onChange(o?.id ?? '')}
disabled={disabled}
required={required}
error={error}
className={
className ??
'bg-background text-foreground w-full rounded border p-2'
}
placeholder={`Seleccionar ou pesquisar ${label.toLowerCase()}`}
/>
);
}
@@ -21,7 +21,8 @@ export default function CatalogueNavigation({
dimensionUrl
)
return dimensionUrl(u.searchParams.get('dimension')!);
if (context) u.searchParams.set('context', context);
if (context && ['/oportunidades', '/educacao'].includes(u.pathname))
u.searchParams.set('context', context);
return searchTerritoryHref(u.pathname + u.search, url);
};
const sections = {
@@ -1,5 +1,6 @@
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
import EvidenceDetails from './evidence-details';
export type ApprovedCompetitiveness = {
draft: CompetitivenessDraft;
@@ -27,9 +28,13 @@ export default function ClusterCompetitivenessPublic({
Competitividade — cinco forças de Porter
</h2>
<p>
{analysis.draft.sector} · {analysis.draft.region} · Versão
aprovada {analysis.version} · {analysis.at}
{analysis.draft.sector} · {analysis.draft.region}
</p>
<EvidenceDetails title="Versão e data da análise">
<p>
Versão aprovada {analysis.version} · {analysis.at}
</p>
</EvidenceDetails>
<p>
Apreciação qualitativa fundamentada nas fontes e períodos
indicados. Não representa garantia de procura ou viabilidade.
@@ -55,14 +60,18 @@ export default function ClusterCompetitivenessPublic({
'Oportunidade e fundamento',
],
] as const
).map(([field, label]) => (
<div key={field}>
<dt className="font-semibold">{label}</dt>
<dd className="whitespace-pre-wrap">
{row[field]}
</dd>
</div>
))}
)
.filter(([field]) => row[field]?.trim())
.map(([field, label]) => (
<div key={field}>
<dt className="font-semibold">
{label}
</dt>
<dd className="whitespace-pre-wrap">
{row[field]}
</dd>
</div>
))}
</dl>
</details>
);
+15 -75
View File
@@ -1,7 +1,5 @@
import { useId, useState } from 'react';
import CatalogueChoiceSelect from './catalogue-choice-select';
export type Country = { code: string; name: string; name_en: string };
export default function CountrySelect({
countries,
value,
@@ -10,7 +8,6 @@ export default function CountrySelect({
disabled = false,
label = 'País',
required = false,
allowLegacyValue = false,
error,
}: {
countries: Country[];
@@ -23,77 +20,20 @@ export default function CountrySelect({
allowLegacyValue?: boolean;
error?: string;
}) {
const generatedId = useId();
const [search, setSearch] = useState('');
const matches = countries.filter(
(country) =>
country.code === value ||
`${country.name} ${country.name_en} ${country.code}`
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.includes(
search
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase(),
),
);
return (
<div className="space-y-2">
<input
disabled={disabled}
className="w-full rounded border p-2"
aria-label={`Pesquisar ${label.toLowerCase()}`}
maxLength={100}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Pesquisar nome ou código do país"
/>
{search.trim() && (
<p role="status" aria-live="polite">
Países:{' '}
{matches.length
? `${matches.length} opções disponíveis`
: 'Nenhuma opção encontrada'}
</p>
)}
<select
disabled={disabled}
id={id ?? generatedId}
aria-label={label}
aria-invalid={!!error}
aria-describedby={
error ? (id ?? generatedId) + '-error' : undefined
}
required={required}
className="w-full rounded border p-2"
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value="">Não informado</option>
{allowLegacyValue &&
value &&
!countries.some((country) => country.code === value) && (
<option value={value}>
{value} (valor anterior conservado)
</option>
)}
{matches.map((country) => (
<option key={country.code} value={country.code}>
{country.name} ({country.code})
</option>
))}
</select>
{error && (
<p
id={(id ?? generatedId) + '-error'}
role="alert"
className="text-red-700"
>
{error}
</p>
)}
</div>
<CatalogueChoiceSelect
options={countries.map((c) => ({
value: c.code,
label: `${c.name} — ${c.code}`,
keywords: [c.name_en, c.code],
}))}
value={value}
onChange={onChange}
id={id}
label={label}
disabled={disabled}
required={required}
error={error}
/>
);
}
@@ -0,0 +1,24 @@
import CatalogueChoiceSelect from './catalogue-choice-select';
import { currencyChoices } from '@/lib/catalogue-choice';
export default function CurrencySelect({
codes,
...props
}: {
codes?: readonly string[];
value: string;
onChange: (value: string) => void;
label?: string;
id?: string;
disabled?: boolean;
required?: boolean;
error?: string;
className?: string;
}) {
return (
<CatalogueChoiceSelect
{...props}
label={props.label ?? 'Moeda'}
options={currencyChoices(codes)}
/>
);
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import DemographicEvidence, {
type DemographicAnalysis,
} from './demographic-evidence';
@@ -8,10 +8,17 @@ export default function DemographicContext({
}: {
territoryId?: string | null;
}) {
if (!territoryId) return null;
// A change of territory resets this consultation, never the investor's dossier.
return <TerritoryDemography key={territoryId} territoryId={territoryId} />;
}
function TerritoryDemography({ territoryId }: { territoryId: string }) {
const [evidence, setEvidence] = useState<DemographicAnalysis | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
if (!territoryId) return null;
const pending = useRef<AbortController | null>(null);
useEffect(() => () => pending.current?.abort(), []);
return (
<details className="rounded border p-4">
<summary>Demografia na análise de viabilidade</summary>
@@ -26,20 +33,35 @@ export default function DemographicContext({
onClick={async () => {
setBusy(true);
setMessage('');
setEvidence(null);
pending.current?.abort();
const controller = new AbortController();
pending.current = controller;
try {
const response = await fetch(
`/api/v1/investment/demography?territories[]=${encodeURIComponent(territoryId)}`,
{ headers: { Accept: 'application/json' } },
`/api/v1/investment/demography?investment_context=1&territories[]=${encodeURIComponent(territoryId)}`,
{
headers: { Accept: 'application/json' },
signal: controller.signal,
},
);
if (!response.ok)
throw new Error('Consulta indisponível.');
setEvidence(await response.json());
const result = await response.json();
if (!controller.signal.aborted) {
setEvidence(result);
if (result.broader_context)
setMessage(
'Contexto administrativo mais amplo. Estes indicadores não representam a população da localidade escolhida.',
);
}
} catch {
if (controller.signal.aborted) return;
setMessage(
'Não foi possível consultar a evidência demográfica.',
);
} finally {
setBusy(false);
if (!controller.signal.aborted) setBusy(false);
}
}}
>
+194 -89
View File
@@ -1,4 +1,8 @@
import EvidenceDetails from './evidence-details';
import DemographicReadingSummary from './demographic-reading-summary';
export type DemographicAnalysis = {
sector?: string;
state: string;
start_year: number;
end_year: number;
@@ -59,6 +63,24 @@ const indicators: Record<string, string> = {
fertility_rate: 'Fecundidade',
population_growth_rate: 'Crescimento populacional',
};
type Value =
DemographicAnalysis['territories'][number]['series'][number]['values'][number];
export function demographicHighlights(values: Value[]): Value[] {
const totals = values.filter(
(v) => v.age_group === 'all' && v.residence === 'all',
);
// Never add age groups, choose between sources or mix observed/projected values.
return totals.filter(
(v) =>
!totals.some(
(other) =>
other.indicator === v.indicator &&
other.unit === v.unit &&
other.data_kind === v.data_kind &&
other.year > v.year,
),
);
}
export default function DemographicEvidence({
evidence,
}: {
@@ -66,8 +88,17 @@ export default function DemographicEvidence({
}) {
return (
<section className="space-y-4" aria-label="Evidência demográfica">
<p className="text-sm">
Os indicadores demográficos não demonstram procura ou
rentabilidade. Cruze-os com oferta existente, acessos, custos e
procura documentada.
</p>
{evidence.state !== 'available' && (
<p>Sem séries demográficas aprovadas para esta selecção.</p>
<p>
Sem indicadores disponíveis para esta selecção. Isto não
significa população inexistente nem ausência de potencial
económico.
</p>
)}
{evidence.territories.map((t) => (
<article
@@ -85,101 +116,175 @@ export default function DemographicEvidence({
<p>Sem dados no período escolhido.</p>
)}
{t.series.map((s) => (
<details key={s.import_id} open={t.series.length === 1}>
<summary>
{s.source} · {s.version} · {s.file_name}
</summary>
<p className="text-xs break-all">
SHA256: {s.sha256}
</p>
<section key={s.import_id} className="space-y-3">
<h3 className="font-semibold">{s.source}</h3>
<DemographicReadingSummary
series={s}
evidence={evidence}
/>
{demographicHighlights(s.values).some(
(v) => v.indicator !== 'population',
) && (
<>
<h4 className="font-semibold">
Outros indicadores mais recentes do
período
</h4>
<dl className="grid gap-3 sm:grid-cols-2">
{demographicHighlights(s.values)
.filter(
(v) =>
v.indicator !==
'population',
)
.map((v) => (
<div
className="rounded border p-3"
key={`${v.sheet}:${v.cell}`}
>
<dt>
{indicators[
v.indicator
] ?? v.indicator}{' '}
· {v.year}
</dt>
<dd className="text-lg font-semibold">
{v.original_value}{' '}
{v.unit ===
'unspecified'
? '(unidade não indicada)'
: v.unit}
</dd>
<dd className="text-sm">
{v.data_kind ===
'projected'
? 'Projecção'
: 'Tipo estatístico não especificado'}
</dd>
{v.validation_status !==
'valid' && (
<dd className="text-sm">
Valor com
advertência na
fonte; consulte o
detalhe antes de o
utilizar.
</dd>
)}
</div>
))}
</dl>
</>
)}
{s.missing_years.length > 0 && (
<p>
Anos sem dados: {s.missing_years.join(', ')}
. Não foram preenchidos por estimativa.
</p>
)}
{Object.keys(s.issue_counts).length > 0 && (
<details>
<summary>Advertências da série</summary>
<pre className="overflow-auto text-xs">
{JSON.stringify(
s.issue_counts,
null,
2,
)}
</pre>
</details>
{(Object.keys(s.issue_counts).length > 0 ||
s.values.some(
(v) => v.validation_status !== 'valid',
)) && (
<p>
Esta série contém advertências. Consulte-as
antes de comparar ou utilizar os valores.
</p>
)}
<div className="max-h-96 overflow-auto">
<table className="w-full text-left text-sm">
<caption className="text-left">
Valores originais por ano e dimensão —
versões apresentadas separadamente
</caption>
<thead>
<tr>
{[
'Ano',
'Indicador',
'Idade',
'Residência',
'Valor',
'Unidade',
'Tipo',
'Referência',
].map((h) => (
<th className="p-2" key={h}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{s.values.map((v) => (
<tr
key={`${v.sheet}:${v.cell}`}
className="border-t"
>
<td className="p-2">
{v.year}
</td>
<td>
{indicators[v.indicator] ??
v.indicator}
</td>
<td>
{v.age_group === 'all'
? 'Todas'
: v.age_group}
</td>
<td>
{
{
all: 'Total',
urban: 'Urbana',
rural: 'Rural',
}[v.residence]
}
</td>
<td>{v.original_value}</td>
<td>
{v.unit === 'unspecified'
? 'Não indicada na fonte'
: v.unit}
</td>
<td>
{v.data_kind === 'projected'
? 'Projecção'
: 'Não especificado'}
</td>
<td>
{v.sheet}!{v.cell}
</td>
<EvidenceDetails title="Valores por dimensão, fonte e advertências">
<p>
{s.version} · {s.file_name}
</p>
<p className="text-xs break-all">
SHA256: {s.sha256}
</p>
{Object.keys(s.issue_counts).length > 0 && (
<details>
<summary>Advertências da série</summary>
<pre className="overflow-auto text-xs">
{JSON.stringify(
s.issue_counts,
null,
2,
)}
</pre>
</details>
)}
<div className="max-h-96 overflow-auto">
<table className="w-full min-w-[44rem] text-left text-sm whitespace-nowrap">
<caption className="text-left">
Valores originais por ano e dimensão
— versões apresentadas separadamente
</caption>
<thead>
<tr>
{[
'Ano',
'Indicador',
'Idade',
'Residência',
'Valor',
'Unidade',
'Tipo',
'Referência',
].map((h) => (
<th className="p-2" key={h}>
{h}
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
</details>
</thead>
<tbody>
{s.values.map((v) => (
<tr
key={`${v.sheet}:${v.cell}`}
className="border-t"
>
<td className="p-2">
{v.year}
</td>
<td>
{indicators[
v.indicator
] ?? v.indicator}
</td>
<td>
{v.age_group === 'all'
? 'Todas'
: v.age_group}
</td>
<td>
{
{
all: 'Total',
urban: 'Urbana',
rural: 'Rural',
}[v.residence]
}
</td>
<td>{v.original_value}</td>
<td>
{v.unit ===
'unspecified'
? 'Não indicada na fonte'
: v.unit}
</td>
<td>
{v.data_kind ===
'projected'
? 'Projecção'
: 'Não especificado'}
</td>
<td>
{v.sheet}!{v.cell}
</td>
</tr>
))}
</tbody>
</table>
</div>
</EvidenceDetails>
</section>
))}
</article>
))}
@@ -0,0 +1,159 @@
import type { DemographicAnalysis } from './demographic-evidence';
import {
demographicReading,
populationLabel,
percentageLabel,
} from '@/lib/demographic-reading';
export default function DemographicReadingSummary({
series,
evidence,
}: {
series: DemographicAnalysis['territories'][number]['series'][number];
evidence: DemographicAnalysis;
}) {
const reading = demographicReading(
series,
evidence.start_year,
evidence.end_year,
);
const current = reading.last;
const sector = evidence.sector ?? 'general';
return (
<section
className="space-y-4 rounded-lg bg-slate-50 p-4"
aria-label="Leitura para o investimento"
>
<h3 className="font-semibold">
Retrato da população · {evidence.end_year}
</h3>
<p className="text-sm">
{series.source} ·{' '}
{current.kind === 'projected'
? 'Projecção'
: 'Tipo de dado não especificado'}
. Valores de apresentação arredondados; os originais permanecem
disponíveis abaixo.
</p>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
[
'População total',
populationLabel(current.total),
'pessoas',
],
[
'População rural',
populationLabel(current.rural),
percentageLabel(current.ruralShare) + ' do total',
],
[
'Menores de 15 anos',
populationLabel(current.under15),
percentageLabel(current.under15Share) + ' do total',
],
[
'População de 15 a 64 anos',
populationLabel(current.age15to64),
percentageLabel(current.age15to64Share) + ' do total',
],
].map(([label, value, note]) => (
<div key={label} className="rounded border bg-white p-3">
<dt className="text-sm">{label}</dt>
<dd className="text-2xl font-semibold">{value}</dd>
<dd className="text-sm text-slate-600">{note}</dd>
</div>
))}
</dl>
{evidence.start_year === evidence.end_year ? (
<p>
Está a consultar um único ano. Escolha um ano final
posterior para conhecer a evolução projectada.
</p>
) : (
<div>
<h4 className="font-semibold">
Evolução entre {evidence.start_year} e{' '}
{evidence.end_year}
</h4>
{reading.delta === null ? (
<p>
Faltam totais comparáveis nos anos extremos; não foi
calculado crescimento.
</p>
) : (
<p>
{populationLabel(reading.first.total)} →{' '}
{populationLabel(reading.last.total)} pessoas.
Variação no período:{' '}
{populationLabel(reading.delta)} pessoas (
{percentageLabel(reading.growth)}). Não é uma taxa
anual.
</p>
)}
<details>
<summary>Série anual de população</summary>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>
<th>Ano</th>
<th>Total</th>
<th>Urbana</th>
<th>Rural</th>
</tr>
</thead>
<tbody>
{reading.years.map((y) => (
<tr key={y.year}>
<td>{y.year}</td>
<td>{populationLabel(y.total)}</td>
<td>{populationLabel(y.urban)}</td>
<td>{populationLabel(y.rural)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
)}
<div className="space-y-2">
<h4 className="font-semibold">
Como usar estes dados na ideia de investimento
</h4>
{current.ruralShare !== null && (
<p>
{percentageLabel(current.ruralShare)} da população está
em áreas rurais. Para avaliar distribuição, comércio ou
serviços, confronte essa dispersão com acessos,
distâncias, custos de entrega e oferta existente.
</p>
)}
{current.under15Share !== null &&
['general', 'education', 'health'].includes(sector) && (
<p>
Os menores de 15 anos representam{' '}
{percentageLabel(current.under15Share)} do total. É
uma base para estudar serviços educativos e de
saúde, cruzando população-alvo, capacidade
instalada, acessibilidade e rendimento das famílias.
</p>
)}
{current.age15to64 !== null && (
<p>
As {populationLabel(current.age15to64)} pessoas entre 15
e 64 anos ajudam a dimensionar a população nessa faixa
etária. Para planear contratações, faltam qualificações,
participação no mercado de trabalho e disponibilidade.
</p>
)}
<p>
Próximo passo: comparar estes indicadores com empresas e
serviços existentes, infra-estruturas, preços e procura
documentada. Só essa comparação permite fundamentar uma
lacuna de mercado.
</p>
</div>
</section>
);
}
@@ -7,6 +7,10 @@ import {
type ReactNode,
} from 'react';
import { contextApi, type DossierContext } from '@/lib/investor-context';
import {
readNavigationFocus,
rememberNavigationFocus,
} from '@/lib/navigation-focus';
export type NavigationView = {
case: { id: string; title: string; version: number };
phases: Record<string, string>;
@@ -129,7 +133,15 @@ export function NavigationContextProvider({
new URL(url, 'https://context.invalid').searchParams.get(
'context',
);
let focus = pref.focus;
const localFocus = readNavigationFocus(
actor,
listing.contexts.map((c) => c.id),
);
// The server preference seeds a new tab, but another tab cannot
// replace this tab's deliberate dossier choice on navigation/reload.
let focus = localFocus
? { ...localFocus, version: pref.focus?.version ?? 0 }
: pref.focus;
const code = /^#S(0[1-9]|1[0-9])$/.test(window.location.hash)
? window.location.hash.slice(1)
: null;
@@ -157,6 +169,7 @@ export function NavigationContextProvider({
)
: null;
if (!stopped) {
rememberNavigationFocus(actor, focus);
setLoaded({ actor, cases: listing.contexts, focus, view });
if (pref.message) setError(pref.message);
}
@@ -182,16 +195,22 @@ export function NavigationContextProvider({
if (!actor || !current?.cases.some((c) => c.id === id)) return;
setBusy(true);
setError('');
void contextApi(
void contextApi<{ focus: Focus | null }>(
'/investimentos/contexto-atual',
{
case_id: id,
step_code: step,
version: current.focus?.version ?? 0,
},
'PUT',
)
.then(() => {
.then((pref) =>
contextApi<{ focus: Focus }>(
'/investimentos/contexto-atual',
{
case_id: id,
step_code: step,
version: pref.focus?.version ?? 0,
},
'PUT',
),
)
.then(({ focus }) => {
rememberNavigationFocus(actor, focus);
const target = `/investimentos/${id}${step ? '#' + step : ''}`;
if (url !== target) router.visit(target);
else setReload((x) => x + 1);
+1 -1
View File
@@ -32,7 +32,7 @@ const empty = (v: unknown) =>
v === '' ||
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
empty(v) ? '' : typeof v === 'string' ? v : JSON.stringify(v);
function savedOpportunity(values: Record<string, unknown>): string {
if (values.context_opportunity)
return inputText(values.context_opportunity);
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
/** Keep provenance available without making it the investor's primary content. */
export default function EvidenceDetails({
children,
title = 'Fontes e detalhes da informação',
}: {
children: ReactNode;
title?: string;
}) {
if (!children) return null;
return (
<details className="min-w-0 rounded-lg border border-slate-200 p-3 text-sm [overflow-wrap:anywhere]">
<summary className="cursor-pointer font-semibold focus-visible:outline-2 focus-visible:outline-offset-2">
{title}
</summary>
<div className="mt-3 min-w-0 space-y-3">{children}</div>
</details>
);
}
@@ -14,7 +14,7 @@ export function FiscalL3Preparation({
if (!forms.length) return null;
return (
<details className="rounded border p-3">
<summary>Preparação dos quadros L3 e formalidades</summary>
<summary>Correspondência documental e formalidades</summary>
<p>
Reutiliza entradas e resultados congelados. Os campos com
correspondência identificada ficam preparados para
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
export default function FiscalOperationPicker({
family,
selected,
operations,
onChange,
}: {
family: string;
selected: unknown;
operations: {
id: string;
label: string;
description: string;
render: () => ReactNode;
}[];
onChange: (selected: string[]) => void;
}) {
const saved = Array.isArray(selected)
? selected.filter((id): id is string => typeof id === 'string')
: [];
const chosen = operations.filter((operation) =>
saved.includes(`${family}:${operation.id}`),
);
return (
<section
className="space-y-4 rounded border p-4"
aria-label="Operações fiscais adicionais"
>
<h4 className="font-semibold">Operações que pretende analisar</h4>
<p>
Acrescente apenas as operações que prevê realizar. Se não sabe
se uma operação se aplica, consulte a orientação e conserve-a
por confirmar; o sector, por si só, não determina o
enquadramento.
</p>
<CatalogueChoiceSelect
label="Adicionar outra operação"
value=""
options={operations
.filter(
(operation) =>
!saved.includes(`${family}:${operation.id}`),
)
.map((operation) => ({
value: operation.id,
label: operation.label,
keywords: [operation.description],
}))}
onChange={(id) => {
if (operations.some((operation) => operation.id === id))
onChange([...saved, `${family}:${id}`]);
}}
/>
{!chosen.length && (
<p>
Nenhuma operação adicional escolhida. As preparações e os
resultados anteriormente guardados permanecem no rascunho e
no histórico.
</p>
)}
{chosen.map((operation) => (
<section key={operation.id} className="space-y-3 border-t pt-3">
<h5 className="font-semibold">{operation.label}</h5>
<p>{operation.description}</p>
<button
type="button"
className="underline"
onClick={() =>
onChange(
saved.filter(
(id) => id !== `${family}:${operation.id}`,
),
)
}
>
Retirar da análise actual, conservando os dados
guardados
</button>
{operation.render()}
</section>
))}
</section>
);
}
@@ -0,0 +1,63 @@
import CurrencySelect from './currency-select';
/** A planning interval is not the amount used in a financial calculation. */
export default function InvestmentBudgetRange({
values,
onChange,
}: {
values: Record<string, string>;
onChange?: (field: string, value: string) => void;
}) {
if (
!onChange &&
!values.investment_budget_min &&
!values.investment_budget_max
)
return null;
return (
<section className="grid gap-3 rounded border p-4">
<h3 className="font-semibold">Orçamento previsto</h3>
{onChange ? (
<>
<div className="grid gap-3 sm:grid-cols-2">
{(
[
'investment_budget_min',
'investment_budget_max',
] as const
).map((field, index) => (
<label key={field} className="grid gap-1">
{index === 0 ? 'Desde' : 'Até'}
<input
className="min-w-0 rounded border p-2"
type="number"
min="0"
step="any"
value={values[field] ?? ''}
onChange={(event) =>
onChange(field, event.target.value)
}
/>
</label>
))}
</div>
<CurrencySelect
label="Moeda do orçamento"
value={values.currency ?? ''}
onChange={(value) => onChange('currency', value)}
/>
</>
) : (
<p>
{values.investment_budget_min || 'Mínimo por indicar'} –{' '}
{values.investment_budget_max || 'Máximo por indicar'}{' '}
{values.currency}
</p>
)}
<p className="text-muted-foreground text-sm">
Este intervalo orienta a preparação. O valor de cada cenário de
investimento será indicado por si no simulador.
</p>
</section>
);
}
@@ -0,0 +1,159 @@
import { useState } from 'react';
import { sumFinancialAmounts } from '@/lib/financial-calculators';
type Row = { label: string; amount: string };
const initialRows = (): Row[] =>
[
'Instalações',
'Equipamentos',
'Preparação e arranque',
'Fundo de maneio inicial',
].map((label) => ({ label, amount: '' }));
export function restoreInitialBudget(snapshot: string): Row[] {
try {
const parsed = JSON.parse(snapshot);
if (
parsed.version === 1 &&
Array.isArray(parsed.rows) &&
parsed.rows.length <= 30 &&
parsed.rows.every(
(row: Row) =>
typeof row?.label === 'string' &&
row.label.length <= 120 &&
typeof row.amount === 'string' &&
row.amount.length <= 40,
)
)
return parsed.rows;
} catch {
/* Preserve the saved original; no invented amounts. */
}
return initialRows();
}
export default function InvestmentInitialBudget({
snapshot,
currency,
onChange,
}: {
snapshot: string;
currency: string;
onChange: (key: string, value: string) => void;
}) {
const [rows, setRows] = useState(() => restoreInitialBudget(snapshot));
const [message, setMessage] = useState('');
const save = (next: Row[]) => {
setRows(next);
setMessage('');
onChange(
'initial_budget_snapshot',
JSON.stringify({ version: 1, rows: next }),
);
};
return (
<section
className="space-y-3 rounded border p-4"
aria-label="Construir orçamento inicial"
>
<h3 className="font-semibold">
Ajuda para construir o investimento inicial
</h3>
<p>
Liste apenas os custos iniciais aplicáveis. O fundo de maneio
cobre o arranque; não repita aqui custos já incluídos noutra
parcela. Um valor desconhecido fica em branco, não é zero.
</p>
{rows.map((row, index) => (
<div key={index} className="grid gap-2 sm:grid-cols-2">
<label>
Parcela
<input
className="block w-full rounded border p-2"
maxLength={120}
value={row.label}
onChange={(e) =>
save(
rows.map((item, i) =>
i === index
? { ...item, label: e.target.value }
: item,
),
)
}
/>
</label>
<label>
Montante {currency}
<input
className="block w-full rounded border p-2"
type="number"
min="0"
step="0.01"
value={row.amount}
onChange={(e) =>
save(
rows.map((item, i) =>
i === index
? {
...item,
amount: e.target.value,
}
: item,
),
)
}
/>
</label>
<button
type="button"
className="text-left underline"
onClick={() => save(rows.filter((_, i) => i !== index))}
>
Retirar parcela não aplicável
</button>
</div>
))}
<button
type="button"
className="mr-3 rounded border px-3 py-2"
disabled={rows.length >= 30}
onClick={() => save([...rows, { label: '', amount: '' }])}
>
Acrescentar parcela
</button>
<button
type="button"
className="rounded border px-3 py-2"
onClick={() => {
try {
if (
!currency ||
!rows.length ||
rows.some(
(row) =>
!row.label.trim() || !row.amount.trim(),
)
)
throw new Error(
'Indique a moeda e as parcelas conhecidas; complete os montantes em falta antes de usar a soma.',
);
const total = sumFinancialAmounts(
rows.map((row) => row.amount),
);
onChange('total_investment', total);
setMessage(
`Soma adoptada por sua escolha: ${total} ${currency}. O intervalo orçamental original permanece separado.`,
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Confira as parcelas.',
);
}
}}
>
Usar a soma como investimento deste cenário
</button>
{message && <p role="status">{message}</p>}
</section>
);
}
@@ -0,0 +1,267 @@
import { useState } from 'react';
import {
estimateOperatingCosts,
estimateOperatingRevenue,
restoreOperatingAssumptions,
} from '@/lib/investment-operating-assistance';
export default function InvestmentOperatingAssistance({
snapshot,
currency,
onChange,
}: {
snapshot: string;
currency: string;
onChange: (key: string, value: string) => void;
}) {
const [data, setData] = useState(() =>
restoreOperatingAssumptions(snapshot),
);
const [message, setMessage] = useState('');
const save = (next: typeof data) => {
setData(next);
setMessage('');
onChange(
'planning_assumptions_snapshot',
JSON.stringify({ version: 1, data: next }),
);
};
const field = (
key: 'capacity' | 'price' | 'occupancy' | 'frequency',
label: string,
) => (
<label className="block">
{label}
<input
className="block w-full rounded border p-2"
type="number"
min="0"
step="0.01"
value={data[key]}
onChange={(e) => save({ ...data, [key]: e.target.value })}
/>
</label>
);
const adopt = (kind: 'revenue' | 'costs') => {
try {
if (!currency)
throw new Error(
'Seleccione a moeda do investimento antes de utilizar a estimativa.',
);
const value =
kind === 'revenue'
? estimateOperatingRevenue(data)
: estimateOperatingCosts(data);
onChange(
kind === 'revenue'
? 'annual_revenue'
: 'annual_operating_costs',
value,
);
setMessage(
`Estimativa anual de ${kind === 'revenue' ? 'receitas' : 'custos'} adoptada por sua escolha: ${value} ${currency}. Pode alterá-la nos campos acima.`,
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Confira os pressupostos.',
);
}
};
return (
<section
className="space-y-4 rounded border p-4"
aria-label="Ajuda para estimar receitas e custos"
>
<h3 className="font-semibold">
Não sei estimar receitas ou custos
</h3>
<p>
Escolha a actividade e introduza os pressupostos que conhece.
Não são previsões de procura nem valores recomendados. Nenhuma
estimativa substitui os seus valores até clicar em Usar.
</p>
<label className="block">
Modelo para esta estimativa
<select
className="block w-full rounded border p-2"
value={data.model}
onChange={(e) =>
save({
...data,
model: e.target.value as typeof data.model,
})
}
>
<option value="">Escolher</option>
<option value="hotel">Hotel — quartos e ocupação</option>
<option value="agriculture">
Agronegócio — produção e ciclos
</option>
<option value="other">
Outra actividade — unidades e frequência
</option>
</select>
</label>
{data.model && (
<>
<div className="grid gap-3 sm:grid-cols-2">
{field(
'capacity',
data.model === 'hotel'
? 'Quartos disponíveis'
: 'Unidades vendáveis por ciclo ou período',
)}
{field(
'price',
data.model === 'hotel'
? 'Preço por quarto/noite'
: 'Preço por unidade',
)}
{data.model === 'hotel' &&
field('occupancy', 'Ocupação prevista (%)')}
{field(
'frequency',
data.model === 'hotel'
? 'Dias de funcionamento previstos no ano'
: 'Ciclos ou períodos no ano',
)}
</div>
<p className="text-sm">
{data.model === 'hotel'
? 'Receita anual = quartos × dias de funcionamento × ocupação ÷ 100 × preço. Considere sazonalidade ao escolher a ocupação e os dias.'
: 'Receita anual = unidades vendáveis × preço × ciclos. Considere perdas e sazonalidade nas unidades e ciclos declarados.'}{' '}
A facturação não equivale a recebimentos; a próxima
etapa trata a tesouraria.
</p>
<label className="block">
Fundamento ou informação ainda a confirmar
<textarea
className="block w-full rounded border p-2"
maxLength={1000}
value={data.source}
onChange={(e) =>
save({ ...data, source: e.target.value })
}
/>
</label>
<button
type="button"
className="rounded border px-3 py-2"
onClick={() => adopt('revenue')}
>
Usar esta estimativa de receitas
</button>
<details className="rounded border p-3">
<summary>Categorias de custos anuais</summary>
<p>
{data.model === 'hotel'
? 'Considere pessoal, limpeza/lavandaria, energia, água, manutenção, consumíveis e distribuição.'
: data.model === 'agriculture'
? 'Considere insumos, pessoal, água/energia, armazenagem, transformação, transporte e perdas.'
: 'Considere pessoal, matérias-primas, serviços, instalações, energia, manutenção e distribuição.'}{' '}
Acrescente apenas categorias aplicáveis; a soma não
demonstra que todos os custos foram identificados.
</p>
{data.costs.map((row, index) => (
<div
className="my-3 grid gap-2 sm:grid-cols-2"
key={index}
>
<label>
Categoria
<input
className="block w-full rounded border p-2"
maxLength={120}
value={row.label}
onChange={(e) =>
save({
...data,
costs: data.costs.map(
(item, i) =>
i === index
? {
...item,
label: e
.target
.value,
}
: item,
),
})
}
/>
</label>
<label>
Estimativa anual
<input
className="block w-full rounded border p-2"
type="number"
min="0"
step="0.01"
value={row.annual}
onChange={(e) =>
save({
...data,
costs: data.costs.map(
(item, i) =>
i === index
? {
...item,
annual: e
.target
.value,
}
: item,
),
})
}
/>
</label>
<button
type="button"
className="text-left underline"
onClick={() =>
save({
...data,
costs: data.costs.filter(
(_, i) => i !== index,
),
})
}
>
Retirar esta categoria
</button>
</div>
))}
<button
type="button"
className="mr-3 rounded border px-3 py-2"
disabled={data.costs.length >= 30}
onClick={() =>
save({
...data,
costs: [
...data.costs,
{ label: '', annual: '' },
],
})
}
>
Acrescentar categoria
</button>
<button
type="button"
className="rounded border px-3 py-2"
onClick={() => adopt('costs')}
>
Usar esta estimativa de custos
</button>
</details>
</>
)}
{message && <p role="status">{message}</p>}
</section>
);
}
@@ -36,10 +36,16 @@ function ChoiceCard({
const [parkError, setParkError] = useState('');
const [parkLoading, setParkLoading] = useState(false);
const corridor =
corridors.find((r) => r.key === c.corridor_key) ||
corridors.find((r) => r.name === c.coverage);
c.corridor_key !== undefined
? corridors.find((r) => r.key === c.corridor_key)
: corridors.find((r) => r.name === c.coverage);
const path = c.territory_path ?? [];
function location(next: TerritoryOption[], key = corridor?.key) {
function location(
next: TerritoryOption[],
key: string | null | undefined = c.corridor_key !== undefined
? c.corridor_key
: corridor?.key,
) {
update({
corridor_key: key,
territory_path: next,
@@ -64,16 +70,8 @@ function ChoiceCard({
!corridor.provinces.some((p) => p.id === path[0].id)
) {
setNotice(
'A localização anterior foi retirada porque não pertence ao corredor escolhido. Escolha uma localização compatível.',
'Localizacao guardada conservada. A associacao ao corredor nao e compativel; altere-a ou retire-a antes de confirmar.',
);
location(corridor.provinces.length === 1 ? corridor.provinces : []);
} else if (!path.length && corridor.provinces.length === 1) {
setNotice(
'Província seleccionada automaticamente: ' +
corridor.provinces[0].name +
'.',
);
location(corridor.provinces);
}
}, [
corridor?.key,
@@ -140,33 +138,18 @@ function ChoiceCard({
const next = corridors.find(
(r) => r.key === e.target.value,
);
if (!next) return;
const compatible =
!path.length ||
next.provinces.some((p) => p.id === path[0].id);
const kept = compatible ? path : [];
location(
kept.length
? kept
: next.provinces.length === 1
? next.provinces
: [],
next.key,
);
update({
corridor_key: next?.key ?? null,
corridor_name: next?.name ?? null,
});
setNotice(
!path.length
? next.provinces.length === 1
? 'Província seleccionada automaticamente: ' +
next.provinces[0].name +
'.'
: 'Escolha uma das províncias deste corredor.'
: compatible
? 'A localização compatível foi conservada.'
: 'A província e os níveis territoriais incompatíveis foram retirados.',
'A localizacao foi conservada; a associacao e opcional e exige compatibilidade documentada.',
);
}}
>
<option value="">Seleccione um corredor</option>
<option value="">
Sem corredor associado (opcional)
</option>
{corridors.map((r) => (
<option key={r.key} value={r.key}>
{r.name}
@@ -381,7 +364,7 @@ export default function OpportunityChoices({
</h2>
<p>
{onChange
? 'Escolha o corredor e confirme onde pretende realizar o investimento.'
? 'Confirme a localização conhecida. Corredores e zonas são associações opcionais, quando documentadas.'
: 'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}
</p>
{choices.map((c, i) => (
@@ -1,5 +1,6 @@
import { useEffect, useId } from 'react';
import CountrySelect, { type Country } from './country-select';
import CatalogueChoiceSelect from './catalogue-choice-select';
import OrganizationCountryReuse from './organization-country-reuse';
import { Field, input, button } from '@/pages/investment/shared';
import {
@@ -18,6 +19,7 @@ export default function InvestmentProfileEditor({
reportDirty,
labels,
organizations = [],
legalForms = [],
}: {
record: ProfileRecord;
fields: string[];
@@ -28,6 +30,7 @@ export default function InvestmentProfileEditor({
reportDirty: (id: string, dirty: boolean) => void;
labels: Record<string, string>;
organizations?: OrganizationRecord[];
legalForms?: { value: string; label: string }[];
}) {
const isCompany = company || !!record.id;
const editor = useInvestmentProfileEditor(
@@ -113,6 +116,18 @@ export default function InvestmentProfileEditor({
editor.change(key, value)
}
/>
) : key === 'legal_form' ? (
<CatalogueChoiceSelect
id={fieldId}
label={labels[key]}
options={legalForms}
value={editor.draft[key] ?? ''}
disabled={disabled}
error={error}
onChange={(value) =>
editor.change(key, value)
}
/>
) : long ? (
<textarea
id={fieldId}
@@ -0,0 +1,214 @@
import { useRef, useState } from 'react';
import { Link, router } from '@inertiajs/react';
import CatalogueChoiceSelect from './catalogue-choice-select';
import { contextApi, type PromoterEntity } from '@/lib/investor-context';
import { createOperationKey } from '@/lib/operation-key';
type Options = {
version: number;
selected: PromoterEntity;
can_write: boolean;
companies: { id: string; name: string; version: number }[];
organizations: { id: string; name: string; version: number }[];
};
const valueOf = (entity?: PromoterEntity) =>
entity && ['company', 'organization'].includes(entity.kind)
? entity.kind + ':' + entity.id
: (entity?.kind ?? 'undetermined');
export default function InvestmentPromoter({
caseId,
selected,
canEdit,
}: {
caseId: string;
selected?: PromoterEntity;
canEdit: boolean;
}) {
const [options, setOptions] = useState<Options | null>(null);
const [choice, setChoice] = useState(valueOf(selected));
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [uncertain, setUncertain] = useState(false);
const command = useRef<Record<string, unknown> | null>(null);
const current = options?.selected ?? selected;
const url = `/investimentos/${caseId}/contexto/entidade`;
const recover = async () => {
setBusy(true);
setError('');
try {
setOptions(await contextApi<Options>(url));
command.current = null;
setUncertain(false);
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Não foi possível consultar a associação.',
);
} finally {
setBusy(false);
}
};
return (
<section
id="entidade-promotora"
className="space-y-3 rounded border p-4"
>
<h2 className="font-semibold">Entidade promotora</h2>
<p>
{current?.snapshot?.name ||
(current?.kind === 'being_formed'
? 'Entidade por constituir'
: 'Entidade por definir')}
</p>
<p>
{current?.kind === 'company' || current?.kind === 'organization'
? 'Cadastro associado no portal. A associação não comprova constituição legal nem altera a propriedade ou o acesso ao dossier.'
: 'Pode continuar a ideia e a viabilidade. Os actos que dependam da entidade serão identificados na respectiva etapa.'}
</p>
<nav className="flex flex-wrap gap-3">
{canEdit && (
<Link
className="underline"
href={`/simulador-fiscal?context=${caseId}`}
>
Continuar a viabilidade
</Link>
)}
{current?.kind === 'being_formed' && (
<Link
className="underline"
href={`/investimentos/${caseId}/preparar-empresa`}
>
Continuar a preparação da empresa
</Link>
)}
{current?.kind !== 'company' &&
current?.kind !== 'organization' && (
<Link
className="underline"
href={`/investimentos/${caseId}/forma-juridica`}
>
Comparar formas jurídicas, se precisar
</Link>
)}
</nav>
{canEdit && (
<details
onToggle={(event) => {
if (event.currentTarget.open && !options && !busy)
void recover();
}}
>
<summary>Associar ou alterar a entidade promotora</summary>
{options && (
<div className="mt-3 grid gap-3">
<CatalogueChoiceSelect
label="Entidade para este investimento"
value={choice}
disabled={
busy || uncertain || !options.can_write
}
options={[
{
value: 'undetermined',
label: 'Continuar com entidade por definir',
},
{
value: 'being_formed',
label: 'Preparar uma entidade por constituir',
},
...options.companies.map((company) => ({
value: 'company:' + company.id,
label:
company.name +
' — empresa declarada',
})),
...options.organizations.map(
(organization) => ({
value:
'organization:' +
organization.id,
label:
organization.name +
' — organização com acesso autorizado',
}),
),
]}
onChange={(value) => {
setChoice(value || 'undetermined');
command.current = null;
}}
/>
<Link
className="underline"
href={`/investidor/profile?dossier=${caseId}`}
>
Registar uma empresa existente no cadastro
habitual e regressar
</Link>
<button
type="button"
className="min-h-11 rounded border p-2"
disabled={
busy || uncertain || !options.can_write
}
onClick={async () => {
setBusy(true);
setError('');
const [kind, id] = choice.split(':');
const source = (
kind === 'company'
? options.companies
: options.organizations
).find((source) => source.id === id);
command.current ??= {
version: options.version,
operation_key: createOperationKey(),
kind,
...(id
? {
id,
source_version:
source?.version,
}
: {}),
};
try {
const result = await contextApi<{
continue: string;
}>(url, command.current);
command.current = null;
router.visit(result.continue);
} catch (error) {
setUncertain(true);
setError(
error instanceof Error
? error.message
: 'Não foi possível confirmar a associação.',
);
} finally {
setBusy(false);
}
}}
>
Guardar associação e continuar
</button>
</div>
)}
{error && <p role="alert">{error}</p>}
{(error || uncertain) && (
<button
type="button"
className="underline"
disabled={busy}
onClick={() => void recover()}
>
Recuperar associação guardada sem perder a escolha
</button>
)}
</details>
)}
</section>
);
}
@@ -0,0 +1,172 @@
import { useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { contextApi, type SupplyAggregate } from '@/lib/investor-context';
import { createOperationKey } from '@/lib/operation-key';
export default function InvestmentSupplyAggregate({
caseId,
version,
revision,
history,
disabled = false,
}: {
caseId: string;
version: number;
revision: number;
history: SupplyAggregate[];
disabled?: boolean;
}) {
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const command = useRef<{ version: number; operation_key: string } | null>(
null,
);
const latest = history.at(-1);
return (
<section
className="space-y-3 rounded border p-4"
aria-label="Oferta empresarial agregada"
>
<h2 className="font-semibold">
Actividades existentes na localização
</h2>
<p>
Consulte uma análise agregada no seu dossier. As listas, nomes e
contactos internos permanecem reservados.
</p>
<button
type="button"
className="min-h-11 rounded border px-3 py-2 underline"
disabled={disabled || busy}
onClick={async () => {
if (busy) return;
setBusy(true);
setMessage('');
command.current ??= {
version,
operation_key: createOperationKey(),
};
try {
await contextApi(
`/investimentos/${caseId}/contexto/oferta-agregada`,
command.current,
);
command.current = null;
setMessage('Análise agregada guardada no dossier.');
router.reload({
only: [
'case',
'supply_aggregates',
'supply_aggregate_enabled',
],
});
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível confirmar a consulta. A repetição conserva o mesmo comando.',
);
} finally {
setBusy(false);
}
}}
>
{busy
? 'A consultar…'
: command.current
? 'Repetir a mesma consulta'
: 'Consultar e guardar análise agregada'}
</button>
{message && <p role="status">{message}</p>}
{latest && (
<>
<h3 className="font-semibold">
{latest.scope.level === 'district'
? 'Contexto distrital'
: latest.scope.level === 'province'
? 'Contexto provincial'
: 'Contexto local'}{' '}
— {latest.scope.name}
</h3>
{latest.broader_context && (
<p>
Local escolhido: {latest.requested_territory.name}.
Os totais referem-se ao território mais amplo
indicado acima.
</p>
)}
<p>
{latest.entities} entidades identificadas nos registos
administrativos; {latest.repeated} observações repetidas
e {latest.uncertain} identificações incertas, separadas
dos totais.
</p>
{latest.context_revision !== revision && (
<p role="status">
O contexto mudou depois desta análise. Reveja-a; a
consulta anterior mantém-se no histórico.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full">
<caption className="text-left">
Grupos para investigar actividades similares e
complementares
</caption>
<thead>
<tr>
<th className="p-2 text-left">
Actividade
</th>
<th className="p-2">Entidades</th>
<th className="p-2">Período conhecido</th>
</tr>
</thead>
<tbody>
{latest.groups.map((group) => (
<tr key={group.code}>
<td className="p-2">{group.label}</td>
<td className="p-2 text-center">
{group.total}
</td>
<td className="p-2">
{group.first_year ??
'Por confirmar'}
{group.last_year &&
group.last_year !== group.first_year
? '–' + group.last_year
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!latest.groups.length && (
<p>
Não existem registos aplicáveis nesta consulta. A
oferta empresarial permanece por determinar; não
significa ausência de empresas.
</p>
)}
{latest.truncated && (
<p>
Apresentação limitada aos primeiros 100 grupos; não
representa cobertura completa.
</p>
)}
<p>{latest.interpretation}</p>
<p>{latest.limitations}</p>
<details>
<summary>Consulta e histórico</summary>
<p>
{history.length} análises preservadas. Última
consulta: {latest.recorded_at}. Cobertura
territorial e actividade actual por confirmar.
</p>
</details>
</>
)}
</section>
);
}
@@ -185,12 +185,16 @@ export function TerritoryFields({
endpoint = '/api/v1/investment/territory-options',
disabled = false,
corridor,
showMap = true,
levelCount = 4,
}: {
initial?: TerritoryOption[];
onChange?: (path: TerritoryOption[]) => void;
endpoint?: string;
disabled?: boolean;
corridor?: string | null;
showMap?: boolean;
levelCount?: 2 | 4;
}) {
const [path, setPath] = useState(initial);
useEffect(() => setPath(initial), [initial]);
@@ -203,41 +207,45 @@ export function TerritoryFields({
'admin_post_id',
'locality_id',
] as const
).map((field, index) => (
<div key={`${field}-${path[index - 1]?.id ?? ''}`}>
<input
type="hidden"
name={field}
value={path[index]?.id ?? ''}
/>
<TerritoryLevel
disabled={disabled}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint={endpoint}
corridor={corridor}
onChange={(value) => {
const next = replaceTerritoryPath(
path,
index,
value,
);
setPath(next);
onChange?.(next);
}}
/>
</div>
))}
<TerritoryGeometryPreview
territoryId={path.at(-1)?.id}
management={endpoint.startsWith('/management/')}
endpoint={
endpoint.startsWith('/minhas-oportunidades/')
? '/minhas-oportunidades/territory-geometries'
: undefined
}
/>
)
.slice(0, levelCount)
.map((field, index) => (
<div key={`${field}-${path[index - 1]?.id ?? ''}`}>
<input
type="hidden"
name={field}
value={path[index]?.id ?? ''}
/>
<TerritoryLevel
disabled={disabled}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint={endpoint}
corridor={corridor}
onChange={(value) => {
const next = replaceTerritoryPath(
path,
index,
value,
);
setPath(next);
onChange?.(next);
}}
/>
</div>
))}
{showMap && (
<TerritoryGeometryPreview
territoryId={path.at(-1)?.id}
management={endpoint.startsWith('/management/')}
endpoint={
endpoint.startsWith('/minhas-oportunidades/')
? '/minhas-oportunidades/territory-geometries'
: undefined
}
/>
)}
</div>
);
}
@@ -2,6 +2,8 @@ import { Field, input, button, groupLabels } from '@/pages/investment/shared';
import type { References } from '@/pages/investment/shared';
import { TerritoryLevel, type TerritoryOption } from './investment-territory';
import CatalogueFilterGroup from './catalogue-filter-group';
import { createInvestmentHref } from '@/lib/preparation-intent';
import CurrencySelect from './currency-select';
import { useEffect, useState } from 'react';
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react';
@@ -96,56 +98,69 @@ export default function OpportunitySearchFilters({
key: string,
choices: { value: string; label: string }[],
hint?: string,
) => (
<Field key={key} label={labels[key]}>
<select
className={input}
aria-label={labels[key]}
aria-describedby={
specialisedEmpty[key] ? `filter-help-${key}` : undefined
}
disabled={
Boolean(specialisedEmpty[key]) &&
choices.length === 0 &&
!data[key]
}
) =>
key === 'currency' ? (
<CurrencySelect
key={key}
label={labels[key]}
codes={choices.map((choice) => choice.value).filter(Boolean)}
value={String(data[key] ?? '')}
onChange={(e) =>
change({
[key]: e.target.value,
...(key === 'sector'
? { subsector_id: '', chain_id: '' }
: {}),
})
}
>
<option value="">Todos</option>
{data[key] &&
!choices.some((o) => o.value === String(data[key])) && (
<option value={String(data[key])}>
Selecção indisponível neste contexto — remova o
filtro
onChange={(value) => change({ [key]: value })}
error={errors[key]}
/>
) : (
<Field key={key} label={labels[key]}>
<select
className={input}
aria-label={labels[key]}
aria-describedby={
specialisedEmpty[key] ? `filter-help-${key}` : undefined
}
disabled={
Boolean(specialisedEmpty[key]) &&
choices.length === 0 &&
!data[key]
}
value={String(data[key] ?? '')}
onChange={(e) =>
change({
[key]: e.target.value,
...(key === 'sector'
? { subsector_id: '', chain_id: '' }
: {}),
})
}
>
<option value="">Todos</option>
{data[key] &&
!choices.some((o) => o.value === String(data[key])) && (
<option value={String(data[key])}>
Selecção indisponível neste contexto — remova o
filtro
</option>
)}
{choices.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
)}
{choices.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{specialisedEmpty[key] && (
<p id={`filter-help-${key}`} className="text-sm text-slate-600">
{choices.length === 0
? specialisedEmpty[key]
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
{key === 'subsector_id' &&
choices.length > 0 &&
' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
</p>
)}
{hint && <p className="text-sm text-slate-600">{hint}</p>}
</Field>
);
))}
</select>
{specialisedEmpty[key] && (
<p
id={`filter-help-${key}`}
className="text-sm text-slate-600"
>
{choices.length === 0
? specialisedEmpty[key]
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
{key === 'subsector_id' &&
choices.length > 0 &&
' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
</p>
)}
{hint && <p className="text-sm text-slate-600">{hint}</p>}
</Field>
);
const zoneChoices = (category: string) =>
(references.zones ?? [])
.filter((z) => z.category === category)
@@ -727,6 +742,15 @@ export default function OpportunitySearchFilters({
</p>
))}
<div className="catalogue-filter-submit grid gap-3">
{mode === 'opportunities' && (
<a
className="rounded border p-3 underline"
href={createInvestmentHref(data, path)}
>
Criar investimento com estas escolhas, sem anúncio
associado
</a>
)}
{select('sort', [
{ value: 'recent', label: 'Mais recentes' },
{ value: 'title', label: 'Título' },
+2 -2
View File
@@ -290,13 +290,13 @@ export default function PortalHero({
href="/investimentos/iniciar"
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
>
Partir de uma ideia
Criar investimento
</Link>
<Link
href="/oportunidades"
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
>
Consultar oportunidades
Procurar oportunidades
<ArrowRight size={16} aria-hidden="true" />
</Link>
<Link
@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react';
import EvidenceDetails from './evidence-details';
export type PublicDocument = {
id: number;
@@ -36,50 +37,51 @@ export function PublicDocumentContent({
{r.description && (
<p className="whitespace-pre-line">{r.description}</p>
)}
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="font-semibold">Entidade emissora / autor</dt>
<dd>{r.issuer ?? 'Não indicada'}</dd>
</div>
{r.document_number && (
<div>
<dt className="font-semibold">Número</dt>
<dd>{r.document_number}</dd>
</div>
)}
{r.document_date && (
<div>
<dt className="font-semibold">Data do documento</dt>
<dd>{r.document_date}</dd>
</div>
)}
{r.applicable_from && (
<div>
<dt className="font-semibold">
Data de início indicada no registo
</dt>
<dd>{r.applicable_from}</dd>
</div>
)}
{r.applicable_until && (
<div>
<dt className="font-semibold">
Data final indicada no registo
</dt>
<dd>{r.applicable_until}</dd>
</div>
)}
<div>
<dt className="font-semibold">Fonte</dt>
<dd>
{r.source_confirmation === 'official_confirmed'
? 'Fonte oficial'
: r.source_url
? 'Fonte identificada'
: 'Não indicada'}
</dd>
</div>
</dl>
<EvidenceDetails title="Identificação e fonte do documento">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
{r.issuer && (
<div>
<dt className="font-semibold">
Entidade emissora / autor
</dt>
<dd>{r.issuer}</dd>
</div>
)}
{r.document_number && (
<div>
<dt className="font-semibold">Número</dt>
<dd>{r.document_number}</dd>
</div>
)}
{r.document_date && (
<div>
<dt className="font-semibold">Data do documento</dt>
<dd>{r.document_date}</dd>
</div>
)}
{(r.source_url ||
r.source_confirmation === 'official_confirmed') && (
<div>
<dt className="font-semibold">Fonte</dt>
<dd>
{r.source_confirmation === 'official_confirmed'
? 'Fonte oficial'
: r.source_url
? 'Fonte identificada'
: 'Não indicada'}
</dd>
</div>
)}
</dl>
</EvidenceDetails>
{(r.applicable_from || r.applicable_until) && (
<p className="text-sm">
Período indicado:{' '}
{r.applicable_from ?? 'início não indicado'}
{' — '}
{r.applicable_until ?? 'fim não indicado'}.
</p>
)}
{r.usage_notice && (
<p
role="note"
@@ -94,7 +96,7 @@ export function PublicDocumentContent({
className="underline"
href={`/documentos/${r.id}?${new URLSearchParams({ back }).toString()}`}
>
Consultar ficha
Consultar documento
</Link>
)}
{r.view_url && (
@@ -0,0 +1,28 @@
import { Link } from '@inertiajs/react';
export default function PublishedOpportunitiesEmpty({
href = '/oportunidades',
label = 'Rever a pesquisa de oportunidades',
}: {
href?: string;
label?: string;
}) {
return (
<div
className="space-y-2 rounded-lg border border-slate-200 p-4"
role="status"
>
<p className="font-semibold">
Sem anúncios publicados nesta selecção.
</p>
<p className="text-sm">
Este resultado não indica ausência de empresas, recursos ou
potencial de investimento. Consulte também o contexto
territorial e sectorial; a viabilidade exige análise própria.
</p>
<Link className="inline-block py-2 underline" href={href}>
{label}
</Link>
</div>
);
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { Link } from '@inertiajs/react';
import SectorEntry, { type SectorEntryData } from '@/components/sector-entry';
import PublishedOpportunitiesEmpty from './published-opportunities-empty';
export type SectorDetailData = {
sector: {
@@ -90,7 +91,9 @@ export default function SectorContent({
</article>
))}
{!opportunities?.length && (
<p>Sem oportunidades publicadas neste sector.</p>
<PublishedOpportunitiesEmpty
href={`/oportunidades?sector=${sector.slug}`}
/>
)}
<Link
className={button}
@@ -1,3 +1,4 @@
import CurrencySelect from '@/components/currency-select';
import { useEffect, useState } from 'react';
import {
compareBusinessScenarios,
@@ -23,48 +24,48 @@ const emptyPlan: BusinessFinancialPlanInput = {
fxRate: '',
fxDate: '',
fxSource: '',
openingCash: '0',
openingCash: '',
products: [
{
label: 'Produto ou serviço',
monthlyUnits: '100',
unitPrice: '1000',
unitVariableCost: '400',
monthlyUnits: '',
unitPrice: '',
unitVariableCost: '',
},
],
monthlyCollections: '100000',
monthlyCollections: '',
salesCollectionLagMonths: null,
salesCollectionPercent: '100',
salesCollectionPercent: '',
salesRecoverySchedule: [],
openingReceivablesCollectionMonth: null,
openingReceivablesCollectionPercent: '100',
openingReceivablesCollectionPercent: '',
openingReceivablesInstallments: [],
openingPayablesInstallments: [],
monthlyVariableCostPayments: '40000',
monthlyVariableCostPayments: '',
monthlyVariableCostPaymentsByPeriod: null,
monthlyInventoryPurchases: '40000',
monthlyInventoryPurchases: '',
inventoryPurchaseLeadMonths: 0,
inventoryPurchaseSchedule: null,
useInventoryPurchaseSchedule: false,
useProductInventorySchedule: false,
useSupplierPaymentTerms: false,
monthlyOperatingExpenses: '20000',
monthlyOperatingPayments: '20000',
monthlyPayrollExpense: '15000',
monthlyPayrollPayments: '15000',
assetInvestment: '100000',
inventory: '20000',
receivables: '10000',
payables: '5000',
equity: '80000',
shareholderLoans: '0',
bankLoanProceeds: '50000',
grants: '0',
assetDepreciation: '100000',
monthlyOperatingExpenses: '',
monthlyOperatingPayments: '',
monthlyPayrollExpense: '',
monthlyPayrollPayments: '',
assetInvestment: '',
inventory: '',
receivables: '',
payables: '',
equity: '',
shareholderLoans: '',
bankLoanProceeds: '',
grants: '',
assetDepreciation: '',
depreciationLifeMonths: 60,
monthlyInterestExpense: '0',
monthlyInterestPayment: '0',
monthlyPrincipalRepayment: '0',
monthlyInterestExpense: '',
monthlyInterestPayment: '',
monthlyPrincipalRepayment: '',
periods: 12,
};
@@ -913,30 +914,23 @@ export function SimulationBusinessPlan({
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block text-sm font-medium">
Moeda (código ISO)
<input
list="plan-currency-options"
maxLength={3}
pattern="[A-Z]{3}"
Moeda
<CurrencySelect
label="Moeda"
codes={simulationCurrencyOptions}
value={currency}
onChange={(event) => {
const value = event.target.value.toUpperCase();
onChange={(value) => {
onCurrencyChange(value);
savePlan({ ...plan, currency: value });
}}
className="mt-1 w-full rounded border p-2"
aria-invalid={Boolean(
currency && !isSimulationCurrency(currency),
)}
error={
currency && !isSimulationCurrency(currency)
? 'Confirme a moeda seleccionada.'
: undefined
}
/>
</label>
<datalist id="plan-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
<p className="text-sm text-slate-700">
O plano financeiro aceita moedas ISO suportadas com duas
casas decimais. Se um investidor indicar uma moeda com
@@ -952,31 +946,22 @@ export function SimulationBusinessPlan({
</p>
<label className="block text-sm font-medium">
Moeda de apresentação (opcional)
<input
list="plan-presentation-currency-options"
maxLength={3}
pattern="[A-Z]{3}"
<CurrencySelect
label="Moeda de apresentação"
codes={simulationCurrencyOptions}
value={plan.presentationCurrency ?? ''}
onChange={(event) =>
update(
'presentationCurrency',
event.target.value.toUpperCase(),
)
onChange={(value) =>
update('presentationCurrency', value)
}
className="mt-1 w-full rounded border p-2"
aria-invalid={Boolean(
error={
plan.presentationCurrency &&
!isSimulationCurrency(plan.presentationCurrency),
)}
!isSimulationCurrency(plan.presentationCurrency)
? 'Confirme a moeda seleccionada.'
: undefined
}
/>
</label>
<datalist id="plan-presentation-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
{plan.presentationCurrency &&
plan.presentationCurrency !== currency && (
<>
@@ -1,3 +1,4 @@
import CurrencySelect from '@/components/currency-select';
import { useEffect, useState } from 'react';
import {
exportMonthlyCashflowCsv,
@@ -90,30 +91,20 @@ export function SimulationCashflowPlanner({
aria-labelledby="cashflow-title"
>
<label className="block max-w-xs text-sm font-medium">
Moeda do projecto (código ISO)
<input
list="cashflow-currency-options"
inputMode="text"
maxLength={3}
pattern="[A-Z]{3}"
autoCapitalize="characters"
Moeda do projecto
<CurrencySelect
label="Moeda do projecto"
codes={simulationCurrencyOptions}
value={currency}
onChange={(event) =>
onCurrencyChange(event.target.value.toUpperCase())
}
className="mt-1 w-full rounded border p-2"
aria-invalid={
currency !== '' && !isSimulationCurrency(currency)
onChange={onCurrencyChange}
error={
currency && !isSimulationCurrency(currency)
? 'Confirme a moeda seleccionada.'
: undefined
}
/>
</label>
<datalist id="cashflow-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
<h3 id="cashflow-title" className="text-lg font-semibold">
Projecção de tesouraria — 12 meses
</h3>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
import {SimulationIrpcPecObligation} from './simulation-irpc-pec-obligation';
import { SimulationIrpcPecObligation } from './simulation-irpc-pec-obligation';
import { SimulationIrpcPpcObligation } from './simulation-irpc-ppc-obligation';
import { SimulationIrpcInvestment } from './simulation-irpc-investment';
import { SimulationIrpcEconomic } from './simulation-irpc-economic';
@@ -38,6 +38,19 @@ type Props = {
export function SimulationIrpcAnnual({ draft, onChange }: Props) {
const [error, setError] = useState('');
if (draft.fiscal_component !== 'irpc') return null;
if (
typeof draft.fiscal_period === 'string' &&
draft.fiscal_period &&
draft.fiscal_period !== '2026'
)
return (
<p role="alert">
Esta estimativa anual tem cobertura documentada para 2026. O
período escolhido, {draft.fiscal_period}, permanece inalterado;
os dados e resultados anteriores ficam preservados, sem aplicar
este cálculo a outro ano.
</p>
);
try {
const b = annualBook(draft),
save = (next: AnnualBook) => {
@@ -51,10 +64,10 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
if (!b)
return (
<section
aria-label="IRPC — L8 fecho anual"
aria-label="IRPC — estimativa anual"
className="rounded border p-4"
>
<h4>L8 — da colecta ao saldo anual simulado</h4>
<h4>Do imposto estimado ao saldo anual simulado</h4>
<p>
Reutiliza a colecta válida, retenções efectivamente
sofridas por conta e PPC comprovados do próprio
@@ -77,14 +90,14 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
}
}}
>
Preparar saldo anual L8
Preparar estimativa anual
</button>
{error && <p role="alert">{error}</p>}
</section>
);
const patch = (p: Partial<AnnualBook>) => save({ ...b, ...p }),
a = annualAssessment(draft),
advancePeriod = annualAdvancePeriod(draft,b),
advancePeriod = annualAdvancePeriod(draft, b),
options = annualWithholdingOptions(draft);
const field = (
key: 'subjectNuit' | 'year' | 'reference' | 'assessmentReference',
@@ -143,10 +156,10 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
);
return (
<section
aria-label="IRPC — L8 fecho anual"
aria-label="IRPC — estimativa anual"
className="space-y-3 rounded border p-4"
>
<h4>L8 — colecta, abatimentos e saldo anual simulado</h4>
<h4>Imposto estimado, abatimentos e saldo anual simulado</h4>
<p>
A colecta acima é anterior aos abatimentos. Este saldo tem
cálculo e histórico próprios; retenção entregue pela empresa
@@ -350,11 +363,33 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
</fieldset>
<fieldset className="space-y-2 rounded border p-3">
<legend>Estimativas PPC e PEC para a viabilidade</legend>
<p>Simulação fiscal para avaliar a viabilidade do negócio e as necessidades de tesouraria. Os valores e meses previstos são estimativas; o portal não executa pagamentos nem submete declarações fiscais.</p>
<SimulationIrpcAdvancePeriodDeclaration draft={draft} book={b} onChange={declaration=>patch({advancePeriodDeclaration:declaration})}/>
<SimulationIrpcPecObligation book={b} period={advancePeriod} onChange={pecObligation=>patch({pecObligation})}/>
<SimulationIrpcPpcObligation book={b} period={advancePeriod} onChange={ppcObligation=>patch({ppcObligation})}/>
<SimulationIrpcAdvanceCashflow book={b} period={advancePeriod}/>
<p>
Simulação fiscal para avaliar a viabilidade do negócio e
as necessidades de tesouraria. Os valores e meses
previstos são estimativas; o portal não executa
pagamentos nem submete declarações fiscais.
</p>
<SimulationIrpcAdvancePeriodDeclaration
draft={draft}
book={b}
onChange={(declaration) =>
patch({ advancePeriodDeclaration: declaration })
}
/>
<SimulationIrpcPecObligation
book={b}
period={advancePeriod}
onChange={(pecObligation) => patch({ pecObligation })}
/>
<SimulationIrpcPpcObligation
book={b}
period={advancePeriod}
onChange={(ppcObligation) => patch({ ppcObligation })}
/>
<SimulationIrpcAdvanceCashflow
book={b}
period={advancePeriod}
/>
</fieldset>
<fieldset className="space-y-2 rounded border p-3">
<legend>PPC documentados como pagos</legend>
@@ -627,33 +662,255 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
}
}
export function SimulationIrpcAdvanceCashflow({book,period}: {book: AnnualBook;period?: AdvanceFiscalPeriod}) {
export function SimulationIrpcAdvanceCashflow({
book,
period,
}: {
book: AnnualBook;
period?: AdvanceFiscalPeriod;
}) {
if (!book.ppcObligation && !book.pecObligation) return null;
const m = annualAdvanceCashflow(book,period);
const m = annualAdvanceCashflow(book, period);
const show = advanceCashflowAmount;
return <section className="space-y-3 rounded border p-3" aria-label="Projecção conjunta PPC e PEC">
<h5 className="font-semibold">Saídas previstas PPC/PEC por mês</h5>
{period && <p>Período preparado em L6: {period.from || 'por confirmar'} — {period.to || 'por confirmar'}.</p>}
<p>Reúne as estimativas PPC e a hipótese de tesouraria PEC. Os comprovativos pagos e o fecho anual permanecem separados; esta vista não acrescenta outra saída ao fluxo financeiro do negócio.</p>
<p role="status">{m.total ? 'Total da projecção: '+show(m.total) : 'Projecção parcial — total por determinar.'}</p>
{!!m.pending.length && <ul className="list-disc pl-5">{m.pending.map(p=><li key={p}>{p}</li>)}</ul>}
{!!m.rows.length && <div className="overflow-x-auto"><table className="w-full text-left text-sm">
<caption className="sr-only">Projecção mensal das estimativas PPC e PEC</caption>
<thead><tr><th className="p-2">Mês</th><th className="p-2">PPC</th><th className="p-2">PEC</th><th className="p-2">{m.total ? 'Total previsto' : 'Subtotal conhecido'}</th></tr></thead>
<tbody>{m.rows.map(r=><tr key={r.month} className="border-t"><th scope="row" className="p-2">{r.month}</th><td className="p-2">{m.ppcKnown ? show(r.ppc) : 'Por determinar'}</td><td className="p-2">{m.pecKnown ? show(r.pec) : 'Por determinar'}</td><td className="p-2">{show(r.knownSubtotal)}</td></tr>)}</tbody>
</table></div>}
{!m.rows.length && m.total && <p>Sem saída PPC/PEC estimada neste recorte.</p>}
<p>Meses e montantes dependem das fontes e opções preparadas acima. A opção PEC concentrada conserva o mês escolhido; esta apresentação não certifica o prazo legal.</p>
</section>;
return (
<section
className="space-y-3 rounded border p-3"
aria-label="Projecção conjunta PPC e PEC"
>
<h5 className="font-semibold">Saídas previstas PPC/PEC por mês</h5>
{period && (
<p>
Período preparado em L6: {period.from || 'por confirmar'} —{' '}
{period.to || 'por confirmar'}.
</p>
)}
<p>
Reúne as estimativas PPC e a hipótese de tesouraria PEC. Os
comprovativos pagos e o fecho anual permanecem separados; esta
vista não acrescenta outra saída ao fluxo financeiro do negócio.
</p>
<p role="status">
{m.total
? 'Total da projecção: ' + show(m.total)
: 'Projecção parcial — total por determinar.'}
</p>
{!!m.pending.length && (
<ul className="list-disc pl-5">
{m.pending.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
)}
{!!m.rows.length && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Projecção mensal das estimativas PPC e PEC
</caption>
<thead>
<tr>
<th className="p-2">Mês</th>
<th className="p-2">PPC</th>
<th className="p-2">PEC</th>
<th className="p-2">
{m.total
? 'Total previsto'
: 'Subtotal conhecido'}
</th>
</tr>
</thead>
<tbody>
{m.rows.map((r) => (
<tr key={r.month} className="border-t">
<th scope="row" className="p-2">
{r.month}
</th>
<td className="p-2">
{m.ppcKnown
? show(r.ppc)
: 'Por determinar'}
</td>
<td className="p-2">
{m.pecKnown
? show(r.pec)
: 'Por determinar'}
</td>
<td className="p-2">
{show(r.knownSubtotal)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!m.rows.length && m.total && (
<p>Sem saída PPC/PEC estimada neste recorte.</p>
)}
<p>
Meses e montantes dependem das fontes e opções preparadas acima.
A opção PEC concentrada conserva o mês escolhido; esta
apresentação não certifica o prazo legal.
</p>
</section>
);
}
export function SimulationIrpcAdvancePeriodDeclaration({draft,book,onChange}:{draft:Record<string,unknown>;book:AnnualBook;onChange:(value:NonNullable<AnnualBook['advancePeriodDeclaration']>)=>void}){
const period=annualAdvancePeriod(draft,book),calendar=advanceCalendar(period,book.year,book.subjectNuit),d=book.advancePeriodDeclaration;
if(!d&&!calendar.candidate)return null;
if(!d)return <section className="rounded border p-3"><h5>Período anual não civil</h5><p>Confronte o fundamento do período L6 e os doze meses do período anterior. As datas sugeridas não substituem o suporte documental.</p><button type="button" className="rounded border p-2" onClick={()=>onChange(newAdvancePeriodDeclaration(book.subjectNuit,book.year,period))}>Preparar correspondência dos períodos</button></section>;
const patch=(values:Partial<typeof d>)=>onChange({...d,...values,confirmed:''});
const labels={subjectNuit:'NUIT dos períodos',year:'Exercício de termo',currentFrom:'Início do período L6 declarado',currentTo:'Termo do período L6 declarado',currentReference:'Fundamento documentado do período anual não civil',previousFrom:'Início do período anterior',previousTo:'Termo do período anterior',previousReference:'Fonte e correspondência do período anterior'};
return <section className="space-y-2 rounded border p-3" aria-label="Correspondência dos períodos anuais"><h5>Correspondência dos períodos anuais</h5><p role="status">{calendar.mode==='annual_non_civil'?'Correspondência documental conferida;previsões nos meses do período L6.':calendar.mode==='civil'?'L6 usa o ano civil;referência anterior conservada sem aplicação.':calendar.pending.join(' ')}</p><p>PPC: imposto anterior {book.ppcObligation?.previousTax||'por confirmar'} MZN;retenções {book.ppcObligation?.previousWithholdings||'por confirmar'} MZN. PEC: volume anterior {book.pecObligation?.previousTurnover||'por confirmar'} MZN;PPC anteriores {book.pecObligation?.previousPpcPaid||'por confirmar'} MZN.</p>{d.confirmed==='yes'&&(d.ppcBasisKey!==advancePeriodBasis(book,'ppc')||d.pecBasisKey!==advancePeriodBasis(book,'pec'))&&<button type="button" className="rounded border p-2" onClick={()=>onChange({...d,ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}>Reconferi os montantes e as fontes neste período</button>}<details open={d.confirmed!=='yes'||calendar.pending.length>0}><summary>Conferir ou alterar períodos</summary><label className="block">Aplicação<select aria-label="Aplicação do período não civil" value={d.mode} onChange={e=>patch({mode:e.target.value as typeof d.mode})}><option value="annual_non_civil">Período anual não civil</option><option value="">Conservar sem aplicação</option></select></label>{(Object.keys(labels) as Array<keyof typeof labels>).map(key=><label className="block" key={key}>{labels[key]}<input className="mt-1 block w-full rounded border px-3 py-2" aria-label={labels[key]} value={d[key]} onChange={e=>patch({[key]:e.target.value})}/></label>)}<p>No PPC, indique que o período anterior não é um ano civil completo. Os doze meses e a continuidade são conferidos pelas datas e fontes aqui indicadas.</p><label className="block"><input type="checkbox" checked={d.confirmed==='yes'} onChange={e=>onChange({...d,confirmed:e.target.checked?'yes':'',ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}/>Conferi no suporte os dois períodos, o fundamento do enquadramento e a correspondência dos montantes e fontes PPC/PEC</label></details><p>Guardar conserva estas escolhas. Uma alteração nas datas L6 exige reconferência; o portal não autoriza uma mudança do período fiscal.</p></section>;
export function SimulationIrpcAdvancePeriodDeclaration({
draft,
book,
onChange,
}: {
draft: Record<string, unknown>;
book: AnnualBook;
onChange: (
value: NonNullable<AnnualBook['advancePeriodDeclaration']>,
) => void;
}) {
const period = annualAdvancePeriod(draft, book),
calendar = advanceCalendar(period, book.year, book.subjectNuit),
d = book.advancePeriodDeclaration;
if (!d && !calendar.candidate) return null;
if (!d)
return (
<section className="rounded border p-3">
<h5>Período anual não civil</h5>
<p>
Confronte o fundamento do período L6 e os doze meses do
período anterior. As datas sugeridas não substituem o
suporte documental.
</p>
<button
type="button"
className="rounded border p-2"
onClick={() =>
onChange(
newAdvancePeriodDeclaration(
book.subjectNuit,
book.year,
period,
),
)
}
>
Preparar correspondência dos períodos
</button>
</section>
);
const patch = (values: Partial<typeof d>) =>
onChange({ ...d, ...values, confirmed: '' });
const labels = {
subjectNuit: 'NUIT dos períodos',
year: 'Exercício de termo',
currentFrom: 'Início do período L6 declarado',
currentTo: 'Termo do período L6 declarado',
currentReference: 'Fundamento documentado do período anual não civil',
previousFrom: 'Início do período anterior',
previousTo: 'Termo do período anterior',
previousReference: 'Fonte e correspondência do período anterior',
};
return (
<section
className="space-y-2 rounded border p-3"
aria-label="Correspondência dos períodos anuais"
>
<h5>Correspondência dos períodos anuais</h5>
<p role="status">
{calendar.mode === 'annual_non_civil'
? 'Correspondência documental conferida;previsões nos meses do período L6.'
: calendar.mode === 'civil'
? 'L6 usa o ano civil;referência anterior conservada sem aplicação.'
: calendar.pending.join(' ')}
</p>
<p>
PPC: imposto anterior{' '}
{book.ppcObligation?.previousTax || 'por confirmar'}{' '}
MZN;retenções{' '}
{book.ppcObligation?.previousWithholdings || 'por confirmar'}{' '}
MZN. PEC: volume anterior{' '}
{book.pecObligation?.previousTurnover || 'por confirmar'}{' '}
MZN;PPC anteriores{' '}
{book.pecObligation?.previousPpcPaid || 'por confirmar'} MZN.
</p>
{d.confirmed === 'yes' &&
(d.ppcBasisKey !== advancePeriodBasis(book, 'ppc') ||
d.pecBasisKey !== advancePeriodBasis(book, 'pec')) && (
<button
type="button"
className="rounded border p-2"
onClick={() =>
onChange({
...d,
ppcBasisKey: advancePeriodBasis(book, 'ppc'),
pecBasisKey: advancePeriodBasis(book, 'pec'),
})
}
>
Reconferi os montantes e as fontes neste período
</button>
)}
<details
open={d.confirmed !== 'yes' || calendar.pending.length > 0}
>
<summary>Conferir ou alterar períodos</summary>
<label className="block">
Aplicação
<select
aria-label="Aplicação do período não civil"
value={d.mode}
onChange={(e) =>
patch({ mode: e.target.value as typeof d.mode })
}
>
<option value="annual_non_civil">
Período anual não civil
</option>
<option value="">Conservar sem aplicação</option>
</select>
</label>
{(Object.keys(labels) as Array<keyof typeof labels>).map(
(key) => (
<label className="block" key={key}>
{labels[key]}
<input
className="mt-1 block w-full rounded border px-3 py-2"
aria-label={labels[key]}
value={d[key]}
onChange={(e) =>
patch({ [key]: e.target.value })
}
/>
</label>
),
)}
<p>
No PPC, indique que o período anterior não é um ano civil
completo. Os doze meses e a continuidade são conferidos
pelas datas e fontes aqui indicadas.
</p>
<label className="block">
<input
type="checkbox"
checked={d.confirmed === 'yes'}
onChange={(e) =>
onChange({
...d,
confirmed: e.target.checked ? 'yes' : '',
ppcBasisKey: advancePeriodBasis(book, 'ppc'),
pecBasisKey: advancePeriodBasis(book, 'pec'),
})
}
/>
Conferi no suporte os dois períodos, o fundamento do
enquadramento e a correspondência dos montantes e fontes
PPC/PEC
</label>
</details>
<p>
Guardar conserva estas escolhas. Uma alteração nas datas L6
exige reconferência; o portal não autoriza uma mudança do
período fiscal.
</p>
</section>
);
}
@@ -1,4 +1,6 @@
import { SimulationIrpcSpecial } from '@/components/simulation-irpc-special';
import FiscalOperationPicker from '@/components/fiscal-operation-picker';
import { inputText } from '@/lib/input-text';
import { SimulationIrpcFormPreparation } from '@/components/simulation-irpc-form-preparation';
import { SimulationIrpcPeriodFx } from '@/components/simulation-irpc-period-fx';
import { SimulationIrpcDepreciation } from '@/components/simulation-irpc-depreciation';
@@ -31,7 +33,7 @@ export function SimulationIrpcNormal({
onChange,
}: {
draft: Record<string, unknown>;
onChange: (field: string, value: string) => void;
onChange: (field: string, value: string | string[]) => void;
}) {
const [error, setError] = useState('');
if (draft.fiscal_component !== 'irpc') return null;
@@ -46,6 +48,26 @@ export function SimulationIrpcNormal({
setError(String(e));
}
};
const selectedPeriod = inputText(draft.fiscal_period ?? '');
if (selectedPeriod && selectedPeriod !== b.year)
return (
<aside className="rounded border p-3" role="alert">
<p>
O enquadramento escolheu {selectedPeriod}, mas esta
preparação de IRPC está em {b.year}. Confira o período
antes de calcular; os dados anteriores ficam preservados
e nenhum ano foi alterado.
</p>
<button
type="button"
className="underline"
disabled={!/^\d{4}$/.test(selectedPeriod)}
onClick={() => save({ ...b, year: selectedPeriod })}
>
Preparar o ano escolhido, sujeito à cobertura disponível
</button>
</aside>
);
const patch = (id: string, p: Partial<NormalAdjustment>) =>
save({
...b,
@@ -74,16 +96,16 @@ export function SimulationIrpcNormal({
aria-label="IRPC normal — ajustamentos"
className="space-y-3 rounded border p-4"
>
<h4>L6 — IRPC normal: ajustamentos ao resultado financeiro</h4>
<h4>IRPC — ajustamentos ao resultado financeiro</h4>
<p>
Acréscimos de multas sem origem contratual e juros
compensatórios incluídos nos custos do plano, artigo 36(1)c.
A conferência anual pode determinar o lucro tributável
simulado no recorte ordinário documentado. A matéria
colectável exige conferência específica de deduções e
perdas; a colecta tem cálculo próprio e o saldo final a
pagar ou recuperar pertence aL8. Não reutiliza
automaticamente juros simulados de L5.
perdas; o imposto estimado e a estimativa anual têm cálculos
próprios. Os juros da simulação de empréstimo não são
adoptados automaticamente como custo fiscal.
</p>
{error && <p role="alert">{error}</p>}
{source ? (
@@ -437,31 +459,126 @@ export function SimulationIrpcNormal({
})}
</fieldset>
)}
<SimulationIrpcPeriodFx draft={draft} book={b} save={save} />
<SimulationIrpcSchedules book={b} save={save} />
<SimulationIrpcEconomicInclusion
key={JSON.stringify([
draft.context_opportunity,
b.base?.subjectNuit,
b.year,
])}
draft={draft}
book={b}
save={save}
/>
<SimulationIrpcDepreciation
draft={draft}
book={b}
save={save}
/>
<SimulationIrpcCosts draft={draft} book={b} save={save} />
<SimulationIrpcTaxableBase draft={draft} book={b} save={save} />
<SimulationIrpcAssessment book={b} save={save} />
<SimulationIrpcSpecial draft={draft} book={b} save={save} />
<SimulationIrpcFormPreparation
draft={draft}
book={b}
save={save}
<FiscalOperationPicker
family="irpc"
selected={draft.fiscal_tools_selection}
onChange={(selected) =>
onChange('fiscal_tools_selection', selected)
}
operations={[
{
id: 'period',
label: 'Período e moeda do plano',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcPeriodFx
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'schedules',
label: 'Mapas complementares',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcSchedules book={b} save={save} />
),
},
{
id: 'economic',
label: 'Inclusões e correcções económicas',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcEconomicInclusion
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'depreciation',
label: 'Amortizações e depreciações',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcDepreciation
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'costs',
label: 'Tratamento dos custos',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcCosts
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'taxable',
label: 'Matéria colectável',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcTaxableBase
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'assessment',
label: 'Estimativa do imposto',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcAssessment
book={b}
save={save}
/>
),
},
{
id: 'special',
label: 'Situações fiscais específicas',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcSpecial
draft={draft}
book={b}
save={save}
/>
),
},
{
id: 'forms',
label: 'Preparação documental fiscal',
description:
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
render: () => (
<SimulationIrpcFormPreparation
draft={draft}
book={b}
save={save}
/>
),
},
]}
/>
<button
type="button"
@@ -49,6 +49,29 @@ export function SimulationIspcQuarter({
},
change = (patch: Partial<IspcQuarterInput>) =>
save({ ...book, input: { ...i, ...patch } });
if (
typeof draft.fiscal_period === 'string' &&
draft.fiscal_period &&
draft.fiscal_period !== i.year
)
return (
<aside className="rounded border p-3" role="alert">
<p>
O ano desta preparação difere do enquadramento fiscal
escolhido. Confira a correspondência antes de calcular;
nenhum período foi alterado automaticamente.
</p>
<button
type="button"
className="underline"
onClick={() =>
change({ year: draft.fiscal_period as string })
}
>
Preparar o ano escolhido, sujeito à cobertura disponível
</button>
</aside>
);
let service = false,
ready = false;
try {
@@ -47,7 +47,7 @@ export function SimulationMiners({
return (
<details className="rounded border p-3">
<summary>
Importações dos mineiros — regime da África do Sul (P059)
Importações dos mineiros — regime da África do Sul
</summary>
<p>
Lei n.º2/2011,artigos1–4:página1 doPDF;artigo5:página2/p110-(8).
@@ -3,6 +3,7 @@ import {
modeloAPurchaseReferences,
modeloAReference,
} from '@/lib/iva-modelo-a';
import EvidenceDetails from './evidence-details';
export function SimulationModeloA({
draft,
@@ -100,14 +101,15 @@ export function SimulationModeloA({
</div>
)}
</details>
<details>
<summary>
Ver campos, cálculos, fundamentos e cobertura do Modelo A
</summary>
<p>
{modeloAReference.file}. {modeloAReference.edition} SHA256:{' '}
{modeloAReference.sha256}.
</p>
<EvidenceDetails title="Campos e fundamentos da estimativa — Modelo A">
<EvidenceDetails title="Exemplar e integridade da fonte">
<p>
{modeloAReference.file}. {modeloAReference.edition}
</p>
<p className="break-all">
SHA256: {modeloAReference.sha256}
</p>
</EvidenceDetails>
<p>
O exemplar contém divergências de numeração/remissões,
registadas no confronto documental. Não foram corrigidas no
@@ -159,7 +161,7 @@ export function SimulationModeloA({
</tbody>
</table>
</div>
</details>
</EvidenceDetails>
</section>
);
}
@@ -1,3 +1,4 @@
import FiscalOperationPicker from './fiscal-operation-picker';
import { inputText } from '../lib/input-text.ts';
import { SimulationRemissionImports } from './simulation-remission-imports';
import { SimulationObjectiveImports } from './simulation-objective-imports';
@@ -58,7 +59,7 @@ export function SimulationMonthlyIva({
onCalculate: (
input: MonthlyIvaInput,
) => Promise<ApiSimulationExecutionV1 | null | undefined>;
onChange: (field: string, value: string) => void;
onChange: (field: string, value: string | string[]) => void;
}) {
const settings = readMonthlyIvaSettings(
inputText(draft.monthly_iva_settings ?? ''),
@@ -416,38 +417,394 @@ export function SimulationMonthlyIva({
</dl>
</details>
)}
<SimulationOperations draft={draft} onChange={onChange} />
<SimulationAcquisitions draft={draft} onChange={onChange} />
<SimulationImportExemptions draft={draft} onChange={onChange} />
<SimulationObjectiveImports draft={draft} onChange={onChange} />
<SimulationMiners draft={draft} onChange={onChange} />
<SimulationRemissionImports draft={draft} onChange={onChange} />
<SimulationReturnees draft={draft} onChange={onChange} />
<SimulationPublicAuthorityBase draft={draft} onChange={onChange} />
<SimulationCommissionBase draft={draft} onChange={onChange} />
<SimulationActSaleBase draft={draft} onChange={onChange} />
<SimulationFuelBase draft={draft} onChange={onChange} />
<SimulationNoncashBase draft={draft} onChange={onChange} />
<SimulationCompositionBase draft={draft} onChange={onChange} />
<SimulationExclusionBase draft={draft} onChange={onChange} />
<SimulationValueReview draft={draft} onChange={onChange} />
<SimulationServiceNormal draft={draft} onChange={onChange} />
<SimulationGoodsLocation draft={draft} onChange={onChange} />
<SimulationServiceLocation draft={draft} onChange={onChange} />
<SimulationListedServices draft={draft} onChange={onChange} />
<SimulationReducedBases draft={draft} onChange={onChange} />
<SimulationOwnName draft={draft} onChange={onChange} />
<SimulationGoodsAllocation draft={draft} onChange={onChange} />
<SimulationForeignOperations draft={draft} onChange={onChange} />
<SimulationDigital draft={draft} onChange={onChange} />
<SimulationTravel draft={draft} onChange={onChange} />
<SimulationTravelEvents draft={draft} onChange={onChange} />
<SimulationCurrencyEvents draft={draft} onChange={onChange} />
<SimulationPropertyTransfers draft={draft} onChange={onChange} />
<SimulationGoodsPresumptions draft={draft} onChange={onChange} />
<SimulationSecondhand draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
<FiscalOperationPicker
family="iva"
selected={draft.fiscal_tools_selection}
onChange={(selected) =>
onChange('fiscal_tools_selection', selected)
}
operations={[
{
id: 'operations',
label: 'Operações de venda e prestação de serviços',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationOperations
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'acquisitions',
label: 'Aquisições e dedutibilidade',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationAcquisitions
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'import_exemptions',
label: 'Importações com isenção',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationImportExemptions
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'objective_imports',
label: 'Condições específicas de importação',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationObjectiveImports
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'miners',
label: 'Importações no regime de trabalhadores mineiros',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationMiners
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'remission_imports',
label: 'Importações com isenção por remissão legal',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationRemissionImports
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'returnees',
label: 'Regresso definitivo e bagagem',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationReturnees
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'public_authority_base',
label: 'Operações de entidades públicas',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationPublicAuthorityBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'commission_base',
label: 'Comissões',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationCommissionBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'act_sale_base',
label: 'Venda por acto ou processo específico',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationActSaleBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'fuel_base',
label: 'Revenda de combustíveis',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationFuelBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'noncash_base',
label: 'Operações sem contrapartida em dinheiro',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationNoncashBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'composition_base',
label: 'Composição da base tributável',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationCompositionBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'exclusion_base',
label: 'Parcelas excluídas da base',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationExclusionBase
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'value_review',
label: 'Revisão do valor tributável',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationValueReview
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'service_normal',
label: 'Valor de serviços',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationServiceNormal
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'goods_location',
label: 'Localização das operações sobre bens',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationGoodsLocation
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'service_location',
label: 'Localização das prestações de serviços',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationServiceLocation
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'listed_services',
label: 'Serviços com enquadramento específico',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationListedServices
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'reduced_bases',
label: 'Bases reduzidas',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationReducedBases
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'own_name',
label: 'Operações em nome próprio',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationOwnName
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'goods_allocation',
label: 'Afectação de bens',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationGoodsAllocation
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'foreign_operations',
label: 'Operações com o exterior',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationForeignOperations
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'digital',
label: 'Serviços digitais',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationDigital
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'travel',
label: 'Viagens',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationTravel
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'travel_events',
label: 'Factos específicos de viagens',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationTravelEvents
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'currency_events',
label: 'Operações em moeda estrangeira',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationCurrencyEvents
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'property_transfers',
label: 'Transmissões de imóveis',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationPropertyTransfers
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'goods_presumptions',
label: 'Presunções relativas a bens',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationGoodsPresumptions
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'secondhand',
label: 'Bens em segunda mão',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationSecondhand
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'offer_preparations',
label: 'Preparação de ofertas',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => (
<SimulationOfferPreparations
draft={draft}
onChange={onChange}
/>
),
},
{
id: 'modelo_a',
label: 'Conferência do Modelo A',
description:
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
render: () => <SimulationModeloA draft={draft} />,
},
]}
/>
</section>
);
}
@@ -1,5 +1,6 @@
import { inputText } from '../lib/input-text.ts';
import { useState } from 'react';
import EvidenceDetails from '@/components/evidence-details';
import {
remissionBook,
saveRemissionBook,
@@ -52,9 +53,7 @@ export function SimulationRemissionImports({
}
return (
<details className="rounded border p-3">
<summary>
Importações — quatro famílias de isenção por remissão (P047)
</summary>
<summary>Importações — isenções por remissão legal</summary>
<p>
Confirme o fundamento e o âmbito da isenção de direitos. Taxa
pautal de 0%, redução e pedido apresentado são situações
@@ -500,7 +499,9 @@ export function SimulationRemissionImports({
)}
<details>
<summary>Fontes e limites de cobertura</summary>
<p>{remissionSource}</p>
<EvidenceDetails title="Fontes e âmbito deste cálculo">
<p>{remissionSource.replace(/^P\d+\s*[—-]\s*/, '')}</p>
</EvidenceDetails>
<p>
Avalia o IVA por remissão quando a isenção de direitos está
demonstrada. Não substitui a classificação pautal nem
@@ -45,8 +45,7 @@ export function SimulationReturnees({
return (
<details className="rounded border p-3">
<summary>
Regresso definitivo — veículos e bagagem (P058, cobertura
parcial)
Regresso definitivo — veículos e bagagem (cobertura parcial)
</summary>
<p>
Prepare os bens e escolha o percurso próprio:veículos no
@@ -54,7 +53,7 @@ export function SimulationReturnees({
artigos57.º/61.º do Decreto37/2023. Isenção, redução de80% e
redução excepcional de50% têm condições distintas. Estas
percentagens são benefícios sobre as imposições; não são taxas
deIVA. Não se aplicam aqui as condições dos mineirosP059.
de IVA. Não se aplicam aqui as condições próprias dos mineiros.
</p>
<p>
Bagagem de funcionários e estudantes tem percurso
@@ -1,3 +1,5 @@
import EvidenceDetails from './evidence-details';
export type TerritorialSourceEvidence = {
association: string;
role: string;
@@ -15,16 +17,7 @@ export default function TerritorialSources({
}) {
if (!sources?.length) return null;
return (
<section className="min-w-0 space-y-3 rounded border p-4 [overflow-wrap:anywhere]">
<h2 className="text-xl font-bold">
Fundamento documental das associações
</h2>
<p>
Fontes oficiais identificadas são aceites; recuperar uma
referência não retira a aceitação existente. O fundamento
aplica-se à informação indicada, sem deduzir implantação,
disponibilidade ou capacidade.
</p>
<EvidenceDetails title="Fontes das relações territoriais">
{sources.map((source, i) => (
<article className="border-t pt-3" key={i}>
<h3 className="font-semibold">
@@ -36,17 +29,19 @@ export default function TerritorialSources({
'ligação ao exemplar por recuperar'}
</p>
<p>{source.source_quote ?? source.supports}</p>
<p>
{source.official_source_accepted
? 'Fonte oficial identificada e aceite.'
: 'Aceitação existente preservada; ponte ao exemplar oficial por recuperar. Esta referência não recebe outra classificação de proveniência.'}{' '}
Rastreabilidade: {source.traceability}. Estado
registado:{' '}
{source.existing_validation ?? 'sem campo específico'};
preservado.
</p>
<EvidenceDetails title="Proveniência e versão da referência">
<p>
{source.official_source_accepted
? 'Fonte oficial identificada.'
: 'Ligação ao exemplar oficial por recuperar.'}
</p>
<p>{source.traceability}</p>
{source.existing_validation && (
<p>{source.existing_validation}</p>
)}
</EvidenceDetails>
</article>
))}
</section>
</EvidenceDetails>
);
}
+14 -4
View File
@@ -2,16 +2,26 @@ import { useEffect, useState } from 'react';
import { newSubmissionKey } from '@/lib/portal-api';
export function useInterestDraft<T>(name: string, initial: T) {
const storage = 'investemoz-interest:' + name;
const [draft, setDraft] = useState<{ form: T; key: string }>(() => {
const read = () => {
try {
const value = JSON.parse(sessionStorage.getItem(storage) ?? 'null');
if (value?.form && typeof value.key === 'string') return value;
if (value?.form && typeof value.key === 'string')
return { ...value, storage } as {
form: T;
key: string;
storage: string;
};
} catch {
/* unavailable storage leaves the current form usable */
}
return { form: initial, key: newSubmissionKey() };
});
return { form: initial, key: newSubmissionKey(), storage };
};
const [draft, setDraft] = useState(read);
// Inertia can reuse this component for a different opportunity. Never write
// the previous opportunity's answers into the new opportunity's storage key.
if (draft.storage !== storage) setDraft(read());
useEffect(() => {
if (draft.storage !== storage) return;
try {
sessionStorage.setItem(storage, JSON.stringify(draft));
} catch {}
+35
View File
@@ -0,0 +1,35 @@
export const normaliseChoice = (value: string) =>
value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLocaleLowerCase('pt')
.trim();
export function matchesChoice(query: string, labels: string[]): boolean {
const needle = normaliseChoice(query);
return (
!needle ||
labels.some((label) => normaliseChoice(label).includes(needle))
);
}
/** The same ISO currency source already used by the simulator, with readable labels. */
export function currencyChoices(codes?: readonly string[]) {
const intl = Intl as unknown as {
supportedValuesOf?: (key: string) => string[];
};
const names = new Intl.DisplayNames(['pt-MZ'], { type: 'currency' });
return [
...new Set(
codes ??
intl.supportedValuesOf?.('currency') ?? [
'MZN',
'USD',
'EUR',
'ZAR',
],
),
].map((code) => ({
value: code,
label: `${names.of(code) ?? code} — ${code}`,
keywords: [code, names.of(code) ?? code],
}));
}
+110 -7
View File
@@ -1,10 +1,94 @@
import { searchTerritoryHref } from './search-territory.ts';
const geography = [
'province_id',
'district_id',
'admin_post_id',
'locality_id',
];
const catalogueKeys: Record<string, string[]> = {
'/carteira': ['q', 'kind', 'sector', 'geo_location_id'],
'/zonas': [
'q',
'category',
'physical_category',
'legal_regime',
'development_status',
'availability_status',
'province',
],
'/oportunidades': [
'q',
'query',
'sector',
'province',
'district',
'geo_location_id',
'include_national',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'corridor_id',
'project_id',
'education',
'min_amount',
'max_amount',
'currency',
'maturity_level',
'preparation',
'before',
'sort',
'facets[]',
'view',
'dimension',
'association_type',
'association_target',
'association_state',
'association_purpose',
'chain_id',
'subsector_id',
],
};
function family(path: string): string {
if (path.startsWith('/management/portfolio')) return '/carteira';
if (path.startsWith('/management/investment/zones')) return '/zonas';
if (path === '/educacao' || path.startsWith('/educacao/'))
return '/oportunidades';
return (
Object.keys(catalogueKeys).find(
(route) => path === route || path.startsWith(route + '/'),
) ?? ''
);
}
/** Explicit URL criteria only. No ambient storage, dossier IDs or nested return URLs. */
export function catalogueQuery(path: string, query: string): URLSearchParams {
const group = family(path);
const allowed = new Set(
group
? [...geography, ...catalogueKeys[group], 'page', 'per_page']
: [],
);
const result = new URLSearchParams();
for (const [key, value] of new URLSearchParams(query)) {
if (!allowed.has(key) || !value || value.length > 2000) continue;
if (
['page', 'per_page'].includes(key) &&
(!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
)
continue;
if (!key.endsWith('[]')) result.delete(key);
result.append(key, value);
}
return result;
}
/** Only a query string is carried; the return destination is always local. */
export function catalogueDetailHref(
path: string,
query: string,
pageUrl?: string,
): string {
const target = new URL(path, 'https://portal.invalid');
if (target.origin !== 'https://portal.invalid') return path;
if (pageUrl) {
const page = new URL(
pageUrl,
@@ -20,16 +104,35 @@ export function catalogueDetailHref(
query = params.toString();
}
}
return query
? `${path}?catalogue_query=${encodeURIComponent(query)}`
: path;
const selected = catalogueQuery(target.pathname, query).toString();
if (selected) target.searchParams.set('catalogue_query', selected);
return target.pathname + target.search + target.hash;
}
export function catalogueReturnHref(base: string, pageUrl: string): string {
const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get(
'catalogue_query',
);
const source = new URL(pageUrl, 'https://portal.invalid');
const target = new URL(base, 'https://portal.invalid');
if (target.origin !== 'https://portal.invalid') return base;
const query = source.searchParams.get('catalogue_query');
const selected = catalogueQuery(target.pathname, query ?? '');
let changedAncestor = false;
for (const key of geography) {
if (target.searchParams.has(key))
changedAncestor ||=
target.searchParams.get(key) !== selected.get(key);
else if (changedAncestor) selected.delete(key);
}
const explicit = new Set(target.searchParams.keys());
for (const [key, value] of selected) {
// Text, kind, price and page belong to their original catalogue only.
if (
family(source.pathname) !== family(target.pathname) &&
!geography.includes(key)
)
continue;
if (!explicit.has(key)) target.searchParams.append(key, value);
}
return searchTerritoryHref(
base + (query ? `?${new URLSearchParams(query).toString()}` : ''),
target.pathname + target.search + target.hash,
pageUrl,
);
}
+8
View File
@@ -1,3 +1,11 @@
/** Historical definitions remain stored verbatim; internal delivery wording is not investor guidance. */
export function companyPreparationNotice(notice: string): string {
return notice.replace(
'Os sete exemplares referidos no complemento ainda não foram fornecidos.',
'Os modelos contratuais não estão disponíveis nesta preparação.',
);
}
export type Party = {
id: string;
name: string | null;
+124
View File
@@ -0,0 +1,124 @@
import type { DemographicAnalysis } from '../components/demographic-evidence';
type Series = DemographicAnalysis['territories'][number]['series'][number];
type Row = Series['values'][number];
const children = ['0-4', '5-9', '10-14'];
const adults = [
'15-19',
'20-24',
'25-29',
'30-34',
'35-39',
'40-44',
'45-49',
'50-54',
'55-59',
'60-64',
];
// Display-only derivation. Original decimals, persisted evidence and fiscal calculations are untouched.
function numeric(row: Row | undefined): number | null {
if (
!row ||
!['pessoas', 'persons'].includes(row.unit) ||
!/^\d+(\.\d+)?$/.test(row.value)
)
return null;
const n = Number(row.value);
return Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER ? n : null;
}
function one(rows: Row[], age: string, residence = 'all'): Row | undefined {
const found = rows.filter(
(r) =>
r.indicator === 'population' &&
r.age_group === age &&
r.residence === residence,
);
return found.length === 1 ? found[0] : undefined;
}
export const populationLabel = (n: number | null) =>
n === null
? 'Sem dados'
: new Intl.NumberFormat('pt-MZ', { maximumFractionDigits: 0 }).format(
n,
);
export const percentageLabel = (n: number | null) =>
n === null
? 'Sem dados'
: new Intl.NumberFormat('pt-MZ', {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
}).format(n) + '%';
export function demographicReading(series: Series, start: number, end: number) {
function year(year: number) {
const rows = series.values.filter((r) => r.year === year);
const totalRow = one(rows, 'all');
const total = numeric(totalRow);
const compatible = (r: Row | undefined) =>
r &&
totalRow &&
r.unit === totalRow.unit &&
r.data_kind === totalRow.data_kind;
const part = (age: string, residence = 'all') => {
const r = one(rows, age, residence);
return compatible(r) ? numeric(r) : null;
};
const sum = (ages: string[]) => {
const values = ages.map((age) => part(age));
return values.every((v) => v !== null)
? values.reduce<number>((a, v) => a + (v ?? 0), 0)
: null;
};
const share = (n: number | null) =>
total !== null && total > 0 && n !== null && n <= total
? (n / total) * 100
: null;
const urban = part('all', 'urban'),
rural = part('all', 'rural');
const residenceReconciled =
total !== null &&
urban !== null &&
rural !== null &&
Math.abs(urban + rural - total) <=
Math.max(0.000001, total * 1e-10);
const under15 = sum(children),
age15to64 = sum(adults);
return {
year,
total,
kind: totalRow?.data_kind,
unit: totalRow?.unit,
urban,
rural,
urbanShare: residenceReconciled ? share(urban) : null,
ruralShare: residenceReconciled ? share(rural) : null,
under15,
under15Share: share(under15),
age15to64,
age15to64Share: share(age15to64),
};
}
const first = year(start),
last = year(end);
const comparable =
end > start &&
first.total !== null &&
last.total !== null &&
first.kind === last.kind &&
first.unit === last.unit;
const delta = comparable ? last.total! - first.total! : null;
const growth =
delta !== null && first.total! > 0
? (delta / first.total!) * 100
: null;
return {
first,
last,
delta,
growth,
years: Array.from({ length: end - start + 1 }, (_, i) =>
year(start + i),
),
};
}
+14 -9
View File
@@ -329,19 +329,24 @@ export function buildLoanSchedule(input: LoanScheduleInput) {
}
/** A simple one-period budget allows an operating deficit; it is not forced to zero. */
function sumMonetaryInputs(values: string[], label: string): bigint {
if (!values.length)
throw new Error('Indique pelo menos uma parcela conhecida.');
return values.reduce(
(sum, value) => sum + decimalToInteger(value, 2, label),
0n,
);
}
export function sumFinancialAmounts(values: string[]): string {
return money(sumMonetaryInputs(values, 'Parcela'));
}
export function calculateBudget(incomes: string[], expenses: string[]) {
if (incomes.length === 0 || expenses.length === 0)
throw new Error(
'Introduza pelo menos uma linha de receitas e uma linha de despesas; use zero apenas quando esse valor for conhecido.',
);
const incomeTotal = incomes.reduce(
(sum, value) => sum + decimalToInteger(value, 2, 'Receita'),
0n,
);
const expenseTotal = expenses.reduce(
(sum, value) => sum + decimalToInteger(value, 2, 'Despesa'),
0n,
);
const incomeTotal = sumMonetaryInputs(incomes, 'Receita');
const expenseTotal = sumMonetaryInputs(expenses, 'Despesa');
return {
incomeTotal: money(incomeTotal),
expenseTotal: money(expenseTotal),
@@ -444,7 +449,7 @@ export function exportFinancialToolsCsv(
['Estado', 'Estimativa com pressupostos introduzidos pelo utilizador'],
[
'Fonte dos pressupostos',
'Valores editaveis desta ferramenta; os valores iniciais sao exemplos demonstrativos',
'Pressupostos editáveis introduzidos ou recuperados pelo utilizador; conferir antes de utilizar os resultados.',
],
['Período do orçamento e dos custos fixos', input.budgetPeriod],
[
@@ -0,0 +1,94 @@
export type OperatingAssumptions = {
model: '' | 'hotel' | 'agriculture' | 'other';
capacity: string;
price: string;
occupancy: string;
frequency: string;
source: string;
costs: { label: string; annual: string }[];
};
export const emptyOperatingAssumptions = (): OperatingAssumptions => ({
model: '',
capacity: '',
price: '',
occupancy: '',
frequency: '',
source: '',
costs: [],
});
export function restoreOperatingAssumptions(
snapshot: string,
): OperatingAssumptions {
try {
const parsed = JSON.parse(snapshot);
const data = parsed?.data;
if (
parsed.version !== 1 ||
!data ||
!['', 'hotel', 'agriculture', 'other'].includes(data.model)
)
return emptyOperatingAssumptions();
if (
!['capacity', 'price', 'occupancy', 'frequency', 'source'].every(
(key) =>
typeof data[key] === 'string' && data[key].length <= 1000,
)
)
return emptyOperatingAssumptions();
if (
!Array.isArray(data.costs) ||
data.costs.length > 30 ||
!data.costs.every(
(row: { label: unknown; annual: unknown }) =>
typeof row?.label === 'string' &&
typeof row?.annual === 'string' &&
row.label.length <= 120 &&
row.annual.length <= 40,
)
)
return emptyOperatingAssumptions();
return data;
} catch {
return emptyOperatingAssumptions();
}
}
function hundredths(value: string): bigint {
const match = /^(\d{1,15})(?:\.(\d{1,2}))?$/.exec(value.trim());
if (!match)
throw new Error(
'Indique cada pressuposto em falta, com até duas casas decimais.',
);
return BigInt(match[1]) * 100n + BigInt((match[2] ?? '').padEnd(2, '0'));
}
const money = (cents: bigint) =>
`${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`;
export function estimateOperatingRevenue(data: OperatingAssumptions): string {
if (!data.model)
throw new Error('Escolha o modelo de actividade que pretende estimar.');
const capacity = hundredths(data.capacity),
price = hundredths(data.price),
frequency = hundredths(data.frequency);
let numerator = capacity * price * frequency;
let divisor = 10000n;
if (data.model === 'hotel') {
const occupancy = hundredths(data.occupancy);
if (occupancy > 10000n)
throw new Error('A ocupação não pode exceder 100%.');
numerator *= occupancy;
divisor *= 10000n;
}
return money((numerator + divisor / 2n) / divisor);
}
export function estimateOperatingCosts(data: OperatingAssumptions): string {
if (!data.costs.length)
throw new Error(
'Acrescente as categorias de custos que conhece. Valores em falta não são zero.',
);
return money(
data.costs.reduce((total, row) => {
if (!row.label.trim())
throw new Error('Identifique a categoria de cada custo.');
return total + hundredths(row.annual);
}, 0n),
);
}
+31
View File
@@ -32,6 +32,27 @@ export type ContextDocument = {
opportunity_id: number;
download_url: string;
};
export type SupplyAggregate = {
context_revision: number;
recorded_at: string;
requested_territory: { id: string; name: string };
scope: { id: string; name: string; level: string };
broader_context: boolean;
entities: number;
repeated: number;
uncertain: number;
groups: {
code: string;
label: string;
total: number;
first_year: string | null;
last_year: string | null;
}[];
truncated: boolean;
coverage_unknown: boolean;
limitations: string;
interpretation: string;
};
export type ContextReference = {
key: string;
kind: string;
@@ -92,6 +113,7 @@ export type OpportunityChoice = {
source_sha256?: string | null;
};
export type DossierContext = {
promoter_entity?: PromoterEntity;
complements?: import('@/components/investment-complements').Complement[];
requirements_assessment?: RequirementsAssessment;
opportunity_choices?: OpportunityChoice[];
@@ -115,6 +137,12 @@ export type DossierContext = {
updated_at?: string;
dependency_hash?: string;
};
export type PromoterEntity = {
kind: 'company' | 'organization' | 'being_formed' | 'undetermined';
id?: string | null;
source_version?: number | null;
snapshot?: Record<string, string | null>;
};
export type ContextView = {
private_investment?: Preparation | null;
preparation_documents?: PreparationDocument[];
@@ -203,6 +231,9 @@ export async function contextApi<T>(
}
}
export const contextFieldNames: Record<string, string> = {
products_services: 'Actividade concreta e produto ou serviço',
investment_budget_min: 'Orçamento previsto — mínimo',
investment_budget_max: 'Orçamento previsto — máximo',
investment_form: 'Tipo do investimento',
investment_preparation: 'Preparação do pedido',
investment_activity_scope: 'Natureza da actividade',
+166 -4
View File
@@ -1,5 +1,7 @@
import { pathStates, type PathResult } from './investor-path.ts';
import type { SupplyAggregate } from './investor-context';
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
import { restoreOperatingAssumptions } from './investment-operating-assistance.ts';
import { buildProjectDossierRows } from './project-dossier.ts';
import type { SwotDraft } from './investment-preparation.ts';
import type {
@@ -34,6 +36,7 @@ export type PreparationReport = {
swot_review_required: boolean;
};
export type InvestorDossierReportInput = {
supply_aggregates?: SupplyAggregate[];
case: { id: string; title: string; version: number };
context_opportunity: string | null;
opportunity_options: unknown[];
@@ -322,6 +325,7 @@ export function createInvestorDossierReport(
generatedAt = new Date().toISOString(),
): DossierReportModel {
const index = investorDossierReportIndex(input);
const supply = input.supply_aggregates?.at(-1);
const row = (
field: string,
value: string,
@@ -339,11 +343,101 @@ export function createInvestorDossierReport(
result.context_state !== 'review_required'
? 'Estimativa guardada'
: 'Histórico — rever antes de utilizar';
const preparation = result.preparation_snapshot ?? {};
for (const [key, label] of Object.entries({
total_investment: 'Investimento declarado do cenário',
secured_amount: 'Financiamento efectivamente mobilizado',
planned_funding: 'Financiamento previsto — não mobilizado',
requested_funding: 'Financiamento solicitado — não mobilizado',
approved_funding: 'Financiamento aprovado — não mobilizado',
annual_revenue: 'Receitas anuais estimadas',
annual_operating_costs: 'Custos anuais estimados',
investment_budget_min: 'Orçamento previsto — mínimo do intervalo',
investment_budget_max: 'Orçamento previsto — máximo do intervalo',
})) {
if (typeof preparation[key] === 'string' && preparation[key].trim())
finance.push(
row(
label,
`${preparation[key]} ${result.currency ?? ''}`.trim(),
reference,
state,
),
);
}
const assumptions = restoreOperatingAssumptions(
preparation.planning_assumptions_snapshot ?? '',
);
if (assumptions.model) {
const assumptionLabels = {
model: 'Modelo escolhido para estimar receitas',
capacity:
assumptions.model === 'hotel'
? 'Quartos disponíveis'
: 'Unidades vendáveis por ciclo',
price: 'Preço declarado',
occupancy: 'Ocupação prevista (%)',
frequency:
assumptions.model === 'hotel'
? 'Dias de funcionamento no ano'
: 'Ciclos ou períodos no ano',
source: 'Fundamento dos pressupostos',
};
for (const [key, label] of Object.entries(assumptionLabels)) {
const value =
key === 'model'
? {
hotel: 'Hotel',
agriculture: 'Agronegócio',
other: 'Outra actividade',
}[assumptions.model]
: assumptions[key as keyof typeof assumptionLabels];
finance.push(
row(
label,
value || 'Por confirmar',
reference,
'Pressuposto declarado, não comprova procura ou rentabilidade',
),
);
}
for (const cost of assumptions.costs)
finance.push(
row(
`Custo anual — ${cost.label}`,
cost.annual || 'Por confirmar',
reference,
state,
),
);
}
try {
if (
result.engine !== 'financial-calculators:v1' ||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
)
const budget = JSON.parse(
preparation.initial_budget_snapshot ?? '{}',
);
if (budget.version === 1 && Array.isArray(budget.rows))
for (const part of budget.rows) {
if (
typeof part?.label === 'string' &&
typeof part?.amount === 'string'
)
finance.push(
row(
`Investimento inicial — ${part.label}`,
part.amount || 'Por confirmar',
reference,
state,
),
);
}
} catch {
/* The original remains stored; malformed optional details do not become invented amounts. */
}
try {
const tools = restoreFinancialToolsSnapshot(
result.financial_snapshot,
);
if (result.engine !== 'financial-calculators:v1' || !tools)
throw new Error('Versão não recuperável');
const currency = result.currency ?? '';
const baseline = new Set(
@@ -358,6 +452,25 @@ export function createInvestorDossierReport(
'Financiamento',
'Orçamento',
]);
if (
preparation.planning_loan === 'no' ||
[
tools.loan.principal,
tools.loan.rate,
tools.loan.months,
].every((value) => !value.trim())
)
sections.delete('Empréstimo educativo');
if (Object.values(tools.breakEven).every((value) => !value.trim()))
sections.delete('Ponto de equilíbrio');
if (
[...tools.incomes, ...tools.expenses].every(
(value) => !value.amount.trim(),
)
)
sections.delete('Orçamento');
if (Object.values(tools.funding).every((value) => !value.trim()))
sections.delete('Financiamento');
const rows = buildProjectDossierRows({
currency,
financial_tools_snapshot: result.financial_snapshot,
@@ -400,6 +513,50 @@ export function createInvestorDossierReport(
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
],
sections: [
{
id: 'supply',
title: 'Actividades existentes — análise agregada',
private: true,
paragraphs: supply
? [
`Consulta de ${supply.recorded_at}; contexto v${supply.context_revision}.`,
`Âmbito: ${supply.scope.name} (${supply.scope.level}). Local escolhido: ${supply.requested_territory.name}.`,
supply.broader_context
? 'Contexto administrativo mais amplo; não representa apenas a localidade escolhida.'
: 'Âmbito da localização escolhida.',
supply.context_revision !==
input.investment_context?.revision
? 'Contexto alterado; análise anterior preservada e por rever.'
: 'Análise guardada para esta revisão do contexto.',
supply.interpretation,
supply.limitations,
]
: ['Análise agregada ainda não guardada neste dossier.'],
rows: supply
? [
row(
'Entidades identificadas administrativamente',
String(supply.entities),
),
row(
'Observações repetidas, separadas do total',
String(supply.repeated),
),
row(
'Identificações incertas, separadas do total',
String(supply.uncertain),
),
...supply.groups.map((group) =>
row(
group.label,
String(group.total),
`${group.first_year ?? 'Período por confirmar'}–${group.last_year ?? ''}; ${supply.scope.name}`,
'Actividade actual e relação económica por confirmar',
),
),
]
: [],
},
{
id: 'project',
title: 'Investimento e localização em preparação',
@@ -410,11 +567,16 @@ export function createInvestorDossierReport(
rows: Object.entries({
project_title: 'Projecto',
project_summary: 'Descrição',
products_services: 'Actividade e produtos ou serviços',
sector: 'Sector',
province: 'Província',
district: 'Distrito',
location: 'Localização',
total_investment: 'Investimento previsto',
investment_budget_min:
'Orçamento previsto — mínimo do intervalo',
investment_budget_max:
'Orçamento previsto — máximo do intervalo',
currency: 'Moeda',
expected_start_date: 'Início previsto',
})
+1
View File
@@ -22,6 +22,7 @@ export type PathResult = {
context_opportunity?: string | null;
id: string;
financial_snapshot: string;
preparation_snapshot?: Record<string, string>;
currency: string | null;
draft_id: string;
draft_version: number;
+8 -8
View File
@@ -2,16 +2,16 @@ import { csvCell } from './csv.js';
export function defaultCashflowDraftInputs() {
return {
openingCash: '0',
monthlyRevenue: '200000',
monthlyOperatingCosts: '150000',
monthlyDebtService: '0',
openingCash: '',
monthlyRevenue: '',
monthlyOperatingCosts: '',
monthlyDebtService: '',
monthlyTaxAssessed: '',
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
vatCreditEstimated: '0',
vatRefundRequested: '0',
vatRefundRecognized: '0',
vatRefundExpectedReceipt: '0',
vatCreditEstimated: '',
vatRefundRequested: '',
vatRefundRecognized: '',
vatRefundExpectedReceipt: '',
vatRefundExpectedMonth: '',
};
}
+8 -1
View File
@@ -91,10 +91,17 @@ export function monthlyIvaInput(
draft: Record<string, unknown>,
settings: MonthlyIvaSettings,
): MonthlyIvaInput {
if (
draft.fiscal_period &&
inputText(draft.fiscal_period) !== settings.start.slice(0, 4)
)
throw new Error(
'O ano escolhido no enquadramento fiscal difere do primeiro mês da projecção. Confira o período; nenhum ano foi alterado automaticamente.',
);
const sales = monthlySalesFromDraft(draft);
if (!/^2026-(0[1-9]|1[0-2])$/.test(settings.start))
throw new Error(
'Indique o primeiro mês real da projecção. Este lote abrange apenas 2026.',
'Indique o primeiro mês real da projecção. Este cálculo dispõe de cobertura apenas para 2026.',
);
const start = Number(settings.start.slice(5));
if (sales.amounts.length > 12 || start + sales.amounts.length > 13)
+45
View File
@@ -0,0 +1,45 @@
export type NavigationFocus = {
case_id: string;
step_code: string | null;
version: number;
};
const key = (actor: number) => `investmoz-navigation-focus:${actor}`;
/** A per-tab hint, never authority to access a dossier. Recheck the server listing. */
export function readNavigationFocus(
actor: number,
allowed: string[],
): NavigationFocus | null {
try {
const value = JSON.parse(sessionStorage.getItem(key(actor)) ?? 'null');
if (
!value ||
!allowed.includes(value.case_id) ||
!Number.isSafeInteger(value.version) ||
value.version < 0
)
return null;
if (
value.step_code !== null &&
!/^S(0[1-9]|1[0-9])$/.test(value.step_code)
)
return null;
return {
case_id: value.case_id,
step_code: value.step_code,
version: value.version,
};
} catch {
return null;
}
}
export function rememberNavigationFocus(
actor: number,
focus: NavigationFocus | null,
): void {
try {
if (focus) sessionStorage.setItem(key(actor), JSON.stringify(focus));
else sessionStorage.removeItem(key(actor));
} catch {
// The current consultation remains usable when browser storage is blocked.
}
}
@@ -0,0 +1,61 @@
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
const amount = (value: unknown): string | null => {
if (typeof value !== 'string') return null;
const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
if (!match) return null;
return (
match[1].replace(/^0+(?=\d)/, '') +
'.' +
(match[2] ?? '').replace(/0+$/, '')
);
};
export const samePlanningAmount = (a: string, b: string) =>
(!a && !b) || (amount(a) !== null && amount(a) === amount(b));
/** Compare named origins without adopting either one or turning unknown into zero. */
export function fundingDifferences(
source: { total: string; secured: string },
calculator: { total: string; secured: string },
) {
return (['total', 'secured'] as const).filter(
(key) => !samePlanningAmount(source[key], calculator[key]),
);
}
/** Unknown calculator inputs may reuse the canonical source; a known divergent value requires a decision. */
export function canLinkFundingSource(
source: { total: string; secured: string } | undefined,
stored: { total: string; secured: string } | undefined,
): boolean {
return Boolean(
source &&
(!stored ||
(['total', 'secured'] as const).every(
(key) =>
!stored[key].trim() ||
samePlanningAmount(source[key], stored[key]),
)),
);
}
export function draftFundingDifferences(draft: Record<string, unknown>) {
const snapshot = restoreFinancialToolsSnapshot(
typeof draft.financial_tools_snapshot === 'string'
? draft.financial_tools_snapshot
: undefined,
);
if (!snapshot) return [];
return fundingDifferences(
{
total:
typeof draft.total_investment === 'string'
? draft.total_investment
: '',
secured:
typeof draft.secured_amount === 'string'
? draft.secured_amount
: '',
},
snapshot.funding,
);
}
+50
View File
@@ -0,0 +1,50 @@
/** Only confirmed context becomes a criterion; titles never assign a sector. */
export function preparationSearch(
query: string,
sector: string,
path: { id: string; location_type: string }[],
) {
const params = new URLSearchParams();
if (query.trim()) params.set('q', query.trim());
if (/^sector:[1-9]\d*$/.test(sector))
params.set('sector_id', sector.slice(7));
for (const place of path)
if (
['province', 'district', 'admin_post', 'locality'].includes(
place.location_type,
)
)
params.set(place.location_type + '_id', place.id);
return params.toString();
}
/** Carry only deliberate preparation criteria, never catalogue relationships. */
export function createInvestmentHref(
values: Record<string, unknown>,
path: { id: string; location_type: string }[] = [],
) {
const query = new URLSearchParams();
for (const key of ['sector_id', 'project_title', 'currency']) {
if (typeof values[key] === 'string' && values[key])
query.set(key, values[key]);
}
if (typeof values.sector === 'string' && values.sector)
query.set('sector', values.sector);
for (const [from, to] of [
['min_amount', 'investment_budget_min'],
['max_amount', 'investment_budget_max'],
] as const) {
if (typeof values[from] === 'string' && values[from] !== '')
query.set(to, values[from]);
}
for (const place of path)
if (
['province', 'district', 'admin_post', 'locality'].includes(
place.location_type,
)
)
query.set(place.location_type + '_id', place.id);
return (
'/investimentos/iniciar' + (query.size ? '?' + query.toString() : '')
);
}
+2 -1
View File
@@ -33,7 +33,8 @@ export const dossierReportSections = [
] as const;
export type DossierReportSectionId =
(typeof dossierReportSections)[number]['id'];
| (typeof dossierReportSections)[number]['id']
| 'supply';
export type DossierReference = {
version: number;
date: string;
+1
View File
@@ -38,6 +38,7 @@ export function simulationApiFieldErrors(
for (const [path, messages] of Object.entries(value.errors ?? {})) {
const key = path
.replace(/^(economic_snapshot|personal_snapshot)\./, '')
.replace(/^selection_labels\./, '')
.split('.')[0];
if (/^[a-z][a-z0-9_]*$/.test(key) && messages.length)
fields[key] = messages.join(' ');
+164 -2
View File
@@ -8,6 +8,114 @@ export type SimulationDraft = Record<string, string | string[]>;
export const projectQuestionnaireVersion =
'investmoz-project-dossier-2026-10-05.1';
export function isAssistedInvestmentJourney(draft: SimulationDraft): boolean {
return (
draft.journey_type === 'investor' &&
['simulate_only', 'own_investment'].includes(String(draft.purpose)) &&
draft.detail_level !== 'advanced'
);
}
export const assistedInvestmentSections = [
{ id: 'purpose', title: 'Intenção, entidade e localização' },
{ id: 'market', title: 'Mercado e dimensão do negócio' },
{ id: 'investment', title: 'Investimento inicial' },
{ id: 'financial', title: 'Financiamento' },
{ id: 'operating', title: 'Receitas e custos' },
{ id: 'cashflow', title: 'Tesouraria e cenários' },
{ id: 'tax', title: 'Enquadramento fiscal' },
{ id: 'review', title: 'Resultados e próximos passos' },
];
/** One field contract for rendering, validation and the final review. */
export function simulationFieldsForSection(
draft: SimulationDraft,
section: string,
) {
if (!isAssistedInvestmentJourney(draft))
return simulationFieldGroups[section] ?? [];
const unique = (fields: (typeof simulationFieldGroups)[string]) =>
fields.filter(
(field, index) =>
fields.findIndex((other) => other.key === field.key) === index,
);
if (section === 'purpose')
return unique([
...simulationFieldGroups.purpose.filter((field) =>
[
'journey_type',
'detail_level',
'purpose',
'preferred_language',
].includes(field.key),
),
...simulationFieldGroups.project.filter((field) =>
[
'project_title',
'project_summary',
'products_services',
'sector_id',
'activity_id',
].includes(field.key),
),
...simulationFieldGroups.location.filter((field) =>
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
].includes(field.key),
),
]);
if (section === 'market')
return unique([
...simulationFieldGroups.project.filter(
(field) =>
![
'project_title',
'project_summary',
'products_services',
'sector_id',
'activity_id',
].includes(field.key),
),
...simulationFieldGroups.market,
]);
if (section === 'investment')
return simulationFieldGroups.financial.filter((field) =>
['cost_estimate_status', 'currency', 'total_investment'].includes(
field.key,
),
);
if (section === 'financial')
return simulationFieldGroups.financial.filter(
(field) =>
![
'cost_estimate_status',
'currency',
'total_investment',
'annual_revenue',
'annual_operating_costs',
'expected_profit',
'receivables_timing_status',
'planning_loan',
].includes(field.key),
);
if (section === 'operating')
return simulationFieldGroups.financial.filter((field) =>
[
'annual_revenue',
'annual_operating_costs',
'expected_profit',
].includes(field.key),
);
if (section === 'cashflow')
return simulationFieldGroups.financial.filter((field) =>
['receivables_timing_status', 'planning_loan'].includes(field.key),
);
return simulationFieldGroups[section] ?? [];
}
function isProjectGuidedJourney(draft: SimulationDraft): boolean {
return (
draft.purpose === 'simulate_only' &&
@@ -80,6 +188,7 @@ export const simulationSections = [
/** Keep the anonymous arithmetic journey short; formal submissions retain the full questionnaire. */
export function getSimulationSections(draft: SimulationDraft) {
if (isAssistedInvestmentJourney(draft)) return assistedInvestmentSections;
if (isProjectGuidedJourney(draft)) {
if (draft.journey_type === 'small_business') {
return [
@@ -192,7 +301,28 @@ export const advancedSimulationFields = new Set([
'required_documents',
]);
export function simulationFieldAvailableAtDetailLevel(
draft: SimulationDraft,
section: string,
key: string,
): boolean {
return (
draft.detail_level === 'advanced' ||
(isAssistedInvestmentJourney(draft) &&
[
'secured_amount',
'sought_amount',
'annual_revenue',
'annual_operating_costs',
'expected_profit',
].includes(key)) ||
(section === 'procedures' && key === 'required_documents') ||
!advancedSimulationFields.has(key)
);
}
export const simulationOptions: Record<string, string[]> = {
planning_loan: ['yes', 'no', 'unknown'],
journey_type: ['personal', 'small_business', 'company', 'investor'],
detail_level: ['simple', 'advanced'],
purpose: [
@@ -784,6 +914,30 @@ export const simulationFieldGroups: Record<
},
],
financial: [
{
key: 'planning_loan',
label: 'Pretende estudar um empréstimo neste cenário?',
options: 'planning_loan',
showWhen: isAssistedInvestmentJourney,
},
{
key: 'planned_funding',
label: 'Financiamento previsto (ainda não mobilizado)',
type: 'number',
showWhen: isAssistedInvestmentJourney,
},
{
key: 'requested_funding',
label: 'Financiamento solicitado (ainda não mobilizado)',
type: 'number',
showWhen: isAssistedInvestmentJourney,
},
{
key: 'approved_funding',
label: 'Financiamento aprovado (ainda não mobilizado)',
type: 'number',
showWhen: isAssistedInvestmentJourney,
},
{
key: 'cost_estimate_status',
label: 'Já identificou os principais custos e respectiva periodicidade?',
@@ -1540,6 +1694,9 @@ export function restoreSimulationDraft(
export function simulationDraftFingerprint(draft: SimulationDraft): string {
const sort = (value: unknown): unknown => {
// Nullable empty form inputs are normalised to null by the HTTP
// middleware. They remain unknown, never numeric zero.
if (value === null) return '';
if (Array.isArray(value)) return value.map(sort);
if (value && typeof value === 'object')
return Object.fromEntries(
@@ -1561,7 +1718,7 @@ export function validateSimulationDraft(
if (sectionId === 'tax' && draft.fiscal_scenario_mode === 'custom_planning')
return {};
const errors: Record<string, string> = {};
for (const field of simulationFieldGroups[sectionId] ?? []) {
for (const field of simulationFieldsForSection(draft, sectionId)) {
if (field.showWhen && !field.showWhen(draft)) continue;
if (
sectionId === 'tax' &&
@@ -1581,7 +1738,12 @@ export function validateSimulationDraft(
if (
required &&
empty &&
!(sectionId === 'tax' && isInvestorProjectJourney(draft))
!(isAssistedInvestmentJourney(draft) && sectionId !== 'purpose') &&
!(
sectionId === 'tax' &&
(isInvestorProjectJourney(draft) ||
isAssistedInvestmentJourney(draft))
)
) {
errors[field.key] = 'Este campo é obrigatório.';
}
+259 -100
View File
@@ -9,14 +9,25 @@ import {
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { type Programme, windowLabels } from './shared';
import { useRef, useState } from 'react';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
export default function Index({
export default function FundingIndex(props: Parameters<typeof Index>[0]) {
return <Index key={JSON.stringify(props.filters)} {...props} />;
}
function Index({
items,
filters,
kinds,
management,
options,
needs = {},
categoryTypes = {},
territorySelection = [],
criteriaErrors = {},
}: {
items: {
data: Programme[];
@@ -36,6 +47,10 @@ export default function Index({
institution_type?: string;
location_mode?: string;
include_historical?: string;
province_id?: string;
district_id?: string;
admin_post_id?: string;
locality_id?: string;
};
options: {
sectors: { id: number; name: string }[];
@@ -44,7 +59,11 @@ export default function Index({
kinds: Record<string, string>;
needs?: Record<string, string>;
management: boolean;
categoryTypes?: Record<string, string[]>;
territorySelection?: TerritoryOption[];
criteriaErrors?: Record<string, string[]>;
}) {
const [notice, setNotice] = useState('');
const form = useForm({
category: filters.category ?? '',
institution_type: filters.institution_type ?? '',
@@ -57,9 +76,21 @@ export default function Index({
kind: filters.kind ?? '',
sector: filters.sector ?? '',
territory: filters.territory ?? '',
province_id: filters.province_id ?? '',
district_id: filters.district_id ?? '',
admin_post_id: filters.admin_post_id ?? '',
locality_id: filters.locality_id ?? '',
certainty: filters.certainty ?? 'include_unknown',
state: filters.state ?? '',
});
const applied = useRef(JSON.stringify(form.data));
const criteriaChanged = JSON.stringify(form.data) !== applied.current;
const typeAllowed = (category: string, type: string) =>
!category ||
category === 'other' ||
!type ||
type === 'other' ||
(categoryTypes[category] ?? []).includes(type);
return (
<Frame title="Instituições financeiras, fundos e programas de apoio">
{management && (
@@ -76,20 +107,96 @@ export default function Index({
investimento.
</p>
<form
className={`${card} flex flex-wrap gap-4`}
className={`${card} flex flex-wrap gap-4 [&_select]:w-full [&_select]:max-w-full [&_select]:min-w-0 [&>label]:w-full [&>label]:max-w-full [&>label]:min-w-0 sm:[&>label]:w-auto`}
onSubmit={(e) => {
e.preventDefault();
form.get('/fundos');
}}
>
{Object.keys(criteriaErrors).length > 0 && (
<aside
className="basis-full rounded border border-amber-600 p-3"
role="alert"
>
<h2 className="font-semibold">
Reveja esta combinação de critérios
</h2>
{Object.entries(criteriaErrors).map(
([key, messages]) => (
<p key={key}>{messages.join(' ')}</p>
),
)}
<p>
A pesquisa não foi executada. Corrija os critérios
indicados e volte a pesquisar.
</p>
</aside>
)}
{notice && (
<p className="basis-full" role="status">
{notice}
</p>
)}
{Object.entries(form.errors).map(([key, error]) => (
<p className="basis-full" role="alert" key={key}>
{error}
</p>
))}
{form.data.kind &&
form.data.kind !== 'institution' &&
(form.data.category ||
form.data.institution_type ||
form.data.location_mode === 'physical') && (
<aside className="basis-full rounded border p-3">
<p>
Categoria, tipo de instituição e presença física
não identificam um fundo ou programa. Reveja o
tipo de registo ou retire apenas estes critérios
incompatíveis.
</p>
<button
type="button"
className="underline"
onClick={() => {
form.setData({
...form.data,
category: '',
institution_type: '',
location_mode: 'coverage',
});
setNotice(
'Critérios de instituição retirados por sua escolha. Local, sector, necessidade e janela foram preservados.',
);
}}
>
Manter o fundo/programa e rever os critérios de
instituição
</button>
</aside>
)}
<label>
Categoria
Categoria da instituição
<select
className="block rounded border p-2"
value={form.data.category}
onChange={(e) =>
form.setData('category', e.target.value)
}
onChange={(e) => {
const category = e.target.value;
const valid = typeAllowed(
category,
form.data.institution_type,
);
if (!valid)
setNotice(
'O tipo anterior não pertence à nova categoria e foi retirado. Escolha um dos tipos aplicáveis.',
);
form.setData({
...form.data,
category,
institution_type: valid
? form.data.institution_type
: '',
});
}}
>
<option value="">Todas</option>
{Object.entries(financialCategories).map(
@@ -102,7 +209,7 @@ export default function Index({
</select>
</label>
<label>
Tipo
Tipo de instituição
<select
className="block rounded border p-2"
value={form.data.institution_type}
@@ -111,11 +218,19 @@ export default function Index({
}
>
<option value="">Todos</option>
{Object.entries(financialTypes).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
{Object.entries(financialTypes)
.filter(
([id]) =>
typeAllowed(form.data.category, id) ||
id === form.data.institution_type,
)
.map(([id, label]) => (
<option key={id} value={id}>
{typeAllowed(form.data.category, id)
? label
: 'Incompatível: ' + label}
</option>
))}
</select>
</label>
<label>
@@ -174,7 +289,7 @@ export default function Index({
/>
</label>
<label>
Tipo
Tipo de registo a consultar
<select
className="block rounded border p-2"
value={form.data.kind}
@@ -206,23 +321,41 @@ export default function Index({
))}
</select>
</label>
<label>
Local de interesse
<select
className="block rounded border p-2"
value={form.data.territory}
onChange={(e) =>
form.setData('territory', e.target.value)
<fieldset className="min-w-0 basis-full">
<legend>
Local de interesse — seleccione até ao nível conhecido
</legend>
<TerritoryFields
initial={territorySelection}
showMap={false}
onChange={(path) =>
form.setData({
...form.data,
territory: path.at(-1)?.id ?? '',
province_id:
path.find(
(node) =>
node.location_type === 'province',
)?.id ?? '',
district_id:
path.find(
(node) =>
node.location_type === 'district',
)?.id ?? '',
admin_post_id:
path.find(
(node) =>
node.location_type === 'admin_post',
)?.id ?? '',
locality_id:
path.find(
(node) =>
node.location_type === 'locality',
)?.id ?? '',
})
}
>
<option value="">Todos</option>
{options.territories.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
/>
</fieldset>
<label>
Informação por confirmar
<select
@@ -256,82 +389,108 @@ export default function Index({
</select>
</label>
</form>
<p className="text-sm">
Explore as instituições e soluções adequadas ao seu
investimento. Use os filtros para encontrar a informação de que
precisa.
</p>
{items.data.length === 0 && (
<p>
Não foram encontrados programas publicados para esta
pesquisa.
{criteriaChanged && (
<p role="status">
Alterou os critérios. Clique em Pesquisar para os aplicar.
Os resultados anteriores ficam separados abaixo.
</p>
)}
{items.data.map((item) => (
<article className={card} key={item.id}>
<h2 className="text-xl font-semibold">
<Link
className="underline"
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
>
{item.payload.title}
</Link>
</h2>
{item.payload.kind === 'institution' ? (
<InstitutionView
compact
value={
item.payload as InstitutionDetails & {
title: string;
summary: string;
}
}
/>
) : (
<>
<p>{item.payload.summary}</p>
<p>Entidade gestora: {item.payload.manager_name}</p>
{Object.entries(item.filter_explanation ?? {}).map(
([key, message]) => (
<p key={key}>{message}</p>
),
)}
</>
)}
{item.payload.kind === 'institution' &&
item.payload.windows.length === 0 ? null : item.payload
.windows.length === 0 ? (
<details
open={
!criteriaChanged && Object.keys(criteriaErrors).length === 0
}
className="space-y-3"
>
<summary>
{Object.keys(criteriaErrors).length
? 'Pesquisa não executada — critérios inválidos'
: criteriaChanged
? 'Resultados da pesquisa anterior — não correspondem aos critérios por aplicar'
: 'Resultados correspondentes à pesquisa'}
</summary>
<p className="text-sm">
Explore as instituições e soluções adequadas ao seu
investimento. Use os filtros para encontrar a informação de
que precisa.
</p>
{items.data.length === 0 &&
Object.keys(criteriaErrors).length === 0 && (
<p>
Não existe uma janela de candidatura confirmada para
este programa.
Não foram encontradas instituições, fundos ou
programas publicados que correspondam a todos os
critérios desta pesquisa.
</p>
) : (
<ul>
{item.payload.windows.map((window) => (
<li key={window.code}>
{window.title}: {windowLabels[window.state]}
</li>
))}
</ul>
)}
</article>
))}
<nav aria-label="Páginas do catálogo" className="flex gap-3">
{items.links.map((link, index) =>
link.url ? (
<Link
key={index}
href={link.url}
aria-current={link.active ? 'page' : undefined}
className="underline"
>
{link.label
.replace(/&laquo;/g, '«')
.replace(/&raquo;/g, '»')}
</Link>
) : null,
)}
</nav>
{items.data.map((item) => (
<article className={card} key={item.id}>
<h2 className="text-xl font-semibold">
<Link
className="underline"
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
>
{item.payload.title}
</Link>
</h2>
{item.payload.kind === 'institution' ? (
<InstitutionView
compact
value={
item.payload as InstitutionDetails & {
title: string;
summary: string;
}
}
/>
) : (
<>
<p>{item.payload.summary}</p>
<p>
Entidade gestora:{' '}
{item.payload.manager_name}
</p>
{Object.entries(
item.filter_explanation ?? {},
).map(([key, message]) => (
<p key={key}>{message}</p>
))}
</>
)}
{item.payload.kind === 'institution' &&
item.payload.windows.length === 0 ? null : item.payload
.windows.length === 0 ? (
<p>
Não existe uma janela de candidatura confirmada
para este programa.
</p>
) : (
<ul>
{item.payload.windows.map((window) => (
<li key={window.code}>
{window.title}:{' '}
{windowLabels[window.state]}
</li>
))}
</ul>
)}
</article>
))}
<nav aria-label="Páginas do catálogo" className="flex gap-3">
{items.links.map((link, index) =>
link.url ? (
<Link
key={index}
href={link.url}
aria-current={link.active ? 'page' : undefined}
className="underline"
>
{link.label
.replace(/&laquo;/g, '«')
.replace(/&raquo;/g, '»')}
</Link>
) : null,
)}
</nav>
</details>
</Frame>
);
}
@@ -3,6 +3,7 @@ import {
persistOpportunitySelection,
} from '@/lib/opportunity-selection';
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
import PublishedOpportunitiesEmpty from '@/components/published-opportunities-empty';
import DemographicContext from '@/components/demographic-context';
import OpportunityCorridorGroups, {
type CorridorGroup,
@@ -409,6 +410,10 @@ export default function Catalogue({
: 'Catálogo de investimento'
}
>
<section className="my-4 space-y-3 rounded border p-4" aria-label="Estudar a localização">
<h2 className="font-semibold">Estudar a localização e preparar uma ideia</h2>
<p>A análise territorial é independente dos anúncios publicados. Os filtros comerciais abaixo aplicam-se à pesquisa de oportunidades.</p>
<div className="flex flex-wrap items-center gap-4">
<Link
className="underline"
href={
@@ -435,6 +440,8 @@ export default function Catalogue({
Iniciar ou continuar ideia nesta localização
</Link>
)}
</div>
</section>
<p className="catalogue-intro">
Combine os filtros para encontrar oportunidades publicadas.
Nenhum critério é alargado automaticamente.
@@ -506,6 +513,15 @@ export default function Catalogue({
error={searchError}
total={results.total}
/>
{!searchLoading &&
!searchError &&
results.total === 0 &&
selectedStructures.length === 0 && (
<PublishedOpportunitiesEmpty
href="/localizacoes/demografia"
label="Consultar indicadores territoriais"
/>
)}
{!searchLoading &&
!searchError &&
results.total === 0 &&
@@ -36,6 +36,7 @@ type Result = {
}[];
};
type Props = {
entity_kind?: string;
definition: { version: string; questions: Record<string, Question> };
case: { id: string; title: string; version: number } | null;
canWrite: boolean;
@@ -98,7 +99,7 @@ function Comparator(props: Props) {
}),
);
};
async function save() {
async function save(next?: string) {
if (!dossier || busy) return;
setBusy(true);
setError('');
@@ -107,8 +108,9 @@ function Comparator(props: Props) {
version: dossier.version,
operation_key: operation.current,
answers: currentValues(),
...(next ? { continue_preparation: true } : {}),
});
router.visit(base, { preserveState: false });
router.visit(next ?? base, { preserveState: false });
} catch (e) {
setError(
e instanceof Error
@@ -222,7 +224,7 @@ function Comparator(props: Props) {
type="button"
className={button}
disabled={busy}
onClick={save}
onClick={() => void save()}
>
{busy ? 'A guardar…' : 'Guardar no dossier'}
</button>
@@ -321,9 +323,36 @@ function Comparator(props: Props) {
</article>
))}
</div>
<h3 className="mt-4 font-semibold">
Preparar a próxima conversa
</h3>
<h3 className="mt-4 font-semibold">Continuar a preparação</h3>
{dossier &&
!['company', 'organization'].includes(
props.entity_kind ?? '',
) &&
!readOnly &&
props.canWrite &&
answers.preferred &&
answers.preferred !== 'unknown' && (
<button
className={button}
type="button"
disabled={busy}
onClick={() =>
void save(
`/investimentos/${dossier.id}/preparar-empresa?form=${encodeURIComponent(answers.preferred)}`,
)
}
>
Usar esta opção e continuar a preparação da empresa
</button>
)}
{dossier && (
<Link
className="block underline"
href={`/simulador-fiscal?context=${dossier.id}`}
>
Continuar a viabilidade sem repetir esta comparação
</Link>
)}
<ul className="list-disc pl-5">
{result.preparation.map((item) => (
<li key={item}>{item}</li>
@@ -1,9 +1,11 @@
import CurrencySelect from '@/components/currency-select';
import { useRef, useState } from 'react';
import { Link, router } from '@inertiajs/react';
import { Frame, card, input, button } from './shared';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import {
companyPreparationNotice,
preparationValues,
type CompanyFacts,
type Party,
@@ -34,12 +36,17 @@ type Props = {
>;
history: { id: string; revision: number; at: string }[];
historyNext: number | null;
entity?: {
kind: string;
snapshot?: { name?: string; legal_form?: string };
} | null;
proposedForm?: string | null;
};
export default function CompanyPreparation(props: Props) {
return (
<Preparation
key={`${props.case.id}:${props.saved?.revision}:${props.historical}`}
key={`${props.case.id}:${props.saved?.revision}:${props.historical}:${props.proposedForm ?? ''}`}
{...props}
/>
);
@@ -86,19 +93,19 @@ function Preparation(props: Props) {
onChange={(e) => update(key, e.target.value)}
maxLength={2000}
/>
) : key === 'currency' ? (
<CurrencySelect
label="Moeda"
value={facts[key] ?? ''}
onChange={(value) => update(key, value)}
/>
) : (
<input
className={input}
name={key}
value={facts[key] ?? ''}
onChange={(e) => update(key, e.target.value)}
maxLength={
key === 'proposed_name'
? 250
: key === 'currency'
? 3
: 25
}
maxLength={key === 'proposed_name' ? 250 : 25}
/>
)}
</label>
@@ -138,7 +145,7 @@ function Preparation(props: Props) {
</label>
);
}
async function save() {
async function save(next?: string) {
if (busy || readOnly) return;
setBusy(true);
setError('');
@@ -151,7 +158,7 @@ function Preparation(props: Props) {
operation_key: operation.current,
facts: values,
});
router.visit(base, { preserveState: false });
router.visit(next ?? base, { preserveState: false });
} catch (e) {
setError(
e instanceof Error
@@ -178,7 +185,41 @@ function Preparation(props: Props) {
}
}
return (
<Frame title="Preparar a constituição da empresa">
<Frame
title={
['company', 'organization'].includes(props.entity?.kind ?? '')
? 'Preparação da empresa existente'
: 'Preparar a constituição da empresa'
}
>
{props.entity?.snapshot?.name && (
<p>
Entidade associada: {props.entity.snapshot.name}. Os dados
recuperados não alteram o cadastro nem comprovam um acto
legal.
</p>
)}
{props.proposedForm &&
facts.form !== props.proposedForm &&
!readOnly && (
<aside className={card}>
<p>
A alternativa escolhida não substituiu a forma já
preparada.
</p>
<button
type="button"
className="underline"
onClick={() => {
update('form', props.proposedForm!);
setDirty(true);
operation.current = createOperationKey();
}}
>
Usar a alternativa escolhida nesta preparação
</button>
</aside>
)}
<p>
<Link
className="underline"
@@ -194,7 +235,7 @@ function Preparation(props: Props) {
Comparar formas jurídicas
</Link>
</p>
<p>{props.definition.template_notice}</p>
<p>{companyPreparationNotice(props.definition.template_notice)}</p>
<p>
Guarde mesmo que faltem dados. Só se pedem informações desta
preparação; valores indicados como realizados são declarações,
@@ -619,6 +660,18 @@ function Preparation(props: Props) {
<button type="submit" className={button}>
Guardar ficha no dossier
</button>
<button
type="button"
className={button}
disabled={busy}
onClick={() =>
void save(
`/simulador-fiscal?context=${props.case.id}`,
)
}
>
Guardar e continuar para a viabilidade
</button>
</section>
</fieldset>
</form>
+126 -23
View File
@@ -1,11 +1,21 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
import DemographicEvidence, {
type DemographicAnalysis,
} from '@/components/demographic-evidence';
import { Frame, button } from './shared';
export default function Demography({
export default function Demography(
props: Parameters<typeof DemographySelection>[0],
) {
// Server filters identify this consultation, including browser back/forward.
return (
<DemographySelection key={JSON.stringify(props.filters)} {...props} />
);
}
function DemographySelection({
evidence,
filters,
territories,
@@ -27,6 +37,10 @@ export default function Demography({
sectors: Record<string, string>;
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [province, setProvince] = useState('');
const [district, setDistrict] = useState('');
const [selectionNotice, setSelectionNotice] = useState('');
const candidate = district || province;
const [start, setStart] = useState(
filters.start_year ?? new Date().getFullYear(),
);
@@ -55,31 +69,96 @@ export default function Demography({
);
}}
>
<label>
Províncias ou distritos (até quatro)
<select
multiple
className="block h-48 w-full rounded border p-2"
value={selected}
onChange={(e) =>
setSelected(
Array.from(
e.target.selectedOptions,
(o) => o.value,
).slice(0, 4),
<fieldset className="min-w-0 space-y-3">
<legend>Localizações a comparar (até quatro)</legend>
<label htmlFor="demography-province" className="block">
Província
</label>
<CatalogueChoiceSelect
id="demography-province"
label="Província da comparação"
value={province}
options={territories
.filter((t) => t.location_type === 'province')
.map((t) => ({ value: t.id, label: t.name }))}
onChange={(value) => {
setProvince(value);
if (district)
setSelectionNotice(
'A escolha do distrito foi limpa. Os locais já acrescentados à comparação foram mantidos.',
);
setDistrict('');
}}
/>
<label htmlFor="demography-district" className="block">
Distrito (opcional)
</label>
<CatalogueChoiceSelect
id="demography-district"
label="Distrito da comparação (opcional)"
value={district}
disabled={!province}
options={territories
.filter(
(t) =>
t.location_type === 'district' &&
t.parent_id === province,
)
.map((t) => ({ value: t.id, label: t.name }))}
onChange={setDistrict}
/>
<p>
Pode comparar apenas a província ou escolher um dos seus
distritos.
</p>
{selectionNotice && <p role="status">{selectionNotice}</p>}
<button
type="button"
className={button}
disabled={
!candidate ||
selected.length >= 4 ||
selected.includes(candidate)
}
onClick={() =>
setSelected((current) => [...current, candidate])
}
>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name} —{' '}
{t.location_type === 'province'
? 'Província'
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
</option>
))}
</select>
</label>
Acrescentar à comparação
</button>
<ul className="space-y-2">
{selected.map((id) => {
const t = territories.find(
(item) => item.id === id,
);
const label = t
? `${t.name} — ${t.location_type === 'province' ? 'Província' : 'Distrito'}`
: 'Localização guardada';
return (
<li
key={id}
className="flex flex-wrap items-center gap-2"
>
<span>{label}</span>
<button
type="button"
className="underline"
aria-label={`Retirar ${label} da comparação`}
onClick={() =>
setSelected((current) =>
current.filter(
(value) => value !== id,
),
)
}
>
Retirar
</button>
</li>
);
})}
</ul>
</fieldset>
<div className="space-y-3">
<label className="block">
Sector
@@ -123,6 +202,30 @@ export default function Demography({
</div>
</form>
<DemographicEvidence evidence={evidence} />
{evidence.territories.length > 0 && (
<section className="space-y-3 rounded border p-4">
<h2 className="text-xl font-semibold">
Transformar a análise numa ideia
</h2>
<p>
Prepare uma ideia nesta localização e confronte-a com a
oferta existente. Não é necessário haver uma
oportunidade publicada para iniciar a análise.
</p>
{evidence.territories.map((t) => (
<a
key={t.id}
className="block py-2 underline"
href={
'/investimentos/iniciar?source=' +
encodeURIComponent('territory:' + t.id)
}
>
Preparar ideia em {t.name}
</a>
))}
</section>
)}
</Frame>
);
}
+21
View File
@@ -34,6 +34,7 @@ const labels: Record<string, string> = {
investment_capacity: 'Capacidade de investimento declarada',
name: 'Nome da empresa',
registration: 'Referência de registo / NUIT',
legal_form: 'Forma jurídica declarada',
role: 'Função ou relação com a empresa',
description: 'Actividade e observações',
};
@@ -45,9 +46,13 @@ export default function Profile({
countries,
organizations = [],
organizationCountryAvailable = false,
continuation = null,
legalForms = [],
}: {
account: RecordData;
actorId: number;
continuation?: { id: string; title: string; href: string } | null;
legalForms?: { value: string; label: string }[];
companies: Company[];
countries: Country[];
organizations?: Organization[];
@@ -69,6 +74,17 @@ export default function Profile({
);
return (
<Frame title="Perfil de investimento e empresas">
{continuation && (
<aside className={card}>
<p>
Preparação de {continuation.title}. O cadastro no portal
não comprova a constituição legal.
</p>
<Link className="underline" href={continuation.href}>
Voltar ao dossier e associar esta empresa
</Link>
</aside>
)}
<Link className="underline" href="/formas-juridicas">
Comparar formas jurídicas antes de constituir uma entidade
</Link>
@@ -88,6 +104,7 @@ export default function Profile({
actorId={actorId}
editorId="account"
labels={labels}
legalForms={legalForms}
reportDirty={reportDirty}
record={account}
countries={countries}
@@ -132,6 +149,7 @@ export default function Profile({
actorId={actorId}
editorId={company.id}
labels={labels}
legalForms={legalForms}
reportDirty={reportDirty}
record={company}
organizations={organizations}
@@ -140,6 +158,7 @@ export default function Profile({
'name',
'incorporation_country_code',
'registration',
'legal_form',
'sector',
'role',
'description',
@@ -172,6 +191,7 @@ export default function Profile({
actorId={actorId}
editorId="new-company"
labels={labels}
legalForms={legalForms}
reportDirty={reportDirty}
record={{ version: 0, payload: {} }}
company
@@ -180,6 +200,7 @@ export default function Profile({
'name',
'incorporation_country_code',
'registration',
'legal_form',
'sector',
'role',
'description',
@@ -12,6 +12,7 @@ import {
type OpportunityChoice,
} from '@/lib/investor-context';
import { createOperationKey } from '@/lib/operation-key';
import InvestmentBudgetRange from '@/components/investment-budget-range';
export default function ContextStart({
contexts,
source,
@@ -36,7 +37,10 @@ export default function ContextStart({
0,
200,
)
: (source?.title ?? opportunityChoices[0]?.opportunity.title ?? ''),
: (entryQuery.project_title ??
source?.title ??
opportunityChoices[0]?.opportunity.title ??
''),
);
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
@@ -80,6 +84,14 @@ export default function ContextStart({
};
return (
<Frame title="Iniciar ou continuar um investimento">
{!source && !opportunityChoices.length && (
<p>
Prepare a sua ideia no mesmo dossier, mesmo sem anúncios
publicados. Corredores, parques e zonas são associações
opcionais.
</p>
)}
<InvestmentBudgetRange values={entryQuery} />
<SelectedComplements items={entryComplements} />
{!!opportunityChoices.length && (
<section className={card}>
+445 -281
View File
@@ -29,14 +29,30 @@ import OpportunityChoices from '@/components/investment-opportunity-choices';
import InvestmentRequirements from '@/components/investment-requirements';
import type { OpportunityChoice } from '@/lib/investor-context';
import type { CorridorOption } from '@/components/investment-opportunity-choices';
import CountrySelect, { type Country } from '@/components/country-select';
import CurrencySelect from '@/components/currency-select';
import InvestmentBudgetRange from '@/components/investment-budget-range';
import DemographicContext from '@/components/demographic-context';
import { preparationSearch } from '@/lib/preparation-intent';
type Options = {
opportunities: { key: string; title: string; kind: string }[];
opportunities: {
key: string;
title: string;
kind: string;
relevance?: string;
}[];
countries?: Country[];
sectors: { id: number; name: string }[];
parks: { id: string; name: string; version_no: number }[];
search_limited: boolean;
};
type Slot = 'portfolio' | 'opportunity' | 'sector' | 'territory' | 'park';
export default function ContextEditor({
export default function ContextEditor(
props: Parameters<typeof ScopedContextEditor>[0],
) {
return <ScopedContextEditor key={props.initial.case.id} {...props} />;
}
function ScopedContextEditor({
initial,
options: initialOptions,
source,
@@ -47,6 +63,7 @@ export default function ContextEditor({
entryLocationDifferences = [],
entryComplements = [],
entryTerritory = null,
entryIdea = {},
}: {
initial: ContextView;
options: Options;
@@ -56,6 +73,7 @@ export default function ContextEditor({
entrySources?: string[];
entryComplements?: Complement[];
entryTerritory?: ContextReference | null;
entryIdea?: Record<string, string>;
entryLocationDifferences?: {
title: string;
saved: string;
@@ -78,7 +96,21 @@ export default function ContextEditor({
>([]);
const [complementError, setComplementError] = useState('');
const [choices, setChoices] = useState<OpportunityChoice[]>(
requestedChoices ?? initial.context.opportunity_choices ?? [],
requestedChoices ??
(initial.context.opportunity_choices?.length
? initial.context.opportunity_choices
: initial.context.selection.opportunity?.kind === 'opportunity'
? [
{
opportunity: initial.context.selection.opportunity,
territory:
initial.context.selection.territory ?? null,
territory_path: territorySelection,
location_reason: '',
location_state: 'undetermined',
},
]
: []),
);
const [manageChoices, setManageChoices] = useState(
requestedChoices !== null ||
@@ -149,8 +181,17 @@ export default function ContextEditor({
const [preparation, setPreparation] = useState(initial.private_investment);
const [options, setOptions] = useState(initialOptions);
const [query, setQuery] = useState('');
const [relatedOpen, setRelatedOpen] = useState(false);
const [relatedSearch, setRelatedSearch] = useState<string | null>(null);
const [consultationPath, setConsultationPath] =
useState(territorySelection);
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(
() => ({
...(entryIdea.sector_id &&
!initial.context.selection.sector &&
!draft?.selection?.sector
? { sector: 'sector:' + entryIdea.sector_id }
: {}),
...draft?.selection,
...(entryTerritory &&
!draft?.selection?.territory &&
@@ -159,7 +200,7 @@ export default function ContextEditor({
: {}),
...(source?.kind === 'portfolio'
? { portfolio: source.key }
: draft?.selection
: draft?.selection || source?.kind === 'territory'
? {}
: source
? {
@@ -171,7 +212,20 @@ export default function ContextEditor({
}),
);
const [declarations, setDeclarations] = useState<Record<string, string>>(
draft?.declarations ?? {},
() => ({
...Object.fromEntries(
Object.entries(entryIdea).filter(
([key]) =>
[
'project_title',
'investment_budget_min',
'investment_budget_max',
'currency',
].includes(key) && !initial.context.fields[key],
),
),
...draft?.declarations,
}),
);
const [documentLinks, setDocumentLinks] = useState<
Record<string, string | null>
@@ -188,21 +242,32 @@ export default function ContextEditor({
const [message, setMessage] = useState('');
const [corridors, setCorridors] = useState<CorridorOption[]>([]);
const [parks, setParks] = useState<Options['parks']>([]);
const [corridorLoading, setCorridorLoading] = useState(true);
const [corridorLoading, setCorridorLoading] = useState(
choices.length > 0 || consultationPath.length > 0,
);
const [corridorError, setCorridorError] = useState('');
const [corridorRetry, setCorridorRetry] = useState(0);
const [draftStatus, setDraftStatus] = useState('');
const [draftRetry, setDraftRetry] = useState(0);
const queue = useRef<Promise<unknown>>(Promise.resolve());
const firstCorridor =
choices[0]?.corridor_key ??
corridors.find((c) => c.name === choices[0]?.coverage)?.key;
const firstTerritory = choices[0]?.territory?.id;
choices[0]?.corridor_key !== undefined
? choices[0].corridor_key
: corridors.find((c) => c.name === choices[0]?.coverage)?.key;
const firstTerritory =
choices[0]?.territory?.id ?? consultationPath.at(-1)?.id;
useEffect(() => {
let active = true;
if (!choices.length && !firstTerritory) {
setCorridorLoading(false);
setCorridorError('');
setParks([]);
return;
}
setCorridorLoading(true);
setCorridorError('');
const params = new URLSearchParams();
if (!choices.length) params.set('include_corridors', '0');
if (firstCorridor) params.set('corridor', firstCorridor);
if (firstTerritory) params.set('territory', firstTerritory);
void contextApi<{
@@ -222,10 +287,9 @@ export default function ContextEditor({
park &&
!data.parks.some((p) => 'park:' + p.id === park)
) {
setChanges((old) => ({ ...old, park: '' }));
setPlan(null);
// Keep the previous association until the investor reviews it.
setMessage(
'A zona ou parque anterior foi retirado porque não tem enquadramento compatível com a localização escolhida.',
'Reveja a zona ou parque anterior: a compatibilidade com esta escolha ainda nao foi confirmada.',
);
}
})
@@ -238,7 +302,7 @@ export default function ContextEditor({
return () => {
active = false;
};
}, [firstCorridor, firstTerritory, corridorRetry]);
}, [firstCorridor, firstTerritory, corridorRetry, choices.length]);
const draftBody = JSON.stringify({
complements: complements.map(complementInput),
entry_sources: entrySources,
@@ -512,8 +576,8 @@ export default function ContextEditor({
</Link>
<p>{view.case.title}</p>
<p>
Confirme as oportunidades e a localização, complete os dados
disponíveis e reveja antes de guardar.
Confirme a sua ideia, o sector e a localização. Complete
apenas o que falta; associar anúncios é opcional.
</p>
<SelectedComplements
items={complements}
@@ -616,7 +680,9 @@ export default function ContextEditor({
</p>
)}
{corridorLoading && (
<p role="status">A carregar opções do corredor…</p>
<p role="status">
A consultar associações documentadas…
</p>
)}
<p role="status">{draftStatus}</p>
{draft?.needs_review && (
@@ -635,16 +701,20 @@ export default function ContextEditor({
Tentar guardar novamente
</button>
)}
<OpportunityChoices
corridors={corridors}
choices={choices}
disabled={busy || corridorLoading || !!corridorError}
onChange={(next) => {
setChoices(next);
setManageChoices(true);
setPlan(null);
}}
/>
{!!choices.length && (
<OpportunityChoices
corridors={corridors}
choices={choices}
disabled={
busy || corridorLoading || !!corridorError
}
onChange={(next) => {
setChoices(next);
setManageChoices(true);
setPlan(null);
}}
/>
)}
{(changes.portfolio ||
view.context.selection.portfolio) && (
<p className="rounded border p-3">
@@ -694,145 +764,165 @@ export default function ContextEditor({
contextId={view.case.id}
/>
)}
<label className="block">
Acrescentar uma oportunidade à escolha
<select
className={input}
value=""
disabled={busy}
onChange={(e) => {
const o = options.opportunities.find(
(o) => o.key === e.target.value,
);
if (
o &&
!choices.some(
(c) => c.opportunity.key === o.key,
<details
open={relatedOpen}
onToggle={(e) => setRelatedOpen(e.currentTarget.open)}
className="rounded border p-3"
>
<summary className="cursor-pointer font-semibold">
Oportunidades relacionadas (opcional)
</summary>
<p>
Pode preparar a sua ideia sem associar um anuncio ou
corredor. Pesquise apenas se uma oportunidade puder
apoiar este investimento.
</p>
<div className="flex flex-wrap gap-2">
<label className="min-w-0 flex-1">
Pesquisar oportunidades
<input
className={input}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<button
type="button"
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void contextApi<Options>(
'/investimentos/fontes-contexto?' +
preparationSearch(
query,
selected('sector'),
consultationPath,
),
)
) {
setChoices([
...choices,
{
opportunity: {
...o,
id: o.key.split(':')[1],
href: '/oportunidades',
version: null,
sha256: '',
fields: {},
},
territory: null,
location_reason: '',
location_state: 'undetermined',
},
]);
setManageChoices(true);
setPlan(null);
}
}}
>
<option value="">Seleccionar ficha</option>
{options.opportunities
.filter(
(o) =>
o.kind === 'opportunity' &&
.then((data) => {
setOptions(data);
setRelatedSearch(
preparationSearch(
query,
selected('sector'),
consultationPath,
),
);
})
.catch((e) => setError(e.message))
.finally(() => setBusy(false));
}}
>
Pesquisar
</button>
</div>
<label className="block">
Acrescentar uma oportunidade à escolha
<select
className={input}
value=""
disabled={busy}
onChange={(e) => {
const o = options.opportunities.find(
(o) => o.key === e.target.value,
);
if (o?.kind === 'business') {
change('opportunity', o.key);
setManageChoices(false);
setPlan(null);
return;
}
if (
o &&
!selected('opportunity').startsWith(
'business:',
) &&
!choices.some(
(c) => c.opportunity.key === o.key,
),
)
) {
setChoices([
...choices,
{
opportunity: {
...o,
id: o.key.split(':')[1],
href: '/oportunidades',
version: null,
sha256: '',
fields: {},
},
territory: null,
location_reason: '',
location_state: 'undetermined',
},
]);
setManageChoices(true);
setPlan(null);
}
}}
>
<option value="">Seleccionar ficha</option>
{(relatedSearch ===
preparationSearch(
query,
selected('sector'),
consultationPath,
)
.map((o) => (
<option key={o.key} value={o.key}>
{o.title}
</option>
))}
</select>
</label>
<h2 className="text-xl font-bold">Dados do investimento</h2>
<div className="flex flex-wrap gap-2">
<label className="min-w-0 flex-1">
Pesquisar oportunidades
<input
className={input}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
? options.opportunities
: []
)
.filter(
(o) =>
!choices.some(
(c) =>
c.opportunity.key === o.key,
),
)
.map((o) => (
<option
key={o.key}
value={o.key}
disabled={
o.kind === 'business'
? choices.length > 0
: selected(
'opportunity',
).startsWith('business:')
}
>
{o.title} —{' '}
{o.relevance ??
'Correspondência pelo título; confirme o sector e a localização.'}
</option>
))}
</select>
</label>
<button
type="button"
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void contextApi<Options>(
'/investimentos/fontes-contexto?q=' +
encodeURIComponent(query),
)
.then(setOptions)
.catch((e) => setError(e.message))
.finally(() => setBusy(false));
}}
>
Pesquisar
</button>
</div>
<label className="grid min-w-0 gap-1">
Referência individual (sem selecção múltipla)
<select
className={input + ' max-w-full min-w-0'}
value={selected('opportunity')}
disabled={busy || manageChoices}
onChange={(e) =>
change('opportunity', e.target.value)
}
>
<option value="">Sem oportunidade associada</option>
{source &&
['opportunity', 'business'].includes(
source.kind,
) &&
!options.opportunities.some(
(x) => x.key === source.key,
) && (
<option value={source.key}>
{source.title}
</option>
)}
{view.context.selection.opportunity &&
!options.opportunities.some(
(x) =>
x.key ===
view.context.selection.opportunity!.key,
) && (
<option
value={
view.context.selection.opportunity
.key
}
>
{
view.context.selection.opportunity
.title
}{' '}
— referência guardada
</option>
)}
{options.opportunities.map((o) => (
<option key={o.key} value={o.key}>
{o.title} ·{' '}
{o.kind === 'business'
? 'Negócios'
: 'Catálogo'}
</option>
))}
</select>
</label>
{options.search_limited && (
<p>
Resultados limitados por página de pesquisa;
especifique o título para encontrar outras
oportunidades.
</p>
)}
{selected('opportunity').startsWith('business:') && (
<p>
{options.opportunities.find(
(o) => o.key === selected('opportunity'),
)?.title ??
view.context.selection.opportunity?.title}
<button
type="button"
className="ml-3 underline"
disabled={busy}
onClick={() => change('opportunity', '')}
>
Retirar esta referencia
</button>
</p>
)}
{options.search_limited && (
<p>
Resultados limitados por página de pesquisa;
especifique o título para encontrar outras
oportunidades.
</p>
)}
</details>
<label className="grid min-w-0 gap-1">
Sector
<select
@@ -866,7 +956,8 @@ export default function ContextEditor({
</select>
</label>
{!manageChoices && (
<>
<details className="rounded border p-3">
<summary>Zonas e parques (opcional)</summary>
<label className="grid min-w-0 gap-1">
{choices.length > 1
? 'Zona ou parque da primeira oportunidade'
@@ -877,7 +968,7 @@ export default function ContextEditor({
busy ||
corridorLoading ||
!!corridorError ||
(!!firstCorridor && !firstTerritory)
!firstTerritory
}
value={selected('park')}
onChange={(e) =>
@@ -887,10 +978,7 @@ export default function ContextEditor({
<option value="">
Sem ZEE, ZFI ou parque seleccionado
</option>
{(firstCorridor
? parks
: options.parks
).map((p) => (
{parks.map((p) => (
<option
key={p.id}
value={'park:' + p.id}
@@ -911,7 +999,7 @@ export default function ContextEditor({
: 'A consulta não confirma disponibilidade de lotes, admissão ou incentivos.'}
</p>
)}
</>
</details>
)}
{!manageChoices && (
<>
@@ -922,15 +1010,16 @@ export default function ContextEditor({
initial={territorySelection}
disabled={busy || manageChoices}
endpoint="/api/v1/investment/territory-options"
onChange={(path) =>
onChange={(path) => {
setConsultationPath(path);
change(
'territory',
path.length
? 'territory:' +
path[path.length - 1].id
: '',
)
}
);
}}
/>
{view.context.selection.territory && (
<p>
@@ -942,117 +1031,11 @@ export default function ContextEditor({
)}
</section>
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Declarações deste investimento
</h2>
<p>
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}
disabled={busy}
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}
disabled={busy}
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) => (
'project_title',
'products_services',
'project_summary',
].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
<textarea
@@ -1063,7 +1046,13 @@ export default function ContextEditor({
''
}
disabled={busy}
maxLength={k === 'project_title' ? 200 : 5000}
maxLength={
k === 'project_title'
? 200
: k === 'products_services'
? 2000
: 5000
}
onChange={(e) => {
setDeclarations({
...declarations,
@@ -1074,6 +1063,181 @@ export default function ContextEditor({
/>
</label>
))}
</section>
<InvestmentBudgetRange
values={{ ...view.context.fields, ...declarations }}
onChange={(field, value) => {
setDeclarations((old) => ({ ...old, [field]: value }));
setPlan(null);
}}
/>
<DemographicContext territoryId={consultationPath.at(-1)?.id} />
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Declarações deste investimento
</h2>
<details className="rounded border p-3">
<summary>
Enquadramento da entidade e fiscal, quando
necessário
</summary>
<p>
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>
{[
[
'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',
null,
],
].map(([key, label, choices]) => (
<label className="grid gap-1" key={String(key)}>
{String(label)}
{key === 'currency' ? (
<CurrencySelect
label="Moeda do projecto"
disabled={busy}
value={
declarations.currency ??
view.context.fields.currency ??
''
}
error={
(declarations.currency ??
view.context.fields.currency) &&
!/^[A-Z]{3}$/.test(
declarations.currency ??
view.context.fields
.currency ??
'',
)
? 'Seleccione a moeda na lista para confirmar este valor anterior.'
: undefined
}
onChange={(value) => {
setDeclarations({
...declarations,
currency: value,
});
setPlan(null);
}}
/>
) : key === 'tax_residency_country' ? (
<CountrySelect
label="País de residência fiscal"
countries={options.countries ?? []}
disabled={busy}
value={
declarations.tax_residency_country ??
view.context.fields
.tax_residency_country ??
''
}
onChange={(value) => {
setDeclarations({
...declarations,
tax_residency_country: value,
});
setPlan(null);
}}
/>
) : Array.isArray(choices) ? (
<select
className={input}
disabled={busy}
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}
disabled={busy}
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>
))}
</details>
<details>
<summary>
Substituições explícitas de declarações
+42 -2
View File
@@ -15,6 +15,11 @@ import {
type Preparation,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import InvestmentBudgetRange from '@/components/investment-budget-range';
import DemographicContext from '@/components/demographic-context';
import InvestmentSupplyAggregate from '@/components/investment-supply-aggregate';
import InvestmentPromoter from '@/components/investment-promoter';
import type { SupplyAggregate } from '@/lib/investor-context';
import { SelectedComplements } from '@/components/investment-complements';
import InvestmentRequirements from '@/components/investment-requirements';
import OpportunityChoices from '@/components/investment-opportunity-choices';
@@ -91,6 +96,9 @@ type Path = {
private_investment?: Preparation | null;
fintech: FintechView | null;
investment_context: DossierContext | null;
can_set_promoter?: boolean;
supply_aggregate_enabled?: boolean;
supply_aggregates?: SupplyAggregate[];
zone_contexts?: {
zone_id: string;
consulted_public_version: number;
@@ -652,11 +660,16 @@ export default function InvestmentPath(path: Path) {
)}
{!!path.sector_references?.length && (
<section className="space-y-3 rounded border p-4">
<h2 className="text-lg font-semibold">Sectores e cadeias de valor</h2>
<h2 className="text-lg font-semibold">
Sectores e cadeias de valor
</h2>
{path.sector_references.map((reference) => (
<article key={reference.key}>
<h3>{reference.title}</h3>
<p>{reference.location_label ?? reference.locations_text}</p>
<p>
{reference.location_label ??
reference.locations_text}
</p>
<p>{reference.value_chain}</p>
<Link className="underline" href={reference.href}>
Consultar a referência mineral
@@ -668,12 +681,39 @@ export default function InvestmentPath(path: Path) {
<ContextDocuments
source={path.investment_context?.selection.opportunity}
/>
{path.supply_aggregate_enabled && (
<InvestmentSupplyAggregate
key={path.case.id}
caseId={path.case.id}
version={path.case.version}
revision={path.investment_context?.revision ?? 0}
history={path.supply_aggregates ?? []}
disabled={!path.canWrite}
/>
)}
<InvestmentPromoter
key={path.case.id}
caseId={path.case.id}
selected={path.investment_context?.promoter_entity}
canEdit={path.can_set_promoter ?? false}
/>
<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">
<InvestmentBudgetRange
values={path.investment_context.fields}
/>
<DemographicContext
territoryId={
path.investment_context.fields.locality_id ||
path.investment_context.fields.admin_post_id ||
path.investment_context.fields.district_id ||
path.investment_context.fields.province_id
}
/>
<h2 className="font-semibold">As suas escolhas</h2>
{Object.values(path.investment_context.selection).map(
(ref) => (
+6
View File
@@ -459,6 +459,12 @@ export default function Search({
Experimente outras palavras ou uma expressão
mais curta.
</p>
<p className="mt-2 text-sm text-slate-600">
A pesquisa consulta informação disponível no
portal. Não encontrar anúncios não comprova
ausência de empresas, recursos ou potencial
no território.
</p>
<div className="mt-5">
<QuickLinks />
</div>
+4 -1
View File
@@ -1,6 +1,7 @@
import { Link, useForm, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { catalogueReturnHref } from '@/lib/catalogue-context';
import PublishedOpportunitiesEmpty from '@/components/published-opportunities-empty';
import {
searchTerritoryHref,
portfolioPreparationHref,
@@ -366,7 +367,9 @@ function Editor({
</article>
))}
{!opportunities.length && (
<p>Sem oportunidades publicadas associadas a esta ficha.</p>
<PublishedOpportunitiesEmpty
href={searchTerritoryHref('/oportunidades', pageUrl)}
/>
)}
</section>
{management ? (
+7 -8
View File
@@ -1,3 +1,4 @@
import CurrencySelect from '@/components/currency-select';
import { Link, router, useForm } from '@inertiajs/react';
import InvestmentTerritory, {
TerritoryFields,
@@ -287,19 +288,17 @@ export default function Editor({
/>
</Field>
<Field
label="Moeda (código de três letras)"
label="Moeda do orçamento"
error={form.errors.budget_currency}
>
<input
maxLength={3}
<CurrencySelect
label="Moeda do orçamento"
className={control}
value={form.data.budget_currency}
onChange={(e) =>
form.setData(
'budget_currency',
e.target.value.toUpperCase(),
)
onChange={(value) =>
form.setData('budget_currency', value)
}
error={form.errors.budget_currency}
/>
</Field>
<Field
+2
View File
@@ -38,6 +38,8 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::get('/{id}/contexto', [InvestorContextController::class, 'show'])->whereUuid('id');
Route::get('/{id}/contexto/editar', [InvestorContextController::class, 'edit'])->whereUuid('id');
Route::post('/{id}/contexto/rascunho', [InvestorContextController::class, 'saveDraft'])->whereUuid('id');
Route::post('/{id}/contexto/oferta-agregada', [InvestorContextController::class, 'supplyAggregate'])->whereUuid('id');
Route::match(['GET', 'POST'], '/{id}/contexto/entidade', [InvestorContextController::class, 'promoter'])->whereUuid('id');
Route::post('/{id}/contexto/previa', [InvestorContextController::class, 'preview'])->whereUuid('id');
Route::post('/{id}/contexto/confirmar', [InvestorContextController::class, 'commit'])->whereUuid('id');
Route::get('/a-partir-do-parque/{zoneId}', [Path::class, 'zoneStart'])->whereUuid('zoneId');
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature;
use App\Models\Sector;
use App\Models\User;
use App\Services\FundingProgrammes;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
final class FundingCriteriaCoherenceTest extends TestCase
{
use DatabaseTransactions;
private User $editor;
private array $geo = [];
private int $sector;
protected function setUp(): void
{
parent::setUp(); $this->withoutVite();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('investor_intent_20261115_test', $db->name); $this->assertSame(55441, (int) $db->port);
$this->editor = User::factory()->create(); config(['portal.managers' => [$this->editor->email]]);
$parent = null;
foreach (['country', 'province', 'district'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type, 'source_system' => 'cartografia-workbook', 'data_classification' => 'public', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
$this->geo[$type] = $id; $parent = $id;
}
$this->sector = Sector::create(['slug' => 'synthetic-funding-qa', 'name' => 'SYNTHETIC sector', 'is_active' => true, 'published_at' => now()])->id;
}
private function publish(string $code, string $kind = 'institution', array $overrides = []): string
{
$source = ['source_url' => 'https://example.invalid/synthetic-qa', 'source_reference' => 'SYNTHETIC QA ONLY', 'checked_on' => today()->toDateString()];
$draft = ['title' => 'SYNTHETIC '.$code, 'kind' => $kind, 'manager_name' => 'SYNTHETIC manager', 'summary' => 'Synthetic isolated record, not real content.', 'source_url' => $source['source_url'], 'source_reference' => $source['source_reference'], 'source_checked_on' => $source['checked_on'], 'source_status' => 'identified', 'coverage' => 'national', 'sectors_scope' => 'all', 'funding_needs' => ['investment'], 'windows' => []];
if ($kind === 'institution') $draft += ['institution_key' => $code, 'institution_nature' => 'bank', 'institution_categories' => ['banking'], 'institution_types' => ['commercial_bank'], 'institution_status' => 'reported',
'locations' => [['role' => 'headquarters', 'label' => 'Synthetic headquarters', 'territory_id' => $this->geo['district'], 'evidence_status' => 'reported', ...$source]],
'products' => [['code' => 'investment', 'title' => 'Synthetic investment product', 'needs' => ['investment'], 'coverage' => 'national', 'availability' => 'documented', ...$source]]];
$service = app(FundingProgrammes::class);
$id = $service->save($this->editor, null, ['version' => 0, 'code' => $code, 'reason' => 'Synthetic QA fixture', 'draft' => array_replace($draft, $overrides)]);
$plan = $service->preview($this->editor, $id);
$service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'Synthetic QA only, rolled back']);
return $id;
}
public function test_bank_filters_apply_to_the_shared_query_and_never_return_brokers_or_microinsurers(): void
{
$bank = $this->publish('qa-bank');
$this->publish('qa-broker', overrides: ['institution_categories' => ['intermediation'], 'institution_types' => ['broker']]);
$this->publish('qa-microinsurance', overrides: ['institution_categories' => ['insurance'], 'institution_types' => ['microinsurer']]);
$fund = $this->publish('qa-fund', 'fund');
$service = app(FundingProgrammes::class);
$filters = ['category' => 'banking', 'institution_type' => 'commercial_bank', 'kind' => 'institution', 'certainty' => 'documented', 'location_mode' => 'physical', 'territory' => $this->geo['district'], 'sector' => $this->sector, 'need' => 'investment'];
$this->assertSame([$bank], $service->catalogueQuery($filters)->pluck('p.id')->all());
$this->assertSame([$fund], $service->catalogueQuery(['kind' => 'fund'])->pluck('p.id')->all());
$this->assertSame(0, $service->catalogueQuery(['q' => 'no-such-record'])->count());
$this->assertSame(0, $service->catalogueQuery(['category' => 'banking', 'institution_type' => 'investment_bank'])->count(), 'A valid combination without records is an empty result, not an invalid combination.');
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.id', $bank)->where('territorySelection.0.id', $this->geo['province'])->where('territorySelection.1.id', $this->geo['district']));
$returnFilters = [...$filters, 'q' => 'qa', 'state' => 'closed', 'kind' => 'institution', 'include_historical' => '0', 'province_id' => $this->geo['province'], 'district_id' => $this->geo['district'], 'page' => '2'];
$this->get('/fundos/'.$bank.'?'.http_build_query($returnFilters))->assertOk()->assertInertia(function ($page) use ($returnFilters) {
foreach ($returnFilters as $key => $value) $page->where('selection.'.$key, (string) $value);
});
$this->getJson('/api/v1/investment/territory-options?type=district')->assertOk()->assertJsonCount(0, 'data');
$this->getJson('/api/v1/investment/territory-options?type=district&parent_id='.$this->geo['province'])->assertOk()->assertJsonCount(1, 'data');
}
public function test_contradictory_type_or_record_kind_and_incomplete_hierarchy_are_rejected(): void
{
foreach ([['category' => 'banking', 'institution_type' => 'broker'], ['category' => 'banking', 'institution_type' => 'commercial_bank', 'kind' => 'fund', 'location_mode' => 'physical'], ['district_id' => $this->geo['district']]] as $filters) {
try { app(FundingProgrammes::class)->catalogueQuery($filters)->count(); $this->fail('Contradictory criteria must not silently broaden a query.'); }
catch (ValidationException $error) { $this->assertNotEmpty($error->errors()); }
$this->getJson('/fundos?'.http_build_query($filters))->assertUnprocessable();
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->has('criteriaErrors')->has('items.data', 0));
}
}
public function test_unknown_scope_is_not_a_documented_match_and_physical_presence_does_not_prove_coverage(): void
{
$this->publish('qa-unknown', overrides: ['sectors_scope' => 'unknown', 'products' => []]);
$service = app(FundingProgrammes::class);
$this->assertSame(0, $service->catalogueQuery(['need' => 'investment', 'certainty' => 'documented'])->count());
$this->assertSame(0, $service->catalogueQuery(['sector' => $this->sector, 'certainty' => 'documented'])->count());
$this->assertSame(1, $service->catalogueQuery(['territory' => $this->geo['district'], 'location_mode' => 'physical', 'certainty' => 'documented'])->count());
$this->assertSame(0, $service->catalogueQuery(['territory' => $this->geo['district'], 'location_mode' => 'coverage', 'certainty' => 'documented'])->count());
$this->assertSame(1, $service->catalogueQuery(['need' => 'investment', 'certainty' => 'include_unknown'])->count());
$this->assertSame(0, $service->catalogueQuery(['state' => 'closed'])->count(), 'An institution without a window is not a closed funding window.');
}
public function test_window_state_and_scope_are_matched_on_the_same_window_without_inventing_a_local_window(): void
{
$source = ['source_url' => 'https://example.invalid/synthetic-qa', 'source_reference' => 'Synthetic window', 'checked_on' => today()->toDateString()];
$id = $this->publish('qa-closed-window', overrides: ['windows' => [['code' => 'closed', 'title' => 'Synthetic closed window', 'mode' => 'closed', 'coverage' => 'unknown', ...$source]]]);
$service = app(FundingProgrammes::class);
$criteria = ['territory' => $this->geo['district'], 'certainty' => 'documented', 'state' => 'closed', 'location_mode' => 'coverage'];
$this->assertSame(0, $service->catalogueQuery($criteria)->count());
$this->assertSame([$id], $service->catalogueQuery([...$criteria, 'certainty' => 'include_unknown'])->pluck('p.id')->all());
$this->assertSame([$id], $service->catalogueQuery([...$criteria, 'location_mode' => 'physical'])->pluck('p.id')->all());
$this->assertSame(0, $service->catalogueQuery([...$criteria, 'state' => 'open'])->count());
}
}
@@ -0,0 +1,202 @@
<?php
namespace Tests\Feature;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Models\InvestmentZone;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestorContext;
use App\Services\ProactiveSupply;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class InvestorIntentContinuityTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('investor_intent_20261115_test', $db->name);
$this->assertSame(55441, (int) $db->port);
$this->withoutVite();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
public function test_internal_business_observations_remain_closed_to_an_ordinary_investor(): void
{
$this->expectException(HttpException::class);
app(ProactiveSupply::class)->authorize(User::factory()->create());
}
public function test_demography_source_recovers_its_hierarchy_without_replacing_a_saved_location(): void
{
$owner = User::factory()->create();
$parent = null;
$ids = [];
foreach (['country', 'province', 'district'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
$ids[$type] = $id;
$parent = $id;
}
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'SYNTHETIC demography source', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$url = '/investimentos/'.$id.'/contexto/editar?'.http_build_query(['source' => 'territory:'.$ids['district']]);
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $ids['district'])->where('territorySelection.0.id', $ids['province'])->where('territorySelection.1.id', $ids['district']));
$version = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('case.version');
$body = ['version' => $version, 'selection' => ['territory' => 'territory:'.$ids['province']], 'opportunity_choices' => [], 'declarations' => []];
$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->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('initial.context.selection.territory.id', $ids['province'])->where('territorySelection.0.id', $ids['province'])->has('territorySelection', 1));
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
}
public function test_an_own_idea_preserves_sector_locality_and_budget_interval_without_any_advert_or_corridor(): void
{
$owner = User::factory()->create();
$parent = null;
$path = [];
foreach (['country' => 'QA Mozambique', 'province' => 'Maputo Provincia', 'district' => 'Boane', 'admin_post' => 'Matola Rio', 'locality' => 'Matola Rio-Sede'] as $type => $name) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$name, 'code' => $id, 'location_type' => $type,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
$path[$type.'_id'] = $id;
$parent = $id;
}
$reviewer = User::factory()->create();
$zone = InvestmentZone::query()->create(['id' => (string) Str::uuid(), 'code' => 'SINT-QA-'.Str::random(8), 'name' => 'SYNTHETIC documented site',
'category' => 'industrial_park', 'physical_category' => 'industrial_park', 'primary_territory_id' => $parent, 'publication_status' => 'published', 'validation_status' => 'validated',
'editor_id' => $owner->id, 'reviewer_id' => $reviewer->id, 'published_at' => now(), 'source_evidence' => [['source_type' => 'promoter', 'url' => 'https://example.invalid/synthetic-qa', 'supports' => 'Synthetic QA only, no operational or legal assertion.']]]);
$zones = $this->actingAs($owner)->getJson('/investimentos/opcoes-corredor?include_corridors=0&territory='.$parent);
$this->assertSame(200, $zones->status(), $zones->getContent());
$zones->assertJsonPath('parks.0.id', $zone->id)->assertJsonCount(1, 'parks');
$this->getJson('/investimentos/opcoes-corredor?include_corridors=0')->assertOk()->assertJsonPath('parks', []);
config(['investor-path.aggregate_supply_qa_enabled' => true]);
$source = hash('sha256', 'Synthetic private aggregate source');
DB::table('proactive_supply_sources')->insert(['sha256' => $source, 'name' => 'PRIVATE-SOURCE-NOT-FOR-OUTPUT.xlsx', 'period' => '2020', 'limitations' => 'Synthetic only', 'receipt' => '{}', 'imported_at' => now()]);
$base = ['source_hash' => $source, 'sheet' => 'PRIVATE-SHEET', 'entity_key' => hash('sha256', 'entity-a'), 'name' => 'PRIVATE-COMPANY-NAME', 'province' => 'Synthetic', 'district' => 'Synthetic', 'cae' => '01110', 'activity' => 'PRIVATE-CONTACT-+258000000000', 'sector' => 'PRIVATE-RAW-SECTOR', 'source_date' => '2020', 'fingerprint' => hash('sha256', 'fingerprint'), 'duplicate_of' => null, 'identity_conflict' => false, 'territory_id' => $path['district_id'], 'territory_resolution' => 'exact_normalized_province_district'];
foreach ([1 => [], 2 => ['duplicate_of' => hash('sha256', 'row-1')], 3 => ['identity_conflict' => true, 'entity_key' => hash('sha256', 'uncertain')], 4 => ['entity_key' => hash('sha256', 'unclassified'), 'cae' => 'PRIVATE-UNCLASSIFIED-TEXT']] as $number => $changes) {
DB::table('proactive_supply_observations')->insert([...$base, 'id' => hash('sha256', 'row-'.$number), 'row_number' => $number, ...$changes]);
}
foreach (['Agronegocio' => 'agro-qa', 'Criar Hotel' => 'hotel-qa'] as $title => $slug) {
$sector = Sector::create(['name' => 'SYNTHETIC '.$slug, 'slug' => $slug, 'is_active' => true]);
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$entry = [...array_diff_key($path, ['country_id' => true]), 'sector_id' => (string) $sector->id, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000'];
$this->get('/investimentos/iniciar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryQuery.investment_budget_max', '5000000')->where('entryQuery.locality_id', $parent));
$this->get('/investimentos/'.$id.'/contexto/editar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $parent)->where('entryIdea.sector_id', (string) $sector->id));
$body = ['version' => $view['case']['version'], 'selection' => ['sector' => 'sector:'.$sector->id, 'territory' => 'territory:'.$parent],
'opportunity_choices' => [], 'declarations' => ['project_title' => $title, 'products_services' => 'SYNTHETIC explicit activity '.$slug, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
$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();
$saved = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.locality_id', $parent)
->assertJsonPath('prefill.simulator.investment_budget_min', '1000000')->assertJsonPath('prefill.simulator.investment_budget_max', '5000000')
->assertJsonPath('prefill.simulator.project_title', $title)->json();
$this->assertSame('SYNTHETIC explicit activity '.$slug, $saved['prefill']['simulator']['products_services']);
$this->assertEmpty($saved['context']['opportunity_choices'] ?? []);
$this->assertEmpty($saved['context']['selection']['opportunity'] ?? null);
$this->assertEmpty($saved['prefill']['simulator']['total_investment'] ?? null);
$command = ['version' => $saved['case']['version'], 'operation_key' => (string) Str::uuid()];
$url = '/investimentos/'.$id.'/contexto/oferta-agregada';
$aggregate = $this->postJson($url, $command)->assertOk()->assertJsonPath('analysis.entities', 2)->assertJsonPath('analysis.repeated', 1)->assertJsonPath('analysis.uncertain', 1)
->assertJsonPath('analysis.broader_context', true)->assertJsonPath('analysis.scope.id', $path['district_id']);
$this->assertStringNotContainsString('PRIVATE-', $aggregate->getContent());
$this->assertStringNotContainsString($source, $aggregate->getContent());
$this->postJson($url, $command)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'supply_aggregates')->assertJsonPath('supply_aggregates.0.entities', 2);
config(['investor-path.aggregate_supply_qa_enabled' => false]);
$this->postJson($url, $command)->assertNotFound();
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('supply_aggregates', [])->assertJsonPath('supply_aggregate_enabled', false);
config(['investor-path.aggregate_supply_qa_enabled' => true]);
$this->actingAs(User::factory()->create())->postJson($url, $command)->assertForbidden();
$this->actingAs($owner);
// UI keeps display labels in selection_labels, outside canonical fields.
$economic = array_filter($saved['prefill']['simulator'], fn ($key) => ! str_ends_with($key, '_label') && $key !== 'email', ARRAY_FILTER_USE_KEY);
$economic['purpose'] = 'own_investment';
// Explicit synthetic scenario; the budget interval remains separate.
$economic['total_investment'] = '2000000';
$economic['secured_amount'] = '1000000';
$economic['annual_revenue'] = '300000';
$economic['planning_loan'] = 'no';
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'other', 'capacity' => '100', 'price' => '250', 'occupancy' => '', 'frequency' => '12', 'source' => 'Synthetic explicit assumptions', 'costs' => []]], JSON_THROW_ON_ERROR);
$economic['financial_tools_snapshot'] = json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
'loan' => ['principal' => '', 'rate' => '', 'months' => '', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '', 'upfrontFee' => '', 'recurringFee' => ''],
'breakEven' => ['fixed' => '100000', 'price' => '5000', 'variable' => '2000'], 'funding' => ['total' => '2000000', 'secured' => '1000000'],
'incomes' => [['label' => 'Synthetic declared revenue', 'amount' => '300000']], 'expenses' => [['label' => 'Synthetic declared costs', 'amount' => '200000']]]], JSON_THROW_ON_ERROR);
$allowed = explode(',', substr((new StoreSimulationFormDraftRequest)->rules()['economic_snapshot'][1], 6));
$this->assertSame([], array_values(array_diff(array_keys($economic), $allowed)));
$simulation = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$draft = $this->getJson('/api/v1/simulation-drafts/'.$simulation)->assertOk()->assertJsonPath('data.economic_snapshot.investment_budget_min', '1000000')->assertJsonPath('data.economic_snapshot.investment_budget_max', '5000000')->assertJsonPath('data.economic_snapshot.financial_tools_snapshot', $economic['financial_tools_snapshot'])->json('data');
$dossier = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$return = ['version' => $dossier['case']['version'], 'draft_id' => $simulation, 'draft_version' => $draft['version_no'], 'context_revision' => $dossier['investment_context']['revision'], 'context_reviewed' => true];
$result = $this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.financial_snapshot', $economic['financial_tools_snapshot'])->assertJsonPath('result.preparation_snapshot.planning_loan', 'no')->assertJsonPath('result.preparation_snapshot.annual_revenue', '300000')->assertJsonPath('result.preparation_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->json('result.id');
$this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.id', $result);
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'results')->assertJsonPath('results.0.id', $result)->assertJsonPath('results.0.currency', 'MZN');
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/'.$simulation)->assertNotFound();
$this->actingAs($owner);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_budget_max', '5000000');
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
}
}
public function test_an_incomplete_currency_can_be_recovered_privately_but_cannot_be_confirmed(): void
{
$this->actingAs(User::factory()->create());
$id = $this->postJson('/investimentos', ['title' => 'Synthetic draft', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'MZ']];
$this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertOk();
$this->assertSame('MZ', app(InvestorContext::class)->draft(auth()->user(), $id)['declarations']['currency']);
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.currency');
$body['declarations'] = ['currency' => 'MZN', 'investment_budget_min' => '5000000', 'investment_budget_max' => '1000000'];
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.investment_budget_max');
}
public function test_district_demography_is_never_presented_as_locality_population(): void
{
$owner = User::factory()->create();
$parent = null;
$district = null;
foreach (['country', 'province', 'district', 'admin_post', 'locality'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type, 'data_classification' => 'public',
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
if ($type === 'district') {
$district = $id;
}
$parent = $id;
}
$batch = (string) Str::uuid();
DB::table('demographic_imports')->insert(['id' => $batch, 'sha256' => hash('sha256', $batch), 'file_name' => 'SYNTHETIC.xlsx', 'source' => 'Synthetic only', 'publication_version' => 'QA/1', 'status' => 'approved', 'report' => '{}', 'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $owner->id, 'created_at' => now(), 'updated_at' => now()]);
DB::table('demographic_observations')->insert(['import_id' => $batch, 'geo_location_id' => $district, 'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => '12345', 'numeric_value' => '12345', 'sheet' => '2026', 'cell' => 'B4', 'validation_status' => 'valid']);
$url = '/api/v1/investment/demography?'.http_build_query(['territories' => [$parent], 'start_year' => 2026, 'investment_context' => 1]);
$this->getJson($url)->assertOk()->assertJsonPath('broader_context', true)->assertJsonPath('requested_territory.id', $parent)
->assertJsonPath('territories.0.id', $district)->assertJsonPath('territories.0.level', 'district')->assertJsonPath('territories.0.series.0.values.0.original_value', '12345');
DB::table('geo_locations')->where('id', $parent)->update(['data_classification' => 'internal']);
$this->getJson($url)->assertOk()->assertJsonPath('requested_territory', null)->assertJsonPath('territories', []);
}
public function test_guided_unknown_answers_and_their_labels_save_and_recover_in_the_existing_simulator_contract(): void
{
$this->actingAs(User::factory()->create());
$economic = ['purpose' => 'simulate_only', 'journey_type' => 'investor'];
$economic['preparation_step'] = 'operating';
$economic['fiscal_tools_selection'] = ['tax:sales', 'iva:acquisitions'];
$economic['initial_budget_snapshot'] = json_encode(['version' => 1, 'rows' => [['label' => 'Synthetic equipment', 'amount' => '1000000']]], JSON_THROW_ON_ERROR);
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'hotel', 'capacity' => '10', 'price' => '5000', 'occupancy' => '50', 'frequency' => '300', 'source' => 'Synthetic only', 'costs' => []]], JSON_THROW_ON_ERROR);
foreach (['customer_validation_status', 'location_assessment_status', 'cost_estimate_status', 'receivables_timing_status', 'funding_status'] as $field) {
$economic[$field] = 'unknown';
$economic['selection_labels'][$field] = 'Ainda não sei';
}
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->assertJsonPath('data.economic_snapshot.selection_labels.funding_status', 'Ainda não sei')->assertJsonPath('data.economic_snapshot.preparation_step', 'operating')->assertJsonPath('data.economic_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->assertJsonPath('data.economic_snapshot.fiscal_tools_selection', $economic['fiscal_tools_selection'])->assertJsonPath('data.economic_snapshot.initial_budget_snapshot', $economic['initial_budget_snapshot']);
$economic['selection_labels']['unsupported_field'] = 'Unsupported';
$this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.selection_labels.unsupported_field');
}
}
@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestorPromoterContinuityTest 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('investor_intent_20261115_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 $owner, string $title): string
{
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['products_services' => 'SYNTHETIC activity '.$title, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
$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();
return $id;
}
public function test_existing_company_is_reused_without_changing_ownership_or_repeating_the_comparison(): void
{
$owner = User::factory()->create();
$this->actingAs($owner);
$company = $this->postJson('/api/v1/investment/companies', ['version' => 0, 'operation_key' => (string) Str::uuid(), 'payload' => ['name' => 'SYNTHETIC Hotel Company', 'incorporation_country_code' => 'MZ', 'registration' => 'SYNTHETIC-QA-REG', 'role' => 'SYNTHETIC representative', 'legal_form' => 'quotas']])->assertSuccessful()->json('id');
$id = $this->dossier($owner, 'Criar Hotel');
$options = $this->getJson('/investimentos/'.$id.'/contexto/entidade')->assertOk()->assertJsonPath('companies.0.id', $company)->json();
$memberships = DB::table('memberships')->count();
$command = ['version' => $options['version'], 'operation_key' => (string) Str::uuid(), 'kind' => 'company', 'id' => $company, 'source_version' => 1];
$url = '/investimentos/'.$id.'/contexto/entidade';
$this->postJson($url, $command)->assertOk()->assertJsonPath('continue', '/investimentos/'.$id);
$this->postJson($url, $command)->assertOk()->assertJsonPath('replayed', true);
$case = DB::table('desk_cases')->where('id', $id)->first();
$this->assertSame($owner->id, (int) $case->user_id);
$this->assertNull($case->organization_id);
$this->assertSame($company, $case->company_id);
$this->assertSame($memberships, DB::table('memberships')->count());
$this->assertSame(1, DB::table('investment_company_declarations')->where('user_id', $owner->id)->count());
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.legal_name', 'SYNTHETIC Hotel Company')->assertJsonPath('prefill.simulator.legal_form', 'quotas')->assertJsonPath('prefill.simulator.entity_stage', 'existing')->assertJsonPath('prefill.simulator.investment_budget_max', '5000000');
$preparation = $this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.proposed_name', 'SYNTHETIC Hotel Company')->assertJsonPath('facts.form', 'quotas')->json();
$facts = $preparation['facts'];
$facts['notes'] = 'SYNTHETIC deliberate note';
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $preparation['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertOk();
$recovered = $this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.notes', 'SYNTHETIC deliberate note')->json();
$facts['form'] = 'anonymous';
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $recovered['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertUnprocessable();
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('tools.simulator', '/simulador-fiscal?context='.$id);
$this->actingAs(User::factory()->create())->postJson($url, $command)->assertForbidden();
$otherCase = $this->dossier(auth()->user(), 'Another private idea');
$v = $this->getJson('/investimentos/'.$otherCase.'/contexto/entidade')->assertOk()->assertJsonCount(0, 'companies')->json('version');
$this->postJson('/investimentos/'.$otherCase.'/contexto/entidade', [...$command, 'version' => $v, 'operation_key' => (string) Str::uuid()])->assertNotFound();
}
public function test_optional_comparison_can_continue_into_the_same_unconstituted_entity_and_recover_without_creating_a_company(): void
{
$owner = User::factory()->create();
foreach (['Criar Hotel', 'Agronegocio QA'] as $title) {
$id = $this->dossier($owner, $title);
$comparison = $this->getJson('/investimentos/'.$id.'/forma-juridica')->assertOk()->json();
$answers = $comparison['answers'];
$answers['preferred'] = 'quotas';
$body = ['version' => $comparison['case']['version'], 'operation_key' => (string) Str::uuid(), 'answers' => $answers, 'continue_preparation' => true];
$this->postJson('/investimentos/'.$id.'/forma-juridica', $body)->assertOk();
$this->postJson('/investimentos/'.$id.'/forma-juridica', $body)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.entity_stage', 'being_formed');
$preparation = $this->getJson('/investimentos/'.$id.'/preparar-empresa?form=quotas')->assertOk()->assertJsonPath('facts.form', 'quotas')->assertJsonPath('facts.activities', 'SYNTHETIC activity '.$title)->json();
$facts = $preparation['facts'];
$facts['proposed_name'] = 'SYNTHETIC new company '.$title;
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $preparation['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertOk();
$this->getJson('/investimentos/'.$id.'/preparar-empresa?form=anonymous')->assertOk()->assertJsonPath('facts.form', 'quotas')->assertJsonPath('proposedForm', 'anonymous');
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('investment_context.promoter_entity.kind', 'being_formed');
$this->assertSame(0, DB::table('investment_company_declarations')->where('user_id', $owner->id)->count());
$this->assertNull(DB::table('desk_cases')->where('id', $id)->value('company_id'));
$version = $this->getJson('/investimentos/'.$id.'/contexto/entidade')->json('version');
$this->postJson('/investimentos/'.$id.'/contexto/entidade', ['version' => $version, 'operation_key' => (string) Str::uuid(), 'kind' => 'undetermined'])->assertOk();
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('investment_context.promoter_entity.kind', 'undetermined')->assertJsonPath('tools.simulator', '/simulador-fiscal?context='.$id);
$this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.proposed_name', $facts['proposed_name']);
}
}
public function test_an_authorised_organization_snapshot_does_not_transfer_the_case_or_grant_master_write_access(): void
{
$owner = User::factory()->create();
$org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC represented organization', 'source_system' => 'unit-qa', 'organization_type' => 'private']);
DB::table('memberships')->insert(['user_id' => $owner->id, 'organization_id' => $org, 'role' => 'viewer']);
$id = $this->dossier($owner, 'Hotel with existing organization');
$url = '/investimentos/'.$id.'/contexto/entidade';
$options = $this->getJson($url)->assertOk()->assertJsonPath('organizations.0.id', $org)->json();
$command = ['kind' => 'organization', 'id' => $org, 'version' => $options['version'], 'operation_key' => (string) Str::uuid(), 'source_version' => 99];
$this->postJson($url, $command)->assertConflict();
$command['source_version'] = 1;
$this->postJson($url, $command)->assertOk();
$case = DB::table('desk_cases')->where('id', $id)->first();
$this->assertNull($case->organization_id); $this->assertNull($case->company_id); $this->assertSame($owner->id, (int) $case->user_id);
$this->assertSame('viewer', DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $org)->value('role'));
config(['portal.organization_country_enabled' => true]);
$this->putJson('/api/v1/investment/organizations/'.$org.'/country', ['version' => 1, 'country' => 'MZ', 'source_reference' => 'Synthetic QA'])->assertForbidden();
DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $org)->update(['deleted_at' => now()]);
$current = $this->getJson($url)->assertOk()->assertJsonCount(0, 'organizations')->assertJsonPath('selected.snapshot.name', 'SYNTHETIC represented organization')->json();
$this->postJson($url, [...$command, 'version' => $current['version'], 'operation_key' => (string) Str::uuid()])->assertForbidden();
}
}
+707
View File
@@ -0,0 +1,707 @@
{
"provenance": "Texto colado(5).txt fornecido pela Tempo, conferido na página pública em 2026-11-15; Manica.xlsx, folha 2026, B/E/H54 e B/E/H56:72. Valores de apresentação, não reimportação.",
"values": [
{
"year": 2026,
"indicator": "population",
"age_group": "all",
"residence": "all",
"value": "248518.44160696748",
"original_value": "248518.44160696748",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B54",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "all",
"residence": "urban",
"value": "86749.367563197302",
"original_value": "86749.367563197302",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E54",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "all",
"residence": "rural",
"value": "161769.0740437702",
"original_value": "161769.0740437702",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H54",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "0-4",
"residence": "all",
"value": "44289.138086159306",
"original_value": "44289.138086159306",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B56",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "0-4",
"residence": "urban",
"value": "14514.88099755138",
"original_value": "14514.88099755138",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E56",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "0-4",
"residence": "rural",
"value": "29774.257088607934",
"original_value": "29774.257088607934",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H56",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "5-9",
"residence": "all",
"value": "35827.776591901806",
"original_value": "35827.776591901806",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B57",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "5-9",
"residence": "urban",
"value": "11793.491606737534",
"original_value": "11793.491606737534",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E57",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "5-9",
"residence": "rural",
"value": "24034.284985164271",
"original_value": "24034.284985164271",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H57",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "10-14",
"residence": "all",
"value": "33152.970732328176",
"original_value": "33152.970732328176",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B58",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "10-14",
"residence": "urban",
"value": "10676.607713136782",
"original_value": "10676.607713136782",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E58",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "10-14",
"residence": "rural",
"value": "22476.363019191398",
"original_value": "22476.363019191398",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H58",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "15-19",
"residence": "all",
"value": "29611.438388752566",
"original_value": "29611.438388752566",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B59",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "15-19",
"residence": "urban",
"value": "10262.814463997336",
"original_value": "10262.814463997336",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E59",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "15-19",
"residence": "rural",
"value": "19348.623924755229",
"original_value": "19348.623924755229",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H59",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "20-24",
"residence": "all",
"value": "24355.209722981959",
"original_value": "24355.209722981959",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B60",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "20-24",
"residence": "urban",
"value": "9042.4481146842572",
"original_value": "9042.4481146842572",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E60",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "20-24",
"residence": "rural",
"value": "15312.761608297704",
"original_value": "15312.761608297704",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H60",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "25-29",
"residence": "all",
"value": "19575.376496383324",
"original_value": "19575.376496383324",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B61",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "25-29",
"residence": "urban",
"value": "7599.5545011499053",
"original_value": "7599.5545011499053",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E61",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "25-29",
"residence": "rural",
"value": "11975.82199523342",
"original_value": "11975.82199523342",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H61",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "30-34",
"residence": "all",
"value": "14806.636171744825",
"original_value": "14806.636171744825",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B62",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "30-34",
"residence": "urban",
"value": "6262.1273781029959",
"original_value": "6262.1273781029959",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E62",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "30-34",
"residence": "rural",
"value": "8544.5087936418295",
"original_value": "8544.5087936418295",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H62",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "35-39",
"residence": "all",
"value": "11161.453343365156",
"original_value": "11161.453343365156",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B63",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "35-39",
"residence": "urban",
"value": "4625.8522262806891",
"original_value": "4625.8522262806891",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E63",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "35-39",
"residence": "rural",
"value": "6535.6011170844686",
"original_value": "6535.6011170844686",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H63",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "40-44",
"residence": "all",
"value": "8538.8490092624561",
"original_value": "8538.8490092624561",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B64",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "40-44",
"residence": "urban",
"value": "3443.8323932108847",
"original_value": "3443.8323932108847",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E64",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "40-44",
"residence": "rural",
"value": "5095.0166160515719",
"original_value": "5095.0166160515719",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H64",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "45-49",
"residence": "all",
"value": "7101.0415927196163",
"original_value": "7101.0415927196163",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B65",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "45-49",
"residence": "urban",
"value": "2463.4886973717157",
"original_value": "2463.4886973717157",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E65",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "45-49",
"residence": "rural",
"value": "4637.5528953479006",
"original_value": "4637.5528953479006",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H65",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "50-54",
"residence": "all",
"value": "5226.3070949472531",
"original_value": "5226.3070949472531",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B66",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "50-54",
"residence": "urban",
"value": "1679.6467072735772",
"original_value": "1679.6467072735772",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E66",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "50-54",
"residence": "rural",
"value": "3546.6603876736754",
"original_value": "3546.6603876736754",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H66",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "55-59",
"residence": "all",
"value": "3998.2566339012192",
"original_value": "3998.2566339012192",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B67",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "55-59",
"residence": "urban",
"value": "1240.8880754969321",
"original_value": "1240.8880754969321",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E67",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "55-59",
"residence": "rural",
"value": "2757.3685584042869",
"original_value": "2757.3685584042869",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H67",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "60-64",
"residence": "all",
"value": "3135.4929526634714",
"original_value": "3135.4929526634714",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B68",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "60-64",
"residence": "urban",
"value": "1009.9040617408737",
"original_value": "1009.9040617408737",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E68",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "60-64",
"residence": "rural",
"value": "2125.5888909225978",
"original_value": "2125.5888909225978",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H68",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "65-69",
"residence": "all",
"value": "2537.0624121248256",
"original_value": "2537.0624121248256",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B69",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "65-69",
"residence": "urban",
"value": "753.99704079870799",
"original_value": "753.99704079870799",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E69",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "65-69",
"residence": "rural",
"value": "1783.0653713261181",
"original_value": "1783.0653713261181",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H69",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "70-74",
"residence": "all",
"value": "2016.6006284927157",
"original_value": "2016.6006284927157",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B70",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "70-74",
"residence": "urban",
"value": "576.8557228333616",
"original_value": "576.8557228333616",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E70",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "70-74",
"residence": "rural",
"value": "1439.7449056593541",
"original_value": "1439.7449056593541",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H70",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "75-79",
"residence": "all",
"value": "1320.7863269208433",
"original_value": "1320.7863269208433",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B71",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "75-79",
"residence": "urban",
"value": "331.39109408599069",
"original_value": "331.39109408599069",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E71",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "75-79",
"residence": "rural",
"value": "989.39523283485266",
"original_value": "989.39523283485266",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H71",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "80+",
"residence": "all",
"value": "1864.0454223179609",
"original_value": "1864.0454223179609",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "B72",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "80+",
"residence": "urban",
"value": "471.58676874437788",
"original_value": "471.58676874437788",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "E72",
"validation_status": "warning"
},
{
"year": 2026,
"indicator": "population",
"age_group": "80+",
"residence": "rural",
"value": "1392.458653573583",
"original_value": "1392.458653573583",
"unit": "pessoas",
"data_kind": "projected",
"sheet": "2026",
"cell": "H72",
"validation_status": "warning"
}
]
}
+83
View File
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
catalogueDetailHref,
catalogueReturnHref,
catalogueQuery,
} from '../../resources/js/lib/catalogue-context.ts';
test('return preserves deliberate destination edits, including cleared ancestors, without stale descendants', () => {
const detail = catalogueDetailHref(
'/carteira/a',
'q=energia&province_id=p&district_id=d&page=4',
);
assert.equal(
catalogueReturnHref('/carteira?q=turismo&province_id=', detail),
'/carteira?q=turismo&province_id=&page=4',
);
assert.equal(
catalogueReturnHref('/carteira?province_id=other', detail),
'/carteira?province_id=other&q=energia&page=4',
);
});
test('different catalogue families share geography but not text, kind, price, page or private context', () => {
const detail =
'/carteira/a?catalogue_query=' +
encodeURIComponent(
'q=airport&kind=project&page=4&province_id=p&context=private&min_amount=200',
);
assert.equal(
catalogueReturnHref('/zonas?category=zee', detail),
'/zonas?category=zee&province_id=p',
);
assert.equal(catalogueReturnHref('/documentos', detail), '/documentos');
});
test('resource-specific return queries do not inherit from other tabs or mutable storage', () => {
const a = catalogueDetailHref(
'/oportunidades/a',
'q=rice&facets[]=2&facets[]=3&currency=MZN',
);
const b = catalogueDetailHref('/oportunidades/b', 'q=gas&currency=USD');
const back = catalogueReturnHref('/oportunidades', a);
assert.deepEqual(
new URL(back, 'https://portal.invalid').searchParams.getAll('facets[]'),
['2', '3'],
);
assert.match(back, /q=rice/);
assert.match(catalogueReturnHref('/oportunidades', b), /q=gas/);
assert.equal(
catalogueReturnHref('/oportunidades', '/oportunidades/c'),
'/oportunidades',
);
});
test('detail URL preserves existing parameters and fragment; unsafe and recursive filters are not carried', () => {
const href = catalogueDetailHref(
'/oportunidades/a?from_dossier=origin#details',
'q=rice&context=other&catalogue_query=recursive&password=secret&kind=cluster',
);
const url = new URL(href, 'https://portal.invalid');
assert.equal(url.searchParams.get('from_dossier'), 'origin');
assert.equal(url.hash, '#details');
assert.equal(url.searchParams.get('catalogue_query'), 'q=rice');
assert.equal(
catalogueDetailHref('https://outside.test/a', 'q=rice'),
'https://outside.test/a',
);
});
test('only positive finite page numbers survive; repeated scalar criteria have one deliberate value', () => {
assert.equal(
catalogueQuery(
'/carteira',
'q=old&q=new&page=-1&per_page=Infinity',
).toString(),
'q=new',
);
assert.equal(
catalogueReturnHref(
'/carteira',
'/carteira/a?catalogue_query=' +
encodeURIComponent('https://outside.test'),
),
'/carteira',
);
});
@@ -26,6 +26,23 @@ function load(file, mocks = {}) {
return module.exports;
}
const lib = load('../../resources/js/lib/company-preparation.ts');
test('historical delivery wording is presented professionally without changing the stored notice or legal limits', () => {
const legal =
' Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.';
const stored =
'Os sete exemplares referidos no complemento ainda não foram fornecidos.' +
legal;
assert.equal(
lib.companyPreparationNotice(stored),
'Os modelos contratuais não estão disponíveis nesta preparação.' +
legal,
);
assert.match(stored, /sete exemplares/);
assert.equal(
lib.companyPreparationNotice('Outro aviso específico.'),
'Outro aviso específico.',
);
});
const party = {
id: 'SYNTHETIC-party',
name: '<SYNTHETIC>',
@@ -148,6 +165,7 @@ function harness() {
router: { visit: (...args) => visits.push(args) },
},
'./shared': { Frame: 'main', card: '', input: '', button: '' },
'@/components/currency-select': { default: () => null },
'@/lib/company-preparation': lib,
'@/lib/operation-key': {
createOperationKey: () => 'SYNTHETIC-operation',

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