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

200 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/** Subordinate documentary records in the existing encrypted park envelope. */
class InvestmentZoneIntegralDetails
{
public const GROUPS = ['titles', 'phases', 'plots', 'occupants', 'observations', 'chains'];
public const EXTRA = [
'titles' => ['kind', 'identifier', 'valid_from', 'valid_until'],
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'],
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
public const COMMON = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', 'disclosure', 'private_note', 'support_document_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): array
{
abort_if(array_diff(array_keys($input), ['schema_version', 'remove_ids', ...self::GROUPS]) !== [], 422, 'Campo da ficha integral desconhecido.');
$rules = ['schema_version' => 'sometimes|in:1', 'remove_ids' => 'sometimes|array:'.implode(',', self::GROUPS)];
foreach (self::GROUPS as $group) {
$rules[$group] = 'sometimes|array|max:200';
$rules[$group.'.*'] = 'array:'.implode(',', [...self::COMMON, ...self::EXTRA[$group]]);
foreach (['id' => 'nullable|uuid', 'label' => 'required|string|max:300', 'label_en' => 'nullable|string|max:300', 'description' => 'nullable|string|max:3000', 'description_en' => 'nullable|string|max:3000', 'knowledge' => 'required|in:unknown,documented,disputed', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'scope' => 'nullable|string|max:500', 'disclosure' => 'required|boolean', 'private_note' => 'nullable|string|max:4000', 'support_document_ids' => 'sometimes|array|max:20', 'support_document_ids.*' => 'uuid'] as $key => $rule) {
$rules[$group.'.*.'.$key] = $rule;
}
foreach (self::EXTRA[$group] as $key) {
$rules[$group.'.*.'.$key] = match ($key) {
'organization_id','phase_id','plot_id','zone_link_id','geometry_id' => 'nullable|uuid',
'area' => 'nullable|numeric|min:0|max:999999999999999',
'value' => 'nullable|numeric|between:-999999999999999,999999999999999',
'starts_at','ends_at','valid_from','valid_until','period_start','period_end' => 'nullable|date_format:Y-m-d',
'expansion' => 'nullable|boolean',
default => 'nullable|string|max:2000',
};
}
$rules['remove_ids.'.$group] = 'sometimes|array|max:200';
$rules['remove_ids.'.$group.'.*'] = 'uuid';
}
$rules['plots.*.area_unit'] = 'nullable|in:ha,m2';
$rules['plots.*.status'] = 'nullable|in:unknown,planned,available_declared,reserved,occupied,construction,inactive';
$rules['phases.*.status'] = 'nullable|in:unknown,planned,construction,operational,suspended,closed';
$rules['occupants.*.status'] = 'nullable|in:unknown,approved,construction,installed,inactive';
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
$rows = collect(StructuredData::collectionItems($before[$group] ?? []))->keyBy('id')->all();
$remove = $data['remove_ids'][$group] ?? [];
$seen = [];
foreach ($data[$group] ?? [] as $item) {
$id = $item['id'] ?? (string) Str::uuid();
abort_if(in_array($id, $seen, true) || in_array($id, $remove, true), 422, 'Identificador repetido ou removido na mesma operação.');
$seen[] = $id;
$item = [...($rows[$id] ?? []), ...$item, 'id' => $id];
if ($item['knowledge'] !== 'unknown') {
abort_unless(trim($item['source_reference'] ?? '') !== '', 422, 'Indique a fonte documental ou preserve como desconhecido.');
}
if ($item['knowledge'] === 'unknown') {
abort_if(isset($item['value']) || isset($item['area']), 422, 'Quantidade desconhecida deve permanecer vazia, não zero.');
}
if ($item['knowledge'] === 'unknown' && ! empty($item['status'])) {
abort_unless($item['status'] === 'unknown', 422, 'Situação operacional declarada exige fonte ou indicação de divergência.');
}
foreach ([['starts_at', 'ends_at'], ['valid_from', 'valid_until'], ['period_start', 'period_end']] as [$a,$b]) {
if (isset($item[$a],$item[$b])) {
abort_if($item[$b] < $item[$a], 422, 'Período final anterior ao início.');
}
}
if ($group === 'plots' && isset($item['area'])) {
abort_unless(! empty($item['area_unit']) && ! empty($item['scope']), 422, 'Área declarada exige unidade e âmbito.');
}
if ($group === 'plots' && ($item['status'] ?? '') === 'available_declared') {
abort_unless($item['knowledge'] === 'documented' && ! empty($item['source_date']), 422, 'Disponibilidade declarada exige fonte e data; não é disponibilidade em tempo real.');
}
if ($group === 'observations' && isset($item['value'])) {
abort_unless(! empty($item['unit']) && ! empty($item['period_start']) && ! empty($item['period_end']) && ! empty($item['scope']) && ! empty($item['population']), 422, 'Indicador numérico exige unidade, período, âmbito e base/população.');
}
if (! empty($item['organization_id'])) {
abort_unless(DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->exists(), 422, 'Reutilize uma entidade existente.');
}
if (! empty($item['geometry_id'])) {
abort_unless(DB::table('investment_zone_geometries')->where('zone_id', $zone->id)->where('geometry_id', $item['geometry_id'])->exists(), 422, 'Geometria deve estar associada a este parque; limite administrativo não é perímetro.');
}
if (! empty($item['zone_link_id'])) {
abort_unless(DB::table('investment_zone_links')->where('zone_id', $zone->id)->where('id', $item['zone_link_id'])->exists(), 422, 'Relação económica deve pertencer a este parque.');
}
foreach ($item['support_document_ids'] ?? [] as $doc) {
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', $doc)->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->exists(), 422, 'Suporte deve estar consultável e associado a este parque.');
}
$rows[$id] = $item;
}
foreach ($remove as $id) {
abort_unless(isset($rows[$id]), 422, 'O detalhe a remover não pertence ao parque.');
unset($rows[$id]);
}
abort_if(count($rows) > 200, 422, 'Limite de detalhes excedido.');
$result[$group] = array_values($rows);
}
// References resolve only inside this park, including new items in the same transaction.
foreach (self::GROUPS as $group) {
foreach ($result[$group] as $item) {
foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) {
if (! empty($item[$key])) {
abort_unless(in_array($item[$key], array_column($result[$target], 'id'), true), 422, 'Referência interna inexistente ou de outro parque.');
}
}
if (! empty($item['phase_id']) && ! empty($item['plot_id'])) {
$plot = collect($result['plots'])->firstWhere('id', $item['plot_id']);
abort_if(! empty($plot['phase_id']) && $plot['phase_id'] !== $item['phase_id'], 422, 'Lote e fase não correspondem.');
}
}
}
foreach (['phases', 'plots'] as $group) {
$codes = [];
foreach ($result[$group] as $item) {
if (trim($item['code'] ?? '') !== '') {
$code = mb_strtolower(trim($item['code']));
abort_if(in_array($code, $codes, true), 422, 'Código interno repetido no parque.');
$codes[] = $code;
}
}
}
$keys = [];
foreach ($result['occupants'] as $item) {
$key = implode('|', [$item['organization_id'] ?? $item['documentary_name'] ?? $item['id'], $item['plot_id'] ?? '', $item['valid_from'] ?? '', $item['valid_until'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'Ocupante/período já registado; reutilize o detalhe.');
$keys[] = $key;
}
$keys = [];
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
return [...$result, 'schema_version' => 1];
}
/**
* @param array<array-key, mixed> $fields
* @return array<array-key, mixed>
*/
public function publicData(array $fields): array
{
$public = ['schema_version' => 1];
foreach (self::GROUPS as $group) {
$public[$group] = [];
foreach ($fields[$group] ?? [] as $item) {
if (! ($item['disclosure'] ?? false)) {
continue;
}
$keys = ['id', 'label', 'label_en', 'description', 'description_en', 'knowledge', 'source_reference', 'source_date', 'scope', ...self::EXTRA[$group]];
$visible = array_intersect_key($item, array_flip($keys));
unset($visible['organization_id'],$visible['documentary_name'],$visible['geometry_id'],$visible['zone_link_id']);
if (in_array($group, ['occupants', 'chains'], true) && ! empty($item['organization_id'])) {
$org = DB::table('organizations')->where('id', $item['organization_id'])->whereNull('deleted_at')->where('data_classification', 'public')->first();
abort_unless($org && $item['knowledge'] === 'documented', 422, 'Divulgar entidade exige cadastro público e fonte; a menção preliminar permanece privada.');
$visible['organization_name'] = $org->name;
}
if ($group === 'occupants') {
abort_unless(! empty($visible['organization_name']), 422, 'Ocupante por identificar permanece privado.');
}
$public[$group][] = $visible;
}
}
// A disclosed item must not leak the identifier of an undisclosed phase or plot.
foreach (self::GROUPS as $group) {
foreach ($public[$group] as &$item) {
foreach (['phase_id' => 'phases', 'plot_id' => 'plots'] as $key => $target) {
if (! empty($item[$key]) && ! in_array($item[$key], array_column($public[$target], 'id'), true)) {
unset($item[$key]);
}
}
}
}unset($item);
return $public;
}
}