fix(catalogue): include approved provincial corridor coverage and show territorial detail

This commit is contained in:
2026-10-28 20:49:30 +02:00
parent d7a9284d5f
commit 1b2ede41e2
13 changed files with 232 additions and 14 deletions
@@ -29,7 +29,7 @@ class InvestmentDiscoveryController extends Controller
'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(),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
}
@@ -74,7 +74,7 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)));
}
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
+41 -3
View File
@@ -71,7 +71,9 @@ class InvestmentCatalogue
} elseif (($filters['territory_basis'] ?? 'all') === 'context') {
$query->whereNull('opportunities.primary_territory_id')->whereNotExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'));
$query->where(fn ($q) => $q->whereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_projects')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('project_id', app(InvestmentRelations::class)->projectIds()))
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_clusters')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('cluster_id', $this->publicStructureIds('clusters', 'cluster_id'))));
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_clusters')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('cluster_id', $this->publicStructureIds('clusters', 'cluster_id')))
->orWhere(fn ($source) => $source->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->whereIn('workflow_context->corridor_agribusiness->corridor', array_column(config('investment_corridors', []), 'name'))));
}
if (! empty($filters['opportunity_id'])) {
$query->whereKey($filters['opportunity_id']);
@@ -80,12 +82,13 @@ class InvestmentCatalogue
$includeNational = filter_var($filters['include_national'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($territory) use ($ids, $includeNational, $filters): void {
$query->where(function ($territory) use ($ids, $includeNational, $filters, $path): void {
$territory->whereIn('opportunities.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
if (count($path) === 1) $this->approvedCorridorProvince($territory, $path[0]->id);
}
if ($includeNational) {
$territory->orWhere('opportunities.geography_scope', 'national');
@@ -162,6 +165,9 @@ class InvestmentCatalogue
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
if (app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->exists()) {
$this->approvedCorridorProvince($matches, $filters['geo_location_id']);
}
}
});
@@ -212,6 +218,38 @@ class InvestmentCatalogue
};
}
private function approvedCorridorProvince($query, string $provinceId): void
{
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
$names = [];
foreach (config('investment_corridors', []) as $entry) {
if (in_array($code, $entry['provinces'], true)) $names[] = $entry['name'];
}
if (!$names) return;
$query->orWhere(fn ($context) => $context->whereNull('opportunities.primary_territory_id')
->whereNotExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->whereIn('workflow_context->corridor_agribusiness->corridor', $names));
}
/** Public result presentation only; no editorial or territorial data is saved. */
public function presentLocation(Opportunity $record): Opportunity
{
$geo = app(InvestmentGeography::class);
$paths = [];
$ids = array_filter([$record->primary_territory_id, ...$record->additionalTerritories()->pluck('geo_locations.id')->all()]);
foreach (array_unique($ids) as $id) {
$path = $geo->path($id);
if ($path) $paths[] = implode(' / ', array_column($path, 'name'));
}
if ($paths) $label = implode('; ', array_unique($paths));
elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
$label = implode(' / ', array_column($provinces, 'name')).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
} else $label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
$record->setAttribute('territorial_summary', $label);
return $record;
}
/** @param array<string,mixed> $territoryFilters
* @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
@@ -429,7 +467,7 @@ class InvestmentCatalogue
$located = 0;
foreach ($records as $record) {
$properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name,
'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
'location' => $this->presentLocation($record)->getAttribute('territorial_summary'), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
@@ -14,6 +14,15 @@ final class InvestmentCorridorContext
return null;
}
/** Reuse Tempo's approved provincial context; never infer a district or master link. */
public function catalogueProvinces(Opportunity $record): array
{
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) return [];
if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) return [];
$key = $this->key($record);
return $key ? $this->provinces($key) : [];
}
public function provinces(string $key): array
{
$entry = config('investment_corridors.'.$key);
+4 -1
View File
@@ -35,8 +35,11 @@ final class OpportunityCorridorExplorer
$path = collect($context['primary_territory_path'] ?? []);
$province = $path->firstWhere('location_type', 'province')->name ?? $record->province;
$district = $path->firstWhere('location_type', 'district')->name ?? $record->district;
$contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record);
$provinceOptions = $province ? [$province] : array_column($contextProvinces, 'name');
$item = ['id' => $record->id, 'title' => $record->title, 'slug' => $record->slug, 'summary' => $record->summary,
'province' => $province, 'district' => $district, 'chains' => array_values(array_unique($chains)),
'province' => $province, 'district' => $district, 'provinces' => $provinceOptions,
'territorial_summary' => $catalogue->presentLocation($record)->getAttribute('territorial_summary'), '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' => []];
@@ -1739,3 +1739,15 @@ O delta instalado contém Área de trabalho no nível principal, destinos subord
Pendente: conferência visual autenticada desktop/móvel/teclado/toque e percurso Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier. Último bloqueio conhecido: `The browser access host is unavailable`; uma aba aberta não restabelece o host. Não repetir as provas QA aceites nem reinstalar a base interna para resolver essa pendência. As 435 observações de Gurué não equivalem a 435 empresas únicas; listas internas, cobertura parcial e hipótese de investimento mantêm-se explícitas. L8 permanece parcial.
Evidência: `docs/investmoz/unification/navigation-git-delivery-20261028.json`. O relatório QA anterior permanece como histórico do candidato antes da instalação. Actualizados os mesmos sete requisitos existentes, preservando o seu histórico; não foi criada outra matriz.
# Inventário antes da verificação — catálogo territorial, 28/10/2026
Preservar menus `6e8de43a`, dados, base interna, aprovações, configuração de corredores e históricos já aceites. Caso Maputo Província / Agronegócio reproduzido por leitura QA: zero resultados apesar de cinco oportunidades públicas do Corredor de Maputo, cujo âmbito provincial está aprovado desde 23/10 em `corridor-adaptive-context-20261023.json`. A consulta não inclui esse contexto; os cartões também não mostram todos os níveis territoriais disponíveis. Correcção delimitada nos requisitos existentes V2-07/V2-10, sem inferir distrito/posto administrativo onde a fonte não os indica. Conferência visual autenticada continua pendente; não há nova matriz nem reabertura de provas aceites.
## 28/10/2026 — correcção provincial do catálogo em QA, ainda sem instalação
Caso da Tempo: Agronegócio / Maputo Província. A leitura QA reproduziu zero resultados para cinco oportunidades públicas do Corredor de Maputo. Reutilizada a decisão expressa de 23/10 que abrange Maputo Província e Maputo Cidade, antes disponível no percurso mas omitida na consulta. O filtro provincial passa a incluir esse contexto aprovado, mantendo sector e estado público, sem duplicar oportunidades nem atribuir distrito/posto administrativo ou coordenadas inexistentes. Uma localização explícita posterior prevalece sobre o contexto antigo.
Os resultados apresentam a hierarquia territorial disponível; o contexto de corredor é identificado e os níveis não indicados continuam explícitos. Pesquisa/API, vista por corredores e lista sem coordenadas do mapa concordam. O atalho «Ver toda a província» elimina a necessidade de limpar vários níveis manualmente. Não foram criadas publicações, relações a mestres ou permissões. Provas QA: 4 testes/70 verificações, zero falhas, transacções revertidas; tipos, compilação QA e sintaxe PHP passaram. O inventário anterior foi registado antes dos testes. Menus 6e8de43a, base interna, históricos e aceitação anterior preservados; L8 permanece parcial.
Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo processo Git e conferência visual autenticada continuam pendentes. Não declarar auditados todos os outros menus por este delta. Cobertura actualizada nos requisitos existentes V2-07/V2-10; sem nova matriz.
@@ -0,0 +1,57 @@
{
"date": "2026-10-28",
"status": "QA_corrected_and_verified_not_installed",
"installed_baseline": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"user_case": "Agronegócio / Maputo Província",
"reproduction": {
"QA_public_agribusiness": 37,
"Maputo_corridor_public_opportunities": 5,
"province_results_before": 0,
"province_results_after": 5
},
"cause": "The provincial catalogue ignored the corridor context already approved by Tempo; no records were deleted.",
"reused_decision": [
"config/investment_corridors.php",
"docs/investmoz/unification/corridor-adaptive-context-20261023.json"
],
"correction": [
"Reuse approved corridor province context only at province level, with the accepted source hash and existing public publication decisions",
"Do not override an explicit primary/additional territory or infer district/post/coordinates",
"Display available province,district,administrative post,locality directly in results",
"Show both approved Maputo units as contextual coverage, not as two separate opportunities",
"Keep catalogue,API,corridor view and unlocated map list consistent",
"One action returns from district to whole province; no requirement to select every level"
],
"QA": {
"tests": 4,
"assertions": 70,
"failures": 0,
"database": "proactive_investment_20261027_test",
"port": 55441,
"transactions_rolled_back": true,
"types": "passed",
"build": "passed",
"PHP_syntax": "passed"
},
"preserved": [
"Installed menus and accepted supply proofs",
"Originals,source data,territory IDs,publication states,permissions,private lists and histories",
"L8 unchanged,partial"
],
"limitations": [
"No active installation in this report",
"Real authenticated visual conference remains pending;last connector error The browser access host is unavailable",
"This fixes the shared investment catalogue and corridor view;it is not proof that every menu in the portal has been audited",
"Provincial corridor coverage is not a documented district site;unknown levels stay unknown"
],
"existing_requirements": [
"V2-07",
"V2-10"
],
"restriction_origins": {
"territorial_behaviour": "Tempo current request",
"corridor_coverage": "Tempo express decision,23/10/2026",
"no_new_publication_or_roles": "existing approved scope",
"UI_summary_and_province_shortcut": "implementation choice"
}
}
@@ -7297,3 +7297,15 @@ O delta instalado contém Área de trabalho no nível principal, destinos subord
Pendente: conferência visual autenticada desktop/móvel/teclado/toque e percurso Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier. Último bloqueio conhecido: `The browser access host is unavailable`; uma aba aberta não restabelece o host. Não repetir as provas QA aceites nem reinstalar a base interna para resolver essa pendência. As 435 observações de Gurué não equivalem a 435 empresas únicas; listas internas, cobertura parcial e hipótese de investimento mantêm-se explícitas. L8 permanece parcial.
Evidência: `docs/investmoz/unification/navigation-git-delivery-20261028.json`. O relatório QA anterior permanece como histórico do candidato antes da instalação. Actualizados os mesmos sete requisitos existentes, preservando o seu histórico; não foi criada outra matriz.
# Inventário de preservação — consulta territorial, 28/10/2026
Antes das novas verificações: menus `6e8de43a` instalados, base interna e percurso proactivo preservados; conferência visual autenticada continua pendente. Reutilizam-se as provas anteriores. O caso relatado é Agronegócio / Maputo Província. Em leitura da QA isolada, as 37 oportunidades públicas incluem cinco do Corredor de Maputo; o filtro provincial devolve zero. A correspondência aprovada do corredor para Maputo Província e Maputo Cidade já existe em `config/investment_corridors.php` e `corridor-adaptive-context-20261023.json`, mas a pesquisa não a utiliza. Não se trata de eliminação dos registos. Os cartões mostram apenas província e omitem o detalhe territorial disponível. A correcção é de consulta/apresentação, sem criar localizações distritais não documentadas, relações a mestres, permissões ou novas publicações. Referência aos requisitos existentes V2-07/V2-10; a implementação transversal e a sessão real não ficam declaradas concluídas por este inventário.
## 28/10/2026 — correcção provincial do catálogo em QA, ainda sem instalação
Caso da Tempo: Agronegócio / Maputo Província. A leitura QA reproduziu zero resultados para cinco oportunidades públicas do Corredor de Maputo. Reutilizada a decisão expressa de 23/10 que abrange Maputo Província e Maputo Cidade, antes disponível no percurso mas omitida na consulta. O filtro provincial passa a incluir esse contexto aprovado, mantendo sector e estado público, sem duplicar oportunidades nem atribuir distrito/posto administrativo ou coordenadas inexistentes. Uma localização explícita posterior prevalece sobre o contexto antigo.
Os resultados apresentam a hierarquia territorial disponível; o contexto de corredor é identificado e os níveis não indicados continuam explícitos. Pesquisa/API, vista por corredores e lista sem coordenadas do mapa concordam. O atalho «Ver toda a província» elimina a necessidade de limpar vários níveis manualmente. Não foram criadas publicações, relações a mestres ou permissões. Provas QA: 4 testes/70 verificações, zero falhas, transacções revertidas; tipos, compilação QA e sintaxe PHP passaram. O inventário anterior foi registado antes dos testes. Menus 6e8de43a, base interna, históricos e aceitação anterior preservados; L8 permanece parcial.
Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo processo Git e conferência visual autenticada continuam pendentes. Não declarar auditados todos os outros menus por este delta. Cobertura actualizada nos requisitos existentes V2-07/V2-10; sem nova matriz.
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ 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};
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;provinces?:string[];territorial_summary?:string;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};
@@ -10,10 +10,11 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
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 provinceNames=(r:Item)=>r.provinces??(r.province?[r.province]:[]);
const provinces=[...new Set(group.opportunities.flatMap(provinceNames))].sort();
const districts=[...new Set(group.opportunities.filter(r=>!province||provinceNames(r).includes(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)));
const rows=group.opportunities.filter(r=>(!province||provinceNames(r).includes(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>
@@ -25,7 +26,7 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
<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>{r.territorial_summary||[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>
@@ -222,6 +222,11 @@ export default function OpportunitySearchFilters({
</div>
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<legend className="mb-2 font-semibold">Localização</legend>
<p className="text-sm sm:col-span-2 lg:col-span-4">
A província inclui as oportunidades dos seus distritos, postos administrativos e localidades.
Não é necessário escolher todos os níveis. O contexto provincial de um corredor é identificado nos resultados; não confirma uma implantação distrital.
{path.length > 1 && <button type="button" className="ml-2 underline" onClick={() => territory(path.slice(0, 1))}>Ver toda a província de {path[0].name}</button>}
</p>
{(
[
'province_id',
+1 -1
View File
@@ -591,7 +591,7 @@ export default function Catalogue({
<article key={r.id} className={card + ' space-y-3'}>
<p className="text-sm">
{r.sector?.name} ·{' '}
{r.province || 'Localização não indicada'}
{r.territorial_summary || [r.province, r.district].filter(Boolean).join(' / ') || 'Localização não indicada'}
</p>
<h2 className="text-xl font-bold">
<Link
+1
View File
@@ -53,6 +53,7 @@ export type Opportunity = {
description: string | null;
province: string | null;
district: string | null;
territorial_summary?: string;
sector?: { name: string; slug: string };
estimated_investment_amount: string | null;
estimated_investment_currency: string;
@@ -0,0 +1,80 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\CorridorAgribusinessOpportunities;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class CatalogueProvinceCoverageTest extends TestCase
{
use DatabaseTransactions;
private const PROVINCE = 'bfde2651-2b46-5d9c-a500-733fa55a8c5c';
protected function setUp(): void
{
parent::setUp();\Tests\Support\ProactiveInvestmentFixture::guard();$this->withoutVite();
}
public function test_accepted_maputo_source_is_found_in_both_approved_provinces_without_becoming_a_district_site(): void
{
$c=app(InvestmentCatalogue::class);
$expected=Opportunity::published()->where('workflow_context->corridor_agribusiness->source_sha256',CorridorAgribusinessOpportunities::SOURCE_HASH)
->where('workflow_context->corridor_agribusiness->corridor','Corredor de Maputo')->pluck('id')->all();
$this->assertCount(5,$expected);
foreach([self::PROVINCE,'4aa31470-997e-5c63-8fe0-ff2f62586f56']as$province){
foreach(['all','context']as$basis){
$ids=$c->query(['sector'=>'agronegocio','province_id'=>$province,'territory_basis'=>$basis])->pluck('id')->all();
foreach($expected as$id)$this->assertContains($id,$ids);
}
}
foreach($expected as$id)$this->assertNotContains($id,$c->query(['province_id'=>self::PROVINCE,'territory_basis'=>'direct'])->pluck('id')->all());
$district=app(InvestmentGeography::class)->publicReferences()->where('parent_id',self::PROVINCE)->where('location_type','district')->first();
$this->assertNotNull($district);
foreach($expected as$id)$this->assertNotContains($id,$c->query(['province_id'=>self::PROVINCE,'district_id'=>$district->id])->pluck('id')->all());
$label=$c->presentLocation(Opportunity::findOrFail($expected[0]))->territorial_summary;
$this->assertStringContainsString('contexto provincial do corredor',$label);
$this->assertStringContainsString('distrito e posto administrativo não indicados',$label);
foreach($expected as$id)$this->assertContains($id,$c->query(['geo_location_id'=>self::PROVINCE])->pluck('id')->all());
$this->assertSame([],array_intersect($expected,$c->query(['province_id'=>'059b02ca-7095-577a-8705-4d4c46896747'])->pluck('id')->all()));
}
public function test_province_includes_its_descendants_with_full_location_and_excludes_private_and_other_provinces(): void
{
$geo=app(InvestmentGeography::class);$c=app(InvestmentCatalogue::class);
$district=$geo->publicReferences()->where('parent_id',self::PROVINCE)->where('location_type','district')->first();
$post=$geo->publicReferences()->where('parent_id',$district->id)->where('location_type','admin_post')->first();
$this->assertNotNull($post);
$make=fn($territory,$status='published')=>Opportunity::create(['sector_id'=>1,'reference'=>'SYNTHETIC-QA-'.Str::uuid(),'slug'=>'synthetic-'.Str::uuid(),'title'=>'SYNTHETIC QA province coverage','status'=>$status,'published_at'=>now()->subMinute(),'primary_territory_id'=>$territory,'geography_scope'=>'specific']);
$record=$make($post->id);$private=$make($post->id,'draft');$other=$make('059b02ca-7095-577a-8705-4d4c46896747');
$ids=$c->query(['province_id'=>self::PROVINCE,'sector'=>'agronegocio'])->pluck('id')->all();
$this->assertContains($record->id,$ids);$this->assertNotContains($private->id,$ids);$this->assertNotContains($other->id,$ids);
$label=$c->presentLocation($record)->territorial_summary;
foreach(['Maputo Província',$district->name,$post->name]as$name)$this->assertStringContainsString($name,$label);
}
public function test_actual_territory_and_publication_decisions_override_old_corridor_context(): void
{
$c=app(InvestmentCatalogue::class);
$record=Opportunity::published()->where('workflow_context->corridor_agribusiness->corridor','Corredor de Maputo')->firstOrFail();
$record->primary_territory_id='059b02ca-7095-577a-8705-4d4c46896747';$record->save();
$this->assertNotContains($record->id,$c->query(['province_id'=>self::PROVINCE])->pluck('id')->all());
$record->primary_territory_id=null;$record->status='draft';$record->save();
$this->assertNotContains($record->id,$c->query(['province_id'=>self::PROVINCE])->pluck('id')->all());
}
public function test_public_search_and_corridor_view_agree_without_inventing_map_coordinates(): void
{
$filters=['province_id'=>self::PROVINCE,'sector'=>'agronegocio'];
$response=$this->getJson('/api/v1/investment/search?'.http_build_query($filters))->assertOk()->assertJsonPath('total',5);
foreach($response->json('data')as$row)$this->assertStringContainsString('contexto provincial do corredor',$row['territorial_summary']);
$groups=app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters);
$this->assertCount(1,$groups);$this->assertSame(5,$groups[0]['count']);
foreach($groups[0]['opportunities']as$row){$this->assertContains('Maputo Província',$row['provinces']);$this->assertNull($row['district']);}
$map=app(InvestmentCatalogue::class)->map($filters);
$this->assertCount(5,$map['unlocated']);$this->assertSame([],$map['features']);
}
}