Files
Investidor-V2/app/Services/InvestmentZoneOperationalDetails.php
nala cf003c8623
tests / ci (push) Canceled after 0s
versao nrgocios 4
2026-10-18 15:57:06 +02:00

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