Files
portal/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php
T
Дмитрий 2e0f5a4d29
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix(autopodbor): лимит 0 = «не режем» (защита обрезки) + авто-повтор при пустом сборе
Баг: при autopodbor_max_competitors=0 джоба делала array_slice($unique,0,0)=[]
→ сохраняла 0 конкурентов, НО всё равно списывала 50₽ (chargeForRun после обрезки,
проверка empty стои́т ДО неё). Теперь $max>0?slice:все — 0 значит «сохранить всех».
Плюс авто-повтор findCompetitors при пустом сборе (search_retry_on_empty, деф. 2):
моргание 2ГИС/Яндекс/EXA не показывает клиенту «ничего не нашлось». Реально пустая
ниша → empty без списания. 2 теста; ветка автоподбора 176/176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:01:29 +03:00

217 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Mail\AutopodborReadyMail;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\CompetitorIdentity;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// ОДИН прогон без авто-ретраев: движок платный (xfetch+ИИ+EXA), и повтор всего прогона при
// таймауте = тройная цена и время. Медленный-но-настоящий ответ теперь ждём большими таймаутами
// стадий (config services.aitunnel/exa), а не гоняем движок трижды.
public int $tries = 1;
// Живой поиск (2ГИС+Яндекс через антибот + канал В sonar ×2 + EXA + «вскрыть всё»: параллельное
// открытие карточек без лимита) идёт минутами — большой запас, чтобы не убить прогон на середине.
public int $timeout = 3600;
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 1 «собрать поле» (ИИ + внешние сервисы) — своя очередь, отдельный воркер-процесс,
// чтобы тяжёлый поиск не стоял в одной линии с быстрым шагом 2 (изучением) другого клиента.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом
* (ретрай/повторный dispatch). Ключ — runId.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity, AutopodborNormalizer $normalizer, RunProgressChannel $progress): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок будет отмечать этапы именно сюда.
$progress->bind($run->id);
try {
$p = $run->params;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$request = new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
);
// Авто-повтор при ПУСТОМ сборе: внешние источники (2ГИС/Яндекс/EXA) иногда моргают и
// отдают пусто — вместо «ничего не нашлось» пробуем ещё раз (по умолчанию до 2 доп. попыток).
// Реально пустая ниша (все попытки пусты) идёт дальше штатным путём «empty» — без списания.
$retries = max(0, (int) config('autopodbor.search_retry_on_empty', 2));
$attempt = 0;
do {
$attempt++;
$res = $agent->findCompetitors($request);
} while ($res->competitors === [] && $attempt <= $retries);
$unique = $dedup->dedupCompetitors($res->competitors);
// На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же
// опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с
// АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы —
// при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои
// результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем.
$activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where('box', '!=', 'archived')
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->get(['site_url', 'directory_urls'])
->map(fn ($c) => array_flip($identity->keys([
'site_url' => $c->site_url,
'directory_urls' => $c->directory_urls ?? [],
])))
->all();
$unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool {
$keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]);
if ($keys === []) {
return true;
}
foreach ($activeKeySets as $have) {
$shares = false;
$hasNew = false;
foreach ($keys as $k) {
if (isset($have[$k])) {
$shares = true;
} else {
$hasNew = true;
}
}
if ($shares && ! $hasNew) {
return false; // полный дубль активной фирмы → скрыть
}
}
return true;
}));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
$this->notifyReady($run, 0);
return;
}
// $max === 0 → «не режем»: сохраняем ВСЕХ найденных. Иначе array_slice(…, 0, 0) обнулил бы
// непустой результат (баг: прогон сохранял 0 конкурентов, но всё равно списывал).
$saved = $max > 0 ? array_slice($unique, 0, $max) : $unique;
foreach ($saved as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => $c['relevance_pct'] ?? null,
'origin' => 'auto',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
// Телефоны (нормализованные) — для показа совпадений клиенту на «Предложениях».
// В тихую склейку НЕ идут (решение 03.07) — только клиентская «Найти дубли».
'phones' => array_values(array_unique(array_filter(array_map(
static fn ($p): string => $normalizer->phone((string) $p),
$c['phones'] ?? [],
)))),
'provenance' => $c['provenance'] ?? [],
]
);
}
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
$charge->chargeForRun($run, $price);
$run->update(['status' => 'done', 'finished_at' => now()]);
$this->notifyReady($run, count($saved));
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
/**
* Письмо клиенту «подбор готов» — чтобы он не ждал у экрана. Не роняет успешный подбор,
* если почта недоступна (try/catch + report).
*/
private function notifyReady(AutopodborRun $run, int $found): void
{
try {
$email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email');
if (is_string($email) && $email !== '') {
Mail::to($email)->send(new AutopodborReadyMail($run, $found));
}
} catch (\Throwable $e) {
report($e);
}
}
}