From c641b267ac3dca1615c014ab5dd75dc3b4abde8d Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 23 Oct 2026 10:49:50 +0200 Subject: [PATCH] Publish agribusiness lot through scoped management and extend existing dossier for multiple opportunities --- .../CorridorAgribusinessController.php | 73 ++++++ .../Controllers/InvestorContextController.php | 11 +- .../StoreSimulationFormDraftRequest.php | 2 +- .../InvestmentContextRequirements.php | 48 ++++ app/Services/InvestmentOpportunityChoices.php | 41 +++ app/Services/InvestorContext.php | 42 ++- .../consolidated-review-matrix-2026-10-03.md | 2 + ...s-publication-and-context-QA-20261023.json | 246 ++++++++++++++++++ .../unification/traceability-and-manual.md | 18 ++ .../investment-opportunity-choices.tsx | 26 ++ .../js/components/investment-requirements.tsx | 19 ++ resources/js/lib/investor-context.ts | 17 ++ resources/js/pages/investment/catalogue.tsx | 9 + .../js/pages/investor-path/context-start.tsx | 7 +- resources/js/pages/investor-path/context.tsx | 21 +- resources/js/pages/investor-path/show.tsx | 4 + resources/js/pages/management/content-hub.tsx | 1 + .../management/corridor-agribusiness.tsx | 26 ++ routes/web.php | 4 + .../Feature/CorridorAgribusinessBatchTest.php | 47 ++++ .../InvestmentOpportunityChoicesTest.php | 124 +++++++++ tools/publish-corridor-agribusiness-qa.php | 45 ++++ 22 files changed, 818 insertions(+), 15 deletions(-) create mode 100644 app/Http/Controllers/CorridorAgribusinessController.php create mode 100644 app/Services/InvestmentContextRequirements.php create mode 100644 app/Services/InvestmentOpportunityChoices.php create mode 100644 docs/investmoz/unification/agribusiness-publication-and-context-QA-20261023.json create mode 100644 resources/js/components/investment-opportunity-choices.tsx create mode 100644 resources/js/components/investment-requirements.tsx create mode 100644 resources/js/pages/management/corridor-agribusiness.tsx create mode 100644 tests/Feature/CorridorAgribusinessBatchTest.php create mode 100644 tests/Feature/InvestmentOpportunityChoicesTest.php create mode 100644 tools/publish-corridor-agribusiness-qa.php diff --git a/app/Http/Controllers/CorridorAgribusinessController.php b/app/Http/Controllers/CorridorAgribusinessController.php new file mode 100644 index 00000000..0c132132 --- /dev/null +++ b/app/Http/Controllers/CorridorAgribusinessController.php @@ -0,0 +1,73 @@ +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]; + })); + } +} diff --git a/app/Http/Controllers/InvestorContextController.php b/app/Http/Controllers/InvestorContextController.php index 81e5aa53..0c30b1a8 100644 --- a/app/Http/Controllers/InvestorContextController.php +++ b/app/Http/Controllers/InvestorContextController.php @@ -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) diff --git a/app/Http/Requests/StoreSimulationFormDraftRequest.php b/app/Http/Requests/StoreSimulationFormDraftRequest.php index b92a2385..cc8e7838 100644 --- a/app/Http/Requests/StoreSimulationFormDraftRequest.php +++ b/app/Http/Requests/StoreSimulationFormDraftRequest.php @@ -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'], diff --git a/app/Services/InvestmentContextRequirements.php b/app/Services/InvestmentContextRequirements.php new file mode 100644 index 00000000..155900e3 --- /dev/null +++ b/app/Services/InvestmentContextRequirements.php @@ -0,0 +1,48 @@ + $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.']; + } +} diff --git a/app/Services/InvestmentOpportunityChoices.php b/app/Services/InvestmentOpportunityChoices.php new file mode 100644 index 00000000..b75d742c --- /dev/null +++ b/app/Services/InvestmentOpportunityChoices.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 6a81dfed..657c5b1c 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -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']), diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 418f03f2..43aae012 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -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 | diff --git a/docs/investmoz/unification/agribusiness-publication-and-context-QA-20261023.json b/docs/investmoz/unification/agribusiness-publication-and-context-QA-20261023.json new file mode 100644 index 00000000..a7fdfea7 --- /dev/null +++ b/docs/investmoz/unification/agribusiness-publication-and-context-QA-20261023.json @@ -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." +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 0fe52232..17adb548 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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. diff --git a/resources/js/components/investment-opportunity-choices.tsx b/resources/js/components/investment-opportunity-choices.tsx new file mode 100644 index 00000000..57ebe357 --- /dev/null +++ b/resources/js/components/investment-opportunity-choices.tsx @@ -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) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c)); + return
+

Oportunidades escolhidas e localização de cada ficha

+

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.

+ {choices.map((c,i) =>
+

{i + 1}. {c.opportunity.title}

+

{c.opportunity.key} · versão {c.opportunity.version ?? 'não indicada'}

+ {c.coverage &&

Abrangência documental: {c.coverage}

} +

Localização: {c.territory?.title ?? 'Por escolher — não existe implantação documentada nesta ficha.'}

+ {c.source &&

Fonte: {c.source}

} + {onChange && <> +
Actividades que pretende realizar nesta oportunidade{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])=>)}
+ 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})}/> +