diff --git a/app/Http/Controllers/DemographyController.php b/app/Http/Controllers/DemographyController.php index 1845e2f3..1a8da895 100644 --- a/app/Http/Controllers/DemographyController.php +++ b/app/Http/Controllers/DemographyController.php @@ -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 diff --git a/app/Http/Controllers/InvestorPathController.php b/app/Http/Controllers/InvestorPathController.php index 54bdd039..94866883 100644 --- a/app/Http/Controllers/InvestorPathController.php +++ b/app/Http/Controllers/InvestorPathController.php @@ -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']]); } diff --git a/app/Http/Controllers/LegacyContentController.php b/app/Http/Controllers/LegacyContentController.php index a1246a2c..f5ff4ffa 100644 --- a/app/Http/Controllers/LegacyContentController.php +++ b/app/Http/Controllers/LegacyContentController.php @@ -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.'); } diff --git a/app/Services/InvestorPath.php b/app/Services/InvestorPath.php index d415d25c..5ab9ad4a 100644 --- a/app/Services/InvestorPath.php +++ b/app/Services/InvestorPath.php @@ -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 ?? []))) diff --git a/app/Services/LegacyContent.php b/app/Services/LegacyContent.php index ebe1ca48..4653730d 100644 --- a/app/Services/LegacyContent.php +++ b/app/Services/LegacyContent.php @@ -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. */ diff --git a/app/Services/SectorContentAnalysis.php b/app/Services/SectorContentAnalysis.php new file mode 100644 index 00000000..40514e0c --- /dev/null +++ b/app/Services/SectorContentAnalysis.php @@ -0,0 +1,236 @@ + '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 $article + * @return array + */ + 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(''.$article['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 $article + * @param array $input + * @return array|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 $input + * @return array|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 $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> $sources + * @return list> + */ + 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 $ids + * @return list> + */ + 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; + } +} diff --git a/app/Services/SectorInvestment.php b/app/Services/SectorInvestment.php index ce46507a..f1cae54f 100644 --- a/app/Services/SectorInvestment.php +++ b/app/Services/SectorInvestment.php @@ -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; diff --git a/resources/js/components/educational-article-card.tsx b/resources/js/components/educational-article-card.tsx index 3d7d0d36..0a3bf982 100644 --- a/resources/js/components/educational-article-card.tsx +++ b/resources/js/components/educational-article-card.tsx @@ -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; diff --git a/resources/js/components/sector-content-analysis.tsx b/resources/js/components/sector-content-analysis.tsx new file mode 100644 index 00000000..c9b88ff2 --- /dev/null +++ b/resources/js/components/sector-content-analysis.tsx @@ -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 ( +
+

Do contexto à sua ideia

+

{data.notice}

+ +

+ Hipótese de negócio: {idea.title} +

+

+ {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.'} +

+
+ Fundamento e origem + {idea.evidence.section &&

{idea.evidence.section}

} +
+ {idea.evidence.quote} +
+

+ Edição consultada: {data.source_date}. Afirmação atribuída à + fonte; não constitui previsão do seu negócio. +

+
+
    +
  1. Escolha ou confirme a localização conhecida.
  2. +
  3. + Compare actividades similares e complementares; confira a + cobertura dos registos. +
  4. +
  5. Prepare custos, receitas e cenários no mesmo dossier.
  6. +
+ +

+ 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. +

+
+ ); +} diff --git a/resources/js/components/sector-entry.tsx b/resources/js/components/sector-entry.tsx index b5e8fe12..09f4082f 100644 --- a/resources/js/components/sector-entry.tsx +++ b/resources/js/components/sector-entry.tsx @@ -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.

- {data.resource_reference && } + {data.resource_reference && ( + + )} + {data.articles.map( + (article) => + article.sector_analysis && ( + + ), + )}