89 lines
5.0 KiB
PHP
89 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\InvestmentPortfolioController;
|
|
use App\Models\Opportunity;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Str;
|
|
|
|
/** Public keyword search: every meaningful term is required, with explicit narrow writing variants. */
|
|
final class OpportunityTextSearch
|
|
{
|
|
private const STOP_WORDS = ['a', 'as', 'o', 'os', 'de', 'da', 'das', 'do', 'dos', 'e', 'em', 'no', 'nos', 'na', 'nas', 'para', 'por', 'com', 'um', 'uma', 'uns', 'umas'];
|
|
|
|
private const CULTIVATION = ['plantio', 'plantacao', 'plantacoes', 'cultivo', 'cultivar', 'cultivacao', 'semeadura', 'semeio'];
|
|
|
|
/** @return list<string> */
|
|
public function terms(string $text): array
|
|
{
|
|
$parts = preg_split('/[\s,;]+/u', Str::lower(Str::ascii(trim($text)))) ?: [];
|
|
|
|
return array_values(array_unique(array_filter(array_map(fn ($part) => trim($part, "\"'"), $parts),
|
|
fn ($part) => $part !== '' && ! in_array($part, self::STOP_WORDS, true))));
|
|
}
|
|
|
|
/** A secondary query keeps the subject/location; it never relabels processing as cultivation. */
|
|
public function relatedQuery(string $text): ?string
|
|
{
|
|
$terms = $this->terms($text);
|
|
$subject = array_values(array_diff($terms, self::CULTIVATION));
|
|
|
|
return $subject !== [] && count($subject) < count($terms) ? implode(' ', $subject) : null;
|
|
}
|
|
|
|
/** @param literal-string $column
|
|
* @return literal-string */
|
|
private function normalized(string $column): string
|
|
{
|
|
return "translate(lower(COALESCE($column, '')), 'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ', 'aaaaaaeeeeiiiiooooouuuucnyy')";
|
|
}
|
|
|
|
/** @param Builder<Opportunity> $query */
|
|
public function constrain(Builder $query, string $text): void
|
|
{
|
|
$terms = $this->terms($text);
|
|
if ($terms === []) {
|
|
$query->whereRaw('false');
|
|
|
|
return;
|
|
}
|
|
foreach ($terms as $term) {
|
|
$variants = in_array($term, self::CULTIVATION, true) ? ['plantio', 'plantacao', 'plantacoes', 'cultiv', 'semead', 'semeio'] : [$term];
|
|
$patterns = array_map(fn ($value) => '%'.addcslashes($value, '%_\\').'%', $variants);
|
|
$geography = app(InvestmentGeography::class);
|
|
$locations = $geography->publicReferences()->where(function ($names) use ($patterns): void {
|
|
foreach ($patterns as $pattern) {
|
|
$names->orWhereRaw($this->normalized('name').' LIKE ?', [$pattern]);
|
|
}
|
|
})->pluck('id');
|
|
$territories = [];
|
|
foreach ($locations as $location) {
|
|
$territories = array_merge($territories, $geography->descendants((string) $location));
|
|
}
|
|
$territories = array_values(array_unique($territories));
|
|
$query->where(function ($matches) use ($patterns, $territories): void {
|
|
foreach ($patterns as $pattern) {
|
|
foreach (['opportunities.title', 'opportunities.description', 'opportunities.summary', 'opportunities.reference', 'opportunities.province', 'opportunities.district'] as $column) {
|
|
$matches->orWhereRaw($this->normalized($column).' LIKE ?', [$pattern]);
|
|
}
|
|
$matches->orWhereHas('sector', fn ($sector) => $sector->whereRaw($this->normalized('name').' LIKE ?', [$pattern])
|
|
->orWhereRaw($this->normalized('slug').' LIKE ?', [$pattern]));
|
|
$matches->orWhereExists(fn ($dossier) => $dossier->selectRaw('1')->from('investment_dossiers as searchable_dossier')
|
|
->whereIn('searchable_dossier.id', InvestmentPortfolioController::visible()->reorder()->select('investment_dossiers.id'))
|
|
->where(fn ($value) => $value->whereRaw($this->normalized('searchable_dossier.title').' LIKE ?', [$pattern])
|
|
->orWhereRaw($this->normalized('searchable_dossier.content::text').' LIKE ?', [$pattern]))
|
|
->where(fn ($relation) => $relation
|
|
->whereExists(fn ($project) => $project->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id'))
|
|
->orWhereExists(fn ($cluster) => $cluster->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id'))));
|
|
}
|
|
if ($territories !== []) {
|
|
$matches->orWhereIn('opportunities.primary_territory_id', $territories)
|
|
->orWhereExists(fn ($linked) => $linked->selectRaw('1')->from('opportunity_locations as search_link')
|
|
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereIn('search_link.geo_location_id', $territories));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|