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 */ 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 $filters * @return Builder */ 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 $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; if ($id && $zone->category === 'industrial_park') { $prepared=app(InvestmentZoneOperationalProfile::class)->prepare($zone,$data,$actor); $this->storePendingLocations($zone,$data['locations'] ?? [],$actor); return $prepared; } 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']); abort_if(array_key_exists('operational_details',$data),422,'Prepare L1 numa ficha de parque existente; não cria outro cadastro.'); $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(); $profile = app(InvestmentZoneOperationalProfile::class); if ($zone->category === 'industrial_park') { $profile->promote($zone,$version); } else { 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' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $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(); $profile=app(InvestmentZoneOperationalProfile::class); abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int)$zone->version_no) !== $version, 409, 'Versão alterada.'); $zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $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 = json_encode([...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],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)]); } /** Existing explicit territorial control: new associations remain pending, never promoted with the profile. */ private function storePendingLocations(InvestmentZone $zone,array $locations,int $actor): void { 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(), ]); } } }