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

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))];
}
}