320 lines
24 KiB
PHP
320 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\ExistingBusiness;
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Services\CorridorAgribusinessOpportunities;
|
|
use App\Services\EditorialReading;
|
|
use App\Services\FederatedSearch;
|
|
use App\Services\FundingInstitutionDetails;
|
|
use App\Services\FundingProgrammes;
|
|
use App\Services\InvestmentCatalogue;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\InvestmentZones;
|
|
use App\Services\LegacyContent;
|
|
use App\Services\SearchTerms;
|
|
use App\Services\SectorOrganization;
|
|
use App\Services\SectorResourceReferences;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
class GlobalSearchController extends Controller
|
|
{
|
|
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
|
|
{
|
|
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
|
|
$original = trim($input['q'] ?? '');
|
|
$intent = $search->requestIntent($original);
|
|
if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
|
|
$intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
|
|
'filter' => $intent['filter'] ?? $original,
|
|
'category' => match ($input['category']) {
|
|
'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
|
|
}];
|
|
}
|
|
$term = $intent['filter'] ?? $original;
|
|
$tokens = $search->tokens($term);
|
|
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
|
|
if ($tokens !== [] || $intent !== null) {
|
|
$factories = [
|
|
'Produtos e localizações' => function () use ($search, $tokens, $term) {
|
|
try {
|
|
return $this->productReferences($search, $tokens, $term);
|
|
} catch (\RuntimeException $exception) {
|
|
throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
|
|
}
|
|
},
|
|
'Produtos financeiros' => function () use ($search, $tokens, $term) {
|
|
$service = app(FundingProgrammes::class);
|
|
if (! $service->installed()) {
|
|
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
|
|
}
|
|
$query = $service->catalogueQuery([])->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
|
|
foreach ($tokens as $token) {
|
|
$query->where(function ($match) use ($search, $token) {
|
|
$search->orFields($match, ["(v.payload->>'title')",
|
|
"(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
|
|
"(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
|
|
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
|
|
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
|
|
foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
|
|
$text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
|
|
if ($search->matches($text, [$token])) {
|
|
$match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
|
|
->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|
|
foreach (FundingInstitutionDetails::NATURES as $code => $label) {
|
|
if ($search->matches($label, [$token])) {
|
|
$match->orWhere('v.payload->institution_nature', $code);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
|
|
$p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
|
$products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
|
|
$services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
|
|
$places = [];
|
|
foreach ($p['locations'] ?? [] as $location) {
|
|
if (! empty($location['location_text'])) {
|
|
$places[] = $location['location_text'];
|
|
}
|
|
foreach ($location['territory_path'] ?? [] as $node) {
|
|
if ($node['type'] !== 'country') {
|
|
$places[] = $node['name'];
|
|
}
|
|
}
|
|
}
|
|
$national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
|
|
|
|
return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
|
|
'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
|
|
'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
|
|
'url' => '/fundos/'.$row->id];
|
|
});
|
|
},
|
|
'Páginas e serviços' => function () use ($search, $tokens, $term) {
|
|
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
|
|
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
|
|
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
|
|
|
|
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
|
|
},
|
|
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
|
|
$query = $catalogue->query(['include_national' => true]);
|
|
foreach ($tokens as $token) {
|
|
$query->where(function ($match) use ($catalogue, $search, $token) {
|
|
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
|
|
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
|
|
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
|
|
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
|
|
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
|
|
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
|
|
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
|
|
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
|
|
if (preg_match('/^[a-z0-9]+$/', $token)) {
|
|
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
|
|
}
|
|
});
|
|
}
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
|
|
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
|
|
},
|
|
'Negócios' => function () use ($search, $tokens, $term) {
|
|
$query = ExistingBusiness::query()->published();
|
|
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
|
|
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
|
|
},
|
|
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
|
|
$query = InvestmentPortfolioController::visible();
|
|
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
|
|
if ($intent !== null && $intent['kind'] !== null) {
|
|
[$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
|
|
$query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
|
|
->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
|
|
// The existing unique entity key already prevents duplicate dossiers.
|
|
}
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
|
|
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
|
|
},
|
|
'Documentos e legislação' => function () use ($search, $tokens, $term) {
|
|
$query = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
|
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
|
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
|
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
|
|
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
|
|
$query->select('r.id', 'r.title', 'r.description');
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
|
|
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
|
|
},
|
|
'Artigos' => function () use ($search, $tokens, $term) {
|
|
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
|
|
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
|
|
}
|
|
$query = app(EditorialReading::class)->publicResources()
|
|
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
|
|
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
|
|
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
|
|
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
|
|
},
|
|
'Sectores' => function () use ($search, $tokens, $term) {
|
|
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
|
|
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
|
|
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
|
|
},
|
|
'Localizações' => function () use ($search, $tokens, $term) {
|
|
$query = app(InvestmentGeography::class)->publicReferences()
|
|
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
|
|
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
|
|
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
|
|
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
|
|
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
|
|
},
|
|
'Zonas de investimento' => function () use ($search, $tokens, $term) {
|
|
$query = app(InvestmentZones::class)->visible();
|
|
$search->constrain($query, ['name', 'code'], $tokens);
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
|
|
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
|
|
},
|
|
'Parcerias' => function () use ($search, $tokens, $term) {
|
|
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
|
|
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
|
|
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
|
|
|
|
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
|
|
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
|
|
},
|
|
];
|
|
if ($intent !== null) {
|
|
$categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
|
|
$factories = array_intersect_key($factories, array_flip($categories));
|
|
}
|
|
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
|
|
fn ($item) => $search->score($item['title'], $term, $tokens));
|
|
}
|
|
|
|
$answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
|
|
'count' => $results['unavailable'] === [] ? $results['total'] : null];
|
|
|
|
if ($answer !== null && $intent['type'] === 'products') {
|
|
$answer['category'] = match ($intent['category']) {
|
|
'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
|
|
};
|
|
}
|
|
|
|
return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
|
|
}
|
|
|
|
/** @param list<string> $tokens
|
|
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
|
|
*/
|
|
private function productReferences(SearchTerms $search, array $tokens, string $term): array
|
|
{
|
|
$rows = [];
|
|
foreach (['mineracao', 'energia-especifica'] as $sector) {
|
|
if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
|
|
continue;
|
|
}
|
|
$data = app(SectorResourceReferences::class)->view($sector);
|
|
foreach ($data['groups'] as $group) {
|
|
$locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
|
|
$rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
|
|
'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
|
|
...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
|
|
}
|
|
}
|
|
if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
|
|
try {
|
|
app(LegacyContent::class)->article('agronegocio', false);
|
|
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
|
|
$published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
|
|
foreach ($entries as $entry) {
|
|
if (! isset($published[$entry['key']])) {
|
|
continue;
|
|
}
|
|
$rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
|
|
'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
|
|
}
|
|
} catch (HttpException $exception) {
|
|
if ($exception->getStatusCode() !== 404) {
|
|
throw $exception;
|
|
}
|
|
}
|
|
}
|
|
$rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
|
|
usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
|
|
|
|
return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
|
|
}
|
|
|
|
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
|
|
* @param list<string> $tokens
|
|
* @param callable(mixed):array<string,mixed> $present
|
|
* @param literal-string $title
|
|
* @param literal-string $id
|
|
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
|
|
*/
|
|
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
|
|
{
|
|
$count = (clone $query)->reorder()->count();
|
|
$query->reorder();
|
|
$search->orderByRelevance($query, $title, $term, $tokens, $id);
|
|
|
|
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
|
|
}
|
|
|
|
/** Public service entry points and terms used to find them.
|
|
* @return list<array{title:string,description:string,url:string}>
|
|
*/
|
|
private function pages(): array
|
|
{
|
|
return [
|
|
['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
|
|
['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
|
|
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
|
|
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
|
|
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
|
|
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
|
|
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
|
|
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
|
|
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
|
|
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
|
|
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
|
|
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
|
|
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
|
|
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
|
|
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
|
|
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
|
|
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
|
|
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
|
|
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
|
|
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
|
|
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
|
|
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
|
|
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
|
|
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
|
|
];
|
|
}
|
|
}
|