Publish agribusiness lot through scoped management and extend existing dossier for multiple opportunities
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-23 10:49:50 +02:00
parent 7d08c2c664
commit c641b267ac
22 changed files with 818 additions and 15 deletions
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
final class CorridorAgribusinessController extends Controller
{
private function manifest(CorridorAgribusinessOpportunities $service): string
{
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
}
public function show(Request $request, CorridorAgribusinessOpportunities $service)
{
$access = app(PortalProfileAccess::class);
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403);
$records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key');
foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403);
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]);
}
public function register(Request $request, CorridorAgribusinessOpportunities $service)
{
$data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
$access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project');
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.');
return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]);
}
public function preview(Request $request, CorridorAgribusinessOpportunities $service)
{
$previews = [];
foreach ($service->entries() as $entry) {
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
$input = data_get($record->workflow_context, 'promotional_summary.input');
abort_unless(is_array($input), 409, 'Complete a selecção pública na divulgação individual.');
$previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record,
[...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
}
return response()->json(['previews' => $previews]);
}
public function disclose(Request $request, CorridorAgribusinessOpportunities $service)
{
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']);
$expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all();
$supplied = array_column($data['previews'], 'id'); sort($supplied);
abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.');
return response()->json(DB::transaction(function () use ($request, $data) {
$result = [];
foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
return ['records' => $result];
}));
}
}
@@ -38,7 +38,12 @@ final class InvestorContextController extends Controller
$data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403);
return Inertia::render('investor-path/context', ['initial' => $data, 'options' => $context->options(), 'source' => $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
$ids = $r->validate(['opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1'])['opportunities'] ?? [];
$choices = $ids ? app(\App\Services\InvestmentOpportunityChoices::class)->resolve(array_map(fn ($id) => ['source' => 'opportunity:'.$id], $ids)) : null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
if ($choices !== null) $choices = collect([...($data['context']['opportunity_choices'] ?? []), ...$choices])->unique(fn ($choice) => $choice['opportunity']['key'])->values()->all();
return Inertia::render('investor-path/context', ['requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
}
public function preview(Request $r, string $id, InvestorContext $context)
@@ -56,7 +61,9 @@ final class InvestorContextController extends Controller
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
return Inertia::render('investor-path/context-start', ['contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
$ids = $r->validate(['opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1'])['opportunities'] ?? [];
$choices = $ids ? app(\App\Services\InvestmentOpportunityChoices::class)->resolve(array_map(fn ($id) => ['source' => 'opportunity:'.$id], $ids)) : [];
return Inertia::render('investor-path/context-start', ['opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install)
@@ -72,7 +72,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:1000000'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
@@ -0,0 +1,48 @@
<?php
namespace App\Services;
/** Preparation within the existing path; no licence, DUAT or fiscal benefit is granted. */
final class InvestmentContextRequirements
{
public const VERSION = 'context-requirements-20261023';
public function assess(array $context): array
{
$rows = [];
foreach ($context['opportunity_choices'] ?? [] as $choice) {
$activities = $choice['activities'] ?? [];
$key = $choice['opportunity']['key'];
$common = ['opportunity_key' => $key, 'opportunity_title' => $choice['opportunity']['title']];
$add = function ($code, $title, $state, $step, $authority, $documents, $basis, $url) use (&$rows, $common) {
$rows[] = [...$common, 'code' => $code, 'title' => $title, 'state' => $state, 'step' => $step,
'authority' => $authority, 'documents' => $documents, 'basis' => $basis, 'source_url' => $url,
'decision' => 'Preparação; enquadramento e acto concreto conservam confirmação na etapa correspondente.'];
};
$add('geography', 'Localização e geolocalização', $choice['territory'] ? 'selected' : 'missing', 'S09', 'Cadastro territorial e serviços de cadastro competentes',
['Referência administrativa confirmada', 'Planta/esboço e coordenadas quando disponíveis; não inventar polígonos'],
'Referência territorial escolhida no dossier; não comprova disponibilidade nem direito de uso.', '/investimentos');
$add('land', 'Acesso à terra e DUAT', 'conditional', 'S10', 'Serviços de cadastro e órgão competente para o pedido territorial concreto',
['Fundamento do uso: DUAT, arrendamento ou situação declarada', 'Planta/esboço do local; documentação do pedido correspondente'],
'Regulamento de Investimento, artigos 9(1)(c)–(d) e 14(1)(c)–(d). A preparação distingue DUAT e arrendamento; escolher território não atribui terra.', PrivateInvestmentPreparation::SOURCE.'#page=42');
$add('environment', 'Enquadramento ambiental', 'conditional', 'S08', 'Autoridade de Avaliação do Impacto Ambiental; competência central/provincial conforme categorização',
['Descrição da actividade, dimensão e localização', 'Instrumento ambiental definido pela categorização competente; acto e condições quando emitidos'],
'Decreto 54/2015: a natureza, dimensão e localização determinam o enquadramento; a categoria não é deduzida do corredor ou do sector.', 'https://sibmoz.gov.mz/legal-framework/');
$add('sector', 'Licenciamento sectorial', $activities ? 'declared' : 'missing', 'S08', 'BAU, IP e autoridade sectorial competente conforme a actividade',
['Actividades efectivas e modalidade', 'Requerimento e anexos da modalidade aplicável; acto quando emitido'],
'Identificar as actividades efectivas antes de seleccionar o procedimento. O nome Agronegócio não escolhe uma licença única.', 'https://www.bau.gov.mz/nossos-servicos/');
foreach (['processing' => ['Licenciamento industrial', 'Decreto 22/2014'], 'trade' => ['Licenciamento comercial', 'Decreto 34/2013']] as $activity => [$title, $basis]) {
if (in_array($activity, $activities, true)) $add($activity, $title, 'conditional', 'S08', 'BAU, IP / entidade licenciadora da modalidade',
['Caracterização do estabelecimento e actividade', 'Documentos e regime aplicáveis, incluindo simplificado ou mera comunicação quando preenchidas as suas condições'],
$basis.'; regimes 39/2017 e 27/2023 no catálogo oficial BAU. Dimensão e modalidade ainda devem ser especificadas; não se selecciona automaticamente o regime.', 'https://www.bau.gov.mz/legislacao/');
}
foreach (['water' => 'Uso de água', 'forestry' => 'Actividade florestal', 'animal' => 'Actividade pecuária', 'transport' => 'Transporte e logística'] as $activity => $title) {
if (in_array($activity, $activities, true)) $add($activity, $title, 'undetermined', 'S08', 'Autoridade sectorial competente a identificar para a modalidade declarada',
['Modalidade e dimensão da actividade', 'Fonte e documentação sectorial específica a associar ao dossier'],
'Actividade declarada; a referência do lote não contém o procedimento específico. Não se considera dispensada ou autorizada.', 'https://www.apiex.gov.mz/wp-content/uploads/2024/01/Mozambique-Business-Licensing-Guide.pdf');
}
}
return ['version' => self::VERSION, 'requirements' => $rows,
'fiscal_notice' => 'Localização, corredor, cluster ou projecto âncora não activam benefícios fiscais. O simulador mantém as condições, fonte, período e âmbito próprios; condições ausentes permanecem indeterminadas.'];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
/** Multiple identified references in the existing encrypted investment context. */
final class InvestmentOpportunityChoices
{
public function resolve(array $choices, array $previous = []): array
{
$result = [];
foreach ($choices as $choice) {
$ref = app(InvestorContext::class)->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.');
$record = Opportunity::findOrFail($ref['id']);
$documented = $record->primary_territory_id;
$territory = $choice['territory'] ?? ($documented ? 'territory:'.$documented : null);
$old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']);
$previousTerritory = $old['territory']['key'] ?? null;
$newAlternative = $documented && $territory !== 'territory:'.$documented
&& (! $old || ($old['documented_territory_id'] ?? null) !== $documented || $territory !== $previousTerritory);
if ($newAlternative || ($previousTerritory && $territory !== $previousTerritory)) {
abort_unless(! empty($choice['confirm_location_change']) && mb_strlen(trim($choice['location_reason'] ?? '')) >= 20,
422, 'Uma localização documentada ou já confirmada só muda por escolha explícita, com confirmação e motivo.');
}
$location = $territory ? app(InvestorContext::class)->reference($territory) : null;
abort_unless($location === null || $location['kind'] === 'territory', 422);
$result[] = ['opportunity' => $ref, 'territory' => $location,
'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [],
'activities' => $choice['activities'] ?? ($old['activities'] ?? []),
'documented_territory_id' => $documented, 'location_reason' => $choice['location_reason'] ?? '',
'location_state' => $location ? ($documented && $territory === 'territory:'.$documented ? 'documented' : 'investor_selected') : 'undetermined',
'location_changed_explicitly' => $old && $territory === $previousTerritory ? (bool) ($old['location_changed_explicitly'] ?? false) : (bool) ($choice['confirm_location_change'] ?? false),
'coverage' => data_get($record->workflow_context, 'corridor_agribusiness.corridor'),
'source' => data_get($record->workflow_context, 'corridor_agribusiness.source'),
'source_sha256' => data_get($record->workflow_context, 'corridor_agribusiness.source_sha256')];
}
return $result;
}
}
+35 -7
View File
@@ -268,7 +268,7 @@ final class InvestorContext
private function input(array $input): array
{
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
abort_if(array_diff(array_keys($input), ['opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.');
abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.');
@@ -280,7 +280,12 @@ final class InvestorContext
$inputRules['declarations.'.$question['key']] = isset($question['options']) ? ['nullable', Rule::in(array_keys($question['options']))] : ['nullable', 'regex:/^\d+(?:\.\d+)?$/D', 'numeric', 'min:0', 'max:999999999999999999'];
}
return Validator::make($input, [...$inputRules, 'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
return Validator::make($input, [...$inputRules,
'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities',
'opportunity_choices.*.activities' => 'sometimes|array', 'opportunity_choices.*.activities.*' => 'string|distinct|in:production,processing,trade,transport,water,forestry,animal',
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean',
'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
@@ -303,6 +308,23 @@ final class InvestorContext
unset($links[$key]);
}
}
$choices = $old['opportunity_choices'] ?? [];
if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) {
$source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null);
if ($source && str_starts_with($source, 'opportunity:')) {
$documented = Opportunity::findOrFail(substr($source, 12))->primary_territory_id;
abort_if($documented && $data['selection']['territory'] !== 'territory:'.$documented, 422,
'Confirme a alternativa territorial por ficha e indique o motivo; a localização documentada foi preservada.');
}
}
if (array_key_exists('opportunity_choices', $data)) {
$choices = app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $choices);
$data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
// Shared fields concern the first identified opportunity; other sites remain separate.
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null;
} elseif (array_key_exists('opportunity', $data['selection']) && $choices !== []) {
abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.');
}
$selection = $old['selection'];
foreach ($data['selection'] as $slot => $key) {
if ($key === null || $key === '') {
@@ -371,10 +393,12 @@ final class InvestorContext
}
}
$changedFields = array_column($replaced, 'field');
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []);
if ($choicesChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$dependencyChanged = $choicesChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
$calculationInputsChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
$calculationInputsChanged = $choicesChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
$payload = $this->desk->decode($case->payload);
$results = $payload['investor_path']['results'] ?? [];
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
@@ -392,11 +416,12 @@ final class InvestorContext
$afterPreparation = app(PrivateInvestmentPreparation::class)->assess(['fields' => $fields, 'selection' => $selection, 'preparation_documents' => $links], $docs);
$privatePreparation = ['before' => $beforePreparation, 'after' => $afterPreparation];
}
$plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
$plan = ['opportunity_choices' => $choices, 'case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
'preparation_documents' => $links, 'private_investment' => $privatePreparation,
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $calculationInputsChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'calculation_inputs_changed' => $calculationInputsChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
'notice' => 'Não confirma incentivos, licenças, disponibilidade de terra, reserva ou autorização. Submissões e decisões anteriores mantêm o conteúdo enviado.'];
$plan['requirements_assessment'] = app(InvestmentContextRequirements::class)->assess(['opportunity_choices' => $choices, 'fields' => $fields]);
$hash = hash_hmac('sha256', json_encode(Arr::sortRecursive([$actor->id, $data, $plan]), JSON_THROW_ON_ERROR), config('app.key'));
return [...$plan, 'preview_hash' => $hash];
@@ -435,8 +460,9 @@ final class InvestorContext
foreach ($plan['review_steps'] as $step) {
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
}
$context = ['preparation_documents' => $plan['preparation_documents'], 'preparation_snapshot' => $plan['private_investment']['after'] ?? $before['preparation_snapshot'] ?? null, 'revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
$context = ['opportunity_choices' => $plan['opportunity_choices'], 'preparation_documents' => $plan['preparation_documents'], 'preparation_snapshot' => $plan['private_investment']['after'] ?? $before['preparation_snapshot'] ?? null, 'revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash([...($plan['opportunity_choices'] !== [] ? ['opportunity_choices' => $plan['opportunity_choices']] : []), 'selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
$context['requirements_assessment'] = $plan['requirements_assessment'];
if ($plan['calculation_inputs_changed']) {
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
@@ -494,6 +520,8 @@ final class InvestorContext
'version' => 1, 'context_revision' => $ctx['revision'],
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null,
'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
@@ -4,6 +4,8 @@ Data: 2026-10-03. Âmbito: prompt master recebido em `Pasted text.txt`, document
## Estado inicial e ambiente
**Orientação de publicação e percurso do lote, 23/10/2026:** as 34 oportunidades de Agronegócio ficam publicáveis sem relações obrigatórias com corredores/clusters/projectos. Lacuna encontrada: a comparação múltipla existente não era transportada ao dossier. Correcção aditiva no contexto cifrado existente, com escolhas identificadas e localização por ficha, confirmação de alternativa, requisitos/fontes/documentos/entidades por contexto, reutilização no simulador e conservação de resultados/histórico. Sete testes do contexto (132 asserções) e um do lote pela sessão HTTP (15) passaram. Publicação QA efectuada; instalação no destino separa código, cadastro privado e divulgação autenticada. Ver [orientação e guia no manual](unification/traceability-and-manual.md). Não altera Turismo de Inhambane nem concede benefícios por localização. Lacunas de modalidade/dimensão/acto concreto permanecem explícitas, sem nova revalidação de fontes oficiais aceites.
**Correcção de âmbito, 23/10/2026 — agronegócio e percurso existente:** a lista informativa instalada não encerrava o cadastro. Preparadas e efectivamente cadastradas 34 oportunidades privadas em QA, com as seis designações documentais intactas, sector, fonte/hash e lacunas explícitas. Reutilizada a candidata de divulgação promocional `9d3bfa89` no fluxo necessário, sem alterações independentes de administração de contas nem novos papéis. Equipa edita/recupera e divulga/actualiza/retira pela interface; público acede à ficha individual, manifestação e roteiro com referência confirmada no mesmo dossier. Três testes específicos, 553 asserções; tipos/build concluídos; tabelas/linhas preexistentes comparadas e preservadas. As divulgações dos testes foram revertidas; as 34 fichas permanecem privadas. Sem alteração da homologação nesta correcção. Carregamento inicial técnico QA já executado; não é dependência das operações correntes. Guia, limites e recibo na [secção de correcção do manual](unification/traceability-and-manual.md#correcção-de-âmbito--34-oportunidades-cadastradas-e-roteiro-em-qa-23102026). Não encerra viabilidade, implantação, financiamento ou execução das oportunidades.
| Item | Evidência observada |
@@ -0,0 +1,246 @@
{
"target": {
"name": "simulator_fiscal_delta_20261021_test",
"port": 55441
},
"published": 34,
"authentication": "QA role simulation only; no production authentication claimed",
"source_sha256": "1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055",
"records": [
{
"id": 269,
"title": "Batata, trigo, feijão, milho e soja — Corredor de Pemba–Lichinga",
"corridor": "Corredor de Pemba–Lichinga",
"url": "/oportunidades/inv-mz-gen-5150397406444731"
},
{
"id": 270,
"title": "Algodão — Corredor de Pemba–Lichinga",
"corridor": "Corredor de Pemba–Lichinga",
"url": "/oportunidades/inv-mz-gen-1647249176453799"
},
{
"id": 271,
"title": "Sésamo — Corredor de Pemba–Lichinga",
"corridor": "Corredor de Pemba–Lichinga",
"url": "/oportunidades/inv-mz-gen-3352774267583285"
},
{
"id": 272,
"title": "Florestas — Corredor de Pemba–Lichinga",
"corridor": "Corredor de Pemba–Lichinga",
"url": "/oportunidades/inv-mz-gen-7850139886806206"
},
{
"id": 273,
"title": "Avicultura — Corredor de Pemba–Lichinga",
"corridor": "Corredor de Pemba–Lichinga",
"url": "/oportunidades/inv-mz-gen-3839727014705762"
},
{
"id": 274,
"title": "Mandioca — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-8963722873273976"
},
{
"id": 275,
"title": "Milho, amendoim e sésamo — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-9998208319499345"
},
{
"id": 276,
"title": "Algodão — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-6731688736856562"
},
{
"id": 277,
"title": "Frutas e caju — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-2325832878312017"
},
{
"id": 278,
"title": "Florestas — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-8943819075565910"
},
{
"id": 279,
"title": "Avicultura — Corredor de Nacala",
"corridor": "Corredor de Nacala",
"url": "/oportunidades/inv-mz-gen-3960434222994703"
},
{
"id": 280,
"title": "Arroz — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-4676568685660682"
},
{
"id": 281,
"title": "Milho e batata — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-9955994100950689"
},
{
"id": 282,
"title": "Sésamo — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-5272341950046110"
},
{
"id": 283,
"title": "Algodão — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-5628419400094441"
},
{
"id": 284,
"title": "Avicultura — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-8668117557933061"
},
{
"id": 285,
"title": "Bovinos e caprinos — Corredor do Vale do Zambeze",
"corridor": "Corredor do Vale do Zambeze",
"url": "/oportunidades/inv-mz-gen-6651453468338687"
},
{
"id": 286,
"title": "Milho, trigo e arroz — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-8128691124566423"
},
{
"id": 287,
"title": "Soja — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-5563232446855414"
},
{
"id": 288,
"title": "Hortícolas — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-5715109945209502"
},
{
"id": 289,
"title": "Sésamo — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-6307033318408482"
},
{
"id": 290,
"title": "Açúcar — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-8123001467176624"
},
{
"id": 291,
"title": "Florestas — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-5629890674429876"
},
{
"id": 292,
"title": "Carne bovina — Corredor da Beira",
"corridor": "Corredor da Beira",
"url": "/oportunidades/inv-mz-gen-2472671405287788"
},
{
"id": 293,
"title": "Arroz — Corredor do Limpopo",
"corridor": "Corredor do Limpopo",
"url": "/oportunidades/inv-mz-gen-5995177607070005"
},
{
"id": 294,
"title": "Hortícolas — Corredor do Limpopo",
"corridor": "Corredor do Limpopo",
"url": "/oportunidades/inv-mz-gen-7967808283045554"
},
{
"id": 295,
"title": "Carne vermelha — Corredor do Limpopo",
"corridor": "Corredor do Limpopo",
"url": "/oportunidades/inv-mz-gen-5036463681202017"
},
{
"id": 296,
"title": "Avicultura — Corredor do Limpopo",
"corridor": "Corredor do Limpopo",
"url": "/oportunidades/inv-mz-gen-5524442722730781"
},
{
"id": 297,
"title": "Infraestruturas e serviços de apoio — Corredor do Limpopo",
"corridor": "Corredor do Limpopo",
"url": "/oportunidades/inv-mz-gen-2099729870926704"
},
{
"id": 298,
"title": "Arroz — Corredor de Maputo",
"corridor": "Corredor de Maputo",
"url": "/oportunidades/inv-mz-gen-1866403697817634"
},
{
"id": 299,
"title": "Hortícolas — Corredor de Maputo",
"corridor": "Corredor de Maputo",
"url": "/oportunidades/inv-mz-gen-6293243388893029"
},
{
"id": 300,
"title": "Carne vermelha — Corredor de Maputo",
"corridor": "Corredor de Maputo",
"url": "/oportunidades/inv-mz-gen-9330026487459372"
},
{
"id": 301,
"title": "Avicultura — Corredor de Maputo",
"corridor": "Corredor de Maputo",
"url": "/oportunidades/inv-mz-gen-1761515912486377"
},
{
"id": 302,
"title": "Infraestruturas e serviços de apoio — Corredor de Maputo",
"corridor": "Corredor de Maputo",
"url": "/oportunidades/inv-mz-gen-2643789930780297"
}
],
"active_writes": 0,
"checks": {
"context_tests": 7,
"context_assertions": 132,
"batch_tests": 1,
"batch_assertions": 15,
"frontend_types": "passed",
"frontend_build": "passed",
"browser_catalogue_results": 34,
"browser_selected_opportunities": 2
},
"preservation": {
"target": {
"name": "simulator_fiscal_delta_20261021_test",
"port": 55441
},
"records": 34,
"private_drafts": 0,
"preserved": {
"opportunities": true,
"audit_events": true,
"development_corridors": true,
"portal_role_assignments": true,
"portal_resources": true,
"documents": true
},
"active_writes": 0,
"published_in_qa": 34
},
"qa_catalogue_url": "http://127.0.0.1:18574/oportunidades?sector=agronegocio",
"homologation_status": "Backup/restore verified; code delivery and authenticated registration/disclosure still separate."
}
@@ -1,5 +1,23 @@
# Rastreabilidade, utilização e formação
## Orientação do lote de Agronegócio — catálogo e selecção múltipla, 23/10/2026
Decisão expressa da Tempo nesta passagem: publicar as 34 fichas no catálogo de Agronegócio e permitir escolher uma ou várias, confirmar a localização por ficha, preparar o investimento com reutilização dos dados e consultar requisitos/documentos/entidades, simular e acompanhar no dossier autenticado. Corredores, clusters e projectos âncora enriquecem a análise e não são pré-condição de publicação; localização não activa benefícios fiscais. Não alterar Turismo de Inhambane. Esta orientação supera o estado privado do recibo QA anterior, conservado como histórico.
**Conferência do existente e delta:** o catálogo já comparava quatro fichas e o dossier já tinha 19 etapas, prévia/confirmação de contexto, mapas, reutilização no simulador/terra/Balcão, pedidos e histórico. Faltava transportar uma escolha múltipla identificada para esse mesmo dossier. Acrescentado `opportunity_choices` no contexto cifrado existente, sem tabela, migration, novo dossier paralelo ou novo papel. Cada escolha conserva ID, título, versão/hash, fonte, corredor documental e referência territorial própria. A escolha do catálogo persiste ao mudar de página; ao continuar um dossier, referências novas são acrescentadas às existentes, sem eliminar silenciosamente as anteriores. A comparação de quatro fichas permanece independente da escolha de investimento. A guarda de 100 referências por pedido é uma escolha técnica de tamanho, não nova decisão organizacional.
**Percurso da equipa e do interessado:**
1. Na gestão única, abrir **Rever o lote das 34 oportunidades de Agronegócio** (`/minhas-oportunidades/lotes/agronegocio`). Conferir o manifesto e cadastrar/recuperar privadamente. Depois, preparar a prévia dos 34 textos, rever por ficha e confirmar a divulgação com motivo e MFA recente. A publicação não depende de relações com mestres. Edição, actualização ou retirada individual continuam na divulgação promocional existente.
2. No catálogo, marcar **Escolher para preparar investimento** em uma ou várias fichas e usar **Preparar investimento com as oportunidades escolhidas**. Entrar normalmente, continuar um dossier autorizado ou criar um novo. Confirmar cada ficha e a localização no editor do mesmo contexto.
3. Localização documentada é recuperada. Uma alternativa exige confirmação explícita e motivo; a ficha de origem fica intacta. Uma alternativa já confirmada não exige repetir a decisão enquanto se mantiver. Sem informação de implantação, escolher o nível territorial conhecido ou conservar a lacuna identificada; o mapa não inventa limite, parcela ou DUAT.
4. Declarar as actividades efectivas por ficha. A prévia e o dossier apresentam requisitos por oportunidade: localização/geolocalização, acesso à terra/DUAT, ambiente, enquadramento sectorial e ramos industrial/comercial quando declarados, com fundamento, documentos, entidade e ligação à etapa existente. Reutilizadas as condições e fontes da preparação do investimento privado. Fontes adicionais de consulta: [BAU — legislação](https://www.bau.gov.mz/legislacao/), [serviços BAU](https://www.bau.gov.mz/nossos-servicos/) e [quadro ambiental oficial](https://sibmoz.gov.mz/legal-framework/). Categoria ambiental, modalidade de licença e competência concreta dependem de actividade, dimensão e local; não são deduzidas de um corredor. Água, florestas, pecuária e transporte mantêm visível a necessidade do procedimento sectorial específico quando não identificado no acervo aplicado. Não é nova validação genérica da fonte nem dispensa de licença.
5. Confirmar a prévia. Título próprio e dados declarados são preservados; resumo/sector/local da referência principal são reutilizados. As outras fichas e localizações mantêm-se individualizadas: não se somam investimentos ou benefícios presumidos. Abrir as ferramentas e o acompanhamento pelas ligações do roteiro. O snapshot da simulação conserva o conjunto de escolhas, fontes e requisitos; a guarda textual foi aumentada de 20.000 para 1.000.000 caracteres para comportar o lote, sem mudança de schema. Mudança de actividade/local/escolha identifica resultados para revisão e preserva os valores e o histórico anteriores.
**Provas do delta QA:** sete testes específicos/de continuidade, 132 asserções; selecção múltipla, localizações distintas, recusa de alteração territorial silenciosa, não repetição da confirmação já aceite, recusa de fonte alterada após prévia, escopo de acesso, regras por actividade, histórico/resultados preservados e guarda das 34 escolhas no simulador. Prova adicional da operação de lote pela sessão HTTP normal: um teste, 15 asserções, incluindo manifesto divergente, prévia exacta de 34, repetição idempotente, recusa sem MFA/âmbito e preservação das restantes oportunidades. As 34 fichas foram efectivamente divulgadas **em QA**, como Em preparação; a simulação inicial de papéis QA não constitui autenticação real de homologação. Conferidos também no navegador 34 resultados e escolha de duas fichas com acção de preparação. Não repetidas as suites gerais aceites.
**Disponibilização no destino:** revisão servida inicial `761035ae`; 30 oportunidades preexistentes preservadas no inventário. Conta real de cadastro e organização operadora localizadas pelas designações existentes, sem criar papéis ou contas. Backup/restauro fresco em `/var/backups/investmoz-agribusiness-opportunities-20261023/backup`: 174 tabelas e 369 ficheiros conferidos. A integração Git de código é separada do cadastro/divulgação autenticados pela página do lote. Sem migrations ou importação de ficheiros privados; os originais e artigos existentes permanecem no armazenamento. Reversão de visibilidade usa retirada auditada pela interface, sem restaurar a base nem apagar manifestações/dossiers posteriores; reversão de código deve preservar o novo contexto cifrado e as referências adicionadas. A conferência final do destino e o recibo devem ser registados abaixo após execução.
## Correcção de âmbito — 34 oportunidades cadastradas e roteiro em QA, 23/10/2026
A lista informativa anteriormente instalada no artigo não cumpria o cadastro pedido pela Tempo. Esta correcção cria **34 fichas privadas reais na base QA isolada**, recuperáveis pela gestão existente, com título, resumo e descrição da entrada, sector Agronegócio, nome exacto do corredor documental, fonte e hash do DOCX. Conserva as seis secções e os totais 5/6/6/7/5/5. Não modifica a homologação, os corredores mestres, dossiers anteriores ou o original do artigo. Não transforma a organização operadora no promotor dos investimentos. Implantação, área, montantes, promotores, viabilidade e licenças ausentes ficam explicitamente por completar; nenhuma correspondência com corredores de nomes mais amplos foi inferida.
@@ -0,0 +1,26 @@
import { Link } from '@inertiajs/react';
import { TerritoryFields } from './investment-territory';
import type { OpportunityChoice } from '@/lib/investor-context';
import { card, input } from '@/pages/investment/shared';
export default function OpportunityChoices({choices, onChange, disabled = false}: {choices: OpportunityChoice[]; onChange?: (choices: OpportunityChoice[]) => void; disabled?: boolean}) {
const update = (index: number, change: Partial<OpportunityChoice>) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c));
return <section className="space-y-4" aria-label="Oportunidades e respectivas localizações">
<h2 className="text-xl font-bold">Oportunidades escolhidas e localização de cada ficha</h2>
<p>Cada ficha conserva a sua identidade e fonte. Os dados gerais e a simulação têm como referência principal a primeira ficha; não somam investimentos ou benefícios de fichas diferentes.</p>
{choices.map((c,i) => <article key={c.opportunity.key} className={card + ' space-y-3'}>
<h3 className="font-semibold"><Link className="underline" href={c.opportunity.href}>{i + 1}. {c.opportunity.title}</Link></h3>
<p>{c.opportunity.key} · versão {c.opportunity.version ?? 'não indicada'}</p>
{c.coverage && <p>Abrangência documental: {c.coverage}</p>}
<p>Localização: {c.territory?.title ?? 'Por escolher — não existe implantação documentada nesta ficha.'}</p>
{c.source && <p>Fonte: {c.source}</p>}
{onChange && <>
<fieldset><legend>Actividades que pretende realizar nesta oportunidade</legend>{Object.entries({production:'Produção agrícola',processing:'Transformação agroindustrial',trade:'Comercialização e exportação',transport:'Transporte e logística',water:'Captação/utilização de água',forestry:'Actividade florestal',animal:'Produção/serviços pecuários'}).map(([key,label])=><label key={key} className="mr-4 inline-block"><input type="checkbox" checked={c.activities?.includes(key) ?? false} onChange={e=>update(i,{activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label}</label>)}</fieldset>
<TerritoryFields initial={c.territory_path} disabled={disabled} onChange={path => update(i,{territory_path:path,territory:path.length ? {key:'territory:'+path.at(-1)!.id,kind:'territory',id:path.at(-1)!.id,title:path.map(p=>p.name).join(' / '),href:'/oportunidades',version:null,sha256:'',fields:{}} : null})}/>
<label className="block">Motivo da escolha ou da alternativa territorial<textarea className={input} value={c.location_reason} onChange={e=>update(i,{location_reason:e.target.value})}/></label>
<label className="block"><input type="checkbox" checked={!!c.confirm_location_change} onChange={e=>update(i,{confirm_location_change:e.target.checked})}/>Confirmo expressamente a alteração da localização documentada ou anteriormente escolhida, quando existir.</label>
<button type="button" className="underline" onClick={()=>onChange(choices.filter((_,index)=>index!==i))}>Retirar esta ficha da escolha</button>
</>}
</article>)}
</section>;
}
@@ -0,0 +1,19 @@
import { Link } from '@inertiajs/react';
import type { RequirementsAssessment } from '@/lib/investor-context';
import { card } from '@/pages/investment/shared';
export default function InvestmentRequirements({assessment, contextId}: {assessment?: RequirementsAssessment | null; contextId: string}) {
if (!assessment?.requirements.length) return null;
const labels: Record<string,string> = {selected:'Localização escolhida; direito não inferido',missing:'Informação em falta',conditional:'Conforme as condições do projecto',declared:'Actividades declaradas; enquadrar modalidade',undetermined:'Procedimento específico por determinar'};
const groups = new Map<string, RequirementsAssessment['requirements']>();
for (const row of assessment.requirements) groups.set(row.opportunity_key,[...(groups.get(row.opportunity_key)??[]),row]);
return <section className="space-y-4" aria-label="Regras e documentos por oportunidade"><h2 className="text-xl font-bold">Requisitos por oportunidade e contexto</h2>
<p>{assessment.fiscal_notice}</p>
{[...groups.entries()].map(([key, rows])=><details key={key} className={card}><summary className="cursor-pointer font-semibold">{rows[0].opportunity_title} · {rows.length} aspectos a conferir</summary>{rows.map(r=><article key={r.opportunity_key+':'+r.code} className="my-3 space-y-2 border-t pt-3">
<h3 className="font-semibold">{r.title} — {r.opportunity_title}</h3><p>{labels[r.state] ?? r.state}</p><p>Entidade: {r.authority}</p><p>{r.basis}</p>
<ul className="list-disc pl-5">{r.documents.map(d=><li key={d}>{d}</li>)}</ul>
<a className="underline" href={r.source_url}>Consultar fundamento e procedimento</a>{' · '}<Link className="underline" href={`/investimentos/${contextId}#${r.step}`}>Preparar e acompanhar em {r.step}</Link>
<p>{r.decision}</p>
</article>)}</details>)}
</section>;
}
+17
View File
@@ -38,7 +38,22 @@ export type ContextReference = {
visibility?: 'private' | 'public';
state?: string | null;
};
export type RequirementsAssessment = {version: string; fiscal_notice: string; requirements: {opportunity_key: string; opportunity_title: string; code: string; title: string; state: string; step: string; authority: string; documents: string[]; basis: string; source_url: string; decision: string}[]};
export type OpportunityChoice = {
activities?: string[];
territory_path?: import('@/components/investment-territory').TerritoryOption[];
confirm_location_change?: boolean;
opportunity: ContextReference;
territory: ContextReference | null;
location_reason: string;
location_state: string;
coverage?: string | null;
source?: string | null;
source_sha256?: string | null;
};
export type DossierContext = {
requirements_assessment?: RequirementsAssessment;
opportunity_choices?: OpportunityChoice[];
revision: number;
selection: Partial<
Record<
@@ -79,6 +94,8 @@ export type ContextView = {
notice: string;
};
export type ContextPlan = {
requirements_assessment?: RequirementsAssessment;
opportunity_choices?: OpportunityChoice[];
private_investment?: { before: Preparation; after: Preparation } | null;
preview_hash: string;
version: number;
@@ -49,6 +49,13 @@ export default function Catalogue({
}) {
const page = usePage<{ auth: { user: unknown } }>();
const { auth } = page.props;
const [investmentSelection, setInvestmentSelection] = useState<number[]>(() => {
try { return JSON.parse(sessionStorage.getItem('investment-opportunity-selection') ?? '[]'); } catch { return []; }
});
function selectInvestment(id: number, checked: boolean) {
const next = checked ? [...new Set([...investmentSelection, id])] : investmentSelection.filter(x => x !== id);
setInvestmentSelection(next); sessionStorage.setItem('investment-opportunity-selection', JSON.stringify(next));
}
const form = useForm({
association_type: String(filters.association_type ?? ''),
association_target: String(filters.association_target ?? ''),
@@ -603,6 +610,7 @@ export default function Catalogue({
? 'Retirar favorito'
: 'Guardar favorito'}
</button>
<label className="flex gap-2"><input type="checkbox" checked={investmentSelection.includes(r.id)} onChange={e => selectInvestment(r.id,e.target.checked)} />Escolher para preparar investimento</label>
<label className="flex gap-2">
<input
type="checkbox"
@@ -662,6 +670,7 @@ export default function Catalogue({
Comparar {selected.length} oportunidades
</button>
)}
{!!investmentSelection.length && <section className={card}><p>{investmentSelection.length} oportunidades escolhidas, conservadas ao mudar de página.</p><Link className={button} href={'/investimentos/iniciar?' + investmentSelection.map(id => 'opportunities[]='+id).join('&')}>Preparar investimento com as oportunidades escolhidas</Link><button className="ml-4 underline" onClick={() => {setInvestmentSelection([]);sessionStorage.removeItem('investment-opportunity-selection');}}>Limpar escolha</button></section>}
{comparison.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
@@ -2,15 +2,17 @@ import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
import { Frame, card, input, button } from '@/pages/investment/shared';
import { contextApi, type ContextReference } from '@/lib/investor-context';
import { contextApi, type ContextReference, type OpportunityChoice } from '@/lib/investor-context';
import { createOperationKey } from '@/lib/operation-key';
export default function ContextStart({
contexts,
source,
opportunityChoices = [],
step = null,
}: {
contexts: { id: string; title: string; version: number }[];
source: ContextReference | null;
opportunityChoices?: OpportunityChoice[];
step?: string | null;
}) {
const [chosen, setChosen] = useState('');
@@ -19,9 +21,10 @@ export default function ContextStart({
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [key] = useState(createOperationKey);
const suffix = source ? '?source=' + encodeURIComponent(source.key) : '';
const suffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
return (
<Frame title="Iniciar ou continuar um investimento">
{!!opportunityChoices.length && <section className={card}><h2 className="font-bold">Oportunidades escolhidas</h2><ul>{opportunityChoices.map(c => <li key={c.opportunity.key}><Link className="underline" href={c.opportunity.href}>{c.opportunity.title}</Link> · {c.territory?.title ?? 'Localização por escolher'}</li>)}</ul><p>Confirme a localização de cada ficha na comparação do dossier.</p></section>}
<p>
Ideia, oportunidade, sector e localização convergem no mesmo
dossier. A referência é para preparação e não confirma direitos
+18 -3
View File
@@ -20,6 +20,9 @@ import {
} from '@/lib/investor-context';
import { useNavigationContext } from '@/components/navigation-context';
import { createOperationKey } from '@/lib/operation-key';
import OpportunityChoices from '@/components/investment-opportunity-choices';
import InvestmentRequirements from '@/components/investment-requirements';
import type { OpportunityChoice } from '@/lib/investor-context';
type Options = {
opportunities: { key: string; title: string; kind: string }[];
sectors: { id: number; name: string }[];
@@ -32,13 +35,17 @@ export default function ContextEditor({
options: initialOptions,
source,
territorySelection = [],
requestedChoices = null,
}: {
initial: ContextView;
options: Options;
source: ContextReference | null;
territorySelection?: TerritoryOption[];
requestedChoices?: OpportunityChoice[] | null;
}) {
const navigation = useNavigationContext();
const [choices, setChoices] = useState<OpportunityChoice[]>(requestedChoices ?? initial.context.opportunity_choices ?? []);
const [manageChoices, setManageChoices] = useState(requestedChoices !== null || !!initial.context.opportunity_choices?.length);
const [view, setView] = useState(initial);
const [preparation, setPreparation] = useState(initial.private_investment);
const [options, setOptions] = useState(initialOptions);
@@ -107,6 +114,7 @@ export default function ContextEditor({
setError('');
setMessage('');
const body = {
...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
version: view.case.version,
selection: changes,
declarations,
@@ -149,6 +157,7 @@ export default function ContextEditor({
`/investimentos/${view.case.id}/contexto`,
);
setView(next);
setChoices(next.context.opportunity_choices ?? []);
setPreparation(next.private_investment);
navigation.refresh();
setChanges({});
@@ -177,6 +186,7 @@ export default function ContextEditor({
raw={view.prefill.simulator?.context_applicability_snapshot}
/>
<ContextDocuments source={view.context.selection.opportunity} />
<InvestmentRequirements assessment={view.context.requirements_assessment} contextId={view.case.id}/>
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
className="underline"
@@ -210,6 +220,8 @@ export default function ContextEditor({
)}
{message && <p role="status">{message}</p>}
<section className={card + ' space-y-4'}>
<OpportunityChoices choices={choices} disabled={busy} onChange={next=>{setChoices(next);setManageChoices(true);setPlan(null);}}/>
<label className="block">Acrescentar uma oportunidade à escolha<select className={input} value="" onChange={e=>{const o=options.opportunities.find(o=>o.key===e.target.value);if(o && !choices.some(c=>c.opportunity.key===o.key)){setChoices([...choices,{opportunity:{...o,id:o.key.split(':')[1],href:'/oportunidades',version:null,sha256:'',fields:{}},territory:null,location_reason:'',location_state:'undetermined'}]);setManageChoices(true);setPlan(null);}}}><option value="">Seleccionar ficha</option>{options.opportunities.filter(o=>o.kind==='opportunity'&&!choices.some(c=>c.opportunity.key===o.key)).map(o=><option key={o.key} value={o.key}>{o.title}</option>)}</select></label>
<h2 className="text-xl font-bold">
Escolher referências existentes
</h2>
@@ -241,11 +253,11 @@ export default function ContextEditor({
</button>
</div>
<label className="grid min-w-0 gap-1">
Oportunidade
Referência individual (sem selecção múltipla)
<select
className={input + ' max-w-full min-w-0'}
value={selected('opportunity')}
disabled={busy}
disabled={busy || manageChoices}
onChange={(e) =>
change('opportunity', e.target.value)
}
@@ -353,7 +365,7 @@ export default function ContextEditor({
</h3>
<TerritoryFields
initial={territorySelection}
disabled={busy}
disabled={busy || manageChoices}
endpoint="/api/v1/investment/territory-options"
onChange={(path) =>
change(
@@ -371,6 +383,7 @@ export default function ContextEditor({
{view.context.selection.territory.version}.
</p>
)}
{manageChoices && <p>Confirme a localização em cada ficha acima; a localização geral não substitui as escolhas individuais.</p>}
</section>
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
@@ -574,6 +587,8 @@ export default function ContextEditor({
<h2 className="text-xl font-bold">
Comparação antes da confirmação
</h2>
{!!plan.opportunity_choices?.length && <OpportunityChoices choices={plan.opportunity_choices}/>}
<InvestmentRequirements assessment={plan.requirements_assessment} contextId={view.case.id}/>
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] table-fixed text-left">
<thead>
@@ -3,6 +3,8 @@ import {
type Preparation,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import InvestmentRequirements from '@/components/investment-requirements';
import OpportunityChoices from '@/components/investment-opportunity-choices';
import type { DossierContext } from '@/lib/investor-context';
import FintechInvestment, {
type FintechView,
@@ -539,6 +541,8 @@ export default function InvestmentPath(path: Path) {
}
/>
)}
{!!path.investment_context?.opportunity_choices?.length && <OpportunityChoices choices={path.investment_context.opportunity_choices}/>}
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id}/>
{!!path.content_sources?.length && (
<section
className="space-y-2 rounded-xl border p-4"
@@ -18,6 +18,7 @@ export default function ContentHub({access, counts}: {access: Access; counts: Re
<p>Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.</p>
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
{access.opportunities.profileWorkflow && <Link className="block underline" href="/minhas-oportunidades/lotes/agronegocio">Rever o lote das 34 oportunidades de Agronegócio</Link>}
<p>Para divulgar um resumo ainda em preparação, abra a ficha e escolha <strong>Divulgação promocional</strong>: prepare o texto público, reveja a prévia e confirme divulgar, actualizar ou retirar. Esta operação não conclui a validação do investimento.</p>
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
@@ -0,0 +1,26 @@
import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { profileJson } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import { Management, button, control } from './shared';
type Entry = {key:string;id:number|null;title:string;summary:string;description:string;corridor:string;status:string|null;href:string|null};
type Preview = {id:number;token:string;projection:{title:string;summary:string;description:string};operation_key:string};
export default function AgribusinessBatch({entries,manifestHash,canRegister}:{entries:Entry[];manifestHash:string;canRegister:boolean}) {
const [previews,setPreviews]=useState<Preview[]>([]); const [reason,setReason]=useState(''); const [confirmed,setConfirmed]=useState(false); const [busy,setBusy]=useState(false); const [message,setMessage]=useState('');
const base='/minhas-oportunidades/lotes/agronegocio';
async function run(action:'register'|'preview'|'disclose') {setBusy(true);setMessage('');try {
if(action==='register'){await profileJson(base+'/cadastrar',{manifest_hash:manifestHash,confirm:true});router.reload();}
if(action==='preview'){const result=await profileJson(base+'/previa',{}) as {previews:Preview[]};setPreviews(result.previews.map(p=>({...p,operation_key:createOperationKey()})));setConfirmed(false);}
if(action==='disclose'){await profileJson(base+'/divulgar',{previews:previews.map(({id,token,operation_key})=>({id,token,operation_key})),reason,confirm:confirmed});setPreviews([]);setMessage('As 34 fichas foram divulgadas como Em preparação; a execução não foi autorizada.');router.reload();}
}catch(e){setMessage(e instanceof Error?e.message:'Operação não concluída. A versão anterior foi preservada.');}finally{setBusy(false);}}
return <Management title="34 oportunidades de Agronegócio"><p>Fonte: investimentos nos corredores.docx. Mantêm-se as seis designações documentais, sem renomear mestres nem inferir implantação. Promotor, área, montantes, financiamento e licenças ausentes continuam por completar. A associação a corredores, clusters e projectos não é condição de divulgação.</p>
<p>O cadastro é privado. A divulgação seguinte exige prévia, confirmação e MFA recente, com os papéis existentes. Documentos e contactos privados permanecem separados.</p>
<p>{entries.filter(e=>e.id).length} cadastradas · {entries.filter(e=>e.status==='published').length} divulgadas</p>
{canRegister && <button className={button} disabled={busy} onClick={()=>void run('register')}>Cadastrar ou recuperar as 34 fichas privadamente</button>}
{entries.every(e=>e.id) && <button className={button+' ml-3'} disabled={busy} onClick={()=>void run('preview')}>Pré-visualizar divulgação das 34 fichas</button>}
<ul className="my-5 space-y-3">{entries.map(e=><li key={e.key} className="rounded border p-3"><h2>{e.title}</h2><p>{e.summary}</p><p>{e.corridor} · {e.status??'Por cadastrar'}</p>{e.id&&<Link className="underline" href={`/minhas-oportunidades/${e.id}/edit`}>Rever e editar a ficha</Link>}</li>)}</ul>
{!!previews.length && <section aria-label="Prévia pública das 34 fichas"><h2>Conteúdo a divulgar — Em preparação</h2>{previews.map(p=><details key={p.id} className="my-3 rounded border p-3"><summary>{p.projection.title}</summary><p>{p.projection.summary}</p><p className="whitespace-pre-line">{p.projection.description}</p></details>)}<label className="block">Motivo<textarea className={control} value={reason} onChange={e=>setReason(e.target.value)}/></label><label className="block"><input type="checkbox" checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Confirmo a divulgação destes resumos em preparação; não é autorização de execução.</label><button className={button} disabled={busy||!confirmed||reason.trim().length<20} onClick={()=>void run('disclose')}>Divulgar as 34 oportunidades no catálogo</button></section>}
{message&&<p role="status">{message}</p>}<Link className="block underline" href="/oportunidades?sector=agronegocio">Consultar catálogo de Agronegócio</Link>
</Management>;
}
+4
View File
@@ -166,6 +166,10 @@ Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class]
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
$profiles = PortalProfileController::class;
Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);
Route::get('/minhas-oportunidades/lotes/agronegocio', [\App\Http\Controllers\CorridorAgribusinessController::class, 'show']);
Route::post('/minhas-oportunidades/lotes/agronegocio/cadastrar', [\App\Http\Controllers\CorridorAgribusinessController::class, 'register']);
Route::post('/minhas-oportunidades/lotes/agronegocio/previa', [\App\Http\Controllers\CorridorAgribusinessController::class, 'preview']);
Route::post('/minhas-oportunidades/lotes/agronegocio/divulgar', [\App\Http\Controllers\CorridorAgribusinessController::class, 'disclose']);
Route::get('/minhas-oportunidades/territory-options', [InvestmentTerritoryController::class, 'options']);
Route::get('/minhas-oportunidades/territory-geometries', [InvestmentTerritoryController::class, 'geometries']);
Route::get('/minhas-oportunidades/create', [$profiles, 'edit']);
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\CorridorAgribusinessOpportunities;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class CorridorAgribusinessBatchTest extends TestCase
{
use DatabaseTransactions;
public function test_batch_uses_normal_scoped_session_preview_and_idempotent_disclosure_without_touching_other_opportunities(): void
{
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite(); config(['portal.profile_access_enabled' => true]);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$keys = array_column($entries, 'key');
$others = Opportunity::withTrashed()->whereNotIn('management_creation_key', $keys)->orWhereNull('management_creation_key')->orderBy('id')->get()->map(fn ($r) => $r->getRawOriginal())->all();
$count = Opportunity::withTrashed()->count();
$actor = User::findOrFail(295);
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
$base = '/minhas-oportunidades/lotes/agronegocio';
$this->get($base)->assertOk();
$this->postJson($base.'/cadastrar', ['manifest_hash' => str_repeat('0', 64), 'confirm' => true])->assertConflict();
$this->postJson($base.'/cadastrar', ['manifest_hash' => hash('sha256', json_encode($entries, JSON_THROW_ON_ERROR)), 'confirm' => true])->assertOk();
$this->assertSame($count, Opportunity::withTrashed()->count());
$previews = $this->postJson($base.'/previa')->assertOk()->json('previews');
$this->assertCount(34, $previews);
$input = ['confirm' => true, 'reason' => 'QA: divulgação das fichas expressamente seleccionadas no lote de Agronegócio.',
'previews' => array_map(fn ($p) => ['id' => $p['id'], 'token' => $p['token'], 'operation_key' => (string) Str::uuid()], $previews)];
$bad = $input; array_pop($bad['previews']);
$this->postJson($base.'/divulgar', $bad)->assertUnprocessable();
$this->postJson($base.'/divulgar', $input)->assertOk();
$this->postJson($base.'/divulgar', $input)->assertOk();
$this->assertSame(34, Opportunity::whereIn('management_creation_key', $keys)->published()->count());
$this->assertSame($others, Opportunity::withTrashed()->whereNotIn('management_creation_key', $keys)->orWhereNull('management_creation_key')->orderBy('id')->get()->map(fn ($r) => $r->getRawOriginal())->all());
$this->withSession(['portal.mfa_at' => 0])->postJson($base.'/previa')->assertForbidden();
$this->actingAs(User::factory()->create())->get($base)->assertForbidden();
}
}
@@ -0,0 +1,124 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestorContext;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestmentOpportunityChoicesTest extends TestCase
{
use DatabaseTransactions;
private User $investor;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->withoutVite();
$this->investor = User::factory()->create(); $this->actingAs($this->investor);
}
private function dossier(): string
{
return $this->postJson('/investimentos', ['title' => 'QA: investimento conjunto', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
public function test_multiple_opportunities_keep_sites_sources_requirements_and_results_in_one_dossier(): void
{
$entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2);
$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get();
$sites = DB::table('geo_locations')->where('location_type', 'province')->where('data_classification', 'public')->whereNull('deleted_at')->orderBy('id')->limit(2)->pluck('id');
$this->get('/investimentos/iniciar?opportunities[]='.$records[0]->id.'&opportunities[]='.$records[1]->id)->assertOk();
$id = $this->dossier();
$before = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$input = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => [
['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production', 'processing']],
['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[1], 'activities' => ['trade']],
]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
$this->assertCount(2, $plan['opportunity_choices']);
$this->assertSame($sites[0], $plan['opportunity_choices'][0]['territory']['id']);
$this->assertSame($sites[1], $plan['opportunity_choices'][1]['territory']['id']);
$this->assertSame(CorridorAgribusinessOpportunities::SOURCE_HASH, $plan['opportunity_choices'][1]['source_sha256']);
$this->assertSame('QA: investimento conjunto', $plan['fields']['project_title']);
$this->assertSame($records[0]->summary, $plan['fields']['project_summary']);
$this->assertContains('processing', array_column($plan['requirements_assessment']['requirements'], 'code'));
$this->assertContains('trade', array_column($plan['requirements_assessment']['requirements'], 'code'));
$this->assertSame($before['case']['version'], $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'));
$control = [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
$after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->assertCount(2, $after['context']['opportunity_choices']);
$snapshot = json_decode($after['prefill']['simulator']['context_applicability_snapshot'], true);
$this->assertSame($after['context']['opportunity_choices'], $snapshot['opportunity_choices']);
$this->assertSame('general', $snapshot['fields']['investment_regime']);
$this->assertArrayNotHasKey('tax_benefit', $snapshot['fields']);
$this->get('/investimentos/'.$id)->assertOk();
$desk = app(VirtualDesk::class); $case = $desk->case($this->investor, $id);
$payload = $desk->decode($case->payload); $resultId = (string) Str::uuid();
$payload['investor_path']['results'] = [['id' => $resultId, 'engine' => 'synthetic-qa-history', 'context_state' => 'current', 'value' => 'preserve']];
DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload)]);
$next = ['version' => $after['case']['version'], 'selection' => [], 'opportunity_choices' => [
['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production']],
['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[0], 'confirm_location_change' => true, 'location_reason' => 'Alternativa territorial escolhida expressamente para comparar o investimento.', 'activities' => ['trade']],
]];
$impact = $this->postJson('/investimentos/'.$id.'/contexto/previa', $next)->assertOk()->json();
$this->assertSame($resultId, $impact['dependent_results'][0]['id']);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$next, 'preview_hash' => $impact['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('results.0.value', 'preserve')->assertJsonPath('results.0.context_state', 'review_required');
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
}
public function test_documented_location_cannot_change_silently_and_stale_source_cannot_be_confirmed(): void
{
$record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
$sites = DB::table('geo_locations')->where('location_type', 'province')->where('data_classification', 'public')->whereNull('deleted_at')->orderBy('id')->limit(2)->pluck('id');
$record->primary_territory_id = $sites[0]; $record->save();
$id = $this->dossier(); $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
$input = ['version' => $version, 'selection' => [], 'opportunity_choices' => [['source' => 'opportunity:'.$record->id, 'territory' => 'territory:'.$sites[1]]]];
$this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertUnprocessable();
$this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $version, 'selection' => ['opportunity' => 'opportunity:'.$record->id, 'territory' => 'territory:'.$sites[1]]])->assertUnprocessable();
$input['opportunity_choices'][0]['confirm_location_change'] = true;
$input['opportunity_choices'][0]['location_reason'] = 'Escolha explícita de implantação alternativa, sem alterar a origem.';
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
$this->assertSame($sites[0], $record->fresh()->primary_territory_id);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$input['version'] = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
unset($input['opportunity_choices'][0]['confirm_location_change']);
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->assertJsonPath('dependency_changed', false)->json();
$record->title .= ' QA source changed'; $record->save();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertConflict();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.revision', 1);
}
public function test_the_34_choices_and_sources_fit_the_existing_simulation_draft_without_reentry(): void
{
$records = Opportunity::whereIn('management_creation_key', array_column(app(CorridorAgribusinessOpportunities::class)->entries(), 'key'))->orderBy('id')->get();
$this->assertCount(34, $records);
$id = $this->dossier();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$input = ['version' => $view['case']['version'], 'selection' => [], 'opportunity_choices' => $records->map(fn ($r) => ['source' => 'opportunity:'.$r->id])->all()];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $input)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$input, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$snapshot = $view['prefill']['simulator']['context_applicability_snapshot'];
$this->assertCount(34, json_decode($snapshot, true)['opportunity_choices']);
$this->assertLessThan(1000000, strlen($snapshot));
$draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [],
'economic_snapshot' => [...$view['prefill']['simulator'], 'purpose' => 'simulate_only', 'currency' => 'MZN']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json();
$this->assertNotEmpty($draft);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.opportunity_choices.33.opportunity.key', 'opportunity:'.$records[33]->id);
}
}
@@ -0,0 +1,45 @@
<?php
foreach (['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'array'] as $k => $v) {
putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
}
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityPromotion;
$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
if (! app()->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) throw new RuntimeException('QA only');
config(['portal.profile_access_enabled' => true]);
$actor = App\Models\User::findOrFail(295);
// QA-role simulation, like the bounded tests; this is not evidence of a real production MFA login.
session(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'login.id' => null]);
$request = Illuminate\Http\Request::create('/minhas-oportunidades', 'GET');
$request->setUserResolver(fn () => $actor); $app->instance('request', $request);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$dir = storage_path('app/private/agribusiness-opportunities-20261023');
$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->get();
if (! file_exists($dir.'/before-disclosure.json')) file_put_contents($dir.'/before-disclosure.json', json_encode($records->map(fn ($r) => $r->getRawOriginal())->all(), JSON_THROW_ON_ERROR));
$result = DB::transaction(function () use ($actor, $entries) {
DB::select('SELECT pg_advisory_xact_lock(231026, 34)');
$result = [];
foreach ($entries as $entry) {
$record = Opportunity::where('management_creation_key', $entry['key'])->lockForUpdate()->firstOrFail();
if ($record->status !== 'published') {
$values = app(App\Services\OpportunityManagement::class)->values($record);
if ($record->status !== 'draft' || ($values['canonical']['official_title'] ?? null) !== $entry['title'] || ($values['canonical']['executive_summary'] ?? null) !== $entry['summary']) throw new RuntimeException('Edited or withdrawn record requires individual review: '.$record->id);
$input = $record->workflow_context['promotional_summary']['input'];
$preview = app(OpportunityPromotion::class)->preview($actor, $record, [...$input, 'source_date' => null, 'action' => 'disclose', 'etag' => app(App\Services\OpportunityManagement::class)->etag($record)]);
app(OpportunityPromotion::class)->confirm($actor, $record->id, ['token' => $preview['token'], 'operation_key' => (string) Illuminate\Support\Str::uuid(), 'confirm' => true,
'reason' => 'QA automatizada: divulgação das 34 fichas de Agronegócio expressamente instruída pela Tempo; em preparação, sem autorização de execução.']);
} elseif (data_get($record->workflow_context, 'publication.purpose') !== OpportunityPromotion::PURPOSE) throw new RuntimeException('Different publication preserved');
$result[] = ['id' => $record->id, 'title' => $entry['title'], 'corridor' => $entry['corridor'], 'url' => '/oportunidades/'.$record->slug];
}
return $result;
});
$receipt = ['target' => $target, 'published' => count($result), 'authentication' => 'QA role simulation only; no production authentication claimed', 'source_sha256' => CorridorAgribusinessOpportunities::SOURCE_HASH, 'records' => $result, 'active_writes' => 0];
file_put_contents($dir.'/disclosure-receipt.json', json_encode($receipt, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo json_encode(['published' => count($result), 'target' => $target, 'active_writes' => 0]).PHP_EOL;