Files
Investidor-V2/app/Services/InvestmentCatalogue.php
T

555 lines
38 KiB
PHP

<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Support\StructuredData;
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
{
public const RELATIONS = ['clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors'],
'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']];
/** @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', 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']]);
$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 ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
}
if (Str::isUuid($filters['association_target'])) {
$filters[$alias] = $filters['association_target'];
unset($filters['association_type'], $filters['association_target']);
}
}
return $filters;
}
/**
* @param array<string,mixed> $filters
* @return Builder<Opportunity>
*/
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');
}
$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')))
->orWhere(fn ($source) => $source->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->whereIn('workflow_context->corridor_agribusiness->corridor', array_column(config('investment_corridors', []), 'name'))));
}
if (! empty($filters['opportunity_id'])) {
$query->whereKey($filters['opportunity_id']);
}
$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, $path): void {
$territory->whereIn('opportunities.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
if (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 ($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'));
$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()) {
$this->approvedCorridorProvince($matches, $filters['geo_location_id']);
}
}
});
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 Builder<Opportunity> $query */
private function declaredProvince(Builder $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))]));
}
/** @param Builder<Opportunity> $query */
private function approvedCorridorProvince(Builder $query, string $provinceId): void
{
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
$names = [];
foreach (config('investment_corridors', []) as $entry) {
if (in_array($code, $entry['provinces'], true)) {
$names[] = $entry['name'];
}
}
if (! $names) {
return;
}
$query->orWhere(fn ($context) => $context->whereNull('opportunities.primary_territory_id')
->whereNotExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->whereIn('workflow_context->corridor_agribusiness->corridor', $names));
}
/** Public result presentation only; no editorial or territorial data is saved. */
public function presentLocation(Opportunity $record): Opportunity
{
$geo = app(InvestmentGeography::class);
$paths = [];
$ids = array_filter([$record->primary_territory_id, ...$record->additionalTerritories()->pluck('geo_locations.id')->all()]);
foreach (array_unique($ids) as $id) {
$path = $geo->path($id);
if ($path) {
$paths[] = implode(' / ', array_column($path, 'name'));
}
}
if ($paths) {
$label = implode('; ', array_unique($paths));
} elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
$label = implode(' / ', array_column($provinces, 'name')).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
} else {
$label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
}
$record->setAttribute('territorial_summary', $label);
return $record;
}
/** @param array<string,mixed> $territoryFilters
* @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
{
$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' => 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(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'] ?? collect())->pluck('id'))->pluck('id');
$result['classification']['targets']['chain'] = collect(StructuredData::collectionItems($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;
}
/**
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
*/
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;
}
}
}
}
});
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)]));
}
}
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<string,mixed> */
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<string,mixed> $filters
* @return array<string,mixed>
*/
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' => $this->presentLocation($record)->getAttribute('territorial_summary'), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
$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];
}
}