From 3439f35038f15c93224e6cf876bf559135824d13 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 27 Oct 2026 09:35:20 +0200 Subject: [PATCH] feat(investment): prepare evidence-led territorial needs and ideas in existing workflows --- .../OpportunityManagementController.php | 2 +- app/Models/Opportunity.php | 1 + app/Services/InvestmentCatalogue.php | 4 +- app/Services/InvestorContext.php | 2 + app/Services/OpportunityManagement.php | 13 +- app/Services/OpportunityPromotion.php | 4 +- app/Services/PortalProfileAccess.php | 4 +- app/Services/ProactiveInvestment.php | 181 ++++++++++++++++++ .../components/opportunity-search-filters.tsx | 2 + .../js/components/proactive-investment.tsx | 90 +++++++++ .../js/lib/proactive-investment-report.ts | 31 +++ resources/js/lib/project-dossier.ts | 2 + resources/js/pages/investment/catalogue.tsx | 3 +- resources/js/pages/investment/detail.tsx | 2 + resources/js/pages/investment/shared.tsx | 1 + resources/js/pages/investor-path/show.tsx | 3 + .../js/pages/management/opportunity-draft.tsx | 12 ++ .../management/opportunity-promotion.tsx | 12 +- tests/Feature/ProactiveInvestmentTest.php | 159 +++++++++++++++ tests/Support/ProactiveInvestmentFixture.php | 57 ++++++ tests/frontend/proactive-investment.test.mjs | 13 ++ tools/proactive-investment-qa.php | 21 ++ 22 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 app/Services/ProactiveInvestment.php create mode 100644 resources/js/components/proactive-investment.tsx create mode 100644 resources/js/lib/proactive-investment-report.ts create mode 100644 tests/Feature/ProactiveInvestmentTest.php create mode 100644 tests/Support/ProactiveInvestmentFixture.php create mode 100644 tests/frontend/proactive-investment.test.mjs create mode 100644 tools/proactive-investment-qa.php diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php index e6a82817..440fa69f 100644 --- a/app/Http/Controllers/OpportunityManagementController.php +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -147,7 +147,7 @@ class OpportunityManagementController extends Controller Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? [])); $classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null; - return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES, + return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(\App\Services\ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(\App\Services\ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES, 'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS], 'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true), 'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]); diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index f902bfc6..e7c7a4bb 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -35,6 +35,7 @@ class Opportunity extends Model { $data = parent::toArray(); if (isset($data['description'])) $data['description'] = \App\Services\OpportunityPublicText::description($this, $data['description']); + if ($this->exists && ($proactive = app(\App\Services\ProactiveInvestment::class)->publicData($this))) $data['proactive'] = $proactive; return $data; } diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index a7990dc0..d6fabb00 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -17,7 +17,7 @@ class InvestmentCatalogue /** @return array */ public function filters(Request $request): array { - $filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], + $filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'],'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], 'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))], 'opportunity_id' => ['nullable', 'integer', 'min:1'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'], @@ -63,6 +63,8 @@ class InvestmentCatalogue public function query(array $filters): Builder { $query = Opportunity::published()->with('sector:id,name,slug'); + if (in_array($filters['perspective'] ?? '', ['exists','needs','ideas'], true)) $query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']); + elseif (($filters['perspective'] ?? '') === 'opportunities') $query->whereNull('workflow_context->promotional_summary->projection->proactive'); $associated = $this->publicAssociatedIds($filters); if (($filters['territory_basis'] ?? 'all') === 'direct') { $query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))); diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 2fea1b36..d92eb748 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -159,6 +159,8 @@ final class InvestorContext $fields['sector_id'] = (string) $o->sector_id; $fields['sector'] = $o->sector->name; } + $proactive = $published ? app(ProactiveInvestment::class)->publicData($o) : ($draft['proactive'] ?? null); + if ($proactive) $zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => !$published]; // Text labels do not establish a canonical territorial correspondence. if ($o->province) { $fields['province'] = $o->province; diff --git a/app/Services/OpportunityManagement.php b/app/Services/OpportunityManagement.php index 8cebfc24..06ff8c1d 100644 --- a/app/Services/OpportunityManagement.php +++ b/app/Services/OpportunityManagement.php @@ -21,7 +21,7 @@ class OpportunityManagement public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version']; - public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical']; + public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical', 'proactive']; /** @return array */ public function values(Opportunity $record): array @@ -63,7 +63,7 @@ class OpportunityManagement abort_if(array_key_exists('organization_id', $input) && $input['organization_id'] !== $existingRecord->organization_id, 403, 'A organização não pode ser alterada por edição.'); } } elseif ($profileCreation) { - abort_unless($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? ''), 403); + abort_unless(($access->canCreate($actor, $input['organization_id'] ?? null, $input['opportunity_category'] ?? '') || (isset($input['proactive']) && empty($input['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $input['opportunity_category'] ?? '') !== null)), 403); } else { abort_unless($access->legacyWriteAllowed($actor), 403, 'O papel v1 não usa a criação legada para contornar o seu âmbito.'); } @@ -92,6 +92,7 @@ class OpportunityManagement 'opportunity_category' => ['nullable', Rule::in(self::CATEGORIES)], 'real_estate' => ['sometimes', 'array'], 'canonical' => ['sometimes', 'array'], + 'proactive' => ['sometimes', 'array'], 'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'], 'estimated_investment_currency' => ['nullable', 'in:MZN,USD,EUR,ZAR'], 'reason' => ['required', 'string', 'min:20', 'max:1000'], @@ -127,6 +128,7 @@ class OpportunityManagement $this->check($record, $data['etag']); abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.'); } + if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record); $before = $record->getAttributes(); $previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : []; $changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])); @@ -170,7 +172,7 @@ class OpportunityManagement $record->management_version = 1; $record->estimated_investment_currency = null; if ($profileCreation) { - abort_unless($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? ''), 403); + abort_unless(($access->canCreate($actor, $data['organization_id'] ?? null, $data['opportunity_category'] ?? '') || (isset($data['proactive']) && empty($data['organization_id']) && !$access->has($actor, 'auditor') && $access->internalAssignment($actor, $data['opportunity_category'] ?? '') !== null)), 403); $record->owner_user_id = $actor->id; $record->organization_id = $data['organization_id'] ?? null; $record->workflow_context = ['policy_version' => PortalProfileAccess::VERSION, 'contributors' => [$actor->id]]; @@ -187,12 +189,12 @@ class OpportunityManagement $record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR); } else { foreach ($changes as $key => $value) { - if (in_array($key, ['real_estate', 'canonical', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) { + if (in_array($key, ['real_estate', 'canonical', 'proactive', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) { continue; } $record->{$key} = $value; } - if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes)) { + if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes) || array_key_exists('proactive', $changes)) { // The existing draft JSON holds the versioned catalogue; legacy columns stay intact. $previous = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : []; $record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::PRIVATE_DRAFT_FIELDS))], JSON_THROW_ON_ERROR); @@ -225,6 +227,7 @@ class OpportunityManagement $record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail(); abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.'); $this->check($record, $data['etag']); + if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record); $before = $record->getAttributes(); if ($data['action'] === 'restore') { abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.'); diff --git a/app/Services/OpportunityPromotion.php b/app/Services/OpportunityPromotion.php index 608fc8c4..ff71d6ba 100644 --- a/app/Services/OpportunityPromotion.php +++ b/app/Services/OpportunityPromotion.php @@ -48,7 +48,7 @@ final class OpportunityPromotion 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [], 'sector_id' => $record->sector_id]; return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record), - 'initial' => $form, 'publicNow' => $existing && $record->status === 'published', + 'proactiveRelations' => app(ProactiveInvestment::class)->publicationRelations($record), 'proactiveKind' => app(OpportunityManagement::class)->values($record)['proactive']['kind'] ?? null, 'proactiveEnabled' => isset(app(OpportunityManagement::class)->values($record)['proactive']), 'proactiveEvidence' => array_values(array_filter(app(OpportunityManagement::class)->values($record)['proactive']['evidence'] ?? [], fn ($e) => $e['access'] === 'public')), 'proactiveOptions' => app(ProactiveInvestment::class)->options(), 'initial' => [...$form, ...($saved['input']['proactive_public'] ?? null ? ['proactive_public' => $saved['input']['proactive_public']] : [])], 'publicNow' => $existing && $record->status === 'published', 'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor), 'sectors' => \App\Models\Sector::catalog()->orderBy('name')->get(['id', 'name']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']), @@ -63,6 +63,7 @@ final class OpportunityPromotion 'title' => 'required_if:action,disclose|nullable|string|max:255', 'summary' => 'required_if:action,disclose|nullable|string|max:499', 'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500', 'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d', + 'proactive_public' => 'sometimes|array', 'proactive_reviewed' => 'sometimes|boolean', 'sector_id' => ['nullable', 'integer', Rule::in(\App\Models\Sector::catalog()->pluck('id')->all())], 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct', ])->validate(); @@ -83,6 +84,7 @@ final class OpportunityPromotion $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body, 'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE, 'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)]; + if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) { $projection['proactive'] = $proactive; $projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]); } if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'], diff --git a/app/Services/PortalProfileAccess.php b/app/Services/PortalProfileAccess.php index 75b656b4..fbdf60ea 100644 --- a/app/Services/PortalProfileAccess.php +++ b/app/Services/PortalProfileAccess.php @@ -245,7 +245,9 @@ class PortalProfileAccess return $this->canView($actor, $record) && $this->promoterRecordScope($actor, $record) && ! $record->trashed() && in_array($this->state($record), ['draft', 'needs_information'], true) - && $this->scoped($actor, 'promoter', $record->organization_id, $newType ?? $record->opportunity_category) + && ($this->scoped($actor, 'promoter', $record->organization_id, $newType ?? $record->opportunity_category) + || ($record->organization_id === null && isset(app(OpportunityManagement::class)->values($record)['proactive']) + && $this->internalAssignment($actor, $newType ?? $record->opportunity_category) !== null)) && $this->operational($actor, 'edit', $record->id); } diff --git a/app/Services/ProactiveInvestment.php b/app/Services/ProactiveInvestment.php new file mode 100644 index 00000000..727975b1 --- /dev/null +++ b/app/Services/ProactiveInvestment.php @@ -0,0 +1,181 @@ + '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]); + } +} diff --git a/resources/js/components/opportunity-search-filters.tsx b/resources/js/components/opportunity-search-filters.tsx index 6025b73c..3c371ff4 100644 --- a/resources/js/components/opportunity-search-filters.tsx +++ b/resources/js/components/opportunity-search-filters.tsx @@ -17,6 +17,7 @@ export const modalityLabels: Record = { type Values = Record; const labels: Record = { q: 'O que procura', + perspective: 'Consultar por', sector: 'Sector', category: 'Modalidade da oportunidade', preparation: 'Estado de preparação', @@ -215,6 +216,7 @@ export default function OpportunitySearchFilters({ onChange={(e) => change({ q: e.target.value })} /> + {select('perspective', [{value:'exists',label:'O que existe'},{value:'needs',label:'Necessidades identificadas'},{value:'ideas',label:'Ideias de investimento'},{value:'opportunities',label:'Oportunidades cadastradas — disponibilidade na ficha'}])} {select('sector', choicesFor('sector'))} {select('category', choicesFor('category'))} diff --git a/resources/js/components/proactive-investment.tsx b/resources/js/components/proactive-investment.tsx new file mode 100644 index 00000000..3c76cd4a --- /dev/null +++ b/resources/js/components/proactive-investment.tsx @@ -0,0 +1,90 @@ +import { createOperationKey } from '@/lib/operation-key'; +import { Field, control, button } from '@/pages/management/shared'; + +export type ProactiveEvidence = {id:string;category:string;purpose:'local'|'methodology';statement:string;interpretation:string;source:string;locator:string;period:string;scope:string;coverage:string;verified_on:string;access:'internal'|'public';document_id:string}; +export type ProactiveAnalysis = { + version:1;kind:'exists'|'needs'|'ideas';evidence:ProactiveEvidence[]; + need:{state:'hypothesis'|'confirmed';problem:string;observed_supply:string;observed_demand:string;type:string;clients:string;evidence_ids:string[];limitations:string;missing:string;confirmation:string}; + idea:{solution:string;activity:string;chain_id:string;stage:string;coverage:string;mode:string;resources:string;assumptions:string;conditions:string;pending:string;next_action:string}; + relations:{id:string;kind:string;target_id:string;relation:string;reference:string;evidence_ids:string[]}[]; +}; +export type ProactiveOptions = {documents?:{id:string;title:string}[];groups:Record;references:Record}; +export type ProactivePublic = {version?:number;group:string;state:string;need:string;proposal:string;basis:string;limitations:string;next_action:string;evidence_ids:string[];chain_id?:string|null;stage?:string|null;notice?:string;resources?:string;assumptions?:string;conditions?:string;relation_ids?:string[];relations?:{kind:string;target_id:string;label:string;relationship:string;basis:string}[];evidence?:Partial[]}; +export const proactiveNotice = '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.'; +export const newProactive = ():ProactiveAnalysis => ({version:1,kind:'ideas',evidence:[],need:{state:'hypothesis',problem:'',observed_supply:'',observed_demand:'',type:'',clients:'',evidence_ids:[],limitations:'',missing:'',confirmation:''},idea:{solution:'',activity:'',chain_id:'',stage:'',coverage:'',mode:'',resources:'',assumptions:'',conditions:'',pending:'',next_action:''},relations:[]}); +export const newPublicProactive = ():ProactivePublic => ({group:'ideas',state:'hypothesis',need:'',proposal:'',basis:'',limitations:'',next_action:'',evidence_ids:[],chain_id:'',stage:'',resources:'',assumptions:'',conditions:'',relation_ids:[]}); + +export function ProactiveInvestmentEditor({value,onChange,options,history=[]}:{value?:ProactiveAnalysis;onChange:(value:ProactiveAnalysis)=>void;options?:ProactiveOptions;history?:{version:number;at:string;state:string;problem:string;confirmation:string}[]}){ + if(!value)return

Análise territorial e preparação de ideias

Comece pelo território e sector existentes, mesmo sem oportunidade publicada, promotor, montante ou coordenadas. O rascunho e a publicação são independentes.

; + const patch=(p:Partial)=>onChange({...value,...p}); + const text=(label:string,v:string,change:(v:string)=>void)=>