This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user