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