Files
Investidor-V2/app/Services/OpportunityTextSearch.php
T
2026-11-02 20:31:15 +02:00

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