144 lines
10 KiB
PHP
144 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentZone;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Editorial preparation in the existing master. No operational/land entitlement is inferred. */
|
|
class InvestmentZoneOperationalProfile
|
|
{
|
|
public const MIGRATION = '2026_10_16_130000_add_investment_zone_editorial_draft';
|
|
public const FIELDS = ['code','category','name','name_en','description','description_en','province','district','operator_id','legal_source_id',
|
|
'physical_category','legal_regime','operational_status','development_status','availability_status','area_components','source_evidence',
|
|
'area_value','area_unit','aliases','activities','resources','data_as_of','sources_consulted_at','parent_zone_id','historical_territory'];
|
|
public const DETAIL_PUBLIC = ['id','name','status','source_reference','source_date','kind','scope','capacity_type','capacity_value','capacity_unit','focus','economic_activity_id'];
|
|
|
|
public function installed(): bool { return Schema::hasColumn('investment_zones', 'editorial_draft'); }
|
|
|
|
public function draft(InvestmentZone $zone): ?array
|
|
{
|
|
$raw = $zone->getRawOriginal('editorial_draft');
|
|
if (! $raw) { return null; }
|
|
try { $data = json_decode(Crypt::decryptString($raw), true, 512, JSON_THROW_ON_ERROR); }
|
|
catch (\Throwable $e) { throw ValidationException::withMessages(['editorial_draft'=>'Não foi possível recuperar a preparação privada. Os dados foram preservados; contacte o suporte.']); }
|
|
abort_unless(($data['schema_version'] ?? null) === 1 && is_array($data['fields'] ?? null), 409, 'Formato de preparação incompatível; os dados foram preservados.');
|
|
return $data;
|
|
}
|
|
|
|
public function workingVersion(InvestmentZone $zone): int { return (int) ($this->draft($zone)['working_version'] ?? $zone->version_no); }
|
|
|
|
public function nextVersion(InvestmentZone $zone): int
|
|
{
|
|
return max((int) $zone->version_no, (int) DB::table('investment_zone_revisions')->where('zone_id',$zone->id)->max('version_no')) + 1;
|
|
}
|
|
|
|
public function management(InvestmentZone $zone): array
|
|
{
|
|
$draft = $this->draft($zone);
|
|
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no'=>$draft['working_version'] ?? $zone->version_no,
|
|
'public_version_no'=>$zone->version_no, 'base_public_version'=>$draft['base_public_version'] ?? $zone->version_no,
|
|
'has_private_draft'=>$draft !== null, 'profile_available'=>$this->installed(),
|
|
'operational_details'=>($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
|
|
'details_available'=>app(InvestmentZoneOperationalDetails::class)->installed()];
|
|
}
|
|
|
|
public function publicDetails(array $record): array
|
|
{
|
|
unset($record['editorial_draft'], $record['operational_details']);
|
|
foreach (['activities','resources'] as $key) {
|
|
if (isset($record[$key])) { $record[$key] = array_map(fn ($item)=>array_intersect_key($item,array_flip(self::DETAIL_PUBLIC)), $record[$key]); }
|
|
}
|
|
return $record;
|
|
}
|
|
|
|
private function mergeDetails(string $zoneId,array $before, array $after): array
|
|
{
|
|
$result=[];
|
|
foreach ($after as $item) {
|
|
$old = collect($before)->first(fn($b)=>isset($item['id']) ? ($b['id'] ?? null)===$item['id'] : ($b['name'] ?? null)===($item['name'] ?? null));
|
|
$item=[...($old ?? []), ...$item];
|
|
$item['id'] ??= (string) Str::uuid();
|
|
if (isset($item['capacity_value']) && empty($item['capacity_unit'])) { throw ValidationException::withMessages(['resources'=>'Indique a unidade da capacidade declarada; pode deixar o valor desconhecido.']); }
|
|
if (!empty($item['economic_activity_id'])) {
|
|
abort_unless(trim((string)($item['source_reference'] ?? '')) !== '' && DB::table('economic_activities')->where('id',$item['economic_activity_id'])->where('data_classification','public')->whereNull('deleted_at')->exists(),422,'A correspondência CAE exige referência publicada e fundamento explícito.');
|
|
}
|
|
foreach ($item['support_document_ids'] ?? [] as $document) {
|
|
abort_unless(DB::table('investment_zone_attachments')->where('zone_id',$zoneId)->where('document_id',$document)->exists(),422,'Selecione suporte privado já associado a esta ficha.');
|
|
$d=DB::table('documents')->where('id',$document)->whereNull('deleted_at')->first();
|
|
abort_unless($d && $d->status==='active' && $d->malware_scan_status==='clean' && trim($d->sha256)===$d->malware_scanned_sha256,422,'Documento de suporte ainda não consultável.');
|
|
}
|
|
$result[]=$item;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** Called with zone row locked by InvestmentZones. Only this encrypted column is written. */
|
|
public function prepare(InvestmentZone $zone,array $data,int $actor): InvestmentZone
|
|
{
|
|
abort_unless($this->installed(),503,'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.');
|
|
abort_unless((int)$data['version_no']===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de guardar.');
|
|
$previous=$this->draft($zone);
|
|
$base=$previous['fields'] ?? array_intersect_key($zone->toArray(),array_flip(self::FIELDS));
|
|
$fields=[...$base,...array_intersect_key($data,array_flip(self::FIELDS))];
|
|
$details=app(InvestmentZoneOperationalDetails::class);
|
|
if (array_key_exists('operational_details',$data)) {
|
|
$fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null);
|
|
} elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) {
|
|
$fields['operational_details']=$details->recover($zone);
|
|
}
|
|
$details=app(InvestmentZoneOperationalDetails::class);
|
|
if (array_key_exists('operational_details',$data)) {
|
|
$fields['operational_details']=$details->merge($zone,$base['operational_details'] ?? $details->recover($zone),$data['operational_details'],$fields['operator_id'] ?? null);
|
|
} elseif ($details->stored($zone) && !array_key_exists('operational_details',$fields)) {
|
|
$fields['operational_details']=$details->recover($zone);
|
|
}
|
|
foreach (['activities','resources'] as $key) { if (array_key_exists($key,$data)) { $fields[$key]=$this->mergeDetails($zone->id,$base[$key] ?? [],$data[$key]); } }
|
|
$draft=['schema_version'=>1,'base_public_version'=>$previous['base_public_version'] ?? (int)$zone->version_no,
|
|
'working_version'=>$this->nextVersion($zone),'fields'=>$fields,'actor_id'=>$actor,'prepared_at'=>now()->toIso8601String()];
|
|
DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone,$draft['working_version'],$actor,'profile_prepared',['public'=>[...$zone->toArray(),'operational_details'=>$details->stored($zone)],'draft'=>$draft]);
|
|
return $zone->fresh();
|
|
}
|
|
|
|
/** Apply only inside the publication transaction; a failed validation rolls everything back. */
|
|
public function promote(InvestmentZone $zone,int $version): void
|
|
{
|
|
$draft=$this->draft($zone);
|
|
abort_unless($version===$this->workingVersion($zone),409,'A preparação mudou; recupere a versão atual antes de publicar.');
|
|
if (!$draft) { return; }
|
|
abort_unless((int)$draft['base_public_version']===(int)$zone->version_no,409,'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
|
|
$zone->fill(array_intersect_key($draft['fields'],array_flip(self::FIELDS)));
|
|
if (array_key_exists('operational_details',$draft['fields'])) {
|
|
$zone->setAttribute('operational_details',app(InvestmentZoneOperationalDetails::class)->pack($draft['fields']['operational_details']));
|
|
}
|
|
// Existing resource support remains in the encrypted snapshot; L1 private fields are durable.
|
|
foreach(['activities','resources'] as $key) { $zone->setAttribute($key,$this->publicDetails([$key=>$zone->$key ?? []])[$key]); }
|
|
$zone->setAttribute('editorial_draft',null);
|
|
}
|
|
|
|
public function reconcile(InvestmentZone $zone,int $working,int $public,int $actor): InvestmentZone
|
|
{
|
|
$draft=$this->draft($zone);
|
|
abort_unless($draft && $working===(int)$draft['working_version'] && $public===(int)$zone->version_no,409,'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
|
|
abort_if((int)$draft['base_public_version']===$public,422,'A preparação já corresponde à versão pública atual.');
|
|
$draft['base_public_version']=$public;$draft['working_version']=$this->nextVersion($zone);$draft['actor_id']=$actor;$draft['prepared_at']=now()->toIso8601String();
|
|
DB::table('investment_zones')->where('id',$zone->id)->update(['editorial_draft'=>Crypt::encryptString(json_encode($draft,JSON_THROW_ON_ERROR))]);
|
|
$this->snapshot($zone,$draft['working_version'],$actor,'profile_reconciled',['public'=>[...$zone->toArray(),'operational_details'=>app(InvestmentZoneOperationalDetails::class)->stored($zone)],'draft'=>$draft]);
|
|
return $zone->fresh();
|
|
}
|
|
|
|
public function snapshot(InvestmentZone $zone,int $version,int $actor,string $action,array $data): void
|
|
{
|
|
$json=json_encode($data,JSON_THROW_ON_ERROR);$hash=hash('sha256',$json);
|
|
DB::table('investment_zone_revisions')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'version_no'=>$version,
|
|
'actor_id'=>$actor,'action'=>$action,'snapshot'=>Crypt::encryptString($json),'sha256'=>$hash]);
|
|
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'=>$version,'sha256'=>$hash],JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|