Adapt preparation presentation to declared applicability
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Instruções permanentes do projeto InvesteMoz
|
||||
|
||||
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
|
||||
|
||||
Apresentar apenas o necessário ao investimento em preparação, com entrada por ideia, oportunidade, sector ou localização, reutilizando informação válida e autorizada da oportunidade, perfil, organização, dossier e fontes anteriores. As etapas são adaptáveis, não seis formulários obrigatórios. Não sobrescrever edições deliberadas; explicar divergências e impactos antes da confirmação. Conservar escolhas ao avançar, regressar, actualizar e autenticar-se. Seleccionar opções únicas e pedir apenas dados em falta ou desactualizados.
|
||||
|
||||
Ocultar campos e acções sem aplicação, conservando explicação breve e histórico; desconhecido não significa não aplicável. Na selecção múltipla, partilhar dados comuns sem perder os requisitos específicos de cada oportunidade. Consulta pública livre; autenticação no momento de manifestar interesse ou iniciar operação privada. Relatórios e exportações devem reflectir o contexto e as etapas efectivamente aplicáveis. O utilizador deve perceber onde está, o que falta e a próxima acção. Aperfeiçoar os componentes existentes por incrementos e registar cobertura na matriz existente; esta instrução não declara concluída a sua implementação transversal.
|
||||
|
||||
## Regras permanentes do InvesteMoz — decisão da Tempo, 12/10/2026
|
||||
|
||||
1. O portal InvesteMoz é propriedade da Tempo, Lda.
|
||||
|
||||
@@ -170,7 +170,7 @@ final class InvestorPath
|
||||
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '') : null,
|
||||
'desk' => '/balcao-virtual/dossiers/'.$id, 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
|
||||
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
|
||||
'land' => (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]];
|
||||
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
|
||||
@@ -270,8 +270,13 @@ Relatório desta observação: [verificação editorial](../../../outputs/invest
|
||||
|
||||
## Matriz executável
|
||||
|
||||
Decisão transversal da Tempo, 23/10/2026: percurso adaptativo com entradas por ideia/oportunidade/sector/localização; perguntar uma vez, preservar edições deliberadas e contexto entre ecrãs/autenticação; seleccionar opções únicas; ocultar exigências não aplicáveis sem confundir ausência de informação com dispensa; conservar especificidades na selecção múltipla. Mostrar posição, pendências e próxima acção. Relatórios/exportações seguem a mesma aplicabilidade. Origem: pedido expresso da Tempo nesta data; implementação incremental nos componentes existentes, sem novos aprovadores.
|
||||
|
||||
Incremento de apresentação: o dossier conserva as 19 etapas e o histórico, mas apresenta formulários apenas das etapas com preparação permitida. As restantes têm resumo do fundamento e podem regressar ao rever a decisão. A ferramenta Terra/DUAT deixa de ser proposta quando `land_needed=no`; `unknown` mantém a preparação disponível. Relatórios do plano reúnem requisitos documentais `not_applicable` numa explicação, mantendo requisitos em falta ou indeterminados. Esta alteração não remove obrigações jurídicas nem modifica dados guardados. A aplicação transversal a todos os formulários, entradas e exportações permanece por conferir; não se encerra esse requisito por documentação.
|
||||
|
||||
| Menu / ecrã | Componente React | Campos / entidade / tabela | Rota ou API | Perfil / regra | Evidência |
|
||||
| ------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |
|
||||
| Preparação adaptativa / relatório | `investor-path/show`, `investment-requirements`, `project-dossier-report` | Aplicabilidade e contexto existentes; histórico preservado | `/investimentos/{id}`, `/aplicabilidade`; exportações existentes do plano | Tempo, 23/10/2026: ocultar preparação sem aplicação; desconhecido não é dispensa | Incremento QA: `InvestmentCorridorContextTest::test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence` (18 asserções); relatório 11 testes; types/build; cenário sintético no navegador. Cobertura transversal ainda parcial. |
|
||||
| Início | `PortalInvestidor` | Sectores, oportunidades, recursos publicados | `/`, APIs existentes de homepage | Visitante; contagens do cadastro | `PublicPortalNavigationTest`, navegador |
|
||||
| Catálogo / Educação | `investment/catalogue` | Filtros, educação, pivots, facetas | `/oportunidades`, `/educacao`; `/api/v1/investment/search` | Visitante; filtros em intersecção, montantes com moeda | `InvestmentUnificationTest` |
|
||||
| Cluster / Corredor / Âncora | `portfolio/index`, `portfolio/show` | Dossiers, relações, cadeias e oportunidades | `/clusters`, `/corredores`, `/projectos-ancora`, `/carteira/{id}` | Visitante; dossier publicado; cadeia pública | `InvestmentPortfolioTest`, testes territoriais |
|
||||
|
||||
@@ -2,11 +2,14 @@ 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}) {
|
||||
export default function InvestmentRequirements({assessment, contextId, excludedSteps = []}: {assessment?: RequirementsAssessment | null; contextId: string; excludedSteps?: 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]);
|
||||
for (const row of assessment.requirements) {
|
||||
if (excludedSteps.includes(row.step)) continue;
|
||||
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">
|
||||
|
||||
@@ -316,7 +316,15 @@ function makeSections(
|
||||
preparation.calculation_notice,
|
||||
preparation.notice,
|
||||
);
|
||||
for (const requirement of preparation.requirements ?? [])
|
||||
const omitted = (preparation.requirements ?? []).filter(
|
||||
(requirement: { state: string }) => requirement.state === 'not_applicable',
|
||||
);
|
||||
if (omitted.length)
|
||||
section?.paragraphs.push(
|
||||
`Não exigido neste contexto: ${omitted.map((requirement: { label: string; reason: string }) => `${requirement.label} (${requirement.reason})`).join('; ')}.`,
|
||||
);
|
||||
for (const requirement of preparation.requirements ?? []) {
|
||||
if (requirement.state === 'not_applicable') continue;
|
||||
section?.rows.push({
|
||||
field: requirement.label,
|
||||
value:
|
||||
@@ -331,6 +339,7 @@ function makeSections(
|
||||
classification:
|
||||
'Preparação informativa — simulação, sem submissão à Administração',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (zone?.href)
|
||||
section?.rows.push({
|
||||
|
||||
@@ -531,7 +531,7 @@ 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}/>
|
||||
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id} excludedSteps={path.steps.filter((step) => !step.preparation_allowed).map((step) => step.code)}/>
|
||||
{!!path.content_sources?.length && (
|
||||
<section
|
||||
className="space-y-2 rounded-xl border p-4"
|
||||
@@ -569,15 +569,14 @@ export default function InvestmentPath(path: Path) {
|
||||
<h2 className="font-semibold">Próxima acção</h2>
|
||||
{path.next ? (
|
||||
<a className="underline" href={path.next.href}>
|
||||
{path.next.action}: {path.next.code} · {path.next.title}
|
||||
{path.next.action}: {path.next.title}
|
||||
</a>
|
||||
) : (
|
||||
<p>Reveja as pendências e o acompanhamento.</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Pode preparar etapas independentes enquanto aguarda
|
||||
informação. Percurso v{path.definition_version}; os outros
|
||||
roteiros mantêm a numeração original.
|
||||
informação. As etapas abaixo adaptam-se às suas escolhas.
|
||||
</p>
|
||||
</section>
|
||||
<nav
|
||||
@@ -686,14 +685,25 @@ export default function InvestmentPath(path: Path) {
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{Object.entries(path.phases).map(([number, label]) => (
|
||||
{path.steps.some((step) => !step.preparation_allowed) && (
|
||||
<section className="rounded-xl border p-4" aria-label="Etapas sem aplicação">
|
||||
<h2 className="font-semibold">Não precisa de preparar neste contexto</h2>
|
||||
<ul>
|
||||
{path.steps.filter((step) => !step.preparation_allowed).map((step) => (
|
||||
<li key={step.code}>{step.title}: {step.reason || 'Conforme as escolhas guardadas.'}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm">Pode rever estas escolhas acima. Os dados e o histórico destas etapas ficam conservados.</p>
|
||||
</section>
|
||||
)}
|
||||
{Object.entries(path.phases).filter(([number]) => path.steps.some((step) => step.phase === Number(number) && step.preparation_allowed)).map(([number, label]) => (
|
||||
<section className="space-y-3" key={number}>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Fase {number} · {label}
|
||||
</h2>
|
||||
<div className="grid items-start gap-3 md:grid-cols-2">
|
||||
{path.steps
|
||||
.filter((step) => step.phase === Number(number))
|
||||
.filter((step) => step.phase === Number(number) && step.preparation_allowed)
|
||||
.map((step) => (
|
||||
<Stage
|
||||
key={step.code}
|
||||
|
||||
@@ -30,6 +30,28 @@ class InvestmentCorridorContextTest extends TestCase
|
||||
$entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
|
||||
return Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
|
||||
}
|
||||
public function test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence(): void
|
||||
{
|
||||
$id = $this->postJson('/investimentos', ['title' => 'QA adaptação sem perda', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
|
||||
$this->assertNotNull($view['tools']['land']);
|
||||
$this->assertSame('undetermined', collect($view['steps'])->firstWhere('code', 'S10')['applicability']);
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Informação territorial já guardada'])->assertOk();
|
||||
$view = $this->getJson('/investimentos/'.$id)->json();
|
||||
$facts = ['land_needed' => 'no', 'external_funding' => 'no', 'viability' => 'unknown', 'reason' => 'Investimento sem necessidade de terra nem financiamento externo'];
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts])->assertOk();
|
||||
$view = $this->getJson('/investimentos/'.$id)->assertJsonPath('tools.land', null)->json();
|
||||
$step = collect($view['steps'])->firstWhere('code', 'S10');
|
||||
$this->assertFalse($step['preparation_allowed']);
|
||||
$this->assertSame('preparing', $step['saved_state']);
|
||||
$this->assertSame('Informação territorial já guardada', $step['detail']['reason']);
|
||||
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Não deve executar etapa sem aplicação'])->assertUnprocessable();
|
||||
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts, 'land_needed' => 'yes'])->assertOk();
|
||||
$view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
|
||||
$this->assertNotNull($view['tools']['land']);
|
||||
$this->assertSame('preparing', collect($view['steps'])->firstWhere('code', 'S10')['state']);
|
||||
$this->assertCount(19, $view['steps']);
|
||||
}
|
||||
public function test_tempo_configuration_uses_existing_ids_and_maputo_includes_both_units(): void
|
||||
{
|
||||
foreach(config('investment_corridors') as $key=>$entry){
|
||||
|
||||
@@ -12,6 +12,29 @@ import {
|
||||
validateDossierAttachments,
|
||||
} from '../../resources/js/lib/project-dossier-report.ts';
|
||||
|
||||
test('report separates inapplicable documents from missing and unknown requirements without changing the saved snapshot', async () => {
|
||||
const requirements = [
|
||||
{ label: 'Título DUAT', state: 'not_applicable', reason: 'Arrendamento declarado' },
|
||||
{ label: 'Identificação', state: 'missing', reason: 'Pedido em preparação' },
|
||||
{ label: 'Capital externo', state: 'indeterminate', reason: 'Origem por determinar' },
|
||||
];
|
||||
const snapshot = JSON.stringify({ version: 1, context_revision: 2, preparation: {
|
||||
enabled: true, mode: 'registration', version: 'QA', next: 'Completar identificação',
|
||||
calculation_notice: 'Cálculo separado', notice: 'Preparação', requirements,
|
||||
} });
|
||||
const draft = { context_applicability_snapshot: snapshot };
|
||||
const report = createProjectDossierReport(draft, { selectedSections: new Set(['sources']) });
|
||||
const section = report.sections.find((section) => section.id === 'sources');
|
||||
assert.ok(section);
|
||||
assert.equal(section.rows.some((row) => row.field === 'Título DUAT'), false);
|
||||
assert.match(section.paragraphs.join(' '), /Não exigido neste contexto: Título DUAT \(Arrendamento declarado\)/);
|
||||
assert.equal(section.rows.find((row) => row.field === 'Identificação').value, 'Documento por associar');
|
||||
assert.equal(section.rows.find((row) => row.field === 'Capital externo').value, 'Informação em falta para decidir');
|
||||
const print = await createProjectDossierPrintHtml(report, [], false);
|
||||
assert.match(print, /Arrendamento declarado/);
|
||||
assert.equal(draft.context_applicability_snapshot, snapshot);
|
||||
});
|
||||
|
||||
test('executive summary exposes the same financial result and period without replacing missing data by zero', () => {
|
||||
const draft = { currency:'MZN', financial_tools_snapshot:serializeFinancialToolsSnapshot({budgetPeriod:'Mensal',loan:{principal:'1200',rate:'0',months:'12',rateType:'nominal',periodMonths:'1',gracePeriods:'0',upfrontFee:'0',recurringFee:'0'},breakEven:{fixed:'100.01',price:'30',variable:'20'},funding:{total:'1000',secured:'800'},incomes:[{label:'Receita QA',amount:'1000'}],expenses:[{label:'Despesa QA',amount:'1100'}]}) };
|
||||
const report = createProjectDossierReport(draft,{selectedSections:new Set(['summary','finance'])});
|
||||
|
||||
Reference in New Issue
Block a user