is('management/*', 'api/v2/investment/admin/*'); $zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id); $territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management); $zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province); $zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district); $zone->setAttribute('territory_path', $territoryPath); $publicFields = ['id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at']; $locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id') ->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']); $source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no']); $operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at') ->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name'); $field = ['zee' => 'zee_id', 'zfi' => 'zfi_id', 'industrial_park' => 'park_id'][$zone->category] ?? null; $opportunities = $field && ! $management ? app(InvestmentCatalogue::class)->query([$field => $id])->paginate(12)->withQueryString() : null; $related = collect(); foreach (['cluster_id', 'corridor_id', 'project_id'] as $foreign) { $relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign); $related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get()); } $attachmentColumns = $management ? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status', 'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by', 'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status', 'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at'] : ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes']; $attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id') ->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active')) ->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')) ->whereNull('d.deleted_at') ->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed') ->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')) ->orderBy('a.created_at')->get($attachmentColumns); $attachments->each(function ($attachment) use ($id, $management): void { $attachment->download_url = $management ? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id) : url('/zonas/'.$id.'/anexos/'.$attachment->id); }); $props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management, 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'), 'related' => $related->unique('id')->values(), 'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], 'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], 'targets' => $management ? ['opportunities' => Opportunity::orderBy('title')->limit(200)->get(['id', 'title']), 'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null, 'opportunities' => $opportunities, 'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [], 'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [], 'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], 'locations' => $locations, 'territorialSources' => app(InvestmentTerritorialTraceability::class)->sources('investment_zones', $id), 'attachments' => $attachments, 'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []]; return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props); } public function index(Request $request, InvestmentZones $zones): Response|JsonResponse { $filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'], 'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'], 'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]); $management = $request->is('management/*', 'api/v2/investment/admin/*'); abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503, 'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.'); $path = app(InvestmentGeography::class)->selection($filters); $publicColumns = ['id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at']; $publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column))); $rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id') ->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString(); $rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) { $territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management); $zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province); $zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district); $zone->setAttribute('territory_path', $territoryPath); $zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id') ->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at') ->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type'])); return $zone; }); $props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path, 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')]; return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props); } public function export(Request $request, InvestmentZones $zones): StreamedResponse { $filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'], 'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'], 'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]); abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503, 'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.'); $query = $zones->search($filters)->orderBy('name')->orderBy('id'); return response()->streamDownload(function () use ($query): void { $geography = app(InvestmentGeography::class); $stream = fopen('php://output', 'wb'); if ($stream === false) { throw new \RuntimeException('CSV output stream unavailable.'); } fwrite($stream, "\xEF\xBB\xBF"); fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';'); $query->chunk(200, function ($zones) use ($stream, $geography): void { foreach ($zones as $zone) { $territoryPath = $geography->path($zone->primary_territory_id); $province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province; $district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district; $sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | '); $values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status, $zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources]; $values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values); fputcsv($stream, $values, ';'); } }); fclose($stream); }, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); } public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse { if ($request->has('aliases_text')) { $request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]); } if ($request->filled('historical_name')) { $request->merge(['historical_territory' => [ 'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'), 'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'), ]]); } if ($request->filled('activity_name')) { $request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]); } $data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'], 'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)], 'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'], 'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'], 'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], 'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')], 'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'], 'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'], 'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'], 'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'], 'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'], 'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'], 'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'], 'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'], 'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'], 'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'], 'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'], 'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:name,status,source_reference'], 'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'], 'activities.*.source_reference' => ['nullable', 'string', 'max:2000'], 'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:name,status,source_reference'], 'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'], 'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'], 'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'], 'historical_territory' => ['nullable', 'array:name,district,province,source_reference'], 'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'], 'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'], 'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'], 'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'], 'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'], 'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]); foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) { if (! Schema::hasColumn('investment_zones', $column)) { unset($data[$column]); } } foreach ($data['activities'] ?? [] as $index => $activity) { if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') { throw ValidationException::withMessages([ 'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.', ]); } } if (! $id && $request->has('province_id')) { $geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]); $path = app(InvestmentGeography::class)->selection($geo, true); $data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id; $data['territory_version'] = $path === [] ? 0 : 1; $data['province'] = $path[0]->name ?? null; $data['district'] = $path[1]->name ?? null; } return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201); } public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse { $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); return response()->json($action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) : $zones->suspend($id, $data['version_no'], $request->user()->id)); } public function revisions(string $id): JsonResponse { InvestmentZone::findOrFail($id); return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at'])); } public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse { InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers'); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id)); } }