245 lines
21 KiB
PHP
245 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\InvestmentZone;
|
|
use App\Models\Opportunity;
|
|
use App\Services\InvestmentCatalogue;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\InvestmentZoneAccess;
|
|
use App\Services\InvestmentZones;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class InvestmentZoneController extends Controller
|
|
{
|
|
public function show(Request $request, InvestmentZones $zones, string $id): Response|JsonResponse
|
|
{
|
|
$management = $request->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,
|
|
'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 ($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)
|
|
{
|
|
$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');
|
|
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));
|
|
}
|
|
}
|