555 lines
38 KiB
PHP
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];
|
|
}
|
|
}
|