;
+ sectorReadings?: {
+ title: string;
+ href: string;
+ analysis: SectorAnalysis;
+ }[];
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [province, setProvince] = useState('');
@@ -226,6 +235,15 @@ function DemographySelection({
))}
)}
+ {sectorReadings.map((reading) => (
+
+ ))}
);
}
diff --git a/resources/js/pages/investor-path/index.tsx b/resources/js/pages/investor-path/index.tsx
index bc15f514..6416d8f3 100644
--- a/resources/js/pages/investor-path/index.tsx
+++ b/resources/js/pages/investor-path/index.tsx
@@ -19,6 +19,8 @@ export type InvestmentContext = {
export default function InvestmentContexts({
contexts,
contentSource,
+ sectorIdea,
+ analysisAction,
sectorSource,
resourceSource,
zoneSource,
@@ -30,6 +32,15 @@ export default function InvestmentContexts({
companyComparisonAnswers?: CompanyAnswers | null;
guideSelection?: GuideSelection | null;
contentSource?: LegacyArticle;
+ sectorIdea?: {
+ title: string;
+ quote: string;
+ location_label: string | null;
+ evidence_key: string;
+ source_sha256: string;
+ source_version: number;
+ } | null;
+ analysisAction?: 'prepare' | 'compare';
zoneSource?: {
zone_id: string;
consulted_public_version: number;
@@ -44,10 +55,14 @@ export default function InvestmentContexts({
zone_id: zoneSource?.zone_id,
consulted_public_version: zoneSource?.consulted_public_version,
confirm: !!zoneSource,
- title: '',
+ title: sectorIdea?.title ?? '',
client_key: key,
reuse_consent: false,
content_resource_id: contentSource?.id,
+ evidence: sectorIdea?.evidence_key,
+ source_sha256: sectorIdea?.source_sha256,
+ source_version: sectorIdea?.source_version,
+ idea: sectorIdea?.title,
sector_slug: sectorSource?.slug,
sector_reference: resourceSource
? {
@@ -101,9 +116,15 @@ export default function InvestmentContexts({
{resourceSource && (
{resourceSource.title}
- {resourceSource.location_label ?? resourceSource.locations_text}
+
+ {resourceSource.location_label ??
+ resourceSource.locations_text}
+
{resourceSource.value_chain}
- Esta referência será guardada apenas no dossier que escolher.
+
+ Esta referência será guardada apenas no dossier que
+ escolher.
+
)}
{contentSource && (
@@ -115,6 +136,25 @@ export default function InvestmentContexts({
.
)}
+ {sectorIdea && (
+
+ Hipótese a preparar: {sectorIdea.title}
+
+ {sectorIdea.location_label ??
+ 'Localização por escolher'}
+ . Confirme a implantação no passo seguinte.
+
+
+ Fundamento seleccionado
+ {sectorIdea.quote}
+
+
+ {analysisAction === 'compare'
+ ? 'Continue no seu dossier, guarde a localização e consulte a análise de mercado e oferta existente.'
+ : 'A ideia e a fonte ficam no mesmo dossier. Complete apenas os dados em falta.'}
+
+
+ )}
Prepare cada investimento no seu dossier. Pode trabalhar em
várias etapas em paralelo.
@@ -174,7 +214,13 @@ export default function InvestmentContexts({
{
version: item.version,
sector_slug: sectorSource.slug,
- ...(resourceSource ? { sector_reference: form.data.sector_reference } : {}),
+ ...(resourceSource
+ ? {
+ sector_reference:
+ form.data
+ .sector_reference,
+ }
+ : {}),
},
)
}
@@ -192,6 +238,12 @@ export default function InvestmentContexts({
{
version: item.version,
resource_id: contentSource.id,
+ evidence: sectorIdea?.evidence_key,
+ source_sha256:
+ sectorIdea?.source_sha256,
+ source_version:
+ sectorIdea?.source_version,
+ idea: sectorIdea?.title,
},
)
}
diff --git a/resources/js/pages/investor-path/show.tsx b/resources/js/pages/investor-path/show.tsx
index b2f8c7f5..92ce861a 100644
--- a/resources/js/pages/investor-path/show.tsx
+++ b/resources/js/pages/investor-path/show.tsx
@@ -1,3 +1,4 @@
+import type { SectorAnalysisSnapshot } from '@/lib/investor-dossier-report';
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
@@ -120,6 +121,8 @@ type Path = {
tools: Record;
results: PathResult[];
content_sources: {
+ analyses?: SectorAnalysisSnapshot[];
+ analysis_review_required?: boolean;
resource_id: number;
title: string;
return_href: string;
@@ -375,7 +378,9 @@ function Stage({ step, path }: { step: Step; path: Path }) {
}));
form.post(
`/investimentos/${path.case.id}/etapas/${step.code}`,
- { preserveScroll: true },
+ {
+ preserveScroll: true,
+ },
);
}}
>
@@ -851,6 +856,44 @@ export default function InvestmentPath(path: Path) {
>
Exemplar e origem
+ {source.analysis_review_required && (
+
+ A fonte foi alterada ou deixou de estar
+ disponível. Reveja a hipótese; o trecho
+ guardado foi preservado.
+
+ )}
+ {(source.analyses ?? []).map((analysis) => (
+
+
+ Hipótese guardada: {analysis.title}
+
+
+ {analysis.location_label ??
+ 'Implantação por definir'}
+ . Contexto da fonte, sem atribuição
+ distrital automática.
+
+
+ {analysis.quote}
+
+
+ Edição: {analysis.source_date}.
+ Preserve este fundamento e reveja-o
+ se a fonte mudar.
+
+
+ Continuar a preparação no mesmo
+ dossier
+
+
+ ))}
))}
@@ -910,7 +953,9 @@ export default function InvestmentPath(path: Path) {
}));
facts.post(
`/investimentos/${path.case.id}/aplicabilidade`,
- { preserveScroll: true },
+ {
+ preserveScroll: true,
+ },
);
}}
>
@@ -1252,7 +1297,9 @@ export default function InvestmentPath(path: Path) {
}));
task.post(
`/investimentos/${path.case.id}/tarefas`,
- { preserveScroll: true },
+ {
+ preserveScroll: true,
+ },
);
}}
>
diff --git a/resources/js/pages/portal/legacy.tsx b/resources/js/pages/portal/legacy.tsx
index 5189067f..b6b8415b 100644
--- a/resources/js/pages/portal/legacy.tsx
+++ b/resources/js/pages/portal/legacy.tsx
@@ -1,9 +1,13 @@
+import SectorContentAnalysis, {
+ type SectorAnalysis,
+} from '@/components/sector-content-analysis';
import { Head, Link, useForm } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from '@/components/educational-article-card';
export type LegacyArticle = {
+ sector_analysis?: SectorAnalysis;
summary: string;
opportunities_href?: string | null;
opportunities_label?: string | null;
@@ -72,6 +76,9 @@ export default function LegacySector({
.legacy-article ol { list-style: decimal; padding-inline-start: 1.6em; }
.legacy-article a { text-decoration: underline; }
`}
+ {article.sector_analysis && (
+
+ )}
assertSame('sector_context_20261115_test', $identity->name);
+ $this->assertSame(55441, (int) $identity->port);
+ $this->withoutVite();
+ Storage::fake('local');
+ config(['portal.legacy_content_qa_enabled' => true, 'investor-path.enabled' => true]);
+ }
+
+ private function source(string $sector, int $legacy = 999999): array
+ {
+ $s = Sector::firstOrCreate(['slug' => $sector], ['name' => 'SYNTHETIC '.$sector, 'is_active' => true]);
+ $slug = match ($sector) {
+ 'energia-especifica' => 'energia', default => $sector
+ };
+ $external = 'post-'.$legacy.'-synthetic-sector-'.Str::random(10);
+ $url = 'https://example.invalid/'.$slug;
+ config(['legacy_content.articles.'.$slug => ['external_id' => $external, 'source_url' => $url,
+ 'group' => $sector === 'mineracao' || $sector === 'energia-especifica' ? 'energia-mineracao' : $sector,
+ 'sector_slug' => $sector, 'publishable' => true]]);
+ $id = (string) Str::uuid();
+ $quote = 'SYNTHETIC: hotelaria, turismo cultural, conservacao; produção agrícola, processamento, logística, água, saneamento, construção, geração e distribuição de electricidade. Não representa uma oferta aberta.';
+ $bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $url,
+ 'content_html' => 'Contexto sintetico '.$quote.'
',
+ 'title' => 'SYNTHETIC context', 'author' => 'SYNTHETIC', 'legacy_id' => $legacy,
+ 'legacy_category' => 'SYNTHETIC', 'published_at' => '2026-01-01', 'modified_at' => '2026-01-02'], JSON_THROW_ON_ERROR);
+ Storage::disk('local')->put('synthetic/'.$id, $bytes);
+ DB::table('documents')->insert(['id' => $id, 'source_system' => 'synthetic-test', 'external_id' => $external,
+ 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'synthetic/'.$id,
+ 'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes),
+ 'malware_scan_status' => 'clean', 'size_bytes' => strlen($bytes), 'status' => 'active',
+ 'rights_status' => 'authorized', 'data_classification' => 'public']);
+ $resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'SYNTHETIC context',
+ 'category' => 'education', 'topic' => $sector, 'published' => true, 'rights_status' => 'authorized',
+ 'source_url' => $url, 'source_reference' => 'SYNTHETIC', 'metadata_version' => 1]);
+
+ return [app(LegacyContent::class)->article($slug, false), $resource, $slug];
+ }
+
+ public function test_five_sectors_save_the_exact_passage_in_the_same_dossier_without_an_advert_and_isolate_accounts(): void
+ {
+ $owner = User::factory()->create();
+ $stranger = User::factory()->create();
+ $country = (string) Str::uuid();
+ DB::table('geo_locations')->insert(['id' => $country, 'name' => 'SYNTHETIC country', 'code' => $country,
+ 'location_type' => 'country', 'source_system' => 'cartografia-workbook', 'data_classification' => 'public',
+ 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$country]);
+ $territory = (string) Str::uuid();
+ DB::table('geo_locations')->insert(['id' => $territory, 'parent_id' => $country, 'name' => 'SYNTHETIC outside zones', 'code' => $territory,
+ 'location_type' => 'province', 'source_system' => 'cartografia-workbook', 'data_classification' => 'public',
+ 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$territory]);
+ config(['investor-path.aggregate_supply_qa_enabled' => true]);
+ foreach (['turismo', 'agronegocio', 'infraestruturas', 'mineracao', 'energia-especifica'] as $sector) {
+ [$article, $resource, $slug] = $this->source($sector);
+ $idea = $article['sector_analysis']['ideas'][0];
+ $input = ['evidence' => $idea['evidence']['key'], 'source_sha256' => $article['sha256'], 'source_version' => $article['version'], 'idea' => $idea['title']];
+ $this->actingAs($owner)->get('/investimentos/a-partir-de/'.$slug.'?'.http_build_query($input))->assertOk();
+ $before = DB::table('opportunities')->count();
+ $id = $this->postJson('/investimentos', ['title' => 'SYNTHETIC '.$sector, 'client_key' => (string) Str::uuid(),
+ 'reuse_consent' => true, 'content_resource_id' => $resource, ...$input])->assertCreated()->json('id');
+ $view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
+ $this->assertSame($id, $view['case']['id']);
+ $this->assertSame($idea['evidence']['quote'], $view['content_sources'][0]['analyses'][0]['quote']);
+ $this->assertSame($article['sha256'], $view['content_sources'][0]['analyses'][0]['source_sha256']);
+ $this->assertFalse($view['content_sources'][0]['analyses'][0]['open_participation']);
+ $this->assertSame($before, DB::table('opportunities')->count());
+ $context = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $body = ['version' => $context['case']['version'], 'selection' => ['sector' => 'sector:'.$article['sector']['id'], 'territory' => 'territory:'.$territory],
+ 'opportunity_choices' => [], 'declarations' => ['project_title' => 'SYNTHETIC '.$sector, 'products_services' => $idea['title'],
+ 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
+ $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
+ $context = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/contexto/oferta-agregada', ['version' => $context['case']['version'], 'operation_key' => (string) Str::uuid()])->assertOk()->assertJsonPath('analysis.coverage_unknown', true);
+ $context = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $economic = array_filter($context['prefill']['simulator'], fn ($k) => ! str_ends_with($k, '_label') && $k !== 'email', ARRAY_FILTER_USE_KEY);
+ $economic = [...$economic, 'purpose' => 'own_investment', 'total_investment' => '2000000', 'secured_amount' => '1000000', 'annual_revenue' => '300000', 'planning_loan' => 'no'];
+ $economic['financial_tools_snapshot'] = json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
+ 'loan' => ['principal' => '', 'rate' => '', 'months' => '', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '', 'upfrontFee' => '', 'recurringFee' => ''],
+ 'breakEven' => ['fixed' => '100000', 'price' => '5000', 'variable' => '2000'], 'funding' => ['total' => '2000000', 'secured' => '1000000'],
+ 'incomes' => [['label' => 'SYNTHETIC revenue', 'amount' => '300000']], 'expenses' => [['label' => 'SYNTHETIC costs', 'amount' => '200000']]]], JSON_THROW_ON_ERROR);
+ $draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data');
+ $view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/resultados', ['version' => $view['case']['version'], 'draft_id' => $draft['id'], 'draft_version' => $draft['version_no'], 'context_revision' => $view['investment_context']['revision'], 'context_reviewed' => true])->assertOk();
+ $this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'results')->assertJsonPath('results.0.preparation_snapshot.annual_revenue', '300000')
+ ->assertJsonPath('content_sources.0.analyses.0.quote', $idea['evidence']['quote']);
+ $this->actingAs($stranger)->getJson('/investimentos/'.$id)->assertForbidden();
+ $this->actingAs($owner);
+ }
+ }
+
+ public function test_all_eleven_regions_keep_provincial_scope_and_never_assign_districts(): void
+ {
+ foreach (SectorContentAnalysis::REGIONS as $legacy => $name) {
+ [$a] = $this->source('turismo', $legacy);
+ $analysis = $a['sector_analysis'];
+ $this->assertSame($name, $analysis['region']);
+ $this->assertSame('province', $analysis['ideas'][0]['location_precision']);
+ $this->assertArrayNotHasKey('district_id', $analysis['ideas'][0]);
+ }
+ }
+
+ public function test_district_consultation_reuses_only_the_matching_provincial_reading(): void
+ {
+ [$article] = $this->source('turismo', 172237);
+ $country = (string) Str::uuid();
+ $province = (string) Str::uuid();
+ $district = (string) Str::uuid();
+ foreach ([[$country, null, 'SYNTHETIC country', 'country'], [$province, $country, 'Maputo Província', 'province'], [$district, $province, 'SYNTHETIC district', 'district']] as [$id, $parent, $name, $type]) {
+ DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => $name, 'code' => $id,
+ 'location_type' => $type, 'source_system' => 'cartografia-workbook', 'data_classification' => 'public',
+ 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
+ }
+ $readings = app(SectorContentAnalysis::class)->forTerritories([$district]);
+ $this->assertCount(1, $readings);
+ $this->assertSame($article['href'], $readings[0]['href']);
+ $this->assertSame('Maputo Província', $readings[0]['analysis']['region']);
+ $this->assertSame('province', $readings[0]['analysis']['ideas'][0]['location_precision']);
+ $this->assertSame([], app(SectorContentAnalysis::class)->forTerritories([(string) Str::uuid()]));
+ }
+
+ public function test_changed_source_or_fabricated_passage_cannot_be_attached(): void
+ {
+ [$a, $resource] = $this->source('turismo');
+ $owner = User::factory()->create();
+ $id = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'SYNTHETIC guard', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
+ $version = $this->getJson('/investimentos/'.$id)->json('case.version');
+ $idea = $a['sector_analysis']['ideas'][0];
+ $input = ['version' => $version, 'resource_id' => $resource, 'evidence' => $idea['evidence']['key'], 'idea' => $idea['title'], 'source_version' => $a['version'], 'source_sha256' => str_repeat('0', 64)];
+ $this->postJson('/investimentos/'.$id.'/conteudos', $input)->assertConflict();
+ $this->postJson('/investimentos/'.$id.'/conteudos', [...$input, 'source_sha256' => $a['sha256'], 'idea' => 'Fabricated availability'])->assertUnprocessable();
+ $this->assertSame([], $this->getJson('/investimentos/'.$id)->json('content_sources'));
+ }
+
+ public function test_saved_passage_survives_editorial_changes_and_withdrawal_with_a_review_notice(): void
+ {
+ [$a, $resource] = $this->source('turismo');
+ $owner = User::factory()->create();
+ $idea = $a['sector_analysis']['ideas'][0];
+ $id = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'SYNTHETIC deliberate title', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true,
+ 'content_resource_id' => $resource, 'evidence' => $idea['evidence']['key'], 'idea' => $idea['title'], 'source_sha256' => $a['sha256'], 'source_version' => $a['version']])->assertCreated()->json('id');
+ $this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('content_sources.0.analysis_review_required', false);
+ $selection = app(SectorContentAnalysis::class)->selection($a, ['evidence' => $idea['evidence']['key'], 'idea' => $idea['title'], 'source_sha256' => $a['sha256'], 'source_version' => $a['version']]);
+ $this->assertStringNotContainsString('project_title=', app(SectorContentAnalysis::class)->continuation($id, $selection));
+ $version = $this->getJson('/investimentos/'.$id)->json('case.version');
+ $this->postJson('/investimentos/'.$id.'/conteudos', ['version' => $version, 'resource_id' => $resource,
+ 'evidence' => $idea['evidence']['key'], 'idea' => $idea['title'], 'source_sha256' => $a['sha256'], 'source_version' => $a['version']])->assertOk();
+ $this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'content_sources.0.analyses')->assertJsonPath('case.title', 'SYNTHETIC deliberate title');
+ DB::table('portal_resources')->where('id', $resource)->update(['metadata_version' => 2, 'editorial_content' => json_encode(['body' => 'SYNTHETIC amended source; the saved conclusion must not change.'], JSON_THROW_ON_ERROR)]);
+ $this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('case.title', 'SYNTHETIC deliberate title')
+ ->assertJsonPath('content_sources.0.analysis_review_required', true)->assertJsonPath('content_sources.0.analyses.0.quote', $idea['evidence']['quote']);
+ DB::table('portal_resources')->where('id', $resource)->update(['published' => false]);
+ $this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('content_sources.0.analysis_review_required', true)
+ ->assertJsonPath('content_sources.0.analyses.0.source_version', 1)->assertJsonPath('content_sources.0.analyses.0.quote', $idea['evidence']['quote']);
+ }
+}
diff --git a/tests/frontend/investor-dossier-report.test.mjs b/tests/frontend/investor-dossier-report.test.mjs
index 3425af73..bc9dc446 100644
--- a/tests/frontend/investor-dossier-report.test.mjs
+++ b/tests/frontend/investor-dossier-report.test.mjs
@@ -11,6 +11,59 @@ import {
} from '../../resources/js/lib/project-dossier-report.ts';
import { serializeFinancialToolsSnapshot } from '../../resources/js/lib/financial-calculators.ts';
+void test('sector hypotheses preserve their exact passage, provincial scope and edition in the same private report', async () => {
+ const data = fixture();
+ data.content_sources = [
+ {
+ title: 'SYNTHETIC regional source',
+ source_url: 'https://example.invalid/source',
+ analyses: [
+ {
+ key: 'synthetic-key',
+ title: 'SYNTHETIC hypothesis',
+ quote: 'SYNTHETIC passage ',
+ section: 'Supporting passage',
+ source_sha256: 'a'.repeat(64),
+ source_version: 7,
+ source_date: '2026-11-15',
+ location_label: 'SYNTHETIC province',
+ location_precision: 'province',
+ href: '/synthetic-source',
+ },
+ ],
+ },
+ ];
+ const model = createInvestorDossierReport(data, '2026-11-15T00:00:00Z');
+ const market = model.sections.find((s) => s.id === 'market');
+ assert.equal(
+ market.rows.find((r) => r.field.endsWith('SYNTHETIC hypothesis')).value,
+ data.content_sources[0].analyses[0].quote,
+ );
+ assert.match(
+ market.rows.find((r) => r.classification === 'province').value,
+ /SYNTHETIC province/,
+ );
+ assert.match(
+ market.rows.find((r) => r.value.startsWith('7; SHA256')).value,
+ /7; SHA256/,
+ );
+ const html = await createProjectDossierPrintHtml(model, [], true);
+ assert.ok(html.includes('SYNTHETIC hypothesis'));
+ assert.ok(!html.includes(''));
+ assert.equal(
+ model.sections.filter((s) => s.id === 'market').length,
+ 1,
+ 'Existing SWOT and source evidence share one market section',
+ );
+ assert.equal(
+ createInvestorDossierReport(fixture()).sections.filter(
+ (s) => s.id === 'market',
+ ).length,
+ 1,
+ 'Unrelated dossiers retain their original SWOT section',
+ );
+});
+
function fixture() {
return {
case: {
@@ -233,7 +286,9 @@ void test('financial results from a different opportunity do not enter the conso
id: 'other',
context_opportunity: 'op-b',
financial_snapshot: 'OTHER-PRIVATE-INPUTS',
- preparation_snapshot: { annual_revenue: 'OTHER-PRIVATE-PREPARATION' },
+ preparation_snapshot: {
+ annual_revenue: 'OTHER-PRIVATE-PREPARATION',
+ },
draft_id: 'other-draft',
draft_version: 8,
recorded_at: '2026-11-13',
@@ -246,7 +301,12 @@ void test('financial results from a different opportunity do not enter the conso
'OTHER-PRIVATE-INPUTS',
),
);
- assert.equal(JSON.stringify(createInvestorDossierReport(input)).includes('OTHER-PRIVATE-PREPARATION'), false);
+ assert.equal(
+ JSON.stringify(createInvestorDossierReport(input)).includes(
+ 'OTHER-PRIVATE-PREPARATION',
+ ),
+ false,
+ );
assert.ok(
!investorDossierReportIndex(input).some(
(entry) => entry.id === 'finance:other',
@@ -282,7 +342,28 @@ void test('the consolidated report reuses loan, break-even, financing and budget
input.results = [
{
id: 'finance-a',
- preparation_snapshot: { total_investment: '100000', investment_budget_min: '1000000', investment_budget_max: '5000000', annual_revenue: '7500000', planning_assumptions_snapshot: JSON.stringify({ version: 1, data: { model: 'hotel', capacity: '10', price: '5000', occupancy: '50', frequency: '300', source: 'Synthetic assumptions', costs: [{ label: 'Energia', annual: '' }] } }), initial_budget_snapshot: JSON.stringify({ version: 1, rows: [{ label: 'Equipamento', amount: '100000' }] }) },
+ preparation_snapshot: {
+ total_investment: '100000',
+ investment_budget_min: '1000000',
+ investment_budget_max: '5000000',
+ annual_revenue: '7500000',
+ planning_assumptions_snapshot: JSON.stringify({
+ version: 1,
+ data: {
+ model: 'hotel',
+ capacity: '10',
+ price: '5000',
+ occupancy: '50',
+ frequency: '300',
+ source: 'Synthetic assumptions',
+ costs: [{ label: 'Energia', annual: '' }],
+ },
+ }),
+ initial_budget_snapshot: JSON.stringify({
+ version: 1,
+ rows: [{ label: 'Equipamento', amount: '100000' }],
+ }),
+ },
context_opportunity: 'op-a',
context_revision: 2,
context_state: 'review_required',
@@ -321,18 +402,74 @@ void test('the consolidated report reuses loan, break-even, financing and budget
);
assert.match(rows[0].classification, /Histórico/);
assert.equal(input.results[0].financial_snapshot, financial_snapshot);
- assert.equal(rows.find(item => item.field === 'Receitas anuais estimadas').value, '7500000 MZN');
- assert.equal(rows.find(item => item.field === 'Quartos disponíveis').value, '10');
- assert.equal(rows.find(item => item.field === 'Custo anual — Energia').value, 'Por confirmar');
- assert.equal(rows.find(item => item.field === 'Investimento inicial — Equipamento').value, '100000');
+ assert.equal(
+ rows.find((item) => item.field === 'Receitas anuais estimadas').value,
+ '7500000 MZN',
+ );
+ assert.equal(
+ rows.find((item) => item.field === 'Quartos disponíveis').value,
+ '10',
+ );
+ assert.equal(
+ rows.find((item) => item.field === 'Custo anual — Energia').value,
+ 'Por confirmar',
+ );
+ assert.equal(
+ rows.find((item) => item.field === 'Investimento inicial — Equipamento')
+ .value,
+ '100000',
+ );
});
test('funding remains calculable without presenting an unchosen empty loan or empty comparisons', () => {
const input = fixture();
- input.results = [{ id: 'funding-only', context_opportunity: 'op-a', context_revision: 3, currency: 'MZN', draft_id: 'draft-a', draft_version: 1, recorded_at: '2026-11-15', engine: 'financial-calculators:v1', preparation_snapshot: { planning_loan: 'no' }, financial_snapshot: serializeFinancialToolsSnapshot({ budgetPeriod: 'Mensal', loan: { principal: '', rate: '', months: '', rateType: 'nominal', periodMonths: '1', gracePeriods: '', upfrontFee: '', recurringFee: '' }, breakEven: { fixed: '', price: '', variable: '' }, funding: { total: '1000000', secured: '1000000' }, incomes: [{ label: 'Receita', amount: '' }], expenses: [{ label: 'Custos', amount: '' }] }) }];
- const rows = createInvestorDossierReport(input).sections.find(section => section.id === 'finance').rows;
- assert.equal(rows.find(row => row.field === 'Financiamento — Diferença por financiar').value, '0.00 MZN');
- assert.equal(rows.some(row => /Empréstimo educativo|Ponto de equilíbrio|Orçamento —/.test(row.field)), false);
+ input.results = [
+ {
+ id: 'funding-only',
+ context_opportunity: 'op-a',
+ context_revision: 3,
+ currency: 'MZN',
+ draft_id: 'draft-a',
+ draft_version: 1,
+ recorded_at: '2026-11-15',
+ engine: 'financial-calculators:v1',
+ preparation_snapshot: { planning_loan: 'no' },
+ financial_snapshot: serializeFinancialToolsSnapshot({
+ budgetPeriod: 'Mensal',
+ loan: {
+ principal: '',
+ rate: '',
+ months: '',
+ rateType: 'nominal',
+ periodMonths: '1',
+ gracePeriods: '',
+ upfrontFee: '',
+ recurringFee: '',
+ },
+ breakEven: { fixed: '', price: '', variable: '' },
+ funding: { total: '1000000', secured: '1000000' },
+ incomes: [{ label: 'Receita', amount: '' }],
+ expenses: [{ label: 'Custos', amount: '' }],
+ }),
+ },
+ ];
+ const rows = createInvestorDossierReport(input).sections.find(
+ (section) => section.id === 'finance',
+ ).rows;
+ assert.equal(
+ rows.find(
+ (row) => row.field === 'Financiamento — Diferença por financiar',
+ ).value,
+ '0.00 MZN',
+ );
+ assert.equal(
+ rows.some((row) =>
+ /Empréstimo educativo|Ponto de equilíbrio|Orçamento —/.test(
+ row.field,
+ ),
+ ),
+ false,
+ );
});
void test('the existing Word and print exporters retain dossier identity, pending stages, source and history, escaping active HTML', async () => {