Files
Investidor-V2/app/Services/InvestmentZones.php
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

193 lines
13 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 ($query): void {
$query->where(function ($legal): void {
$legal->whereIn('category', ['zee', 'zfi'])->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())));
})->orWhere(function ($informational): void {
$informational->whereNotIn('category', ['zee', 'zfi'])->whereRaw('jsonb_array_length(source_evidence) > 0')
->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(source_evidence) AS evidence WHERE evidence->>'url' LIKE 'https://%')");
});
});
}
/** @param array<string, mixed> $filters */
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.');
abort_if((int) $zone->editor_id === $actor, 403, 'A revisão exige outro gestor autorizado.');
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->lockForUpdate()->first();
if (in_array($zone->category, ['zee', 'zfi'], true)) {
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica válida e revista em falta para este regime.');
} else {
$evidence = is_array($zone->source_evidence) ? $zone->source_evidence : [];
abort_unless(collect($evidence)->contains(fn ($item) => in_array($item['source_type'] ?? null, ['government', 'institutional', 'promoter'], true)
&& str_starts_with((string) ($item['url'] ?? ''), 'https://')), 422, 'Fonte institucional ou do promotor com URL HTTPS em falta.');
}
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, 'Public, independently reviewed territory required.');
$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, 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)]);
}
}