feat: preserve grounded sector ideas through private investment dossiers in QA

This commit is contained in:
2026-11-15 20:31:57 +02:00
parent 25d18f51bb
commit 6e7dfdabd0
17 changed files with 882 additions and 36 deletions
@@ -6,6 +6,7 @@ use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use App\Services\PortalEditorialWorkflow;
use App\Services\SectorContentAnalysis;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,7 +34,7 @@ final class DemographyController extends Controller
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
return Inertia::render('investment/demography', ['sectorReadings' => app(SectorContentAnalysis::class)->forTerritories($data['territories'] ?? []), 'evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
@@ -8,6 +8,7 @@ use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\SectorContentAnalysis;
use App\Services\SectorResourceReferences;
use App\Services\SectorInvestment;
use App\Services\VirtualDesk;
@@ -99,6 +100,8 @@ class InvestorPathController extends Controller
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$analysis = $source !== null ? app(SectorContentAnalysis::class)->selection(app(LegacyContent::class)->articleForResource((int) $source), $request->all()) : null;
abort_if($request->filled('evidence') && $source === null, 422);
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone, $reference): string {
if ($reference !== null) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
@@ -112,7 +115,7 @@ class InvestorPathController extends Controller
}
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
app(SectorContentAnalysis::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source, ...$request->only(['evidence', 'source_sha256', 'source_version', 'idea'])]);
}
if ($sector !== null) {
@@ -128,7 +131,7 @@ class InvestorPathController extends Controller
return $id;
});
$href = $comparison === null ? '/investimentos/'.$id : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
$href = $comparison === null ? ($analysis ? app(SectorContentAnalysis::class)->continuation($id, $analysis) : '/investimentos/'.$id) : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
if ($guide !== null) {
$href = '/investimentos/'.$id.'/guias/'.$guide['slug'].'?'.http_build_query(['current' => 1, 'selection' => 1, 'sha256' => $guide['sha256'], 'instruments' => $guide['instruments']]);
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\PublicSearchMetrics;
use App\Services\SectorContentAnalysis;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -40,12 +41,19 @@ class LegacyContentController extends Controller
abort_unless($path->available(), 404);
$article = $legacy->article($legacySlug);
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
$analysis = app(SectorContentAnalysis::class)->selection($article, $request->all());
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article, 'sectorIdea' => $analysis, 'analysisAction' => $request->query('analysis_action') === 'compare' ? 'compare' : 'prepare']);
}
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
{
$legacy->attach($request->user(), $id, $request->all());
$analysis = app(SectorContentAnalysis::class)->attach($request->user(), $id, $request->all());
if ($analysis) {
$href = app(SectorContentAnalysis::class)->continuation($id, $analysis);
return $request->expectsJson() ? response()->json(['saved' => true, 'continue' => $href]) : redirect($href);
}
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
}
+1 -1
View File
@@ -189,7 +189,7 @@ final class InvestorPath
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
'content_sources' => ($owner || $manager) ? app(SectorContentAnalysis::class)->savedSources($path['content_sources'] ?? []) : [],
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
->whereNull('deleted_at')
->when($preparationTaskKeys !== null, fn ($q) => $q->where(fn ($p) => $p->whereNull('guidance_key')->orWhere('guidance_key', 'not like', 'swot:%')->orWhereIn('guidance_key', $preparationTaskKeys ?? [])))
+4 -1
View File
@@ -104,7 +104,7 @@ final class LegacyContent
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
$article = ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
@@ -124,6 +124,9 @@ final class LegacyContent
'territorial_status' => 'Menções territoriais preservadas no artigo. Limites administrativos, parcelas e geometrias não associados automaticamente.',
'library_href' => '/documentos/'.$row->resource_id,
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
$article['sector_analysis'] = app(SectorContentAnalysis::class)->describe($article);
return $article;
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace App\Services;
use App\Models\User;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** An adapter over published, versioned articles; never another opportunity register. */
final class SectorContentAnalysis
{
public const REGIONS = [172263 => 'Zambézia', 172256 => 'Sofala', 172261 => 'Tete',
172258 => 'Manica', 172253 => 'Inhambane', 172240 => 'Maputo Cidade',
172237 => 'Maputo Província', 172233 => 'Gaza', 172230 => 'Nampula',
172223 => 'Cabo Delgado', 172219 => 'Niassa'];
private const ACTIVITIES = [
'turismo' => ['Hotelaria e alojamento' => '/hotel|alojamento|lodge|resort/iu', 'Turismo de natureza' => '/ecoturismo|eco.turismo|conservação/iu', 'Cultura e experiências' => '/turismo cultural|gastronomia|turismo gastronómico/iu'],
'agronegocio' => ['Produção agrícola' => '/produção agrícola/iu', 'Processamento agro-industrial' => '/processamento|transformação agro/iu', 'Armazenagem e logística' => '/armazenagem|logística/iu'],
'infraestruturas' => ['Água e saneamento' => '/água|saneamento/iu', 'Transportes e logística' => '/transportes|logística/iu', 'Construção e modernização' => '/construção|modernização/iu'],
'mineracao' => ['Processamento mineral' => '/processamento|beneficiamento/iu', 'Serviços à mineração' => '/serviços especializados|manutenção/iu', 'Logística mineira' => '/logística|transporte/iu'],
'energia-especifica' => ['Geração de energia' => '/geração|electricidade|el[eé]ctrica/iu', 'Distribuição e fornecimento' => '/distribuição|fornecimento/iu', 'Transformação industrial' => '/transformação industrial|fertilizantes|downstream/iu'],
];
/** Read every paragraph/list item in the consulted edition; headings are citation context.
* @param array<string,mixed> $article
* @return array<string,mixed>
*/
public function describe(array $article): array
{
$sector = $article['sector']['slug'] ?? '';
if (! isset(self::ACTIVITIES[$sector])) {
return ['ideas' => [], 'blocks' => []];
}
$doc = new DOMDocument;
$previous = libxml_use_internal_errors(true);
try {
$doc->loadHTML('<?xml encoding="UTF-8"><html><body>'.$article['html'].'</body></html>', LIBXML_NONET);
$nodes = (new DOMXPath($doc))->query('//h2|//h3|//h4|//p[not(ancestor::li)]|//li[not(descendant::li)]');
$blocks = [];
$section = '';
$occurrences = [];
if ($nodes === false) {
throw new \RuntimeException('Não foi possível analisar o conteúdo sectorial.');
}
foreach ($nodes as $node) {
if (! $node instanceof \DOMElement) {
continue;
}
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent));
if ($text === '') {
continue;
}
if (str_starts_with($node->nodeName, 'h')) {
$section = $text;
continue;
}
$hash = hash('sha256', $section."\n".$text);
$occurrences[$hash] = ($occurrences[$hash] ?? 0) + 1;
$blocks[] = ['key' => $hash.':'.$occurrences[$hash], 'section' => $section, 'quote' => $text];
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$region = self::REGIONS[$article['legacy_id']] ?? null;
$ideas = [];
foreach (self::ACTIVITIES[$sector] as $label => $pattern) {
// Exact supporting passage, never a conclusion about demand or return.
$block = array_find($blocks, fn ($b) => preg_match($pattern, $b['quote']) === 1);
if (! $block) {
continue;
}
$ideas[] = ['title' => $label, 'classification' => 'hypothesis', 'evidence' => $block,
'location_label' => $region, 'location_precision' => $region ? 'province' : 'source_scope',
'start_href' => $article['start_href'].'?'.http_build_query(['evidence' => $block['key'], 'source_sha256' => $article['sha256'], 'source_version' => $article['version'], 'idea' => $label])];
}
return ['ideas' => $ideas, 'blocks' => $blocks, 'region' => $region, 'sector_slug' => $sector,
'source_date' => $article['modified_at'], 'source_version' => $article['version'],
'notice' => 'Possibilidades para estudar, não anúncios abertos. A fonte descreve o contexto; procura, concorrência, preços, direitos e rentabilidade exigem análise própria.'];
}
/** Resolve identifiers against the current published bytes, not a client-supplied quotation.
* @param array<string,mixed> $article
* @param array<string,mixed> $input
* @return array<string,mixed>|null
*/
public function selection(array $article, array $input): ?array
{
if (empty($input['evidence'])) {
return null;
}
$v = Validator::make($input, ['evidence' => ['required', 'regex:/^[a-f0-9]{64}:\d+$/D'],
'source_sha256' => 'required|string|size:64', 'source_version' => 'required|integer|min:1', 'idea' => 'required|string|max:200'])->validate();
abort_unless(hash_equals($article['sha256'], $v['source_sha256']) && (int) $v['source_version'] === (int) $article['version'], 409, 'O conteúdo mudou. Consulte a edição actual antes de continuar.');
$analysis = $this->describe($article);
$idea = array_find($analysis['ideas'], fn ($i) => $i['title'] === $v['idea'] && $i['evidence']['key'] === $v['evidence']);
abort_unless($idea, 422, 'Escolha uma possibilidade sustentada pelo conteúdo consultado.');
return ['key' => hash('sha256', $article['id'].':'.$article['version'].$article['sha256'].$v['evidence'].$v['idea']),
'title' => $v['idea'], 'evidence_key' => $v['evidence'], 'classification' => 'hypothesis', 'evidence_classification' => 'documented_source_statement',
'quote' => $idea['evidence']['quote'], 'section' => $idea['evidence']['section'],
'source_sha256' => $article['sha256'], 'source_version' => $article['version'], 'source_date' => $article['modified_at'],
'content_sha256' => hash('sha256', $article['html']),
'resource_id' => $article['id'], 'sector' => $article['sector'], 'location_label' => $analysis['region'],
'location_precision' => $analysis['region'] ? 'province' : 'source_scope',
'href' => $article['href'], 'open_participation' => false];
}
/** Save the immutable passage inside the existing dossier source; do not overwrite declarations.
* @param array<string,mixed> $input
* @return array<string,mixed>|null
*/
public function attach(User $actor, string $id, array $input): ?array
{
$article = app(LegacyContent::class)->articleForResource((int) ($input['resource_id'] ?? 0));
$selection = $this->selection($article, $input);
if (! $selection) {
app(LegacyContent::class)->attach($actor, $id, $input);
return null;
}
return DB::transaction(function () use ($actor, $id, $input, $selection) {
$desk = app(VirtualDesk::class);
$case = $desk->case($actor, $id, true, true);
abort_unless((int) $case->version === (int) ($input['version'] ?? 0), 409, 'O dossier mudou. Recupere-o antes de continuar.');
app(LegacyContent::class)->attach($actor, $id, $input);
$case = $desk->case($actor, $id, true, true);
$payload = $desk->decode($case->payload);
foreach ($payload['investor_path']['content_sources'] as &$source) {
if ($source['resource_id'] !== $selection['resource_id'] || $source['sha256'] !== $selection['source_sha256']) {
continue;
}
if (array_any($source['analyses'] ?? [], fn ($a) => $a['key'] === $selection['key'])) {
return $selection;
}
$source['analyses'][] = [...$selection, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()];
}
unset($source);
DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
$desk->event($desk->case($actor, $id), $actor, 'sector_analysis_referenced', ['key' => $selection['key'], 'resource_id' => $selection['resource_id']]);
return $selection;
});
}
/** Propose only the expressly identified province; district and exact position stay unset.
* @param array<string,mixed> $selection
*/
public function continuation(string $id, array $selection): string
{
$query = ['sector_id' => $selection['sector']['id']];
if ($selection['location_label']) {
$matches = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->get(['id', 'name'])
->filter(fn ($p) => Str::lower(Str::ascii($p->name)) === Str::lower(Str::ascii($selection['location_label'])));
if ($matches->count() === 1) {
$query['province_id'] = $matches->first()->id;
}
}
return '/investimentos/'.$id.'/contexto/editar?'.http_build_query($query);
}
/** The saved quotation survives source changes/withdrawal; never replace it silently.
* @param list<array<string,mixed>> $sources
* @return list<array<string,mixed>>
*/
public function savedSources(array $sources): array
{
foreach ($sources as &$source) {
if (empty($source['analyses'])) {
continue;
}
try {
$current = app(LegacyContent::class)->articleForResource($source['resource_id']);
$source['analysis_review_required'] = array_any($source['analyses'], fn ($a) => $a['source_version'] !== $current['version'] || ! hash_equals($a['source_sha256'], $current['sha256'])
|| ! hash_equals($a['content_sha256'], hash('sha256', $current['html'])));
} catch (HttpException $error) {
if (! in_array($error->getStatusCode(), [404, 409, 422], true)) {
throw $error;
}
$source['analysis_review_required'] = true;
}
}
unset($source);
return $sources;
}
/** Provincial reading remains provincial when consulted from a district/locality.
* @param list<string> $ids
* @return list<array<string,mixed>>
*/
public function forTerritories(array $ids): array
{
if (! app(LegacyContent::class)->available() || $ids === []) {
return [];
}
$provinces = [];
foreach ($ids as $id) {
foreach (app(InvestmentGeography::class)->path($id) as $node) {
if ($node->location_type === 'province') {
$provinces[] = Str::lower(Str::ascii($node->name));
}
}
}
$result = [];
foreach (config('legacy_content.articles', []) as $slug => $identity) {
if ($identity['group'] !== 'turismo') {
continue;
}
preg_match('/post-(\d+)-/', $identity['external_id'], $match);
$region = self::REGIONS[(int) ($match[1] ?? 0)] ?? null;
if (! $region || ! in_array(Str::lower(Str::ascii($region)), $provinces, true)) {
continue;
}
try {
$a = app(LegacyContent::class)->article($slug, false);
$result[] = ['title' => $a['title'], 'href' => $a['href'], 'analysis' => $a['sector_analysis']];
} catch (HttpException $error) {
if ($error->getStatusCode() !== 404) {
throw $error;
}
}
}
return $result;
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ final class SectorInvestment
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
$articles[] = Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'sector_analysis', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
@@ -1,6 +1,8 @@
import type { SectorAnalysis } from './sector-content-analysis';
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
sector_analysis?: SectorAnalysis;
title: string;
href: string;
summary: string;
@@ -0,0 +1,98 @@
import { Link } from '@inertiajs/react';
import { useState } from 'react';
export type SectorIdea = {
title: string;
start_href: string;
location_label: string | null;
evidence: { key: string; quote: string; section: string };
};
export type SectorAnalysis = {
ideas: SectorIdea[];
region?: string | null;
notice: string;
source_date: string;
};
/** The same evidence-to-preparation controls on sector and regional articles. */
export default function SectorContentAnalysis({
data,
}: {
data: SectorAnalysis;
}) {
const [selected, setSelected] = useState(0);
const idea = data.ideas[selected];
if (!idea) return null;
return (
<section
className="space-y-4 rounded-xl border bg-white p-4"
aria-label="Ideias fundamentadas neste contexto"
>
<h2 className="text-xl font-semibold">Do contexto à sua ideia</h2>
<p>{data.notice}</p>
<label className="grid gap-1">
Possibilidade a explorar
<select
className="min-w-0 rounded border p-3"
value={selected}
onChange={(e) => setSelected(Number(e.target.value))}
>
{data.ideas.map((item, index) => (
<option key={item.title} value={index}>
{item.title}
</option>
))}
</select>
</label>
<p>
<strong>Hipótese de negócio:</strong> {idea.title}
</p>
<p>
{idea.location_label
? `Contexto provincial: ${idea.location_label}. O distrito e a implantação serão escolhidos por si.`
: 'A localização do investimento fica por escolher. Não é necessário pertencer a uma zona demarcada.'}
</p>
<details className="rounded border p-3">
<summary>Fundamento e origem</summary>
{idea.evidence.section && <p>{idea.evidence.section}</p>}
<blockquote className="my-3 border-l-2 pl-3">
{idea.evidence.quote}
</blockquote>
<p>
Edição consultada: {data.source_date}. Afirmação atribuída à
fonte; não constitui previsão do seu negócio.
</p>
</details>
<ol className="list-decimal space-y-2 pl-5">
<li>Escolha ou confirme a localização conhecida.</li>
<li>
Compare actividades similares e complementares; confira a
cobertura dos registos.
</li>
<li>Prepare custos, receitas e cenários no mesmo dossier.</li>
</ol>
<nav
className="flex flex-wrap gap-3"
aria-label="Continuar esta ideia"
>
<Link
className="rounded bg-emerald-800 p-3 text-white"
href={idea.start_href}
>
Preparar investimento a partir deste contexto
</Link>
<Link
className="rounded border p-3"
href={`${idea.start_href}&analysis_action=compare`}
>
Comparar com a oferta existente
</Link>
</nav>
<p className="text-sm">
A comparação é feita no dossier privado, com os acessos
autorizados. Zero anúncios ou registos não prova ausência de
empresas, concorrência ou potencial.
</p>
</section>
);
}
+16 -2
View File
@@ -1,4 +1,7 @@
import SectorResourceReference, { type SectorResourceData } from './sector-resource-reference';
import SectorContentAnalysis from './sector-content-analysis';
import SectorResourceReference, {
type SectorResourceData,
} from './sector-resource-reference';
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
@@ -45,7 +48,18 @@ export default function SectorEntry({
modalidade adequada. Cada oportunidade tem promotor, objetivo e
dados próprios.
</p>
{data.resource_reference && <SectorResourceReference data={data.resource_reference} />}
{data.resource_reference && (
<SectorResourceReference data={data.resource_reference} />
)}
{data.articles.map(
(article) =>
article.sector_analysis && (
<SectorContentAnalysis
key={article.href}
data={article.sector_analysis}
/>
),
)}
<nav aria-label="Ações no setor" className="flex flex-wrap gap-3">
{data.start_href ? (
<Link
+52 -8
View File
@@ -35,7 +35,24 @@ export type PreparationReport = {
swot: SwotDraft;
swot_review_required: boolean;
};
export type SectorAnalysisSnapshot = {
key: string;
title: string;
quote: string;
section: string;
source_sha256: string;
source_version: number;
source_date: string;
location_label: string | null;
location_precision: string;
href: string;
};
export type InvestorDossierReportInput = {
content_sources?: {
title: string;
source_url: string;
analyses?: SectorAnalysisSnapshot[];
}[];
supply_aggregates?: SupplyAggregate[];
case: { id: string; title: string; version: number };
context_opportunity: string | null;
@@ -332,6 +349,28 @@ export function createInvestorDossierReport(
origin = 'Registo guardado neste dossier',
classification = 'Preparação do investimento',
): DossierReportRow => ({ field, value, origin, classification });
const sectorEvidence = (input.content_sources ?? []).flatMap((source) =>
(source.analyses ?? []).flatMap((a) => [
row(
'Hipótese: ' + a.title,
a.quote,
`${source.title} — ${a.section}; ${source.source_url}; edição ${a.source_date}`,
'Hipótese; afirmação atribuída à fonte',
),
row(
'Âmbito territorial',
a.location_label ?? 'Âmbito da fonte; implantação por definir',
a.href,
a.location_precision,
),
row(
'Versão preservada',
`${a.source_version}; SHA256 ${a.source_sha256}`,
a.href,
'Rastreabilidade da evidência',
),
]),
);
const finance: DossierReportRow[] = [];
for (const result of input.results.filter(
(item) =>
@@ -656,21 +695,26 @@ export function createInvestorDossierReport(
},
{
id: 'market',
title: 'Análise SWOT',
title: sectorEvidence.length
? 'Mercado, evidências sectoriais e SWOT'
: 'Análise SWOT',
private: true,
paragraphs: swot?.rows.length
? [
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
]
: ['Análise SWOT ainda não produzida neste âmbito.'],
rows: (swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
rows: [
...(swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
),
),
),
...sectorEvidence,
],
},
{
id: 'sources',
@@ -1,3 +1,6 @@
import SectorContentAnalysis, {
type SectorAnalysis,
} from '@/components/sector-content-analysis';
import { router } from '@inertiajs/react';
import { useState } from 'react';
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
@@ -20,6 +23,7 @@ function DemographySelection({
filters,
territories,
sectors,
sectorReadings = [],
}: {
evidence: DemographicAnalysis;
filters: {
@@ -35,6 +39,11 @@ function DemographySelection({
parent_id: string | null;
}[];
sectors: Record<string, string>;
sectorReadings?: {
title: string;
href: string;
analysis: SectorAnalysis;
}[];
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [province, setProvince] = useState('');
@@ -226,6 +235,15 @@ function DemographySelection({
))}
</section>
)}
{sectorReadings.map((reading) => (
<section key={reading.href} className="space-y-3">
<h2 className="text-xl font-semibold">{reading.title}</h2>
<a href={reading.href} className="underline">
Consultar contexto provincial completo
</a>
<SectorContentAnalysis data={reading.analysis} />
</section>
))}
</Frame>
);
}
+56 -4
View File
@@ -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 && (
<section className="rounded border p-4">
<h2>{resourceSource.title}</h2>
<p>{resourceSource.location_label ?? resourceSource.locations_text}</p>
<p>
{resourceSource.location_label ??
resourceSource.locations_text}
</p>
<p>{resourceSource.value_chain}</p>
<p>Esta referência será guardada apenas no dossier que escolher.</p>
<p>
Esta referência será guardada apenas no dossier que
escolher.
</p>
</section>
)}
{contentSource && (
@@ -115,6 +136,25 @@ export default function InvestmentContexts({
.
</p>
)}
{sectorIdea && (
<section className="space-y-2 rounded border p-4">
<h2>Hipótese a preparar: {sectorIdea.title}</h2>
<p>
{sectorIdea.location_label ??
'Localização por escolher'}
. Confirme a implantação no passo seguinte.
</p>
<details>
<summary>Fundamento seleccionado</summary>
<blockquote>{sectorIdea.quote}</blockquote>
</details>
<p>
{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.'}
</p>
</section>
)}
<p>
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,
},
)
}
+50 -3
View File
@@ -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<string, string | null>;
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
</Link>
{source.analysis_review_required && (
<p role="status">
A fonte foi alterada ou deixou de estar
disponível. Reveja a hipótese; o trecho
guardado foi preservado.
</p>
)}
{(source.analyses ?? []).map((analysis) => (
<details
className="my-2 rounded border p-3"
key={analysis.key}
>
<summary>
Hipótese guardada: {analysis.title}
</summary>
<p>
{analysis.location_label ??
'Implantação por definir'}
. Contexto da fonte, sem atribuição
distrital automática.
</p>
<blockquote>
{analysis.quote}
</blockquote>
<p>
Edição: {analysis.source_date}.
Preserve este fundamento e reveja-o
se a fonte mudar.
</p>
<Link
className="underline"
href={`/investimentos/${path.case.id}/contexto/editar`}
>
Continuar a preparação no mesmo
dossier
</Link>
</details>
))}
</li>
))}
</ul>
@@ -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,
},
);
}}
>
+7
View File
@@ -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; }
`}</style>
{article.sector_analysis && (
<SectorContentAnalysis data={article.sector_analysis} />
)}
<nav
className="flex flex-wrap gap-4"
aria-label={article.group.label}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace Tests\Feature;
use App\Models\Sector;
use App\Models\User;
use App\Services\LegacyContent;
use App\Services\SectorContentAnalysis;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
final class SectorContentAnalysisTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->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' => '<h2>Contexto sintetico</h2><p>'.$quote.'</p>',
'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']);
}
}
+148 -11
View File
@@ -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 <script>private()</script>',
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('<script>private()</script>'));
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 () => {