|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 */ 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 */ 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 $before * @param array $input * @return array */ 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 $fields * @return array */ 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))]; } }