Files
Investidor-V2/app/Services/ProactiveInvestment.php

281 lines
18 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Analysis in the existing opportunity draft; publication and private preparation remain separate. */
final class ProactiveInvestment
{
public const VERSION = 1;
public const NOTICE = 'A ausência de registo não comprova ausência de oferta. Proximidade não comprova contrato, participação, energia acessível, terra disponível ou benefício fiscal. Necessidade confirmada não comprova viabilidade comercial; hipótese de fornecimento não é encomenda.';
public const GROUPS = ['exists' => 'O que existe', 'needs' => 'Necessidades identificadas', 'ideas' => 'Ideias de investimento'];
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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<array-key, mixed> $input
* @return array<array-key, mixed>|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<array-key, mixed>|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<array-key, mixed>
*/
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<array-key, mixed>
*/
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<array-key, mixed>
*/
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]);
}
}