Files
Investidor-V2/app/Http/Controllers/InvestmentZoneGeometryController.php
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

126 lines
8.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\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']]);
$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)]);
}
}