feat(investment): prepare evidence-led territorial needs and ideas in existing workflows

This commit is contained in:
2026-10-27 09:35:20 +02:00
parent f8d57bddf9
commit 3439f35038
22 changed files with 605 additions and 14 deletions
@@ -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']) : []]);
+1
View File
@@ -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;
}
+3 -1
View File
@@ -17,7 +17,7 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
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')));
+2
View File
@@ -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;
+8 -5
View File
@@ -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<string,mixed> */
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.');
+3 -1
View File
@@ -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'],
+3 -1
View File
@@ -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);
}
+181
View File
@@ -0,0 +1,181 @@
<?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]);
}
}
@@ -17,6 +17,7 @@ export const modalityLabels: Record<string, string> = {
type Values = Record<string, string | boolean | number[]>;
const labels: Record<string, string> = {
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 })}
/>
</Field>
{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'))}
</div>
@@ -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<string,string>;references:Record<string,{id:string;name:string}[]>};
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<ProactiveEvidence>[]};
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 <section className="rounded border p-4"><h2 className="font-semibold">Análise territorial e preparação de ideias</h2><p>Comece pelo território e sector existentes, mesmo sem oportunidade publicada, promotor, montante ou coordenadas. O rascunho e a publicação são independentes.</p><button type="button" className={button} onClick={()=>onChange(newProactive())}>Preparar análise e ideia</button></section>;
const patch=(p:Partial<ProactiveAnalysis>)=>onChange({...value,...p});
const text=(label:string,v:string,change:(v:string)=>void)=><Field label={label}><textarea className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}/></Field>;
const select=(label:string,v:string,choices:Record<string,string>,change:(v:string)=>void)=><Field label={label}><select className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}><option value="">Por determinar</option>{Object.entries(choices).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></Field>;
const evidenceChoice=(selected:string[],change:(v:string[])=>void)=><fieldset><legend>Evidências que fundamentam esta entrada</legend>{value.evidence.map((e,i)=><label key={e.id} className="block"><input type="checkbox" checked={selected.includes(e.id)} onChange={event=>change(event.target.checked?[...selected,e.id]:selected.filter(id=>id!==e.id))}/> Evidência {i+1}: {e.statement||'Por descrever'} ({e.access==='public'?'Pública autorizada':'Interna'})</label>)}</fieldset>;
return <section className="space-y-4 rounded border p-4" aria-label="Análise territorial e ideia">
<h2 className="text-xl font-semibold">Território e sector → evidências → necessidade → ideia</h2><details><summary>Como fundamentar esta análise</summary><p>Inventarie actividades, recursos, infra-estruturas e serviços de apoio. Confronte oferta observada com procura documentada e identifique os clientes ou beneficiários. Registe o período, cobertura e limitações de cada observação. Use referências internacionais para orientar as perguntas; a confirmação do caso depende de evidência local.</p></details><p>Reutiliza o sector e a localização principal escolhidos nesta ficha. Guarde pelo botão de rascunho existente. Nenhum montante, promotor ou ponto exacto é exigido.</p><p>{proactiveNotice}</p>
{select('Tipo de conteúdo em preparação',value.kind,{exists:'O que existe',needs:'Necessidade identificada',ideas:'Ideia de investimento'},v=>patch({kind:v as ProactiveAnalysis['kind']}))}
<fieldset className="space-y-3 rounded border p-3"><legend>Evidências e proveniência</legend><p>Registe factos e interpretações em campos separados. Listas de empresas, hotéis, restaurantes, contactos e documentos internos permanecem internos; não são copiados para a divulgação.</p>
{value.evidence.map((e,i)=>{const change=(p:Partial<ProactiveEvidence>)=>patch({evidence:value.evidence.map(x=>x.id===e.id?{...x,...p}:x)});return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência {i+1}</legend>
{select(`Finalidade da evidência ${i+1}`,e.purpose??'',{local:'Evidência local do caso',methodology:'Referência metodológica — não prova procura local'},v=>change({purpose:v as 'local'|'methodology'}))}
{select(`Categoria da evidência ${i+1}`,e.category,{activity:'Actividade',resource:'Recurso',infrastructure:'Infra-estrutura',supply:'Oferta',demand:'Procura'},v=>change({category:v}))}
{([['statement','Facto documentado'],['interpretation','Interpretação ou hipótese'],['source','Fonte e exemplar'],['locator','Localizador ou trecho'],['period','Período da evidência'],['scope','Abrangência'],['coverage','Cobertura e limitações']]as const).map(([key,label])=><div key={key}>{text(`${label} ${i+1}`,e[key],v=>change({[key]:v}))}</div>)}
<Field label={`Verificação ${i+1}`}><input aria-label={`Verificação ${i+1}`} className={control} type="date" value={e.verified_on??''} onChange={event=>change({verified_on:event.target.value})}/></Field>
<Field label={`Documento existente associado ${i+1}`}><select aria-label={`Documento existente associado ${i+1}`} className={control} value={e.document_id??''} onChange={event=>change({document_id:event.target.value})}><option value="">Sem documento associado — indicar fonte e localizador</option>{options?.documents?.map(d=><option key={d.id} value={d.id}>{d.title}</option>)}</select></Field>
{select(`Divulgação da evidência ${i+1}`,e.access,{internal:'Interna — análise privada',public:'Pública — origem e divulgação autorizadas'},v=>change({access:v as ProactiveEvidence['access']}))}
<button type="button" className="underline" onClick={()=>patch({evidence:value.evidence.filter(x=>x.id!==e.id),need:{...value.need,evidence_ids:value.need.evidence_ids.filter(id=>id!==e.id)},relations:value.relations.map(r=>({...r,evidence_ids:r.evidence_ids.filter(id=>id!==e.id)}))})}>Retirar evidência {i+1} deste rascunho</button>
</fieldset>;})}
<button type="button" className={button} onClick={()=>patch({evidence:[...value.evidence,{id:createOperationKey(),category:'activity',purpose:'local',statement:'',interpretation:'',source:'',locator:'',period:'',scope:'',coverage:'',verified_on:'',access:'internal',document_id:''}]})}>Acrescentar evidência</button>
</fieldset>
{value.kind!=='exists'&&<fieldset className="space-y-3 rounded border p-3"><legend>Necessidade ou lacuna</legend>
{select('Estado da necessidade',value.need.state,{hypothesis:'Hipótese analítica',confirmed:'Necessidade confirmada — fundamento documentado'},v=>patch({need:{...value.need,state:v as 'hypothesis'|'confirmed'}}))}
{select('Tipo de lacuna',value.need.type,{quantity:'Quantidade',quality:'Qualidade',logistics:'Logística',processing:'Transformação',coordination:'Coordenação',support:'Serviços de apoio'},v=>patch({need:{...value.need,type:v}}))}
{([['problem','Problema identificado'],['observed_supply','Oferta observada'],['observed_demand','Procura observada'],['clients','Potenciais clientes ou beneficiários'],['limitations','Limitações da necessidade'],['missing','Informação por confirmar'],['confirmation','Fundamentação do estado da necessidade']]as const).map(([key,label])=><div key={key}>{text(label,value.need[key],v=>patch({need:{...value.need,[key]:v}}))}</div>)}
{evidenceChoice(value.need.evidence_ids,v=>patch({need:{...value.need,evidence_ids:v}}))}
</fieldset>}
{value.kind==='ideas'&&<fieldset className="space-y-3 rounded border p-3"><legend>Ideia em preparação</legend>
{([['solution','Solução proposta'],['activity','Actividade proposta'],['stage','Etapa da cadeia de valor'],['coverage','Localização ou abrangência da ideia'],['resources','Recursos documentados'],['assumptions','Pressupostos explícitos'],['conditions','Condições da ideia'],['pending','Pendências da ideia'],['next_action','Próxima acção']]as const).map(([key,label])=><div key={key}>{text(label,value.idea[key],v=>patch({idea:{...value.idea,[key]:v}}))}</div>)}
{select('Cadeia de valor existente',value.idea.chain_id,Object.fromEntries((options?.references.chain??[]).map(c=>[c.id,c.name])),v=>patch({idea:{...value.idea,chain_id:v}}))}
{select('Possibilidade de investimento',value.idea.mode,{create:'Criar actividade',expand:'Expandir actividade',associate:'Associar actividades existentes'},v=>patch({idea:{...value.idea,mode:v}}))}
</fieldset>}
{value.kind!=='ideas'&&<p>Os campos anteriores da ideia são conservados. Escolha “Ideia de investimento” quando pretender preparar uma solução.</p>}
<fieldset className="space-y-3 rounded border p-3"><legend>Relações com estruturas existentes</legend>
{value.relations.map((r,i)=>{const change=(p:Partial<typeof r>)=>patch({relations:value.relations.map(x=>x.id===r.id?{...x,...p}:x)});return <fieldset key={r.id} className="rounded border p-3"><legend>Relação {i+1}</legend>
{select(`Tipo de referência ${i+1}`,r.kind,{project:'Projecto âncora',cluster:'Cluster',corridor:'Corredor',park:'Parque',chain:'Cadeia de valor'},v=>change({kind:v,target_id:''}))}
{select(`Referência existente ${i+1}`,r.target_id,Object.fromEntries((options?.references[r.kind]??[]).map(c=>[c.id,c.name])),v=>change({target_id:v}))}
{select(`Fundamento da relação ${i+1}`,r.relation,{documented:'Documentada',hypothesis:'Hipótese de relação',proximity:'Proximidade, sem compromisso'},v=>change({relation:v}))}
{text(`Fonte da relação ${i+1}`,r.reference,v=>change({reference:v}))}{evidenceChoice(r.evidence_ids,v=>change({evidence_ids:v}))}
<button type="button" className="underline" onClick={()=>patch({relations:value.relations.filter(x=>x.id!==r.id)})}>Retirar relação {i+1}</button>
</fieldset>;})}
<button type="button" className={button} onClick={()=>patch({relations:[...value.relations,{id:createOperationKey(),kind:'project',target_id:'',relation:'hypothesis',reference:'',evidence_ids:[]}]})}>Relacionar referência existente</button>
</fieldset>
{!!history.length&&<details><summary>Histórico do fundamento da necessidade</summary>{history.map(h=><p key={h.version}>Versão {h.version} · {h.at} · {h.state==='confirmed'?'Necessidade confirmada':'Hipótese'}: {h.problem} — {h.confirmation||'Fundamentação por completar'}</p>)}</details>}
<p>Não calcula mercado, investimento ou rentabilidade. Informação em falta permanece por determinar. Uma nova edição não altera os históricos nem publica a análise.</p>
</section>;
}
export function ProactiveSummary({value}:{value?:ProactivePublic|null}){
if(!value)return null;
return <section className="space-y-2 rounded border p-4" aria-label="Origem proactiva do investimento"><h2 className="text-xl font-semibold">{value.group==='ideas'?'Ideia em preparação':value.group==='needs'?'Necessidade identificada':'O que existe'}</h2><p>{value.group==='exists'?'Registo documental; consultar abrangência, cobertura e limitações das fontes.':value.state==='confirmed'?'Necessidade confirmada no âmbito documentado':'Hipótese analítica; conclusão económica por confirmar'}</p>{([['need','Necessidade'],['proposal','Proposta'],['basis','Fundamento'],['limitations','Limitações'],['next_action','Próximo passo'],['stage','Etapa da cadeia'],['resources','Recursos'],['assumptions','Pressupostos'],['conditions','Condições']]as const).map(([key,label])=>value[key]?<p key={key}><strong>{label}:</strong> {value[key]}</p>:null)}{value.relations?.map(r=><p key={r.kind+r.target_id}>Relação seleccionada: {r.label} · {r.relationship==='documented'?'Documentada':r.relationship==='proximity'?'Proximidade, sem pertença':'Hipótese de relação'}. Fundamento: {r.basis}. Não confirma contrato, acesso ou participação.</p>)}{value.evidence?.map((e,i)=><p key={e.id??i}>Evidência pública: {e.statement}; fonte: {e.source}; localizador: {e.locator}; período: {e.period}; abrangência: {e.scope}; cobertura: {e.coverage}; verificação: {e.verified_on}.</p>)}<p>{value.notice??proactiveNotice}</p><p>Sem disponibilidade de participação, viabilidade comercial ou benefício fiscal presumidos.</p></section>;
}
export function ProactivePublicationFields({value,onChange,evidence,relations=[],options,reviewed,onReview}:{value:ProactivePublic;onChange:(v:ProactivePublic)=>void;evidence:ProactiveEvidence[];relations?:{id:string;label:string;relationship:string}[];options?:ProactiveOptions;reviewed:boolean;onReview:(v:boolean)=>void}){
const text=(key:'need'|'proposal'|'basis'|'limitations'|'next_action'|'stage'|'resources'|'assumptions'|'conditions',label:string)=><Field label={label}><textarea aria-label={label} className={control} value={value[key]??''} onChange={e=>{onChange({...value,[key]:e.target.value});onReview(false);}}/></Field>;
return <fieldset className="space-y-3 rounded border p-4"><legend>Síntese proactiva expressamente pública</legend><p>Redija uma síntese independente. Nenhum facto, interpretação, contacto ou fonte interna é copiado automaticamente. A síntese também não pode revelar informação privada indirectamente.</p>
<Field label="Grupo público"><select className={control} aria-label="Grupo público" value={value.group} onChange={e=>onChange({...value,group:e.target.value})}>{Object.entries(options?.groups??{exists:'O que existe',needs:'Necessidades identificadas',ideas:'Ideias de investimento'}).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></Field>
{value.group!=='exists'&&<Field label="Estado público da necessidade"><select className={control} aria-label="Estado público da necessidade" value={value.state} onChange={e=>onChange({...value,state:e.target.value})}><option value="hypothesis">Hipótese analítica</option><option value="confirmed">Necessidade confirmada com fundamento público</option></select></Field>}
{value.group!=='exists'&&text('need','Necessidade pública')}{value.group==='ideas'&&text('proposal','Proposta pública')}{text('basis','Fundamento público')}{text('limitations','Limitações públicas da ideia')}{text('next_action','Próximo passo público')}{text('stage','Etapa pública da cadeia')}{text('resources','Recursos públicos documentados')}{text('assumptions','Pressupostos públicos')}{text('conditions','Condições públicas')}
<Field label="Cadeia de valor pública"><select className={control} aria-label="Cadeia de valor pública" value={value.chain_id??''} onChange={e=>onChange({...value,chain_id:e.target.value})}><option value="">Por determinar</option>{options?.references.chain?.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<fieldset><legend>Relações públicas seleccionadas</legend>{relations.map(r=><label key={r.id} className="block"><input type="checkbox" checked={(value.relation_ids??[]).includes(r.id)} onChange={event=>onChange({...value,relation_ids:event.target.checked?[...(value.relation_ids??[]),r.id]:(value.relation_ids??[]).filter(id=>id!==r.id)})}/> {r.label} · {r.relationship}</label>)}</fieldset><fieldset><legend>Evidências públicas a divulgar</legend>{evidence.map(e=><label key={e.id} className="block"><input type="checkbox" checked={value.evidence_ids.includes(e.id)} onChange={event=>onChange({...value,evidence_ids:event.target.checked?[...value.evidence_ids,e.id]:value.evidence_ids.filter(id=>id!==e.id)})}/> {e.statement||'Evidência pública por descrever'}</label>)}</fieldset>
<label className="block"><input type="checkbox" checked={reviewed} onChange={e=>onReview(e.target.checked)}/> Confirmei a divulgação deste texto e das evidências públicas seleccionadas, sem exposição directa ou indirecta de informação privada.</label>
</fieldset>;
}
export function proactiveView(raw:unknown):ProactivePublic|null{
if(!raw||typeof raw!=='object')return null;
const p=raw as Partial<ProactivePublic&ProactiveAnalysis>;
if(p.version!==1)return null;
if(typeof p.group==='string'&&typeof p.need==='string')return p as ProactivePublic;
const a=raw as ProactiveAnalysis;
if(!a.need||typeof a.need.problem!=='string'||!a.idea)return null;
return {version:1,group:a.kind,state:a.need.state,need:a.kind==='exists'?'':a.need.problem,proposal:a.kind==='ideas'?a.idea.solution:'',basis:a.need.confirmation||'Fundamento por completar',limitations:[a.need.limitations,a.need.missing,a.idea.assumptions,a.idea.conditions,a.idea.pending].filter(Boolean).join('; '),next_action:a.idea.next_action,evidence_ids:a.need.evidence_ids,chain_id:a.idea.chain_id,stage:a.kind==='ideas'?a.idea.stage:'',notice:'Origem privada autorizada deste dossier. '+proactiveNotice};
}
@@ -0,0 +1,31 @@
type Analysis = {version?:number;group?:string;kind?:string;state?:string;need?:string|{state?:string;problem?:string;confirmation?:string;limitations?:string;missing?:string};idea?:{solution?:string;activity?:string;stage?:string;coverage?:string;resources?:string;assumptions?:string;conditions?:string;pending?:string;next_action?:string};proposal?:string;basis?:string;limitations?:string;next_action?:string;stage?:string;resources?:string;assumptions?:string;conditions?:string;relations?:{kind:string;target_id:string;label:string;relationship:string;basis:string}[];evidence?:{id?:string;statement?:string;interpretation?:string;source?:string;locator?:string;period?:string;scope?:string;coverage?:string;verified_on?:string;access?:string}[]};
type Source={key?:string;title?:string;version?:string|number;analysis?:{proactive?:Analysis}};
export function proactiveInvestmentRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
const rows:Array<[string,string,string,string]>=[],sources:Source[]=[],seen=new Set<string>();
try{
const selected=draft.context_opportunity_snapshot?JSON.parse(String(draft.context_opportunity_snapshot)):null;
const context=draft.context_applicability_snapshot?JSON.parse(String(draft.context_applicability_snapshot)):null;
if(selected?.choice?.opportunity)sources.push(selected.choice.opportunity);
else if(Array.isArray(context?.opportunity_choices))sources.push(...context.opportunity_choices.map((c:{opportunity:Source})=>c.opportunity));
else if(context?.selection?.opportunity)sources.push(context.selection.opportunity);
for(const source of sources){
const p=source?.analysis?.proactive;if(!p||p.version!==1||!source.key||seen.has(source.key))continue;seen.add(source.key);
if(draft.context_opportunity&&draft.context_opportunity!==source.key)continue;
const origin=`${source.title??'Ideia por identificar'}; ${source.key}; versão ${source.version??'Por identificar'}`;
const kind=p.group??p.kind;
const privateNeed=typeof p.need==='object'?p.need:null;
rows.push(['Localização','Origem da análise e ideia',source.title??'Por identificar',origin]);
if(kind!=='exists')rows.push(['Localização','Estado da necessidade',(privateNeed?.state??p.state)==='confirmed'?'Necessidade confirmada no âmbito documentado; não comprova viabilidade comercial':'Hipótese analítica; informação económica por confirmar',origin]);
if(kind!=='exists')rows.push(['Localização','Necessidade identificada',privateNeed?.problem??(typeof p.need==='string'?p.need:'Por determinar'),origin]);
if(kind==='ideas')rows.push(['Localização','Proposta de investimento',p.proposal??p.idea?.solution??'Por determinar',origin]);
rows.push(['Localização','Fundamento da ideia',p.basis??privateNeed?.confirmation??'Por completar',origin]);
if(kind==='ideas')rows.push(['Localização','Etapa e pressupostos',[p.stage??p.idea?.stage,p.idea?.coverage,p.resources??p.idea?.resources,p.assumptions??p.idea?.assumptions,p.conditions??p.idea?.conditions].filter(Boolean).join('; ')||'Por determinar',origin]);
rows.push(['Localização','Limitações da ideia',[p.limitations,kind!=='exists'?privateNeed?.limitations:null,kind!=='exists'?privateNeed?.missing:null,kind==='ideas'?p.idea?.pending:null].filter(Boolean).join('; ')||'Por completar',origin]);
rows.push(['Localização','Próxima acção da ideia',p.next_action??p.idea?.next_action??'Por determinar',origin]);
for(const r of p.relations??[])rows.push(['Localização','Relação documentada ou hipótese',`${r.label}; ${r.relationship}; sem contrato, acesso ou participação presumidos`,`${origin}; ${r.kind}:${r.target_id}; ${r.basis}`]);
for(const e of p.evidence??[])rows.push(['Localização','Evidência da ideia',[e.statement,e.interpretation?`Interpretação: ${e.interpretation}`:null,`Período: ${e.period||'Por determinar'}`,`Abrangência: ${e.scope||'Por determinar'}`,`Cobertura: ${e.coverage||'Por determinar'}`,`Verificação: ${e.verified_on||'Por determinar'}`].filter(Boolean).join('; '),`${origin}; fonte: ${e.source||'Por identificar'}; localizador: ${e.locator||'Por identificar'}`]);
rows.push(['Localização','Limites da preparação proactiva','Não comprova contrato, encomenda, participação disponível, energia acessível, terra disponível, viabilidade ou benefício fiscal. Informação em falta não é zero.',origin]);
}
}catch{/* Preserve unreadable original context; do not reconstruct an economic conclusion. */}
return rows;
}
+2
View File
@@ -1,3 +1,4 @@
import {proactiveInvestmentRows} from './proactive-investment-report.ts';
import {normalReportRows} from './irpc-normal.ts';
import {irpcReportRows} from './irpc-withholdings.ts';
import {ispcResidualReportRows} from './ispc-residuals.ts';
@@ -802,6 +803,7 @@ export function buildProjectDossierRows(
if (land.need === 'yes') rows.push(['Localização', 'Situação do terreno', String(draft.land_status_label ?? ({ owned: 'Próprio', duat: 'DUAT', leased: 'Arrendado', requested: 'Pedido em curso', unknown: 'Por determinar' } as Record<string, string>)[String(draft.land_status)] ?? 'Informação em falta'), 'Informação declarada; não comprova título ou disponibilidade']);
else rows.push(['Localização', 'Preparação do terreno', land.need === 'no' ? 'Situação do terreno não aplicável à preparação actual; valores anteriores conservados.' : 'Definir se precisa de terra; pode continuar a preparar o investimento.', 'Sem inferência de dispensa legal']);
}
rows.push(...proactiveInvestmentRows(draft));
const lineageFields: Record<string, string> = {'Título':'project_title','Resumo':'project_summary','Sector':'sector_id','Província':'province_id','Distrito':'district_id','Posto administrativo':'admin_post_id','Localidade':'locality_id'};
return rows.map(row => {
const key = lineageFields[row[1]];
+2 -1
View File
@@ -60,6 +60,7 @@ export default function Catalogue({
setInvestmentSelection(next); sessionStorage.setItem('investment-opportunity-selection', JSON.stringify(next));
}
const form = useForm({
perspective: String(filters.perspective ?? ''),
association_type: String(filters.association_type ?? ''),
association_target: String(filters.association_target ?? ''),
association_purpose: String(filters.association_purpose ?? ''),
@@ -601,7 +602,7 @@ export default function Catalogue({
</h2>
<p>{r.summary}</p>
<p className="font-semibold">{money(r)}</p>
<p>{r.maturity_label}</p>
<p>{r.proactive?(r.proactive.group==='ideas'?'Ideia em preparação':r.proactive.group==='needs'?'Necessidade identificada':'O que existe'):r.maturity_label}</p>
<div className="flex flex-wrap gap-4">
<Link
className="underline"
+2
View File
@@ -1,3 +1,4 @@
import {ProactiveSummary} from '@/components/proactive-investment';
import EducationalGuides from '@/components/educational-guides';
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
import { searchTerritoryHref, investmentPreparationHref } from '@/lib/search-territory';
@@ -53,6 +54,7 @@ export default function Detail({
<Link href={returnUrl} className="underline">
Voltar à pesquisa
</Link>
<ProactiveSummary value={r.proactive}/>
{dossierOrigin && (
<p>
<Link
+1
View File
@@ -44,6 +44,7 @@ export type References = {
sectors: { id: number; name: string; slug: string }[];
};
export type Opportunity = {
proactive?: import('@/components/proactive-investment').ProactivePublic;
id: number;
title: string;
slug: string;
@@ -1,3 +1,4 @@
import {ProactiveSummary,proactiveView} from '@/components/proactive-investment';
import {
PreparationSummary,
type Preparation,
@@ -548,6 +549,8 @@ export default function InvestmentPath(path: Path) {
/>
)}
{!!path.investment_context?.opportunity_choices?.length && <OpportunityChoices contextId={path.case.id} choices={path.investment_context.opportunity_choices}/>}
{path.investment_context?.opportunity_choices?.map(choice=><ProactiveSummary key={choice.opportunity.key} value={proactiveView(choice.opportunity.analysis?.proactive)}/>)}
{!path.investment_context?.opportunity_choices?.length&&<ProactiveSummary value={proactiveView(path.investment_context?.selection.opportunity?.analysis?.proactive)}/>}
<SelectedComplements items={path.investment_context?.complements ?? []}/>
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id} excludedSteps={path.steps.filter((step) => !step.preparation_allowed).map((step) => step.code)}/>
{!!path.content_sources?.length && (
@@ -1,3 +1,4 @@
import {ProactiveInvestmentEditor,type ProactiveAnalysis,type ProactiveOptions} from '@/components/proactive-investment';
import { ClassificationReview } from '@/components/opportunity-classification';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
@@ -25,6 +26,8 @@ type Entry = {
};
export default function OpportunityDraft({
record,
proactiveOptions,
proactiveHistory,
values,
etag,
readOnly,
@@ -38,6 +41,8 @@ export default function OpportunityDraft({
maturityCatalogue,
}: {
record: Entry | null;
proactiveOptions?: ProactiveOptions;
proactiveHistory?: {version:number;at:string;state:string;problem:string;confirmation:string}[];
values: Values;
etag: string | null;
readOnly: boolean;
@@ -474,6 +479,13 @@ export default function OpportunityDraft({
void send('save');
}}
>
{profileMode&&Boolean(data.proactive)&&<fieldset disabled={busy||readOnly||unavailable} className="space-y-3 rounded border p-4"><legend>Contexto existente da análise</legend>
<Field label="Título da análise"><input aria-label="Título da análise" className={control} value={String((data.canonical as Record<string,unknown>|undefined)?.official_title??data.title??'')} onChange={e=>{const title=e.target.value;setData(d=>({...d,title,...(d.canonical&&Object.prototype.hasOwnProperty.call(d.canonical,'official_title')?{canonical:{...(d.canonical as Record<string,unknown>),official_title:title}}:{})}));}}/></Field>
<Field label="Sector da análise"><select aria-label="Sector da análise" className={control} value={String(data.sector_id??'')} onChange={e=>setData(d=>({...d,sector_id:e.target.value,...(d.canonical?{canonical:{...(d.canonical as Record<string,unknown>),sector_id:Number(e.target.value)||null}}:{})}))}><option value="">Por determinar</option>{sectors.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select></Field>
<p>Seleccione apenas o nível territorial conhecido; não exige coordenadas, montante ou promotor.</p>
{(['province_id','district_id','admin_post_id','locality_id']as const).map((key,index)=><TerritoryLevel key={`proactive-${key}-${path[index-1]?.id??''}`} index={index} parent={path[index-1]?.id??''} selected={path[index]} endpoint="/minhas-oportunidades/territory-options" fieldError={errors[key]} onChange={value=>{const next=[...path.slice(0,index),...(value?[value]:[])];setPath(next);setData(d=>({...d,province_id:next[0]?.id??'',district_id:next[1]?.id??'',admin_post_id:next[2]?.id??'',locality_id:next[3]?.id??''}));}}/>)}
</fieldset>}
<fieldset disabled={busy || readOnly || unavailable}><ProactiveInvestmentEditor value={data.proactive as ProactiveAnalysis|undefined} onChange={v=>setData(d=>({...d,proactive:v}))} options={proactiveOptions} history={proactiveHistory}/></fieldset>
{!profileMode && (
<fieldset
disabled={busy || readOnly || unavailable}
@@ -1,19 +1,21 @@
import {ProactivePublicationFields,ProactiveSummary,newPublicProactive,type ProactivePublic,type ProactiveEvidence,type ProactiveOptions} from '@/components/proactive-investment';
import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { Management, Field, control, button } from './shared';
type Draft = {title: string; summary: string; description: string; limitations: string; source_reference: string; edition: string; source_date: string; sector_id: number | null; territory_ids: string[]};
type Projection = {title: string; summary: string; description: string; sector_id: number | null; maturity_label: string; territories: {id: string; label: string}[]};
type Draft = {proactive_public?:ProactivePublic;proactive_reviewed?:boolean;title: string; summary: string; description: string; limitations: string; source_reference: string; edition: string; source_date: string; sector_id: number | null; territory_ids: string[]};
type Projection = {title: string; summary: string; description: string; sector_id: number | null; maturity_label: string; territories: {id: string; label: string}[];proactive?:ProactivePublic};
type Preview = {token: string; action: 'disclose' | 'withdraw'; projection: Projection; current: {title: string; summary: string; description: string; status: string}};
export default function OpportunityPromotion({record, initial, etag, publicNow, reauthenticationRequired, sectors, territories, history}: {
export default function OpportunityPromotion({proactiveEnabled=false,proactiveKind,proactiveRelations=[],proactiveEvidence=[],proactiveOptions,record, initial, etag, publicNow, reauthenticationRequired, sectors, territories, history}: {
proactiveRelations?:{id:string;label:string;relationship:string}[];proactiveEnabled?:boolean;proactiveKind?:string;proactiveEvidence?:ProactiveEvidence[];proactiveOptions?:ProactiveOptions;
record: {id: number; reference: string}; initial: Draft; etag: string; publicNow: boolean; reauthenticationRequired: boolean;
sectors: {id: number; name: string}[]; territories: {id: string; name: string; location_type: string}[];
history: {id: string; action: string; actor_user_id: number; occurred_at: string}[];
}) {
const [draft, setDraft] = useState<Draft>({...initial, source_date: initial.source_date ?? ''});
const [draft, setDraft] = useState<Draft>({...initial, source_date: initial.source_date ?? '',...(proactiveEnabled?{proactive_public:initial.proactive_public??{...newPublicProactive(),group:proactiveKind??'ideas'},proactive_reviewed:false}:{})});
const [preview, setPreview] = useState<Preview | null>(null);
const [operationKey, setOperationKey] = useState(createOperationKey);
const [reason, setReason] = useState('');
@@ -45,6 +47,7 @@ export default function OpportunityPromotion({record, initial, etag, publicNow,
{needsMfa && <p role="alert">Entre novamente com a sua conta e MFA para confirmar esta operação. O cadastro guardado permanece disponível; a prévia ainda não confirmada terá de ser preparada novamente. <button className="underline" onClick={renewAuthentication}>Renovar entrada com MFA</button></p>}
{Object.entries(errors).map(([key,value]) => <p role="alert" key={key}>{key}: {value}</p>)}
<form className="space-y-4 rounded border p-5" onSubmit={e=>{e.preventDefault(); void prepare('disclose');}}>
{proactiveEnabled&&draft.proactive_public&&<ProactivePublicationFields value={draft.proactive_public} onChange={v=>{change('proactive_public',v);change('proactive_reviewed',false);}} evidence={proactiveEvidence} relations={proactiveRelations} options={proactiveOptions} reviewed={Boolean(draft.proactive_reviewed)} onReview={v=>change('proactive_reviewed',v)}/>}
<Field label="Título público"><input required className={control} maxLength={255} value={draft.title} onChange={e=>change('title',e.target.value)}/></Field>
<Field label="Resumo público"><textarea required className={control} maxLength={499} value={draft.summary} onChange={e=>change('summary',e.target.value)}/></Field>
<Field label="Descrição pública"><textarea className={control} maxLength={6000} value={draft.description} onChange={e=>change('description',e.target.value)}/></Field>
@@ -62,6 +65,7 @@ export default function OpportunityPromotion({record, initial, etag, publicNow,
{preview && <section className="space-y-4 rounded border p-5" aria-label="Prévia da divulgação">
<h2 className="text-xl font-semibold">{preview.action === 'withdraw' ? 'Rever retirada do catálogo' : 'Prévia do conteúdo público'}</h2>
<p><strong>{preview.projection.maturity_label}</strong></p>
<ProactiveSummary value={preview.projection.proactive}/>
<h3>{preview.projection.title}</h3><p>{preview.projection.summary}</p><p className="whitespace-pre-wrap">{preview.projection.description}</p>
<p>Sector: {sectors.find(s=>s.id===preview.projection.sector_id)?.name ?? 'Por indicar'}</p>
<ul>{preview.projection.territories.map(t=><li key={t.id}>{t.label}</li>)}</ul>
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\ProactiveInvestment;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\ProactiveInvestmentFixture as F;
use Tests\TestCase;
final class ProactiveInvestmentTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
protected function setUp(): void
{
parent::setUp();F::guard();$this->withoutVite();
config(['portal.profile_access_enabled'=>true,'portal.opportunity_specialized_drafts_enabled'=>true,'portal.managers'=>[],'investor-path.enabled'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
$this->actor=F::operator();$this->login($this->actor);
}
private function login(User $u): void {$this->actingAs($u)->withSession(['portal.mfa_actor'=>$u->id,'portal.mfa_at'=>time(),'portal.access_revision'=>(int)$u->portal_access_revision]);}
private function create(string $pilot='Cahora Bassa'): Opportunity
{
$response=$this->postJson('/minhas-oportunidades/drafts',F::input($pilot));if($response->status()!==201)fwrite(STDERR,substr($response->getContent(),0,400)."\n");$id=$response->assertCreated()->json('id');return Opportunity::findOrFail($id);
}
private function publication(Opportunity $o,array $patch=[]): array
{
$a=app(OpportunityManagement::class)->values($o)['proactive'];
return [...['etag'=>app(OpportunityManagement::class)->etag($o),'action'=>'disclose','title'=>$o->title,'summary'=>'SYNTHETIC QA ideia em preparação; não é oportunidade com participação disponível.','description'=>'Conteúdo público expressamente redigido.','limitations'=>'Conclusões locais por confirmar.','source_reference'=>'SYNTHETIC QA exemplo público','edition'=>'QA1','source_date'=>'2026-10-27','sector_id'=>$o->sector_id,'territory_ids'=>[$o->primary_territory_id]],...F::publication($a),...$patch];
}
private function publish(Opportunity $o): array
{
$p=$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$this->publication($o))->assertOk()->json();
$input=['token'=>$p['token'],'operation_key'=>(string)Str::uuid(),'confirm'=>true,'reason'=>'SYNTHETIC QA explicit public projection reviewed without private information'];
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar',$input)->assertOk();return $input;
}
public function test_legacy_records_without_analysis_add_no_publication_lookup(): void
{
$o=new Opportunity;$o->forceFill(['workflow_context'=>[]]);DB::enableQueryLog();DB::flushQueryLog();
$this->assertNull(app(ProactiveInvestment::class)->publicData($o));$this->assertSame([],DB::getQueryLog());DB::disableQueryLog();
}
public function test_create_recover_edit_with_etag_and_historical_foundation_without_financial_or_promoter_requirements(): void
{
$o=$this->create();$a=app(OpportunityManagement::class)->values($o)['proactive'];$tag=app(OpportunityManagement::class)->etag($o);
$this->assertNull($o->estimated_investment_amount);$this->assertNull($o->promoter_name);$this->assertNotNull($o->primary_territory_id);
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertOk()->assertInertia(fn($page)=>$page->where('values.proactive.need.state','hypothesis')->has('proactiveHistory',1));
$a['need']['state']='confirmed';$a['need']['confirmation']='SYNTHETIC QA confirmation of fixture conditions only, no real market validation.';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>$tag,'proactive'=>$a,'reason'=>'SYNTHETIC QA updated documented foundation for this fixture only'])->assertOk();
$this->assertSame('confirmed',app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
$history=app(ProactiveInvestment::class)->history($o);$this->assertCount(2,$history);$this->assertSame('hypothesis',$history[1]['state']);
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>$tag,'proactive'=>$a,'reason'=>'SYNTHETIC QA stale version cannot overwrite current analysis'])->assertStatus(409);
}
public function test_hypothesis_cannot_be_confirmed_without_documented_conditions_and_missing_is_not_zero(): void
{
$o=$this->create();$a=app(OpportunityManagement::class)->values($o)['proactive'];$a['need']['state']='confirmed';$a['need']['observed_demand']='';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA incomplete confirmation must preserve prior hypothesis'])->assertUnprocessable();
$this->assertSame('hypothesis',app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
$a['need']['state']='hypothesis';$a['idea']['resources']='';$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA unknown resource remains blank rather than zero'])->assertOk();
$this->assertNull(app(OpportunityManagement::class)->values($o->fresh())['proactive']['idea']['resources']);
}
public function test_methodology_alone_cannot_confirm_local_need(): void
{
$o=$this->create();$a=app(OpportunityManagement::class)->values($o)['proactive'];$a['need']['state']='confirmed';$a['evidence'][0]['purpose']='methodology';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA international methodology is not evidence of local demand'])->assertUnprocessable();
$this->assertSame('hypothesis',app(OpportunityManagement::class)->values($o->fresh())['proactive']['need']['state']);
}
public function test_creation_replay_and_duplicate_evidence_and_relations_do_not_duplicate_analysis(): void
{
$input=F::input();$id=$this->postJson('/minhas-oportunidades/drafts',$input)->assertCreated()->json('id');$this->postJson('/minhas-oportunidades/drafts',$input)->assertCreated()->assertJsonPath('id',$id);
$o=Opportunity::findOrFail($id);$a=$input['proactive'];$a['evidence'][]=[...$a['evidence'][0],'id'=>(string)Str::uuid()];
$this->putJson('/minhas-oportunidades/'.$id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA repeated evidence must be reconciled, not counted twice'])->assertUnprocessable();
$this->assertSame(1,Opportunity::where('management_creation_key',$input['creation_key'])->count());
}
public function test_document_from_another_scope_cannot_be_attached_or_reclassified_as_public(): void
{
$o=$this->create();$a=app(OpportunityManagement::class)->values($o)['proactive'];$a['evidence'][0]['document_id']=(string)Str::uuid();
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA document association must use existing access rules'])->assertUnprocessable();
}
public function test_existing_permissions_reject_unassigned_and_unrelated_operators(): void
{
$o=$this->create();$this->login(User::factory()->create());
$this->postJson('/minhas-oportunidades/drafts',F::input())->assertForbidden();
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertForbidden();
$this->login(F::operator());$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertForbidden();
$this->login($this->actor);$input=F::input();unset($input['proactive']);$this->postJson('/minhas-oportunidades/drafts',$input)->assertForbidden();
$this->assertNull($o->organization_id);
}
public function test_internal_attached_document_cannot_be_changed_to_public_through_analysis(): void
{
$o=$this->create();$doc=(string)Str::uuid();$hash=hash('sha256','SYNTHETIC QA private source');
DB::table('documents')->insert(['id'=>$doc,'file_name'=>'SYNTHETIC-QA-INTERNAL-LIST.txt','storage_key'=>'synthetic-qa/'.$doc,'media_type'=>'text/plain','sha256'=>$hash,'size_bytes'=>strlen('SYNTHETIC QA private source'),'uploaded_by'=>$this->actor->id,'status'=>'active','data_classification'=>'internal','malware_scan_status'=>'clean','malware_scanned_sha256'=>$hash]);
DB::table('opportunity_documents')->insert(['id'=>(string)Str::uuid(),'opportunity_id'=>$o->id,'document_id'=>$doc,'version'=>1,'created_by'=>$this->actor->id,'payload'=>json_encode(['title'=>'SYNTHETIC QA internal list','visibility'=>'internal'])]);
$a=app(OpportunityManagement::class)->values($o)['proactive'];$a['evidence'][0]['document_id']=$doc;$a['evidence'][0]['access']='public';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA original document restrictions cannot be overridden here'])->assertUnprocessable();
$this->assertSame('internal',DB::table('documents')->where('id',$doc)->value('data_classification'));
}
public function test_selected_public_relationship_and_assumptions_travel_without_private_reference_or_duplicate(): void
{
$o=$this->create();$a=app(OpportunityManagement::class)->values($o)['proactive'];$target=app(\App\Services\OpportunityClassification::class)->targets('project')->first(['id','name']);$this->assertNotNull($target);
$r=['id'=>(string)Str::uuid(),'kind'=>'project','target_id'=>$target->id,'relation'=>'hypothesis','reference'=>'PRIVATE-QA-RELATION-REFERENCE-NOT-FOR-PUBLICATION','evidence_ids'=>[$a['evidence'][0]['id']]];$a['relations']=[$r];
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA reference is a hypothesis, not access or contract'])->assertOk();$o->refresh();
$bad=$a;$bad['relations'][]=[...$r,'id'=>(string)Str::uuid()];$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$bad,'reason'=>'SYNTHETIC QA same reference cannot appear twice in the same analysis'])->assertUnprocessable();
$d=$this->publication($o);$d['proactive_public']=[...$d['proactive_public'],'relation_ids'=>[$r['id']],'resources'=>'SYNTHETIC QA public resource, no guaranteed capacity.','assumptions'=>'No contract, land or power guaranteed.','conditions'=>'Confirm actual local evidence.'];
$p=$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$d)->assertOk()->json();$this->assertSame($target->name,$p['projection']['proactive']['relations'][0]['label']);$this->assertStringNotContainsString('PRIVATE-QA-RELATION',json_encode($p['projection']));
$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar',['token'=>$p['token'],'operation_key'=>(string)Str::uuid(),'confirm'=>true,'reason'=>'SYNTHETIC QA explicitly reviewed public relationship and assumptions'])->assertOk();
$ref=app(\App\Services\InvestorContext::class)->reference('opportunity:'.$o->id);$this->assertSame('hypothesis',$ref['analysis']['proactive']['relations'][0]['relationship']);$this->assertSame('No contract, land or power guaranteed.',$ref['analysis']['proactive']['assumptions']);
}
public function test_publication_needs_explicit_review_and_rejects_internal_source_and_literal_leakage(): void
{
$o=$this->create();$data=$this->publication($o);unset($data['proactive_reviewed']);$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$data)->assertUnprocessable();
$data=$this->publication($o);$a=app(OpportunityManagement::class)->values($o)['proactive'];$data['proactive_public']['evidence_ids']=[$a['evidence'][1]['id']];$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$data)->assertUnprocessable();
$data=$this->publication($o,['description'=>$a['evidence'][1]['statement']]);$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$data)->assertUnprocessable();
$data=$this->publication($o);$data['proactive_public']['state']='confirmed';$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/previa',$data)->assertUnprocessable();
}
public function test_public_snapshot_separate_from_private_edits_and_anonymous_access_no_coordinates_required(): void
{
$o=$this->create();$private=$o->management_draft;$confirm=$this->publish($o);$this->postJson('/minhas-oportunidades/'.$o->id.'/divulgacao/confirmar',$confirm)->assertOk()->assertJsonPath('replayed',true);
$o->refresh();$this->assertSame($private,$o->management_draft);$this->assertFalse(app(ProactiveInvestment::class)->publicData($o)['economic_validation']);
$a=app(OpportunityManagement::class)->values($o)['proactive'];$a['idea']['solution']='PRIVATE-QA-NEW-UNPUBLISHED-SOLUTION';
$this->putJson('/minhas-oportunidades/'.$o->id.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA private update must not change published projection'])->assertOk();
$this->app['auth']->forgetGuards();$this->get('/oportunidades/'.$o->slug)->assertOk()->assertDontSee('PRIVATE-QA-');
$this->get('/minhas-oportunidades/'.$o->id.'/edit')->assertRedirect('/login');
$this->assertNotNull(app(ProactiveInvestment::class)->publicData($o->fresh()));
}
public function test_three_synthetic_pilots_reuse_existing_territories_and_catalogue_groups(): void
{
foreach(['Cahora Bassa','Montepuez','Gurué']as$pilot){$o=$this->create($pilot);$this->publish($o);$this->assertNotNull($o->fresh()->primary_territory_id);$this->assertTrue(app(\App\Services\InvestmentCatalogue::class)->query(['perspective'=>'ideas'])->whereKey($o->id)->exists());$this->assertFalse(app(\App\Services\InvestmentCatalogue::class)->query(['perspective'=>'needs'])->whereKey($o->id)->exists());}
$this->get('/oportunidades?perspective=ideas')->assertOk();
}
public function test_existing_facts_and_needs_do_not_require_irrelevant_idea_fields(): void
{
foreach(['exists','needs']as$kind){
$input=F::input();$input['proactive']['kind']=$kind;$id=$this->postJson('/minhas-oportunidades/drafts',$input)->assertCreated()->json('id');$o=Opportunity::findOrFail($id);
$d=$this->publication($o);unset($d['proactive_public']['proposal']);if($kind==='exists')unset($d['proactive_public']['need']);
$p=$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/previa',$d)->assertOk()->json();$this->assertSame($kind,$p['projection']['proactive']['group']);$this->assertSame('',$p['projection']['proactive']['proposal']);
$this->postJson('/minhas-oportunidades/'.$id.'/divulgacao/confirmar',['token'=>$p['token'],'operation_key'=>(string)Str::uuid(),'confirm'=>true,'reason'=>'SYNTHETIC QA only applicable questions for this content group'])->assertOk();
$this->assertTrue(app(\App\Services\InvestmentCatalogue::class)->query(['perspective'=>$kind])->whereKey($id)->exists());
}
}
public function test_authenticated_multi_idea_preparation_keeps_each_origin_and_deliberate_edits_in_existing_dossier(): void
{
$first=$this->create();$second=$this->create('Gurué');$this->publish($first);$this->publish($second);$investor=User::factory()->create();$this->login($investor);
$key=(string)Str::uuid();$create=['title'=>'SYNTHETIC QA deliberately chosen project title','client_key'=>$key,'reuse_consent'=>true];
$id=$this->postJson('/investimentos',$create)->assertCreated()->json('id');$this->postJson('/investimentos',$create)->assertCreated()->assertJsonPath('id',$id);
$before=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$input=['version'=>$before['case']['version'],'selection'=>[],'opportunity_choices'=>array_map(fn($o)=>['source'=>'opportunity:'.$o->id,'territory'=>'territory:'.$o->primary_territory_id],[$first,$second])];
$preview=$this->postJson('/investimentos/'.$id.'/contexto/previa',$input)->assertOk()->json();
$this->assertCount(2,$preview['opportunity_choices']);$this->assertSame('SYNTHETIC QA deliberately chosen project title',$preview['fields']['project_title']);
foreach($preview['opportunity_choices']as$c){$this->assertSame('ideas',$c['opportunity']['analysis']['proactive']['group']);$this->assertStringNotContainsString('PRIVATE-QA-',json_encode($c));}
$confirm=[...$input,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true];$this->postJson('/investimentos/'.$id.'/contexto/confirmar',$confirm)->assertOk();$this->postJson('/investimentos/'.$id.'/contexto/confirmar',$confirm)->assertOk()->assertJsonPath('replayed',true);
$saved=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();$this->assertCount(2,$saved['context']['opportunity_choices']);
$consumers=[];foreach([$first,$second]as$record){$consumer=$this->getJson('/investimentos/'.$id.'/contexto?opportunity=opportunity:'.$record->id)->assertOk()->json();$snapshot=json_decode($consumer['prefill']['simulator']['context_applicability_snapshot'],true);$this->assertCount(1,$snapshot['opportunity_choices']);$this->assertSame('opportunity:'.$record->id,$snapshot['opportunity_choices'][0]['opportunity']['key']);$this->assertSame('ideas',$snapshot['opportunity_choices'][0]['opportunity']['analysis']['proactive']['group']);$this->assertArrayNotHasKey('tax_benefit',$consumer['prefill']['simulator']);$consumers[]=$consumer;}$saved['consumer_contexts']=$consumers;
file_put_contents(base_path('outputs/proactive-investment-20261027/recovered-context.json'),json_encode($saved,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
$this->get('/investimentos/'.$id)->assertOk();$this->login($this->actor);$this->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Support;
use App\Models\User;
use App\Models\Sector;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Synthetic fixture only, exclusively in the isolated QA database. */
final class ProactiveInvestmentFixture
{
public static function guard(): void
{
$t=DB::selectOne('select current_database() as name,inet_server_port() as port');
throw_unless($t->name==='proactive_investment_20261027_test'&&(int)$t->port===55441,\RuntimeException::class,'Isolated proactive QA required');
}
public static function operator(): User
{
self::guard();
$actor=User::factory()->create(['name'=>'SYNTHETIC QA editorial operator','email'=>'proactive-'.Str::uuid().'@example.invalid','password'=>bcrypt('Synthetic-QA-only-2026!')]);
$actor->forceFill(['two_factor_secret'=>Crypt::encrypt('JBSWY3DPEHPK3PXP'),'two_factor_confirmed_at'=>now()])->save();
$org=(string)Str::uuid();
DB::table('organizations')->insert(['id'=>$org,'name'=>'SYNTHETIC QA proactivity operator','external_id'=>'SYNTHETIC-QA-'.$org,'organization_type'=>'private']);
DB::table('audit_events')->insert(['source_system'=>'portal-profiles-v1','entity_type'=>'authorization','action'=>'operator_organization_registered','data_classification'=>'restricted','after_data'=>json_encode(['organization_id'=>$org,'organization'=>['name'=>'SYNTHETIC QA proactivity operator','nuit'=>'SYNTHETIC-QA-'.$org],'source_reference'=>'SYNTHETIC QA designation, not a real appointment'])]);
app(PortalProfileAdministration::class)->insertGrant((string)Str::uuid(),User::factory()->create(),['user_id'=>$actor->id,'role'=>'promoter','organization_id'=>$org,'opportunity_types'=>OpportunityManagement::CATEGORIES,'responsibilities'=>[],'source'=>'internal-portal-designation']);
return $actor;
}
public static function analysis(string $pilot='Cahora Bassa'): array
{
$id=(string)Str::uuid();$private=(string)Str::uuid();
return ['version'=>1,'kind'=>'ideas','evidence'=>[
['id'=>$id,'category'=>'infrastructure','purpose'=>'local','statement'=>'SYNTHETIC QA: capacidade fictícia em '.$pilot.'; não é diagnóstico económico real.','interpretation'=>'Hipótese sintética, sem contrato ou oferta comprovados.','source'=>'SYNTHETIC QA exemplar público fictício '.$pilot,'locator'=>'Fixture QA, linha1','period'=>'Exercício2026 — cenário sintético','scope'=>$pilot.' — contexto piloto','coverage'=>'Cobertura fictícia; informação real por confirmar','verified_on'=>'2026-10-27','access'=>'public','document_id'=>null],
['id'=>$private,'category'=>'supply','purpose'=>'local','statement'=>'PRIVATE-QA-UNIQUE-SUPPLY-'.Str::uuid(),'interpretation'=>'PRIVATE-QA-INTERPRETATION-'.Str::uuid(),'source'=>'PRIVATE-QA-COMPANY-LIST-'.Str::uuid(),'locator'=>'PRIVATE-QA-LOCATOR-'.Str::uuid(),'period'=>'2026','scope'=>'Análise interna','coverage'=>'Não divulgar','verified_on'=>'2026-10-27','access'=>'internal','document_id'=>null],
],'need'=>['state'=>'hypothesis','problem'=>'SYNTHETIC QA lacuna por verificar em '.$pilot,'observed_supply'=>'Oferta real desconhecida; não equivale a ausência.','observed_demand'=>'Procura real não comprovada; cenário sintético.','type'=>'support','clients'=>'Beneficiários hipotéticos, sem encomenda ou compromisso.','evidence_ids'=>[$id],'limitations'=>'Não demonstra mercado nem viabilidade.','missing'=>'Dados locais reais por confirmar.','confirmation'=>'Fundamento de hipótese sintética, não confirmação económica.'],
'idea'=>['solution'=>'SYNTHETIC QA: '.match($pilot){'Montepuez'=>'fornecimentos e manutenção mineira','Gurué'=>'serviços da cadeia do chá',default=>'serviços complementares de energia'},'activity'=>'Actividade hipotética de apoio','chain_id'=>null,'stage'=>'Serviços de apoio — por corresponder','coverage'=>$pilot,'mode'=>'create','resources'=>'Não demonstrados; por inventariar','assumptions'=>'Não há acesso garantido a energia, terra ou cliente.','conditions'=>'Confirmar dados e autorizações aplicáveis.','pending'=>'Evidência económica real','next_action'=>'Recolher dados locais e testar a hipótese.'],'relations'=>[]];
}
public static function input(string $pilot='Cahora Bassa',bool $analysis=true): array
{
self::guard();
$t=DB::table('geo_locations')->whereNull('deleted_at')->where('data_classification','public')->where('location_type','district')->where('name','ilike',$pilot==='Gurué'?'Guru%':($pilot==='Cahora Bassa'?'CAHORA-BASSA':$pilot))->first();
throw_unless($t,\RuntimeException::class,'Existing pilot territory required:'.$pilot);
$path=app(\App\Services\InvestmentGeography::class)->path($t->id,true);$geo=[];foreach($path as $n)if(in_array($n->location_type,['province','district','admin_post','locality']))$geo[$n->location_type.'_id']=$n->id;
$s=Sector::choices()->whereNotNull('published_at')->where('slug',match($pilot){'Cahora Bassa'=>'energia-especifica','Montepuez'=>'mineracao',default=>'agronegocio'})->firstOrFail();
return ['creation_key'=>(string)Str::uuid(),'reason'=>'SYNTHETIC QA creating an independent territorial analysis, no active publication','title'=>'SYNTHETIC QA — '.$pilot,'summary'=>'Piloto técnico sintético; conclusões económicas reais por confirmar.','description'=>'Preparação editorial sintética em QA.','sector_id'=>$s->id,'opportunity_category'=>'investment_project',...$geo,...($analysis?['proactive'=>self::analysis($pilot)]:[])];
}
public static function publication(array $analysis): array
{
return ['proactive_reviewed'=>true,'proactive_public'=>['group'=>$analysis['kind'],'state'=>'hypothesis','need'=>'SYNTHETIC QA necessidade por verificar.','proposal'=>'SYNTHETIC QA solução em preparação.','basis'=>'Fixture pública sintética; não é evidência de procura local.','limitations'=>'Sem validação económica, contrato, terra ou energia garantidos.','next_action'=>'Confirmar evidências reais antes de decidir.','evidence_ids'=>[$analysis['evidence'][0]['id']],'chain_id'=>null,'stage'=>'Apoio — correspondência real por confirmar']];
}
}
@@ -0,0 +1,13 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {buildProjectDossierRows} from '../../resources/js/lib/project-dossier.ts';
import {proactiveInvestmentRows} from '../../resources/js/lib/proactive-investment-report.ts';
const source=(id,need)=>({key:'opportunity:'+id,title:'SYNTHETIC QA '+id,version:3,analysis:{proactive:{version:1,group:'ideas',state:'hypothesis',need,proposal:'Solução '+id,basis:'Fixture pública sintética',limitations:'Não comprova procura real.',next_action:'Confirmar dados reais.',evidence:[{statement:'QA facto '+id,source:'QA exemplar',locator:'Fixture linha1',period:'2026',scope:'Piloto',coverage:'Sintética',verified_on:'2026-10-27'}]}}});
test('absent proactivity is a no-op for accepted dossiers',()=>{assert.deepEqual(proactiveInvestmentRows({}),[]);assert.deepEqual(proactiveInvestmentRows({context_applicability_snapshot:'{"version":1,"opportunity_choices":[]}'}),[]);assert.deepEqual(proactiveInvestmentRows({context_opportunity_snapshot:'unreadable-original-preserved'}),[]);});
test('each idea retains origin,source,need,limits without summing or inventing economic values',()=>{const choices=[{opportunity:source(1,'A')},{opportunity:source(2,'B')}],d={context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:choices})},r=proactiveInvestmentRows(d);assert.equal(r.filter(x=>x[1]==='Origem da análise e ideia').length,2);assert.match(JSON.stringify(r),/opportunity:1/);assert.match(JSON.stringify(r),/opportunity:2/);assert.match(JSON.stringify(r),/Hipótese analítica/);assert.match(JSON.stringify(r),/não é zero/);assert.equal(d.context_applicability_snapshot,JSON.stringify({version:1,opportunity_choices:choices}));});
test('consumer selection only reports its idea even when other source contexts are present',()=>{const choices=[{opportunity:source(1,'A')},{opportunity:source(2,'B')}],r=proactiveInvestmentRows({context_opportunity:'opportunity:2',context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:choices})});assert.doesNotMatch(JSON.stringify(r),/opportunity:1/);assert.match(JSON.stringify(r),/opportunity:2/);});
test('explicit consumer snapshot is retained and repeat references do not duplicate report rows',()=>{const a=source(1,'A');let r=proactiveInvestmentRows({context_opportunity_snapshot:JSON.stringify({choice:{opportunity:a}})});assert.equal(r.filter(x=>x[1]==='Origem da análise e ideia').length,1);r=proactiveInvestmentRows({context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:[{opportunity:a},{opportunity:a}]})});assert.equal(r.filter(x=>x[1]==='Origem da análise e ideia').length,1);});
test('new API-recovered multi idea snapshot produces an independently scoped report',()=>{const d=JSON.parse(fs.readFileSync('outputs/proactive-investment-20261027/recovered-context.json','utf8'));const r=d.consumer_contexts.flatMap(c=>proactiveInvestmentRows(c.prefill.simulator));assert.equal(r.filter(x=>x[1]==='Origem da análise e ideia').length,2);assert.doesNotMatch(JSON.stringify(r),/PRIVATE-QA-/);fs.writeFileSync('outputs/proactive-investment-20261027/recovered-report-rows.json',JSON.stringify(r,null,2));});
test('resources,assumptions and selected relationships are retained in the existing dossier report',()=>{const a=source(7,'A');Object.assign(a.analysis.proactive,{resources:'QA recurso declarado',assumptions:'QA pressuposto explícito',conditions:'QA condição',relations:[{kind:'project',target_id:'QA-reference',label:'QA projecto existente',relationship:'hypothesis',basis:'QA fundamento público'}]});const draft={context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:[{opportunity:a}]})},rows=buildProjectDossierRows(draft);assert.match(JSON.stringify(rows),/QA pressuposto explícito/);assert.match(JSON.stringify(rows),/QA projecto existente/);assert.match(JSON.stringify(rows),/sem contrato, acesso ou participação presumidos/);});
test('existing facts hide unrelated need and idea rows without deleting previous preparation',()=>{const a=source(8,'Old need');a.analysis.proactive.group='exists';a.analysis.proactive.proposal='Old idea';const d={context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:[{opportunity:a}]})},before=d.context_applicability_snapshot,r=proactiveInvestmentRows(d);assert.equal(r.some(x=>x[1]==='Estado da necessidade'||x[1]==='Proposta de investimento'),false);assert.equal(d.context_applicability_snapshot,before);assert.match(JSON.stringify(r),/QA facto 8/);});
+21
View File
@@ -0,0 +1,21 @@
<?php
$root=dirname(__DIR__);
$operation=$argv[1]??'';
if(!in_array($operation,['routes','test'],true)||basename($root)!=='portal-investidor-proactive-qa-20261027')throw new RuntimeException('Explicit isolated QA workspace and routes/test operation required.');
foreach(['storage/framework/views','storage/framework/cache','storage/framework/sessions','bootstrap/cache','storage/logs','outputs/proactive-investment-20261027']as$dir)if(!is_dir($root.'/'.$dir))mkdir($root.'/'.$dir,0700,true);
foreach(['APP_BASE_PATH'=>$root,'APP_ENV'=>'testing','APP_KEY'=>'base64:'.base64_encode(str_repeat('Q',32)),'DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55441','DB_DATABASE'=>'proactive_investment_20261027_test','DB_USERNAME'=>'postgres','DB_PASSWORD'=>'','DB_URL'=>'','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array'] as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$loader=require $root.'/vendor/autoload.php';
spl_autoload_register(function($class)use($root){foreach(['App\\'=>'app/','Tests\\'=>'tests/','Database\\Factories\\'=>'database/factories/']as$prefix=>$dir){if(str_starts_with($class,$prefix)){$file=$root.'/'.$dir.str_replace('\\','/',substr($class,strlen($prefix))).'.php';if(is_file($file)){require $file;return;}}}},true,true);
$app=require $root.'/bootstrap/app.php';
(new Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables)->bootstrap($app);
(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
$c=config('database.connections.pgsql');
if($app->configurationIsCached()||$c['host']!=='127.0.0.1'||(int)$c['port']!==55441||$c['database']!=='proactive_investment_20261027_test'||!empty($c['url']))throw new RuntimeException('QA guard');
$pdo=new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$c['database'],$c['username'],$c['password']);
if($pdo->query('select current_database()')->fetchColumn()!==$c['database'])throw new RuntimeException('QA identity');
if($operation==='routes'){
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$status=Illuminate\Support\Facades\Artisan::call('wayfinder:generate',['--with-form'=>true]);
echo Illuminate\Support\Facades\Artisan::output();exit($status);
}
$argv=['phpunit','-c','phpunit.investmoz.xml','tests/Feature/ProactiveInvestmentTest.php'];if(getenv('PROACTIVE_QA_FILTER')){$argv[]='--filter';$argv[]=getenv('PROACTIVE_QA_FILTER');}$argc=count($argv);$_SERVER['argv']=$argv;$_SERVER['argc']=$argc;require $root.'/vendor/phpunit/phpunit/phpunit';