['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors'], 'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']]; /** @return array */ public function filters(Request $request): array { $filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], 'preparation' => ['nullable', \Illuminate\Validation\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'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], 'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'], 'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'], 'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'], 'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined,corridors'], 'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'], 'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])], 'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'], 'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'], 'association_type' => ['nullable', 'in:'.implode(',', array_keys(OpportunityClassification::TARGETS))], 'association_target' => ['nullable', 'string', 'max:80'], 'association_state' => ['nullable', 'in:'.implode(',', OpportunityClassification::STATES)], 'association_purpose' => ['nullable', 'in:'.implode(',', array_keys(OpportunityClassification::PURPOSES))], 'authority_id' => ['nullable', 'uuid'], 'chain_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid'], 'category' => ['nullable', 'in:'.implode(',', OpportunityManagement::CATEGORIES)], 'publication_state' => ['nullable', 'in:published'], 'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]); 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.']); } if (\Illuminate\Support\Str::isUuid($filters['association_target'])) { $filters[$alias] = $filters['association_target']; unset($filters['association_type'], $filters['association_target']); } } return $filters; } /** * @param array $filters * @return Builder */ public function query(array $filters): Builder { $query = Opportunity::published()->with('sector:id,name,slug'); $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'))); } 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')))); } if (! empty($filters['opportunity_id'])) { $query->whereKey($filters['opportunity_id']); } $path = app(InvestmentGeography::class)->selection($filters); $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 { $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 ($includeNational) { $territory->orWhere('opportunities.geography_scope', 'national'); } }); } elseif (! $includeNational) { $query->where('opportunities.geography_scope', '!=', 'national'); } if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { $query->whereIn('opportunities.id', app(InvestmentZones::class)->links()->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $filters['dimension'])->select('mapping.opportunity_id')); } foreach (['cluster' => 'clusters', 'corridor' => 'corridors'] as $dimension => $relation) { if (($filters['dimension'] ?? null) === $dimension) { $foreign = $dimension.'_id'; $query->whereIn('opportunities.id', DB::table('opportunity_'.$relation.' as linked') ->whereIn('linked.'.$foreign, $this->publicStructureIds($relation, $foreign))->select('linked.opportunity_id')); } } foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) { if (! empty($filters[$field])) { $legacy = app(InvestmentZones::class)->links()->where('zl.association_type', '<>', 'proximity') ->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $category) ->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id'); $query->where(fn ($matches) => $matches->whereIn('opportunities.id', $legacy) ->orWhereIn('opportunities.id', $associated[$field] ?? [])); } } if (! empty($filters['q'])) { $term = '%'.$filters['q'].'%'; $query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('description', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term) ->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term)) ->orWhereExists(fn ($g) => $g->selectRaw('1')->from('geo_locations as search_location')->whereNull('search_location.deleted_at')->where('search_location.data_classification', 'public') ->where('search_location.name', 'ilike', $term)->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id')) ->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id') ->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations as search_link')->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id')))) ->orWhereExists(fn ($d) => $d->selectRaw('1')->from('investment_dossiers as searchable_dossier') ->whereNull('searchable_dossier.deleted_at')->where('searchable_dossier.publication_status', 'published') ->where(fn ($text) => $text->where('searchable_dossier.title', 'ilike', $term)->orWhereRaw('searchable_dossier.content::text ILIKE ?', [$term])) ->where(fn ($relation) => $relation ->whereExists(fn ($p) => $p->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id')) ->orWhereExists(fn ($c) => $c->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id'))))); } if (! empty($filters['sector'])) { SectorOrganization::constrain($query, $filters['sector']); } foreach (['province', 'district'] as $field) { if (! empty($filters[$field])) { $query->where($field, $filters[$field]); } } if (! empty($filters['preparation'])) { OpportunityMaturity::constrainSearch($query, $filters['preparation']); } elseif (! empty($filters['maturity_level'])) { // Legacy links predate the eight-stage catalogue: retain their original meaning. OpportunityMaturity::constrainSearch($query, match ((int) $filters['maturity_level']) { 7 => 'stage:implementation', 8 => 'stage:operational', default => 'historical:'.$filters['maturity_level'], }); } foreach (self::RELATIONS as $relation => [$foreign, $table]) { if (! empty($filters[$foreign])) { $locationIds = $relation === 'locations' ? app(InvestmentGeography::class)->descendants((string) $filters[$foreign]) : null; if ($relation === 'locations') { $query->where(function ($matches) use ($locationIds, $filters) { $matches->whereIn('opportunities.primary_territory_id', $locationIds ?? []) ->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link') ->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')); if (($filters['territory_basis'] ?? 'all') !== 'direct') { app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []); } }); continue; } if (in_array($relation, ['clusters', 'corridors'], true)) { $query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link') ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign]) ->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign))) ->orWhereIn('opportunities.id', $associated[$foreign] ?? [])); } else { $query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at') ->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign))) ->orWhereIn('opportunities.id', $associated[$foreign] ?? [])); } } } if (! empty($filters['education'])) { $query->where(fn ($q) => $q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('is_education', true)) ->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('education_investment_proposals')->whereColumn('opportunity_id', 'opportunities.id')->whereNull('deleted_at'))); } $facetGroups = DB::table('investment_reference_options')->whereIn('id', $filters['facets'] ?? [])->get(['id', 'group'])->groupBy('group'); foreach ($facetGroups as $options) { $optionIds = $options->pluck('id')->all(); $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_facets')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('option_id', $optionIds)); } foreach (['min_amount' => '>=', 'max_amount' => '<='] as $field => $operator) { if (isset($filters[$field])) { $query->where('estimated_investment_amount', $operator, $filters[$field]); } } if (! empty($filters['currency'])) { $query->where('estimated_investment_currency', $filters['currency']); } 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']); app(OpportunityClassification::class)->constrain($query, $filters); $sort = $filters['sort'] ?? 'recent'; // Monetary sorting stays grouped by currency; no undocumented FX conversion. return match ($sort) { 'title' => $query->orderBy('title')->orderBy('id'), 'amount_asc', 'amount_desc' => $query->orderBy('estimated_investment_currency')->orderBy('estimated_investment_amount', $sort === 'amount_asc' ? 'asc' : 'desc')->orderBy('id'), default => $query->orderByDesc('published_at')->orderByDesc('id'), }; } /** @param array $territoryFilters * @return array */ public function references(bool $management = false, array $territoryFilters = []): array { $path = app(InvestmentGeography::class)->selection($territoryFilters); $result = ['classification' => app(OpportunityClassification::class)->options(), 'preparationOptions' => OpportunityMaturity::searchOptions(), '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]]; foreach (self::RELATIONS as $relation => [$foreign, $table]) { $query = DB::table($table)->whereNull('deleted_at'); if (! $management) { if ($relation === 'locations') { $query->where('data_classification', 'public'); } elseif (in_array($relation, ['clusters', 'corridors'], true)) { $query->whereIn('id', $this->publicStructureIds($relation, $foreign)); } else { $query->whereIn('id', app(OpportunityClassification::class)->targets('project')->select('id')); } } if (! $management && $path !== [] && $relation !== 'locations') { $dossiers = InvestmentPortfolioController::visible(); app(InvestmentGeography::class)->constrainDossiersToTerritories($dossiers, app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id)); $territories = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id); $query->where(fn ($area) => $area->whereIn('primary_territory_id', $territories) ->orWhereIn('id', $dossiers->whereNotNull($foreign)->select($foreign))); } $result[$relation] = $query->orderBy('name')->get(['id', 'name']); if (! $management && $relation !== 'locations') { $dossiers = InvestmentPortfolioController::visible()->whereIn($foreign, $result[$relation]->pluck('id')) ->orderBy('id')->get(['id', $foreign])->unique($foreign)->keyBy($foreign); $result[$relation]->each(function ($record) use ($dossiers): void { $record->url = isset($dossiers[$record->id]) ? '/carteira/'.$dossiers[$record->id]->id : null; }); } } $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, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id'])); } $result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']); if (! $management && $path !== []) { // A chain's documented scope comes from its public cluster, never // from the opportunities surviving the current search. $ids = app(OpportunityClassification::class)->targets('chain') ->whereIn('cluster_id', $result['clusters']->pluck('id'))->pluck('id'); $result['classification']['targets']['chain'] = collect($result['classification']['targets']['chain'])->whereIn('id', $ids)->values(); } // Activities are a public classification catalogue, not evidence of // investment availability in a territory or sector. return $result; } private function publicAssociatedIds(array $filters): array { $kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'], '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 ($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; foreach ($selected as $field => $target) { if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id; } } } }); return $ids; } private function publicStructureIds(string $relation, string $foreign): \Illuminate\Database\Query\Builder { $table = self::RELATIONS[$relation][1]; return DB::table($table)->whereNull('deleted_at')->where(function ($query) use ($foreign): void { $query->where('data_classification', 'public') ->orWhereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)); })->select('id'); } /** @return array */ public function context(int $id, bool $management = false): array { $opportunity = Opportunity::findOrFail($id); if (! $management && data_get($opportunity->workflow_context, 'publication.purpose') === OpportunityPromotion::PURPOSE) { // Promotional disclosure includes only its explicit public coverage. // Private profiles, source evidence and other dossier relations are not auto-published. $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(); $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(), 'facets' => DB::table('opportunity_facets as f')->join('investment_reference_options as o', 'o.id', '=', 'f.option_id') ->where('f.opportunity_id', $id)->get(['o.id', 'o.group', 'o.label'])]; foreach (self::RELATIONS as $relation => [$foreign, $table]) { $query = DB::table('opportunity_'.$relation.' as l')->join($table.' as t', 't.id', '=', 'l.'.$foreign)->where('l.opportunity_id', $id)->whereNull('t.deleted_at'); if (! $management) { if ($relation === 'locations') { $query->where('t.data_classification', 'public'); } else { $query->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)); } } $result[$relation] = $query->get(['t.id', 't.name']); if (in_array($relation, ['projects', 'clusters'], true)) { $result[$relation] = $result[$relation]->map(function ($entity) use ($foreign, $management) { $dossiers = ($management ? InvestmentDossier::query() : InvestmentPortfolioController::visible())->where($foreign, $entity->id)->get(); $entity->dossiers = $dossiers->map(fn ($dossier) => ['id' => $dossier->id, 'title' => $dossier->title, 'source' => $dossier->source]); $entity->geography = $dossiers->first() ? app(InvestmentRelations::class)->dossier($dossiers->first(), $management) : null; return $entity; }); } } $result['zones'] = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('m.opportunity_id', $id) ->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference', 'zl.valid_from', 'zl.valid_until']); $opportunity = Opportunity::findOrFail($id); $result['classification'] = $management ? [] : app(OpportunityClassification::class)->publicRows($opportunity); $result['primary_territory_path'] = app(InvestmentGeography::class)->path(DB::table('opportunities')->where('id', $id)->value('primary_territory_id')); $result['opportunity_locations'] = app(InvestmentRelations::class)->opportunityLocations($id, $management); $result['source_evidence'] = app(InvestmentTerritorialTraceability::class)->sources('opportunities', $id); return $result; } /** * @param array $filters * @return array */ public function map(array $filters): array { $records = $this->query($filters)->limit(1000)->get(); $total = $this->query($filters)->reorder()->count(); $ids = $records->pluck('id'); $spatial = DB::table('opportunities as o')->join('geo_locations as g', function ($join) use ($filters) { $join->on('g.id', '=', 'o.primary_territory_id')->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_locations as l')->whereColumn('l.opportunity_id', 'o.id')->whereColumn('l.geo_location_id', 'g.id')); if (($filters['territory_basis'] ?? 'all') === 'direct') { return; } $join->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_projects as map_project') ->join('projects as p', 'p.id', '=', 'map_project.project_id')->whereColumn('map_project.opportunity_id', 'o.id')->whereNull('p.deleted_at') ->whereNotIn('p.data_classification', ['confidential', 'restricted'])->whereIn('p.id', app(InvestmentRelations::class)->projectIds()) ->where(fn ($site) => $site->whereColumn('p.primary_territory_id', 'g.id') ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as map_location') ->whereColumn('map_location.project_id', 'p.id')->whereColumn('map_location.geo_location_id', 'g.id') ->whereNull('map_location.deleted_at')->where('map_location.data_classification', 'public')->where('map_location.role', '!=', 'impact')))); })->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id') ->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public') ->whereNull('g.deleted_at')->whereNull('shape.deleted_at') ->whereRaw("NOT EXISTS (WITH RECURSIVE parents AS ( SELECT id,parent_id,deleted_at,data_classification,ARRAY[id] AS visited,false AS cycle FROM geo_locations WHERE id=g.id UNION ALL SELECT p.id,p.parent_id,p.deleted_at,p.data_classification,a.visited||p.id,p.id=ANY(a.visited) FROM geo_locations p JOIN parents a ON p.id=a.parent_id WHERE NOT a.cycle ) SELECT 1 FROM parents WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)") ->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326'); $points = (clone $spatial)->where('g.location_type', 'site')->where('shape.geometry_type', 'point') ->whereRaw('ST_X(shape.geom) BETWEEN -180 AND 180 AND ST_Y(shape.geom) BETWEEN -90 AND 90') ->orderBy('o.id')->orderByRaw("CASE WHEN g.source_system='investment-site-editor' THEN 0 ELSE 1 END")->orderBy('g.id')->orderBy('shape.id') ->get(['o.id', 'g.name', 'g.id as location_id', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'), DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'opportunity_site' ELSE 'linked_project_site' END as location_basis")]) ->groupBy('id')->map(function ($candidates) { // Several sites are legitimate; only conflicting coordinates for the same site are withheld. $sites = []; foreach ($candidates->groupBy('location_id') as $site) { if ($site->unique('geometry')->count() === 1) { foreach ($site as $candidate) { $sites[] = $candidate; break; } } } return $sites; })->filter(); $relations = []; foreach (['clusters', 'corridors', 'projects'] as $relation) { [$foreign, $table] = self::RELATIONS[$relation]; $relations[$relation] = DB::table('opportunity_'.$relation.' as l')->join($table.' as t', 't.id', '=', 'l.'.$foreign) ->whereIn('l.opportunity_id', $ids)->whereNull('t.deleted_at') ->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)) ->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id'); } $features = $missing = []; $areas = (clone $spatial)->whereIn('shape.geometry_type', ['polygon', 'multipolygon'])->orderBy('shape.id') ->get(['o.id', 'o.title', 'o.slug', 'g.name', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'), DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")]) ->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')', 'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null, 'url' => '/oportunidades/'.$area->slug.($filters === [] ? '' : '?'.http_build_query($filters))])->all(); $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))]; foreach ($relations as $relation => $groups) { $properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all(); } $sites = collect($points->get($record->id) ?? []); if ($sites->isNotEmpty()) { $located++; foreach ($sites as $point) { $features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, 'location_id' => $point->location_id, 'source_reference' => $point->source_system, 'source_date' => json_decode($point->properties ?? '{}', true)['source_date'] ?? null, 'location_basis' => $point->location_basis, 'location' => $point->name.($point->location_basis === 'linked_project_site' ? ' (local do projecto associado; contexto territorial)' : ' (implantação cadastrada da oportunidade)')]]; } } else { $missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates']; } } return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(), 'georeferenced_count' => $located, 'marker_count' => count($features), 'unlocated' => $missing, 'features' => $features, 'areas' => $areas, 'complete' => $total === $located]; } }