feat: preserve grounded sector ideas through private investment dossiers in QA
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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 ?? [])))
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user