From fdadc8ace7ed93be5118ada3ee1b3cc8a120192b Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 29 Oct 2026 14:13:40 +0200 Subject: [PATCH] fix(search): share provincial hierarchy across legacy and catalogue APIs --- .../Api/V1/OpportunityController.php | 20 ++- app/Services/InvestmentCatalogue.php | 105 ++++++++++--- app/Services/InvestmentGeography.php | 15 ++ .../consolidated-review-matrix-2026-10-03.md | 7 +- .../unification/traceability-and-manual.md | 7 +- docs/investmoz/v2/auditoria-portal/backlog.md | 7 +- .../ProvinceSearchCompatibilityTest.php | 148 ++++++++++++++++++ 7 files changed, 276 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/ProvinceSearchCompatibilityTest.php diff --git a/app/Http/Controllers/Api/V1/OpportunityController.php b/app/Http/Controllers/Api/V1/OpportunityController.php index bce21579..c62fee4b 100644 --- a/app/Http/Controllers/Api/V1/OpportunityController.php +++ b/app/Http/Controllers/Api/V1/OpportunityController.php @@ -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 diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 4db099fc..54cfbaed 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -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 */ 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(), diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php index bf600145..6f61f07f 100644 --- a/app/Services/InvestmentGeography.php +++ b/app/Services/InvestmentGeography.php @@ -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. * diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 985bd607..89a4b454 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 247d1f30..e27c9172 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index cde4a40c..8fc8e103 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/tests/Feature/ProvinceSearchCompatibilityTest.php b/tests/Feature/ProvinceSearchCompatibilityTest.php new file mode 100644 index 00000000..5d7287cf --- /dev/null +++ b/tests/Feature/ProvinceSearchCompatibilityTest.php @@ -0,0 +1,148 @@ +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); + } + } + } +}