133 lines
11 KiB
PHP
133 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentZone;
|
|
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'); }
|
|
|
|
public function stored(InvestmentZone $zone): ?array
|
|
{
|
|
$raw = $zone->getRawOriginal('operational_details');
|
|
return $raw ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : null;
|
|
}
|
|
|
|
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.']);
|
|
}
|
|
}
|
|
|
|
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. */
|
|
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($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' && trim($item['source_reference'] ?? '')!=='',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;
|
|
}
|
|
|
|
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))];
|
|
}
|
|
}
|