209 lines
15 KiB
PHP
209 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\InvestmentPortfolioController;
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/** Resolve documented relationships without generating geography or spreading opportunity memberships. */
|
|
class InvestmentRelations
|
|
{
|
|
/** Directly declared opportunity territories; a link does not certify implantation.
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
public function opportunityLocations(int $id, bool $management = false): array
|
|
{
|
|
$record = DB::table('opportunities')->where('id', $id)->first();
|
|
$items = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')
|
|
->map(fn ($location) => ['id' => $location, 'role' => 'opportunity', 'source_reference' => null, 'source_date' => null])->values()->all();
|
|
if ($record?->primary_territory_id && ! collect($items)->contains('id', $record->primary_territory_id)) {
|
|
$items[] = ['id' => $record->primary_territory_id, 'role' => 'opportunity', 'source_reference' => null, 'source_date' => null];
|
|
}
|
|
|
|
return $this->locations(array_values($items), $management);
|
|
}
|
|
|
|
public function projectIds(): Builder
|
|
{
|
|
return InvestmentPortfolioController::visible()->whereNotNull('project_id')->select('project_id')->toBase();
|
|
}
|
|
|
|
public function memberships(): Builder
|
|
{
|
|
// Legacy proposal links are consultable when both dossiers disclose the exact project code.
|
|
// Their pending/legal status remains independent from permission to consult the source.
|
|
return DB::table('cluster_projects as cp')->join('projects as member', 'member.id', '=', 'cp.project_id')
|
|
->whereNull('cp.deleted_at')->whereNull('member.deleted_at')->whereNotIn('member.data_classification', ['confidential', 'restricted'])
|
|
->where('cp.validation_status', '!=', 'revoked')
|
|
->where(fn ($q) => $q->whereNull('cp.valid_from')->orWhereDate('cp.valid_from', '<=', today()))
|
|
->where(fn ($q) => $q->whereNull('cp.valid_until')->orWhereDate('cp.valid_until', '>=', today()))
|
|
->whereIn('cp.project_id', $this->projectIds())
|
|
->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_dossiers as disclosed_cluster')
|
|
->whereColumn('disclosed_cluster.cluster_id', 'cp.cluster_id')->where('disclosed_cluster.publication_status', 'published')->whereNull('disclosed_cluster.deleted_at')
|
|
->where(fn ($authorized) => $authorized->where('cp.data_classification', 'public')
|
|
->orWhere(fn ($legacy) => $legacy->where('cp.source_system', 'upgrade-v4')
|
|
->whereRaw("(disclosed_cluster.content->>'anchors') ~ ('\\m' || member.code || '\\M')"))));
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function projectLocations(string $id, bool $management = false): array
|
|
{
|
|
$project = DB::table('projects')->where('id', $id)->whereNull('deleted_at')->first();
|
|
if (! $project || (! $management && (in_array($project->data_classification, ['confidential', 'restricted'], true)
|
|
|| ! InvestmentPortfolioController::visible()->where('project_id', $id)->exists()))) {
|
|
return [];
|
|
}
|
|
$links = DB::table('project_locations')->where('project_id', $id)->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('role')->get();
|
|
$items = [];
|
|
$metadata = json_decode($project->metadata, true);
|
|
foreach ($links as $link) {
|
|
$items[] = ['id' => $link->geo_location_id, 'role' => $link->role, 'relation_id' => $link->id,
|
|
'source_reference' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_reference'] ?? $link->source_system,
|
|
'source_quote' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_quote'] ?? null,
|
|
'external_id' => $link->external_id];
|
|
}
|
|
if ($project->primary_territory_id && ! collect($items)->contains(fn ($item) => $item['id'] === $project->primary_territory_id && $item['role'] === 'primary')) {
|
|
$items[] = ['id' => $project->primary_territory_id, 'role' => 'primary', 'source_reference' => $project->source_system, 'relation_id' => null];
|
|
}
|
|
|
|
return $this->locations($items, $management);
|
|
}
|
|
|
|
/** @param list<array<string,mixed>> $items
|
|
* @return list<array<string,mixed>> */
|
|
public function locations(array $items, bool $management = false): array
|
|
{
|
|
$result = [];
|
|
foreach ($items as $item) {
|
|
$path = app(InvestmentGeography::class)->path($item['id'], $management);
|
|
// Site nodes are not administrative references; use their documented parent path.
|
|
$site = DB::table('geo_locations')->where('id', $item['id'])->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->first();
|
|
if (! $site) {
|
|
continue;
|
|
}
|
|
if ($site->location_type === 'site') {
|
|
$path = app(InvestmentGeography::class)->path($site->parent_id, $management);
|
|
if ($site->parent_id && ! $path) {
|
|
continue;
|
|
}
|
|
} elseif (! $path) {
|
|
continue;
|
|
}
|
|
$shapes = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))
|
|
->where('geometry_type', 'point')->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
|
|
->whereRaw('ST_X(geom) BETWEEN -180 AND 180 AND ST_Y(geom) BETWEEN -90 AND 90')
|
|
->orderBy('id')->get(['id', 'source_system', 'properties', DB::raw('ST_AsGeoJSON(geom) as geometry')]);
|
|
$coordinates = $shapes->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system,
|
|
'source_date' => json_decode($shape->properties ?? '{}', true)['source_date'] ?? null,
|
|
'geometry' => json_decode($shape->geometry, true)])->all();
|
|
$boundaries = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))
|
|
->whereIn('geometry_type', ['polygon', 'multipolygon'])->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
|
|
->get(['id', 'source_system', 'properties', DB::raw('ST_AsGeoJSON(geom) as geometry')])
|
|
->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system,
|
|
'source_date' => json_decode($shape->properties ?? '{}', true)['source_date'] ?? null, 'geometry' => json_decode($shape->geometry, true)])->all();
|
|
$result[] = [...$item, 'name' => $site->name, 'location_type' => $site->location_type, 'path' => $path, 'coordinates' => $coordinates, 'boundaries' => $boundaries];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function dossier(InvestmentDossier $dossier, bool $management = false): array
|
|
{
|
|
$sourceEvidence = [];
|
|
if ($dossier->project_id) {
|
|
$sourceEvidence = app(InvestmentTerritorialTraceability::class)->sources('projects', $dossier->project_id);
|
|
} elseif ($dossier->cluster_id) {
|
|
$sourceEvidence = app(InvestmentTerritorialTraceability::class)->sources('economic_clusters', $dossier->cluster_id);
|
|
}
|
|
$result = ['locations' => [], 'members' => [], 'clusters' => [], 'chainLinks' => [], 'chains' => [],
|
|
'source_evidence' => $sourceEvidence];
|
|
if ($dossier->project_id) {
|
|
$result['locations'] = $this->projectLocations($dossier->project_id, $management);
|
|
$memberships = $this->memberships()->where('cp.project_id', $dossier->project_id)->get(['cp.cluster_id', 'cp.role', 'cp.validation_status', 'cp.source_reference']);
|
|
foreach ($memberships as $membership) {
|
|
foreach (InvestmentPortfolioController::visible()->where('cluster_id', $membership->cluster_id)->get() as $cluster) {
|
|
$result['clusters'][] = ['id' => $cluster->cluster_id, 'dossier_id' => $cluster->id, 'title' => $cluster->title,
|
|
'role' => $membership->role, 'validation_status' => $membership->validation_status, 'source_reference' => $membership->source_reference ?? $cluster->source];
|
|
}
|
|
}
|
|
}
|
|
if ($dossier->cluster_id) {
|
|
$result['chains'] = DB::table('value_chains')->where('cluster_id', $dossier->cluster_id)->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('code')->get(['id', 'code', 'name', 'stage'])->all();
|
|
$cluster = DB::table('economic_clusters')->where('id', $dossier->cluster_id)->first();
|
|
$metadata = json_decode($cluster->metadata ?? '{}', true);
|
|
$coverage = array_values(array_filter($metadata['documented_coverage'] ?? [], fn ($item) => isset($item['id'], $item['source_reference']) && ($management || ($item['data_classification'] ?? '') === 'public')));
|
|
if ($cluster?->primary_territory_id) {
|
|
$coverage[] = ['id' => $cluster->primary_territory_id, 'role' => 'coverage', 'source_reference' => $cluster->source_system];
|
|
}
|
|
$result['locations'] = $this->locations($coverage, $management);
|
|
foreach ($this->memberships()->where('cp.cluster_id', $dossier->cluster_id)->get(['cp.project_id', 'member.code', 'member.name', 'cp.role', 'cp.validation_status', 'cp.source_reference']) as $member) {
|
|
$projectDossier = InvestmentPortfolioController::visible()->where('project_id', $member->project_id)->first();
|
|
if ($projectDossier) {
|
|
$result['members'][] = ['id' => $member->project_id, 'dossier_id' => $projectDossier->id, 'title' => $projectDossier->title,
|
|
'code' => $member->code, 'role' => $member->role, 'validation_status' => $member->validation_status,
|
|
'source_reference' => $member->source_reference ?? $dossier->source, 'locations' => $this->projectLocations($member->project_id, $management)];
|
|
}
|
|
}
|
|
$result['chainLinks'] = DB::table('value_chain_links as link')->join('value_chains as origin', 'origin.id', '=', 'link.from_value_chain_id')
|
|
->join('value_chains as destination', 'destination.id', '=', 'link.to_value_chain_id')
|
|
->where('origin.cluster_id', $dossier->cluster_id)->where('destination.cluster_id', $dossier->cluster_id)
|
|
->whereNull('link.deleted_at')->whereNull('origin.deleted_at')->whereNull('destination.deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('link.data_classification', 'public')->where('origin.data_classification', 'public')->where('destination.data_classification', 'public'))
|
|
->get(['link.id', 'origin.name as origin', 'destination.name as destination', 'link.link_type'])->all();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** Add explicitly linked project and cluster member territories without creating opportunity-project links.
|
|
* @param Builder|\Illuminate\Database\Eloquent\Builder<Opportunity> $query
|
|
* @param list<string> $ids */
|
|
public function opportunityTerritories(Builder|\Illuminate\Database\Eloquent\Builder $query, array $ids): void
|
|
{
|
|
$query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_projects as geographic_project')
|
|
->join('projects as documented_project', 'documented_project.id', '=', 'geographic_project.project_id')
|
|
->whereColumn('geographic_project.opportunity_id', 'opportunities.id')->whereNull('documented_project.deleted_at')
|
|
->whereIn('documented_project.id', $this->projectIds())
|
|
->where(fn ($area) => $area->whereIn('documented_project.primary_territory_id', $ids)
|
|
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as documented_location')
|
|
->whereColumn('documented_location.project_id', 'documented_project.id')->whereNull('documented_location.deleted_at')
|
|
->where('documented_location.data_classification', 'public')->whereIn('documented_location.geo_location_id', $ids))));
|
|
$query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_clusters as geographic_cluster')
|
|
->join('economic_clusters as documented_cluster', 'documented_cluster.id', '=', 'geographic_cluster.cluster_id')
|
|
->whereColumn('geographic_cluster.opportunity_id', 'opportunities.id')->whereNull('documented_cluster.deleted_at')
|
|
->whereIn('documented_cluster.id', InvestmentPortfolioController::visible()->whereNotNull('cluster_id')->select('cluster_id'))
|
|
->where(fn ($area) => $area->whereIn('documented_cluster.primary_territory_id', $ids)
|
|
->orWhereRaw($this->coverageSql('documented_cluster.metadata', $ids), $ids)
|
|
->orWhereExists(fn ($members) => $members->selectRaw('1')->fromSub($this->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'territorial_member')
|
|
->join('projects as member_project', 'member_project.id', '=', 'territorial_member.project_id')->whereColumn('territorial_member.cluster_id', 'documented_cluster.id')
|
|
->where(fn ($territory) => $territory->whereIn('member_project.primary_territory_id', $ids)
|
|
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as member_location')
|
|
->whereColumn('member_location.project_id', 'member_project.id')->whereNull('member_location.deleted_at')
|
|
->where('member_location.data_classification', 'public')->whereIn('member_location.geo_location_id', $ids))))));
|
|
}
|
|
|
|
/** @param list<string> $ids */
|
|
public function coverageSql(string $column, array $ids): string
|
|
{
|
|
if (! in_array($column, ['documented_cluster.metadata', 'coverage_cluster.metadata'], true)) {
|
|
throw new \InvalidArgumentException('Unsupported coverage column');
|
|
}
|
|
if (! $ids) {
|
|
return 'false';
|
|
}
|
|
|
|
return "EXISTS (SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof({$column}->'documented_coverage')='array' THEN {$column}->'documented_coverage' ELSE '[]'::jsonb END) AS coverage_entry
|
|
WHERE coverage_entry->>'data_classification'='public' AND NULLIF(coverage_entry->>'source_reference','') IS NOT NULL
|
|
AND coverage_entry->>'id' IN (".implode(',', array_fill(0, count($ids), '?')).'))';
|
|
}
|
|
}
|