'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; } }