fix(catalogue): include approved provincial corridor coverage and show territorial detail
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user