> */ 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> */ 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> $items * @return list> */ 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 */ 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 $query * @param list $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 $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), '?')).'))'; } }