'O que existe', 'needs' => 'Necessidades identificadas', 'ideas' => 'Ideias de investimento']; public function draft(array $input, ?Opportunity $record = null, ?\App\Models\User $actor = null): array { $d = Validator::make(['proactive' => $input], [ 'proactive' => 'required|array:version,kind,evidence,need,idea,relations,supply_comparisons', 'proactive.version' => 'required|integer|in:1', 'proactive.kind' => 'required|in:exists,needs,ideas', 'proactive.evidence' => 'present|array|max:60', 'proactive.evidence.*' => 'array:id,category,purpose,statement,interpretation,source,locator,period,scope,coverage,verified_on,access,document_id,observation_id', 'proactive.evidence.*.observation_id'=>'sometimes|nullable|string|size:64', 'proactive.supply_comparisons'=>'sometimes|array|max:40', 'proactive.evidence.*.id' => 'required|uuid|distinct', 'proactive.evidence.*.category' => 'required|in:activity,resource,infrastructure,supply,demand', 'proactive.evidence.*.purpose' => 'required|in:local,methodology', 'proactive.evidence.*.access' => 'required|in:internal,public', 'proactive.evidence.*.document_id' => 'nullable|uuid', 'proactive.evidence.*.verified_on' => 'nullable|date_format:Y-m-d|before_or_equal:today', ...collect(['statement','interpretation','source','locator','period','scope','coverage'])->mapWithKeys(fn ($k) => ['proactive.evidence.*.'.$k => 'present|nullable|string|max:4000'])->all(), 'proactive.need' => 'required|array:state,problem,observed_supply,observed_demand,type,clients,evidence_ids,limitations,missing,confirmation', 'proactive.need.state' => 'required|in:hypothesis,confirmed', 'proactive.need.type' => 'nullable|in:quantity,quality,logistics,processing,coordination,support', ...collect(['problem','observed_supply','observed_demand','clients','limitations','missing','confirmation'])->mapWithKeys(fn ($k) => ['proactive.need.'.$k => 'present|nullable|string|max:6000'])->all(), 'proactive.need.evidence_ids' => 'present|array|max:60', 'proactive.need.evidence_ids.*' => 'uuid|distinct', 'proactive.idea' => 'required|array:solution,activity,chain_id,stage,coverage,mode,resources,assumptions,conditions,pending,next_action', 'proactive.idea.chain_id' => ['nullable','uuid',Rule::exists('value_chains','id')->whereNull('deleted_at')], 'proactive.idea.mode' => 'nullable|in:create,expand,associate', ...collect(['solution','activity','stage','coverage','resources','assumptions','conditions','pending','next_action'])->mapWithKeys(fn ($k) => ['proactive.idea.'.$k => 'present|nullable|string|max:6000'])->all(), 'proactive.relations' => 'present|array|max:40', 'proactive.relations.*' => 'array:id,kind,target_id,relation,reference,evidence_ids', 'proactive.relations.*.id' => 'required|uuid|distinct', 'proactive.relations.*.kind' => 'required|in:project,cluster,corridor,park,chain', 'proactive.relations.*.target_id' => 'required|uuid', 'proactive.relations.*.relation' => 'required|in:documented,hypothesis,proximity', 'proactive.relations.*.reference' => 'required|string|max:4000', 'proactive.relations.*.evidence_ids' => 'present|array|max:60', 'proactive.relations.*.evidence_ids.*' => 'uuid|distinct', ])->validate()['proactive']; $actor??=auth()->user(); if (!empty($d['supply_comparisons'])) $d['supply_comparisons']=app(ProactiveSupply::class)->comparisons($actor,$d['supply_comparisons'],$record); foreach($d['evidence'] as &$proof){ if(!empty($proof['observation_id'])){ $canonical=app(ProactiveSupply::class)->evidence($actor,$proof['observation_id'],$record); if($proof['access']!=='internal') $this->fail('evidence','A observação da base interna não pode ser reclassificada como pública.'); $proof=array_replace($proof,$canonical); } } unset($proof); foreach($d['supply_comparisons']??[] as $comparison){ if(!in_array($comparison['observation_id'],array_column($d['evidence'],'observation_id'),true))$this->fail('supply_comparisons','A comparação deve conservar a evidência interna da observação seleccionada.'); } $ids = array_column($d['evidence'], 'id'); foreach ([$d['need'], ...$d['relations']] as $row) { if (array_diff($row['evidence_ids'], $ids)) $this->fail('evidence', 'Seleccione apenas evidências desta análise.'); } $seen = []; foreach ($d['evidence'] as $e) { $key = mb_strtolower(trim(($e['source'] ?? '').'|'.($e['locator'] ?? '').'|'.($e['statement'] ?? ''))); if (trim($key, '| ') !== '' && isset($seen[$key])) $this->fail('evidence', 'A mesma evidência já foi registada; edite a entrada existente.'); $seen[$key] = true; if (! empty($e['document_id'])) $this->document($record, $e['document_id'], $e['access'] === 'public'); } $seen = []; foreach ($d['relations'] as $row) { $target = app(OpportunityClassification::class)->targets($row['kind'])->where('id', $row['target_id'])->exists(); if (! $target) $this->fail('relations', 'Referência existente indisponível; não foi criada outra empresa, zona ou estrutura.'); $key = $row['kind'].'|'.$row['target_id']; if (isset($seen[$key])) $this->fail('relations', 'A referência já está nesta análise; reúna o fundamento na entrada existente.'); $seen[$key] = true; if ($row['relation'] === 'documented' && ! $row['evidence_ids']) $this->fail('relations', 'Relação documentada exige evidência seleccionada; mantenha hipótese ou proximidade quando não demonstrada.'); } if ($d['kind'] !== 'exists' && $d['need']['state'] === 'confirmed') { foreach (['problem','observed_supply','observed_demand','type','clients','confirmation'] as $field) { if (trim($d['need'][$field] ?? '') === '') $this->fail('need.'.$field, 'Confirmação exige observação e fundamento concretos; pode guardar como hipótese.'); } if (! $d['need']['evidence_ids']) $this->fail('need.evidence_ids', 'Uma hipótese não é confirmada sem evidência seleccionada.'); foreach ($d['evidence'] as $e) { if (! in_array($e['id'], $d['need']['evidence_ids'], true)) continue; if ($e['purpose'] !== 'local') $this->fail('need.evidence_ids', 'Uma referência metodológica internacional não confirma oferta ou procura local. Preserve a hipótese e recolha evidência local.'); foreach (['statement','source','locator','period','scope','coverage','verified_on'] as $field) { if (trim($e[$field] ?? '') === '') $this->fail('evidence', 'Evidência da necessidade confirmada: faltam facto, fonte, localizador, período, abrangência, cobertura ou data de verificação.'); } } } return $d; } private function document(?Opportunity $record, string $id, bool $public): void { $row = $record?->exists ? DB::table('opportunity_documents as link')->join('documents as d','d.id','=','link.document_id') ->where('link.opportunity_id', $record->id)->where('d.id', $id)->whereNull('link.deleted_at')->first(['d.data_classification','d.status','d.malware_scan_status','d.sha256','d.malware_scanned_sha256','d.rights_status','link.payload','link.deleted_at']) : null; if (! $row) $this->fail('evidence', 'Documento não associado à ficha neste âmbito; reutilize o gestor documental existente.'); if ($public && ($row->data_classification !== 'public' || $row->status !== 'active' || $row->malware_scan_status !== 'clean' || $row->sha256 !== $row->malware_scanned_sha256 || $row->rights_status === 'restricted' || data_get(json_decode($row->payload, true), 'visibility') !== 'public')) { $this->fail('evidence', 'Documento interno, restrito ou sem disponibilização pública: preserve a evidência interna, sem a divulgar.'); } } /** No private field is copied into the public summary automatically. */ public function publication(Opportunity $record, array $input): ?array { $private = app(OpportunityManagement::class)->values($record)['proactive'] ?? null; if (! $private) { if (isset($input['proactive_public'])) $this->fail('publication', 'Prepare a análise antes de divulgar esta síntese.'); return null; } $d = Validator::make($input, [ 'proactive_reviewed' => 'required|accepted', 'proactive_public' => 'required|array:group,state,need,proposal,basis,limitations,next_action,evidence_ids,chain_id,stage,resources,assumptions,conditions,relation_ids', 'proactive_public.group' => 'required|in:exists,needs,ideas', 'proactive_public.resources'=>'nullable|string|max:2500', 'proactive_public.assumptions'=>'nullable|string|max:2500', 'proactive_public.conditions'=>'nullable|string|max:2500', 'proactive_public.relation_ids'=>'sometimes|array|max:40', 'proactive_public.relation_ids.*'=>'uuid|distinct', 'proactive_public.state' => 'required|in:hypothesis,confirmed', 'proactive_public.chain_id' => 'nullable|uuid', 'proactive_public.stage' => 'nullable|string|max:1000', 'proactive_public.need'=>'required_unless:proactive_public.group,exists|nullable|string|max:2500', 'proactive_public.proposal'=>'required_if:proactive_public.group,ideas|nullable|string|max:2500', ...collect(['basis','limitations','next_action'])->mapWithKeys(fn ($k) => ['proactive_public.'.$k => 'required|string|max:2500'])->all(), 'proactive_public.evidence_ids' => 'present|array|max:60', 'proactive_public.evidence_ids.*' => 'uuid|distinct', ])->validate()['proactive_public']; $d += ['need'=>'','proposal'=>'']; if ($d['group'] !== $private['kind']) $this->fail('publication', 'O grupo público deve corresponder à análise guardada.'); $proofs = []; foreach ($d['evidence_ids'] as $id) { $e = collect($private['evidence'])->firstWhere('id', $id); if (! $e || $e['access'] !== 'public') $this->fail('publication', 'Não pode divulgar evidência interna nem a sua fonte indirectamente. Redija uma síntese independente ou seleccione evidência pública autorizada.'); if (! empty($e['document_id'])) $this->document($record, $e['document_id'], true); $proofs[] = array_intersect_key($e, array_flip(['id','category','purpose','statement','source','locator','period','scope','coverage','verified_on'])); } if ($d['group'] !== 'exists' && $d['state'] === 'confirmed' && ($private['need']['state'] !== 'confirmed' || array_diff($private['need']['evidence_ids'], $d['evidence_ids']))) $this->fail('publication', 'Confirmação pública exige a necessidade confirmada e o fundamento público seleccionado; pode divulgar uma hipótese com limitações explícitas.'); if (!empty($d['chain_id']) && !app(OpportunityClassification::class)->targets('chain')->where('id', $d['chain_id'])->exists()) $this->fail('publication', 'Cadeia de valor pública indisponível.'); $relations=[]; foreach ($d['relation_ids'] ?? [] as $id) { $r=collect($private['relations'])->firstWhere('id',$id); if (!$r || array_diff($r['evidence_ids'],$d['evidence_ids'])) $this->fail('publication','A relação exige o fundamento público seleccionado; nenhuma referência interna é copiada.'); $target=app(OpportunityClassification::class)->targets($r['kind'])->where('id',$r['target_id'])->first(['id',OpportunityClassification::TARGETS[$r['kind']][1].' as name']); if (!$target) $this->fail('publication','Referência pública da relação indisponível.'); $relations[]=['kind'=>$r['kind'],'target_id'=>$r['target_id'],'label'=>$target->name,'relationship'=>$r['relation'],'evidence_ids'=>$r['evidence_ids'],'basis'=>$d['basis']]; } // Catch literal reuse of private statements, interpretations, references and document identities. $publicText = mb_strtolower(implode(' ', [...array_filter($input, 'is_scalar'), ...array_filter($d, 'is_scalar')])); foreach ($private['evidence'] as $e) { if ($e['access'] === 'public') continue; foreach (['statement','interpretation','source','locator','document_id'] as $key) { $value = trim($e[$key] ?? ''); if (mb_strlen($value) >= 10 && str_contains($publicText, mb_strtolower($value))) $this->fail('publication', 'O resumo contém informação de evidência interna. Reveja a síntese e a divulgação indirecta antes de publicar.'); } } return [...array_map(fn ($v) => is_string($v) ? trim(strip_tags($v)) : $v, $d), 'version' => self::VERSION, 'relations'=>$relations, 'evidence' => $proofs, 'notice' => self::NOTICE, 'availability_confirmed' => false, 'economic_validation' => false]; } public function publicData(Opportunity $record): ?array { $p = data_get($record->workflow_context, 'promotional_summary.projection.proactive'); return is_array($p) && Opportunity::published()->whereKey($record->id)->exists() ? $p : null; } public function publicationRelations(Opportunity $record): array { return collect(app(OpportunityManagement::class)->values($record)['proactive']['relations'] ?? [])->map(function($r){ $label=app(OpportunityClassification::class)->targets($r['kind'])->where('id',$r['target_id'])->value(OpportunityClassification::TARGETS[$r['kind']][1]); return $label?['id'=>$r['id'],'label'=>$label,'relationship'=>$r['relation']]:null; })->filter()->values()->all(); } public function history(?Opportunity $record): array { if (!$record) return []; return DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->orderByRaw("(after_data->>'version')::integer DESC")->limit(30)->get()->map(function ($event) { $entry=json_decode($event->after_data,true);$snapshot=json_decode(Crypt::decryptString($entry['snapshot']),true); $draft=json_decode($snapshot['management_draft'] ?? '{}',true);$need=$draft['proactive']['need'] ?? null; return $need ? ['at'=>$event->occurred_at,'version'=>$entry['version'],'state'=>$need['state'],'problem'=>$need['problem'],'confirmation'=>$need['confirmation'],'evidence_ids'=>$need['evidence_ids']] : null; })->filter()->values()->all(); } public function options(?Opportunity $record = null): array { return ['documents' => $record ? DB::table('opportunity_documents as l')->join('documents as d','d.id','=','l.document_id')->where('l.opportunity_id',$record->id)->whereNull('l.deleted_at')->whereNull('d.deleted_at')->get(['d.id','d.file_name as title'])->all() : [], 'groups' => self::GROUPS, 'references' => collect(['project','cluster','corridor','park','chain'])->mapWithKeys(fn ($kind) => [$kind => app(OpportunityClassification::class)->targets($kind)->orderBy(OpportunityClassification::TARGETS[$kind][1])->limit(300)->get(['id', OpportunityClassification::TARGETS[$kind][1].' as name'])])->all()]; } private function fail(string $key, string $message): never { throw ValidationException::withMessages(['proactive.'.$key => $message]); } }