182 lines
15 KiB
PHP
182 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
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'];
|
|
|
|
public function draft(array $input, ?Opportunity $record = null): array
|
|
{
|
|
$d = Validator::make(['proactive' => $input], [
|
|
'proactive' => 'required|array:version,kind,evidence,need,idea,relations', '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',
|
|
'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'];
|
|
$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]);
|
|
}
|
|
}
|