From 1afdfee8c0a2e3ebf057fdc7eed388a0bf18c556 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 23 Oct 2026 18:25:09 +0200 Subject: [PATCH] fix: share selected opportunity across simulator stages and land shortcuts --- .../Controllers/InvestorPathController.php | 3 +- .../StoreSimulationFormDraftRequest.php | 3 +- app/Services/InvestmentConsumerScope.php | 22 +++ app/Services/InvestorContext.php | 29 ++- app/Services/InvestorPath.php | 54 ++++-- app/Services/SimulationFormDrafts.php | 7 +- .../components/dossier-navigation-context.tsx | 6 +- resources/js/components/dossier-prefill.tsx | 28 +-- .../investment-opportunity-choices.tsx | 2 +- .../js/components/investment-requirements.tsx | 2 +- resources/js/components/simulation-form.tsx | 6 +- resources/js/lib/investor-path.ts | 1 + resources/js/lib/project-dossier-report.ts | 5 + resources/js/pages/investment/detail.tsx | 2 + resources/js/pages/investor-path/show.tsx | 12 ++ .../Feature/InvestmentTreatmentScopeTest.php | 169 ++++++++++++++++++ .../frontend/treatment-scope-export.test.mjs | 32 ++++ 17 files changed, 337 insertions(+), 46 deletions(-) create mode 100644 tests/Feature/InvestmentTreatmentScopeTest.php create mode 100644 tests/frontend/treatment-scope-export.test.mjs diff --git a/app/Http/Controllers/InvestorPathController.php b/app/Http/Controllers/InvestorPathController.php index 43505e78..687be094 100644 --- a/app/Http/Controllers/InvestorPathController.php +++ b/app/Http/Controllers/InvestorPathController.php @@ -88,7 +88,8 @@ class InvestorPathController extends Controller public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response { - $data = $path->view($request->user(), $id); + $choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null; + $data = $path->view($request->user(), $id, $choice); return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data); } diff --git a/app/Http/Requests/StoreSimulationFormDraftRequest.php b/app/Http/Requests/StoreSimulationFormDraftRequest.php index cc8e7838..2ac875ab 100644 --- a/app/Http/Requests/StoreSimulationFormDraftRequest.php +++ b/app/Http/Requests/StoreSimulationFormDraftRequest.php @@ -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', + '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', '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', @@ -73,6 +73,7 @@ class StoreSimulationFormDraftRequest extends FormRequest '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:1000000'], + 'economic_snapshot.context_opportunity' => ['nullable', 'string', 'max:100'], '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'], diff --git a/app/Services/InvestmentConsumerScope.php b/app/Services/InvestmentConsumerScope.php index 14bd1547..82cae6cd 100644 --- a/app/Services/InvestmentConsumerScope.php +++ b/app/Services/InvestmentConsumerScope.php @@ -8,6 +8,27 @@ use Illuminate\Support\Arr; /** An identified opportunity within the authorised dossier, never the account's last choice. */ final class InvestmentConsumerScope { + public function key(array $context, ?string $key, bool $required = false): ?string + { + $choices = $context['opportunity_choices'] ?? []; + $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null); + abort_if($key && !collect($choices)->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.'); + abort_if($required && count($choices) > 1 && !$key, 422, 'Escolha a oportunidade em tratamento.'); + return $key; + } + + public function facts(array $payload, array $context, ?string $key): array + { + $key = $this->key($context, $key); + $path = $payload['investor_path'] ?? []; + $defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown']; + // A dossier-wide dispensation cannot decide a distinct opportunity's needs. + if ($key && count($context['opportunity_choices'] ?? []) > 1) return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])]; + if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]]; + if (count($context['opportunity_choices'] ?? []) > 1) return $defaults; + return [...$defaults, ...($path['facts'] ?? [])]; + } + public function context(array $context, ?string $key): array { $choices = $context['opportunity_choices'] ?? []; @@ -44,6 +65,7 @@ final class InvestmentConsumerScope $context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn($r) => $r['opportunity_key'] === $key)); $context['consumer_opportunity'] = $choice; $context['complements'] = array_values(array_filter($context['complements'] ?? [], fn($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '',10),array_column($choice['portfolio_origins'] ?? [],'id'),true))); + $context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices'=>[$choice], 'fields'=>Arr::except($context['fields'],['project_title','project_summary']), 'complements'=>$context['complements']]); return $context; } diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 90e27e59..1be96221 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -586,8 +586,8 @@ final class InvestorContext $case = $this->desk->case($actor, $id); abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403); $ctx = $this->state($case); - $ctx['land_preparation'] = $this->landPreparation($case); - $path = app(InvestorPath::class)->view($actor, $id); + $ctx['land_preparation'] = $this->landPreparation($case, $opportunity); + $path = app(InvestorPath::class)->view($actor, $id, $opportunity); $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']))))]; @@ -602,12 +602,19 @@ final class InvestorContext } } - $preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($ctx, $this->preparationDocuments($id)) : null; + $preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess(app(InvestmentConsumerScope::class)->context($ctx, $opportunity), $this->preparationDocuments($id)) : null; $ctx['preparation_assessment'] = $preparation; + $prefill = $this->prefill($ctx, $opportunity); + $saved = $this->desk->decode($case->payload); + if (!empty($saved['reuse_consent'])) { + $contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== ''); + $prefill['simulator'] = [...$contacts, ...$prefill['simulator']]; + $prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']]; + } return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']), 'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'], - 'prefill' => $this->prefill($ctx, $opportunity), 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.']; + 'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.']; } public function preparationDocuments(string $id): array @@ -615,18 +622,21 @@ final class InvestorContext return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all(); } - public function landPreparation(\stdClass $case): array + public function landPreparation(\stdClass $case, ?string $opportunity = null): array { - $path = $this->desk->decode($case->payload)['investor_path'] ?? []; - $need = $path['facts']['land_needed'] ?? 'unknown'; + $payload = $this->desk->decode($case->payload); + $ctx = $this->state($case); + $key = app(InvestmentConsumerScope::class)->key($ctx, $opportunity); + $path = $payload['investor_path'] ?? []; + $need = app(InvestmentConsumerScope::class)->facts($payload, $ctx, $key)['land_needed']; - return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? '']; + return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')]; } public function prefill(array $ctx, ?string $opportunity = null): array { $original = $ctx; - if ($opportunity) $ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity); + $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'])]); // Entering this tool from an investment dossier selects the existing investment-analysis route. @@ -646,6 +656,7 @@ final class InvestorContext $zone = $ctx['selection']['park'] ?? null; $sim['context_applicability_snapshot'] = json_encode([ 'version' => 1, 'context_revision' => $ctx['revision'], + 'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null, 'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null, 'provenance' => $ctx['provenance'], 'opportunity_choices' => $ctx['opportunity_choices'] ?? [], diff --git a/app/Services/InvestorPath.php b/app/Services/InvestorPath.php index 90f86e14..e3881da5 100644 --- a/app/Services/InvestorPath.php +++ b/app/Services/InvestorPath.php @@ -68,7 +68,7 @@ final class InvestorPath } /** @return array */ - public function view(User $actor, string $id): array + public function view(User $actor, string $id, ?string $opportunity = null): array { abort_unless($this->available(), 404); $case = $this->desk->case($actor, $id); @@ -79,18 +79,26 @@ final class InvestorPath $payload = $this->desk->decode($case->payload); $path = $payload['investor_path'] ?? []; $context = app(InvestorContext::class)->state($case); - $facts = $path['facts'] ?? ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown']; + $scope = app(InvestmentConsumerScope::class); + $opportunity = $scope->key($context, $opportunity); + $allChoices = $context['opportunity_choices'] ?? []; + $facts = $scope->facts($payload, $context, $opportunity); + $factReasons = $opportunity ? ($path['opportunity_fact_reasons'][$opportunity] ?? []) : ($path['fact_reasons'] ?? []); + $context = $scope->context($context, $opportunity); $records = DB::table('journey_steps')->where('journey_id', $journey->id)->whereNull('deleted_at')->get()->keyBy('step_code'); $steps = []; foreach ($definition['steps'] as $step) { $row = $records->get($step['code']); abort_unless($row !== null, 409, 'Etapa em falta; reparar a instalação sem reconstruir o histórico.'); $detail = $this->desk->decode($row->path_detail); + if ($opportunity && (count($allChoices) > 1 || isset($detail['opportunities'][$opportunity]))) { + $detail = $detail['opportunities'][$opportunity] ?? ['state' => 'not_started']; + } $value = $step['branch'] ? ($facts[$step['branch']] ?? 'unknown') : 'yes'; $applicable = $value === 'unknown' ? 'undetermined' : ($value === 'no' ? 'not_needed_for_preparation' : 'applicable'); $state = $value === 'no' ? 'dispensed' : $detail['state']; $steps[] = [...$step, 'id' => $row->id, 'version' => $row->version_no, 'state' => $state, 'saved_state' => $detail['state'], - 'applicability' => $applicable, 'reason' => $value === 'no' ? ($path['fact_reasons'][$step['branch']] ?? '') : ($detail['reason'] ?? ''), + 'applicability' => $applicable, 'reason' => $value === 'no' ? ($factReasons[$step['branch']] ?? '') : ($detail['reason'] ?? ''), 'detail' => $detail, 'preparation_allowed' => $value !== 'no', 'action_gaps' => []]; } @@ -143,7 +151,8 @@ final class InvestorPath $landCase = (int) $case->user_id === $actor->id ? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get() - ->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id) : null; + ->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id + && (!$allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null; $preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null; if ($preparation && $preparation['enabled']) { @@ -152,11 +161,12 @@ final class InvestorPath return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null], 'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'], - 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $path['fact_reasons'] ?? [], + 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons, + 'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices, 'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null, 'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at, 'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null, - 'investment_context' => ($owner || $manager) ? $context : null, + 'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null, 'results' => $results, 'requests' => $requests, 'documents' => $documents, 'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [], 'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [], @@ -167,25 +177,31 @@ final class InvestorPath ->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']), 'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'definition']) ->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title']]), - 'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '') : null, - 'desk' => '/balcao-virtual/dossiers/'.$id, 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao', + 'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null, + 'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao', 'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id, - 'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]]; + 'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '')) : null]]; } /** @param array $input */ public function facts(User $actor, string $id, array $input): void { - $data = Validator::make($input, ['version' => 'required|integer|min:1', + $data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100', 'land_needed' => ['required', Rule::in(['yes', 'no', 'unknown'])], 'external_funding' => ['required', Rule::in(['yes', 'no', 'unknown'])], 'viability' => ['required', Rule::in(['unknown', 'proceed', 'reformulate', 'archive'])], 'reason' => 'required|string|min:3|max:3000'])->validate(); DB::transaction(function () use ($actor, $id, $data): void { $case = $this->writable($actor, $id, $data['version']); $payload = $this->desk->decode($case->payload); + $choice = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true); foreach (['land_needed', 'external_funding', 'viability'] as $key) { - $payload['investor_path']['facts'][$key] = $data[$key]; - $payload['investor_path']['fact_reasons'][$key] = $data['reason']; + if ($choice) { + $payload['investor_path']['opportunity_facts'][$choice][$key] = $data[$key]; + $payload['investor_path']['opportunity_fact_reasons'][$choice][$key] = $data['reason']; + } else { + $payload['investor_path']['facts'][$key] = $data[$key]; + $payload['investor_path']['fact_reasons'][$key] = $data['reason']; + } } $this->savePayload($case, $payload); $this->desk->event($this->desk->case($actor, $id), $actor, 'path_applicability_declared', $data); @@ -195,7 +211,7 @@ final class InvestorPath /** @param array $input */ public function stage(User $actor, string $id, string $code, array $input): void { - $data = Validator::make($input, ['version' => 'required|integer|min:1', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))], + $data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))], 'reason' => 'required|string|min:3|max:3000', 'action' => ['required', Rule::in(['prepare', 'submit', 'execute', 'operate', 'review', 'record_external'])], 'document_id' => 'nullable|uuid', 'authority' => 'nullable|string|max:200', 'reference' => 'nullable|string|max:300', 'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean', 'decision_date' => 'nullable|date_format:Y-m-d|before_or_equal:today'])->validate(); @@ -203,7 +219,8 @@ final class InvestorPath $case = $this->desk->case($actor, $id, false, true); abort_if($case->closed_at, 409); abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão actual.'); - $view = $this->view($actor, $id); + $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true); + $view = $this->view($actor, $id, $key); $step = $this->findStep($view, $code); abort_unless($step !== null && $step['preparation_allowed'], 422, 'Etapa dispensada de preparação; altere os pressupostos com fundamento.'); $state = $data['state']; @@ -238,7 +255,10 @@ final class InvestorPath } abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.'); $detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()]; - DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($detail), + $stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail')); + if ($key) $stored['opportunities'][$key] = $detail; + else $stored = [...$stored, ...$detail]; + DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored), 'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'), 'version_no' => $step['version'] + 1, 'updated_at' => now()]); DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]); @@ -267,11 +287,12 @@ final class InvestorPath abort_unless(is_array($parsed) && ($parsed['version'] ?? null) === 1 && is_array($parsed['data'] ?? null), 422, 'Snapshot financeiro incompatível.'); $payload = $this->desk->decode($case->payload); $context = app(InvestorContext::class)->state($case); + $opportunity = app(InvestmentConsumerScope::class)->key($context, $economic['context_opportunity'] ?? null, true); if ($context['revision'] > 0) { abort_unless(($data['context_revision'] ?? -1) === $context['revision'], 409, 'O contexto mudou; recupere e confira os pressupostos antes de devolver o resultado.'); abort_unless($data['context_reviewed'] ?? false, 422, 'Confirme a revisão dos pressupostos financeiros para este contexto.'); } - $resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision']); + $resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision'].($opportunity ? ':'.$opportunity : '')); foreach ($payload['investor_path']['results'] ?? [] as $result) { if ($result['source_key'] === $resultKey) { return $result; @@ -280,6 +301,7 @@ final class InvestorPath abort_unless((int) $case->version === $data['version'], 409, 'Recupere a versão actual do dossier.'); $result = ['id' => (string) Str::uuid(), 'source_key' => $resultKey, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot, + 'context_opportunity' => $opportunity, 'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1', '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; diff --git a/app/Services/SimulationFormDrafts.php b/app/Services/SimulationFormDrafts.php index 8ec41ed7..29fa33b9 100644 --- a/app/Services/SimulationFormDrafts.php +++ b/app/Services/SimulationFormDrafts.php @@ -470,7 +470,10 @@ final class SimulationFormDrafts private function landContext(array $economic, \stdClass $case, ?array $previous = null): array { - $decision = app(InvestorContext::class)->landPreparation($case); + $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); + $key = $economic['context_opportunity'] ?? $snapshot['consumer_opportunity'] ?? null; + $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), is_string($key) ? $key : null); + $decision = app(InvestorContext::class)->landPreparation($case, $key); if ($decision['need'] !== 'yes' && $previous !== null) { if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status']; else unset($economic['land_status']); @@ -482,6 +485,8 @@ final class SimulationFormDrafts $snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']]; } $snapshot['land_preparation'] = $decision; + $snapshot['consumer_opportunity'] = $key; + if ($key) $economic['context_opportunity'] = $key; $economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); return $economic; diff --git a/resources/js/components/dossier-navigation-context.tsx b/resources/js/components/dossier-navigation-context.tsx index dfa03ca1..2f146b48 100644 --- a/resources/js/components/dossier-navigation-context.tsx +++ b/resources/js/components/dossier-navigation-context.tsx @@ -153,7 +153,7 @@ export function NavigationContextProvider({ } const view = focus ? await contextApi( - `/investimentos/${focus.case_id}`, + `/investimentos/${focus.case_id}${direct===focus.case_id && new URL(url,'https://context.invalid').searchParams.get('opportunity') ? '?opportunity='+encodeURIComponent(new URL(url,'https://context.invalid').searchParams.get('opportunity')!) : ''}`, ) : null; if (!stopped) { @@ -285,7 +285,7 @@ export function NavigationContextSummary() { {view && ( Abrir dossier atual @@ -293,7 +293,7 @@ export function NavigationContextSummary() { {view?.next && ( Preparar {view.next.code} diff --git a/resources/js/components/dossier-prefill.tsx b/resources/js/components/dossier-prefill.tsx index 46a77ad2..56250b35 100644 --- a/resources/js/components/dossier-prefill.tsx +++ b/resources/js/components/dossier-prefill.tsx @@ -32,6 +32,10 @@ const empty = (v: unknown) => (Array.isArray(v) && v.length === 0); const text = (v: unknown) => typeof v === 'string' ? v : JSON.stringify(v ?? ''); +function savedOpportunity(values: Record): string { + if (values.context_opportunity) return String(values.context_opportunity); + try { return JSON.parse(String(values.context_applicability_snapshot ?? '')).consumer_opportunity ?? ''; } catch { return ''; } +} const displayField = (values: Record, key: string) => key.endsWith('_id') ? text(values[`${key}_label`]) || (empty(values[key]) ? 'não informado' : 'Referência escolhida; designação por recuperar') @@ -52,7 +56,11 @@ export function DossierPrefill(props: Props) { const [selected, setSelected] = useState([]); const [reviewed, setReviewed] = useState(false); const [message, setMessage] = useState(''); - const [opportunity, setOpportunity] = useState(() => String(props.values.context_opportunity || props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : ''))); + const userChose = useRef(false); + const [opportunity, setOpportunity] = useState(() => String(props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : '') || savedOpportunity(props.values))); + useEffect(() => { + if (!userChose.current && !opportunity && enabled) { const saved = savedOpportunity(props.values); if (saved) setOpportunity(saved); } + }, [enabled, props.values, opportunity]); const loadedOpportunity = useRef(''); useEffect(() => { if (!contextId) return; @@ -80,7 +88,7 @@ export function DossierPrefill(props: Props) { const chosen = opportunity || (choices.length === 1 ? choices[0].opportunity.key : ''); const source = view.prefill[consumer] ?? {}; const now = latest.current; - if (consumer !== 'simulator' && chosen && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true); + if ((chosen || consumer === 'simulator') && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true); 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. @@ -139,19 +147,19 @@ export function DossierPrefill(props: Props) {

{view.case.title} - {' · '}Voltar ao dossier + {' · '}Voltar ao dossier

- {(consumer==='simulator'||opportunity||view.context.opportunity_choices?.length===1)&&} + {(opportunity||view.context.opportunity_choices?.length===1)&&} - {consumer!=='simulator'&&contextId&&r.opportunity_key===(opportunity || (view.context.opportunity_choices?.length===1 ? view.context.opportunity_choices[0].opportunity.key : '')))} : null}/>} - {consumer !== 'simulator' && !!view.context.opportunity_choices?.length &&