fix(search): share provincial hierarchy across legacy and catalogue APIs

This commit is contained in:
2026-10-29 14:13:40 +02:00
parent 979f3f850e
commit fdadc8ace7
7 changed files with 276 additions and 33 deletions
@@ -4,6 +4,9 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityMaturity;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -15,6 +18,11 @@ class OpportunityController extends Controller
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'string', 'max:80'],
'province' => ['nullable', 'string', 'max:80'],
'province_id' => ['nullable', 'uuid'],
'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'],
'locality_id' => ['nullable', 'uuid'],
'geo_location_id' => ['nullable', 'uuid'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
@@ -47,22 +55,24 @@ class OpportunityController extends Controller
if (! empty($validated['sector'])) {
$sector = $validated['sector'];
\App\Services\SectorOrganization::constrain($query, $sector);
SectorOrganization::constrain($query, $sector);
}
if (! empty($validated['province'])) {
$query->where('province', $validated['province']);
$territory = array_intersect_key($validated, array_flip(['province', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'geo_location_id']));
if (array_filter($territory, fn ($value) => $value !== null && $value !== '') !== []) {
// Keep the legacy search's sorting and non-territorial filters while sharing the public territorial contract.
$query->whereIn('opportunities.id', app(InvestmentCatalogue::class)->query($territory)->reorder()->select('opportunities.id'));
}
if (! empty($validated['maturity_level'])) {
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
->orderByDesc('published_at')
->paginate($validated['per_page'] ?? 12);
return response()->json($results);
return response()->json($results->withQueryString()->through(fn ($record) => app(InvestmentCatalogue::class)->presentLocation($record)));
}
public function show(string $slug): JsonResponse
+80 -25
View File
@@ -5,9 +5,14 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class InvestmentCatalogue
{
@@ -17,8 +22,8 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
public function filters(Request $request): array
{
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'],'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'], 'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
'opportunity_id' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'],
@@ -40,14 +45,15 @@ class InvestmentCatalogue
'category' => ['nullable', 'in:'.implode(',', OpportunityManagement::CATEGORIES)],
'publication_state' => ['nullable', 'in:published'],
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
app(InvestmentGeography::class)->selection($filters);
$aliases = ['park' => 'park_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id', 'project' => 'project_id'];
$alias = $aliases[$filters['association_type'] ?? ''] ?? null;
if ($alias && ! empty($filters['association_target']) && empty($filters['association_purpose']) && empty($filters['association_state'])) {
if (! empty($filters[$alias]) && $filters[$alias] !== $filters['association_target']) {
throw \Illuminate\Validation\ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
throw ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
}
if (\Illuminate\Support\Str::isUuid($filters['association_target'])) {
if (Str::isUuid($filters['association_target'])) {
$filters[$alias] = $filters['association_target'];
unset($filters['association_type'], $filters['association_target']);
}
@@ -62,9 +68,13 @@ class InvestmentCatalogue
*/
public function query(array $filters): Builder
{
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
$query = Opportunity::published()->with('sector:id,name,slug');
if (in_array($filters['perspective'] ?? '', ['exists','needs','ideas'], true)) $query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
elseif (($filters['perspective'] ?? '') === 'opportunities') $query->whereNull('workflow_context->promotional_summary->projection->proactive');
if (in_array($filters['perspective'] ?? '', ['exists', 'needs', 'ideas'], true)) {
$query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
} elseif (($filters['perspective'] ?? '') === 'opportunities') {
$query->whereNull('workflow_context->promotional_summary->projection->proactive');
}
$associated = $this->publicAssociatedIds($filters);
if (($filters['territory_basis'] ?? 'all') === 'direct') {
$query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id')));
@@ -86,9 +96,14 @@ class InvestmentCatalogue
$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 (count($path) === 1) {
$this->declaredProvince($territory, $path[0]->name);
}
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
if (count($path) === 1) $this->approvedCorridorProvince($territory, $path[0]->id);
if (count($path) === 1) {
$this->approvedCorridorProvince($territory, $path[0]->id);
}
}
if ($includeNational) {
$territory->orWhere('opportunities.geography_scope', 'national');
@@ -163,6 +178,10 @@ class InvestmentCatalogue
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
$province = app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->first(['name']);
if ($province) {
$this->declaredProvince($matches, $province->name);
}
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()) {
@@ -206,7 +225,9 @@ class InvestmentCatalogue
if (! empty($filters['before'])) {
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('target_date', '<=', $filters['before']));
}
if (! empty($filters['category'])) $query->where('opportunity_category', $filters['category']);
if (! empty($filters['category'])) {
$query->where('opportunity_category', $filters['category']);
}
app(OpportunityClassification::class)->constrain($query, $filters);
$sort = $filters['sort'] ?? 'recent';
@@ -218,14 +239,28 @@ class InvestmentCatalogue
};
}
private function declaredProvince($query, string $name): void
{
// Read the published province declaration only when no more precise territory contradicts it.
// This does not assign any canonical ID, district, post, locality or coordinates to the record.
$query->orWhere(fn ($declared) => $declared->whereNull('opportunities.primary_territory_id')
->whereNotExists(fn ($links) => $links->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
->where('opportunities.geography_scope', '!=', 'national')
->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))]));
}
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 (in_array($code, $entry['provinces'], true)) {
$names[] = $entry['name'];
}
}
if (! $names) {
return;
}
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)
@@ -240,13 +275,19 @@ class InvestmentCatalogue
$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 ($path) {
$paths[] = implode(' / ', array_column($path, 'name'));
}
}
if ($paths) $label = implode('; ', array_unique($paths));
elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
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';
} else {
$label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
}
$record->setAttribute('territorial_summary', $label);
return $record;
}
@@ -260,9 +301,9 @@ class InvestmentCatalogue
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
'categories' => OpportunityManagement::CATEGORIES,
'options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
'sectors' => \App\Models\Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
'maturityCatalogue' => ['version'=>OpportunityMaturity::VERSION, 'stages'=>OpportunityMaturity::STAGES,
'legacyVersion'=>OpportunityMaturity::LEGACY, 'legacyLabels'=>OpportunityMaturity::LEGACY_LABELS]];
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES,
'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS]];
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
$query = DB::table($table)->whereNull('deleted_at');
if (! $management) {
@@ -295,7 +336,7 @@ class InvestmentCatalogue
$zones = app(InvestmentZones::class)->visible();
if (! $management && $territoryFilters !== []) {
// Opportunity words/modalities are not names/categories of zones.
$zones = app(InvestmentZones::class)->search(\Illuminate\Support\Arr::only($territoryFilters,
$zones = app(InvestmentZones::class)->search(Arr::only($territoryFilters,
['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id']));
}
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
@@ -318,24 +359,35 @@ class InvestmentCatalogue
'cluster_id' => ['cluster'], 'corridor_id' => ['corridor'], 'project_id' => ['project']];
$selected = array_filter(array_intersect_key($filters, $kinds));
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $key => $category) {
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) unset($selected[$key]);
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) {
unset($selected[$key]);
}
}
if ($selected === []) {
return [];
}
if ($selected === []) return [];
$ids = [];
Opportunity::published()->select('opportunities.*')->chunkById(200, function ($records) use ($selected, $kinds, &$ids) {
foreach ($records as $record) {
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') continue;
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') {
continue;
}
foreach ($selected as $field => $target) {
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id;
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) {
$ids[$field][] = $record->id;
}
}
}
}
});
if (!empty($selected['corridor_id'])) {
if (! empty($selected['corridor_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('corridor_id', $selected['corridor_id'])->first();
if ($dossier) $ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
if ($dossier) {
$ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
}
}
return $ids;
}
@@ -359,9 +411,12 @@ class InvestmentCatalogue
$public = ['profile' => null, 'facets' => collect(), 'zones' => collect(), 'classification' => [], 'source_evidence' => [],
'primary_territory_path' => app(InvestmentGeography::class)->path($opportunity->primary_territory_id, true),
'opportunity_locations' => app(InvestmentRelations::class)->opportunityLocations($id)];
foreach (self::RELATIONS as $relation => $_) $public[$relation] = collect();
foreach (self::RELATIONS as $relation => $_) {
$public[$relation] = collect();
}
$public['locations'] = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')
->where('l.opportunity_id', $id)->where('g.data_classification', 'public')->whereNull('g.deleted_at')->get(['g.id', 'g.name']);
return $public;
}
$result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(),
+15
View File
@@ -12,6 +12,21 @@ final class InvestmentGeography
{
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
/** Resolve an old province-name search only when the public register has exactly one match. */
public function provinceSearch(array $filters): array
{
if (empty($filters['province_id']) && is_string($filters['province'] ?? null) && trim($filters['province']) !== '') {
$matches = $this->publicReferences()->where('location_type', 'province')
->whereRaw('lower(btrim(name)) = ?', [mb_strtolower(trim($filters['province']))])->limit(2)->get(['id']);
if ($matches->count() === 1) {
$filters['province_id'] = $matches[0]->id;
unset($filters['province']);
}
}
return $filters;
}
/**
* Restrict published dossier searches using direct territories and public, validated structure links.
*
@@ -1863,4 +1863,9 @@ Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
### 29/10/2026 — V2-07/V2-10 / ACT-02, diagnóstico dos filtros provinciais em curso
Separação confirmada pela Tempo: a equipa executará e registará a conferência visual autenticada de Competitividade (continua pendente); o Codex prossegue no código/API dos filtros. Provas aceites preservadas, sem repetição das suites fiscais ou de Competitividade. Nenhuma perda de dados confirmada nesta passagem.
Reprodução pública em b81f477b: Agronegócio/Maputo Província por province_id na API /api/v1/investment/search devolve5 oportunidades (IDs60–64). O mesmo nome em province devolve0, tanto nessa API como em /api/v1/opportunities/search. A API antiga ignora province_id e devolve34, incluindo outras províncias. São lacunas de normalização/compatibilidade entre entradas, não prova de eliminação nem falha demonstrada da expansão hierárquica já existente. Corrigir apenas os deltas: resolver nomes provinciais públicos inequívocos para a mesma hierarquia e aplicar o filtro territorial na API antiga, preservando outros filtros, versões públicas, localização real e dados. Demonstrar todos os níveis inferiores por pedidos HTTP em QA isolada; província apenas contextual ou declarada não deve ganhar distrito. O problema permanece aberto até às provas específicas e à conferência da API instalada; não encerrar a conferência visual da equipa por testes técnicos.
@@ -7421,4 +7421,9 @@ Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
### 29/10/2026 — V2-07/V2-10 / ACT-02, diagnóstico dos filtros provinciais em curso
Separação confirmada pela Tempo: a equipa executará e registará a conferência visual autenticada de Competitividade (continua pendente); o Codex prossegue no código/API dos filtros. Provas aceites preservadas, sem repetição das suites fiscais ou de Competitividade. Nenhuma perda de dados confirmada nesta passagem.
Reprodução pública em b81f477b: Agronegócio/Maputo Província por province_id na API /api/v1/investment/search devolve5 oportunidades (IDs60–64). O mesmo nome em province devolve0, tanto nessa API como em /api/v1/opportunities/search. A API antiga ignora province_id e devolve34, incluindo outras províncias. São lacunas de normalização/compatibilidade entre entradas, não prova de eliminação nem falha demonstrada da expansão hierárquica já existente. Corrigir apenas os deltas: resolver nomes provinciais públicos inequívocos para a mesma hierarquia e aplicar o filtro territorial na API antiga, preservando outros filtros, versões públicas, localização real e dados. Demonstrar todos os níveis inferiores por pedidos HTTP em QA isolada; província apenas contextual ou declarada não deve ganhar distrito. O problema permanece aberto até às provas específicas e à conferência da API instalada; não encerrar a conferência visual da equipa por testes técnicos.
@@ -3043,4 +3043,9 @@ Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
### 29/10/2026 — V2-07/V2-10 / ACT-02, diagnóstico dos filtros provinciais em curso
Separação confirmada pela Tempo: a equipa executará e registará a conferência visual autenticada de Competitividade (continua pendente); o Codex prossegue no código/API dos filtros. Provas aceites preservadas, sem repetição das suites fiscais ou de Competitividade. Nenhuma perda de dados confirmada nesta passagem.
Reprodução pública em b81f477b: Agronegócio/Maputo Província por province_id na API /api/v1/investment/search devolve5 oportunidades (IDs60–64). O mesmo nome em province devolve0, tanto nessa API como em /api/v1/opportunities/search. A API antiga ignora province_id e devolve34, incluindo outras províncias. São lacunas de normalização/compatibilidade entre entradas, não prova de eliminação nem falha demonstrada da expansão hierárquica já existente. Corrigir apenas os deltas: resolver nomes provinciais públicos inequívocos para a mesma hierarquia e aplicar o filtro territorial na API antiga, preservando outros filtros, versões públicas, localização real e dados. Demonstrar todos os níveis inferiores por pedidos HTTP em QA isolada; província apenas contextual ou declarada não deve ganhar distrito. O problema permanece aberto até às provas específicas e à conferência da API instalada; não encerrar a conferência visual da equipa por testes técnicos.
@@ -0,0 +1,148 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentGeography;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ProvinceSearchCompatibilityTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new \LogicException('Isolated QA required');
}
return $app;
}
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
private function createOpportunity(?string $territory, string $prefix, string $status = 'published', ?string $province = null): Opportunity
{
return Opportunity::create(['sector_id' => 1, 'reference' => 'SYNTHETIC-QA-'.Str::uuid(), 'slug' => 'synthetic-'.Str::uuid(),
'title' => $prefix.' synthetic provincial search', 'status' => $status, 'published_at' => now()->subMinute(),
'primary_territory_id' => $territory, 'province' => $province, 'district' => null, 'geography_scope' => 'specific']);
}
private function requests(array $filters): array
{
$query = http_build_query([...$filters, 'per_page' => 50]);
return ['/api/v1/investment/search?'.$query, '/api/v1/opportunities/search?'.$query];
}
public function test_a_single_province_includes_all_lower_levels_and_linked_locations_in_both_apis(): void
{
$geo = app(InvestmentGeography::class);
$locality = $geo->publicReferences()->where('location_type', 'locality')->orderBy('id')->first();
$this->assertNotNull($locality, 'Public locality fixture required');
$path = $geo->path($locality->id);
$this->assertCount(4, $path);
$prefix = 'SYNTHETIC-'.Str::uuid();
$records = array_map(fn ($node) => $this->createOpportunity($node->id, $prefix, 'published', 'Legacy label differs from canonical hierarchy'), $path);
$linked = $this->createOpportunity(null, $prefix);
$linked->additionalTerritories()->attach($locality->id);
$records[] = $linked;
$private = $this->createOpportunity($locality->id, $prefix, 'draft');
$deleted = $this->createOpportunity($locality->id, $prefix);
$deleted->delete();
$otherProvince = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $path[0]->id)->first();
$outside = $this->createOpportunity($otherProvince->id, $prefix);
foreach ([['province_id' => $path[0]->id], ['province' => $path[0]->name], ['geo_location_id' => $path[0]->id]] as $entry) {
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
$this->assertEqualsCanonicalizing(array_map(fn ($row) => $row->id, $records), array_column($response->json('data'), 'id'), $url);
foreach ($response->json('data') as $row) {
$this->assertNull($row['district']);
}
foreach ([$private, $deleted, $outside] as $row) {
$this->assertNotContains($row->id, array_column($response->json('data'), 'id'));
}
}
}
foreach ($records as $record) {
$this->assertNull($record->fresh()->district);
$this->assertSame($record->primary_territory_id, $record->fresh()->primary_territory_id);
}
$this->assertSame([$locality->id], $linked->additionalTerritories()->pluck('geo_locations.id')->all());
foreach ($this->requests(['province_id' => $path[0]->id, 'district_id' => $path[1]->id, 'q' => $prefix]) as $url) {
// Province-only records are not silently assigned to a district.
$this->getJson($url)->assertOk()->assertJsonPath('total', 4);
}
foreach ($this->requests(['district_id' => $path[1]->id]) as $url) {
$this->getJson($url)->assertUnprocessable();
}
}
public function test_published_province_declaration_is_found_without_inventing_a_district_or_overriding_an_explicit_other_territory(): void
{
$geo = app(InvestmentGeography::class);
$province = $geo->publicReferences()->where('location_type', 'province')->first();
$other = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $province->id)->first();
$district = $geo->publicReferences()->where('parent_id', $province->id)->where('location_type', 'district')->first();
$prefix = 'SYNTHETIC-'.Str::uuid();
$legacy = $this->createOpportunity(null, $prefix, 'published', $province->name);
$contradictory = $this->createOpportunity($other->id, $prefix, 'published', $province->name);
$linkedOutside = $this->createOpportunity(null, $prefix, 'published', $province->name);
$linkedOutside->additionalTerritories()->attach($other->id);
foreach ([['province' => $province->name], ['province_id' => $province->id], ['geo_location_id' => $province->id]] as $entry) {
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
$this->getJson($url)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $legacy->id)->assertJsonPath('data.0.district', null);
}
}
foreach ($this->requests(['province_id' => $province->id, 'district_id' => $district->id, 'q' => $prefix]) as $url) {
$this->getJson($url)->assertOk()->assertJsonPath('total', 0);
}
$this->assertNull($legacy->fresh()->primary_territory_id);
$this->assertNull($legacy->fresh()->district);
$this->assertSame($other->id, $contradictory->fresh()->primary_territory_id);
}
public function test_maputo_names_ids_page_map_and_export_share_the_existing_approved_corridor_coverage(): void
{
$provinceId = 'bfde2651-2b46-5d9c-a500-733fa55a8c5c';
$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 ([['province' => 'Maputo Província'], ['province' => ' mApUtO Província '], ['province_id' => $provinceId]] as $entry) {
foreach ($this->requests([...$entry, 'sector' => 'agronegocio']) as $url) {
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
$this->assertEqualsCanonicalizing($expected, array_column($response->json('data'), 'id'));
foreach ($response->json('data') as $row) {
$this->assertNull($row['district']);
$this->assertStringContainsString('contexto provincial do corredor', $row['territorial_summary']);
}
}
}
$query = http_build_query(['sector' => 'agronegocio', 'province' => 'Maputo Província']);
$this->get('/oportunidades?'.$query)->assertInertia(fn (AssertableInertia $page) => $page
->component('investment/catalogue')->where('filters.province_id', $provinceId)->missing('filters.province')
->has('territorySelection', 1)->where('results.total', 5));
$this->getJson('/api/v1/investment/map?'.$query)->assertOk()->assertJsonCount(0, 'features')->assertJsonCount(5, 'unlocated');
$csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent();
foreach (Opportunity::whereIn('id', $expected)->get() as $row) {
$this->assertStringContainsString($row->title, $csv);
}
foreach ($this->requests(['sector' => 'turismo', 'province_id' => $provinceId]) as $url) {
$response = $this->getJson($url)->assertOk();
foreach ($response->json('data') as $row) {
$this->assertNotContains($row['id'], $expected);
}
}
}
}