133 lines
9.9 KiB
PHP
133 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\InvestmentZone;
|
|
use App\Services\InvestmentZoneAccess;
|
|
use App\Services\InvestmentZones;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class InvestmentZoneGeometryController extends Controller
|
|
{
|
|
public function store(Request $request, string $id): JsonResponse
|
|
{
|
|
$data = $request->validate(['geometry' => ['required', 'array:type,coordinates'],
|
|
'geometry.type' => ['required', 'in:Point,LineString,Polygon,MultiPolygon'], 'geometry.coordinates' => ['required', 'array'],
|
|
'purpose' => ['required', 'in:official_perimeter,influence,site,alternative'],
|
|
'legal_source_id' => ['nullable', 'required_if:purpose,official_perimeter', 'uuid', 'exists:investment_legal_sources,id'],
|
|
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'validating_entity' => ['required', 'string', 'max:255'],
|
|
'observed_at' => ['required', 'date_format:Y-m-d', 'before_or_equal:today']]);
|
|
$json = json_encode($data['geometry'], JSON_THROW_ON_ERROR);
|
|
if (strlen($json) > 200000) {
|
|
throw ValidationException::withMessages(['geometry' => 'Máximo 200 KB por geometria.']);
|
|
}
|
|
if ($data['purpose'] === 'official_perimeter' && ! in_array($data['geometry']['type'], ['Polygon', 'MultiPolygon'], true)) {
|
|
throw ValidationException::withMessages(['geometry' => 'O perímetro exige polígono ou multipolígono.']);
|
|
}
|
|
try {
|
|
$valid = DB::transaction(fn () => DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_NDims(g)=2 AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) s', [$json]));
|
|
} catch (QueryException) {
|
|
throw ValidationException::withMessages(['geometry' => 'GeoJSON inválido.']);
|
|
}
|
|
if (! $valid->valid) {
|
|
throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora do SRID 4326.']);
|
|
}
|
|
|
|
return DB::transaction(function () use ($request, $id, $data, $json) {
|
|
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
$location = (string) Str::uuid();
|
|
$geometry = (string) Str::uuid();
|
|
$link = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $location, 'name' => $zone->name, 'code' => 'zone:'.$location, 'location_type' => 'site']);
|
|
$type = ['Point' => 'point', 'LineString' => 'line', 'Polygon' => 'polygon', 'MultiPolygon' => 'multipolygon'][$data['geometry']['type']];
|
|
DB::insert('INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom) VALUES (?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326))', [$geometry, $location, $type, $json]);
|
|
unset($data['geometry']);
|
|
DB::table('investment_zone_geometries')->insert([...$data, 'id' => $link, 'zone_id' => $id, 'geometry_id' => $geometry,
|
|
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->audit($link, $request->user()->id, 'created', 1);
|
|
|
|
return response()->json(['id' => $link, 'version_no' => 1, 'validation_status' => 'pending'], 201);
|
|
});
|
|
}
|
|
|
|
public function review(Request $request, string $id, string $action): JsonResponse
|
|
{
|
|
if ($action === 'validate') {
|
|
InvestmentZoneAccess::requireRole($request->user(), 'gis_reviewers');
|
|
}
|
|
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
|
|
|
return DB::transaction(function () use ($request, $id, $action, $data) {
|
|
// Serializes perimeter approvals, including approval of overlapping zones.
|
|
DB::select("SELECT pg_advisory_xact_lock(hashtext('investment-zone-perimeters'))");
|
|
$link = DB::table('investment_zone_geometries')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($link !== null, 404);
|
|
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
|
|
if ($action === 'validate') {
|
|
abort_if((int) $link->editor_id === $request->user()->id, 403, 'Revisão GIS independente obrigatória.');
|
|
abort_unless($link->validation_status === 'pending', 422, 'Geometria já analisada.');
|
|
if ($link->legal_source_id) {
|
|
$source = DB::table('investment_legal_sources')->where('id', $link->legal_source_id)->lockForUpdate()->first();
|
|
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
|
|
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte válida em falta.');
|
|
}
|
|
if ($link->purpose === 'official_perimeter') {
|
|
$conflict = DB::table('investment_zone_geometries as other')->join('geo_geometries as a', 'a.id', '=', 'other.geometry_id')
|
|
->join('geo_geometries as b', fn ($j) => $j->where('b.id', '=', $link->geometry_id))
|
|
->where('other.id', '<>', $id)->where('other.publication_authorized', true)->where('other.purpose', 'official_perimeter')
|
|
->whereRaw('ST_Intersects(a.geom,b.geom) AND NOT ST_Touches(a.geom,b.geom)')->exists();
|
|
abort_if($conflict, 422, 'Sobreposição de perímetros: remeter para validação territorial.');
|
|
}
|
|
}
|
|
DB::table('investment_zone_geometries')->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
|
|
'publication_authorized' => $action === 'validate', 'reviewer_id' => $request->user()->id, 'version_no' => $link->version_no + 1, 'updated_at' => now()]);
|
|
if ($action === 'validate') {
|
|
// The reviewed shape is public only through the zone map, not generic location search.
|
|
DB::table('geo_geometries')->where('id', $link->geometry_id)->update(['data_classification' => 'public']);
|
|
}
|
|
$this->audit($id, $request->user()->id, $action, $link->version_no + 1);
|
|
|
|
return response()->json(['id' => $id, 'version_no' => $link->version_no + 1]);
|
|
});
|
|
}
|
|
|
|
public function map(Request $request, InvestmentZones $zones): JsonResponse
|
|
{
|
|
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
|
|
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
|
|
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], '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($data['development_status']) || ! empty($data['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.');
|
|
$eligibleZones = $zones->search($data)->select('investment_zones.id');
|
|
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
|
|
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
|
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
|
|
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
|
|
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
|
|
->where(fn ($s) => $s->whereNull('ls.valid_until')->orWhereDate('ls.valid_until', '>=', today()))));
|
|
if (isset($data['bbox'])) {
|
|
[$west, $south, $east, $north] = $data['bbox'];
|
|
abort_unless($west >= -180 && $east <= 180 && $south >= -90 && $north <= 90 && $west < $east && $south < $north, 422, 'Janela geográfica inválida.');
|
|
$query->whereRaw('ST_Intersects(g.geom,ST_MakeEnvelope(?,?,?,?,4326))', [$west, $south, $east, $north]);
|
|
}
|
|
$rows = $query->orderBy('l.id')->limit(101)->get(['l.id', 'l.zone_id', 'l.purpose', 'l.source_reference', 'l.observed_at', DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) AS geometry')]);
|
|
|
|
return response()->json(['type' => 'FeatureCollection', 'limit' => 100, 'truncated' => $rows->count() > 100,
|
|
'features' => $rows->take(100)->map(fn ($r) => ['type' => 'Feature', 'id' => $r->id, 'geometry' => json_decode($r->geometry, true),
|
|
'properties' => ['zone_id' => $r->zone_id, 'purpose' => $r->purpose, 'source' => $r->source_reference, 'observed_at' => $r->observed_at]])->values()]);
|
|
}
|
|
|
|
private function audit(string $id, int $actor, string $action, int $version): void
|
|
{
|
|
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_geometries', 'entity_id' => $id,
|
|
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|