Files
Investidor-V2/app/Services/InvestmentZoneOperationalProfile.php
T

199 lines
11 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');
}
/**
* @return array<array-key, mixed>|null
*/
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;
}
/**
* @return array<array-key, mixed>
*/
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()];
}
/**
* @param array<array-key, mixed> $record
* @return array<array-key, mixed>
*/
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;
}
/**
* @param array<array-key, mixed> $after
* @param array<array-key, mixed> $before
* @return array<array-key, mixed>
*/
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.
* @param array<array-key, mixed> $data
*/
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();
}
/**
* @param array<array-key, mixed> $data
*/
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)]);
}
}