varias alteracoes

This commit is contained in:
2026-09-29 08:41:57 +02:00
parent b77714b156
commit 2e0217b486
51 changed files with 2605 additions and 270 deletions
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InvestmentCoordinatesController extends Controller
{
public function show(int $id): JsonResponse
{
Opportunity::findOrFail($id);
$point = DB::table('geo_locations as g')->join('geo_geometries as s', 's.geo_location_id', '=', 'g.id')
->where('g.source_system', 'investment-site-editor')->where('g.external_id', (string) $id)->whereNull('g.deleted_at')->whereNull('s.deleted_at')
->first(['s.version_no', 's.properties', DB::raw('ST_X(s.geom) as longitude'), DB::raw('ST_Y(s.geom) as latitude')]);
return response()->json(['version' => $point->version_no ?? 0, 'latitude' => $point?->latitude, 'longitude' => $point?->longitude,
'source_reference' => $point ? (json_decode($point->properties, true)['source_reference'] ?? '') : '']);
}
public function save(Request $request, int $id): JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'confirmed_site' => ['accepted']]);
$version = DB::transaction(function () use ($data, $id, $request) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$opportunity = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$location = DB::table('geo_locations')->where('source_system', 'investment-site-editor')->where('external_id', (string) $id)->first();
abort_if($location && $location->deleted_at, 409, 'Local arquivado; requer reconciliação.');
abort_if($location && $location->data_classification !== 'public', 409, 'Local retirado de divulgação; requer revisão da classificação.');
$geometry = $location ? DB::table('geo_geometries')->where('geo_location_id', $location->id)->where('geometry_type', 'point')->first() : null;
abort_if($geometry && $geometry->deleted_at, 409, 'Geometria arquivada; requer reconciliação.');
abort_if($geometry && $geometry->data_classification !== 'public', 409, 'Geometria retirada de divulgação; requer revisão da classificação.');
abort_unless((int) ($geometry->version_no ?? 0) === $data['version'], 409, 'Coordenadas alteradas. Recarregue.');
$locationId = $location->id ?? (string) Str::uuid();
if (! $location) {
DB::table('geo_locations')->insert(['id' => $locationId, 'source_system' => 'investment-site-editor', 'external_id' => (string) $id,
'name' => $opportunity->title, 'location_type' => 'site', 'code' => 'SITE-OPP-'.$id, 'data_classification' => 'public', 'parent_id' => $opportunity->primary_territory_id]);
}
$version = $data['version'] + 1;
$geometryId = $geometry->id ?? (string) Str::uuid();
DB::statement("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,data_classification,version_no,properties)
VALUES(?,?,'point',ST_SetSRID(ST_MakePoint(?,?),4326),'public',?,?::jsonb)
ON CONFLICT(id) DO UPDATE SET geom=EXCLUDED.geom,version_no=EXCLUDED.version_no,properties=EXCLUDED.properties,updated_at=now()",
[$geometryId, $locationId, $data['longitude'], $data['latitude'], $version, json_encode(['source_reference' => $data['source_reference'], 'accuracy' => 'site_confirmed_by_editor'], JSON_THROW_ON_ERROR)]);
DB::table('opportunity_locations')->updateOrInsert(['opportunity_id' => $id, 'geo_location_id' => $locationId], []);
DB::table('audit_events')->insert(['source_system' => 'investment-coordinates', 'entity_type' => 'geo_geometries', 'entity_id' => $geometryId,
'actor_user_id' => $request->user()->id, 'action' => 'site_coordinates_saved',
'before_data' => $geometry ? json_encode(['version' => $geometry->version_no, 'geometry_hex' => $geometry->geom], JSON_THROW_ON_ERROR) : null,
'after_data' => json_encode(['opportunity_id' => $id, 'version' => $version, 'latitude' => $data['latitude'], 'longitude' => $data['longitude'], 'source_sha256' => hash('sha256', $data['source_reference'])], JSON_THROW_ON_ERROR)]);
return $version;
});
return response()->json(['version' => $version]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
@@ -24,6 +25,7 @@ class InvestmentDiscoveryController extends Controller
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -36,6 +38,15 @@ class InvestmentDiscoveryController extends Controller
private function territorial(array $filters): Collection
{
$query = InvestmentPortfolioController::visible();
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($query) use ($ids) {
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
}
});
}
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
return $query->whereRaw('false')->get();
}
@@ -7,6 +7,7 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
use App\Models\ValueChain;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -74,7 +75,19 @@ class InvestmentStructureController extends Controller
} else {
$rules['sector'] = ['required', 'string', 'max:200'];
}
PortalAudit::save($record, $request->validate($rules), $request->user()->id);
if (! $id) {
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
}
$data = $request->validate($rules);
if (! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
}
PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class InvestmentTerritoryController extends Controller
{
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
public function subjects(Request $request): JsonResponse
{
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
if (! empty($data['q'])) {
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy($column)->orderBy('id')->paginate(50, ['id', $column.' as name']));
}
public function options(Request $request): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
->whereIn('data_classification', ['public', 'internal']);
if ($data['type'] !== 'province') {
$query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
}
public function show(string $kind, string $id): JsonResponse
{
$table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
$path = [];
$current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) {
$location = DB::table('geo_locations')->where('id', $current)->first(['id', 'name', 'parent_id', 'location_type', 'deleted_at']);
if (! $location) {
break;
}
$path[] = $location;
$current = $location->parent_id;
}
return response()->json(['version' => $record->territory_version, 'path' => $path]);
}
public function save(Request $request, string $kind, string $id): JsonResponse
{
$table = self::TABLES[$kind] ?? abort(404);
if ($kind === 'zones') {
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$version = DB::transaction(function () use ($request, $table, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null;
$names = [];
foreach (array_values($data['path']) as $index => $locationId) {
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first();
if (! $location || $location->location_type !== ['province', 'district', 'admin_post', 'locality'][$index] || ($index > 0 && $location->parent_id !== $parent)) {
throw ValidationException::withMessages(['path' => 'Seleccione uma hierarquia territorial coerente e activa.']);
}
$parent = $locationId;
$names[] = $location->name;
}
$version = $data['version'] + 1;
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null;
}
if ($table === 'investment_zones') {
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id);
} else {
DB::table($table)->where('id', $id)->update($changes);
}
DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => 'primary_territory_saved',
'after_data' => json_encode(['record_id' => $id, 'territory_id' => $parent, 'version' => $version], JSON_THROW_ON_ERROR)]);
return $version;
});
return response()->json(['version' => $version]);
}
}
@@ -6,6 +6,7 @@ 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;
@@ -49,13 +50,18 @@ class InvestmentZoneController extends Controller
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], '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/*');
$query = $management ? InvestmentZone::query() : $zones->visible();
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
}
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management];
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
}
@@ -70,6 +76,15 @@ class InvestmentZoneController extends Controller
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
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);
$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);
}
@@ -6,6 +6,7 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
use Illuminate\Http\JsonResponse;
@@ -177,7 +178,20 @@ class PortalManagementController extends Controller
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities' && ! $id) {
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
}
$data = $request->validate($rules);
if ($resource === 'opportunities' && ! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$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;
}
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PublicGeographyController extends Controller
{
public function options(Request $request, InvestmentGeography $geography): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']);
if ($data['type'] !== 'province') {
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
}
}