feat: adapt investment territories to Tempo corridor configuration
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class InvestmentCorridorContext
|
||||
{
|
||||
public function key(Opportunity $record): ?string
|
||||
{
|
||||
$name = data_get($record->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;
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '',
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
// Functional configuration expressly defined by Tempo, 23/10/2026.
|
||||
// Codes identify the existing national territorial registry; no geometry is inferred.
|
||||
return [
|
||||
'pemba-lichinga' => ['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']],
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
@@ -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<OpportunityChoice>)=>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 <article className={card+' space-y-3'}>
|
||||
<h3 className="font-semibold"><Link className="underline" href={c.opportunity.href}>{i+1}. {c.opportunity.title}</Link></h3>
|
||||
{remove && <label className="block">Corredor para este investimento<select className={input} disabled={disabled||!corridors.length} value={corridor?.key??''} onChange={e=>{
|
||||
const next=corridors.find(r=>r.key===e.target.value);
|
||||
if(!next)return;
|
||||
const compatible=!path.length||next.provinces.some(p=>p.id===path[0].id);
|
||||
const kept=compatible?path:[];
|
||||
location(kept.length?kept:next.provinces.length===1?next.provinces:[],next.key);
|
||||
setNotice(!path.length?(next.provinces.length===1?'Província seleccionada automaticamente: '+next.provinces[0].name+'.':'Escolha uma das províncias deste corredor.'):compatible?'A localização compatível foi conservada.':'A província e os níveis territoriais incompatíveis foram retirados.');
|
||||
}}><option value="">Seleccione um corredor</option>{corridors.map(r=><option key={r.key} value={r.key}>{r.name}</option>)}</select></label>}
|
||||
{!remove&&c.coverage&&<p>Abrangência: {c.coverage}</p>}
|
||||
{notice&&<p role="status">{notice}</p>}
|
||||
<p>Localização: {c.territory?.title??'Por escolher'}</p>
|
||||
{remove&&<>
|
||||
<fieldset disabled={disabled}><legend>Actividades que pretende realizar nesta oportunidade</legend>{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])=><label key={key} className="mr-4 inline-block"><input type="checkbox" checked={c.activities?.includes(key)??false} onChange={e=>update({activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label}</label>)}</fieldset>
|
||||
<TerritoryFields initial={c.territory_path} corridor={corridor?.key} disabled={disabled} onChange={next=>location(next)}/>
|
||||
<label className="block">Motivo da alternativa territorial<textarea className={input} disabled={disabled} value={c.location_reason} onChange={e=>update({location_reason:e.target.value})}/></label>
|
||||
<label className="block"><input type="checkbox" disabled={disabled} checked={!!c.confirm_location_change} onChange={e=>update({confirm_location_change:e.target.checked})}/>Confirmo a alteração da localização anteriormente confirmada, quando existir.</label>
|
||||
<button type="button" disabled={disabled} className="underline" onClick={remove}>Retirar esta ficha da escolha</button>
|
||||
</>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
export default function OpportunityChoices({choices, onChange, disabled = false, corridors=[]}: {choices: OpportunityChoice[]; onChange?: (choices: OpportunityChoice[]) => void; disabled?: boolean;corridors?:CorridorOption[]}) {
|
||||
const update = (index: number, change: Partial<OpportunityChoice>) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c));
|
||||
return <section className="space-y-4" aria-label="Oportunidades e respectivas localizações">
|
||||
<h2 className="text-xl font-bold">Oportunidades escolhidas e localização de cada ficha</h2>
|
||||
<p>Cada ficha conserva a sua identidade e fonte. Os dados gerais e a simulação têm como referência principal a primeira ficha; não somam investimentos ou benefícios de fichas diferentes.</p>
|
||||
{choices.map((c,i) => <article key={c.opportunity.key} className={card + ' space-y-3'}>
|
||||
<h3 className="font-semibold"><Link className="underline" href={c.opportunity.href}>{i + 1}. {c.opportunity.title}</Link></h3>
|
||||
{c.coverage && <p>Abrangência: {c.coverage}</p>}
|
||||
<p>Localização: {c.territory?.title ?? 'Por escolher — não existe implantação documentada nesta ficha.'}</p>
|
||||
{c.source && !/SHA-256|\.docx/i.test(c.source) && <p>Fonte: {c.source}</p>}
|
||||
{onChange && <>
|
||||
<fieldset><legend>Actividades que pretende realizar nesta oportunidade</legend>{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])=><label key={key} className="mr-4 inline-block"><input type="checkbox" checked={c.activities?.includes(key) ?? false} onChange={e=>update(i,{activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label}</label>)}</fieldset>
|
||||
<TerritoryFields initial={c.territory_path} disabled={disabled} onChange={path => update(i,{territory_path:path,territory:path.length ? {key:'territory:'+path.at(-1)!.id,kind:'territory',id:path.at(-1)!.id,title:path.map(p=>p.name).join(' / '),href:'/oportunidades',version:null,sha256:'',fields:{}} : null})}/>
|
||||
<label className="block">Motivo da escolha ou da alternativa territorial<textarea className={input} value={c.location_reason} onChange={e=>update(i,{location_reason:e.target.value})}/></label>
|
||||
<label className="block"><input type="checkbox" checked={!!c.confirm_location_change} onChange={e=>update(i,{confirm_location_change:e.target.checked})}/>Confirmo expressamente a alteração da localização documentada ou anteriormente escolhida, quando existir.</label>
|
||||
<button type="button" className="underline" onClick={()=>onChange(choices.filter((_,index)=>index!==i))}>Retirar esta ficha da escolha</button>
|
||||
</>}
|
||||
</article>)}
|
||||
<p>Escolha o corredor e confirme onde pretende realizar o investimento.</p>
|
||||
{choices.map((c,i)=><ChoiceCard key={c.opportunity.key} c={c} i={i} corridors={corridors} disabled={disabled} update={change=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -189,11 +189,13 @@ export function TerritoryFields({
|
||||
onChange,
|
||||
endpoint = '/api/v1/investment/territory-options',
|
||||
disabled = false,
|
||||
corridor,
|
||||
}: {
|
||||
initial?: TerritoryOption[];
|
||||
onChange?: (path: TerritoryOption[]) => void;
|
||||
endpoint?: string;
|
||||
disabled?: boolean;
|
||||
corridor?: string | null;
|
||||
}) {
|
||||
const [path, setPath] = useState(initial);
|
||||
useEffect(() => setPath(initial), [initial]);
|
||||
@@ -219,6 +221,7 @@ export function TerritoryFields({
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint={endpoint}
|
||||
corridor={corridor}
|
||||
onChange={(value) => {
|
||||
const next = replaceTerritoryPath(
|
||||
path,
|
||||
@@ -252,6 +255,7 @@ export function TerritoryLevel({
|
||||
endpoint = '/management/territory-options',
|
||||
fieldError,
|
||||
disabled = false,
|
||||
corridor,
|
||||
}: {
|
||||
index: number;
|
||||
parent: string;
|
||||
@@ -260,6 +264,7 @@ export function TerritoryLevel({
|
||||
endpoint?: string;
|
||||
fieldError?: string;
|
||||
disabled?: boolean;
|
||||
corridor?: string | null;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -290,6 +295,7 @@ export function TerritoryLevel({
|
||||
page: String(page),
|
||||
q: search,
|
||||
});
|
||||
if (corridor) params.set('corridor', corridor);
|
||||
const response = await fetch(`${endpoint}?${params}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -313,7 +319,7 @@ export function TerritoryLevel({
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [index, parent, page, enabled, endpoint, retry, search]);
|
||||
}, [index, parent, page, enabled, endpoint, retry, search, corridor]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{enabled && (
|
||||
@@ -345,7 +351,7 @@ export function TerritoryLevel({
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? `${id}-error` : undefined}
|
||||
className={control}
|
||||
disabled={!enabled || loading}
|
||||
disabled={!enabled || loading || !!error}
|
||||
value={selected?.id ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(options.find((o) => o.id === e.target.value))
|
||||
|
||||
@@ -40,6 +40,9 @@ export type ContextReference = {
|
||||
};
|
||||
export type RequirementsAssessment = {version: string; fiscal_notice: string; requirements: {opportunity_key: string; opportunity_title: string; code: string; title: string; state: string; step: string; authority: string; documents: string[]; basis: string; source_url: string; decision: string}[]};
|
||||
export type OpportunityChoice = {
|
||||
corridor_key?: string | null;
|
||||
corridor_name?: string | null;
|
||||
allowed_provinces?: import('@/components/investment-territory').TerritoryOption[];
|
||||
activities?: string[];
|
||||
territory_path?: import('@/components/investment-territory').TerritoryOption[];
|
||||
confirm_location_change?: boolean;
|
||||
@@ -119,8 +122,10 @@ export async function contextApi<T>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
method = 'POST',
|
||||
keepalive = false,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
keepalive,
|
||||
method: body === undefined ? 'GET' : method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -21,7 +21,8 @@ export default function ContextStart({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [key] = useState(createOperationKey);
|
||||
const suffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
|
||||
const sourceSuffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
|
||||
const suffix = sourceSuffix ? sourceSuffix+'&selection_key='+encodeURIComponent(key) : '';
|
||||
return (
|
||||
<Frame title="Iniciar ou continuar um investimento">
|
||||
{!!opportunityChoices.length && <section className={card}><h2 className="font-bold">Oportunidades escolhidas</h2><ul>{opportunityChoices.map(c => <li key={c.opportunity.key}><Link className="underline" href={c.opportunity.href}>{c.opportunity.title}</Link> · {c.territory?.title ?? 'Localização por escolher'}</li>)}</ul><p>Confirme a localização de cada ficha na comparação do dossier.</p></section>}
|
||||
@@ -62,12 +63,12 @@ export default function ContextStart({
|
||||
<Link
|
||||
className={button}
|
||||
href={
|
||||
step && !source
|
||||
step && !source && !opportunityChoices.length
|
||||
? `/investimentos/${chosen}#${step}`
|
||||
: `/investimentos/${chosen}/contexto/editar${suffix}`
|
||||
}
|
||||
>
|
||||
{step && !source
|
||||
{step && !source && !opportunityChoices.length
|
||||
? 'Abrir ' + step + ' no mesmo dossier'
|
||||
: 'Preparar neste dossier'}
|
||||
</Link>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '@/components/private-investment-preparation';
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Frame, button, card, input } from '@/pages/investment/shared';
|
||||
import {
|
||||
TerritoryFields,
|
||||
@@ -22,6 +22,7 @@ import { createOperationKey } from '@/lib/operation-key';
|
||||
import OpportunityChoices from '@/components/investment-opportunity-choices';
|
||||
import InvestmentRequirements from '@/components/investment-requirements';
|
||||
import type { OpportunityChoice } from '@/lib/investor-context';
|
||||
import type { CorridorOption } from '@/components/investment-opportunity-choices';
|
||||
type Options = {
|
||||
opportunities: { key: string; title: string; kind: string }[];
|
||||
sectors: { id: number; name: string }[];
|
||||
@@ -35,12 +36,16 @@ export default function ContextEditor({
|
||||
source,
|
||||
territorySelection = [],
|
||||
requestedChoices = null,
|
||||
draft = null,
|
||||
entrySources = [],
|
||||
}: {
|
||||
initial: ContextView;
|
||||
options: Options;
|
||||
source: ContextReference | null;
|
||||
territorySelection?: TerritoryOption[];
|
||||
requestedChoices?: OpportunityChoice[] | null;
|
||||
entrySources?: string[];
|
||||
draft?: {needs_review?:boolean;selection?:Partial<Record<Slot,string>>;declarations?:Record<string,string>;replace_fields?:string[];document_links?:Record<string,string|null>}|null;
|
||||
}) {
|
||||
const navigation = useNavigationContext();
|
||||
const [choices, setChoices] = useState<OpportunityChoice[]>(requestedChoices ?? initial.context.opportunity_choices ?? []);
|
||||
@@ -50,24 +55,71 @@ export default function ContextEditor({
|
||||
const [options, setOptions] = useState(initialOptions);
|
||||
const [query, setQuery] = useState('');
|
||||
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(() =>
|
||||
source
|
||||
draft?.selection ?? (source
|
||||
? {
|
||||
[source.kind === 'business' ? 'opportunity' : source.kind]:
|
||||
source.key,
|
||||
}
|
||||
: {},
|
||||
: {}),
|
||||
);
|
||||
const [declarations, setDeclarations] = useState<Record<string, string>>(
|
||||
{},
|
||||
draft?.declarations ?? {},
|
||||
);
|
||||
const [documentLinks, setDocumentLinks] = useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [replace, setReplace] = useState<string[]>([]);
|
||||
>(draft?.document_links ?? {});
|
||||
const [replace, setReplace] = useState<string[]>(draft?.replace_fields ?? []);
|
||||
const [plan, setPlan] = useState<ContextPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [corridors,setCorridors]=useState<CorridorOption[]>([]);
|
||||
const [parks,setParks]=useState<Options['parks']>([]);
|
||||
const [corridorLoading,setCorridorLoading]=useState(true);
|
||||
const [corridorError,setCorridorError]=useState('');
|
||||
const [corridorRetry,setCorridorRetry]=useState(0);
|
||||
const [draftStatus,setDraftStatus]=useState('');
|
||||
const [draftRetry,setDraftRetry]=useState(0);
|
||||
const queue=useRef<Promise<unknown>>(Promise.resolve());
|
||||
const firstCorridor=choices[0]?.corridor_key ?? corridors.find(c=>c.name===choices[0]?.coverage)?.key;
|
||||
const firstTerritory=choices[0]?.territory?.id;
|
||||
useEffect(()=>{
|
||||
let active=true;setCorridorLoading(true);setCorridorError('');
|
||||
const params=new URLSearchParams();if(firstCorridor)params.set('corridor',firstCorridor);if(firstTerritory)params.set('territory',firstTerritory);
|
||||
void contextApi<{corridors:CorridorOption[];parks:Options['parks']}>('/investimentos/opcoes-corredor?'+params).then(data=>{
|
||||
if(!active)return;setCorridors(data.corridors);setParks(data.parks);
|
||||
const park=Object.hasOwn(changes,'park')?changes.park:view.context.selection.park?.key;
|
||||
if(firstCorridor&&park&&!data.parks.some(p=>'park:'+p.id===park)){
|
||||
setChanges(old=>({...old,park:''}));setPlan(null);setMessage('A zona ou parque anterior foi retirado porque não tem enquadramento compatível com a localização escolhida.');
|
||||
}
|
||||
}).catch(e=>{if(active)setCorridorError(e.message)}).finally(()=>{if(active)setCorridorLoading(false)});
|
||||
return()=>{active=false};
|
||||
},[firstCorridor,firstTerritory,corridorRetry]);
|
||||
const draftBody=JSON.stringify({entry_sources:entrySources,version:view.case.version,selection:changes,declarations,replace_fields:replace,document_links:documentLinks,
|
||||
...(manageChoices?{opportunity_choices:choices.map(c=>({source:c.opportunity.key,corridor_key:c.corridor_key,territory:c.territory?.key??null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))}:{})});
|
||||
const latestDraft=useRef(draftBody);latestDraft.current=draftBody;
|
||||
const skipNextDraft=useRef(false);
|
||||
const confirmedDraftBody=useRef<string|null>(null);
|
||||
const sequence=useRef({body:'',value:Date.now()});
|
||||
if(sequence.current.body!==draftBody)sequence.current={body:draftBody,value:Math.max(Date.now(),sequence.current.value+1)};
|
||||
useEffect(()=>{
|
||||
if(busy||corridorLoading||corridorError)return;
|
||||
if(skipNextDraft.current){skipNextDraft.current=false;confirmedDraftBody.current=draftBody;setDraftStatus('Dados confirmados.');return;}
|
||||
if(draftBody===confirmedDraftBody.current)return;
|
||||
setDraftStatus('A guardar rascunho…');
|
||||
const timer=setTimeout(()=>{
|
||||
const savedSequence=sequence.current.value;
|
||||
queue.current=queue.current.catch(()=>{}).then(()=>contextApi('/investimentos/'+view.case.id+'/contexto/rascunho',{...JSON.parse(draftBody),draft_sequence:savedSequence},'POST',draftBody.length<60000))
|
||||
.then(()=>{if(latestDraft.current===draftBody)setDraftStatus('Rascunho guardado.');})
|
||||
.catch(e=>{if(latestDraft.current===draftBody)setDraftStatus('Rascunho não guardado: '+e.message)});
|
||||
},400);
|
||||
return()=>clearTimeout(timer);
|
||||
},[draftBody,busy,corridorLoading,corridorError,draftRetry]);
|
||||
useEffect(()=>{
|
||||
const flush=()=>{const body=latestDraft.current;if(body!==confirmedDraftBody.current&&body.length<60000)void contextApi('/investimentos/'+initial.case.id+'/contexto/rascunho',{...JSON.parse(body),draft_sequence:sequence.current.value},'POST',true).catch(()=>{});};
|
||||
window.addEventListener('pagehide',flush);
|
||||
return()=>{window.removeEventListener('pagehide',flush);flush();};
|
||||
},[initial.case.id]);
|
||||
const [operation, setOperation] = useState(createOperationKey);
|
||||
const [requestBody, setRequestBody] = useState<Record<
|
||||
string,
|
||||
@@ -113,7 +165,7 @@ export default function ContextEditor({
|
||||
setError('');
|
||||
setMessage('');
|
||||
const body = {
|
||||
...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
|
||||
...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,corridor_key:c.corridor_key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
|
||||
version: view.case.version,
|
||||
selection: changes,
|
||||
declarations,
|
||||
@@ -141,6 +193,7 @@ export default function ContextEditor({
|
||||
async function confirm() {
|
||||
if (!plan || !requestBody) return;
|
||||
setBusy(true);
|
||||
await queue.current.catch(()=>{});
|
||||
setError('');
|
||||
try {
|
||||
await contextApi(
|
||||
@@ -155,6 +208,7 @@ export default function ContextEditor({
|
||||
const next = await contextApi<ContextView>(
|
||||
`/investimentos/${view.case.id}/contexto`,
|
||||
);
|
||||
skipNextDraft.current=true;
|
||||
setView(next);
|
||||
setChoices(next.context.opportunity_choices ?? []);
|
||||
setPreparation(next.private_investment);
|
||||
@@ -208,7 +262,12 @@ export default function ContextEditor({
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
<section className={card + ' space-y-4'}>
|
||||
<OpportunityChoices choices={choices} disabled={busy} onChange={next=>{setChoices(next);setManageChoices(true);setPlan(null);}}/>
|
||||
{corridorError&&<p role="alert">{corridorError} <button className="underline" onClick={()=>setCorridorRetry(v=>v+1)}>Tentar novamente</button></p>}
|
||||
{corridorLoading&&<p role="status">A carregar opções do corredor…</p>}
|
||||
<p role="status">{draftStatus}</p>
|
||||
{draft?.needs_review&&<p role="status">O rascunho foi recuperado, mas o dossier recebeu outras alterações. Reveja os dados antes de confirmar.</p>}
|
||||
{draftStatus.startsWith('Rascunho não guardado')&&<button type="button" className="underline" onClick={()=>setDraftRetry(v=>v+1)}>Tentar guardar novamente</button>}
|
||||
<OpportunityChoices corridors={corridors} choices={choices} disabled={busy||corridorLoading||!!corridorError} onChange={next=>{setChoices(next);setManageChoices(true);setPlan(null);}}/>
|
||||
<ContextDocuments source={source ?? view.context.selection.opportunity} />
|
||||
{view.context.revision > 0 && !Object.keys(changes).length && JSON.stringify(choices) === JSON.stringify(view.context.opportunity_choices ?? []) && <InvestmentRequirements assessment={view.context.requirements_assessment} contextId={view.case.id}/>}
|
||||
<label className="block">Acrescentar uma oportunidade à escolha<select className={input} value="" onChange={e=>{const o=options.opportunities.find(o=>o.key===e.target.value);if(o && !choices.some(c=>c.opportunity.key===o.key)){setChoices([...choices,{opportunity:{...o,id:o.key.split(':')[1],href:'/oportunidades',version:null,sha256:'',fields:{}},territory:null,location_reason:'',location_state:'undetermined'}]);setManageChoices(true);setPlan(null);}}}><option value="">Seleccionar ficha</option>{options.opportunities.filter(o=>o.kind==='opportunity'&&!choices.some(c=>c.opportunity.key===o.key)).map(o=><option key={o.key} value={o.key}>{o.title}</option>)}</select></label>
|
||||
@@ -333,24 +392,25 @@ export default function ContextEditor({
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid min-w-0 gap-1">
|
||||
ZEE, ZFI ou parque consultado
|
||||
{choices.length>1?'Zona ou parque da primeira oportunidade':'ZEE, ZFI ou parque consultado'}
|
||||
<select
|
||||
className={input + ' max-w-full min-w-0'}
|
||||
disabled={busy}
|
||||
disabled={busy||corridorLoading||!!corridorError||(!!firstCorridor&&!firstTerritory)}
|
||||
value={selected('park')}
|
||||
onChange={(e) => change('park', e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Sem ZEE, ZFI ou parque seleccionado
|
||||
</option>
|
||||
{options.parks.map((p) => (
|
||||
{(firstCorridor?parks:options.parks).map((p) => (
|
||||
<option key={p.id} value={'park:' + p.id}>
|
||||
{p.name} · v{p.version_no}
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<h3 className="font-semibold">
|
||||
{firstCorridor&&!corridorLoading&&!corridorError&&<p>{!firstTerritory?'Escolha a localização para consultar zonas e parques.':!parks.length?'Não existem zonas ou parques com enquadramento documentado compatível nesta localização.':'A consulta não confirma disponibilidade de lotes, admissão ou incentivos.'}</p>}
|
||||
{!manageChoices&&<><h3 className="font-semibold">
|
||||
Localização — escolher até ao nível conhecido
|
||||
</h3>
|
||||
<TerritoryFields
|
||||
@@ -372,7 +432,7 @@ export default function ContextEditor({
|
||||
{view.context.selection.territory.title}
|
||||
</p>
|
||||
)}
|
||||
{manageChoices && <p>Confirme a localização em cada ficha acima; a localização geral não substitui as escolhas individuais.</p>}
|
||||
</>}
|
||||
</section>
|
||||
<section className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
|
||||
@@ -13,8 +13,10 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
|
||||
Route::get('/contexto-atual', [InvestorContextController::class, 'current']);
|
||||
Route::put('/contexto-atual', [InvestorContextController::class, 'focus']);
|
||||
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
|
||||
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
|
||||
Route::get('/{id}/contexto', [InvestorContextController::class, 'show'])->whereUuid('id');
|
||||
Route::get('/{id}/contexto/editar', [InvestorContextController::class, 'edit'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/rascunho', [InvestorContextController::class, 'saveDraft'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/previa', [InvestorContextController::class, 'preview'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/confirmar', [InvestorContextController::class, 'commit'])->whereUuid('id');
|
||||
Route::get('/a-partir-do-parque/{zoneId}', [Path::class, 'zoneStart'])->whereUuid('zoneId');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\CorridorAgribusinessOpportunities;
|
||||
use App\Services\InvestmentCorridorContext;
|
||||
use App\Services\InvestmentOpportunityChoices;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvestmentCorridorContextTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
private User $investor;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->withoutVite();
|
||||
$t=DB::selectOne('select current_database() as name,inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test',$t->name);$this->assertSame(55441,(int)$t->port);
|
||||
config(['investor-path.enabled'=>true,'investor-path.private_preparation'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
|
||||
$this->investor=User::factory()->create();$this->actingAs($this->investor);
|
||||
}
|
||||
private function record(string $corridor): Opportunity
|
||||
{
|
||||
$entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
|
||||
return Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
|
||||
}
|
||||
public function test_tempo_configuration_uses_existing_ids_and_maputo_includes_both_units(): void
|
||||
{
|
||||
foreach(config('investment_corridors') as $key=>$entry){
|
||||
$provinces=app(InvestmentCorridorContext::class)->provinces($key);
|
||||
$this->assertEqualsCanonicalizing($entry['provinces'],array_column($provinces,'source_code'));
|
||||
$response=$this->getJson('/api/v1/investment/territory-options?type=province&corridor='.$key)->assertOk();
|
||||
$this->assertEqualsCanonicalizing(array_column($provinces,'id'),array_column($response->json('data'),'id'));
|
||||
}
|
||||
$this->assertEqualsCanonicalizing(['Maputo Província','Maputo Cidade'],array_column(app(InvestmentCorridorContext::class)->provinces('maputo'),'name'));
|
||||
foreach(['nacala','limpopo'] as $key){
|
||||
$choice=app(InvestmentOpportunityChoices::class)->resolve([['source'=>'opportunity:'.$this->record($key)->id]])[0];
|
||||
$this->assertSame(app(InvestmentCorridorContext::class)->provinces($key)[0]->id,$choice['territory']['id']);
|
||||
}
|
||||
}
|
||||
public function test_server_refuses_incompatible_location_in_draft_and_confirmation_preview(): void
|
||||
{
|
||||
$id=$this->postJson('/investimentos',['title'=>'QA corridor guard','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
|
||||
$version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
|
||||
$gaza=app(InvestmentCorridorContext::class)->provinces('limpopo')[0]->id;
|
||||
$body=['version'=>$version,'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$this->record('beira')->id,'corridor_key'=>'beira','territory'=>'territory:'.$gaza]]];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
|
||||
$this->getJson('/api/v1/investment/territory-options?type=district&corridor=beira&parent_id='.$gaza)->assertUnprocessable();
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.revision',0);
|
||||
}
|
||||
public function test_encrypted_draft_survives_refresh_and_reauthentication_without_committing_context(): void
|
||||
{
|
||||
$id=$this->postJson('/investimentos',['title'=>'QA recovery','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
|
||||
$before=$this->getJson('/investimentos/'.$id.'/contexto')->json();
|
||||
$record=$this->record('beira');$province=app(InvestmentCorridorContext::class)->provinces('beira')[0]->id;
|
||||
$body=['version'=>$before['case']['version'],'selection'=>[],'declarations'=>['currency'=>'MZN'],'opportunity_choices'=>[['source'=>'opportunity:'.$record->id,'corridor_key'=>'beira','territory'=>'territory:'.$province]]];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>10])->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>9])->assertConflict();
|
||||
$encrypted=DB::table('desk_cases')->where('id',$id)->value('payload');$this->assertStringNotContainsString($province,$encrypted);
|
||||
$this->flushSession();$this->actingAs($this->investor);
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page
|
||||
->where('draft.declarations.currency','MZN')->where('requestedChoices.0.territory.id',$province)->where('requestedChoices.0.corridor_key','beira'));
|
||||
$after=$this->getJson('/investimentos/'.$id.'/contexto')->json();
|
||||
$this->assertSame($before['context'],$after['context']);$this->assertSame($before['case']['version'],$after['case']['version']);
|
||||
$empty=[...$body,'opportunity_choices'=>[],'entry_sources'=>['opportunity:'.$record->id],'draft_sequence'=>11];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho',$empty)->assertOk();
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$record->id)->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page->has('requestedChoices',0));
|
||||
$new=$this->record('nacala');
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$new->id)->assertOk()->assertInertia(fn(\Inertia\Testing\AssertableInertia $page)=>$page->where('requestedChoices.0.opportunity.key','opportunity:'.$new->id));
|
||||
$this->actingAs(User::factory()->create());$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>12])->assertForbidden();
|
||||
}
|
||||
public function test_zones_need_compatible_documented_territory_and_catalogue_presence_alone_is_insufficient(): void
|
||||
{
|
||||
$service=app(InvestmentCorridorContext::class);
|
||||
$sofala=collect($service->provinces('beira'))->firstWhere('source_code','07')->id;
|
||||
$gaza=$service->provinces('limpopo')[0]->id;
|
||||
$base=app(\App\Services\InvestmentZones::class)->visible()->whereIn('category',['industrial_park','zee','zfi'])->firstOrFail();
|
||||
$clones=[];
|
||||
foreach([$sofala,$gaza,null] as $territory){$zone=$base->replicate();$zone->code='QA-'.Str::uuid();$zone->name='Synthetic corridor zone';$zone->primary_territory_id=$territory;$zone->save();$clones[]=$zone;}
|
||||
$available=array_column($service->parks('beira',$sofala),'id');
|
||||
$this->assertContains($clones[0]->id,$available);$this->assertNotContains($clones[1]->id,$available);$this->assertNotContains($clones[2]->id,$available);
|
||||
$id=$this->postJson('/investimentos',['title'=>'QA zone guard','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
|
||||
$version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
|
||||
$body=['version'=>$version,'selection'=>['park'=>'park:'.$clones[1]->id],'opportunity_choices'=>[['source'=>'opportunity:'.$this->record('beira')->id,'corridor_key'=>'beira','territory'=>'territory:'.$sofala]]];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
|
||||
$this->getJson('/investimentos/opcoes-corredor?corridor=beira&territory='.$sofala)->assertOk()->assertJsonFragment(['id'=>$clones[0]->id]);
|
||||
}
|
||||
public function test_corridor_change_keeps_compatible_paths_and_removes_only_incompatible_geography(): void
|
||||
{
|
||||
$service=app(InvestmentCorridorContext::class);$source='opportunity:'.$this->record('beira')->id;
|
||||
$sofala=collect($service->provinces('beira'))->firstWhere('source_code','07')->id;
|
||||
$district=app(\App\Services\InvestmentGeography::class)->publicReferences()->where('parent_id',$sofala)->where('location_type','district')->firstOrFail();
|
||||
$resolver=app(InvestmentOpportunityChoices::class);
|
||||
$old=$resolver->resolve([['source'=>$source,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]]);
|
||||
$same=$resolver->resolve([['source'=>$source,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]],$old);
|
||||
$this->assertSame($old,$same);
|
||||
$changed=$resolver->resolve([['source'=>$source,'corridor_key'=>'limpopo']],$old);
|
||||
$this->assertSame($service->provinces('limpopo')[0]->id,$changed[0]['territory']['id']);
|
||||
$this->assertCount(1,$changed[0]['territory_path']);
|
||||
$this->assertNull($this->record('beira')->primary_territory_id);
|
||||
$this->getJson('/api/v1/investment/territory-options?type=locality')->assertOk()->assertJsonPath('data',[]);
|
||||
config(['investment_corridors.nacala.provinces'=>['03','99']]);
|
||||
$this->getJson('/api/v1/investment/territory-options?type=province&corridor=nacala')->assertStatus(503);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ final class InvestmentOpportunityChoicesTest extends TestCase
|
||||
{
|
||||
$entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2);
|
||||
$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get();
|
||||
$sites = DB::table('geo_locations')->where('location_type', 'province')->where('data_classification', 'public')->whereNull('deleted_at')->orderBy('id')->limit(2)->pluck('id');
|
||||
$sites = collect(app(\App\Services\InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
|
||||
$this->get('/investimentos/iniciar?opportunities[]='.$records[0]->id.'&opportunities[]='.$records[1]->id)->assertOk();
|
||||
$id = $this->dossier();
|
||||
$before = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
@@ -98,7 +98,7 @@ final class InvestmentOpportunityChoicesTest extends TestCase
|
||||
public function test_documented_location_cannot_change_silently_and_stale_source_cannot_be_confirmed(): void
|
||||
{
|
||||
$record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
|
||||
$sites = DB::table('geo_locations')->where('location_type', 'province')->where('data_classification', 'public')->whereNull('deleted_at')->orderBy('id')->limit(2)->pluck('id');
|
||||
$sites = collect(app(\App\Services\InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
|
||||
$record->primary_territory_id = $sites[0]; $record->save();
|
||||
$id = $this->dossier(); $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
|
||||
$input = ['version' => $version, 'selection' => [], 'opportunity_choices' => [['source' => 'opportunity:'.$record->id, 'territory' => 'territory:'.$sites[1]]]];
|
||||
|
||||
Reference in New Issue
Block a user