'O que existe', 'needs' => 'Necessidades identificadas', 'ideas' => 'Ideias de investimento']; /** * @param array $input * @return array */ public function draft(array $input, ?Opportunity $record = null, ?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,workforce', '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. * @param array $input * @return array|null */ 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(StructuredData::collectionItems($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(StructuredData::collectionItems($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]; } /** * @return array|null */ 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; } /** * @return array */ public function publicationRelations(Opportunity $record): array { return collect(StructuredData::collectionItems(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(); } /** * @return array */ 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(); } /** * @return array */ 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]); } }