201 lines
14 KiB
PHP
201 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentZone;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
|
|
class InvestmentZones
|
|
{
|
|
public function links(bool $territorial = false): \Illuminate\Database\Query\Builder
|
|
{
|
|
return DB::table(($territorial ? 'investment_zone_relations' : 'investment_zone_links').' as zl')->where('zl.validation_status', 'validated')
|
|
->whereIn('zl.zone_id', $this->visible()->select('investment_zones.id'))
|
|
->whereDate('zl.valid_from', '<=', today())->where(fn ($q) => $q->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today()))
|
|
->where(function ($q) {
|
|
$q->whereNull('zl.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
|
|
->whereColumn('ls.id', 'zl.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())));
|
|
});
|
|
}
|
|
|
|
/** @return Builder<InvestmentZone> */
|
|
public function visible(): Builder
|
|
{
|
|
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
|
return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated')
|
|
->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_legal_sources as source')
|
|
->whereColumn('source.id', 'investment_zones.legal_source_id')->where('source.validation_status', 'validated')
|
|
->whereDate('source.valid_from', '<=', today())
|
|
->where(fn ($q) => $q->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', today())));
|
|
}
|
|
|
|
return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated')
|
|
->where(function (Builder $query): void {
|
|
$query->where(function (Builder $evidence): void {
|
|
$evidence->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'source_type' IN ('government','institutional') AND evidence->>'url' LIKE 'https://%' AND length(trim(COALESCE(evidence->>'supports',''))) > 0)")
|
|
->orWhere(function (Builder $promoterEvidence): void {
|
|
// Industrial parks may be listed as informational projects on the promoter's own site.
|
|
// Their promoter source must remain visibly identified and does not validate legal status,
|
|
// authorized activities, operation, incentives, or land availability.
|
|
$promoterEvidence->where('category', 'industrial_park')
|
|
->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'source_type' = 'promoter' AND evidence->>'url' LIKE 'https://%' AND length(trim(COALESCE(evidence->>'supports',''))) > 0)");
|
|
});
|
|
})->orWhereExists(fn ($source) => $source->selectRaw('1')->from('investment_legal_sources as source')
|
|
->whereColumn('source.id', 'investment_zones.legal_source_id')->where('source.validation_status', 'validated')
|
|
->whereDate('source.valid_from', '<=', today())
|
|
->where(fn ($valid) => $valid->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', today())));
|
|
});
|
|
}
|
|
|
|
/** @param array<string, mixed> $filters
|
|
* @return Builder<InvestmentZone> */
|
|
public function search(array $filters, bool $management = false): Builder
|
|
{
|
|
$query = $management ? InvestmentZone::query() : $this->visible();
|
|
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
|
unset($filters['physical_category'], $filters['legal_regime'], $filters['development_status'], $filters['availability_status']);
|
|
}
|
|
$path = app(InvestmentGeography::class)->selection($filters);
|
|
if ($path !== []) {
|
|
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
|
$query->where(function ($matches) use ($territoryIds, $management): void {
|
|
$matches->whereIn('primary_territory_id', $territoryIds)
|
|
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('investment_zone_locations as zl')
|
|
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.geo_location_id', $territoryIds)
|
|
->where('zl.validation_status', 'validated')->where(fn ($valid) => $valid->whereNull('zl.valid_from')->orWhereDate('zl.valid_from', '<=', today()))
|
|
->where(fn ($valid) => $valid->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today())));
|
|
if (! $management) {
|
|
$matches->orWhereExists(fn ($geometry) => $geometry->selectRaw('1')->from('investment_zone_geometries as zg')
|
|
->join('geo_geometries as shape', 'shape.id', '=', 'zg.geometry_id')
|
|
->whereColumn('zg.zone_id', 'investment_zones.id')->where('zg.publication_authorized', true)
|
|
->where('zg.validation_status', 'validated')->whereNull('shape.deleted_at')
|
|
->where('shape.data_classification', 'public')->whereIn('shape.geo_location_id', $territoryIds));
|
|
}
|
|
});
|
|
}
|
|
|
|
return $query->when($filters['category'] ?? null, fn ($q, $value) => $q->where('category', $value))
|
|
->when($filters['physical_category'] ?? null, fn ($q, $value) => $q->where('physical_category', $value))
|
|
->when($filters['legal_regime'] ?? null, fn ($q, $value) => $q->where('legal_regime', $value))
|
|
->when($filters['development_status'] ?? null, fn ($q, $value) => $q->where('development_status', $value))
|
|
->when($filters['availability_status'] ?? null, fn ($q, $value) => $q->where('availability_status', $value))
|
|
->when($filters['activity'] ?? null, fn ($q, $value) => $q->whereRaw('activities @> ?::jsonb', [json_encode([['name' => $value]], JSON_THROW_ON_ERROR)]))
|
|
->when($filters['q'] ?? null, fn ($q, $value) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$value.'%')->orWhere('code', 'ilike', '%'.$value.'%')));
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function save(?string $id, array $data, int $actor): InvestmentZone
|
|
{
|
|
return DB::transaction(function () use ($id, $data, $actor) {
|
|
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
|
|
abort_if($id && (int) $zone->version_no !== (int) $data['version_no'], 409, 'Versão alterada. Recarregue antes de guardar.');
|
|
$version = $id ? (int) $zone->version_no + 1 : 1;
|
|
$locations = $data['locations'] ?? [];
|
|
unset($data['locations'], $data['version_no']);
|
|
$zone->fill([...$data, 'editor_id' => $actor, 'reviewer_id' => null, 'published_at' => null,
|
|
'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save();
|
|
foreach ($locations as $location) {
|
|
DB::table('investment_zone_locations')->insertOrIgnore([
|
|
'id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'geo_location_id' => $location['geo_location_id'],
|
|
'scope_type' => $location['scope_type'] ?? 'administrative', 'source_reference' => $location['source_reference'],
|
|
'legal_source_id' => $location['legal_source_id'] ?? null, 'validation_status' => 'pending',
|
|
'editor_id' => $actor, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
}
|
|
$this->snapshot($zone, $actor, 'saved');
|
|
|
|
return $zone;
|
|
});
|
|
}
|
|
|
|
public function publish(string $id, int $version, int $actor): InvestmentZone
|
|
{
|
|
return DB::transaction(function () use ($id, $version, $actor) {
|
|
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.');
|
|
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first();
|
|
$evidence = $zone->source_evidence ?? [];
|
|
$officialEvidence = collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional'], true)
|
|
&& str_starts_with((string) ($item['url'] ?? ''), 'https://') && trim((string) ($item['supports'] ?? '')) !== '');
|
|
$promoterEvidence = $zone->category === 'industrial_park' && collect($evidence)->contains(fn ($item) => ($item['source_type'] ?? null) === 'promoter'
|
|
&& str_starts_with((string) ($item['url'] ?? ''), 'https://') && trim((string) ($item['supports'] ?? '')) !== '');
|
|
if (in_array($zone->category, ['zee', 'zfi'], true)) {
|
|
$legalSourceCurrent = $source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
|
|
&& (! $source->valid_until || $source->valid_until >= today()->toDateString());
|
|
abort_unless($officialEvidence || $legalSourceCurrent, 422, 'Registe uma fonte oficial ou institucional HTTPS que sustente a identidade e o enquadramento informativo.');
|
|
} else {
|
|
abort_unless($officialEvidence || $promoterEvidence, 422, 'Registe uma fonte HTTPS identificada que sustente a identidade e a localização informativa.');
|
|
}
|
|
abort_unless($zone->category !== 'pending' && trim((string) $zone->name) !== ''
|
|
&& trim((string) $zone->description) !== '', 422, 'Identidade, categoria e descrição são obrigatórias.');
|
|
abort_unless($zone->physical_category && $zone->physical_category !== 'unknown', 422, 'Categoria física por confirmar.');
|
|
if (in_array($zone->category, ['zee', 'zfi'], true)) {
|
|
abort_unless($zone->legal_regime === $zone->category, 422, 'O regime jurídico da ZEE/ZFI deve estar confirmado.');
|
|
}
|
|
$publicLocation = $zone->primary_territory_id
|
|
&& app(InvestmentGeography::class)->publicReferences()->where('id', $zone->primary_territory_id)->exists();
|
|
$publicLocation = $publicLocation || DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
|
->where('zl.zone_id', $id)->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')->whereNull('g.deleted_at')
|
|
->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists();
|
|
abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.');
|
|
$zone->fill(['version_no' => $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
|
|
'validation_status' => 'validated', 'published_at' => now()])->save();
|
|
$this->snapshot($zone, $actor, 'published');
|
|
|
|
return $zone;
|
|
});
|
|
}
|
|
|
|
public function suspend(string $id, int $version, int $actor): InvestmentZone
|
|
{
|
|
return DB::transaction(function () use ($id, $version, $actor) {
|
|
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada.');
|
|
$zone->fill(['version_no' => $version + 1, 'publication_status' => 'suspended'])->save();
|
|
$this->snapshot($zone, $actor, 'suspended');
|
|
|
|
return $zone;
|
|
});
|
|
}
|
|
|
|
public function validateLocation(string $id, int $version, int $actor): object
|
|
{
|
|
return DB::transaction(function () use ($id, $version, $actor) {
|
|
$location = DB::table('investment_zone_locations')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($location !== null, 404);
|
|
abort_if((int) $location->version_no !== $version, 409, 'Version changed.');
|
|
abort_if((int) $location->editor_id === $actor, 403, 'Independent review required.');
|
|
abort_unless($location->validation_status === 'pending', 422, 'Territorial association already reviewed.');
|
|
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location->geo_location_id)->exists(), 422, 'Territory is not a public reference location.');
|
|
if ($location->legal_source_id) {
|
|
$source = DB::table('investment_legal_sources')->where('id', $location->legal_source_id)->first();
|
|
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
|
|
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Location source is not valid.');
|
|
}
|
|
DB::table('investment_zone_locations')->where('id', $id)->update(['validation_status' => 'validated', 'reviewer_id' => $actor,
|
|
'version_no' => $version + 1, 'updated_at' => now()]);
|
|
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_locations',
|
|
'entity_id' => $id, 'actor_user_id' => $actor, 'action' => 'validated',
|
|
'after_data' => json_encode(['version_no' => $version + 1], JSON_THROW_ON_ERROR)]);
|
|
|
|
return DB::table('investment_zone_locations')->where('id', $id)->first();
|
|
});
|
|
}
|
|
|
|
private function snapshot(InvestmentZone $zone, int $actor, string $action): void
|
|
{
|
|
$json = $zone->toJson(JSON_THROW_ON_ERROR);
|
|
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
|
|
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
|
|
'snapshot' => Crypt::encryptString($json), 'sha256' => hash('sha256', $json)]);
|
|
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones',
|
|
'entity_id' => $zone->id, 'actor_user_id' => $actor, 'action' => $action,
|
|
'after_data' => json_encode(['version_no' => $zone->version_no, 'sha256' => hash('sha256', $json)], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|