From 0025309b984ad75c77b8f03182a74290b82b48d3 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 23 Oct 2026 13:24:58 +0200 Subject: [PATCH] feat: adapt investment territories to Tempo corridor configuration --- .../Controllers/InvestorContextController.php | 25 +++- .../Controllers/PublicGeographyController.php | 7 +- app/Services/InvestmentCorridorContext.php | 59 +++++++++ app/Services/InvestmentOpportunityChoices.php | 15 ++- app/Services/InvestorContext.php | 82 ++++++++++++- config/investment_corridors.php | 13 ++ .../unification/traceability-and-manual.md | 14 +++ .../investment-opportunity-choices.tsx | 59 ++++++--- .../js/components/investment-territory.tsx | 10 +- resources/js/lib/investor-context.ts | 5 + .../js/pages/investor-path/context-start.tsx | 7 +- resources/js/pages/investor-path/context.tsx | 88 +++++++++++--- routes/investor-path.php | 2 + .../Feature/InvestmentCorridorContextTest.php | 113 ++++++++++++++++++ .../InvestmentOpportunityChoicesTest.php | 4 +- 15 files changed, 461 insertions(+), 42 deletions(-) create mode 100644 app/Services/InvestmentCorridorContext.php create mode 100644 config/investment_corridors.php create mode 100644 tests/Feature/InvestmentCorridorContextTest.php diff --git a/app/Http/Controllers/InvestorContextController.php b/app/Http/Controllers/InvestorContextController.php index 0c30b1a8..cd648ede 100644 --- a/app/Http/Controllers/InvestorContextController.php +++ b/app/Http/Controllers/InvestorContextController.php @@ -33,6 +33,19 @@ final class InvestorContextController extends Controller return response()->json($context->options($d['q'] ?? '')); } + public function corridorOptions(Request $r) + { + $data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']); + $service = app(\App\Services\InvestmentCorridorContext::class); + return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(), + 'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]); + } + + public function saveDraft(Request $r, string $id, InvestorContext $context) + { + return response()->json($context->saveDraft($r->user(), $id, $r->all())); + } + public function edit(Request $r, string $id, InvestorContext $context) { $data = $context->view($r->user(), $id); @@ -42,8 +55,18 @@ final class InvestorContextController extends Controller $choices = $ids ? app(\App\Services\InvestmentOpportunityChoices::class)->resolve(array_map(fn ($id) => ['source' => 'opportunity:'.$id], $ids)) : null; $source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null; if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]); + $explicitChoices=$choices; + $selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null; + $entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:''); + $entrySources=array_map($entryKey,$explicitChoices??[]); if ($choices !== null) $choices = collect([...($data['context']['opportunity_choices'] ?? []), ...$choices])->unique(fn ($choice) => $choice['opportunity']['key'])->values()->all(); - return Inertia::render('investor-path/context', ['requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]); + $draft = $context->draft($r->user(), $id); + if ($draft && isset($draft['opportunity_choices'])) { + $recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true); + $new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true)); + $choices = collect([...$recovered, ...$new])->unique(fn($c)=>$c['opportunity']['key'])->values()->all(); + } + return Inertia::render('investor-path/context', ['entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]); } public function preview(Request $r, string $id, InvestorContext $context) diff --git a/app/Http/Controllers/PublicGeographyController.php b/app/Http/Controllers/PublicGeographyController.php index 977a96fa..5677e9d6 100644 --- a/app/Http/Controllers/PublicGeographyController.php +++ b/app/Http/Controllers/PublicGeographyController.php @@ -11,8 +11,13 @@ class PublicGeographyController extends Controller { public function options(Request $request, InvestmentGeography $geography): JsonResponse { - $data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); + $data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); $query = $geography->publicReferences()->where('location_type', $data['type']); + if (!empty($data['corridor'])) { + $corridors = app(\App\Services\InvestmentCorridorContext::class); + if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id')); + elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.'); + } if (! empty($data['id'])) { $query->where('id', $data['id']); } diff --git a/app/Services/InvestmentCorridorContext.php b/app/Services/InvestmentCorridorContext.php new file mode 100644 index 00000000..7f4adcbd --- /dev/null +++ b/app/Services/InvestmentCorridorContext.php @@ -0,0 +1,59 @@ +workflow_context, 'corridor_agribusiness.corridor'); + foreach (config('investment_corridors') as $key => $entry) if ($entry['name'] === $name) return $key; + return null; + } + + public function provinces(string $key): array + { + $entry = config('investment_corridors.'.$key); + abort_unless(is_array($entry), 422, 'Seleccione um corredor disponível.'); + $records = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province') + ->whereIn('source_code', $entry['provinces'])->get(['id', 'name', 'parent_id', 'location_type', 'source_code'])->all(); + abort_unless(count($records) === count($entry['provinces']), 503, 'Não foi possível recuperar todas as províncias deste corredor. Tente novamente.'); + return $records; + } + + public function compatible(string $key, ?string $territory): bool + { + if (!$territory) return true; + $path = app(InvestmentGeography::class)->path($territory); + return $path !== [] && in_array($path[0]->id, array_column($this->provinces($key), 'id'), true); + } + + /** Both membership in the selected area and documented public zone coverage are required. */ + public function parks(?string $key, ?string $territory): array + { + if (!$key || !$territory) return []; + abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.'); + $selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id'); + $candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(); + $result = []; + foreach ($candidates as $zone) { + $ids = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->where('validation_status', 'validated') + ->whereIn('scope_type', ['legal_scope', 'administrative', 'site']) + ->where(fn($q)=>$q->whereNull('valid_from')->orWhereDate('valid_from','<=',today())) + ->where(fn($q)=>$q->whereNull('valid_until')->orWhereDate('valid_until','>=',today()))->get(['geo_location_id','scope_type'])->all(); + if ($zone->primary_territory_id) $ids[] = (object)['geo_location_id'=>$zone->primary_territory_id,'scope_type'=>'site']; + foreach ($ids as $link) { + $id=$link->geo_location_id; + if (!$this->compatible($key, $id)) continue; + $path = array_column(app(InvestmentGeography::class)->path($id), 'id'); + if (in_array($territory, $path, true) || ($link->scope_type==='legal_scope' && in_array($id, $selectedPath, true))) { + $result[] = $zone->only(['id', 'name', 'category', 'version_no']); break; + } + } + } + return $result; + } +} diff --git a/app/Services/InvestmentOpportunityChoices.php b/app/Services/InvestmentOpportunityChoices.php index b75d742c..807b89ab 100644 --- a/app/Services/InvestmentOpportunityChoices.php +++ b/app/Services/InvestmentOpportunityChoices.php @@ -7,26 +7,35 @@ use App\Models\Opportunity; /** Multiple identified references in the existing encrypted investment context. */ final class InvestmentOpportunityChoices { - public function resolve(array $choices, array $previous = []): array + public function resolve(array $choices, array $previous = [], bool $pending = false): array { $result = []; foreach ($choices as $choice) { $ref = app(InvestorContext::class)->reference($choice['source']); abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.'); $record = Opportunity::findOrFail($ref['id']); + $old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']); + $corridors = app(InvestmentCorridorContext::class); + $corridor = $choice['corridor_key'] ?? $old['corridor_key'] ?? $corridors->key($record); + $provinces = $corridor ? $corridors->provinces($corridor) : []; $documented = $record->primary_territory_id; $territory = $choice['territory'] ?? ($documented ? 'territory:'.$documented : null); - $old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']); + if (!$territory && count($provinces) === 1) $territory = 'territory:'.$provinces[0]->id; + if ($corridor && $territory) abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.'); $previousTerritory = $old['territory']['key'] ?? null; $newAlternative = $documented && $territory !== 'territory:'.$documented && (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory); - if ($newAlternative || ($previousTerritory && $territory !== $previousTerritory)) { + $corridorChanged = $old && ($old['corridor_key'] ?? $corridors->key($record)) !== $corridor; + $incompatibleOld = $corridorChanged && $previousTerritory && !$corridors->compatible($corridor, substr($previousTerritory, 10)); + if (!$pending && ($newAlternative || ($previousTerritory && $territory !== $previousTerritory && !$incompatibleOld))) { abort_unless(! empty($choice['confirm_location_change']) && mb_strlen(trim($choice['location_reason'] ?? '')) >= 20, 422, 'Uma localização documentada ou já confirmada só muda por escolha explícita, com confirmação e motivo.'); } $location = $territory ? app(InvestorContext::class)->reference($territory) : null; abort_unless($location === null || $location['kind'] === 'territory', 422); $result[] = ['opportunity' => $ref, 'territory' => $location, + 'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null, + 'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces), 'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [], 'activities' => $choice['activities'] ?? ($old['activities'] ?? []), 'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '', diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 657c5b1c..5ba97cd8 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -27,6 +27,64 @@ final class InvestorContext public function __construct(private readonly VirtualDesk $desk) {} + public function draft(User $actor, string $id): ?array + { + $case = $this->desk->case($actor, $id, true); + $draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string)$actor->id] ?? null; + return $draft ? [...$draft, 'needs_review' => (int)$draft['version'] !== (int)$case->version] : null; + } + + public function saveDraft(User $actor, string $id, array $input): array + { + $case=$this->desk->case($actor,$id,true); + abort_if($case->closed_at,409); + $sequence = (int)($input['draft_sequence'] ?? 0); + $entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:100','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[]; + unset($input['draft_sequence'],$input['entry_sources']); + abort_unless($sequence > 0, 422); + $data = $this->input($input); + foreach ($data['selection'] as $slot=>$key) { + if (!$key) continue; + $ref=$this->reference($key); + abort_unless(in_array($ref['kind'],$slot==='opportunity'?['opportunity','business']:[$slot],true),422,'Referência incompatível com a escolha.'); + } + foreach ($data['opportunity_choices'] ?? [] as $choice) { + $ref = $this->reference($choice['source']); + abort_unless($ref['kind'] === 'opportunity', 422); + $record = Opportunity::findOrFail($ref['id']); + $key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record); + if (!empty($choice['territory'])) { + $location = $this->reference($choice['territory']); + abort_unless($location['kind'] === 'territory', 422); + if ($key) abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.'); + } + } + if (!empty($data['selection']['park'])) { + $first=$data['opportunity_choices'][0] ?? null; + $source=$first['source'] ?? $data['selection']['opportunity'] ?? null; + if ($source && str_starts_with($source,'opportunity:')) { + $key=$first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail(substr($source,12))); + if ($key) { + $territory=$first['territory'] ?? $data['selection']['territory'] ?? null; + $ids=app(InvestmentCorridorContext::class)->provinces($key); + $territory=$territory ? substr($territory,10) : (count($ids)===1?$ids[0]->id:null); + $park=$this->reference($data['selection']['park']); + abort_unless($park['kind']==='park' && in_array($park['id'],array_column(app(InvestmentCorridorContext::class)->parks($key,$territory),'id'),true),422,'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); + } + } + } + return DB::transaction(function () use ($actor, $id, $data, $sequence, $entrySources) { + $case = $this->desk->case($actor, $id, true, true); + abort_if($case->closed_at || (int)$case->version !== (int)$data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.'); + $payload = $this->desk->decode($case->payload); + $previous = $payload['investor_path']['context_drafts'][(string)$actor->id] ?? []; + abort_if(($previous['draft_sequence'] ?? 0) > $sequence, 409, 'Existe um rascunho mais recente. Recupere o dossier antes de continuar.'); + $payload['investor_path']['context_drafts'][(string)$actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources'=>array_values(array_unique([...($previous['entry_sources']??[]),...$entrySources]))]; + DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload)]); + return ['saved' => true]; + }); + } + public function installed(): bool { return Schema::hasTable('investor_navigation_preferences'); @@ -281,7 +339,8 @@ final class InvestorContext } return Validator::make($input, [...$inputRules, - 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities', + 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key', + 'opportunity_choices.*.corridor_key' => ['sometimes', 'nullable', Rule::in(array_keys(config('investment_corridors')))], 'opportunity_choices.*.activities' => 'sometimes|array', 'opportunity_choices.*.activities.*' => 'string|distinct|in:production,processing,trade,transport,water,forestry,animal', 'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100', 'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean', @@ -337,6 +396,16 @@ final class InvestorContext abort_unless(in_array($ref['kind'], $expected, true), 422, 'Referência incompatível com a dimensão.'); $selection[$slot] = $ref; } + $corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity' + ? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail($selection['opportunity']['id'])) : null); + if ($corridor && isset($selection['territory'])) abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.'); + if ($corridor && isset($selection['park'])) { + $eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id'); + if (!in_array($selection['park']['id'], $eligible, true)) { + abort_if(!empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.'); + unset($selection['park']); + } + } // A new opportunity may propose its explicitly stored sector; it never matches names. if (isset($data['selection']['opportunity']) && ! array_key_exists('sector', $data['selection']) && isset($selection['opportunity']['fields']['sector_id'])) { if (Sector::where('is_active', true)->whereKey($selection['opportunity']['fields']['sector_id'])->exists()) { @@ -381,6 +450,16 @@ final class InvestorContext unset($fields[$key],$provenance[$key]); } } + if ($corridor) { + foreach (['province_id','district_id','admin_post_id','locality_id','province','district','location'] as $key) { + if (isset($selection['territory']['fields'][$key])) { + $fields[$key]=(string)$selection['territory']['fields'][$key]; + $provenance[$key]=Arr::except($selection['territory'],['fields']); + } else unset($fields[$key],$provenance[$key]); + } + $fields['special_zone_id']=$selection['park']['id']??''; + $fields['investment_regime']=$selection['park']['fields']['investment_regime']??'general'; + } $replaced = []; foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) { if (($old['fields'][$key] ?? null) !== ($fields[$key] ?? null)) { @@ -469,6 +548,7 @@ final class InvestorContext } } $payload['investor_path']['context'] = $context; + unset($payload['investor_path']['context_drafts'][(string)$actor->id]); DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'title' => $context['fields']['project_title'] ?? $case->title, 'version' => $case->version + 1, 'updated_at' => now()]); $this->desk->event($this->desk->case($actor, $id), $actor, 'investment_context_changed', ['before' => $before, 'after' => $context, 'affected_result_ids' => array_column($plan['dependent_results'], 'id'), 'preserved_request_ids' => array_column($plan['requests'], 'id')]); $result = ['case_id' => $id, 'version' => (int) $case->version + 1, 'revision' => $revision, 'replayed' => false]; diff --git a/config/investment_corridors.php b/config/investment_corridors.php new file mode 100644 index 00000000..5db19b6a --- /dev/null +++ b/config/investment_corridors.php @@ -0,0 +1,13 @@ + ['name' => 'Corredor de Pemba–Lichinga', 'provinces' => ['02', '01']], + 'nacala' => ['name' => 'Corredor de Nacala', 'provinces' => ['03']], + 'vale-zambeze' => ['name' => 'Corredor do Vale do Zambeze', 'provinces' => ['05', '04']], + 'beira' => ['name' => 'Corredor da Beira', 'provinces' => ['07', '06']], + 'limpopo' => ['name' => 'Corredor do Limpopo', 'provinces' => ['09']], + // Tempo's final clarification: both Maputo Province and Maputo City. + 'maputo' => ['name' => 'Corredor de Maputo', 'provinces' => ['10', '11']], +]; diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index c737c6e1..f28634d4 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,5 +1,19 @@ # Rastreabilidade, utilização e formação +## Território adaptado ao corredor — decisão funcional da Tempo, 23/10/2026 + +Aplicada a tabela da Tempo como configuração do percurso existente (`config/investment_corridors.php`), usando os códigos e identificadores do cadastro territorial já aceite. Pemba–Lichinga: Cabo Delgado/Niassa; Nacala: Nampula; Vale do Zambeze: Tete/Zambézia; Beira: Sofala/Manica; Limpopo: Gaza; Maputo: **Maputo Província e Maputo Cidade**, conforme o esclarecimento final da Tempo. Só Nacala e Limpopo têm uma única província e a seleccionam automaticamente quando ainda não existe localização. Maputo exige escolher entre as duas unidades. Esta configuração funcional não declara novos limites geográficos nem altera os mestres, as associações ou as fichas publicadas. + +Na preparação, cada oportunidade mantém a sua localização separada. O corredor inicial vem da fonte da ficha quando disponível; o investidor pode escolher outro corredor para o seu investimento, sem alterar a associação publicada da oportunidade. Uma mudança conserva o caminho ainda compatível; se a província deixar de pertencer ao corredor, retira os níveis territoriais dependentes e explica a alteração. A província única é proposta automaticamente; um território documentado ou previamente confirmado continua protegido pela confirmação de alternativa aplicável. A localização da primeira oportunidade alimenta os campos gerais existentes; outras escolhas permanecem identificadas separadamente. + +Zonas e parques deste percurso só aparecem com território público documentado compatível, reutilizando a visibilidade, validade e vínculos existentes. Influência/proximidade não equivale a implantação. Uma indicação provincial de localização não é inventada como cobertura de todos os distritos; apenas âmbito legal documentado permite esse alargamento. Ausência de opções é distinta de erro de consulta, com nova tentativa. Ao mudar a localização, um parque que deixe de ser compatível é retirado e a razão aparece no formulário. Consulta não confirma lotes disponíveis, admissão ou benefícios; nenhum cálculo é activado. + +Recuperação: o rascunho do formulário é guardado no payload cifrado do mesmo dossier, por actor, sem confirmar contexto, aumentar a versão funcional ou activar cálculos. Recupera escolhas, actividades e dados ao regressar, actualizar ou autenticar-se novamente. A gravação automática tem indicação de progresso/sucesso/erro e nova tentativa. Pedidos atrasados não substituem rascunhos mais recentes; conflito com alteração do dossier conserva o rascunho para revisão. Retirar uma ficha não a faz reaparecer ao actualizar a mesma entrada; referências novas vindas do catálogo continuam a ser acrescentadas. A confirmação normal guarda o contexto, preserva o histórico e retira o rascunho desse actor. Não há nova tabela, migration, papel, aprovador ou alteração da autorização existente. + +Validação no servidor: recusa território fora do corredor tanto na prévia/confirmação como no rascunho; recusa parques sem compatibilidade documentada. Os filtros da API territorial aplicam a mesma configuração. A gravação protege o dossier e o actor, com controlo de concorrência, sem aceitar combinações só porque foram enviadas directamente à API. + +Provas QA: suite de cinco testes de configuração, validação, recuperação/autenticação, conflitos, zonas e mudança de corredor — **90 asserções**; quatro testes de continuidade de oportunidades também passaram na passagem completa anterior. Testes usam exclusivamente `simulator_fiscal_delta_20261021_test:55441`, com transacções revertidas. Tipos e build passaram. Navegador com conta/dossier **sintéticos privados em QA**: Beira oferece Sofala/Manica; Nacala selecciona Nampula; Maputo oferece as duas unidades; Sofala/Dondo recuperado depois de actualizar. Captura: `outputs/preservation-inventory-20261023/corridor-adaptive-territory-QA.png`. Nenhum dossier real foi usado para provas de gravação. As 34 oportunidades, Turismo de Inhambane, permissões e geometrias não foram alterados por esta implementação. + ## Continuidade entre escolha e preparação — 23/10/2026 Falha reportada pela Tempo: depois de criar o dossier para Florestas/Beira, o editor começava por uma avaliação do estado ainda não confirmado (`contexto v0`). A escolha solicitada vinha separadamente em `source`/`requestedChoices`; não era outro investimento, mas a avaliação do estado guardado vazio. A ligação do quadro voltava ao editor sem os parâmetros da escolha pendente, permitindo perder essa referência antes de confirmar. Não foi identificada nem corrigida por escrita uma troca de dados entre dossiers. diff --git a/resources/js/components/investment-opportunity-choices.tsx b/resources/js/components/investment-opportunity-choices.tsx index 5099825f..7085d1b5 100644 --- a/resources/js/components/investment-opportunity-choices.tsx +++ b/resources/js/components/investment-opportunity-choices.tsx @@ -2,24 +2,53 @@ import { Link } from '@inertiajs/react'; import { TerritoryFields } from './investment-territory'; import type { OpportunityChoice } from '@/lib/investor-context'; import { card, input } from '@/pages/investment/shared'; +import { useEffect, useState } from 'react'; +import type { TerritoryOption } from './investment-territory'; -export default function OpportunityChoices({choices, onChange, disabled = false}: {choices: OpportunityChoice[]; onChange?: (choices: OpportunityChoice[]) => void; disabled?: boolean}) { +export type CorridorOption = {key:string;name:string;provinces:TerritoryOption[]}; + +function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;i:number;corridors:CorridorOption[];disabled:boolean;update:(change:Partial)=>void;remove?:()=>void}) { + const [notice,setNotice]=useState(''); + const corridor=corridors.find(r=>r.key===c.corridor_key)||corridors.find(r=>r.name===c.coverage); + const path=c.territory_path??[]; + function location(next:TerritoryOption[], key=corridor?.key) { + update({corridor_key:key,territory_path:next,territory:next.length?{key:'territory:'+next.at(-1)!.id,kind:'territory',id:next.at(-1)!.id,title:next.map(p=>p.name).join(' / '),href:'/oportunidades',version:null,sha256:'',fields:{}}:null}); + } + useEffect(()=>{ + if (!remove || !corridor) return; + if (path.length && !corridor.provinces.some(p=>p.id===path[0].id)) { + setNotice('A localização anterior foi retirada porque não pertence ao corredor escolhido. Escolha uma localização compatível.'); + location(corridor.provinces.length===1?corridor.provinces:[]); + } else if (!path.length && corridor.provinces.length===1) {setNotice('Província seleccionada automaticamente: '+corridor.provinces[0].name+'.');location(corridor.provinces);} + },[corridor?.key,JSON.stringify(corridor?.provinces),path[0]?.id,!!remove]); + return
+

{i+1}. {c.opportunity.title}

+ {remove && } + {!remove&&c.coverage&&

Abrangência: {c.coverage}

} + {notice&&

{notice}

} +

Localização: {c.territory?.title??'Por escolher'}

+ {remove&&<> +
Actividades que pretende realizar nesta oportunidade{Object.entries({production:'Produção agrícola',processing:'Transformação agroindustrial',trade:'Comercialização e exportação',transport:'Transporte e logística',water:'Captação/utilização de água',forestry:'Actividade florestal',animal:'Produção/serviços pecuários'}).map(([key,label])=>)}
+ location(next)}/> +