Publish agribusiness lot through scoped management and extend existing dossier for multiple opportunities
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user