feat: explore the public opportunity catalogue by corridor
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-23 12:08:33 +02:00
parent 165c2f6171
commit 203492997e
9 changed files with 201 additions and 7 deletions
@@ -26,6 +26,7 @@ class InvestmentDiscoveryController extends Controller
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
+1 -1
View File
@@ -26,7 +26,7 @@ class InvestmentCatalogue
'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'],
'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'],
'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'],
'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined'],
'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined,corridors'],
'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])],
@@ -0,0 +1,55 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
/** Read-only grouping of the existing public catalogue. */
final class OpportunityCorridorExplorer
{
public function groups(array $filters): array
{
$catalogue = app(InvestmentCatalogue::class);
$groups = [];
foreach ($catalogue->query($filters)->get() as $record) {
$context = $catalogue->context($record->id);
$classification = $context['classification'] ?? [];
$corridors = collect($context['corridors'])->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all();
foreach ($classification as $relation) {
if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) {
$corridors['corridor:'.$relation['target_id']] = $relation['label'];
}
}
// The approved source names are not aliases for similarly named master records.
$source = data_get($record->workflow_context, 'corridor_agribusiness');
$chain = null;
if (is_array($source) && ($source['source_sha256'] ?? null) === CorridorAgribusinessOpportunities::SOURCE_HASH) {
$corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor'];
$chain = $source['chain'];
}
$chains = collect($classification)->where('target_type', 'chain')->pluck('label')->all();
if ($chain) $chains[] = $chain;
$stages = collect($classification)->pluck('production_stage')->filter()->unique()->values()->all();
// Keep the source's actual activity description rather than inventing stage labels.
if ($stages === [] && $chain && ! empty($source['summary'])) $stages = [$source['summary']];
$path = collect($context['primary_territory_path'] ?? []);
$province = $path->firstWhere('location_type', 'province')->name ?? $record->province;
$district = $path->firstWhere('location_type', 'district')->name ?? $record->district;
$item = ['id' => $record->id, 'title' => $record->title, 'slug' => $record->slug, 'summary' => $record->summary,
'province' => $province, 'district' => $district, 'chains' => array_values(array_unique($chains)),
'stages' => $stages, 'maturity_label' => $record->maturity_label];
foreach ($corridors ?: ['other' => 'Outras localizações'] as $key => $name) {
$groups[$key] ??= ['id' => $key, 'name' => $name, 'opportunities' => []];
$groups[$key]['opportunities'][$record->id] = $item;
}
}
foreach ($groups as &$group) {
$group['opportunities'] = array_values($group['opportunities']);
$group['count'] = count($group['opportunities']);
$group['locations'] = array_values(array_unique(array_filter(array_map(fn ($r) => implode(' / ', array_filter([$r['province'], $r['district']])), $group['opportunities']))));
}
unset($group);
uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name']));
return array_values($groups);
}
}
@@ -1,5 +1,17 @@
# Rastreabilidade, utilização e formação
## Explorar oportunidades por corredor — 23/10/2026
Decisão expressa da Tempo: acrescentar o corredor como forma de explorar o catálogo, preservando vistas por sector, território e cadeia de valor, fichas únicas e percurso existente. Nova vista `/oportunidades?sector=agronegocio&view=corridors`, com acesso destacado no início de Agronegócio. Não há migration, cadastro duplicado ou alteração de publicações, associações, permissões, Turismo de Inhambane ou geometrias.
Cada grupo apresenta nome, número de oportunidades publicadas, localizações identificadas, cadeias e etapas/actividades. O lote usa as seis designações exactas da fonte aceite; não as funde com mestres semelhantes. Outras fichas usam as relações públicas já disponibilizadas pelo catálogo; proximidade não é tratada como pertença. Sem associação pública identificada, a ficha aparece em **Outras localizações**. Uma ficha em vários grupos mantém o mesmo ID e a selecção é única. Contagens de grupos podem sobrepor-se; não significam cadastros distintos.
Dentro do grupo, filtrar província, distrito e cadeia de valor; filtros mantêm escolhas anteriores, inclusive de outros grupos. **Comparar as escolhidas** reutiliza a comparação existente de até quatro fichas; essa capacidade não limita a escolha para preparar investimento. **Preparar investimento com as oportunidades escolhidas** envia todos os IDs ao mesmo dossier, onde se confirma a localização por ficha e se reutilizam os dados em licenciamento, terra e simulação. Filtrar a pesquisa não altera a localização da ficha nem escolhe silenciosamente uma implantação.
Lacunas reais: as 34 fichas não têm província/distrito de implantação identificados; a vista indica abrangência por indicar e não transforma nomes de corredores em limites territoriais. As actividades produtivas são apresentadas com o texto da fonte, sem inventar uma classificação de etapa. Etapas adicionais vêm apenas das relações públicas existentes. Os originais e a rastreabilidade permanecem internos.
Delta conferido: dois testes, 26 asserções (seis grupos e 34 IDs sem truncar à primeira página; relação múltipla com um ID; grupo residual; localizações conhecidas sem inventar faltantes). Build e tipos verificados. Navegador QA: escolha de duas fichas de Beira e Maputo, filtro Soja mantendo as escolhas e comparação das duas identidades. Reutilizadas as provas aceites da confirmação territorial e continuidade do dossier.
## Apresentação sem notas internas — 23/10/2026
Correcção solicitada pela Tempo: a interface deve comunicar a tarefa e o conteúdo, sem narrar decisões de implementação. Simplificados o cabeçalho da gestão do lote, a entrada no roteiro e as escolhas de oportunidades. Na ficha pública, removido o alerta genérico de falta de data de validação; mantém-se o estado **Em preparação** e a indicação de que a execução depende das condições e autorizações aplicáveis.
@@ -0,0 +1,51 @@
import { Link } from '@inertiajs/react';
import { useState } from 'react';
import { button, card, input } from '@/pages/investment/shared';
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;chains:string[];stages:string[];maturity_label:string|null};
export type CorridorGroup = {id:string;name:string;count:number;locations:string[];opportunities:Item[]};
type Props = {groups:CorridorGroup[];selected:number[];onSelect:(id:number,checked:boolean)=>void;onCompare:(ids:number[])=>void;onClear:()=>void};
function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'selected'|'onSelect'>) {
const [province,setProvince]=useState('');
const [district,setDistrict]=useState('');
const [chain,setChain]=useState('');
const provinces=[...new Set(group.opportunities.map(r=>r.province).filter((s):s is string=>!!s))].sort();
const districts=[...new Set(group.opportunities.filter(r=>!province||r.province===province).map(r=>r.district).filter((s):s is string=>!!s))].sort();
const chains=[...new Set(group.opportunities.flatMap(r=>r.chains))].sort();
const rows=group.opportunities.filter(r=>(!province||r.province===province)&&(!district||r.district===district)&&(!chain||r.chains.includes(chain)));
return <section className={card+' space-y-4'} aria-label={group.name}>
<header><h2 className="text-2xl font-bold">{group.name}</h2><p>{group.count} oportunidades publicadas</p>
<p>{group.locations.length ? 'Localizações identificadas: '+group.locations.join(' · ') : 'Abrangência territorial por indicar.'}</p></header>
<div className="grid gap-3 md:grid-cols-3">
<label>Província<select className={input} value={province} onChange={e=>{setProvince(e.target.value);setDistrict('');}}><option value="">Todas as províncias</option>{provinces.map(p=><option key={p}>{p}</option>)}</select></label>
<label>Distrito<select className={input} value={district} onChange={e=>setDistrict(e.target.value)}><option value="">Todos os distritos</option>{districts.map(d=><option key={d}>{d}</option>)}</select></label>
<label>Cadeia de valor<select className={input} value={chain} onChange={e=>setChain(e.target.value)}><option value="">Todas as cadeias</option>{chains.map(c=><option key={c}>{c}</option>)}</select></label>
</div>
<p role="status">{rows.length} oportunidades com estes filtros</p>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map(r=><article key={r.id} className="space-y-3 rounded-lg border p-4">
<h3 className="text-lg font-semibold"><Link href={'/oportunidades/'+r.slug}>{r.title}</Link></h3>
<p>{[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}</p>
<p>Cadeia de valor: {r.chains.join(' · ')||'Por indicar'}</p>
{r.summary && !r.stages.includes(r.summary) && <p>{r.summary}</p>}
<p>Etapas e actividades: {r.stages.join(' · ')||'Por indicar'}</p><p>{r.maturity_label}</p>
<label className="flex gap-2"><input type="checkbox" checked={selected.includes(r.id)} onChange={e=>onSelect(r.id,e.target.checked)}/>Escolher para preparar investimento</label>
<Link className="underline" href={'/oportunidades/'+r.slug}>Ver ficha</Link>
</article>)}</div>
</section>;
}
export default function OpportunityCorridorGroups({groups,selected,onSelect,onCompare,onClear}:Props) {
return <div className="space-y-6">
<section className={card+' space-y-3'} aria-label="Escolhas entre corredores">
<h2 className="text-xl font-bold">Explore por corredor e escolha onde investir</h2>
<p>Pode escolher oportunidades de vários corredores e confirmar a localização de cada uma durante a preparação.</p>
<p>{selected.length} oportunidades escolhidas</p>
{!!selected.length&&<div className="flex flex-wrap gap-4"><Link className={button} href={'/investimentos/iniciar?'+selected.map(id=>'opportunities[]='+id).join('&')}>Preparar investimento com as oportunidades escolhidas</Link>
<button className={button} disabled={selected.length>4} onClick={()=>onCompare(selected)}>Comparar as escolhidas</button>
<button className="underline" onClick={onClear}>Limpar escolha</button>
{selected.length>4&&<p>A comparação apresenta até quatro fichas de cada vez. Pode preparar o investimento com todas as escolhidas.</p>}</div>}
</section>
{groups.map(group=><Group key={group.id} group={group} selected={selected} onSelect={onSelect}/>)}
</div>;
}
+14 -6
View File
@@ -1,4 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import OpportunityCorridorGroups, { type CorridorGroup } from '@/components/opportunity-corridor-groups';
import { searchTerritoryHref } from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
@@ -25,8 +26,10 @@ export default function Catalogue({
territorial,
territorySelection,
catalogueUrl,
corridorGroups = [],
}: {
guides?: EducationalArticle[];
corridorGroups?: CorridorGroup[];
filters: Record<string, string | string[] | boolean>;
references: References;
results: {
@@ -123,6 +126,10 @@ export default function Catalogue({
const [saved, setSaved] = useState(favourites);
const [selected, setSelected] = useState<number[]>([]);
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
const comparisonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (view === 'corridors' && comparison.length) comparisonRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [comparison, view]);
const [points, setPoints] = useState<Feature[] | null>(null);
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
const [unlocated, setUnlocated] = useState<
@@ -418,7 +425,7 @@ export default function Catalogue({
Alargar pesquisa (remover localização)
</button>
)}
{['grid', 'list', 'map', 'combined'].map((value, i) => (
{['grid', 'list', 'map', 'combined', 'corridors'].map((value, i) => (
<button
key={value}
disabled={searchLoading || !!searchError}
@@ -434,7 +441,7 @@ export default function Catalogue({
);
}}
>
{['Grelha', 'Lista', 'Mapa', 'Lista e mapa'][i]}
{['Grelha', 'Lista', 'Mapa', 'Lista e mapa', 'Por corredor'][i]}
</button>
))}
<a
@@ -568,7 +575,8 @@ export default function Catalogue({
</ul>
</section>
)}
{!searchLoading &&
{view === 'corridors' && !searchLoading && !searchError && <OpportunityCorridorGroups groups={corridorGroups} selected={investmentSelection} onSelect={selectInvestment} onClear={()=>{setInvestmentSelection([]);sessionStorage.removeItem('investment-opportunity-selection');}} onCompare={ids=>void api('/api/v1/investment/compare?'+ids.map(id=>'ids[]='+id).join('&')).then(data=>setComparison(data.records)).catch(e=>setError(e.message))}/>}
{view !== 'corridors' && !searchLoading &&
!searchError &&
(view !== 'map' || error || points?.length === 0) && (
<div
@@ -647,7 +655,7 @@ export default function Catalogue({
.
</p>
)}
<nav aria-label="Paginação" className="flex gap-6">
<nav aria-label="Paginação" className={view === 'corridors' ? 'hidden' : 'flex gap-6'}>
{results.prev_page_url && (
<Link href={results.prev_page_url}>Anterior</Link>
)}
@@ -670,9 +678,9 @@ export default function Catalogue({
Comparar {selected.length} oportunidades
</button>
)}
{!!investmentSelection.length && <section className={card}><p>{investmentSelection.length} oportunidades escolhidas, conservadas ao mudar de página.</p><Link className={button} href={'/investimentos/iniciar?' + investmentSelection.map(id => 'opportunities[]='+id).join('&')}>Preparar investimento com as oportunidades escolhidas</Link><button className="ml-4 underline" onClick={() => {setInvestmentSelection([]);sessionStorage.removeItem('investment-opportunity-selection');}}>Limpar escolha</button></section>}
{view !== 'corridors' && !!investmentSelection.length && <section className={card}><p>{investmentSelection.length} oportunidades escolhidas, conservadas ao mudar de página.</p><Link className={button} href={'/investimentos/iniciar?' + investmentSelection.map(id => 'opportunities[]='+id).join('&')}>Preparar investimento com as oportunidades escolhidas</Link><button className="ml-4 underline" onClick={() => {setInvestmentSelection([]);sessionStorage.removeItem('investment-opportunity-selection');}}>Limpar escolha</button></section>}
{comparison.length > 0 && (
<div className="overflow-x-auto">
<div ref={comparisonRef} className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<caption className="p-3 text-left font-bold">
Comparação — montantes nas moedas de origem
+1
View File
@@ -105,6 +105,7 @@ export default function LegacySector({
? article.group.heading
: article.title}
</h1>
{article.sector?.slug === 'agronegocio' && <Link className="inline-flex rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white" href="/oportunidades?sector=agronegocio&view=corridors">Explorar oportunidades por corredor</Link>}
<p>
Explore o setor e prepare a sua ideia com o contexto
guardado no dossier.
@@ -0,0 +1,30 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class OpportunityCorridorExplorerTest extends TestCase
{
public function test_the_public_agribusiness_catalogue_groups_all_34_records_without_page_truncation(): void
{
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
$this->get('/oportunidades?sector=agronegocio&view=corridors')->assertOk()->assertInertia(fn (Assert $page) => $page
->component('investment/catalogue')
->has('corridorGroups', 6)
->where('results.total', 34)
->where('corridorGroups', function ($groups) {
$groups = collect($groups);
$ids = $groups->flatMap(fn ($g) => collect($g['opportunities'])->pluck('id'));
return $ids->count() === 34 && $ids->unique()->count() === 34
&& $groups->pluck('count', 'name')->get('Corredor da Beira') === 7
&& $groups->pluck('count', 'name')->get('Corredor de Maputo') === 5
&& ! str_contains(json_encode($groups), 'source_sha256');
}));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Unit;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityCorridorExplorer;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Mockery;
use Tests\TestCase;
class OpportunityCorridorExplorerTest extends TestCase
{
public function test_multiple_groups_keep_one_identity_and_unassociated_records_remain_accessible(): void
{
$first = new Opportunity(['title' => 'Primeira', 'province' => 'Sofala', 'district' => 'Dondo']);
$first->id = 101;
$other = new Opportunity(['title' => 'Outra']); $other->id = 102;
$query = Mockery::mock(Builder::class);
$query->shouldReceive('get')->once()->andReturn(new Collection([$first, $other]));
$catalogue = Mockery::mock(InvestmentCatalogue::class);
$filters = ['sector' => 'agronegocio', 'view' => 'corridors'];
$catalogue->shouldReceive('query')->with($filters)->once()->andReturn($query);
$catalogue->shouldReceive('context')->with(101)->once()->andReturn(['corridors' => collect([(object)['id'=>'a','name'=>'Corredor A'],(object)['id'=>'b','name'=>'Corredor B']])]);
$catalogue->shouldReceive('context')->with(102)->once()->andReturn(['corridors'=>collect()]);
$this->app->instance(InvestmentCatalogue::class, $catalogue);
$groups = app(OpportunityCorridorExplorer::class)->groups($filters);
$this->assertSame(['Corredor A','Corredor B','Outras localizações'], array_column($groups,'name'));
$this->assertSame([101,101,102], array_map(fn($g)=>$g['opportunities'][0]['id'], $groups));
$this->assertSame([1,1,1], array_column($groups,'count'));
$this->assertSame(['Sofala / Dondo'], $groups[0]['locations']);
$this->assertSame([], $groups[2]['locations']);
$this->assertSame([], $groups[0]['opportunities'][0]['stages']);
}
}