Compare commits

..
Author SHA1 Message Date
nala e1f505f7fd Record served IVA functionality and team review limits 2026-10-21 14:43:23 +02:00
23 changed files with 173 additions and 518 deletions
@@ -170,10 +170,8 @@ class InvestmentSubmissionController extends Controller
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
@@ -192,12 +190,11 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
@@ -33,8 +33,7 @@ class StoreLandAssistanceCaseRequest extends FormRequest
return [
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
'payload' => ['required', 'array:context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload' => ['required', 'array:support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
'payload.request_description' => ['nullable', 'string', 'max:4000'],
'payload.project_reference' => ['nullable', 'string', 'max:160'],
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -72,14 +72,6 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.published_iva_base' => ['nullable', 'string', 'max:40'],
'economic_snapshot.published_iva_currency' => ['nullable', 'string', 'max:3'],
'economic_snapshot.published_iva_date' => ['nullable', 'string', 'max:10'],
'economic_snapshot.published_iva_scope' => ['nullable', 'string', 'in:yes,no'],
'economic_snapshot.monthly_iva_settings' => ['nullable', 'string', 'max:2000'],
'economic_snapshot.monthly_iva_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
+16 -68
View File
@@ -5,7 +5,6 @@ namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\SimulationFormDraft;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
@@ -22,7 +21,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'];
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'];
public function __construct(private readonly VirtualDesk $desk) {}
@@ -35,26 +34,8 @@ final class InvestorContext
{
$payload = $this->desk->decode($case->payload);
$context = $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
return $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
'fields' => ['project_title' => $case->title], 'provenance' => ['project_title' => ['kind' => 'dossier', 'id' => $case->id, 'version' => $case->version]], 'review_steps' => []];
// Explicit dossier anchor only: never the account's most recent simulation or another owner's draft.
if (($case->simulation_id ?? null) && request()->user()?->id === (int) $case->user_id) {
$draft = SimulationFormDraft::whereKey($case->simulation_id)->where('owner_user_id', $case->user_id)->whereNull('deleted_at')->first();
if ($draft) {
$values = [...SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), ...SimulationFormDraft::decryptSnapshot($draft->economic_snapshot)];
foreach (Arr::only($values, self::FIELDS) as $key => $value) {
if (is_scalar($value) && (string) $value !== '' && (! array_key_exists($key, $context['fields']) || ($context['provenance'][$key]['kind'] ?? null) === 'linked_simulation')) {
$context['fields'][$key] = (string) $value;
$context['provenance'][$key] = ['kind' => 'linked_simulation', 'id' => $draft->id, 'version' => $draft->version_no, 'sha256' => $draft->snapshot_sha256];
}
}
}
}
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
return $context;
}
public function dependencyHash(array $selection): string
@@ -72,20 +53,17 @@ final class InvestorContext
$documents = [];
$visibility = 'public';
$state = null;
$zoneAnalysis = null;
if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
$o = Opportunity::with('sector')->findOrFail((int) $id);
$published = Opportunity::published()->whereKey($o->id)->exists();
if (! $published) {
if (!$published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
$visibility = 'private';
$state = app(PortalProfileAccess::class)->state($o);
}
if (request()->user()) {
$documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
}
$draft = ! $published ? app(OpportunityManagement::class)->values($o) : [];
if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
$draft = !$published ? app(OpportunityManagement::class)->values($o) : [];
$title = $draft['canonical']['official_title'] ?? $o->title;
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version;
@@ -144,28 +122,19 @@ final class InvestorContext
$fields = $this->territoryFields($id);
} elseif ($kind === 'park') {
abort_unless(Str::isUuid($id), 422);
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id);
$p = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id);
$title = $p->name;
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
$version = $p->version_no;
$zoneAnalysis = ['conditions' => app(InvestmentZoneOperationalDetails::class)->publicData($p)['conditions'] ?? [],
'source' => $p->legal_source_id ? DB::table('investment_legal_sources')->where('id', $p->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'valid_from', 'valid_until']) : null,
'recognition' => 'undetermined', 'executable_benefit' => false];
$href = '/zonas/'.$id;
if ($p->primary_territory_id) {
$fields = [...$fields, ...$this->territoryFields($p->primary_territory_id)];
$fields = $this->territoryFields($p->primary_territory_id);
}
} else {
abort(422, 'Tipo de referência inválido.');
}
$fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== '');
$record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields];
if ($zoneAnalysis !== null) {
$record['analysis'] = $zoneAnalysis;
}
if ($documents || $visibility === 'private') {
$record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
}
if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
}
@@ -201,11 +170,7 @@ final class InvestorContext
}
if (request()->user()) {
$private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get();
foreach ($private as $o) {
if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
}
}
foreach ($private as $o) if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
}
if (config('existing-business.enabled')) {
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
@@ -214,7 +179,7 @@ final class InvestorContext
}
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
'parks' => app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->orderBy('name')->get(['id', 'name', 'version_no'])];
}
public function current(User $actor): array
@@ -270,8 +235,7 @@ final class InvestorContext
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', '_token']), 422, 'Campo de controlo desconhecido.');
return Validator::make($input, ['version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage', '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:project_title,project_summary', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
}
@@ -335,7 +299,7 @@ final class InvestorContext
}
// Remove inherited subordinate geography only when its source was actually replaced.
foreach ($fields as $key => $value) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector', 'special_zone_id', 'investment_regime'], true) && isset($provenance[$key]['key'])) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector'], true) && isset($provenance[$key]['key'])) {
unset($fields[$key],$provenance[$key]);
}
}
@@ -350,16 +314,11 @@ final class InvestorContext
$changed[] = $slot;
}
}
$changedFields = array_column($replaced, 'field');
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection);
$payload = $this->desk->decode($case->payload);
$results = $payload['investor_path']['results'] ?? [];
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
$steps = $dependencyChanged ? array_values(array_unique([...(array_intersect($changed, ['opportunity', 'sector']) ? ['S02', 'S03', 'S04', 'S08', 'S12', 'S13', 'S14', 'S15', 'S17'] : []), ...(array_intersect($changed, ['territory', 'park']) ? ['S08', 'S09', 'S10', 'S11', 'S17'] : [])])) : [];
if ($conditionsChanged) {
$steps = array_values(array_unique([...$steps, 'S02', 'S03', 'S08', 'S12', 'S13', 'S17']));
}
$plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $dependencyChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
@@ -403,7 +362,7 @@ final class InvestorContext
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
}
$context = ['revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
'dependency_hash' => $this->dependencyHash($plan['selection']), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
if ($plan['dependency_changed']) {
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
@@ -425,8 +384,6 @@ final class InvestorContext
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$path = app(InvestorPath::class)->view($actor, $id);
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
$simulatorRevision = null;
if ((int) $case->user_id === $actor->id && $case->simulation_id) {
@@ -447,19 +404,10 @@ final class InvestorContext
public function prefill(array $ctx): array
{
$f = $ctx['fields'];
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location']);
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'path' => $ctx['path_applicability'] ?? null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$sim = Arr::only($f, ['project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
'submission' => array_filter(['sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
}
}
+4 -5
View File
@@ -250,8 +250,7 @@ class VirtualDesk
* @return array<string,mixed> */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse'])],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code'])],
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
foreach ($definition['fields'] as $key => $field) {
@@ -389,7 +388,7 @@ class VirtualDesk
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor, $request), 403);
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403);
}
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
@@ -413,13 +412,13 @@ class VirtualDesk
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id), 403);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
$current = $workflow[$data['stage']];
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
@@ -1612,39 +1612,63 @@
"consumer_activation_changed": false,
"proof_directory": "outputs/modelo-a-20261021"
},
"contextual_continuity_qa_20261022": {
"branch": "codex/project-context-continuity-QA",
"base": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"scope": "Primeiro incremento de continuidade e explicação contextual; adaptação jurídica integral não encerrada",
"status": "QA, revisão; sem integração main ou alteração fiscal activa",
"http_tests": {
"tests": 4,
"assertions": 74,
"transaction_rollback": true
"modelo_a_homologation_delivery_20261021": {
"authorization": "Tempo requested Git delivery of implemented/verified functionality associated with0b0cf761; no new fiscal rules or automatic activation",
"candidate": "0b0cf761",
"functional_monthly_parent": "3f5430c3",
"integration_commit": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"previous_main": "f3ec686f8c39b162a4a0dec35df2df71fad0f467",
"method": "Existing Git main push and automatic delivery; no force; merge retains both histories",
"concurrent_preservation": "resources/css/app.css and resources/js/components/tempo-footer.tsx unchanged from previous main; no database/config/routes/fixture delta",
"served": {
"commit": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"branch": "main",
"built_at": "2026-10-21T12:36:28.155256+00:00",
"manifest_sha256": "3c49d7c6d86265644a978dfde5b9c85cf60127faa98a772325a8127c711d1b62"
},
"frontend_tests": 8,
"typescript": "passed",
"build": "passed",
"visual_evidence_directory": "outputs/context-continuity-20261022",
"same_project_new_session": true,
"deliberate_edit_preserved": true,
"second_project_isolated": true,
"fiscal_execution_id": "ae401465-834f-4761-9406-5af060c499fb",
"new_executions_today": 1,
"reused_proofs": [
"13f68f1b: recuperação/exportadores/PDF",
"83d2f97d e 226fd308: precisão/versões/históricos",
"3f5430c3: cálculo mensal",
"0b0cf761: correspondência parcial Modelo A"
"manifest_hash_verified": true,
"portal_url": "https://teste-investidor.tempo.co.mz/simulador-fiscal",
"served_functional_markers": {
"IVA mensal das vendas projectadas": true,
"Modelo A — correspondência da simulação": true,
"Simulação parcial: campos 01/02": true,
"Aquisições já projectadas": true,
"IVA dedutível": true,
"Ver campos, cálculos, fundamentos e cobertura do Modelo A": true
},
"consumer_enabled_before": true,
"consumer_enabled_after": true,
"published_rule_version_visible": 1,
"rule_binding_last_confirmed": "01a15e1a-5338-700f-8a45-f87707d696a7:1; no binding/configuration write in this delivery; public diagnosis exposes version/source, not rule ID",
"source_observed": {
"title": "Lei n.º 22/2022 — republicação oficial do IVA",
"diploma": "Lei 22/2022",
"article": "Art. 17(1) do CIVA e art. 6 da Lei",
"url": "https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da"
},
"available": [
"Monthly reuse of sales and server-selected published-rate IVA on declared net base",
"Zero versus missing, stale results and versioned recovery/report using existing mechanism",
"Custom manual tax separated from fiscal IVA",
"Consultable Modelo A correspondence and purchase-order references, no presumed deduction"
],
"limitations": [
"Catálogo visual QA sintético; ZEE/ZFI apenas controlos HTTP sintéticos revertidos, sem elegibilidade real demonstrada",
"Condições jurídicas por sector/actividade/regime e benefícios executáveis não concluídos",
"Outros três consumidores usam origem/reconciliador comum; não houve demonstração visual integral de cada processo",
"Sem regra fiscal nova, publicação, activação ou integração em main",
"Edge interrompeu a inspecção após navegação de regresso; endereço e gravação confirmados por inventário/serviço, visual final não observada. Limitação da ferramenta."
"unavailable": [
"Reduced-rate and exempt-operation calculation/classification",
"Deductible VAT, adjustments and complete totals14–18",
"Previous credits, substitutions, payment/refund/final settlement",
"Automatic extraction from VAT-inclusive prices; outside2026 or non-MZN in current lot"
],
"return_evidence_id": "43317c22-50c8-40cf-98e4-e5d4d108c372",
"B_recovered_context_revision": 2
"documentary_reconciliation_complete_fiscal_feature": false,
"original_modelo_a_publication_in_homologation": false,
"validation_scope": "Public entry and served manifest/chunks plus read-only published-rule diagnosis/consumer availability; existing QA calculation/recovery/export proofs reused, no new destination simulation",
"read_query_note": "Initial canonical operation probe rejected422 by generic consultation whitelist; read repeated with documented generic fiscal_operation and existing diagnosis explains scope gap. Not a failed calculation.",
"new_simulations": 0,
"new_rule_registration": 0,
"new_rule_publication": 0,
"activation_writes": 0,
"fixtures_imported": 0,
"tool_limit": "SSH session closed; no new SSH authentication required for Git delivery. Final checks performed over public HTTP; no live DB inspection claimed.",
"proof": "outputs/investmoz-modelo-a-delivery-20261021/receipt-served-delta.json",
"records_branch_only": "Post-delivery receipt update stays on integration branch; does not trigger another main deployment"
}
}
@@ -2020,24 +2020,16 @@ Provas novas apenas do incremento:3testes frontend (agendas, zero/falta, sem IVA
Saldo explícito: **reconciliação e primeiro incremento de interface concluídos, apuramento completo não implementado**. Próximo lote: classificação por operação e origem prevista/efectiva, cadastro fundamentado de âmbito/dedução e ligação a totais14–18 propagando indeterminação; 8–16h cadastro/consolidação e20–36h código/aceitação, total28–52h condicionado ao âmbito/consolidação, sem créditos anteriores/substituições/pagamento/reembolso ou simulador integral. O documento contém o delta e critérios concretos. Integração do código, publicação e activação continuam operações distintas; esta preparação não executa nenhuma no destino.
### Continuidade e adaptação contextual — primeiro incremento QA, 22/10/2026
### Disponibilização autorizada do incremento IVA/Modelo A —21/10/2026
Instrução expressa da Tempo de 22/10: o contexto do investimento é transversal ao percurso, formulários, documentos e simulações, mesmo sem IA. Base Git e revisão servida confirmadas em leitura: `57fc914821d0cefa02caa80a74b3d42e663f2742`. Trabalho isolado em `codex/project-context-continuity-QA`, sem integração ou publicação/activação fiscal no destino.
Tempo autorizou disponibilizar as funcionalidades implementadas/verificadas associadas a0b0cf761 pelo processoGit existente. O commit não é apenas documental: inclui correspondência consultável e compras projectadas; o parent3f5430c3 contém a ligação mensal às vendas. Merge57fc914821d0cefa02caa80a74b3d42e663f2742 sobremainf3ec686f8c39b162a4a0dec35df2df71fad0f467, semforce. Ambos os históricos conservados; rodapé concorrente(app.css/tempo-footer.tsx) igual aomain anterior, matriz reconciliada. Zero delta de database/config/routes/fixtures. Pushmain accionou apenas a disponibilização automática existente.
Causas identificadas no código e reprodução dirigida: `InvestorContext` só distribuía título, descrição, sector e geografia; o preenchimento comum copiava apenas campos vazios sem distinguir herança de edição deliberada; o IVA pontual mantinha moeda/data/base em estado local que desaparecia ao desmontar o ecrã. A projecção empresarial sobrepunha a moeda do projecto à moeda de origem do plano ao apresentar/calcular/exportar. No ensaio anterior, projecto guardado em MZN e selector IVA vazio. A primeira tentativa de abertura encontrou uma sessão expirada, confirmada pela leitura sem autenticação e resolvida pelo início de sessão normal; não foi classificada como perda do dossier.
**Destino efectivamente servido:** https://teste-investidor.tempo.co.mz/simulador-fiscal, revisão57fc9148, build21/10/2026 às14:36:28Maputo(12:36:28UTC), manifestoSHA2563c49d7c6d86265644a978dfde5b9c85cf60127faa98a772325a8127c711d1b62. Hash conferido no manifesto recebido e presença dos novos módulos/textos nas dependênciasJavaScript da página real. Entrada pública aberta noEdge, sem preencher dados ou executar simulação. Não se apresenta esta leitura como novo teste funcional integral no destino; provasQA de cálculo decimal, ligação, gravação/recuperação, relatórios/exportadores/PDF reutilizadas.
Correcção comum: consolidar exclusivamente o rascunho explicitamente ligado ao mesmo dossier e proprietário; conservar origem, versão e hash. Não procurar o último projecto da conta. Herança persistida com assinaturas de alteração por campo e âmbito dossier/consumidor; não são tokens de autorização nem prova jurídica. Actualizar valores ainda herdados, preservar alterações deliberadas e campos apagados; manter a hierarquia geográfica e os restantes dados das parcelas. O armazenamento do navegador passa a incluir conta e projecto. Consumidores do padrão: simulador, apoio à terra, Balcão e manifestação de investimento; esta última recebe também a moeda compatível. Campos sem correspondência semântica não são copiados. Nacionalidade, residência e país de constituição não são equiparados.
Consumidor enabled=true antes/depois. Consulta exclusivamente de leitura confirmou versão publicada1 e fonteLei22/2022, art.17(1), art.6. Não houve cadastro/publicação/activação, alteração da ligação ou simulação nova. A resposta pública do diagnóstico não expõeID; o último vínculo confirmado é01a15e1a-5338-700f-8a45-f87707d696a7:1 e não se afirma nova leitura administrativa desseID. A consulta genérica domestic não é o âmbito canónico do cálculo; a rejeição422 da primeira sonda comoperation foi corrigida no leitor parafiscal_operation, e o diagnóstico existente explica essa diferença. Não foi falha de cálculo. SSH anterior encerrado, sem autenticação automática; não bloqueouGit/HTTP e não se alegou inspecção nova da base.
O IVA pontual guarda as entradas com o rascunho. A exploração fiscal, oportunidade e componente pontual reutilizam a moeda compatível; a projecção mensal reutiliza o plano e o início previsto. Não se infere exigibilidade fiscal a partir da data prevista de início, nem residência jurídica a partir do país. USD é conservado e explicado como não suportado pela versão MZN. Uma divergência entre moeda guardada no plano e moeda do projecto é apresentada, sem conversão ou reclassificação. O plano conserva a moeda de origem também na apresentação e nos exportadores existentes. Vendas, zero/falta e snapshots de IVA continuam a usar o motor publicado e os históricos aceites; nenhum imposto mensal fixo é introduzido.
**Para a equipa:** abrir o link acima e usar a conta/projecto habitual. No percurso de pequeno negócio/plano empresarial, emInvestimento e financiamento, avaliar «IVA mensal das vendas projectadas»: reutilização das vendas do plano, indicação de base líquida, resultados por mês pela versão publicada, diferenciação zero/falta, actualização dependente e recuperação/relatório existentes. MoedaMZN, âmbito declarado e cobertura2026 mantidos. «Modelo A — correspondência da simulação» permite consultar os campos e referências das compras/encomendas; não calcula todo o apuramento. Imposto manual permanece no cenário personalizado separado.
Adaptação deste incremento: o editor existente passa a permitir moeda, início previsto, origem do investidor, tipo/situação da entidade e residência fiscal declarada, sempre pela prévia/confirmar existente. A prévia continua a identificar dados/documentos conservados, resultados e etapas afectados; alterações apenas ao título/descrição não invalidam a aplicabilidade. O selector territorial reutiliza o catálogo público `InvestmentZones` para parques, ZEE e ZFI, preservando versão/hash, fontes e condições públicas já divulgadas no complemento existente. A mesma síntese aparece no contexto e simulador e segue para o relatório do dossier. Os ramos de terra/financiamento reutilizam a definição publicada, factos, estados e razões de `InvestorPath`; não foi criado outro avaliador nem activado benefício.
**Indisponível neste incremento:** cálculo5%/isenções e classificação por operação, deduções, regularizações, totais14–18 completos, créditos anteriores, substituições, pagamento/reembolso e apuramento final. Preços comIVA incluído continuam indeterminados neste lote líquido; não se soma oIVA novamente à tesouraria nem se presume imposto final. Correspondência documental não é funcionalidade fiscal concluída, declaração submetida ou aceite pelaAT. Originais Modelo A continuam preservados na biblioteca privadaQA, sem publicação/importação na homologação por este deploy. A regra geral já autorizada mantém-se; nenhuma nova regra foi activada.
Escolher uma zona abre a análise específica: referência consultada, condições documentadas ou em falta, período/fonte cadastrados quando disponíveis e reconhecimento por determinar. O benefício e o cenário condicionado permanecem indeterminados quando não existe ligação executável às condições/reconhecimento. O IVA disponível é identificado como cenário geral sem benefício; não representa automaticamente o regime escolhido nem apuramento final. Reutilizam-se `InvestorContext`, `InvestmentZones`, `InvestmentZoneOperationalDetails`, `InvestorPath`/definição publicada, resolvedor/avaliador fiscal e snapshots existentes. A escolha técnica de exigir uma nova confirmação genérica dos mesmos dados em cada formulário foi retirada por instrução da Tempo; permissões, declarações, submissões formais, revisões independentes aprovadas e campos autorizados de pedidos devolvidos mantêm os controlos existentes.
Provas novas: 4 testes HTTP/74 asserções, em transacções revertidas na base isolada `simulator_fiscal_delta_20261021_test`/loopback55441; 8 testes frontend de herança, edição/apagamento, isolamento, parcelas, moeda divergente e contexto no relatório; TypeScript/build. Percurso Edge: A nacional/MZN e residência fiscal ZA, vendas guardadas de quatro meses → cálculo mensal → gravação → edição deliberada do título → recarregamento → terminar sessão/iniciar sessão → recuperação e relatório pelos dados efectivamente guardados. Resultado `ae401465-834f-4761-9406-5af060c499fb`, uma única execução nova no dia; Janeiro1000.03/IVA160.0048, Fevereiro2000.06/320.0096, Marçozero e Abrilsem informação. O modelo de relatório recuperado conserva os valores exactos e não altera o snapshot. B estrangeiro/USD, primeiro divergência explícita planoMZN/projectoUSD, depois declaração manual USD no plano, IVA recusado sem conversão; selecção de parque pela prévia e análise específica. Os testes de ZEE/ZFI usam apenas metadados sintéticos revertidos: não demonstram elegibilidade real. Provas privadas em `outputs/context-continuity-20261022`; não entregar fixtures ou credenciais.
Limites concretos: este é o primeiro incremento utilizável de continuidade e explicação contextual, não a conclusão da adaptação de todos os sectores/regimes. O catálogo QA consultado contém parques sintéticos; a ligação jurídica completa de requisitos/documentos/licenças e benefícios às condições de cada actividade/regime não está demonstrada. Condições sem estrutura/relação executável continuam explicitamente em falta; nenhuma alegação nova de direito ou benefício é feita. Não houve nova validação jurídica nem revalidação genérica de credibilidade das fontes oficiais aceites em08/10. Não repetir cálculo decimal/exportadores/paginação já aceites. A demonstração visual foi feita no percurso e simulador; os outros três consumidores receberam o reconciliador comum, sem se afirmar um ensaio visual integral de cada processo.
Revisão: abrir o simulador QA com o dossier A `92d0836c-0ffb-4e07-a7f5-0e8ca3f594b9` ou B `1872412f-251a-4f6d-bf18-70388e71a16a`; usar a conta sintética QA existente, nunca actores sintéticos na homologação. O router privado de QA usa a instalação já existente do percurso, activada apenas neste ensaio isolado; não há migration ou nova exigência de funções. Entregar a candidata para revisão pelo processo Git existente, preservando alterações concorrentes. Uma integração futura exige rever o delta relativamente a main e o estado de instalação/configuração do percurso no destino; não publicar regras ou alterar interruptores fiscais como consequência desta correcção. Preservar todos os históricos na suspensão ou reversão de código.
Fecho da observação deste incremento: o regresso A ao dossier gravou a evidência financeira `43317c22-50c8-40cf-98e4-e5d4d108c372`, rascunho `cf4dec46-c96c-4aa6-aa9c-1a8d6ecb1bea` v8, contexto v1, com hash preservado. A ligação de inspecção do Edge interrompeu-se depois da navegação; o inventário mostrou o endereço do mesmo dossier e a leitura autenticada do serviço confirmou a gravação. A parte visual final do regresso não foi observada e fica como limitação da ferramenta, não falha do simulador. B recuperou contexto v2 e gravou rascunho v6, mantendo USD e sem execução fiscal. O relatório gerado destes dados recuperados conserva a versão do parque e benefício indeterminado. O enriquecimento automático do rascunho privado é exclusivo do proprietário autenticado; o acesso de gestão ao dossier não concede por esta alteração acesso ao rascunho privado da conta.
Provas privadas outputs/investmoz-modelo-a-delivery-20261021: reciboHTTP/manifesto, parâmetros/diagnóstico e captura da entrada real. ReciboJSON e AUD-SIM-INT02 existentes actualizados nesta branch de registo pós-entrega, sem segundo pushmain para documentação. A revisão funcional que a equipa testa é57fc9148, não oHEAD posterior desta branch de recibo.
File diff suppressed because one or more lines are too long
+58 -27
View File
@@ -1,12 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { InvestmentContextAssessment } from './investment-context-assessment';
import { Link } from '@inertiajs/react';
import {
contextApi,
contextFieldNames,
type ContextView,
} from '@/lib/investor-context';
import { inheritCompatibleFields } from '@/lib/context-inheritance';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
@@ -14,7 +12,6 @@ type Props = {
enabled?: boolean;
readOnly?: boolean;
allowedFields?: string[];
persistInheritance?: boolean;
boundRevision?: number;
onApply: (patch: Record<string, unknown>, replace: boolean) => void;
onReview?: (revision: number, reviewed: boolean) => void;
@@ -43,16 +40,17 @@ export function DossierPrefill(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
const filled = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setView(null);
setError('');
void contextApi<ContextView>(`/investimentos/${contextId}/contexto`)
.then((v) => {
if (!stopped) {
setView(v);
setReviewed(false);
latest.current.onReview?.(v.context.revision, false);
}
})
.catch((e) => {
@@ -63,20 +61,44 @@ export function DossierPrefill(props: Props) {
};
}, [contextId, retry]);
useEffect(() => {
if (!view || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
if (!view || !enabled || readOnly || !view.can_write) return;
const generation = `${contextId}:${view.context.revision}:${consumer}`;
if (filled.current === generation) return;
const source = view.prefill[consumer] ?? {};
const now = latest.current;
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
now.onApply({...inherited.patch, ...(now.persistInheritance === false ? {} : {context_reuse:inherited.metadata})}, true);
const patch: Record<string, unknown> = {};
const conflict = geo.some(
(k) =>
!empty(now.values[k]) &&
String(now.values[k]) !== text(source[k]),
);
for (const [k, v] of Object.entries(source)) {
if (
(now.allowedFields && !now.allowedFields.includes(k)) ||
(geo.includes(k) && conflict)
)
continue;
if (k === 'parcels') {
const old = now.values.parcels as
| Record<string, unknown>[]
| undefined;
if (
(!old?.length || geo.every((g) => empty(old[0]?.[g]))) &&
Array.isArray(v) &&
v[0] &&
Object.keys(v[0]).length
)
patch[k] = v;
} else if (empty(now.values[k]) && !empty(v)) patch[k] = v;
}
filled.current = generation;
if (Object.keys(patch).length) {
now.onApply(patch, false);
setMessage(
'Dados compatíveis reutilizados do mesmo dossier. Os valores herdados acompanham o contexto; as suas alterações locais foram preservadas.',
'Campos vazios preenchidos a partir do dossier. Reveja-os; os campos já preenchidos foram preservados.',
);
}
setReviewed(true);
now.onReview?.(view.context.revision, true);
}, [view, enabled, readOnly, contextId, consumer, props.values]);
}, [view, enabled, readOnly, contextId, consumer]);
if (!contextId) return null;
if (error)
return (
@@ -104,7 +126,7 @@ export function DossierPrefill(props: Props) {
),
);
const differences = Object.keys(source).filter(
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
(k) => text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
@@ -130,7 +152,6 @@ export function DossierPrefill(props: Props) {
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
</p>
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
@@ -233,17 +254,7 @@ export function DossierPrefill(props: Props) {
const patch: Record<string, unknown> = {};
for (const k of selected)
patch[k] = source[k] ?? '';
const scope = `dossier:${contextId}:${consumer}`;
let raw = String(props.values.context_reuse ?? '');
try {
const metadata = JSON.parse(raw);
if(metadata.scope === scope) {
for(const key of selected) delete metadata.inherited?.[key];
raw=JSON.stringify(metadata);
}
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
props.onApply(patch, true);
setSelected([]);
setReviewed(false);
props.onReview?.(view.context.revision, false);
@@ -256,7 +267,27 @@ export function DossierPrefill(props: Props) {
</button>
</details>
)}
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
{!readOnly &&
view.can_write &&
props.onReview &&
view.context.revision > 0 && (
<label className="block">
<input
type="checkbox"
checked={reviewed}
onChange={(e) => {
setReviewed(e.target.checked);
props.onReview?.(
view.context.revision,
e.target.checked,
);
}}
/>{' '}
Conferi os dados e pressupostos deste formulário para o
contexto v{view.context.revision}; os valores diferentes
que conservei são declarações deste processo.
</label>
)}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
@@ -1,24 +0,0 @@
import { Link } from '@inertiajs/react';
type Snapshot = {context_revision:number;path?:{definition_version:number;steps:Array<{code:string;title:string;applicability:string;reason:string;state:string;context_review_required?:boolean}>}; zone?:{title:string;href:string;version:number|string;fields?:Record<string,string>;analysis?:{conditions?:Array<{id:string;label:string;knowledge:string;source_reference?:string;valid_from?:string;valid_until?:string}>;source?:{title:string;url:string;diploma?:string;article?:string;valid_from?:string;valid_until?:string}|null}}|null;fields?:Record<string,string>};
export function InvestmentContextAssessment({raw,contextId}:{raw:unknown;contextId?:string|null}) {
let snapshot:Snapshot|null=null;
try {const value=JSON.parse(String(raw ?? ''));if(value.version===1) snapshot=value;} catch { /* Missing context remains explicit. */ }
if(!snapshot) return null;
const fields=snapshot.fields ?? {};const zone=snapshot.zone;
const regime=zone?.fields?.investment_regime ?? fields.investment_regime;
const names:Record<string,string>={zee:'ZEE',zfi:'ZFI',industrial_park:'Parque industrial'};
return <section className="space-y-2 rounded border border-amber-400 p-3" aria-label="Aplicabilidade ao investimento">
<h3 className="font-semibold">Aplicabilidade ao investimento · contexto v{snapshot.context_revision}</h3>
<p>Origem do investidor: {{national:'nacional',foreign:'estrangeiro',mixed:'misto'}[fields.investor_origin] ?? 'informação em falta'}. Residência fiscal declarada: {fields.tax_residency_country || 'informação em falta'}. Estas condições são distintas; uma não determina a outra.</p>
{zone ? <><p><strong>Potencialmente aplicável — análise específica de {names[regime ?? ''] ?? 'zona seleccionada'}.</strong> <Link className="underline" href={zone.href}>{zone.title} · versão {zone.version} · consultar ficha e fundamentos</Link>.</p>
<p>A selecção identifica a referência consultada; não confirma o reconhecimento da entidade, actividade autorizada, licença ou acesso a benefícios. Consulte as condições documentadas na ficha e reúna a autorização ou evidência correspondente no dossier.</p>
{zone.analysis?.source && <p>Fundamento cadastrado: <a className="underline" href={zone.analysis.source.url}>{zone.analysis.source.diploma || zone.analysis.source.title} {zone.analysis.source.article}</a>. Período cadastrado: {zone.analysis.source.valid_from || 'início por indicar'} a {zone.analysis.source.valid_until || 'sem termo indicado'}. A cobertura do investimento exige correspondência com estas condições.</p>}
{zone.analysis?.conditions?.length ? <ul className="list-disc pl-5">{zone.analysis.conditions.map(condition=><li key={condition.id}><strong>{condition.label}</strong>: {condition.knowledge==='documented'?'condição documentada; cumprimento pelo investimento por confirmar':condition.knowledge==='disputed'?'condição com informação divergente':'informação em falta'}. {condition.source_reference && <>Fundamento: {condition.source_reference}.</>} {condition.valid_from && <>Desde {condition.valid_from}.</>} {condition.valid_until && <>Até {condition.valid_until}.</>}</li>)}</ul> : <p>Condições específicas: informação estruturada em falta nesta referência. A ficha e os documentos continuam consultáveis; não se presume cumprimento.</p>}
<p role="status">Benefício fiscal deste enquadramento: indeterminado. Não existe neste consumidor uma regra executável ligada ao reconhecimento e condições da zona. No simulador, o IVA disponível corresponde apenas ao cenário geral sem benefício, sobre vendas declaradas compatíveis; não simula o regime especial. O cenário com benefício e o apuramento final estão indisponíveis.</p></>
: <p>Enquadramento geral de preparação: sem ZEE, ZFI ou parque seleccionado. Isso não confirma a elegibilidade para uma operação fiscal. O cálculo mantém o âmbito e as exclusões da versão publicada.</p>}
<p>Confirmado neste quadro significa apenas dado recuperado do dossier; declarações e referências não são decisões jurídicas. Requisitos específicos não documentados permanecem indeterminados.</p>
{snapshot.path && <details><summary>Passos adaptados pelo percurso existente · versão {snapshot.path.definition_version}</summary><ul className="list-disc pl-5">{snapshot.path.steps.map(step=><li key={step.code}>{step.code} — {step.title}: {step.context_review_required?'informação alterada; rever fundamento':step.applicability==='not_needed_for_preparation'?'não aplicável à preparação declarada':step.applicability==='undetermined'?'informação em falta':['completed','approved'].includes(step.state)?'preparação confirmada no percurso; não é decisão fiscal':'potencialmente aplicável'}. {step.reason}</li>)}</ul></details>}
{contextId && <Link className="underline" href={`/investimentos/${contextId}/contexto/editar`}>Alterar contexto e comparar impacto antes de guardar</Link>}
</section>;
}
@@ -95,7 +95,7 @@ function MoneyInput({
}
export function SimulationBusinessPlan({
currency: projectCurrency,
currency,
snapshot,
onCurrencyChange,
onSnapshotChange,
@@ -253,10 +253,8 @@ export function SimulationBusinessPlan({
const plan = workingPlan ??
restoreBusinessPlanSnapshot(snapshot) ?? {
...emptyPlan,
currency: projectCurrency,
currency,
};
// A project edit is not a conversion or permission to relabel stored sales.
const currency = plan.currency || projectCurrency;
const update = (
key: keyof BusinessFinancialPlanInput,
value:
@@ -860,7 +858,6 @@ export function SimulationBusinessPlan({
<summary className="cursor-pointer text-lg font-semibold">
Plano financeiro empresarial (pressupostos editáveis)
</summary>
{plan.currency && projectCurrency && plan.currency !== projectCurrency && <p role="alert">A projecção guardada está em {plan.currency}; o projecto está em {projectCurrency}. Os valores e a moeda de origem foram preservados. Reveja o enquadramento monetário antes de os reutilizar; não foi feita conversão.</p>}
{snapshotError && (
<p
role="alert"
+3 -6
View File
@@ -1,6 +1,5 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields } from '@/lib/context-inheritance';
import {
PublicDocumentContent,
type PublicDocument,
@@ -779,7 +778,7 @@ function SearchChoice({
export function SimulationForm() {
const { auth } = usePage<{
auth: { user: { id: number; email_verified_at?: string | null } | null };
auth: { user: { email_verified_at?: string | null } | null };
}>().props;
const pageUrl = usePage().url;
const draftContext = useMemo(() => {
@@ -791,12 +790,11 @@ export function SimulationForm() {
};
}, [pageUrl]);
const contextId = draftContext.context;
const projectScope = contextId
const storageScope = contextId
? `:case:${contextId}`
: draftContext.requestedDraft
? `:draft:${draftContext.requestedDraft}`
: '';
const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`;
const draftKey = legacyDraftKey + storageScope;
const draftCreateKey = legacyDraftCreateKey + storageScope;
const draftIdKey = legacyDraftIdKey + storageScope;
@@ -808,7 +806,6 @@ export function SimulationForm() {
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [ready, setReady] = useState(false);
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
const [contextReview, setContextReview] = useState({
revision: 0,
reviewed: false,
@@ -3614,7 +3611,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva draft={draft} onChange={change} busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -1,4 +1,3 @@
import { InvestmentContextAssessment } from './investment-context-assessment';
import { useEffect, useRef, useState } from 'react';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
@@ -32,7 +31,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
return () => { active = false; };
}, [retry]);
useEffect(() => {
if (!settings.auto || !input || input.currency !== 'MZN' || busy || !available || completed || attempt.current === key) return;
if (!settings.auto || !input || busy || !available || completed || attempt.current === key) return;
const request = input;
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
const timer = window.setTimeout(() => {
@@ -57,7 +56,6 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
const snapshot = record?.execution.result_snapshot;
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
return <section className="space-y-3 rounded border border-emerald-700 p-4" aria-label="IVA mensal das vendas projectadas">
<InvestmentContextAssessment raw={draft.context_applicability_snapshot} />
<h3 className="font-semibold">IVA mensal das vendas projectadas</h3>
<p>{source}</p><p>Declare se os preços projectados são sem IVA ou com IVA incluído. Nenhuma taxa é fixada neste formulário; o servidor selecciona a versão publicada.</p>
<label className="block">Primeiro mês da projecção<input type="month" value={settings.start} min="2026-01" max="2026-12" onChange={(event) => saveSettings({start:event.target.value})} className="block rounded border p-2" /></label>
@@ -65,7 +63,6 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<label className="block"><input type="checkbox" checked={settings.declared} onChange={(event) => saveSettings({declared:event.target.checked})} /> Declaro que as vendas projectadas são tributáveis à taxa geral interna e não incluem as operações excluídas deste lote.</label>
<p className="text-sm">Sem importações, taxas reduzidas, isenções, regimes especiais, deduções, reembolsos ou apuramento final. Valores com IVA incluído permanecem indeterminados neste lote; não se aplica a taxa ao preço bruto.</p>
<p className="text-sm">{monthlyIvaCashTreatment}</p>
{!!draft.currency && draft.currency !== "MZN" && <p role="alert">Moeda do projecto: {String(draft.currency)}. A regra disponível aceita apenas MZN. As vendas e a moeda são preservadas, sem conversão; o IVA mensal não pode ser determinado por esta versão.</p>}
{inputError && <p role="alert">{inputError}</p>}
{available === null && !error && <p role="status">A consultar a disponibilidade da versão publicada…</p>}
{available === false && <p role="status">A versão publicada de IVA está indisponível neste ambiente para novas execuções. Os pressupostos e históricos mantêm-se.</p>}
@@ -10,22 +10,16 @@ export type PublishedIvaInput = MonthlyIvaInput | {
scope_declared: boolean;
};
export function SimulationPublishedIva({ busy, onCalculate, draft, onChange }: {
draft: Record<string, unknown>;
onChange: (field: string, value: string) => void;
export function SimulationPublishedIva({ busy, onCalculate }: {
busy: boolean;
onCalculate: (input: PublishedIvaInput) => Promise<unknown>;
}) {
const [available, setAvailable] = useState(false);
const [message, setMessage] = useState('A consultar disponibilidade do cálculo de IVA…');
const base = String(draft.published_iva_base ?? '');
const date = String(draft.published_iva_date ?? '');
const currency = String(draft.published_iva_currency ?? '');
const declared = draft.published_iva_scope === 'yes';
const setBase = (value: string) => onChange('published_iva_base', value);
const setDate = (value: string) => onChange('published_iva_date', value);
const setCurrency = (value: string) => onChange('published_iva_currency', value);
const setDeclared = (value: boolean) => onChange('published_iva_scope', value ? 'yes' : 'no');
const [base, setBase] = useState('');
const [date, setDate] = useState('');
const [currency, setCurrency] = useState('');
const [declared, setDeclared] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
@@ -64,13 +58,12 @@ export function SimulationPublishedIva({ busy, onCalculate, draft, onChange }: {
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
<fieldset disabled={busy} className="space-y-3">
<label className="block">Base tributável líquida (decimal, com ponto)<input aria-label="Base tributável líquida" inputMode="decimal" value={base} onChange={(e) => setBase(e.target.value)} className="block rounded border p-2" /></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option>{currency && currency !== "MZN" && <option value={currency}>{currency} — moeda declarada</option>}</select></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option></select></label>
<label className="block">Data de exigibilidade declarada<input aria-label="Data de exigibilidade IVA" type="date" min="2026-01-01" max="2026-12-31" value={date} onChange={(e) => setDate(e.target.value)} className="block rounded border p-2" /></label>
<label className="block"><input type="checkbox" checked={declared} onChange={(e) => setDeclared(e.target.checked)} /> Declaro operação interna à taxa geral, base líquida já apurada e ausência das excepções acima.</label>
<p className="text-sm">Sem esta declaração, o resultado fica indeterminado. O valor calculado conserva a precisão decimal; não é saldo a pagar.</p>
<button type="button" disabled={!available || busy} onClick={() => void calculate()} className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50">{busy ? 'A calcular…' : 'Calcular IVA e gravar'}</button>
</fieldset>
{currency && currency !== "MZN" && <p role="alert">Moeda: {currency}. A versão disponível aceita apenas MZN. O valor declarado foi preservado; não foi feita conversão. Este cálculo permanece indisponível para esta moeda.</p>}
{error && <p role="alert">{error}</p>}
</section>;
}
-66
View File
@@ -1,66 +0,0 @@
import { restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
const empty = (value: unknown) => value == null || value === '' || (Array.isArray(value) && value.length === 0);
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)]));
return value;
}
// Change tracking only, never an authorization, integrity or legal-acceptance token. No personal values in metadata.
function signature(value: unknown): string {
const text=JSON.stringify(canonical(value)) ?? 'undefined';let hash=14695981039346656037n;
for(let index=0;index<text.length;index++) hash=BigInt.asUintN(64,(hash^BigInt(text.charCodeAt(index)))*1099511628211n);
return `${text.length}:${hash.toString(16)}`;
}
export function inheritCompatibleFields(scope:string, values:Record<string,unknown>, source:Record<string,unknown>, raw:unknown, allowed?:string[]): {patch:Record<string,unknown>; metadata:string; inherited:string[]; conflicts:string[]} {
let previous:Record<string,string>={};
try { const saved=JSON.parse(String(raw ?? ''));if(saved.version===1 && saved.scope===scope && saved.inherited && typeof saved.inherited==='object') previous=saved.inherited; } catch { /* Legacy inputs are retained, not overwritten. */ }
const next={...previous};const patch:Record<string,unknown>={};const inherited:string[]=[];const conflicts:string[]=[];
const geo=['province_id','district_id','admin_post_id','locality_id'];
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
const vacant=key==='parcels' ? geo.every(field=>empty((current as Array<Record<string,unknown>>)?.[0]?.[field])) : empty(current);
if ((geo.includes(key) && geoConflict) || (!owned && (!vacant || key in previous))) {
if(signature(current)!==signature(value)) conflicts.push(key);
continue;
}
if(key==='parcels') {
const parcels=current as Array<Record<string,unknown>>|undefined;
if(!owned && parcels?.length && geo.some(field=>!empty(parcels[0]?.[field]))) {conflicts.push(key);continue;}
}
if(signature(track(current))!==signature(track(value))) {
if(key==='parcels') {
const parcels=(current as Array<Record<string,unknown>>) ?? [];
patch[key]=[{...parcels[0],...Object.fromEntries(geo.map(field=>[field,(value as Array<Record<string,unknown>>)[0]?.[field] ?? '']))},...parcels.slice(1)];
} else patch[key]=value;
}
next[key]=signature(track(value));inherited.push(key);
}
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
if(plan?.currency) source.currency=plan.currency;
const currency=draft.currency || source.currency;
if(currency) {source.fiscal_currency=currency;source.opportunity_currency=currency;source.published_iva_currency=currency;}
if(draft.tax_residency_country) source.fiscal_residency_country=draft.tax_residency_country;
if(draft.fiscal_reference_date) source.published_iva_date=draft.fiscal_reference_date;
let settings:Record<string,unknown>={start:'',basis:'unknown',declared:false,auto:false};
let settingsValid=!draft.monthly_iva_settings;
try {const saved=JSON.parse(String(draft.monthly_iva_settings ?? ''));if(saved && !Array.isArray(saved) && typeof saved.start==='string' && ['net','included','unknown'].includes(saved.basis) && typeof saved.declared==='boolean' && typeof saved.auto==='boolean') {settings=saved;settingsValid=true;}} catch { /* Invalid stored inputs remain intact. */ }
const start=String(draft.expected_start_date ?? '');
if(/^\d{4}-\d{2}-\d{2}$/.test(start)) source.monthly_iva_start=start.slice(0,7);
else if(/^\d{4}$/.test(String(draft.fiscal_period)) && draft.fiscal_periodicity==='monthly' && /^(0?[1-9]|1[0-2])$/.test(String(draft.fiscal_month))) source.monthly_iva_start=`${draft.fiscal_period}-${String(draft.fiscal_month).padStart(2,'0')}`;
if(!settingsValid) delete source.monthly_iva_start;
const values={...draft,monthly_iva_start:settings.start ?? ''};
const patch=inheritCompatibleFields('simulator-compatible-fields',values,source,draft.compatible_fields_snapshot);
const next:Record<string,unknown>={...draft,...patch.patch,compatible_fields_snapshot:patch.metadata};
if('monthly_iva_start' in patch.patch) next.monthly_iva_settings=JSON.stringify({...settings,start:patch.patch.monthly_iva_start});
delete next.monthly_iva_start;
return JSON.stringify(next)===JSON.stringify(draft) ? draft : next as T;
}
-15
View File
@@ -134,19 +134,4 @@ export const contextFieldNames: Record<string, string> = {
province: 'Província',
district: 'Distrito',
location: 'Localização',
currency: 'Moeda do projecto',
person_type: 'Tipo de pessoa',
fiscal_period: 'Ano fiscal',
fiscal_periodicity: 'Periodicidade',
fiscal_month: 'Mês fiscal',
fiscal_residency_country: 'País de residência fiscal declarado',
activity_id: 'Actividade',
project_type: 'Modalidade do investimento',
maturity_stage: 'Fase do investimento',
expected_start_date: 'Início previsto',
investor_origin: 'Origem do investidor',
entity_stage: 'Situação da entidade',
tax_residency_country: 'Residência fiscal declarada',
investment_regime: 'Enquadramento territorial consultado',
special_zone_id: 'ZEE, ZFI ou parque',
};
-1
View File
@@ -21,7 +21,6 @@ export function monthlySalesFromDraft(draft: Record<string, unknown>): { amounts
if (draft.business_plan_snapshot) {
const plan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot));
if (!plan) throw new Error('Não foi possível recuperar a projecção de vendas. Os dados guardados não foram substituídos.');
if (draft.currency && plan.currency && draft.currency !== plan.currency) throw new Error(`A projecção de vendas está em ${plan.currency}, mas o projecto está em ${String(draft.currency)}. Reveja a moeda da projecção; os valores não foram convertidos nem reclassificados.`);
return {amounts: monthlyProjectedSales(plan), source: 'Vendas por produto e mês do plano empresarial; mesmos pressupostos e representação monetária da projecção de vendas.'};
}
if (draft.cashflow_snapshot) {
@@ -277,19 +277,6 @@ function makeSections(
}
}
if (draft.context_applicability_snapshot) {
try {
const context = JSON.parse(String(draft.context_applicability_snapshot));
if (context.version === 1) {
const section = sections.get('sources');
const zone = context.zone;
section?.rows.push({field:'Contexto do investimento',value:`Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,origin:'Referência do mesmo dossier no momento da gravação',classification:'Contexto versionado; não é reconhecimento jurídico'});
section?.paragraphs.push(zone ? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.' : 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.');
if(zone?.href) section?.rows.push({field:'Ficha e fundamentos da zona',value:zone.href,origin:`Versão consultada ${zone.version}`,classification:'Referência consultável; condições por verificar'});
}
} catch { sections.get('sources')?.paragraphs.push('Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.'); }
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
// Questionnaire schema identifiers remain stored; they do not help the
// reader assess the project and are not a business report field.
+1 -2
View File
@@ -1,4 +1,3 @@
import { inheritSimulatorFields } from './context-inheritance.ts';
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
@@ -1531,7 +1530,7 @@ export function restoreSimulationDraft(
}
}
}
return inheritSimulatorFields(draft as SimulationDraft);
return draft as SimulationDraft;
}
export function simulationDraftFingerprint(draft: SimulationDraft): string {
-1
View File
@@ -135,7 +135,6 @@ export default function RequestPage({
<DossierPrefill
contextId={dossier.id}
consumer="desk"
persistInheritance={request.status !== 'returned'}
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
+3 -27
View File
@@ -1,4 +1,3 @@
import { InvestmentContextAssessment } from '@/components/investment-context-assessment';
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@@ -132,7 +131,6 @@ export default function ContextEditor({
}
return (
<Frame title="Contexto do investimento">
<InvestmentContextAssessment contextId={view.case.id} raw={view.prefill.simulator?.context_applicability_snapshot} />
<ContextDocuments source={view.context.selection.opportunity} />
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
@@ -288,7 +286,7 @@ export default function ContextEditor({
</select>
</label>
<label className="grid min-w-0 gap-1">
ZEE, ZFI ou parque consultado
Parque consultado
<select
className={input + ' max-w-full min-w-0'}
disabled={busy}
@@ -296,7 +294,7 @@ export default function ContextEditor({
onChange={(e) => change('park', e.target.value)}
>
<option value="">
Sem ZEE, ZFI ou parque seleccionado
Sem parque no contexto atual
</option>
{options.parks.map((p) => (
<option key={p.id} value={'park:' + p.id}>
@@ -337,17 +335,6 @@ export default function ContextEditor({
Estes campos pertencem ao processo. Alterá-los não muda
o cadastro da oportunidade nem o perfil da organização.
</p>
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
{['project_title', 'project_summary'].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
@@ -379,18 +366,7 @@ export default function ContextEditor({
declarações do utilizador. Autorize somente os
campos que pretende substituir pelo cadastro.
</p>
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
{['project_title', 'project_summary'].map((k) => (
{['project_title', 'project_summary'].map((k) => (
<label className="block" key={k}>
<input
type="checkbox"
@@ -1,103 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\InvestmentZones;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentContextContinuityTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
private function dossier(User $user, string $title): string
{
return $this->actingAs($user)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
public function test_same_dossier_context_reuses_linked_draft_and_preserves_account_and_project_boundaries(): void
{
$owner = User::factory()->create();
$a = $this->dossier($owner, 'A');
$b = $this->dossier($owner, 'B');
foreach ([[$a, 'MZN', 'national'], [$b, 'USD', 'foreign']] as [$id,$currency,$origin]) {
$this->actingAs($owner)->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => ['investor_origin' => $origin, 'tax_residency_country' => 'ZA', 'person_type' => 'collective'], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $id, 'currency' => $currency, 'expected_start_date' => '2026-01-01']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$v = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$v->assertJsonPath('prefill.simulator.currency', $currency)->assertJsonPath('prefill.simulator.investor_origin', $origin)->assertJsonPath('prefill.simulator.tax_residency_country', 'ZA');
$this->assertSame('linked_simulation', $v->json('context.provenance.currency.kind'));
}
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$a.'/contexto')->assertForbidden();
$this->actingAs($owner)->getJson('/investimentos/'.$a.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'MZN');
}
public function test_context_change_is_previewed_versioned_and_idempotent_without_changing_the_source_draft(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Impacto');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'USD', 'investor_origin' => 'foreign', 'tax_residency_country' => 'MZ'], 'replace_fields' => []];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertNotEmpty($plan['updated']);
$this->assertTrue($plan['dependency_changed']);
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'USD')->assertJsonPath('prefill.simulator.tax_residency_country', 'MZ');
}
public function test_zone_selection_reuses_existing_reference_and_impact_workflow_without_granting_a_benefit(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Zonas QA');
$template = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->firstOrFail();
foreach (['zee', 'zfi', 'industrial_park'] as $category) {
// Synthetic catalogue metadata, rolled back. Never a legal source or published rule in homologation.
$zone = $template->replicate();
$zone->id = (string) Str::uuid();
$zone->code = 'SINT-CONTEXT-'.Str::random(12);
$zone->name = 'SINTÉTICO QA '.$category;
$zone->category = $category;
$zone->legal_regime = $category === 'industrial_park' ? 'pending' : $category;
$zone->source_evidence = [['source_type' => 'government', 'url' => 'https://example.test/synthetic-zone', 'supports' => 'Synthetic test only; no legal or eligibility conclusion']];
$zone->save();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => 'park:'.$zone->id]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertSame($category, $plan['fields']['investment_regime']);
$this->assertContains('S17', $plan['review_steps']);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$after->assertJsonPath('prefill.simulator.investment_regime', $category);
$snapshot = json_decode($after->json('prefill.simulator.context_applicability_snapshot'), true);
$this->assertSame($zone->id, $snapshot['zone']['id']);
$this->assertArrayNotHasKey('benefit_rate', $snapshot);
}
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => null]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_regime', 'general')->assertJsonPath('prefill.simulator.special_zone_id', '');
}
public function test_a_title_edit_does_not_invalidate_applicability_or_ask_for_irrelevant_reviews(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Título original');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['project_title' => 'Título revisto']])
->assertOk()->assertJsonPath('dependency_changed', false)->assertJsonPath('review_steps', []);
}
}
@@ -1,63 +0,0 @@
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
import assert from 'node:assert/strict';
import test from 'node:test';
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};
const changed=inheritCompatibleFields('dossier:A:simulator',saved,{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(changed.patch,{currency:'USD',project_title:'A2'});
const edited=inheritCompatibleFields('dossier:A:simulator',{...saved,currency:'EUR',project_title:''},{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(edited.patch,{});assert.deepEqual(edited.conflicts,['currency','project_title']);
const recovered=JSON.parse(JSON.stringify({...saved,currency:'EUR',project_title:''}));
assert.deepEqual(inheritCompatibleFields('dossier:A:simulator',recovered,{currency:'USD',project_title:'A2'},recovered.context_reuse).patch,{});
assert.ok(!first.metadata.includes('MZN'));assert.ok(!first.metadata.includes('project_title":"A'));
});
test('a different dossier cannot take ownership of existing values using another dossier metadata',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN'}, null);
const second=inheritCompatibleFields('dossier:B:simulator',first.patch,{currency:'USD'},first.metadata);
assert.deepEqual(second.patch,{});assert.deepEqual(second.conflicts,['currency']);
});
test('a local geographic override protects the entire hierarchy',()=>{
const first=inheritCompatibleFields('A',{}, {province_id:'p1',district_id:'d1'},null);
const update=inheritCompatibleFields('A',{...first.patch,province_id:'p-local'},{province_id:'p2',district_id:'d2'},first.metadata);
assert.deepEqual(update.patch,{});
});
test('compatible currency and date reuse survives backend partition and new session recovery without invented tax scope',()=>{
const initial=inheritSimulatorFields({currency:'USD',expected_start_date:'2026-03-01',purpose:'simulate_only'});
assert.equal(initial.fiscal_currency,'USD');assert.equal(initial.published_iva_currency,'USD');assert.equal(initial.opportunity_currency,'USD');
assert.equal(JSON.parse(initial.monthly_iva_settings).start,'2026-03');assert.equal(JSON.parse(initial.monthly_iva_settings).declared,false);
assert.equal(initial.published_iva_date,undefined);
assert.equal(inheritSimulatorFields({expected_start_date:'2026-01-01',monthly_iva_settings:'[invalid stored input'}).monthly_iva_settings,'[invalid stored input');
const saved=partitionSimulationDraft(initial,true);const recovered=restoreSimulationDraft(saved.economic_snapshot,saved.personal_snapshot);
assert.equal(recovered.published_iva_currency,'USD');assert.strictEqual(inheritSimulatorFields(recovered),recovered);
const changed=inheritSimulatorFields({...recovered,currency:'EUR'});assert.equal(changed.fiscal_currency,'EUR');
const override=inheritSimulatorFields({...changed,fiscal_currency:'MZN',published_iva_currency:''});assert.equal(override.fiscal_currency,'MZN');assert.equal(override.published_iva_currency,'');
});
test('land consumer reuses geography inside an empty parcel and preserves all other parcel data',()=>{
const first=inheritCompatibleFields('land', {parcels:[{area:'12',province_id:''},{area:'20',province_id:'other'}]}, {parcels:[{province_id:'P',district_id:'D'}]},null);
assert.equal(first.patch.parcels[0].area,'12');assert.equal(first.patch.parcels[0].district_id,'D');assert.equal(first.patch.parcels[1].province_id,'other');
const changed=inheritCompatibleFields('land',{parcels:[{...first.patch.parcels[0],area:'13'},first.patch.parcels[1]]},{parcels:[{province_id:'P2'}]},first.metadata);
assert.equal(changed.patch.parcels[0].area,'13');assert.equal(changed.patch.parcels[0].district_id,'');
});
test('clearing an inherited zone reference clears only the inherited value, preserving a deliberate override',()=>{
const first=inheritCompatibleFields('A',{}, {special_zone_id:'zone'},null);
assert.equal(inheritCompatibleFields('A',first.patch,{special_zone_id:''},first.metadata).patch.special_zone_id,'');
assert.deepEqual(inheritCompatibleFields('A',{special_zone_id:'local'},{special_zone_id:''},first.metadata).patch,{});
});
test('conflicting saved sales currency is explained instead of relabelled or converted',()=>{
const fields=['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
const plan={...Object.fromEntries(fields.map(key=>[key,'0'])),currency:'MZN',periods:4,depreciationLifeMonths:60,products:[{label:'QA',monthlyUnits:'1',unitPrice:'100',unitVariableCost:'0'}]};
assert.throws(()=>monthlySalesFromDraft({currency:'USD',business_plan_snapshot:serializeBusinessPlanSnapshot(plan)}),/projecção de vendas está em MZN.*projecto está em USD/);
});
test('report preserves consulted zone version and makes unsupported benefit explicit without inventing a fiscal result',()=>{
const context={version:1,context_revision:3,zone:{title:'Referência QA',version:7,href:'/zonas/qa'},fields:{currency:'USD'}};
const draft={project_title:'QA',currency:'USD',context_applicability_snapshot:JSON.stringify(context)};
const saved=JSON.stringify(draft);const report=createProjectDossierReport(draft,{selectedSections:new Set(['sources'])});
assert.match(JSON.stringify(report),/Referência QA, versão 7/);assert.match(JSON.stringify(report),/Benefício fiscal da zona indeterminado/);assert.equal(JSON.stringify(draft),saved);
});