Files
Investidor-V2/app/Services/InvestmentCatalogue.php
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

246 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'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'],
'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'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'], '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'],
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
app(InvestmentGeography::class)->selection($filters);
return $filters;
}
/**
* @param array<string,mixed> $filters
* @return Builder<Opportunity>
*/
public function query(array $filters): Builder
{
$query = Opportunity::published()->with('sector:id,name,slug');
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): 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 ($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)->whereIn($foreign, InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))->select('opportunity_id'));
}
}
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) {
if (! empty($filters[$field])) {
$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', $category)
->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id'));
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term)));
}
if (! empty($filters['sector'])) {
$query->whereHas('sector', fn ($s) => $s->where('slug', $filters['sector']));
}
foreach (['province', 'district', 'maturity_level'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
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(fn ($matches) => $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')));
continue;
}
$query->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')->when($relation === 'locations', fn ($q) => $q->whereIn('link.'.$foreign, $locationIds ?? []), fn ($q) => $q->where('link.'.$foreign, $filters[$foreign]))->whereNull('target.deleted_at')
->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'),
fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($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']));
}
$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'),
};
}
/** @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
{
$result = ['options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
'sectors' => DB::table('sectors')->where('is_active', true)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'slug'])];
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
$query = DB::table($table)->whereNull('deleted_at');
if (! $management) {
if ($relation === 'locations') {
$query->where('data_classification', 'public');
} else {
$query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign));
}
}
$result[$relation] = $query->orderBy('name')->get(['id', 'name']);
}
$zones = app(InvestmentZones::class)->visible();
if (! $management && $territoryFilters !== []) {
$zones = app(InvestmentZones::class)->search($territoryFilters);
}
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
return $result;
}
/** @return array<string,mixed> */
public function context(int $id, bool $management = false): array
{
$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']);
}
$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']);
$result['primary_territory_path'] = app(InvestmentGeography::class)->path(DB::table('opportunities')->where('id', $id)->value('primary_territory_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');
$points = DB::table('opportunities as o')->join('geo_locations as g', function ($join) {
$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'));
})->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')
->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')
->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')
->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', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')])->unique('id')->keyBy('id');
$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 = [];
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];
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
if ($point = $points->get($record->id)) {
$features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, 'location' => trim($properties['location'].' — '.$point->name)]];
} else {
$missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates'];
}
}
return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(),
'georeferenced_count' => count($features), 'unlocated' => $missing, 'features' => $features,
'complete' => $total === count($features)];
}
}