2e0f5a4d29
Баг: при 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>
217 lines
11 KiB
PHP
217 lines
11 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|