246 lines
16 KiB
PHP
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)];
|
|
}
|
|
}
|