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']]); $query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id') ->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->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)]); } }