203 lines
12 KiB
PHP
203 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentZone;
|
|
use App\Support\StructuredData;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** L1 supplement in the same park master; publication never implies land or commercial entitlement. */
|
|
class InvestmentZoneOperationalDetails
|
|
{
|
|
public const MIGRATION = '2026_10_16_140000_add_investment_zone_operational_details';
|
|
|
|
public const GROUPS = ['responsibilities', 'conditions', 'risks'];
|
|
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasColumn('investment_zones', 'operational_details');
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>|null
|
|
*/
|
|
public function stored(InvestmentZone $zone): ?array
|
|
{
|
|
$raw = $zone->getRawOriginal('operational_details');
|
|
|
|
return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function recover(InvestmentZone $zone): array
|
|
{
|
|
$stored = $this->stored($zone);
|
|
if (! $stored) {
|
|
return ['schema_version' => 1, 'responsibilities' => [], 'conditions' => [], 'risks' => [], 'review' => []];
|
|
}
|
|
try {
|
|
if (($stored['schema_version'] ?? null) !== 1) {
|
|
throw new \RuntimeException('Unsupported supplement');
|
|
}
|
|
|
|
return json_decode(Crypt::decryptString($stored['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\Throwable $e) {
|
|
throw ValidationException::withMessages(['operational_details' => 'Não foi possível recuperar os detalhes privados. Os dados foram preservados; contacte o suporte.']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function publicData(InvestmentZone $zone): array
|
|
{
|
|
return $this->stored($zone)['public'] ?? [];
|
|
}
|
|
|
|
/** Merge only supplied keys. An absent group/item is preserved; removal requires explicit IDs.
|
|
* @param array<array-key, mixed> $before
|
|
* @param array<array-key, mixed> $input
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function merge(InvestmentZone $zone, array $before, array $input, ?string $operatorId): array
|
|
{
|
|
abort_unless($zone->category === 'industrial_park', 422, 'O complemento L1 aplica-se à ficha de parques.');
|
|
abort_unless($this->installed(), 503, 'Detalhes L1 ainda não instalados. A ficha pública foi preservada.');
|
|
$rules = ['integral' => 'sometimes|array', 'schema_version' => 'sometimes|in:1', 'responsibilities' => 'sometimes|array|max:40', 'conditions' => 'sometimes|array|max:60', 'risks' => 'sometimes|array|max:40',
|
|
'remove_ids' => 'sometimes|array:responsibilities,conditions,risks', 'review' => 'sometimes|array:source_reference,source_date,scope,confidence,last_reviewed_at,next_review_at,private_note',
|
|
'review.confidence' => 'nullable|in:unknown,reported,documented,disputed', 'review.last_reviewed_at' => 'nullable|date_format:Y-m-d', 'review.next_review_at' => 'nullable|date_format:Y-m-d', 'review.private_note' => 'nullable|string|max:4000'];
|
|
$common = 'id,label,knowledge,disclosure,source_reference,source_date,scope,private_note,support_document_ids';
|
|
foreach (self::GROUPS as $g) {
|
|
$extra = $g === 'responsibilities' ? ',role,organization_id,documentary_name,private_contact,public_contact,declared_nature,management_model' : ($g === 'conditions' ? ',value,currency,unit,period,valid_from,valid_until,modality' : ',risk_type,mitigation,responsible_role,channel,channel_owner');
|
|
$rules[$g.'.*'] = ['array:'.$common.$extra];
|
|
foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'knowledge' => 'required|in:unknown,documented,disputed', 'disclosure' => 'required|boolean',
|
|
'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'private_note' => 'nullable|string|max:4000',
|
|
'support_document_ids' => 'sometimes|array|max:10', 'support_document_ids.*' => 'uuid'] as $k => $r) {
|
|
$rules[$g.'.*.'.$k] = $r;
|
|
}
|
|
$rules['remove_ids.'.$g] = 'sometimes|array|max:60';
|
|
$rules['remove_ids.'.$g.'.*'] = 'uuid';
|
|
}
|
|
foreach (['source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500'] as $k => $r) {
|
|
$rules['review.'.$k] = $r;
|
|
}
|
|
foreach (['role' => 'required|in:owner,promoter,operator,manager', 'organization_id' => 'nullable|uuid', 'documentary_name' => 'nullable|string|max:300', 'private_contact' => 'nullable|string|max:1000', 'public_contact' => 'nullable|string|max:1000', 'declared_nature' => 'nullable|string|max:300', 'management_model' => 'nullable|string|max:500'] as $k => $r) {
|
|
$rules['responsibilities.*.'.$k] = $r;
|
|
}
|
|
foreach (['value' => 'nullable|numeric|min:0', 'currency' => 'nullable|in:MZN,USD,EUR,ZAR', 'unit' => 'nullable|string|max:80', 'period' => 'nullable|string|max:80', 'valid_from' => 'nullable|date_format:Y-m-d', 'valid_until' => 'nullable|date_format:Y-m-d', 'modality' => 'nullable|string|max:300'] as $k => $r) {
|
|
$rules['conditions.*.'.$k] = $r;
|
|
}
|
|
foreach (['risk_type' => 'nullable|string|max:300', 'mitigation' => 'nullable|string|max:2000', 'responsible_role' => 'nullable|in:owner,promoter,operator,manager,unknown', 'channel' => 'nullable|string|max:1000', 'channel_owner' => 'nullable|in:park,tempo,unknown'] as $k => $r) {
|
|
$rules['risks.*.'.$k] = $r;
|
|
}
|
|
abort_if(array_diff(array_keys($input), ['schema_version', ...self::GROUPS, 'review', 'remove_ids', 'integral']) !== [], 422, 'Campo L1 não reconhecido.');
|
|
$data = Validator::make($input, $rules)->validate();
|
|
$result = $before;
|
|
foreach (self::GROUPS as $g) {
|
|
$rows = collect(StructuredData::collectionItems($before[$g] ?? []))->keyBy('id')->all();
|
|
$removals = $data['remove_ids'][$g] ?? [];
|
|
$seen = [];
|
|
foreach ($data[$g] ?? [] as $item) {
|
|
$id = $item['id'] ?? (string) Str::uuid();
|
|
abort_if(in_array($id, $seen, true) || in_array($id, $removals, true), 422, 'Identificador L1 repetido ou removido na mesma operação.');
|
|
$seen[] = $id;
|
|
$item = [...($rows[$id] ?? []), ...$item, 'id' => $id];
|
|
if ($item['knowledge'] === 'unknown') {
|
|
abort_if(isset($item['value']), 422, 'Valor desconhecido deve permanecer vazio, nunca zero por defeito.');
|
|
} else {
|
|
abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Identifique a fonte da informação documentada ou divergente.');
|
|
}
|
|
if ($g === 'conditions' && isset($item['value'])) {
|
|
abort_unless(trim($item['currency'] ?? '') !== '' && trim($item['unit'] ?? '') !== '' && trim($item['scope'] ?? '') !== '', 422, 'Uma tarifa numérica exige moeda, unidade/base e âmbito; pode manter o valor desconhecido.');
|
|
}
|
|
if ($g === 'conditions' && isset($item['valid_from'],$item['valid_until'])) {
|
|
abort_if($item['valid_until'] < $item['valid_from'], 422, 'O fim da vigência é anterior ao início; preserve datas desconhecidas como desconhecidas.');
|
|
}
|
|
if ($g === 'risks' && ! empty($item['channel']) && ($item['channel_owner'] ?? 'unknown') === 'park') {
|
|
abort_unless($item['knowledge'] === 'documented', 422, 'O canal próprio do parque exige fonte; pode manter o canal desconhecido ou usar o apoio da Tempo identificado como tal.');
|
|
}
|
|
if ($g === 'responsibilities') {
|
|
$org = $item['organization_id'] ?? null;
|
|
if ($org) {
|
|
abort_unless(DB::table('organizations')->where('id', $org)->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente ou mantenha a menção documental preliminar.');
|
|
}
|
|
if ($item['role'] === 'operator' && $org) {
|
|
$operator = $operatorId;
|
|
abort_unless($operator === $org, 422, 'O operador é a associação canónica da ficha; não crie outro no suplemento.');
|
|
}
|
|
}
|
|
foreach ($item['support_document_ids'] ?? [] as $document) {
|
|
abort_unless(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')->where('a.zone_id', $zone->id)->where('d.id', $document)
|
|
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Selecione suporte consultável já associado à ficha; a publicação do anexo é independente.');
|
|
}
|
|
$rows[$id] = $item;
|
|
}
|
|
foreach ($removals as $id) {
|
|
abort_unless(isset($rows[$id]), 422, 'Detalhe a remover não pertence à ficha.');
|
|
unset($rows[$id]);
|
|
}
|
|
abort_if(count($rows) > ($g === 'conditions' ? 60 : 40), 422, 'Limite de detalhes por grupo excedido.');
|
|
$result[$g] = array_values($rows);
|
|
}
|
|
$pairs = [];
|
|
foreach ($result['responsibilities'] as $item) {
|
|
$key = $item['role'].'|'.($item['organization_id'] ?? $item['documentary_name'] ?? $item['id']);
|
|
abort_if(in_array($key, $pairs, true), 422, 'A mesma entidade/função já está registada; reutilize o detalhe existente.');
|
|
$pairs[] = $key;
|
|
}
|
|
if (array_key_exists('review', $data)) {
|
|
$result['review'] = [...($before['review'] ?? []), ...$data['review']];
|
|
}
|
|
if (array_key_exists('integral', $data)) {
|
|
$result['integral'] = app(InvestmentZoneIntegralDetails::class)->merge($zone, $before['integral'] ?? [], $data['integral']);
|
|
}
|
|
$result['schema_version'] = 1;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $fields
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function pack(array $fields): array
|
|
{
|
|
$public = [];
|
|
foreach (self::GROUPS as $g) {
|
|
$public[$g] = [];
|
|
foreach ($fields[$g] ?? [] as $item) {
|
|
if (! ($item['disclosure'] ?? false)) {
|
|
continue;
|
|
}
|
|
$keys = ['label', 'knowledge', 'source_reference', 'source_date', 'scope'];
|
|
if ($g === 'conditions') {
|
|
$keys = [...$keys, 'value', 'currency', 'unit', 'period', 'valid_from', 'valid_until', 'modality'];
|
|
}
|
|
if ($g === 'risks') {
|
|
$keys = [...$keys, 'risk_type', 'mitigation', 'responsible_role', 'channel', 'channel_owner'];
|
|
}
|
|
$visible = array_intersect_key($item, array_flip($keys));
|
|
if ($g === 'responsibilities') {
|
|
$org = empty($item['organization_id']) ? null : DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first();
|
|
abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgação de função exige entidade pública existente e fundamento; menção preliminar continua privada.');
|
|
$visible = [...$visible, 'role' => $item['role'], 'organization_name' => $org->name, 'public_contact' => $item['public_contact'] ?? null, 'declared_nature' => $item['declared_nature'] ?? null, 'management_model' => $item['management_model'] ?? null];
|
|
}
|
|
$public[$g][] = $visible;
|
|
}
|
|
}
|
|
if (isset($fields['integral'])) {
|
|
$public['integral'] = app(InvestmentZoneIntegralDetails::class)->publicData($fields['integral']);
|
|
}
|
|
$public['review'] = array_intersect_key($fields['review'] ?? [], array_flip(['source_reference', 'source_date', 'scope', 'confidence', 'last_reviewed_at', 'next_review_at']));
|
|
|
|
return ['schema_version' => 1, 'public' => $public, 'private_ciphertext' => Crypt::encryptString(json_encode($fields, JSON_THROW_ON_ERROR))];
|
|
}
|
|
}
|