Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65770f0e70 | |||
| 4c05af7fda | |||
| 8a8783df4a | |||
| 9ff7bc8fa0 | |||
| f78ab4af3d | |||
| a88c4034b7 | |||
| b3f5158e48 | |||
| 76c67e1fc5 | |||
| 9a9164fee2 | |||
| 82123057f5 | |||
| 5a861e0e85 | |||
| 6d48503a0e | |||
| fc4e4bacbf | |||
| 68341e5576 | |||
| f1ab608444 | |||
| c259855349 | |||
| 01a057c66c | |||
| 6e970d7231 | |||
| f9f4743392 | |||
| dee2ebbcf8 | |||
| 5d40de664e | |||
| 5a65165114 | |||
| d468471707 | |||
| f940c060b8 | |||
| 062d46e15e | |||
| 7e1ff09abe | |||
| 405e4bb182 | |||
| adc3590ab6 | |||
| 48e65d231c | |||
| 84e769e454 | |||
| 0636a3c1b4 | |||
| fcff8ecd47 | |||
| 0fc589792f | |||
| 026bc48d41 | |||
| f7ec94e107 | |||
| 0d5e06e895 | |||
| b900874a72 | |||
| 64d6703bb3 | |||
| 4de9474a27 | |||
| b2c89d12bd | |||
| fc907c2564 | |||
| 7746ccc54d | |||
| 858d8be1b2 | |||
| 2cf8b74763 | |||
| 2e37a7a380 | |||
| 92e2eef702 | |||
| bfda9f8d46 | |||
| efdb3ee2c3 | |||
| 5851ac48d3 | |||
| a42647c6fe | |||
| c2d5592696 | |||
| 3f3a142b76 | |||
| 6e5f47962c | |||
| 9ba11e4bd0 | |||
| cb688c334f | |||
| f248c37d1b | |||
| 1b76cfec15 | |||
| be92afebd3 | |||
| b78c3edb8c | |||
| 6f4e6de9a3 | |||
| aedefa3a94 | |||
| fe1c5fbabf | |||
| 7d5ab011ea | |||
| dc290147b1 | |||
| 5ad50d8b8d | |||
| 1d124afb76 | |||
| 97587010a3 | |||
| fbebe40383 | |||
| d93404bc62 | |||
| 0a450bf679 | |||
| d50a3d5108 | |||
| bc462d25fa | |||
| 1b3683c6b1 | |||
| 793b20a39c | |||
| 3561028dd2 | |||
| 4387333118 | |||
| 3d4261cba1 | |||
| ef815c0b8c | |||
| 9b4622da85 | |||
| 23263d18a0 | |||
| 5ba553a0cc | |||
| 48509572b5 | |||
| 3bc4325b78 | |||
| 361d02a256 | |||
| 33ac1a5954 | |||
| 17d93a144b | |||
| aa807c0ed4 | |||
| e52e958484 | |||
| 8cc6511edd | |||
| 02d2163e75 | |||
| 3c8886c97f | |||
| f208fe2f65 | |||
| 98b26f6191 | |||
| d9b3e8dbe1 | |||
| a3b68dbb95 | |||
| 78d1965430 | |||
| 1de6984035 | |||
| 4042890b0a | |||
| 77498df63b | |||
| 6789879a2c | |||
| 3b9c1b8bdc | |||
| 0a111d9f85 | |||
| 3c2bb18537 | |||
| df19af99f9 | |||
| b5c88b2f1d | |||
| 2de1f1e35f | |||
| cc73a70f9e | |||
| 786f796223 | |||
| e7660edd79 | |||
| 1fe071f203 | |||
| c92d498b57 | |||
| 2911f3ac0e | |||
| 75dded78a1 | |||
| cab0347fd2 | |||
| b2f08f28d5 | |||
| 00d32ef182 | |||
| 6536c19c96 | |||
| 14bb8a017c | |||
| 5c68b24c7b | |||
| a43f3df4c1 | |||
| d961d1617a | |||
| 7b44e743a4 | |||
| 1ecb965981 | |||
| 1fe68e7367 | |||
| 22ad20337a | |||
| 89808c1f47 | |||
| fa404e98ec | |||
| eacaee493f | |||
| c03e2b319b | |||
| 36a27cb22c | |||
| 505dd5711e | |||
| 93e8393014 | |||
| 88e816c576 | |||
| 95ea4b764e | |||
| e17433e069 |
@@ -87,6 +87,11 @@ paths = [
|
||||
'''app/composer\.lock''',
|
||||
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
|
||||
'''app/tests/.*\.php''',
|
||||
# Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и
|
||||
# синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном
|
||||
# конкурента (опубликован в открытом справочнике), не клиентские ПДн.
|
||||
# Та же категория, что app/tests/*.php выше.
|
||||
'''app/tests/fixtures/.*''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
|
||||
+3
-6
@@ -84,12 +84,6 @@ MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SUPPORT_EMAIL=support@liderra.ru
|
||||
JIVO_WIDGET_ID=
|
||||
JIVO_BOT_WEBHOOK_SECRET=
|
||||
JIVO_BOT_OUTBOUND_URL=
|
||||
JIVO_BOT_TOKEN=
|
||||
JIVO_BOT_TOURS_ENABLED=false
|
||||
YANDEX_GPT_API_KEY=
|
||||
YANDEX_GPT_FOLDER_ID=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
@@ -102,3 +96,6 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
|
||||
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
|
||||
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
|
||||
|
||||
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
|
||||
XFETCH_API_KEY=
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use App\Support\Help\HelpArticleParser;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Переиндексация базы знаний бота из resources/help/*.md (спека §3).
|
||||
* Полная перезаливка в транзакции: удалённые статьи исчезают, новые появляются.
|
||||
* Ночной schedule 04:30 + ручной запуск при срочном обновлении инструкции.
|
||||
*/
|
||||
class HelpRebuildKnowledgeCommand extends Command
|
||||
{
|
||||
protected $signature = 'help:rebuild-knowledge';
|
||||
|
||||
protected $description = 'Перечитать статьи resources/help и обновить knowledge_chunks';
|
||||
|
||||
public function handle(HelpArticleParser $parser): int
|
||||
{
|
||||
$dir = resource_path('help');
|
||||
$files = glob($dir.'/*.md') ?: [];
|
||||
if ($files === []) {
|
||||
$this->error("В {$dir} нет статей *.md — база знаний осталась прежней.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
foreach ($files as $file) {
|
||||
$articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($articles): void {
|
||||
KnowledgeChunk::query()->delete();
|
||||
foreach ($articles as $article) {
|
||||
foreach ($article->chunks as $i => $chunk) {
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => $article->sourcePath,
|
||||
'title' => $article->title,
|
||||
'tour' => $article->tour,
|
||||
'topics' => $article->topics,
|
||||
'chunk_index' => $i,
|
||||
'content' => $chunk,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('Проиндексировано статей: %d.', count($articles)));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
class RunInFlightException extends \RuntimeException {}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Dashboard\SupplyReconciliation;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -18,41 +19,63 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
/** Период из query: today | 7d | 30d | 60d | 90d (дефолт 7d). */
|
||||
private function periodStart(Request $request): Carbon
|
||||
/**
|
||||
* Диапазон периода из query: либо date_from/date_to (свой период, приоритет),
|
||||
* либо preset period=today|7d|30d|60d|90d (дефолт 7d). Возвращает [from, to]:
|
||||
* to — верхняя граница (конец дня date_to при своём периоде, иначе now).
|
||||
*
|
||||
* @return array{0:Carbon,1:Carbon}
|
||||
*/
|
||||
private function periodRange(Request $request): array
|
||||
{
|
||||
return match ((string) $request->query('period', '7d')) {
|
||||
$df = (string) $request->query('date_from', '');
|
||||
$dt = (string) $request->query('date_to', '');
|
||||
if ($df !== '' && $dt !== '') {
|
||||
try {
|
||||
return [Carbon::parse($df)->startOfDay(), Carbon::parse($dt)->endOfDay()];
|
||||
} catch (\Throwable) {
|
||||
// невалидные даты → падаем на preset ниже
|
||||
}
|
||||
}
|
||||
|
||||
$from = match ((string) $request->query('period', '7d')) {
|
||||
'today' => now()->startOfDay(),
|
||||
'30d' => now()->subDays(30),
|
||||
'60d' => now()->subDays(60),
|
||||
'90d' => now()->subDays(90),
|
||||
default => now()->subDays(7),
|
||||
};
|
||||
|
||||
return [$from, now()];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard — сводка L1 (плитки Финансы + Здоровье). */
|
||||
/** GET /api/admin/dashboard — сводка L1 (все плитки). */
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$from = $this->periodStart($request);
|
||||
[$from, $to] = $this->periodRange($request);
|
||||
|
||||
return response()->json([
|
||||
'period' => (string) $request->query('period', '7d'),
|
||||
'finance' => $this->financeTile($from),
|
||||
'date_from' => $request->query('date_from'),
|
||||
'date_to' => $request->query('date_to'),
|
||||
'finance' => $this->financeTile($from, $to),
|
||||
'health' => $this->healthTile(),
|
||||
'leads' => $this->leadsTile(),
|
||||
'supply' => $this->supplyTile(),
|
||||
'balances' => $this->balancesTile(),
|
||||
'clients' => $this->clientsTile($from, $to),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function financeTile(Carbon $from): array
|
||||
private function financeTile(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$charges = (float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
|
||||
$newClients = DB::table('tenants')->where('created_at', '>=', $from)->whereNull('deleted_at')->count();
|
||||
$newClients = DB::table('tenants')->whereBetween('created_at', [$from, $to])->whereNull('deleted_at')->count();
|
||||
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
|
||||
|
||||
return [
|
||||
@@ -68,12 +91,12 @@ class AdminDashboardController extends Controller
|
||||
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
|
||||
public function finance(Request $request): JsonResponse
|
||||
{
|
||||
$from = $this->periodStart($request);
|
||||
[$from, $to] = $this->periodRange($request);
|
||||
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$charges = abs((float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub'));
|
||||
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub'));
|
||||
|
||||
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
|
||||
$attention = DB::table('tenants')->whereNull('deleted_at')
|
||||
@@ -93,7 +116,7 @@ class AdminDashboardController extends Controller
|
||||
$top = DB::table('balance_transactions')
|
||||
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
|
||||
->where('balance_transactions.type', 'topup')
|
||||
->where('balance_transactions.created_at', '>=', $from)
|
||||
->whereBetween('balance_transactions.created_at', [$from, $to])
|
||||
->whereNull('tenants.deleted_at')
|
||||
->groupBy('tenants.id', 'tenants.organization_name')
|
||||
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
|
||||
@@ -256,11 +279,29 @@ class AdminDashboardController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
|
||||
/** GET /api/admin/dashboard/leads — KPI распределения лидов + топ-10 последних (L2). */
|
||||
public function leads(): JsonResponse
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
// Топ-10 последних лидов для drill (полный список — на экране /admin/leads).
|
||||
$recent = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
|
||||
->orderByDesc('sl.received_at')
|
||||
->limit(10)
|
||||
->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at',
|
||||
'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key'])
|
||||
->map(fn ($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'received_at' => $r->received_at,
|
||||
'platform' => $r->platform,
|
||||
'channel' => $r->channel,
|
||||
'source' => $r->unique_key,
|
||||
'phone_masked' => $this->maskPhoneShort($r->phone),
|
||||
'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0,
|
||||
'processed' => $r->processed_at !== null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'light' => $m['light'],
|
||||
'kpi' => [
|
||||
@@ -269,9 +310,21 @@ class AdminDashboardController extends Controller
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
],
|
||||
'recent' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Короткая маска телефона для drill (152-ФЗ). */
|
||||
private function maskPhoneShort(?string $phone): string
|
||||
{
|
||||
if (! $phone) {
|
||||
return '—';
|
||||
}
|
||||
$d = preg_replace('/\D/', '', $phone);
|
||||
|
||||
return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***';
|
||||
}
|
||||
|
||||
// === Этап 2: Заказ у поставщика ===
|
||||
|
||||
/**
|
||||
@@ -343,6 +396,172 @@ class AdminDashboardController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// === Балансы внешних сервисов (28.06) ===
|
||||
|
||||
/** Порядок «опасности» светофора: больше = хуже. */
|
||||
private const LIGHT_ORDER = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
|
||||
|
||||
/**
|
||||
* Прямая ссылка «Пополнить» для сервиса (статика из конфига; в БД не хранится).
|
||||
* Владелец с планшета: увидел минус → ткнул → попал на страницу оплаты.
|
||||
*/
|
||||
private function topupUrl(string $key): ?string
|
||||
{
|
||||
return match ($key) {
|
||||
'dadata' => (string) config('services.dadata.topup_url') ?: null,
|
||||
'supplier' => (string) config('services.supplier.topup_url') ?: null,
|
||||
'yandex_cloud' => $this->ycTopupUrl(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function ycTopupUrl(): ?string
|
||||
{
|
||||
$base = (string) config('services.yandex_cloud.console_billing_url');
|
||||
$acc = (string) config('services.yandex_cloud.billing_account_id');
|
||||
if ($base === '' || $acc === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim($base, '/').'/'.$acc.'/payments';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function balancesTile(): array
|
||||
{
|
||||
$rows = DB::table('external_service_balances')->get();
|
||||
$light = $rows->isEmpty() ? 'grey'
|
||||
: $rows->map(fn ($r) => $r->ok ? $r->light : 'grey')
|
||||
->sortByDesc(fn ($l) => self::LIGHT_ORDER[$l] ?? 0)->first();
|
||||
|
||||
return [
|
||||
'light' => $light,
|
||||
'count' => $rows->count(),
|
||||
'red' => $rows->where('ok', true)->where('light', 'red')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */
|
||||
public function balances(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('external_service_balances')->get()->map(fn ($r) => [
|
||||
'service_key' => $r->service_key,
|
||||
'balance_amount' => $r->balance_amount,
|
||||
'currency' => $r->currency,
|
||||
'daily_spend_estimate' => $r->daily_spend_estimate,
|
||||
'days_left' => $r->days_left,
|
||||
'light' => $r->ok ? $r->light : 'grey',
|
||||
'ok' => (bool) $r->ok,
|
||||
'error' => $r->error,
|
||||
'checked_at' => $r->checked_at,
|
||||
'topup_url' => $this->topupUrl($r->service_key),
|
||||
])->values();
|
||||
|
||||
$light = $rows->isEmpty() ? 'grey'
|
||||
: $rows->sortByDesc(fn ($s) => self::LIGHT_ORDER[$s['light']] ?? 0)->first()['light'];
|
||||
|
||||
return response()->json(['light' => $light, 'services' => $rows]);
|
||||
}
|
||||
|
||||
// === Клиенты (активность) ===
|
||||
|
||||
/** Клиент «спит», если его тенант не заходил дольше этого срока (или ни разу). */
|
||||
private const DORMANT_DAYS = 14;
|
||||
|
||||
/** @return array{total_active:int,new_count:int,logged_in:int,got_leads:int,paid:int} */
|
||||
private function clientActivityKpi(Carbon $from, Carbon $to): array
|
||||
{
|
||||
return [
|
||||
'total_active' => DB::table('tenants')->whereNull('deleted_at')->where('status', 'active')->count(),
|
||||
'new_count' => DB::table('tenants')->whereNull('deleted_at')->whereBetween('created_at', [$from, $to])->count(),
|
||||
'logged_in' => DB::table('users')->whereBetween('last_login_at', [$from, $to])->distinct()->count('tenant_id'),
|
||||
'got_leads' => DB::table('deals')->whereBetween('received_at', [$from, $to])->where('is_test', false)
|
||||
->whereNull('deleted_at')->distinct()->count('tenant_id'),
|
||||
'paid' => DB::table('balance_transactions')->where('type', 'topup')->whereBetween('created_at', [$from, $to])
|
||||
->distinct()->count('tenant_id'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Активные тенанты без входа дольше DORMANT_DAYS (или ни разу) — «спящие». */
|
||||
private function dormantQuery(): Builder
|
||||
{
|
||||
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
|
||||
->groupBy('tenant_id');
|
||||
|
||||
return DB::table('tenants')
|
||||
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
|
||||
->whereNull('tenants.deleted_at')
|
||||
->where('tenants.status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ll.last_login_at')
|
||||
->orWhere('ll.last_login_at', '<', now()->subDays(self::DORMANT_DAYS));
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function clientsTile(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$kpi = $this->clientActivityKpi($from, $to);
|
||||
$dormant = (clone $this->dormantQuery())->count();
|
||||
|
||||
return [
|
||||
'light' => $dormant > 0 ? 'amber' : 'green',
|
||||
'total_active' => $kpi['total_active'],
|
||||
'new_count' => $kpi['new_count'],
|
||||
'logged_in' => $kpi['logged_in'],
|
||||
'dormant' => $dormant,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/clients — активность клиентов + новые + спящие (L2). */
|
||||
public function clients(Request $request): JsonResponse
|
||||
{
|
||||
[$from, $to] = $this->periodRange($request);
|
||||
$kpi = $this->clientActivityKpi($from, $to);
|
||||
|
||||
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
|
||||
->groupBy('tenant_id');
|
||||
|
||||
$newClients = DB::table('tenants')
|
||||
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
|
||||
->whereNull('tenants.deleted_at')
|
||||
->whereBetween('tenants.created_at', [$from, $to])
|
||||
->orderByDesc('tenants.created_at')
|
||||
->limit(50)
|
||||
->get([
|
||||
'tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.status',
|
||||
'tenants.created_at', 'tenants.balance_rub', 'tenants.delivered_in_month', 'll.last_login_at',
|
||||
])
|
||||
->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name ?: $t->subdomain,
|
||||
'subdomain' => $t->subdomain,
|
||||
'status' => $t->status,
|
||||
'created_at' => $t->created_at,
|
||||
'last_login_at' => $t->last_login_at,
|
||||
'delivered_in_month' => (int) $t->delivered_in_month,
|
||||
'balance_rub' => (string) $t->balance_rub,
|
||||
]);
|
||||
|
||||
$dormant = (clone $this->dormantQuery())
|
||||
->orderByRaw('ll.last_login_at ASC NULLS FIRST')
|
||||
->limit(50)
|
||||
->get(['tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.balance_rub', 'll.last_login_at'])
|
||||
->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name ?: $t->subdomain,
|
||||
'subdomain' => $t->subdomain,
|
||||
'last_login_at' => $t->last_login_at,
|
||||
'balance_rub' => (string) $t->balance_rub,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'kpi' => $kpi,
|
||||
'new_clients' => $newClients,
|
||||
'dormant' => $dormant,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
|
||||
public function supply(): JsonResponse
|
||||
{
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin «Лиды» (L3) — сквозная вложенность дашборда до конечного источника.
|
||||
* Серверная пагинация/фильтры (масштаб: десятки тысяч лидов).
|
||||
* Цепочка: supplier_leads.supplier_project_id → источник (канал+identifier),
|
||||
* platform = поставщик (B1/B2/B3), resolved_subject_code = регион,
|
||||
* deals.source_crm_id = supplier_leads.vid → сделки клиентов.
|
||||
* Группа ['saas-admin','admin-db'] → cross-tenant через pgsql_admin.
|
||||
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
|
||||
*/
|
||||
class AdminLeadsController extends Controller
|
||||
{
|
||||
private const PER_PAGE_DEFAULT = 25;
|
||||
|
||||
private const PER_PAGE_MAX = 100;
|
||||
|
||||
private const STUCK_HOURS = 4;
|
||||
|
||||
/** Маска телефона по 152-ФЗ: «+7 9** *** ** 07» (видны код страны и 2 последние). */
|
||||
private function maskPhone(?string $phone): string
|
||||
{
|
||||
if (! $phone) {
|
||||
return '—';
|
||||
}
|
||||
$digits = preg_replace('/\D/', '', $phone);
|
||||
if (strlen((string) $digits) < 4) {
|
||||
return '***';
|
||||
}
|
||||
$last2 = substr((string) $digits, -2);
|
||||
$first = substr((string) $digits, 0, 2);
|
||||
|
||||
return $first.'** *** ** '.$last2;
|
||||
}
|
||||
|
||||
/** Производный статус лида для UI. */
|
||||
private function statusOf(object $r): string
|
||||
{
|
||||
if ($r->error !== null && $r->error !== '') {
|
||||
return 'error';
|
||||
}
|
||||
if ($r->processed_at !== null) {
|
||||
return ((int) ($r->deals_created_count ?? 0)) > 0 ? 'delivered' : 'no_match';
|
||||
}
|
||||
|
||||
return 'pending'; // визуально «завис» определяет фронт по времени, но базово pending
|
||||
}
|
||||
|
||||
/** Базовый запрос лидов с присоединённым источником (supplier_projects). */
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id');
|
||||
|
||||
if (($df = (string) $request->query('date_from', '')) !== '' && ($dt = (string) $request->query('date_to', '')) !== '') {
|
||||
$q->whereBetween('sl.received_at', [$df.' 00:00:00', $dt.' 23:59:59']);
|
||||
}
|
||||
if (($channel = (string) $request->query('channel', '')) !== '') {
|
||||
$q->where('sp.signal_type', $channel);
|
||||
}
|
||||
if (($platform = (string) $request->query('platform', '')) !== '') {
|
||||
$q->where('sl.platform', $platform);
|
||||
}
|
||||
if (($search = trim((string) $request->query('search', ''))) !== '') {
|
||||
$q->where(function ($w) use ($search) {
|
||||
$w->where('sl.phone', 'like', '%'.$search.'%')
|
||||
->orWhere('sp.unique_key', 'like', '%'.$search.'%')
|
||||
->orWhere('sl.vid', '=', ctype_digit($search) ? (int) $search : 0);
|
||||
});
|
||||
}
|
||||
if (($status = (string) $request->query('status', '')) !== '') {
|
||||
$this->applyStatusFilter($q, $status);
|
||||
}
|
||||
if (($tenantId = (int) $request->query('tenant_id', 0)) > 0) {
|
||||
$q->whereExists(function ($e) use ($tenantId) {
|
||||
$e->select(DB::raw(1))->from('deals')
|
||||
->whereColumn('deals.source_crm_id', 'sl.vid')
|
||||
->where('deals.tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
private function applyStatusFilter(Builder $q, string $status): void
|
||||
{
|
||||
match ($status) {
|
||||
'error' => $q->whereNotNull('sl.error')->where('sl.error', '<>', ''),
|
||||
'delivered' => $q->whereNotNull('sl.processed_at')->where('sl.deals_created_count', '>', 0),
|
||||
'no_match' => $q->whereNotNull('sl.processed_at')
|
||||
->where(fn ($w) => $w->whereNull('sl.deals_created_count')->orWhere('sl.deals_created_count', '=', 0)),
|
||||
'stuck' => $q->whereNull('sl.processed_at')->where('sl.received_at', '<', now()->subHours(self::STUCK_HOURS)),
|
||||
'pending' => $q->whereNull('sl.processed_at'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function rowToArray(object $r): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $r->id,
|
||||
'received_at' => $r->received_at,
|
||||
'platform' => $r->platform,
|
||||
'channel' => $r->channel,
|
||||
'source' => $r->unique_key,
|
||||
'region_code' => $r->resolved_subject_code !== null ? (int) $r->resolved_subject_code : null,
|
||||
'phone_masked' => $this->maskPhone($r->phone),
|
||||
'deals_created_count' => (int) ($r->deals_created_count ?? 0),
|
||||
'status' => $this->statusOf($r),
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/leads — серверный список с фильтрами/пагинацией. */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->query('per_page', self::PER_PAGE_DEFAULT)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = $this->baseQuery($request);
|
||||
$total = (clone $base)->count();
|
||||
|
||||
$rows = $base
|
||||
->orderByDesc('sl.received_at')
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage)
|
||||
->get([
|
||||
'sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.deals_created_count',
|
||||
'sl.processed_at', 'sl.error', 'sl.resolved_subject_code',
|
||||
'sp.signal_type as channel', 'sp.unique_key',
|
||||
])
|
||||
->map(fn ($r) => $this->rowToArray($r));
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/admin/leads/{id} — карточка лида: источник + сделки клиентов (цепочка). */
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$lead = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
|
||||
->where('sl.id', $id)
|
||||
->first([
|
||||
'sl.id', 'sl.received_at', 'sl.processed_at', 'sl.error', 'sl.platform', 'sl.phone',
|
||||
'sl.vid', 'sl.deals_created_count', 'sl.resolved_subject_code', 'sl.region_source',
|
||||
'sl.phone_operator', 'sp.signal_type as channel', 'sp.unique_key', 'sp.id as supplier_project_id',
|
||||
]);
|
||||
|
||||
if ($lead === null) {
|
||||
return response()->json(['message' => 'Лид не найден'], 404);
|
||||
}
|
||||
|
||||
$deals = DB::table('deals')
|
||||
->join('tenants', 'tenants.id', '=', 'deals.tenant_id')
|
||||
->where('deals.source_crm_id', $lead->vid)
|
||||
->orderByDesc('deals.received_at')
|
||||
->limit(50)
|
||||
->get([
|
||||
'deals.id', 'deals.tenant_id', 'tenants.organization_name', 'tenants.subdomain',
|
||||
'deals.status', 'deals.project_id', 'deals.received_at',
|
||||
])
|
||||
->map(fn ($d) => [
|
||||
'id' => (int) $d->id,
|
||||
'tenant_id' => (int) $d->tenant_id,
|
||||
'tenant_name' => $d->organization_name ?: $d->subdomain,
|
||||
'subdomain' => $d->subdomain,
|
||||
'status' => $d->status,
|
||||
'project_id' => $d->project_id !== null ? (int) $d->project_id : null,
|
||||
'received_at' => $d->received_at,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'lead' => [
|
||||
'id' => (int) $lead->id,
|
||||
'platform' => $lead->platform,
|
||||
'phone_masked' => $this->maskPhone($lead->phone),
|
||||
'received_at' => $lead->received_at,
|
||||
'processed_at' => $lead->processed_at,
|
||||
'error' => $lead->error,
|
||||
'region_code' => $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
|
||||
'region_source' => $lead->region_source,
|
||||
'phone_operator' => $lead->phone_operator,
|
||||
'deals_created_count' => (int) ($lead->deals_created_count ?? 0),
|
||||
'status' => $this->statusOf($lead),
|
||||
],
|
||||
'source' => [
|
||||
'platform' => $lead->platform,
|
||||
'channel' => $lead->channel,
|
||||
'identifier' => $lead->unique_key,
|
||||
'supplier_project_id' => $lead->supplier_project_id !== null ? (int) $lead->supplier_project_id : null,
|
||||
],
|
||||
'deals' => $deals,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,14 @@ class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
|
||||
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', '');
|
||||
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
|
||||
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
|
||||
$statuses = $this->csvParam($request, 'statuses');
|
||||
$tariffs = $this->csvParam($request, 'tariffs');
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
@@ -59,8 +63,22 @@ class AdminTenantsController extends Controller
|
||||
])
|
||||
->whereNull('tenants.deleted_at');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('tenants.status', $status);
|
||||
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
|
||||
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
|
||||
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn(DB::raw("(CASE
|
||||
WHEN tenants.is_trial THEN 'trial'
|
||||
WHEN tenants.status = 'suspended' THEN 'suspended'
|
||||
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
|
||||
WHEN tenants.status = 'active' THEN 'active'
|
||||
ELSE 'suspended'
|
||||
END)"), $statuses);
|
||||
} elseif ($status !== '') {
|
||||
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
|
||||
}
|
||||
if ($tariffs !== []) {
|
||||
$query->whereIn('tariff_plans.name', $tariffs);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%'.$search.'%';
|
||||
@@ -451,6 +469,19 @@ class AdminTenantsController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function csvParam(Request $request, string $key): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $request->query($key, '')),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate-stats для page-head: total / active / trial / overdue / revenue.
|
||||
* Считается отдельным запросом без фильтров (показывает глобальную картину
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Autopodbor\CompetitorResource;
|
||||
use App\Http\Resources\Autopodbor\RunResource;
|
||||
use App\Http\Resources\Autopodbor\SourceResource;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Services\Autopodbor\AutopodborProjectCreator;
|
||||
use App\Services\Autopodbor\AutopodborRunService;
|
||||
use App\Services\Autopodbor\ProposalClassifier;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Клиентский API автоподбора конкурентов.
|
||||
*
|
||||
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
|
||||
* Все выборки дополнительно скоупятся по tenant_id (пояс+подтяжки к RLS).
|
||||
*/
|
||||
class AutopodborController extends Controller
|
||||
{
|
||||
/** GET /api/autopodbor/state */
|
||||
public function state(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$runs = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->orderByDesc('id')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'enabled' => SystemSettings::bool('autopodbor_enabled'),
|
||||
'runs' => RunResource::collection($runs),
|
||||
'prices' => [
|
||||
'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'),
|
||||
'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/runs/{run} */
|
||||
public function run(Request $request, int $run): JsonResponse
|
||||
{
|
||||
$r = AutopodborRun::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($run);
|
||||
|
||||
return response()->json(['data' => new RunResource($r)]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/competitors/{competitor} */
|
||||
public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->with('sources.project')
|
||||
->findOrFail($competitor);
|
||||
|
||||
$sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) {
|
||||
$existingProjectId = $s->created_project_id
|
||||
?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier);
|
||||
|
||||
return array_merge(
|
||||
(new SourceResource($s))->resolve(),
|
||||
[
|
||||
'existing_project_id' => $existingProjectId,
|
||||
'project' => $this->projectStatus($s->project),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => new CompetitorResource($comp),
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/runs/{run}/competitors */
|
||||
public function runCompetitors(Request $request, int $run): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
// убедимся, что прогон принадлежит tenant (404 если чужой)
|
||||
AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run);
|
||||
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->where('search_run_id', $run)
|
||||
->orderByDesc('relevance_pct')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => CompetitorResource::collection($competitors)]);
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/search */
|
||||
public function search(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'region_code' => 'required|integer',
|
||||
'examples' => 'array',
|
||||
'about_self' => 'array',
|
||||
'include_federal' => 'boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startSearch(
|
||||
$request->user()->tenant_id,
|
||||
(int) $v['region_code'],
|
||||
$v['examples'] ?? [],
|
||||
$v['about_self'] ?? [],
|
||||
(bool) ($v['include_federal'] ?? false),
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/study */
|
||||
public function study(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startStudy(
|
||||
$request->user()->tenant_id,
|
||||
(int) $v['competitor_id'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/resolve */
|
||||
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => 'required|string',
|
||||
'region_code' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startResolve(
|
||||
$request->user()->tenant_id,
|
||||
$v['name'],
|
||||
(int) $v['region_code'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/manual-study */
|
||||
public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => ['nullable', 'integer'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'site_url' => ['nullable', 'string', 'max:500'],
|
||||
'directory' => ['nullable', 'string', 'max:500'],
|
||||
'region_code' => ['required', 'integer'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
try {
|
||||
if (! empty($v['competitor_id'])) {
|
||||
$run = $svc->startStudy($uid, (int) $v['competitor_id']);
|
||||
} else {
|
||||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||||
$name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент');
|
||||
if (empty($v['name']) && $site === null) {
|
||||
return response()->json(['error' => 'name_or_site_required'], 422);
|
||||
}
|
||||
$run = $svc->startManualStudy($uid, [
|
||||
'name' => $name,
|
||||
'site_url' => $site,
|
||||
'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [],
|
||||
], (int) $v['region_code']);
|
||||
}
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/autopodbor/field — рабочее место «Конкурентное поле».
|
||||
* Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому
|
||||
* источнику и счётчиками (источников / создано проектов / в работе).
|
||||
*/
|
||||
public function field(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->where('box', 'field')
|
||||
->with(['sources' => function ($q) {
|
||||
$q->where('box', 'field')->with('project');
|
||||
}])
|
||||
->orderByDesc('relevance_pct')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$payload = $competitors->map(function (AutopodborCompetitor $comp) {
|
||||
$sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge(
|
||||
(new SourceResource($s))->resolve(),
|
||||
['project' => $this->projectStatus($s->project)],
|
||||
));
|
||||
|
||||
$created = $comp->sources->filter(fn ($s) => $s->project !== null);
|
||||
$inWork = $created->filter(
|
||||
fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null
|
||||
);
|
||||
|
||||
return array_merge(
|
||||
(new CompetitorResource($comp))->resolve(),
|
||||
[
|
||||
'counters' => [
|
||||
'sources' => $comp->sources->count(),
|
||||
'projects_created' => $created->count(),
|
||||
'projects_in_work' => $inWork->count(),
|
||||
],
|
||||
'sources' => $sources,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return response()->json(['competitors' => $payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/autopodbor/competitors/manual — завести конкурента вручную сразу В ПОЛЕ,
|
||||
* без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников — отдельно, по кнопке.
|
||||
*/
|
||||
public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'is_federal' => ['boolean'],
|
||||
'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'site_url' => ['nullable', 'string', 'max:500'],
|
||||
'directory' => ['nullable', 'string', 'max:500'],
|
||||
'directory_urls' => ['nullable', 'array'],
|
||||
'directory_urls.*' => ['string', 'max:500'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||||
|
||||
$dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []);
|
||||
$dirs = array_values(array_filter(array_map('trim', $dirs)));
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $uid,
|
||||
'search_run_id' => null,
|
||||
'name' => $v['name'],
|
||||
'description' => $v['description'] ?? null,
|
||||
'is_federal' => (bool) ($v['is_federal'] ?? false),
|
||||
'relevance_pct' => $v['relevance_pct'] ?? null,
|
||||
'origin' => 'manual',
|
||||
'box' => 'field',
|
||||
'site_url' => $site,
|
||||
'directory_urls' => $dirs,
|
||||
'dedup_key' => $norm->competitorKey($v['name'], $site),
|
||||
]);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */
|
||||
public function updateCompetitor(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||
'is_federal' => ['sometimes', 'boolean'],
|
||||
'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'],
|
||||
'site_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'directory_urls' => ['sometimes', 'array'],
|
||||
'directory_urls.*' => ['string', 'max:500'],
|
||||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($competitor);
|
||||
|
||||
$comp->update($v);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/autopodbor/competitors/{id} — удаление конкурента и его источников.
|
||||
* Блокируется, если у любого источника есть активный созданный проект
|
||||
* (управлять проектом нужно через раздел проектов — §14.10).
|
||||
*/
|
||||
public function destroyCompetitor(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('sources.project')
|
||||
->findOrFail($competitor);
|
||||
|
||||
$hasActive = $comp->sources->contains(
|
||||
fn (AutopodborSource $s) => $s->project && $s->project->is_active
|
||||
);
|
||||
|
||||
if ($hasActive) {
|
||||
return response()->json(['error' => 'has_active_projects'], 409);
|
||||
}
|
||||
|
||||
// Мягкое удаление: прячем конкурента в архив (для группы «ранее удалённые» при повторном
|
||||
// подборе), источники и опознавалки сохраняем — историю не стираем (спека §4).
|
||||
$comp->update(['box' => 'archived']);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/autopodbor/competitors/{competitor}/actualize — актуализация сайта фирмы поля (спека §6).
|
||||
* action=add — добавить новый сайт-источник (старый продолжает работать). action=replace — заменить:
|
||||
* если по старому сайту есть активный проект → 409 manage_via_project (менять только через раздел
|
||||
* проектов, §14.10); иначе прежние сайт-источники в архив, новый заводим. Имя фирмы берём из поля —
|
||||
* не перезатираем. Возвращает id заведённого источника — фронт ведёт клиента в форму создания проекта.
|
||||
*/
|
||||
public function actualize(Request $request, int $competitor, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'action' => ['required', 'in:add,replace'],
|
||||
'new_site' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->with('sources.project')
|
||||
->findOrFail($competitor);
|
||||
|
||||
if ($comp->study_run_id === null) {
|
||||
return response()->json(['error' => 'not_studied'], 422);
|
||||
}
|
||||
|
||||
$site = $norm->domainHead($v['new_site']);
|
||||
|
||||
if ($v['action'] === 'replace') {
|
||||
$hasActive = $comp->sources->contains(
|
||||
fn (AutopodborSource $s) => $s->signal_type === 'site' && $s->project && $s->project->is_active
|
||||
);
|
||||
if ($hasActive) {
|
||||
return response()->json(['error' => 'manage_via_project'], 409);
|
||||
}
|
||||
// старый сайт умер: прежние сайт-источники в архив (мягко), новый заведём ниже.
|
||||
$comp->sources()->where('signal_type', 'site')->update(['box' => 'archived']);
|
||||
}
|
||||
|
||||
$source = AutopodborSource::updateOrCreate(
|
||||
['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey('site', $site)],
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'study_run_id' => $comp->study_run_id,
|
||||
'signal_type' => 'site',
|
||||
'identifier' => $site,
|
||||
'box' => 'field',
|
||||
'provenance_label' => 'Актуализация: новый сайт',
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json(['source_id' => $source->id], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/autopodbor/proposals — предложения, разложенные по группам (спека §3, §5.1):
|
||||
* new (новые) / actualize (на актуализацию) / archived (ранее удалённые). Скрытые полные дубли
|
||||
* активных фирм не показываем. Сверка «та же фирма» — {@see ProposalClassifier} по опознавалкам.
|
||||
*/
|
||||
public function proposals(Request $request, ProposalClassifier $classifier): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')
|
||||
->orderByDesc('relevance_pct')->orderBy('id')->get();
|
||||
$existing = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->whereIn('box', ['field', 'archived'])->with('sources')->get();
|
||||
|
||||
$finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all();
|
||||
$state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all();
|
||||
|
||||
$groups = ['new' => [], 'actualize' => [], 'archived' => []];
|
||||
$byId = $proposals->keyBy('id');
|
||||
$existingById = $existing->keyBy('id');
|
||||
foreach ($classifier->classify($finds, $state) as $row) {
|
||||
if ($row['group'] === 'hidden') {
|
||||
continue; // скрытые дубли не показываем
|
||||
}
|
||||
$card = (new CompetitorResource($byId->get($row['find']['id'])))->resolve();
|
||||
if ($row['group'] === 'actualize') {
|
||||
$card['matched_id'] = $row['matched_id'];
|
||||
$card['delta_keys'] = $row['delta_keys'];
|
||||
$card['matched'] = $this->matchedSummary($existingById->get($row['matched_id']));
|
||||
}
|
||||
$groups[$row['group']][] = $card;
|
||||
}
|
||||
|
||||
return response()->json(['groups' => $groups]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/autopodbor/proposals/dismiss-archived — убрать всю группу «ранее удалённые» разом
|
||||
* (снова спрятать в архив всплывшие предложения; §5.1). Новые/актуализацию не трогает.
|
||||
*/
|
||||
public function dismissArchivedGroup(Request $request, ProposalClassifier $classifier): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')->get();
|
||||
$existing = AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('box', ['field', 'archived'])->get();
|
||||
|
||||
$finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all();
|
||||
$state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all();
|
||||
|
||||
$ids = collect($classifier->classify($finds, $state))
|
||||
->where('group', 'archived')->pluck('find.id')->all();
|
||||
|
||||
AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('id', $ids)->update(['box' => 'archived']);
|
||||
|
||||
return response()->json(['dismissed' => count($ids)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка конкурента для {@see ProposalClassifier}: id + опознавалки (сайт/карточки). Имя — для UI.
|
||||
*
|
||||
* @return array{id:int,name:string,site_url:?string,directory_urls:array}
|
||||
*/
|
||||
private function toClassifyRow(AutopodborCompetitor $c): array
|
||||
{
|
||||
return ['id' => $c->id, 'name' => $c->name, 'site_url' => $c->site_url, 'directory_urls' => $c->directory_urls ?? []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Краткая карточка совпавшей фирмы поля для группы «на актуализацию»: имя (из поля — не трогаем) +
|
||||
* все её текущие сайты (site_url + сайт-источники) и карточки справочника — фронт показывает их
|
||||
* ссылками «проверьте перед решением». null — если фирма вдруг не найдена.
|
||||
*
|
||||
* @return array{id:int,name:string,sites:list<string>,directory_urls:array}|null
|
||||
*/
|
||||
private function matchedSummary(?AutopodborCompetitor $m): ?array
|
||||
{
|
||||
if ($m === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'name' => $m->name,
|
||||
'sites' => collect([$m->site_url])
|
||||
->merge($m->sources->where('signal_type', 'site')->pluck('identifier'))
|
||||
->filter()->unique()->values()->all(),
|
||||
'directory_urls' => $m->directory_urls ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */
|
||||
public function competitorBox(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'box' => ['required', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($competitor);
|
||||
|
||||
$comp->update(['box' => $v['box']]);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/autopodbor/sources/{id} — правка значения/провенанса/ящика источника.
|
||||
* Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService — молча игнорируем).
|
||||
* Смена самого значения (identifier) у источника с активным проектом запрещена —
|
||||
* это смена источника проекта, делается через раздел проектов (§14.10).
|
||||
*/
|
||||
public function updateSource(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'identifier' => ['sometimes', 'string', 'max:500'],
|
||||
'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'],
|
||||
'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'],
|
||||
'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('project')
|
||||
->findOrFail($source);
|
||||
|
||||
$changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier;
|
||||
if ($changesIdentifier && $src->project && $src->project->is_active) {
|
||||
return response()->json(['error' => 'manage_via_project'], 409);
|
||||
}
|
||||
|
||||
$src->update($v);
|
||||
|
||||
return response()->json(['data' => new SourceResource($src)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/autopodbor/sources/{id} — удаление источника.
|
||||
* Блокируется, если у источника есть активный созданный проект (§14.10).
|
||||
*/
|
||||
public function destroySource(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('project')
|
||||
->findOrFail($source);
|
||||
|
||||
if ($src->project && $src->project->is_active) {
|
||||
return response()->json(['error' => 'has_active_project'], 409);
|
||||
}
|
||||
|
||||
$src->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */
|
||||
public function sourceBox(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'box' => ['required', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($source);
|
||||
|
||||
$src->update(['box' => $v['box']]);
|
||||
|
||||
return response()->json(['data' => new SourceResource($src)]);
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/sources/manual */
|
||||
public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => ['required', 'integer'],
|
||||
'raw' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']);
|
||||
if ($comp->study_run_id === null) {
|
||||
return response()->json(['error' => 'not_studied'], 422);
|
||||
}
|
||||
|
||||
$raw = trim($v['raw']);
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
$isCall = strlen($digits) >= 10;
|
||||
$signalType = $isCall ? 'call' : 'site';
|
||||
$identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw);
|
||||
|
||||
$source = AutopodborSource::updateOrCreate(
|
||||
['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)],
|
||||
[
|
||||
'tenant_id' => $uid,
|
||||
'study_run_id' => $comp->study_run_id,
|
||||
'signal_type' => $signalType,
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $isCall ? 'real' : null,
|
||||
'provenance_url' => null,
|
||||
'provenance_label' => 'Добавлено вручную',
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new SourceResource($source)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус проекта источника для UI (пауза/работа/блок). null — проекта нет.
|
||||
*
|
||||
* @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null
|
||||
*/
|
||||
private function projectStatus(?Project $project): ?array
|
||||
{
|
||||
if ($project === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'signal_identifier' => $project->signal_identifier,
|
||||
'is_active' => (bool) $project->is_active,
|
||||
'paused_at' => $project->paused_at?->toIso8601String(),
|
||||
'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(),
|
||||
'daily_limit_target' => (int) $project->daily_limit_target,
|
||||
'delivered_in_month' => (int) $project->delivered_in_month,
|
||||
'delivery_days_mask' => (int) $project->delivery_days_mask,
|
||||
'regions' => $project->regions ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/projects */
|
||||
public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'source_ids' => 'required|array',
|
||||
'source_ids.*' => 'integer',
|
||||
'regions' => 'array',
|
||||
'regions.*' => 'integer',
|
||||
'daily_limit_target' => 'required|integer|min:1|max:10000',
|
||||
'delivery_days_mask' => 'required|integer',
|
||||
'launch' => 'boolean',
|
||||
]);
|
||||
|
||||
$tenant = $request->user()->tenant;
|
||||
$launch = (bool) ($v['launch'] ?? false);
|
||||
|
||||
// Гейт реквизитов первого проекта — как в ProjectController@store.
|
||||
if (Project::where('tenant_id', $tenant->id)->count() === 0
|
||||
&& ! app(RequisitesService::class)->isLightComplete($tenant)) {
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
$projects = $creator->createFromSources(
|
||||
$tenant->id,
|
||||
$v['source_ids'],
|
||||
[
|
||||
'regions' => $v['regions'] ?? [],
|
||||
'daily_limit_target' => (int) $v['daily_limit_target'],
|
||||
'delivery_days_mask' => (int) $v['delivery_days_mask'],
|
||||
],
|
||||
$launch,
|
||||
);
|
||||
|
||||
$launched = collect($projects)->filter(fn ($p) => $p->is_active)->count();
|
||||
$deferred = count($projects) - $launched;
|
||||
// payload баланса — от первого удержанного (для сообщения «пополните ~X ₽»).
|
||||
$held = collect($projects)->first(fn ($p) => ($p->launch_deferred ?? false));
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($projects)->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'is_active' => (bool) $p->is_active,
|
||||
])->all(),
|
||||
'launch' => [
|
||||
'launched' => $launched,
|
||||
'deferred' => $deferred,
|
||||
'balance' => $held?->gate_payload,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Bot\ProcessJivoMessageJob;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Приём событий Jivo Bot API (спека §§2,5). Образец защиты — SupplierWebhookController:
|
||||
* секрет в URL (≥32 симв., config services.jivo_bot.webhook_secret), hash_equals,
|
||||
* несовпадение → 404 (не палим endpoint). Ack мгновенный (лимит Jivo 3 сек):
|
||||
* вся работа — в ProcessJivoMessageJob. Обрабатываем только CLIENT_MESSAGE
|
||||
* с непустым текстом; служебные события подтверждаем и игнорируем.
|
||||
*/
|
||||
class JivoBotController extends Controller
|
||||
{
|
||||
public function receive(Request $request, string $secret = ''): JsonResponse
|
||||
{
|
||||
$expected = (string) config('services.jivo_bot.webhook_secret');
|
||||
if ($expected === '' || strlen($expected) < 32 || ! hash_equals($expected, $secret)) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
$event = (string) $request->input('event', '');
|
||||
$text = trim((string) $request->input('message.text', ''));
|
||||
$chatId = (string) $request->input('chat_id', '');
|
||||
|
||||
if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') {
|
||||
ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,14 @@ use App\Http\Requests\BulkProjectActionRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\LaunchBalanceGate;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
||||
@@ -134,35 +133,18 @@ class ProjectController extends Controller
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
unset($validated['force_save_blocked']); // больше не блокируем создание
|
||||
|
||||
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
$project = $this->projects->create($tenant, $validated, launch: true);
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
|
||||
return response()->json([
|
||||
'data' => new ProjectResource($project->loadCount('supplierProjects')),
|
||||
'launch' => [
|
||||
'launched' => $project->launch_deferred ? 0 : 1,
|
||||
'deferred' => $project->launch_deferred ? 1 : 0,
|
||||
'balance' => $project->gate_payload,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
@@ -171,34 +153,28 @@ class ProjectController extends Controller
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
||||
// проекта + лимиты остальных активных не-blocked.
|
||||
if (array_key_exists('daily_limit_target', $validated)) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('id', '!=', $project->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
$isLimitRaise = array_key_exists('daily_limit_target', $validated)
|
||||
&& (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target
|
||||
&& $project->is_active && $project->preflight_blocked_at === null;
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
if ($isLimitRaise) {
|
||||
$newLimit = (int) $validated['daily_limit_target'];
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse {
|
||||
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
$gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]);
|
||||
if (! $gate->passes) {
|
||||
return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409);
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
});
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
@@ -206,34 +182,6 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
@@ -269,23 +217,13 @@ class ProjectController extends Controller
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
$result = $this->projects->setActive($project, $request->boolean('is_active'));
|
||||
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
// G (балансовый блок): заблокированный за нехваткой баланса проект не
|
||||
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
|
||||
if ($project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
if ($result->activate_deferred ?? false) {
|
||||
return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409);
|
||||
}
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
|
||||
return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CompetitorResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'is_federal' => $this->is_federal,
|
||||
'relevance_pct' => $this->relevance_pct,
|
||||
'origin' => $this->origin,
|
||||
'box' => $this->box,
|
||||
'site_url' => $this->site_url,
|
||||
'directory_urls' => $this->directory_urls,
|
||||
'studied_at' => $this->studied_at?->toIso8601String(),
|
||||
'study_run_id' => $this->study_run_id,
|
||||
'search_run_id' => $this->search_run_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborSource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class RunResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'kind' => $this->kind,
|
||||
'competitor_id' => $this->competitor_id,
|
||||
'status' => $this->status,
|
||||
'region_code' => $this->region_code,
|
||||
'params' => $this->params,
|
||||
'price_rub_charged' => $this->price_rub_charged,
|
||||
'error_code' => $this->error_code,
|
||||
'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(),
|
||||
'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(),
|
||||
'started_at' => $this->started_at?->toIso8601String(),
|
||||
'finished_at' => $this->finished_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SourceResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'competitor_id' => $this->competitor_id,
|
||||
'signal_type' => $this->signal_type,
|
||||
'identifier' => $this->identifier,
|
||||
'phone_kind' => $this->phone_kind,
|
||||
'phone_type' => $this->phone_type,
|
||||
'box' => $this->box,
|
||||
'provenance_url' => $this->provenance_url,
|
||||
'provenance_label' => $this->provenance_label,
|
||||
'created_project_id' => $this->created_project_id,
|
||||
'where_found' => $this->where_found ?? [],
|
||||
'office' => $this->office,
|
||||
'confirmations' => $this->confirmations,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ class ProjectResource extends JsonResource
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
|
||||
'balance_blocked' => $this->preflight_blocked_at !== null,
|
||||
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
|
||||
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
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;
|
||||
|
||||
class RunAutopodborResolveJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $runId)
|
||||
{
|
||||
// Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов.
|
||||
$this->onQueue('autopodbor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай).
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new WithoutOverlapping((string) $this->runId)];
|
||||
}
|
||||
|
||||
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
|
||||
{
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно
|
||||
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()]);
|
||||
|
||||
try {
|
||||
$p = $run->params;
|
||||
|
||||
$res = $agent->resolveByName(new ResolveByNameRequest(
|
||||
name: $p['name'],
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
|
||||
$unique = $dedup->dedupCompetitors($res->candidates);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($unique 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' => null,
|
||||
'origin' => 'resolve',
|
||||
'site_url' => $c['site_url'] ?? null,
|
||||
'directory_urls' => $c['directory_urls'] ?? [],
|
||||
'provenance' => $c['provenance'] ?? [],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$run->update(['status' => 'done', 'finished_at' => now()]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?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\CompetitorIdentity;
|
||||
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 = 1800;
|
||||
|
||||
public function __construct(public int $runId)
|
||||
{
|
||||
// Выделенная очередь: число её воркеров = глобальный потолок одновременных подборов
|
||||
// (лимиты EXA/xfetch/AITUNNEL — на КЛЮЧ, общий на всех клиентов).
|
||||
$this->onQueue('autopodbor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом
|
||||
* (ретрай/повторный 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): void
|
||||
{
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
|
||||
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()]);
|
||||
|
||||
try {
|
||||
$p = $run->params;
|
||||
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
|
||||
|
||||
$res = $agent->findCompetitors(new FindCompetitorsRequest(
|
||||
regionCode: (int) $run->region_code,
|
||||
examples: $p['examples'] ?? [],
|
||||
aboutSelf: $p['about_self'] ?? [],
|
||||
includeFederal: (bool) ($p['include_federal'] ?? false),
|
||||
maxCompetitors: $max,
|
||||
));
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$saved = array_slice($unique, 0, $max);
|
||||
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'] ?? [],
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\AutopodborChargeService;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
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;
|
||||
|
||||
class RunAutopodborStudyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $runId)
|
||||
{
|
||||
// Та же выделенная очередь, что и поиск — единый глобальный потолок внешних сервисов.
|
||||
$this->onQueue('autopodbor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай).
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new WithoutOverlapping((string) $this->runId)];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
CompetitorAgent $agent,
|
||||
AutopodborDedup $dedup,
|
||||
AutopodborChargeService $charge,
|
||||
AutopodborNormalizer $norm,
|
||||
): void {
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
|
||||
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()]);
|
||||
|
||||
try {
|
||||
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
|
||||
|
||||
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
|
||||
competitor: [
|
||||
'name' => $comp->name,
|
||||
'site_url' => $comp->site_url,
|
||||
'directory_urls' => $comp->directory_urls ?? [],
|
||||
],
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
|
||||
$unique = $dedup->dedupSources($res->sources);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($unique as $s) {
|
||||
$identifier = $s['signal_type'] === 'call'
|
||||
? $norm->phone($s['identifier'])
|
||||
: $norm->domainHead($s['identifier']);
|
||||
|
||||
AutopodborSource::updateOrCreate(
|
||||
[
|
||||
'competitor_id' => $comp->id,
|
||||
'dedup_key' => $s['dedup_key'],
|
||||
],
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'study_run_id' => $run->id,
|
||||
'signal_type' => $s['signal_type'],
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $s['phone_kind'] ?? null,
|
||||
'phone_type' => $s['phone_type'] ?? null,
|
||||
'provenance_url' => $s['provenance_url'] ?? null,
|
||||
'provenance_label' => $s['provenance_label'] ?? null,
|
||||
'where_found' => $s['where_found'] ?? null,
|
||||
'office' => $s['office'] ?? null,
|
||||
'confirmations' => $s['confirmations'] ?? 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
|
||||
$charge->chargeForRun($run, $price);
|
||||
|
||||
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
|
||||
|
||||
$run->update(['status' => 'done', 'finished_at' => now()]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
Tenant::query()
|
||||
// Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS).
|
||||
// Список замороженных тенантов брать через дефолтное соединение нельзя — без
|
||||
// app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же
|
||||
// баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS).
|
||||
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
|
||||
->whereNotNull('frozen_by_balance_at')
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->processTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$this->processTenant((int) $tenantId, $service, $tiers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
// SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find,
|
||||
// и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent()
|
||||
// идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен.
|
||||
DB::transaction(function () use ($tenantId, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
|
||||
return; // разморожен/удалён между pluck и обработкой.
|
||||
}
|
||||
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
});
|
||||
}
|
||||
|
||||
private function matchWindow(int $hours): ?string
|
||||
|
||||
@@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->evaluateTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
// Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user
|
||||
// (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без
|
||||
// app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и
|
||||
// sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id
|
||||
// через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает
|
||||
// для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст.
|
||||
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$this->evaluateTenant((int) $tenantId, $service, $tiers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
|
||||
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
|
||||
// RLS-policy на projects падает с "unrecognized configuration parameter".
|
||||
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
DB::transaction(function () use ($tenant, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
DB::transaction(function () use ($tenantId, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find
|
||||
// вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна.
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return; // удалён между pluck и обработкой — пропускаем.
|
||||
}
|
||||
|
||||
$required = $tenant->requiredLeadsForTomorrow();
|
||||
$result = $service->evaluate(
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Bot;
|
||||
|
||||
use App\Models\BotDialog;
|
||||
use App\Services\Bot\BotAnswerService;
|
||||
use App\Services\Bot\JivoBotClient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Оркестратор ответа бота (спека §2). Очередь `bot` — отдельный worker на проде,
|
||||
* чтобы поток лидов не задерживал ответы чата (скорость — требование №1).
|
||||
* timeout 12с < 15с Jivo-страховки: не успели — Jivo сам позовёт оператора.
|
||||
* $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора.
|
||||
*/
|
||||
class ProcessJivoMessageJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 12;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $chatId,
|
||||
public readonly string $clientId,
|
||||
public readonly string $text,
|
||||
) {
|
||||
$this->onQueue('bot');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
|
||||
BotDialog::create([
|
||||
'jivo_chat_id' => $this->chatId,
|
||||
'direction' => 'in',
|
||||
'message' => $this->text,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer($this->text);
|
||||
$jivo = app(JivoBotClient::class);
|
||||
|
||||
$jivo->sendMessage($this->chatId, $this->clientId, $answer->text);
|
||||
if ($answer->escalate) {
|
||||
$jivo->inviteAgent($this->chatId, $this->clientId);
|
||||
}
|
||||
|
||||
BotDialog::create([
|
||||
'jivo_chat_id' => $this->chatId,
|
||||
'direction' => 'out',
|
||||
'message' => $answer->text,
|
||||
'matched_chunks' => $answer->matchedChunkIds,
|
||||
'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
|
||||
'escalated' => $answer->escalate,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\External;
|
||||
|
||||
use App\Services\Dashboard\BalanceHealth;
|
||||
use App\Services\External\BalanceProvider;
|
||||
use App\Services\External\DadataBalanceProvider;
|
||||
use App\Services\External\SupplierBalanceProvider;
|
||||
use App\Services\External\YandexCloudBalanceProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
|
||||
* Каждый провайдер изолирован: fetch() не бросает; ok=false оставляет ПРОШЛЫЙ баланс
|
||||
* + метку ошибки (плитка не падает, показывает «данные от ДАТА»). Пишет под
|
||||
* crm_supplier_worker (BYPASSRLS) — таблица системная, как supplier_sync_runs.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
||||
*/
|
||||
class RefreshExternalBalancesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS для записи системной таблицы
|
||||
|
||||
/** @return array<int,class-string<BalanceProvider>> */
|
||||
private function providers(): array
|
||||
{
|
||||
return [
|
||||
DadataBalanceProvider::class,
|
||||
SupplierBalanceProvider::class,
|
||||
YandexCloudBalanceProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
foreach ($this->providers() as $cls) {
|
||||
/** @var BalanceProvider $p */
|
||||
$p = app($cls);
|
||||
$key = $p->serviceKey();
|
||||
$reading = $p->fetch(); // не бросает
|
||||
|
||||
// Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера
|
||||
// накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert
|
||||
// ошибочно идёт в INSERT существующей строки → нарушение PK.
|
||||
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
|
||||
|
||||
if (! $reading->ok) {
|
||||
// Оставляем прошлый баланс, помечаем ok=false + ошибку.
|
||||
$table->updateOrInsert(
|
||||
['service_key' => $key],
|
||||
[
|
||||
'ok' => false,
|
||||
'error' => $reading->error,
|
||||
'checked_at' => $reading->checkedAt,
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$red, $amber] = $this->floors($key);
|
||||
$h = BalanceHealth::evaluate((float) $reading->balance, $reading->dailySpend, $red, $amber);
|
||||
|
||||
$table->updateOrInsert(
|
||||
['service_key' => $key],
|
||||
[
|
||||
'balance_amount' => $reading->balance,
|
||||
'currency' => $reading->currency,
|
||||
'daily_spend_estimate' => $reading->dailySpend,
|
||||
'days_left' => $h['days_left'],
|
||||
'light' => $h['light'],
|
||||
'ok' => true,
|
||||
'error' => null,
|
||||
'checked_at' => $reading->checkedAt,
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:float} [red_floor, amber_floor] */
|
||||
private function floors(string $key): array
|
||||
{
|
||||
return match ($key) {
|
||||
'dadata' => [
|
||||
(float) config('services.dadata.red_floor_rub', 500),
|
||||
(float) config('services.dadata.amber_floor_rub', 2000),
|
||||
],
|
||||
'yandex_cloud' => [
|
||||
(float) config('services.yandex_cloud.red_floor_rub', 1000),
|
||||
(float) config('services.yandex_cloud.amber_floor_rub', 5000),
|
||||
],
|
||||
'supplier' => [
|
||||
(float) config('services.supplier.red_floor_rub', 5000),
|
||||
(float) config('services.supplier.amber_floor_rub', 15000),
|
||||
],
|
||||
default => [0.0, 0.0],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\AutopodborRun;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Уведомление клиенту, что фоновый подбор конкурентов завершён (клиент не ждёт у экрана —
|
||||
* поставил задачу, работает дальше, получает письмо «готово»).
|
||||
*/
|
||||
class AutopodborReadyMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public AutopodborRun $run,
|
||||
public int $found,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->found > 0
|
||||
? "Подбор конкурентов готов: {$this->found} — Лидерра"
|
||||
: 'Подбор конкурентов готов — Лидерра';
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.autopodbor-ready',
|
||||
with: [
|
||||
'found' => $this->found,
|
||||
'url' => rtrim((string) config('app.url'), '/').'/autopodbor',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AutopodborCompetitor extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'search_run_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_federal',
|
||||
'relevance_pct',
|
||||
'origin',
|
||||
'site_url',
|
||||
'directory_urls',
|
||||
'provenance',
|
||||
'dedup_key',
|
||||
'study_run_id',
|
||||
'studied_at',
|
||||
'box',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_federal' => 'bool',
|
||||
'directory_urls' => 'array',
|
||||
'provenance' => 'array',
|
||||
'studied_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function sources(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutopodborSource::class, 'competitor_id');
|
||||
}
|
||||
|
||||
public function searchRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborRun::class, 'search_run_id');
|
||||
}
|
||||
|
||||
public function studyRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborRun::class, 'study_run_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AutopodborRun extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'kind',
|
||||
'status',
|
||||
'region_code',
|
||||
'params',
|
||||
'competitor_id',
|
||||
'price_rub_charged',
|
||||
'balance_transaction_id',
|
||||
'error_code',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'params' => 'array',
|
||||
'price_rub_charged' => 'decimal:2',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function competitors(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutopodborCompetitor::class, 'search_run_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AutopodborSource extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'competitor_id',
|
||||
'study_run_id',
|
||||
'signal_type',
|
||||
'identifier',
|
||||
'phone_kind',
|
||||
'phone_type',
|
||||
'provenance_url',
|
||||
'provenance_label',
|
||||
'dedup_key',
|
||||
'created_project_id',
|
||||
'box',
|
||||
'where_found',
|
||||
'office',
|
||||
'confirmations',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'where_found' => 'array',
|
||||
'confirmations' => 'integer',
|
||||
];
|
||||
|
||||
public function competitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class, 'created_project_id');
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ class BalanceTransaction extends Model
|
||||
|
||||
public const TYPE_MIGRATION = 'migration';
|
||||
|
||||
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Строка журнала диалога бота (direction: in/out). created_at only — updated_at нет. */
|
||||
class BotDialog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Чанк базы знаний бота. search_tsv — generated column, в PHP не трогаем. */
|
||||
class KnowledgeChunk extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
|
||||
use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\LivePageFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
|
||||
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
|
||||
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
|
||||
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
|
||||
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
|
||||
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AutopodborServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
|
||||
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru.
|
||||
//
|
||||
// Шаг 1 (поиск конкурентов): при включённом флаге autopodbor.real_find подключается ЖИВОЙ
|
||||
// движок (ниша → поиск 2ГИС/Яндекс → резолв → сборка). Без ИИ-ключа отсев агрегаторов и
|
||||
// похожесть-% отключены (null-классификатор + нулевой эмбеддер) — выдаётся сырой список.
|
||||
// Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку (как раньше). resolveByName — заглушка.
|
||||
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
|
||||
$xfetch = new XfetchClient(
|
||||
apiKey: config('services.xfetch.key'),
|
||||
endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
|
||||
concurrency: (int) config('services.xfetch.concurrency', 4),
|
||||
);
|
||||
|
||||
$fetcher = new CompositeFetcher(
|
||||
siteFetcher: new CurlPlaywrightFetcher,
|
||||
directoryFetcher: new XfetchDirectoryFetcher($xfetch),
|
||||
);
|
||||
|
||||
$liveFind = config('autopodbor.real_find')
|
||||
? $this->buildLiveFind($xfetch)
|
||||
: null;
|
||||
|
||||
return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent, liveFind: $liveFind);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Живой движок поиска шага 1 — ФИНАЛ v4 (ZAFIKSIROVANO §0-БИС): шаг АНАЛИЗ (мелкая модель →
|
||||
* запросы-рубрики) → КАНАЛ А (скрейп категории 2ГИС с пагинацией через xfetch → резолв карточек) →
|
||||
* КАНАЛ В (одна модель sonar-reasoning-pro × 2 прохода → ИМЕНА федералов; их САЙТ через EXA) →
|
||||
* сборка (отсев агрегаторов + дедуп + похожесть-эмбеддинги + DTO). Все ИИ/exa/xfetch клиенты
|
||||
* деградируют при пустом ключе (без падения), поэтому подключаем их всегда.
|
||||
*/
|
||||
private function buildLiveFind(XfetchClient $xfetch): LiveFindCompetitors
|
||||
{
|
||||
$pages = new LivePageFetcher($xfetch);
|
||||
$http = $this->app->make(HttpFactory::class);
|
||||
|
||||
$assembler = new FindCompetitorsAssembler(
|
||||
new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)),
|
||||
new AutopodborDedup(new AutopodborNormalizer),
|
||||
new EmbeddingRelevance($this->app->make(AitunnelEmbedder::class)),
|
||||
);
|
||||
|
||||
return new LiveFindCompetitors(
|
||||
new AitunnelQueryAnalyzer($http),
|
||||
new CategoryScraper($pages, new CategoryListingParser),
|
||||
new PlaywrightYandexDirectory((string) config('autopodbor.node_bin', 'node')),
|
||||
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
|
||||
new ExaSiteFinder($http),
|
||||
$assembler,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Aggregator;
|
||||
|
||||
/**
|
||||
* Граница «это поставщик услуги или площадка-агрегатор?» (§12.6 движка v4). За ней — LLM-рассуждение
|
||||
* (sonar-reasoning-pro и т.п.), а НЕ статический список площадок (он мёртв: все агрегаторы не знаем,
|
||||
* плодятся). Позволяет фильтровать агрегаторов офлайн на фейке.
|
||||
*/
|
||||
interface AggregatorClassifier
|
||||
{
|
||||
/**
|
||||
* true — площадка-агрегатор (Авито/Юла/Zoon/Банки.ру…); false — поставщик услуги;
|
||||
* null — модель не смогла решить (тогда конкурента НЕ выкидываем, см. {@see AggregatorFilter}).
|
||||
*/
|
||||
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Aggregator;
|
||||
|
||||
/**
|
||||
* Отсев площадок-агрегаторов из кандидатов (§12.6): спрашивает {@see AggregatorClassifier} по каждому.
|
||||
* Консервативно: выкидываем ТОЛЬКО когда классификатор уверенно сказал «агрегатор» (true). При
|
||||
* неуверенности (null) или «поставщик» (false) — конкурента оставляем, чтобы не потерять настоящего.
|
||||
*/
|
||||
final class AggregatorFilter
|
||||
{
|
||||
public function __construct(private readonly AggregatorClassifier $classifier) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name?:string,site_url?:?string,description?:?string}> $candidates
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function filter(array $candidates): array
|
||||
{
|
||||
$candidates = array_values($candidates);
|
||||
|
||||
// Классификатор умеет батч → один запрос на весь список (иначе ~90 запросов по одному, «умирает»).
|
||||
$verdicts = $this->classifier instanceof BatchAggregatorClassifier && $candidates !== []
|
||||
? $this->classifier->classifyManyAggregators(array_map(fn (array $c): array => [
|
||||
'name' => (string) ($c['name'] ?? ''),
|
||||
'site_url' => $c['site_url'] ?? null,
|
||||
'description' => $c['description'] ?? null,
|
||||
], $candidates))
|
||||
: null;
|
||||
|
||||
$out = [];
|
||||
foreach ($candidates as $i => $c) {
|
||||
$verdict = $verdicts !== null
|
||||
? ($verdicts[$i] ?? null)
|
||||
: $this->classifier->isAggregator(
|
||||
(string) ($c['name'] ?? ''),
|
||||
$c['site_url'] ?? null,
|
||||
$c['description'] ?? null,
|
||||
);
|
||||
if ($verdict === true) {
|
||||
continue; // площадка — не конкурент
|
||||
}
|
||||
$out[] = $c;
|
||||
}
|
||||
|
||||
return array_values($out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Aggregator;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* Живой {@see AggregatorClassifier} через AITUNNEL (OpenAI-совместимый chat, §12.6): спрашивает
|
||||
* у модели, поставщик ли это услуги или площадка-агрегатор. POST {base}/chat/completions →
|
||||
* {choices:[{message:{content}}]}. Ответ AGGREGATOR → true, SUPPLIER → false, иначе/без ключа/
|
||||
* при ошибке → null (тогда конкурента НЕ выкидываем — консервативно).
|
||||
*/
|
||||
final class AitunnelAggregatorClassifier implements AggregatorClassifier, BatchAggregatorClassifier
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
/**
|
||||
* Батч отсева агрегаторов: список режем на ПОРЦИИ по aggregator_batch_size (иначе один запрос
|
||||
* на ~100 фирм долгий и рискует не успеть в таймаут). Каждая порция — один запрос с длинным
|
||||
* таймаутом aggregator_timeout_sec. Пропущенные/непонятные/сбой порции — null (консервативно).
|
||||
*
|
||||
* @param list<array{name:string,site_url:?string,description:?string}> $companies
|
||||
* @return list<?bool>
|
||||
*/
|
||||
public function classifyManyAggregators(array $companies): array
|
||||
{
|
||||
$companies = array_values($companies);
|
||||
$n = count($companies);
|
||||
$none = array_fill(0, $n, null);
|
||||
if ($n === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cfg = (array) config('services.aitunnel');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return $none;
|
||||
}
|
||||
|
||||
$batchSize = max(1, (int) ($cfg['aggregator_batch_size'] ?? 40));
|
||||
$out = $none;
|
||||
$offset = 0;
|
||||
foreach (array_chunk($companies, $batchSize) as $chunk) {
|
||||
foreach ($this->classifyChunk($chunk, $key, $cfg) as $i => $verdict) {
|
||||
$out[$offset + $i] = $verdict;
|
||||
}
|
||||
$offset += count($chunk);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна порция: нумеруем 1..m, один запрос к модели с длинным таймаутом. Ответ → вердикты порции.
|
||||
*
|
||||
* @param list<array{name:string,site_url:?string,description:?string}> $chunk
|
||||
* @return list<?bool>
|
||||
*/
|
||||
private function classifyChunk(array $chunk, string $key, array $cfg): array
|
||||
{
|
||||
$none = array_fill(0, count($chunk), null);
|
||||
|
||||
$lines = [];
|
||||
foreach ($chunk as $i => $c) {
|
||||
$lines[] = ($i + 1).'. «'.($c['name'] ?? '').'» сайт: '.($c['site_url'] ?: '—').
|
||||
' описание: '.($c['description'] ?: '—');
|
||||
}
|
||||
$prompt = "Список компаний:\n".implode("\n", $lines)."\n\n".
|
||||
'Для КАЖДОЙ определи: это сам поставщик услуги/товара (SUPPLIER) или площадка-агрегатор/'
|
||||
.'каталог/маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
|
||||
.'(AGGREGATOR)? Ответ — СТРОГО JSON-массив по номерам, без текста вне него: '
|
||||
.'[{"n":1,"v":"SUPPLIER"},{"n":2,"v":"AGGREGATOR"}]';
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withToken($key)
|
||||
->timeout((int) ($cfg['aggregator_timeout_sec'] ?? 90))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
|
||||
'model' => $cfg['chat_model'] ?? 'gpt-4o-mini',
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'temperature' => 0,
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return $none;
|
||||
}
|
||||
|
||||
return $this->parseVerdicts((string) $resp->json('choices.0.message.content'), $none);
|
||||
} catch (\Throwable) {
|
||||
return $none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-массив [{n,v}] из ответа → вердикты по индексам (n − 1). Пропущенные — остаются null.
|
||||
*
|
||||
* @param list<?bool> $none болванка нужной длины
|
||||
* @return list<?bool>
|
||||
*/
|
||||
private function parseVerdicts(string $content, array $none): array
|
||||
{
|
||||
$start = strpos($content, '[');
|
||||
$end = strrpos($content, ']');
|
||||
if ($start === false || $end === false || $end < $start) {
|
||||
return $none;
|
||||
}
|
||||
$arr = json_decode(substr($content, $start, $end - $start + 1), true);
|
||||
if (! is_array($arr)) {
|
||||
return $none;
|
||||
}
|
||||
|
||||
$out = $none;
|
||||
$count = count($none);
|
||||
foreach ($arr as $row) {
|
||||
if (! is_array($row) || ! isset($row['n'])) {
|
||||
continue;
|
||||
}
|
||||
$idx = ((int) $row['n']) - 1;
|
||||
if ($idx < 0 || $idx >= $count) {
|
||||
continue;
|
||||
}
|
||||
$v = mb_strtolower((string) ($row['v'] ?? ''));
|
||||
if (str_contains($v, 'aggregator')) {
|
||||
$out[$idx] = true;
|
||||
} elseif (str_contains($v, 'supplier')) {
|
||||
$out[$idx] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
|
||||
{
|
||||
$cfg = (array) config('services.aitunnel');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = "Компания: «{$name}». Сайт: ".($siteUrl ?: '—').'. Описание: '.($description ?: '—').'. '
|
||||
.'Это сам поставщик услуги/товара (ответь SUPPLIER) или площадка-агрегатор/каталог/'
|
||||
.'маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями '
|
||||
.'(ответь AGGREGATOR)? Ответь РОВНО одним словом: SUPPLIER или AGGREGATOR.';
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withToken($key)
|
||||
->timeout((int) ($cfg['timeout_sec'] ?? 30))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
|
||||
'model' => $cfg['chat_model'] ?? 'gpt-4o-mini',
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'temperature' => 0,
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = mb_strtolower((string) $resp->json('choices.0.message.content'));
|
||||
if (str_contains($content, 'aggregator')) {
|
||||
return true;
|
||||
}
|
||||
if (str_contains($content, 'supplier')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Aggregator;
|
||||
|
||||
/**
|
||||
* Батч-версия {@see AggregatorClassifier}: классифицирует ВЕСЬ список за один запрос к модели,
|
||||
* а не по одному (иначе на ~90 фирм — 90 последовательных запросов, «умирает»). Классификаторы,
|
||||
* умеющие батч, реализуют этот интерфейс; {@see AggregatorFilter} использует его, если он есть.
|
||||
*/
|
||||
interface BatchAggregatorClassifier
|
||||
{
|
||||
/**
|
||||
* @param list<array{name:string,site_url:?string,description:?string}> $companies
|
||||
* @return list<?bool> вердикты по индексам входа: true — агрегатор, false — поставщик, null — не решил
|
||||
*/
|
||||
public function classifyManyAggregators(array $companies): array;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* Живой {@see QueryAnalyzer} через AITUNNEL: мелкая chat-модель разбивает описание клиента на короткие
|
||||
* запросы-рубрики для скрейпа категории справочников (шаг АНАЛИЗ канала А).
|
||||
*
|
||||
* ⚠️ Промт ВОССТАНОВЛЕН (оригинал жил в самоочистившемся collectA.js) — на живом прогоне точим.
|
||||
* Нет ключа / ошибка / пустой разбор → fallback: один запрос = само описание (канал А не мёртв).
|
||||
*/
|
||||
final class AitunnelQueryAnalyzer implements QueryAnalyzer
|
||||
{
|
||||
private const MAX_QUERIES = 8;
|
||||
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function analyze(string $description, string $region): array
|
||||
{
|
||||
$description = trim($description);
|
||||
$fallback = $description !== '' ? [$description] : [];
|
||||
|
||||
$cfg = (array) config('services.aitunnel');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '' || $description === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
// Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой
|
||||
// сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и
|
||||
// запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки).
|
||||
$prompt = "Описание деятельности клиента: «{$description}». Регион: {$region}.\n".
|
||||
'Дай от 3 до 6 КОРОТКИХ запросов-рубрик, по которым в справочниках 2ГИС и Яндекс.Карты '.
|
||||
"ищут именно ПРЯМЫХ конкурентов клиента — как называется его УЗКАЯ категория.\n".
|
||||
'Правило: рубрики только КОНКРЕТНЫЕ и узкие. ЗАПРЕЩЕНЫ общие «зонтичные» слова, которые '.
|
||||
'тащат кого попало: «услуги», «финансовые услуги», «финансы», «медицина», «товары», «магазин», '.
|
||||
"«сервис», «компания». Каждая рубрика — точная специализация, а не сфера целиком.\n".
|
||||
'Примеры (узкое, НЕ зонтичное): стоматология → «стоматология», «имплантация зубов» (НЕ «медицина»); '.
|
||||
'автосервис → «шиномонтаж», «автосервис» (НЕ «услуги»); займы под залог авто → «автоломбард», '.
|
||||
'«займ под ПТС» (НЕ «финансовые услуги»). Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '.
|
||||
'Только сами запросы, без пояснений, без названий фирм и без города. '.
|
||||
'Ответ — строго JSON-массив строк: ["...","..."]';
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withToken($key)
|
||||
->timeout((int) ($cfg['timeout_sec'] ?? 30))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
|
||||
'model' => $cfg['chat_model'] ?? 'gpt-4o-mini',
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'temperature' => 0,
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$queries = $this->parseQueries((string) $resp->json('choices.0.message.content'));
|
||||
|
||||
return $queries !== [] ? $queries : $fallback;
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-массив строк из сырого ответа: фрагмент `[`…`]`, дедуп (без регистра), отсев пустых, лимит.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseQueries(string $raw): array
|
||||
{
|
||||
$start = strpos($raw, '[');
|
||||
$end = strrpos($raw, ']');
|
||||
if ($start === false || $end === false || $end < $start) {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
|
||||
if (! is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$seen = [];
|
||||
foreach ($decoded as $q) {
|
||||
if (! is_string($q)) {
|
||||
continue;
|
||||
}
|
||||
$q = trim($q);
|
||||
$k = mb_strtolower($q, 'UTF-8');
|
||||
if ($q === '' || isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$out[] = $q;
|
||||
if (count($out) >= self::MAX_QUERIES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
/**
|
||||
* Лёгкий разбор страницы категории 2ГИС (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка, БЕЗ захода в карточку).
|
||||
* Из списка по каждой фирме берём: ИМЯ + ссылку на карточку (firm/<id>) + САЙТ.
|
||||
*
|
||||
* Сайт лежит в редиректе «Перейти на сайт»: `…/<firmId>/null/<hash>?http://site.ru…` — id в пути = id
|
||||
* фирмы, настоящий адрес после `?`. Мусор (агрегаторы/мессенджеры/трекеры/иностранные TLD) отсеиваем —
|
||||
* это не сайт фирмы. Телефоны/описание тут НЕ трогаем (это шаг 2). Чистый: на вход HTML, наружу не ходит.
|
||||
*/
|
||||
final class CategoryListingParser
|
||||
{
|
||||
/** Не сайт фирмы: агрегаторы/каталоги/соцсети/мессенджеры/трекеры. */
|
||||
private const BLACKLIST = [
|
||||
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', 'flamp.ru', 'orgpage.ru',
|
||||
'max.ru', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'wa.me', 'facebook.com', 'instagram.com',
|
||||
'youtube.com', 'dzen.ru', 'rambler.ru', 'mail.ru', 'tns-counter.ru', 'top100.ru',
|
||||
'serving-sys.ru', 'otello.ru', 'russpass.ru', 'gosuslugi.ru',
|
||||
];
|
||||
|
||||
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md', '.com.tr'];
|
||||
|
||||
/**
|
||||
* @return list<array{name:string,card_url:string,site:?string}>
|
||||
*/
|
||||
public function parse(string $html): array
|
||||
{
|
||||
$sites = $this->siteMap($html);
|
||||
|
||||
$out = [];
|
||||
$seen = [];
|
||||
$re = '#<a href="(/[a-z0-9_-]+/firm/(\d+))[^"]*"\s+class="_1rehek"><span class="_lvwrwt"><span>([^<]+)</span>#u';
|
||||
if (preg_match_all($re, $html, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $hit) {
|
||||
$id = $hit[2];
|
||||
if (isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$name = trim(html_entity_decode($hit[3], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'card_url' => 'https://2gis.ru'.$hit[1],
|
||||
'site' => $sites[$id] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* firmId → домен сайта из редиректов «Перейти на сайт». Первый «настоящий» на фирму.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function siteMap(string $html): array
|
||||
{
|
||||
$sites = [];
|
||||
if (preg_match_all('#/(\d{10,})/null/[^"?]*\?(https?://[^"\\\\?]+)#', $html, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $hit) {
|
||||
$id = $hit[1];
|
||||
if (isset($sites[$id])) {
|
||||
continue;
|
||||
}
|
||||
$domain = $this->domain($hit[2]);
|
||||
if ($domain !== null && $this->isRealSite($domain)) {
|
||||
$sites[$id] = $domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $sites;
|
||||
}
|
||||
|
||||
private function domain(string $url): ?string
|
||||
{
|
||||
$url = mb_strtolower(trim($url), 'UTF-8');
|
||||
$url = preg_replace('#^https?://#', '', $url);
|
||||
$url = preg_replace('#^www\.#', '', $url);
|
||||
$url = explode('/', $url)[0];
|
||||
$url = explode('?', $url)[0];
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
}
|
||||
|
||||
private function isRealSite(string $domain): bool
|
||||
{
|
||||
if (in_array($domain, self::BLACKLIST, true)) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::FOREIGN_TLD as $tld) {
|
||||
if (str_ends_with($domain, $tld)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
|
||||
|
||||
/**
|
||||
* Канал А (ZAFIKSIROVANO: шаг 1 = имя+сайт+карточка БЕЗ захода в карточку): по каждому запросу-рубрике
|
||||
* из шага АНАЛИЗ скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ всех страниц и берём из СПИСКА по
|
||||
* фирме имя + ссылку на карточку + сайт (через {@see CategoryListingParser}). Карточки не открываем —
|
||||
* это резко быстрее. Дедуп между страницами и запросами по ссылке карточки. Остановка: пустая
|
||||
* страница / страница без новых фирм / лимит maxPages.
|
||||
*
|
||||
* Загрузка страниц — ПАРАЛЛЕЛЬНАЯ ПОПЕРЁК РУБРИК (страница P всех активных рубрик за один
|
||||
* {@see BatchPageFetcher::htmlBatch()} вызов). Параллельность живёт внутри htmlBatch (режет на
|
||||
* порции по services.xfetch.concurrency). Если передан простой PageFetcher (тестовый стаб без
|
||||
* batch) — деградирует на одиночные html() вызовы, не ломая существующие тесты.
|
||||
*/
|
||||
final class CategoryScraper
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PageFetcher $pages,
|
||||
private readonly CategoryListingParser $parser,
|
||||
private readonly int $maxPages = 5,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
|
||||
* @return list<array{name:string,card_url:string,site:?string}>
|
||||
*/
|
||||
public function collectTwoGis(string $slug, array $queries): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
// Нормализуем и фильтруем пустые рубрики
|
||||
$activeQueries = [];
|
||||
foreach ($queries as $query) {
|
||||
$query = trim((string) $query);
|
||||
if ($query !== '') {
|
||||
$activeQueries[] = $query;
|
||||
}
|
||||
}
|
||||
|
||||
if ($activeQueries === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Базовые URL для каждой рубрики (страница 1)
|
||||
$baseUrls = [];
|
||||
foreach ($activeQueries as $query) {
|
||||
$baseUrls[$query] = "https://2gis.ru/{$slug}/search/".rawurlencode($query);
|
||||
}
|
||||
|
||||
// active — список рубрик, которые ещё «живы» (дали новые фирмы на предыдущей странице)
|
||||
$active = $activeQueries;
|
||||
|
||||
for ($page = 1; $page <= max(1, $this->maxPages); $page++) {
|
||||
if ($active === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Строим URL текущей страницы для каждой активной рубрики
|
||||
$urlByQuery = [];
|
||||
foreach ($active as $query) {
|
||||
$base = $baseUrls[$query];
|
||||
$urlByQuery[$query] = $page === 1 ? $base : $base."/page/{$page}";
|
||||
}
|
||||
|
||||
// Загружаем пачку: BatchPageFetcher — параллельно; простой PageFetcher — по одному
|
||||
$htmlByUrl = $this->fetchBatch(array_values($urlByQuery));
|
||||
|
||||
// Разбираем каждую рубрику; те, что дали 0 новых — выбывают
|
||||
$stillActive = [];
|
||||
foreach ($active as $query) {
|
||||
$url = $urlByQuery[$query];
|
||||
$html = $htmlByUrl[$url] ?? '';
|
||||
$rows = $this->parser->parse($html);
|
||||
|
||||
if ($rows === []) {
|
||||
// Пустая страница — рубрика выбывает
|
||||
continue;
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['card_url'];
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$out[] = $row;
|
||||
$added++;
|
||||
}
|
||||
|
||||
if ($added > 0) {
|
||||
$stillActive[] = $query; // есть новые — рубрика живёт
|
||||
}
|
||||
// $added === 0: страница без новых фирм → рубрика тоже выбывает
|
||||
}
|
||||
|
||||
$active = $stillActive;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает HTML нескольких URL. Если fetcher реализует BatchPageFetcher — делегирует
|
||||
* в htmlBatch() (параллельно, порциями по лимиту сервиса). Иначе — цикл html() по одному
|
||||
* (сохраняет совместимость с простыми PageFetcher-стабами в тестах).
|
||||
*
|
||||
* @param list<string> $urls
|
||||
* @return array<string, string> url => html
|
||||
*/
|
||||
private function fetchBatch(array $urls): array
|
||||
{
|
||||
if ($this->pages instanceof BatchPageFetcher) {
|
||||
return $this->pages->htmlBatch($urls);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($urls as $url) {
|
||||
$result[$url] = $this->pages->html($url);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Живой {@see YandexDirectory} через локальный Playwright (Firecrawl Яндекс.Карты не рендерит, §12.2).
|
||||
* По каждому запросу-рубрике рендерит `yandex.ru/maps/?text=<запрос> <город>`, скроллит ленту и берёт
|
||||
* организации: имя + ссылка на карточку. Скрипт — `scripts/render-yandex-list.cjs` (печатает JSON {orgs}).
|
||||
* Дедуп по id между запросами; потолок на число рендеров (лимит времени/нагрузки при параллельных клиентах).
|
||||
*
|
||||
* Сеть/браузер за Process — офлайн не юнит-тестим; оркестратор тестируется через фейк YandexDirectory.
|
||||
*/
|
||||
final class PlaywrightYandexDirectory implements YandexDirectory
|
||||
{
|
||||
private string $script;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $nodeBin = 'node',
|
||||
string $script = '',
|
||||
private readonly int $timeout = 120,
|
||||
// Не рендерим больше N запросов за подбор (локальный браузер тяжёл при параллельных клиентах).
|
||||
private readonly int $maxQueries = 4,
|
||||
private readonly YandexListParser $parser = new YandexListParser,
|
||||
) {
|
||||
$this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs');
|
||||
}
|
||||
|
||||
public function collect(string $city, array $queries): array
|
||||
{
|
||||
// Собираем сырьё по всем рубрикам, дедуп/опознание фирмы — в парсере ПО SLUG (код фирмы от
|
||||
// Яндекса), а не по id филиала: «Корунд ×15 → один», разные написания одной фирмы склеены.
|
||||
$orgs = [];
|
||||
foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) {
|
||||
$query = trim((string) $query);
|
||||
if ($query === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city));
|
||||
foreach ($this->render($url) as $org) {
|
||||
$orgs[] = $org;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parser->parse($orgs);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> orgs из render-скрипта; ошибка → []. */
|
||||
private function render(string $url): array
|
||||
{
|
||||
try {
|
||||
$p = new Process([$this->nodeBin, $this->script, $url]);
|
||||
$p->setTimeout($this->timeout);
|
||||
$p->run();
|
||||
if (! $p->isSuccessful()) {
|
||||
return [];
|
||||
}
|
||||
$json = json_decode($p->getOutput(), true);
|
||||
|
||||
return is_array($json['orgs'] ?? null) ? $json['orgs'] : [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
/**
|
||||
* Шаг АНАЛИЗ канала А: из описания деятельности клиента → НЕСКОЛЬКО коротких запросов-рубрик для
|
||||
* скрейпа категории 2ГИС/Яндекса (ZAFIKSIROVANO §0-БИС / §12.7 — в тесте Омеги было ~6 запросов).
|
||||
* Реализация решает, какой моделью (по умолчанию — мелкая chat-модель). За границей — для офлайн-теста.
|
||||
*/
|
||||
interface QueryAnalyzer
|
||||
{
|
||||
/** @return list<string> короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»). */
|
||||
public function analyze(string $description, string $region): array;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
/**
|
||||
* Второй справочник канала А — Яндекс.Карты (ZAFIKSIROVANO: Яндекс = имя + карточка, БЕЗ сайта;
|
||||
* сайт в списке Яндекса не отдаётся, только на карточке — не открываем). Собирает по запросам-рубрикам
|
||||
* организации: имя + ссылка на карточку. За границей — для офлайн-теста оркестратора через фейк.
|
||||
*/
|
||||
interface YandexDirectory
|
||||
{
|
||||
/**
|
||||
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
|
||||
* @return list<array{name:string,card_url:string}>
|
||||
*/
|
||||
public function collect(string $city, array $queries): array;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelA;
|
||||
|
||||
/**
|
||||
* Разбор списка организаций Яндекс.Карт (канал А). Дедуп по SLUG — коду фирмы от самого Яндекса
|
||||
* из адреса страницы `/maps/org/<slug>/<id>`, а НЕ по id филиала и НЕ по тексту имени.
|
||||
*
|
||||
* Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой —
|
||||
* поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 → один» и склеивает разные написания
|
||||
* одной фирмы («КрасЛомбард»/«Красломбард» → slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим
|
||||
* именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника,
|
||||
* а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит.
|
||||
*/
|
||||
final class YandexListParser
|
||||
{
|
||||
/**
|
||||
* @param list<array<string,mixed>> $orgs сырые организации из render-скрипта (name, href, category, ...)
|
||||
* @return list<array{name:string,card_url:string,slug:string,description:?string}>
|
||||
*/
|
||||
public function parse(array $orgs): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($orgs as $org) {
|
||||
$href = trim((string) ($org['href'] ?? ''));
|
||||
$name = trim((string) ($org['name'] ?? ''));
|
||||
if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) {
|
||||
continue;
|
||||
}
|
||||
$slug = mb_strtolower($m[1], 'UTF-8');
|
||||
if (isset($seen[$slug])) {
|
||||
continue; // одна фирма (slug) = одна строка; филиалы схлопнуты
|
||||
}
|
||||
$seen[$slug] = true;
|
||||
// Рубрика из списка Яндекса («Ломбард, автоломбард», «Микрофинансовая организация») = описание.
|
||||
$category = trim((string) ($org['category'] ?? ''));
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'card_url' => 'https://yandex.ru'.$href,
|
||||
'slug' => $slug,
|
||||
'description' => $category !== '' ? $category : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelB;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* Живой {@see ResearcherClient} через AITUNNEL (OpenAI-совместимый chat). ФИНАЛ (ZAFIKSIROVANO §0-БИС):
|
||||
* ОДНА рассуждающая модель (`research_model`, по умолчанию sonar-reasoning-pro) с веб-поиском, temperature:0.
|
||||
* POST {base}/chat/completions [system, user] → {choices:[{message:{content}}]}.
|
||||
* Нет ключа / ошибка сети → возвращаем '[]' (движок деградирует, не падает; канал В даст 0 имён).
|
||||
*/
|
||||
final class AitunnelResearcher implements ResearcherClient
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function research(string $system, string $user): string
|
||||
{
|
||||
$cfg = (array) config('services.aitunnel');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withToken($key)
|
||||
->timeout((int) ($cfg['research_timeout_sec'] ?? 120))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
|
||||
'model' => $cfg['research_model'] ?? 'sonar-reasoning-pro',
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $user],
|
||||
],
|
||||
'temperature' => 0,
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
$content = (string) $resp->json('choices.0.message.content');
|
||||
|
||||
return $content !== '' ? $content : '[]';
|
||||
} catch (\Throwable) {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelB;
|
||||
|
||||
/**
|
||||
* Канал В (федералы/онлайн) — генератор ИМЁН моделью, ФИНАЛ владельца (ZAFIKSIROVANO §0-БИС + §11.3):
|
||||
* • На вход ИИ даём СПИСОК ИЗ КАНАЛА А (фирмы, уже найденные в справочниках) + примеры клиента —
|
||||
* как «уже известных, не повторять».
|
||||
* • ИИ выдаёт ТОЛЬКО НАЗВАНИЯ новых конкурентов (+ тип), которых в списке нет. Без сайтов/карточек.
|
||||
* • ОДНА модель × 2 прохода: проход 2 получает стоп-лист = известные + найденное в проходе 1.
|
||||
* • Реальные сайты/карточки/телефоны по этим именам добывает ПОТОМ Firecrawl/резолвер (чистильщик).
|
||||
*
|
||||
* Дедуп по имени (ё→е, без не-букв/цифр). Модель — за границей {@see ResearcherClient} (офлайн-тест).
|
||||
*/
|
||||
final class ChannelBSearch
|
||||
{
|
||||
/** §11.3 — финальный промт «только имена». */
|
||||
public const SYSTEM_PROMPT = <<<'TXT'
|
||||
Ты — поисковик конкурентов для нашей компании. Используй интернет и справочники. Дай список НАЗВАНИЙ реальных компаний-конкурентов в указанном регионе как региональных, так и федеральных игроков в этой сфере — чем больше реальных, тем лучше.
|
||||
НЕ нужно искать сайты, телефоны и карточки — нужны только НАЗВАНИЯ настоящих фирм (как их пишут в справочниках/на вывеске).
|
||||
В запросе дан список уже известных — НЕ повторяй их. Не выдумывай фирмы ради объёма; лучше меньше, но реальные.
|
||||
ФОРМА — строго JSON-массив, без текста вне него: [{"name":"Название","type":"региональная"|"федеральная"}]
|
||||
TXT;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResearcherClient $client,
|
||||
private readonly ResearcherParser $parser,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<string> $known имена, уже известные (список из канала А + примеры клиента)
|
||||
* @return list<array{name:string,type:?string}> только НОВЫЕ имена
|
||||
*/
|
||||
public function harvest(string $profile, string $region, string $clientSite, array $known, int $passes = 2): array
|
||||
{
|
||||
$accumulated = [];
|
||||
$seen = [];
|
||||
$stop = [];
|
||||
|
||||
foreach ($known as $name) {
|
||||
$name = trim((string) $name);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$k = $this->key($name);
|
||||
if (isset($seen[$k])) {
|
||||
continue; // чистим стоп-лист от дублей-филиалов: «Корунд» ×27 → один
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$stop[] = $name;
|
||||
}
|
||||
|
||||
for ($pass = 1; $pass <= max(1, $passes); $pass++) {
|
||||
$user = $this->userPrompt($profile, $region, $clientSite, $stop);
|
||||
$raw = $this->client->research(self::SYSTEM_PROMPT, $user);
|
||||
|
||||
foreach ($this->parser->parse($raw) as $cand) {
|
||||
$k = $this->key($cand['name']);
|
||||
if (isset($seen[$k])) {
|
||||
continue; // уже известен (канал А, пример или прошлый проход)
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$accumulated[] = $cand;
|
||||
$stop[] = $cand['name']; // стоп-лист растёт к следующему проходу
|
||||
}
|
||||
}
|
||||
|
||||
return $accumulated;
|
||||
}
|
||||
|
||||
/** Пользовательский промт §11.3: профиль/регион/сайт клиента + список известных имён. */
|
||||
private function userPrompt(string $profile, string $region, string $clientSite, array $stop): string
|
||||
{
|
||||
$list = $stop === []
|
||||
? '(пока пусто)'
|
||||
: implode("\n", array_map(static fn (string $n): string => '- '.$n, $stop));
|
||||
|
||||
return "Наша компания: {$profile} в {$region}. Наш сайт: {$clientSite}.\n".
|
||||
"Уже известные конкуренты — НЕ выводить их повторно:\n".
|
||||
"{$list}\n".
|
||||
"Дай ТОЛЬКО НОВЫЕ названия конкурентов в {$region}, которых нет в списке. Ответ — строго в формате из системного промта.";
|
||||
}
|
||||
|
||||
/** Ключ дедупа по имени: ё→е, нижний регистр, только буквы/цифры. */
|
||||
private function key(string $name): string
|
||||
{
|
||||
$name = str_replace('ё', 'е', mb_strtolower($name, 'UTF-8'));
|
||||
|
||||
return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelB;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
/**
|
||||
* Нормализация канала В (ZAFIKSIROVANO / §11.4–11.5): у федерала НЕТ карточки в 2ГИС/Яндексе на регион,
|
||||
* поэтому его САЙТ ищем по имени через EXA. Берём домен первого «настоящего» результата, отсеивая
|
||||
* каталоги/агрегаторы/реестры/отзовики и иностранные TLD (это НЕ сайт самой фирмы).
|
||||
*
|
||||
* POST {base}/search {query, numResults} (header x-api-key) → {results:[{url}]}. Нет ключа/ошибка/
|
||||
* нет подходящих → null (тогда у имени из В сайта не будет; резолвер решит «нет филиала»).
|
||||
*/
|
||||
final class ExaSiteFinder
|
||||
{
|
||||
/** Домены-каталоги/агрегаторы/реестры/карты/отзовики — НЕ сайт фирмы (§11.4). */
|
||||
private const BLACKLIST = [
|
||||
'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', '1000bankov.ru',
|
||||
'yandex.ru', 'ya.ru', '2gis.ru', 'flamp.ru', 'orgpage.ru', 'rusprofile.ru',
|
||||
'list-org.com', 'sbis.ru', 'spark-interfax.ru', 'otzovik.com', 'irecommend.ru',
|
||||
'vk.com', 'ok.ru', 'instagram.com', 'facebook.com', 't.me', 'telegram.me',
|
||||
'wikipedia.org', 'youtube.com', 'dzen.ru', 'zen.yandex.ru',
|
||||
];
|
||||
|
||||
/** Иностранные TLD — не сайт российской фирмы (§11.4). */
|
||||
private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md'];
|
||||
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function findSite(string $name, string $region): ?string
|
||||
{
|
||||
$cfg = (array) config('services.exa');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '' || trim($name) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withHeaders(['x-api-key' => $key])
|
||||
->timeout((int) ($cfg['timeout_sec'] ?? 30))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', $this->body($name));
|
||||
|
||||
return $this->pickDomain($resp);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакетный поиск сайтов ПАРАЛЛЕЛЬНО — пулом Laravel Http, порциями по services.exa.concurrency
|
||||
* (внутри порции одновременно, порции по очереди). Лимит EXA — на КЛЮЧ (один на всех клиентов),
|
||||
* поэтому не «все разом», а бережно; глобальный потолок между клиентами держит очередь.
|
||||
* Сбой/429 по имени → его сайт null (не роняет пул и не теряет остальных).
|
||||
*
|
||||
* @param list<string> $names
|
||||
* @return array<string, ?string> имя → домен (или null)
|
||||
*/
|
||||
public function findSites(array $names, string $region): array
|
||||
{
|
||||
$names = array_values(array_unique(array_filter(
|
||||
array_map(static fn ($n): string => trim((string) $n), $names),
|
||||
static fn (string $n): bool => $n !== '',
|
||||
)));
|
||||
|
||||
$out = [];
|
||||
foreach ($names as $n) {
|
||||
$out[$n] = null;
|
||||
}
|
||||
if ($names === []) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$cfg = (array) config('services.exa');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$url = rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search';
|
||||
$timeout = (int) ($cfg['timeout_sec'] ?? 30);
|
||||
$concurrency = max(1, (int) ($cfg['concurrency'] ?? 5));
|
||||
|
||||
foreach (array_chunk($names, $concurrency) as $chunk) {
|
||||
try {
|
||||
$responses = $this->http->pool(fn ($pool) => array_map(
|
||||
fn (string $n) => $pool->as($n)
|
||||
->withHeaders(['x-api-key' => $key])
|
||||
->timeout($timeout)
|
||||
->post($url, $this->body($n)),
|
||||
$chunk,
|
||||
));
|
||||
} catch (\Throwable) {
|
||||
continue; // весь чанк не удался — имена остаются null, идём к следующему
|
||||
}
|
||||
|
||||
foreach ($chunk as $n) {
|
||||
$out[$n] = $this->pickDomain($responses[$n] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Тело запроса EXA по имени. */
|
||||
private function body(string $name): array
|
||||
{
|
||||
return ['query' => $name.' официальный сайт', 'numResults' => 10, 'type' => 'auto'];
|
||||
}
|
||||
|
||||
/** Первый «настоящий» домен из ответа EXA (не каталог/агрегатор/иностранный TLD), иначе null. */
|
||||
private function pickDomain(mixed $resp): ?string
|
||||
{
|
||||
if (! $resp instanceof Response || ! $resp->successful()) {
|
||||
return null;
|
||||
}
|
||||
foreach ((array) $resp->json('results') as $r) {
|
||||
$url = is_array($r) ? (string) ($r['url'] ?? '') : '';
|
||||
$domain = $this->domain($url);
|
||||
if ($domain !== null && $this->isRealSite($domain)) {
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Голый домен из URL: без схемы/www/пути, нижний регистр. */
|
||||
private function domain(string $url): ?string
|
||||
{
|
||||
$url = mb_strtolower(trim($url), 'UTF-8');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
$url = preg_replace('#^https?://#', '', $url);
|
||||
$url = preg_replace('#^www\.#', '', $url);
|
||||
$url = explode('/', $url)[0];
|
||||
$url = explode('?', $url)[0];
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
}
|
||||
|
||||
/** Настоящий сайт фирмы — не из чёрного списка и не иностранный TLD. */
|
||||
private function isRealSite(string $domain): bool
|
||||
{
|
||||
if (in_array($domain, self::BLACKLIST, true)) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::FOREIGN_TLD as $tld) {
|
||||
if (str_ends_with($domain, $tld)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelB;
|
||||
|
||||
/**
|
||||
* Граница живого канала В: один вызов модели-исследователя (system+user промт → сырой текст ответа).
|
||||
* Финал движка (см. ZAFIKSIROVANO §0-БИС) — ОДНА модель, гоняется 2 прохода; реализация решает,
|
||||
* какую модель шлёт (по умолчанию sonar-reasoning-pro). Логика проходов/стоп-листа — за границей,
|
||||
* в {@see ChannelBSearch}, чтобы тестироваться офлайн через фейк-клиент.
|
||||
*/
|
||||
interface ResearcherClient
|
||||
{
|
||||
public function research(string $system, string $user): string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\ChannelB;
|
||||
|
||||
/**
|
||||
* Канал В (§11.3 + ZAFIKSIROVANO §0-БИС): модель-исследователь даёт ТОЛЬКО НАЗВАНИЯ конкурентов
|
||||
* (+ тип регион/федерал), без сайтов/карточек/телефонов — их добывает ПОТОМ Firecrawl/резолвер.
|
||||
* Парсер вытаскивает JSON-массив имён из сырого ответа (часто в markdown-обёртке или с текстом вокруг).
|
||||
* Чистый: на вход строка, наружу не ходит.
|
||||
*/
|
||||
final class ResearcherParser
|
||||
{
|
||||
/**
|
||||
* @return list<array{name:string,type:?string}>
|
||||
*/
|
||||
public function parse(string $raw): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->decodeArray($raw) as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue; // без имени — мусор
|
||||
}
|
||||
$out[] = ['name' => $name, 'type' => $this->str($row['type'] ?? null)];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Достаёт JSON-массив: фрагмент от первой `[` до последней `]`. Не распарсилось — пустой массив.
|
||||
*
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function decodeArray(string $raw): array
|
||||
{
|
||||
$start = strpos($raw, '[');
|
||||
$end = strrpos($raw, ']');
|
||||
if ($start === false || $end === false || $end < $start) {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode(substr($raw, $start, $end - $start + 1), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function str(mixed $v): ?string
|
||||
{
|
||||
if (! is_string($v)) {
|
||||
return null;
|
||||
}
|
||||
$v = trim($v);
|
||||
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
|
||||
interface CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
|
||||
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
|
||||
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class CollectedSource
|
||||
{
|
||||
/** @param array<int,array{label:string,url:?string}> $sources */
|
||||
public function __construct(
|
||||
public readonly string $signalType, // call | site
|
||||
public readonly string $identifier, // 7XXXXXXXXXX | домен
|
||||
public readonly ?string $phoneKind, // real | substitute | null
|
||||
public readonly ?string $phoneType, // city | mobile | tollfree | null
|
||||
public readonly ?string $office, // подпись филиала | null
|
||||
public readonly array $sources, // «где нашли»
|
||||
) {}
|
||||
|
||||
public function confirmations(): int
|
||||
{
|
||||
return count($this->sources);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $regionCode,
|
||||
public readonly array $examples,
|
||||
public readonly array $aboutSelf,
|
||||
public readonly bool $includeFederal,
|
||||
public readonly int $maxCompetitors,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
|
||||
*/
|
||||
public function __construct(public readonly array $competitors) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
|
||||
*/
|
||||
public function __construct(public readonly array $candidates) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorRequest
|
||||
{
|
||||
/**
|
||||
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $competitor,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string,where_found?:array<int,array{label:string,url:?string}>,office?:?string,confirmations?:int}> $sources
|
||||
*/
|
||||
public function __construct(public readonly array $sources) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
final class CalltrackingDetector
|
||||
{
|
||||
private const PROVIDERS = ['roistat', 'calltouch', 'comagic', 'uiscom', 'mango-office', 'callibri', 'ringostat', 'phonet'];
|
||||
|
||||
/** @return list<string> */
|
||||
public function detect(string $html): array
|
||||
{
|
||||
$found = [];
|
||||
foreach (self::PROVIDERS as $p) {
|
||||
if (stripos($html, $p) !== false) {
|
||||
$found[] = $p;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($found));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
|
||||
final class CandidateBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private HtmlPhoneScanner $scanner = new HtmlPhoneScanner,
|
||||
private CalltrackingDetector $detector = new CalltrackingDetector,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<FetchedSite> $sites
|
||||
* @param list<DirectoryCard> $cards
|
||||
* @param ?string $defaultAreaCode запасной код города (по региону конкурента) —
|
||||
* для достройки коротких номеров, если на странице нет полных
|
||||
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
|
||||
*/
|
||||
public function build(array $sites, array $cards, ?string $defaultAreaCode = null): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($sites as $site) {
|
||||
$scan = $this->scanner->scan($site->rawHtml, $defaultAreaCode);
|
||||
$hasTracker = $this->detector->detect($site->rawHtml) !== [];
|
||||
|
||||
foreach (array_keys($scan['code']) as $number) {
|
||||
$out[] = [
|
||||
'number' => (string) $number,
|
||||
'kind' => 'code',
|
||||
'label' => 'в коде сайта',
|
||||
'url' => $site->url,
|
||||
'office' => null,
|
||||
'tracker' => $hasTracker,
|
||||
];
|
||||
}
|
||||
|
||||
// короткие локальные номера, код города которых не удалось определить —
|
||||
// НЕ теряем, отдаём клиенту с пометкой «требует проверки»
|
||||
foreach ($scan['uncertain'] ?? [] as $short) {
|
||||
$out[] = [
|
||||
'number' => (string) $short,
|
||||
'kind' => 'uncertain',
|
||||
'label' => 'локальный номер на сайте — код города не определён, требует проверки',
|
||||
'url' => $site->url,
|
||||
'office' => null,
|
||||
'tracker' => $hasTracker,
|
||||
];
|
||||
}
|
||||
|
||||
$codeNumbers = array_map('strval', array_keys($scan['code']));
|
||||
|
||||
// видимые отрендеренные номера → displayed (подменный), если их нет в коде
|
||||
$visible = [];
|
||||
foreach ($site->visiblePhones as $raw) {
|
||||
$n = $this->normalize($raw);
|
||||
if ($n === null) {
|
||||
continue;
|
||||
}
|
||||
$visible[] = $n;
|
||||
if (! in_array($n, $codeNumbers, true)) {
|
||||
$out[] = [
|
||||
'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)',
|
||||
'url' => $site->url, 'office' => null, 'tracker' => $hasTracker,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере
|
||||
if ($hasTracker) {
|
||||
foreach ($scan['body'] as $number => $count) {
|
||||
$number = (string) $number;
|
||||
if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) {
|
||||
$out[] = [
|
||||
'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены',
|
||||
'url' => $site->url, 'office' => null, 'tracker' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// номера филиалов со страницы «Контакты» (рендер)
|
||||
foreach ($site->contactsNumbers as $row) {
|
||||
$n = $this->normalize($row['number']);
|
||||
if ($n === null) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»',
|
||||
'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($cards as $card) {
|
||||
$n = $this->normalize($card->number);
|
||||
if ($n === null) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'number' => $n, 'kind' => 'directory', 'label' => $card->source,
|
||||
'url' => $card->url, 'office' => $card->office, 'tracker' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function normalize(string $raw): ?string
|
||||
{
|
||||
$n = $this->norm->phone($raw);
|
||||
|
||||
return (strlen($n) === 11 && $n[0] === '7') ? $n : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
|
||||
|
||||
/**
|
||||
* Разбор страниц справочников (2ГИС/Яндекс): список филиалов и карточка филиала.
|
||||
* Чистый: на вход — уже отрендеренный HTML, на выход — ссылки/карточки.
|
||||
*/
|
||||
final class DirectoryParser
|
||||
{
|
||||
/**
|
||||
* Ссылки на карточки филиалов со страницы списка (2ГИС: /city/firm/<id>).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function parseBranchList(string $html): array
|
||||
{
|
||||
// Путь карточки филиала /<город>/firm/<id> — 2ГИС отдаёт его то как href,
|
||||
// то внутри JSON-состояния страницы. Берём отовсюду (с дедупом) — иначе на части
|
||||
// прорисовок филиалы теряются (поймано на живом 2ГИС).
|
||||
$out = [];
|
||||
if (preg_match_all('#/[a-z0-9_-]+/firm/\d+#i', $html, $m)) {
|
||||
foreach ($m[0] as $href) {
|
||||
if (! in_array($href, $out, true)) {
|
||||
$out[] = $href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы.
|
||||
*
|
||||
* @return list<DirectoryCard>
|
||||
*/
|
||||
public function parseFirmCard(string $html, string $url, string $source): array
|
||||
{
|
||||
$office = $this->officeFromTitle($html);
|
||||
|
||||
$out = [];
|
||||
if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) {
|
||||
$seen = [];
|
||||
foreach ($m[1] as $raw) {
|
||||
$num = trim($raw);
|
||||
if (isset($seen[$num])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$num] = true;
|
||||
$out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Адрес офиса из <title> вида «Имя, адрес…, Город — 2ГИС».
|
||||
* Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем).
|
||||
*/
|
||||
private function officeFromTitle(string $html): ?string
|
||||
{
|
||||
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
|
||||
return null;
|
||||
}
|
||||
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
// нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные
|
||||
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
|
||||
$title = trim($title);
|
||||
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
|
||||
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
|
||||
|
||||
$parts = array_map('trim', explode(',', $title));
|
||||
if (count($parts) < 3) {
|
||||
return null; // нет адреса между именем и городом
|
||||
}
|
||||
// имя фирмы — первый кусок, город — последний; адрес — середина
|
||||
$address = array_slice($parts, 1, count($parts) - 2);
|
||||
|
||||
return implode(', ', $address) ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
final class HtmlPhoneScanner
|
||||
{
|
||||
/**
|
||||
* @param ?string $defaultAreaCode код города из запроса (напр. «391») — запасной для
|
||||
* достройки коротких локальных номеров, если на странице нет полных
|
||||
* @return array{code: array<string,list<string>>, body: array<string,int>, emails: list<string>, uncertain: list<string>}
|
||||
*/
|
||||
public function scan(string $html, ?string $defaultAreaCode = null): array
|
||||
{
|
||||
$code = [];
|
||||
$uncertain = [];
|
||||
|
||||
// 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata).
|
||||
// Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны.
|
||||
$rawCandidates = [];
|
||||
$patterns = [
|
||||
['/tel:([+0-9()\s-]{6,})/i', 'tel'],
|
||||
['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'],
|
||||
['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'],
|
||||
];
|
||||
foreach ($patterns as [$re, $slot]) {
|
||||
if (preg_match_all($re, $html, $m)) {
|
||||
foreach ($m[1] as $x) {
|
||||
$rawCandidates[] = ['raw' => $x, 'slot' => $slot];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города.
|
||||
$national = [];
|
||||
foreach ($rawCandidates as $c) {
|
||||
$n = $this->normalizeMaybe($c['raw']);
|
||||
if ($n !== null) {
|
||||
$national[] = substr($n, 1);
|
||||
}
|
||||
}
|
||||
|
||||
$add = function (string $n, string $slot) use (&$code): void {
|
||||
$code[$n] ??= [];
|
||||
if (! in_array($slot, $code[$n], true)) {
|
||||
$code[$n][] = $slot;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города.
|
||||
foreach ($rawCandidates as $c) {
|
||||
$n = $this->normalizeMaybe($c['raw']);
|
||||
if ($n !== null) {
|
||||
$add($n, $c['slot']);
|
||||
|
||||
continue;
|
||||
}
|
||||
[$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode);
|
||||
if ($status === 'built') {
|
||||
$add((string) $value, $c['slot']);
|
||||
} elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) {
|
||||
$uncertain[] = (string) $value; // код города не определить — не теряем, к проверке
|
||||
}
|
||||
// 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем
|
||||
}
|
||||
|
||||
$body = [];
|
||||
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $html, $m)) {
|
||||
foreach ($m[0] as $x) {
|
||||
$n = $this->normalizeMaybe($x);
|
||||
if ($n !== null) {
|
||||
$body[$n] = ($body[$n] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$emails = [];
|
||||
if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) {
|
||||
foreach ($m[1] as $local) {
|
||||
$d = preg_replace('/\D+/', '', $local) ?? '';
|
||||
if (strlen($d) >= 7) {
|
||||
$emails[] = $d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['code' => $code, 'body' => $body, 'emails' => $emails, 'uncertain' => $uncertain];
|
||||
}
|
||||
|
||||
private function normalizeMaybe(string $raw): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
|
||||
return '7'.substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) === 10) {
|
||||
return '7'.$digits;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Классифицирует короткую (6–7 цифр) форму номера из явного телефонного контекста.
|
||||
* Возвращает [статус, значение]:
|
||||
* - 'built' + 7XXXXXXXXXX — локальный номер достроен кодом города;
|
||||
* - 'fragment' + null — обрезок полного номера (страна+код), выкидываем;
|
||||
* - 'uncertain' + цифры — код города не определить, не теряем, помечаем к проверке;
|
||||
* - 'skip' + null — не 6–7-значная форма, не наше.
|
||||
* Пример: «271-33-33» при «+7 (391) …» → ['built','73912713333'];
|
||||
* обрезок «+7 (391) 271» (7391271) → ['fragment', null].
|
||||
*
|
||||
* @param list<string> $national 10-значные национальные части полных номеров страницы
|
||||
* @param ?string $defaultAreaCode запасной код города из запроса (если полных нет)
|
||||
* @return array{0:string,1:?string}
|
||||
*/
|
||||
private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
$len = strlen($digits);
|
||||
if ($len < 6 || $len > 7) {
|
||||
return ['skip', null];
|
||||
}
|
||||
|
||||
$area = $this->areaCode($national, 10 - $len, $defaultAreaCode);
|
||||
if ($area === null) {
|
||||
return ['uncertain', $digits]; // код города не определить
|
||||
}
|
||||
|
||||
// Обрезок полного номера: уже содержит код страны 7/8 + код города (напр. 7391271 = 7·391·271).
|
||||
// Настоящий локальный (2713333, или московский 7712233 при коде 495) этим не задевается.
|
||||
if (str_starts_with($digits, '7'.$area) || str_starts_with($digits, '8'.$area)) {
|
||||
return ['fragment', null];
|
||||
}
|
||||
|
||||
return ['built', '7'.$area.$digits];
|
||||
}
|
||||
|
||||
/**
|
||||
* Код города нужной длины: самый частый префикс среди полных номеров страницы,
|
||||
* иначе запасной из запроса. null — если не определить.
|
||||
*
|
||||
* @param list<string> $national
|
||||
*/
|
||||
private function areaCode(array $national, int $prefixLen, ?string $defaultAreaCode): ?string
|
||||
{
|
||||
if ($national !== []) {
|
||||
$counts = [];
|
||||
foreach ($national as $nat) {
|
||||
$p = substr($nat, 0, $prefixLen);
|
||||
$counts[$p] = ($counts[$p] ?? 0) + 1;
|
||||
}
|
||||
arsort($counts);
|
||||
$prefix = (string) array_key_first($counts);
|
||||
if (strlen($prefix) === $prefixLen) {
|
||||
return $prefix;
|
||||
}
|
||||
}
|
||||
|
||||
if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) {
|
||||
return $defaultAreaCode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
final class PhoneType
|
||||
{
|
||||
/** @param string $p номер в виде 7XXXXXXXXXX */
|
||||
public static function of(string $p): string
|
||||
{
|
||||
$code = substr($p, 1, 3);
|
||||
if ($code === '800') {
|
||||
return 'tollfree';
|
||||
}
|
||||
if (isset($code[0]) && $code[0] === '9') {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'city';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
|
||||
|
||||
final class SourceAggregator
|
||||
{
|
||||
private const TRUSTED = ['code', 'contacts', 'directory', 'email'];
|
||||
|
||||
/**
|
||||
* @param array<int,array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
|
||||
* @return list<CollectedSource>
|
||||
*/
|
||||
public function aggregate(array $candidates): array
|
||||
{
|
||||
/** @var array<string,array{sources:array<string,array{label:string,url:?string}>,kinds:list<string>,office:?string,tracker:bool}> $by */
|
||||
$by = [];
|
||||
foreach ($candidates as $c) {
|
||||
$n = $c['number'];
|
||||
$by[$n] ??= ['sources' => [], 'kinds' => [], 'office' => null, 'tracker' => false];
|
||||
$by[$n]['kinds'][] = $c['kind'];
|
||||
$by[$n]['tracker'] = $by[$n]['tracker'] || $c['tracker'];
|
||||
$by[$n]['office'] ??= $c['office'];
|
||||
if ($c['kind'] !== 'pool') {
|
||||
$key = $c['label'];
|
||||
if (! isset($by[$n]['sources'][$key])) {
|
||||
$by[$n]['sources'][$key] = ['label' => $c['label'], 'url' => $c['url']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($by as $n => $info) {
|
||||
$n = (string) $n; // PHP приводит числовые ключи массива к int — возвращаем строку
|
||||
$kinds = $info['kinds'];
|
||||
$hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds);
|
||||
$hasDisplayed = in_array('displayed', $kinds, true);
|
||||
// короткий локальный номер без определимого кода города — «требует проверки»
|
||||
$isUncertain = ! $hasTrusted && ! $hasDisplayed && in_array('uncertain', $kinds, true);
|
||||
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain; // только pool-свалка
|
||||
if ($onlyHidden) {
|
||||
continue; // пул-свалка — не выводим
|
||||
}
|
||||
if ($isUncertain) {
|
||||
$phoneKind = 'uncertain';
|
||||
$phoneType = null; // тип не выдумываем — номер ещё не достроен
|
||||
} else {
|
||||
$phoneKind = $hasTrusted ? 'real' : 'substitute';
|
||||
$phoneType = PhoneType::of($n);
|
||||
}
|
||||
$out[] = new CollectedSource(
|
||||
signalType: 'call',
|
||||
identifier: $n,
|
||||
phoneKind: $phoneKind,
|
||||
phoneType: $phoneType,
|
||||
office: $info['office'],
|
||||
sources: array_values($info['sources']),
|
||||
);
|
||||
}
|
||||
|
||||
usort($out, function (CollectedSource $a, CollectedSource $b): int {
|
||||
$sa = $a->phoneKind === 'substitute' ? 1 : 0;
|
||||
$sb = $b->phoneKind === 'substitute' ? 1 : 0;
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb; // подменные — вниз
|
||||
}
|
||||
if ($a->confirmations() !== $b->confirmations()) {
|
||||
return $b->confirmations() <=> $a->confirmations(); // больше подтверждений — выше
|
||||
}
|
||||
|
||||
return strcmp($a->identifier, $b->identifier);
|
||||
});
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
|
||||
final class FakeCompetitorAgent implements CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
|
||||
{
|
||||
return new FindCompetitorsResult([
|
||||
['name' => 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']],
|
||||
]);
|
||||
}
|
||||
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
|
||||
{
|
||||
return new StudyCompetitorResult([
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'],
|
||||
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
|
||||
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
|
||||
['signal_type' => 'call', 'identifier' => '88002001122', 'phone_kind' => 'real', 'phone_type' => 'tollfree', 'provenance_url' => 'https://okna-komfort-kzn.ru/contacts', 'provenance_label' => 'бесплатная линия 8-800 на сайте'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
|
||||
{
|
||||
return new ResolveByNameResult([
|
||||
['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
/**
|
||||
* Загрузчик, умеющий тянуть НЕСКОЛЬКО страниц пачкой (параллельно, порциями по лимиту сервиса).
|
||||
* Отдельный интерфейс поверх {@see PageFetcher}, чтобы обходчик справочников мог опционально
|
||||
* ускорять сбор карточек (до 25 филиалов) — а простые/тестовые загрузчики его не реализуют
|
||||
* и продолжают работать по одному через {@see PageFetcher::html()}.
|
||||
*/
|
||||
interface BatchPageFetcher extends PageFetcher
|
||||
{
|
||||
/**
|
||||
* Достать HTML нескольких страниц. Реализация сама режет на порции по лимиту конкурентности
|
||||
* и параллелит внутри порции. Порядок не важен — возвращается карта url => html.
|
||||
*
|
||||
* @param list<string> $urls
|
||||
* @return array<string, string> url => html (пустая строка при неудаче — исключений не кидаем)
|
||||
*/
|
||||
public function htmlBatch(array $urls): array;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
/**
|
||||
* Разводит добычу по двум путям: сайт конкурента — через богатый загрузчик
|
||||
* (curl + локальный Playwright, бесплатно, с видимыми/пул/контактами), справочники
|
||||
* (2ГИС/Яндекс) — через антибот-загрузчик (xfetch). Для движка — один {@see Fetcher}.
|
||||
*/
|
||||
final class CompositeFetcher implements Fetcher
|
||||
{
|
||||
public function __construct(
|
||||
private Fetcher $siteFetcher,
|
||||
private Fetcher $directoryFetcher,
|
||||
) {}
|
||||
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
return $this->siteFetcher->site($url);
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
return $this->directoryFetcher->directory($url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class CurlPlaywrightFetcher implements Fetcher
|
||||
{
|
||||
public function __construct(
|
||||
private string $nodeBin = 'node',
|
||||
private string $renderScript = '', // путь к render-page.cjs; по умолчанию — base_path
|
||||
private int $timeout = 45,
|
||||
private string $firmScript = '', // путь к render-firm.cjs (карточка справочника с кликом)
|
||||
private int $maxBranches = 25, // предел обхода филиалов за один сбор
|
||||
private DirectoryParser $dirParser = new DirectoryParser,
|
||||
) {
|
||||
if ($this->renderScript === '') {
|
||||
$this->renderScript = base_path('scripts/render-page.cjs');
|
||||
}
|
||||
if ($this->firmScript === '') {
|
||||
$this->firmScript = base_path('scripts/render-firm.cjs');
|
||||
}
|
||||
}
|
||||
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
if (! $this->isSafeUrl($url)) {
|
||||
return new FetchedSite(url: $url, rawHtml: '');
|
||||
}
|
||||
|
||||
$raw = $this->curl($url);
|
||||
|
||||
$visible = [];
|
||||
$contacts = [];
|
||||
$rendered = $this->render($url);
|
||||
if ($rendered !== null) {
|
||||
$visible = $rendered['visiblePhones'] ?? [];
|
||||
// номера со страницы /contacts добираем отдельным рендером, если есть такая ссылка
|
||||
$contactsUrl = $this->guessContactsUrl($url, $raw);
|
||||
if ($contactsUrl !== null && $this->isSafeUrl($contactsUrl)) {
|
||||
$rc = $this->render($contactsUrl);
|
||||
if ($rc !== null) {
|
||||
foreach (($rc['visiblePhones'] ?? []) as $p) {
|
||||
$contacts[] = ['number' => $p, 'office' => null];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new FetchedSite(url: $url, rawHtml: $raw, visiblePhones: $visible, contactsNumbers: $contacts);
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
if (! $this->isSafeUrl($url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. Рендерим страницу списка филиалов → ссылки на карточки.
|
||||
$list = $this->render($url);
|
||||
if ($list === null) {
|
||||
return [];
|
||||
}
|
||||
$links = $this->dirParser->parseBranchList($list['html'] ?? '');
|
||||
if ($links === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$origin = parse_url($url);
|
||||
$base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? '');
|
||||
$source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС';
|
||||
|
||||
// 2. Обходим карточки филиалов (с пределом), на каждой жмём «показать телефон».
|
||||
$cards = [];
|
||||
foreach (array_slice($links, 0, $this->maxBranches) as $href) {
|
||||
$firmUrl = str_starts_with($href, 'http') ? $href : $base.$href;
|
||||
if (! $this->isSafeUrl($firmUrl)) {
|
||||
continue;
|
||||
}
|
||||
$firm = $this->renderFirm($firmUrl);
|
||||
if ($firm === null) {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->dirParser->parseFirmCard($firm['html'] ?? '', $firmUrl, $source) as $card) {
|
||||
$cards[] = $card;
|
||||
}
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
private function curl(string $url): string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false, // защита от SSRF через редирект на внутренний адрес
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LiderraBot/1.0)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return is_string($body) ? $body : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пускаем только публичные http/https адреса; блокируем loopback, приватные
|
||||
* и служебные диапазоны (защита от SSRF — заход на внутренние сервисы).
|
||||
*/
|
||||
private function isSafeUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $parts['host'];
|
||||
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
|
||||
if ($ips === []) {
|
||||
return false;
|
||||
}
|
||||
foreach ($ips as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
return false; // приватный/служебный/loopback — не ходим
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array{html:string,visiblePhones:list<string>}|null */
|
||||
private function render(string $url): ?array
|
||||
{
|
||||
$p = new Process([$this->nodeBin, $this->renderScript, $url]);
|
||||
$p->setTimeout($this->timeout);
|
||||
$p->run();
|
||||
if (! $p->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode($p->getOutput(), true);
|
||||
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
/** @return array{html:string}|null */
|
||||
private function renderFirm(string $url): ?array
|
||||
{
|
||||
$p = new Process([$this->nodeBin, $this->firmScript, $url]);
|
||||
$p->setTimeout($this->timeout);
|
||||
$p->run();
|
||||
if (! $p->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode($p->getOutput(), true);
|
||||
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
private function guessContactsUrl(string $base, string $raw): ?string
|
||||
{
|
||||
if (preg_match('#href=["\']([^"\']*(?:contacts?|kontakty)[^"\']*)["\']#i', $raw, $m)) {
|
||||
$href = $m[1];
|
||||
if (preg_match('#^https?://#i', $href)) {
|
||||
return $href;
|
||||
}
|
||||
|
||||
return rtrim($base, '/').'/'.ltrim($href, '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
final class DirectoryCard
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $number, // сырой номер
|
||||
public readonly ?string $office, // подпись филиала
|
||||
public readonly string $url, // ссылка на карточку
|
||||
public readonly string $source, // '2ГИС' | 'Яндекс.Карты'
|
||||
public readonly ?string $siteUrl = null, // сайт, указанный в карточке
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
final class FetchedSite
|
||||
{
|
||||
/**
|
||||
* @param list<string> $visiblePhones видимые посетителю (отрендеренные) номера, сырые строки
|
||||
* @param list<array{number:string,office:?string}> $contactsNumbers номера со страницы «Контакты» с привязкой к офису
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $url,
|
||||
public readonly string $rawHtml,
|
||||
public readonly array $visiblePhones = [],
|
||||
public readonly array $contactsNumbers = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
interface Fetcher
|
||||
{
|
||||
public function site(string $url): FetchedSite;
|
||||
|
||||
/** @return list<DirectoryCard> */
|
||||
public function directory(string $url): array;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Живая добыча страниц для поиска конкурентов (§12.2 движка v4): 2ГИС — через xfetch (обход
|
||||
* антибота); Яндекс — тоже через xfetch, а при пустом ответе fallback на ЛОКАЛЬНЫЙ Playwright
|
||||
* (бесплатный, проверенный рендер) через scripts/render-page.cjs. Прочие домены не грузим
|
||||
* (поиск конкурентов ходит только в справочники). Любая ошибка → '' (как контракт PageFetcher).
|
||||
*/
|
||||
final class LivePageFetcher implements PageFetcher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XfetchClient $xfetch,
|
||||
private readonly string $nodeBin = 'node',
|
||||
private readonly string $renderScript = 'scripts/render-page.cjs',
|
||||
private readonly int $renderTimeoutSec = 90,
|
||||
) {}
|
||||
|
||||
public function html(string $url): string
|
||||
{
|
||||
if (str_contains($url, '2gis.ru')) {
|
||||
return $this->xfetch->html($url);
|
||||
}
|
||||
|
||||
if (str_contains($url, 'yandex.')) {
|
||||
$html = $this->xfetch->html($url);
|
||||
|
||||
return $html !== '' ? $html : $this->renderLocally($url);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Локальный Playwright-рендер (бесплатный запас для Яндекса). */
|
||||
private function renderLocally(string $url): string
|
||||
{
|
||||
try {
|
||||
$process = new Process([$this->nodeBin, base_path($this->renderScript), $url]);
|
||||
$process->setTimeout($this->renderTimeoutSec);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode($process->getOutput(), true);
|
||||
|
||||
return is_array($decoded) && isset($decoded['html']) ? (string) $decoded['html'] : '';
|
||||
} catch (ProcessTimedOutException|\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
/**
|
||||
* Тонкая граница «достать HTML страницы» — за ней может стоять xfetch, локальный
|
||||
* Playwright и т.п. Позволяет тестировать обход справочников без сети.
|
||||
*/
|
||||
interface PageFetcher
|
||||
{
|
||||
/** Вернуть HTML страницы (пустую строку при неудаче — не кидаем исключений). */
|
||||
public function html(string $url): string;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use Illuminate\Http\Client\Pool;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Загрузка страниц через сервис xfetch.ru (обход антибота 2ГИС/Яндекс/Cloudflare).
|
||||
* POST {url, api_key, render, timeout} → {response_body_base64}. Ключ — из конфига,
|
||||
* НИКОГДА не в коде/гите. Без ключа клиент молча возвращает пусто (не падает).
|
||||
*
|
||||
* Пакетная загрузка {@see htmlBatch()} режет список на порции по $concurrency и параллелит
|
||||
* внутри порции (эмпирический предел xf4.ru — ~4 одновременных; выше идут 429 Too Many Requests).
|
||||
*/
|
||||
final class XfetchClient implements BatchPageFetcher
|
||||
{
|
||||
public function __construct(
|
||||
private ?string $apiKey,
|
||||
private string $endpoint = 'https://xf4.ru/fetch',
|
||||
private bool $render = true,
|
||||
private int $renderTimeout = 20,
|
||||
private int $httpTimeout = 120,
|
||||
private int $retries = 3,
|
||||
private int $concurrency = 4,
|
||||
) {}
|
||||
|
||||
public function html(string $url): string
|
||||
{
|
||||
if ($this->apiKey === null || $this->apiKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Рендер 2ГИС/Яндекс флакует: иногда возвращается пустой/ошибочный ответ.
|
||||
// Повторяем до $retries раз — берём первый непустой результат.
|
||||
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
|
||||
$html = $this->decode($this->request($url));
|
||||
if ($html !== '') {
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пачкой (параллельно, порциями по $concurrency). Карта url => html; пустая строка при неудаче.
|
||||
*
|
||||
* @param list<string> $urls
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function htmlBatch(array $urls): array
|
||||
{
|
||||
$urls = array_values(array_unique($urls));
|
||||
if ($this->apiKey === null || $this->apiKey === '' || $urls === []) {
|
||||
return array_fill_keys($urls, '');
|
||||
}
|
||||
|
||||
$result = array_fill_keys($urls, '');
|
||||
foreach (array_chunk($urls, max(1, $this->concurrency)) as $chunk) {
|
||||
$this->fetchChunkInto($chunk, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Догружает порцию параллельно, ретраит только оставшиеся пустыми (флак/429) до $retries раз.
|
||||
*
|
||||
* @param list<string> $chunk
|
||||
* @param array<string, string> $result
|
||||
*/
|
||||
private function fetchChunkInto(array $chunk, array &$result): void
|
||||
{
|
||||
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
|
||||
$pending = array_values(array_filter($chunk, fn (string $u): bool => $result[$u] === ''));
|
||||
if ($pending === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$responses = Http::pool(fn (Pool $pool) => array_map(
|
||||
fn (string $u) => $pool->as($u)->timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
|
||||
'url' => $u,
|
||||
'api_key' => $this->apiKey,
|
||||
'render' => $this->render,
|
||||
'timeout' => $this->renderTimeout,
|
||||
]),
|
||||
$pending,
|
||||
));
|
||||
|
||||
foreach ($pending as $u) {
|
||||
$result[$u] = $this->decode($responses[$u] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function request(string $url): Response|\Throwable|null
|
||||
{
|
||||
return Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
|
||||
'url' => $url,
|
||||
'api_key' => $this->apiKey,
|
||||
'render' => $this->render,
|
||||
'timeout' => $this->renderTimeout,
|
||||
]);
|
||||
}
|
||||
|
||||
private function decode(Response|\Throwable|null $resp): string
|
||||
{
|
||||
if (! $resp instanceof Response || ! $resp->successful()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$b64 = $resp->json('response_body_base64');
|
||||
if (! is_string($b64) || $b64 === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = base64_decode($b64, true);
|
||||
|
||||
return is_string($html) ? $html : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Fetch;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
|
||||
|
||||
/**
|
||||
* Загрузчик справочников (2ГИС/Яндекс.Карты) через {@see PageFetcher} (обычно xfetch).
|
||||
* Список филиалов → ссылки на карточки → телефон+адрес каждой карточки.
|
||||
* Парсинг — в {@see DirectoryParser}; добыча HTML — за антибот-границей PageFetcher.
|
||||
*/
|
||||
final class XfetchDirectoryFetcher implements Fetcher
|
||||
{
|
||||
public function __construct(
|
||||
private PageFetcher $pages,
|
||||
private DirectoryParser $parser = new DirectoryParser,
|
||||
private int $maxBranches = 25,
|
||||
private int $listRetries = 3,
|
||||
) {}
|
||||
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
return new FetchedSite(url: $url, rawHtml: $this->pages->html($url));
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
// Рендер списка филиалов 2ГИС флакует: иногда отдаётся «оболочка» БЕЗ ссылок на
|
||||
// карточки. Повторяем загрузку списка, пока ссылки не появятся (до $listRetries раз).
|
||||
$links = [];
|
||||
for ($attempt = 1; $attempt <= max(1, $this->listRetries); $attempt++) {
|
||||
$listHtml = $this->pages->html($url);
|
||||
$links = $this->parser->parseBranchList($listHtml);
|
||||
if ($links !== []) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($links === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$origin = parse_url($url);
|
||||
$base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? '');
|
||||
$source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС';
|
||||
|
||||
// Абсолютные ссылки на карточки филиалов (до лимита).
|
||||
$firmUrls = array_map(
|
||||
fn (string $href): string => str_starts_with($href, 'http') ? $href : $base.$href,
|
||||
array_slice($links, 0, $this->maxBranches),
|
||||
);
|
||||
|
||||
// Пакетно (параллельно) — если загрузчик умеет; иначе по одной. Карточек до 25, каждый
|
||||
// рендер — секунды, поэтому последовательный обход был узким местом (спека §12.2).
|
||||
if ($this->pages instanceof BatchPageFetcher) {
|
||||
$htmls = $this->pages->htmlBatch($firmUrls);
|
||||
} else {
|
||||
$htmls = [];
|
||||
foreach ($firmUrls as $firmUrl) {
|
||||
$htmls[$firmUrl] = $this->pages->html($firmUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
foreach ($firmUrls as $firmUrl) {
|
||||
$firmHtml = $htmls[$firmUrl] ?? '';
|
||||
if ($firmHtml === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->parser->parseFirmCard($firmHtml, $firmUrl, $source) as $card) {
|
||||
$cards[] = $card;
|
||||
}
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
|
||||
/**
|
||||
* Сборка ядра шага 1 (§12.1, хвост движка v4): резолвленные кандидаты каналов (A/B/0) →
|
||||
* слияние+дедуп+вычет клиента (E, §12) → отсев агрегаторов (§12.6, ПОСЛЕ дедупа — по уникальным
|
||||
* фирмам) → отсев федералов (если не нужны) → похожесть-эмбеддинги (F, §12.5) → срез top-N →
|
||||
* DTO {@see FindCompetitorsResult} (§7.2).
|
||||
*
|
||||
* Это ЧИСТАЯ сборка: добыча страниц/имён (каналы) и резолв — выше по течению, за своими границами.
|
||||
* Поэтому всё ядро тестируется офлайн. Провайдер на боевой движок флипается отдельно (за флагом).
|
||||
*/
|
||||
final class FindCompetitorsAssembler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AggregatorFilter $aggregatorFilter,
|
||||
private readonly AutopodborDedup $dedup,
|
||||
private readonly EmbeddingRelevance $relevance,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array> $candidates резолвленные кандидаты (имя/сайт/описание/телефоны/ссылки/is_federal)
|
||||
* @param list<string> $clientExamples тексты-примеры клиента (для похожести)
|
||||
* @param list<string> $clientKeys НЕ используется (вычет себя убран, спека §7) — параметр оставлен для совместимости вызова
|
||||
*/
|
||||
public function assemble(
|
||||
array $candidates,
|
||||
array $clientExamples,
|
||||
array $clientKeys,
|
||||
bool $includeFederal,
|
||||
int $maxCompetitors,
|
||||
): FindCompetitorsResult {
|
||||
// Склейка/дедуп ПЕРВОЙ — иначе отсев агрегаторов классифицирует дубли-филиалы по многу раз
|
||||
// (реальный случай: gpt-4o-mini залетело 227 позиций с повторами вместо ~90 уникальных).
|
||||
// «Вычет себя» УБРАН (спека §7): свой сайт/пример-конкурент клиент вправе вернуть и поставить
|
||||
// на прослушку — поэтому clientKeys в склейку НЕ передаём (параметр оставлен для совместимости).
|
||||
$merged = $this->dedup->mergeCompetitors($candidates);
|
||||
$filtered = $this->aggregatorFilter->filter($merged);
|
||||
|
||||
if (! $includeFederal) {
|
||||
$filtered = array_values(array_filter($filtered, fn (array $c): bool => empty($c['is_federal'])));
|
||||
}
|
||||
|
||||
$ranked = $this->relevance->rank($clientExamples, $filtered);
|
||||
|
||||
if ($maxCompetitors > 0) {
|
||||
$ranked = array_slice($ranked, 0, $maxCompetitors);
|
||||
}
|
||||
|
||||
return new FindCompetitorsResult(array_map(fn (array $c): array => $this->toCompetitor($c), $ranked));
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка ядра → конкурент §7.2 {name, description, is_federal, relevance_pct, site_url, directory_urls, provenance}.
|
||||
*/
|
||||
private function toCompetitor(array $c): array
|
||||
{
|
||||
return [
|
||||
'name' => (string) ($c['name'] ?? ''),
|
||||
'description' => $c['description'] ?? null,
|
||||
'is_federal' => (bool) ($c['is_federal'] ?? false),
|
||||
'relevance_pct' => $c['relevance_pct'] ?? null,
|
||||
'site_url' => $c['site_url'] ?? null,
|
||||
'directory_urls' => $c['directory_urls'] ?? [],
|
||||
'provenance' => $c['provenance'] ?? ['via' => 'engine'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer;
|
||||
use App\Services\Autopodbor\Agent\ChannelA\YandexDirectory;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
|
||||
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Support\RegionCity;
|
||||
|
||||
/**
|
||||
* Живой findCompetitors — ФИНАЛ v4, ЛЁГКАЯ версия шага 1 (ZAFIKSIROVANO: нужны ТОЛЬКО имя+сайт+карточка;
|
||||
* телефоны/описание/глубокий сбор = шаг 2). БЕЗ захода в карточки и без per-name резолва — резко быстрее.
|
||||
* 1. Шаг АНАЛИЗ ({@see QueryAnalyzer}): описание → запросы-рубрики.
|
||||
* 2. КАНАЛ А ({@see CategoryScraper}): скрейп категории 2ГИС с пагинацией → из СПИСКА имя+карточка+сайт
|
||||
* (местные фирмы, is_federal=false).
|
||||
* 3. КАНАЛ В ({@see ChannelBSearch}): одна модель × 2 прохода → ИМЕНА федералов (стоп-лист = имена из А +
|
||||
* примеры); сайт каждого — через EXA ({@see ExaSiteFinder}). Без сайта (нет якоря) — выкидываем.
|
||||
* 4. Слияние А+В → {@see FindCompetitorsAssembler}: отсев агрегаторов → дедуп+вычет клиента →
|
||||
* федерал-фильтр → похожесть-эмбеддинги (математически) → DTO §7.2.
|
||||
*/
|
||||
final class LiveFindCompetitors
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QueryAnalyzer $analyzer,
|
||||
private readonly CategoryScraper $scraper,
|
||||
private readonly YandexDirectory $yandex,
|
||||
private readonly ChannelBSearch $channelB,
|
||||
private readonly ExaSiteFinder $exa,
|
||||
private readonly FindCompetitorsAssembler $assembler,
|
||||
// Потолок имён канала В на проверку сайта EXA (ограничивает живые exa-вызовы за подбор).
|
||||
private readonly int $channelBCap = 40,
|
||||
private readonly int $passes = 2,
|
||||
) {}
|
||||
|
||||
public function find(FindCompetitorsRequest $r): FindCompetitorsResult
|
||||
{
|
||||
$profile = $this->profile($r->aboutSelf);
|
||||
$clientSite = $this->clientSite($r->aboutSelf);
|
||||
$city = RegionCity::name($r->regionCode) ?? '';
|
||||
$slug = RegionCity::slug($r->regionCode);
|
||||
|
||||
// 1+2. АНАЛИЗ → канал А, ДВА справочника: 2ГИС (xfetch, имя+карточка+сайт из списка) +
|
||||
// Яндекс (локальный Playwright, имя+карточка, без сайта). Слияние ниже объединит дубли по имени.
|
||||
$queries = $this->analyzer->analyze($profile, $city);
|
||||
$twoGisRows = ($slug !== null && $queries !== [])
|
||||
? $this->scraper->collectTwoGis($slug, $queries)
|
||||
: [];
|
||||
$yandexRows = ($queries !== [])
|
||||
? $this->yandex->collect($city, $queries)
|
||||
: [];
|
||||
$aCards = array_merge(
|
||||
array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], $row['site'] ?? null), $twoGisRows),
|
||||
array_map(fn (array $row): array => $this->localCard(
|
||||
$row['name'],
|
||||
$row['card_url'],
|
||||
null,
|
||||
isset($row['slug']) && $row['slug'] !== '' ? 'ya:'.$row['slug'] : null,
|
||||
$row['description'] ?? null,
|
||||
), $yandexRows),
|
||||
);
|
||||
|
||||
// 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем.
|
||||
$bCards = [];
|
||||
if ($r->includeFederal) {
|
||||
$known = array_merge(
|
||||
array_map(static fn (array $c): string => $c['name'], $aCards),
|
||||
$this->stringList($r->examples),
|
||||
);
|
||||
$names = $this->channelB->harvest($profile, $city, $clientSite, $known, $this->passes);
|
||||
$capped = array_slice($names, 0, max(0, $this->channelBCap));
|
||||
// Сайты федералов добываем ПАРАЛЛЕЛЬНО пулом (не по одному в цикле) — см. ExaSiteFinder::findSites.
|
||||
$sites = $this->exa->findSites(array_map(static fn (array $c): string => (string) $c['name'], $capped), $city);
|
||||
foreach ($capped as $cand) {
|
||||
$site = $sites[trim((string) $cand['name'])] ?? null;
|
||||
if ($site !== null) {
|
||||
$bCards[] = $this->federalCard($cand['name'], $site);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_merge($aCards, $bCards);
|
||||
$clientKeys = $this->stringList($r->aboutSelf);
|
||||
$profileTexts = $this->clientProfileTexts($r);
|
||||
|
||||
return $this->assembler->assemble($candidates, $profileTexts, $clientKeys, $r->includeFederal, $r->maxCompetitors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null).
|
||||
* $dirKey — опознавательный код фирмы от справочника (напр. «ya:korund») для склейки по источнику.
|
||||
* $description — рубрика из списка («Ломбард, автоломбард») для похожести-эмбеддингов.
|
||||
*/
|
||||
private function localCard(string $name, string $cardUrl, ?string $site, ?string $dirKey = null, ?string $description = null): array
|
||||
{
|
||||
$source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list';
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'site_url' => $site,
|
||||
'description' => $description,
|
||||
'is_federal' => false,
|
||||
'directory_urls' => [$cardUrl],
|
||||
'directory_keys' => $dirKey !== null ? [$dirKey] : [],
|
||||
'phones' => [],
|
||||
'provenance' => ['via' => 'engine', 'source' => $source],
|
||||
];
|
||||
}
|
||||
|
||||
/** Федеральная карточка (канал В): имя + сайт из EXA, без карточки на регион. */
|
||||
private function federalCard(string $name, string $site): array
|
||||
{
|
||||
return [
|
||||
'name' => $name,
|
||||
'site_url' => $site,
|
||||
'description' => null,
|
||||
'is_federal' => true,
|
||||
'directory_urls' => [],
|
||||
'phones' => [],
|
||||
'provenance' => ['via' => 'engine', 'source' => 'channelB-exa'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Профиль (ниша) = первая непустая строка «о себе». */
|
||||
private function profile(array $aboutSelf): string
|
||||
{
|
||||
foreach ($aboutSelf as $v) {
|
||||
$v = trim((string) $v);
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Сайт клиента = первая «о себе»-строка, похожая на домен (есть точка, нет пробела). */
|
||||
private function clientSite(array $aboutSelf): string
|
||||
{
|
||||
foreach ($aboutSelf as $v) {
|
||||
$v = trim((string) $v);
|
||||
if ($v !== '' && $this->looksLikeDomain($v)) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Тексты клиента для ЦЕНТРА похожести (§12.5): описание ниши из «о себе» + примеры-конкуренты.
|
||||
* Собственный домен клиента (напр. lkomega.ru) в центр НЕ тащим — он строку-имя, а не смысл ниши,
|
||||
* и смещал бы центр (рычаг чистоты №1: раньше центр строился ТОЛЬКО из доменов-примеров и был «сбит»).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function clientProfileTexts(FindCompetitorsRequest $r): array
|
||||
{
|
||||
$niche = array_values(array_filter(
|
||||
$this->stringList($r->aboutSelf),
|
||||
fn (string $v): bool => ! $this->looksLikeDomain($v),
|
||||
));
|
||||
|
||||
return array_values(array_unique(array_merge($niche, $this->stringList($r->examples))));
|
||||
}
|
||||
|
||||
/** Похоже на домен: есть точка, нет пробела (например «lkomega.ru», но не «займы под залог»). */
|
||||
private function looksLikeDomain(string $v): bool
|
||||
{
|
||||
return ! str_contains($v, ' ') && str_contains($v, '.');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function stringList(array $items): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map(static fn ($v): string => trim((string) $v), $items),
|
||||
static fn (string $v): bool => $v !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Phone;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneType;
|
||||
use App\Services\DaData\DaDataException;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
|
||||
/**
|
||||
* Обогащение телефонов конкурента через DaData clean/phone (§12.4 движка v4): тип (городской/
|
||||
* мобильный/8-800), регион, годность (qc=0). Переиспользует ЖИВОЙ {@see DaDataPhoneClient}
|
||||
* (тот же, что резолв региона лида) — не дублирует клиент. Если DaData недоступна, подбор не
|
||||
* падает: тип берётся по префиксу номера ({@see PhoneType}), регион пуст, номер помечается негодным.
|
||||
*
|
||||
* Берём ТОЛЬКО опубликованные фирмой номера (§12.4) — здесь они не синтезируются, только классифицируются.
|
||||
*/
|
||||
final class CompetitorPhoneEnricher
|
||||
{
|
||||
public function __construct(private readonly DaDataPhoneClient $client) {}
|
||||
|
||||
/**
|
||||
* @param list<string> $phones номера 7XXXXXXXXXX (из карточки конкурента)
|
||||
* @return list<array{phone:string,type:string,region:?string,valid:bool}>
|
||||
*/
|
||||
public function enrich(array $phones): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($phones as $phone) {
|
||||
try {
|
||||
$r = $this->client->cleanPhone($phone);
|
||||
$out[] = [
|
||||
'phone' => $phone,
|
||||
'type' => $this->slug($r->type, $phone),
|
||||
'region' => $r->region,
|
||||
'valid' => $r->qc === 0,
|
||||
];
|
||||
} catch (DaDataException) {
|
||||
// DaData недоступна/ошибка — деградируем, не роняя весь подбор.
|
||||
$out[] = [
|
||||
'phone' => $phone,
|
||||
'type' => PhoneType::of($phone),
|
||||
'region' => null,
|
||||
'valid' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Тип DaData (рус.) → slug city/mobile/tollfree; неизвестный — по префиксу номера. */
|
||||
private function slug(?string $type, string $phone): string
|
||||
{
|
||||
$t = mb_strtolower(trim((string) $type));
|
||||
if ($t === '') {
|
||||
return PhoneType::of($phone);
|
||||
}
|
||||
if (str_contains($t, 'мобильн')) {
|
||||
return 'mobile';
|
||||
}
|
||||
if (str_contains($t, 'стационар') || str_contains($t, 'городск')) {
|
||||
return 'city';
|
||||
}
|
||||
if (str_contains($t, 'бесплатн') || str_contains($t, 'колл')) {
|
||||
return 'tollfree';
|
||||
}
|
||||
|
||||
return PhoneType::of($phone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\CollectedSource;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Support\RegionAreaCode;
|
||||
|
||||
final class RealCompetitorAgent implements CompetitorAgent
|
||||
{
|
||||
public function __construct(
|
||||
private Fetcher $fetcher,
|
||||
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
|
||||
private CandidateBuilder $builder = new CandidateBuilder,
|
||||
private SourceAggregator $aggregator = new SourceAggregator,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
|
||||
) {}
|
||||
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
|
||||
{
|
||||
// Подключён живой движок поиска — используем его; иначе заглушка (демо-данные).
|
||||
return $this->liveFind?->find($r) ?? $this->fallback->findCompetitors($r);
|
||||
}
|
||||
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
|
||||
{
|
||||
return $this->fallback->resolveByName($r);
|
||||
}
|
||||
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
|
||||
{
|
||||
$competitor = $r->competitor;
|
||||
$siteUrl = $competitor['site_url'] ?? null;
|
||||
$directoryUrls = $competitor['directory_urls'] ?? [];
|
||||
|
||||
// 1. Грузим сайт(ы) и карточки справочников
|
||||
$sites = [];
|
||||
if (is_string($siteUrl) && $siteUrl !== '') {
|
||||
$sites[] = $this->fetcher->site($this->ensureScheme($siteUrl));
|
||||
}
|
||||
$cards = [];
|
||||
foreach ($directoryUrls as $du) {
|
||||
foreach ($this->fetcher->directory($du) as $card) {
|
||||
$cards[] = $card;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Размечаем кандидатов и сводим ядром. Запасной код города — по региону
|
||||
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
|
||||
$areaCode = RegionAreaCode::forSubject($r->regionCode);
|
||||
$candidates = $this->builder->build($sites, $cards, $areaCode);
|
||||
$collected = $this->aggregator->aggregate($candidates);
|
||||
|
||||
// 3. Маппинг в существующий контракт
|
||||
$rows = [];
|
||||
|
||||
// 3a. Сайты: сайт конкурента + сайты из карточек
|
||||
$siteIds = [];
|
||||
if (is_string($siteUrl) && $siteUrl !== '') {
|
||||
$siteIds[$this->norm->domainHead($siteUrl)] = ['url' => $this->ensureScheme($siteUrl), 'label' => 'сайт конкурента'];
|
||||
}
|
||||
foreach ($cards as $card) {
|
||||
if ($card->siteUrl !== null && $card->siteUrl !== '') {
|
||||
$siteIds[$this->norm->domainHead($card->siteUrl)] ??= ['url' => $card->url, 'label' => $card->source.' — сайт в карточке'];
|
||||
}
|
||||
}
|
||||
foreach ($siteIds as $domain => $meta) {
|
||||
$rows[] = [
|
||||
'signal_type' => 'site',
|
||||
'identifier' => $domain,
|
||||
'phone_kind' => null,
|
||||
'phone_type' => null,
|
||||
'provenance_url' => $meta['url'],
|
||||
'provenance_label' => $meta['label'],
|
||||
'where_found' => [['label' => $meta['label'], 'url' => $meta['url']]],
|
||||
'office' => null,
|
||||
'confirmations' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
// 3b. Телефоны
|
||||
foreach ($collected as $c) {
|
||||
$rows[] = $this->callRow($c);
|
||||
}
|
||||
|
||||
return new StudyCompetitorResult($rows);
|
||||
}
|
||||
|
||||
/** @return array{signal_type:string,identifier:string,phone_kind:?string,phone_type:?string,provenance_url:?string,provenance_label:?string,where_found:array<int,array{label:string,url:?string}>,office:?string,confirmations:int} */
|
||||
private function callRow(CollectedSource $c): array
|
||||
{
|
||||
$top = $c->sources[0] ?? ['label' => null, 'url' => null];
|
||||
|
||||
return [
|
||||
'signal_type' => 'call',
|
||||
'identifier' => $c->identifier,
|
||||
'phone_kind' => $c->phoneKind,
|
||||
'phone_type' => $c->phoneType,
|
||||
'provenance_url' => $top['url'] ?? null,
|
||||
'provenance_label' => $top['label'] ?? null,
|
||||
'where_found' => $c->sources,
|
||||
'office' => $c->office,
|
||||
'confirmations' => $c->confirmations(),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureScheme(string $url): string
|
||||
{
|
||||
return preg_match('#^[a-z]+://#i', $url) ? $url : 'https://'.$url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
|
||||
|
||||
/**
|
||||
* Единый резолвер имени конкурента в настоящую карточку справочника (§12.3 движка v4).
|
||||
* Порядок: 2ГИС-в-городе (по прямой ссылке из канала А) → иначе Яндекс (поиск по имени+городе
|
||||
* → первый org → проверка имя/город) → иначе local=false. `is_federal` ПО ФАКТУ: нет местной
|
||||
* карточки + есть сайт = федерал; есть карточка = местный (модели не верим, §12.3).
|
||||
*
|
||||
* Транспорт — за {@see PageFetcher} (html('') при неудаче), поэтому вся логика тестируема офлайн.
|
||||
*/
|
||||
final class CompetitorResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PageFetcher $pages,
|
||||
private readonly TwoGisResolver $twoGis = new TwoGisResolver,
|
||||
private readonly YandexResolver $yandex = new YandexResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $name имя конкурента (из канала 0/А/В)
|
||||
* @param ?string $twoGisUrl прямая ссылка карточки 2ГИС firm/<id> из канала А, если есть
|
||||
* @param string $city город клиента (регион поиска)
|
||||
* @param ?string $knownSite сайт кандидата, если известен из канала (для пометки федерала)
|
||||
*/
|
||||
public function resolve(string $name, ?string $twoGisUrl, string $city, ?string $knownSite = null): ResolvedCompetitor
|
||||
{
|
||||
// 1) 2ГИС по прямой ссылке из канала А — самый чистый сигнал.
|
||||
if ($twoGisUrl !== null && $twoGisUrl !== '') {
|
||||
$card = $this->twoGis->parse($this->pages->html($twoGisUrl), $twoGisUrl);
|
||||
if ($card !== null && $this->inCity($card->region, $city)) {
|
||||
return $card; // местная карточка 2ГИС
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Яндекс: поиск по имени+городе → первый org → проверка имя/город внутри YandexResolver.
|
||||
$ya = $this->resolveYandex($name, $city);
|
||||
if ($ya !== null) {
|
||||
return $ya;
|
||||
}
|
||||
|
||||
// 3) Местной карточки нет. Есть сайт → федерал/онлайн; иначе — «нет филиала в регионе».
|
||||
if ($knownSite !== null && $knownSite !== '') {
|
||||
return new ResolvedCompetitor(name: $name, siteUrl: $knownSite, region: $city, isFederal: true);
|
||||
}
|
||||
|
||||
return new ResolvedCompetitor(name: $name, region: $city, isFederal: false);
|
||||
}
|
||||
|
||||
/** Поиск в Яндекс.Картах по «имя город» → первый org → проверка имя/город в YandexResolver. */
|
||||
private function resolveYandex(string $name, string $city): ?ResolvedCompetitor
|
||||
{
|
||||
$searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode($name.' '.$city);
|
||||
$searchHtml = $this->pages->html($searchUrl);
|
||||
if (! preg_match('#/maps/org/[a-z0-9_-]+/\d+#i', $searchHtml, $m)) {
|
||||
return null; // в выдаче нет ни одной организации
|
||||
}
|
||||
$orgUrl = 'https://yandex.ru'.$m[0];
|
||||
|
||||
return $this->yandex->parse($this->pages->html($orgUrl), $orgUrl, $name, $city);
|
||||
}
|
||||
|
||||
/** Карточка считается местной, если её город совпал с городом/регионом клиента. */
|
||||
private function inCity(?string $cardCity, string $city): bool
|
||||
{
|
||||
return $cardCity !== null && DirectoryFields::localeMatches($cardCity, $city);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
/**
|
||||
* Общие извлекатели полей из карточек справочников (2ГИС/Яндекс) — то, что у резолверов
|
||||
* совпадает байт-в-байт: имя+город из <title> и телефоны из contact_groups/phones.
|
||||
* Чистые статические функции, без состояния.
|
||||
*/
|
||||
final class DirectoryFields
|
||||
{
|
||||
/**
|
||||
* Имя (первый сегмент) и город (последний сегмент) из <title> карточки.
|
||||
* Заголовок справочников: «Имя, …адрес/рубрика…, Город — 2ГИС|Яндекс Карты».
|
||||
*
|
||||
* @return array{0: ?string, 1: ?string} [имя, город]; имя=null если title не похож на карточку
|
||||
*/
|
||||
public static function nameAndCity(string $html): array
|
||||
{
|
||||
$parts = self::titleParts($html);
|
||||
if (count($parts) < 2) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [$parts[0], $parts[count($parts) - 1]];
|
||||
}
|
||||
|
||||
/** Имя фирмы — первый сегмент <title> (для справочников, где город в заголовке не на фикс. месте). */
|
||||
public static function titleName(string $html): ?string
|
||||
{
|
||||
$parts = self::titleParts($html);
|
||||
|
||||
return $parts === [] ? null : $parts[0];
|
||||
}
|
||||
|
||||
/** Содержит ли заголовок карточки указанный город — устойчиво к позиции города в заголовке. */
|
||||
public static function titleHasCity(string $html, string $city): bool
|
||||
{
|
||||
$city = mb_strtolower(trim($city));
|
||||
if ($city === '') {
|
||||
return false;
|
||||
}
|
||||
$title = mb_strtolower(implode(', ', self::titleParts($html)));
|
||||
|
||||
return $title !== '' && self::localeMatches($title, $city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сегменты <title> карточки: декодированы, схлопнуты пробелы, отрезан хвост « — 2ГИС/Яндекс».
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function titleParts(string $html): array
|
||||
{
|
||||
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
|
||||
return [];
|
||||
}
|
||||
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
|
||||
$title = trim($title);
|
||||
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
|
||||
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
|
||||
|
||||
return array_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Телефоны из встроенного JSON карточки (объекты type=phone, поле value),
|
||||
* нормализованы к 7XXXXXXXXXX (8→7, без +/скобок/пробелов).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function phones(string $html): array
|
||||
{
|
||||
$out = [];
|
||||
if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) {
|
||||
foreach ($m[1] as $raw) {
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
if (strlen($digits) === 11 && $digits[0] === '8') {
|
||||
$digits = '7'.substr($digits, 1);
|
||||
}
|
||||
if ($digits !== '' && ! in_array($digits, $out, true)) {
|
||||
$out[] = $digits;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Свободное сравнение локаций (город ↔ субъект): без регистра, по вхождению в любую сторону.
|
||||
* Так город карточки «Красноярск» совпадает с регионом «Красноярский край» (ручной резолв
|
||||
* по region_code), но «Красноярск» НЕ совпадает с «Москва». Консервативно: при сомнении — мимо.
|
||||
*/
|
||||
public static function localeMatches(string $a, string $b): bool
|
||||
{
|
||||
$a = mb_strtolower(trim($a));
|
||||
$b = mb_strtolower(trim($b));
|
||||
|
||||
return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
/**
|
||||
* Результат резолва одного имени конкурента в настоящую карточку справочника (§12.3).
|
||||
* Если местной карточки нет (ни 2ГИС-в-городе, ни Яндекс-совпал) — directoryUrl/source = null,
|
||||
* {@see isLocal()} = false (UI «нет филиала в регионе»).
|
||||
*
|
||||
* ИНН-полей НЕТ намеренно (решение владельца 29.06): карточка = имя/сайт/телефоны/справочник.
|
||||
*/
|
||||
final class ResolvedCompetitor
|
||||
{
|
||||
/** @param list<string> $phones номера в формате 7XXXXXXXXXX */
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly ?string $siteUrl = null,
|
||||
public readonly array $phones = [],
|
||||
public readonly ?string $directoryUrl = null, // прямая ссылка 2ГИС firm/<id> или Яндекс org/<seo>/<id>
|
||||
public readonly ?string $source = null, // 2ГИС | Яндекс.Карты | null
|
||||
public readonly ?string $region = null, // город из адреса карточки
|
||||
public readonly ?string $description = null, // рубрики/категории — для эмбеддинг-похожести
|
||||
public readonly bool $isFederal = false, // нет местной карточки + есть сайт = федерал/онлайн
|
||||
) {}
|
||||
|
||||
/** Есть ли настоящая местная карточка в справочнике (иначе — «нет филиала в регионе»). */
|
||||
public function isLocal(): bool
|
||||
{
|
||||
return $this->directoryUrl !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Support\RegionCity;
|
||||
|
||||
/**
|
||||
* Ручной резолв по названию (контракт §7.2 `resolveByName`): имя + регион → кандидат(ы)
|
||||
* из настоящей карточки справочника через {@see CompetitorResolver}. Манульный путь не несёт
|
||||
* прямой ссылки 2ГИС (её даёт канал А), поэтому идёт через поиск Яндекса по «имя + регион»;
|
||||
* если местной карточки нет — честный кандидат-заглушка «нет филиала в регионе».
|
||||
*/
|
||||
final class ResolvingAgent
|
||||
{
|
||||
public function __construct(private readonly CompetitorResolver $resolver) {}
|
||||
|
||||
public function resolve(ResolveByNameRequest $r): ResolveByNameResult
|
||||
{
|
||||
// Город центра субъекта (для поиска/проверки карточки); запасной — имя субъекта.
|
||||
$city = RegionCity::name($r->regionCode) ?? '';
|
||||
$card = $this->resolver->resolve($r->name, twoGisUrl: null, city: $city);
|
||||
|
||||
return new ResolveByNameResult([$this->toCandidate($card)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка резолвера → кандидат §7.2 {name, description, site_url, directory_urls[], provenance}.
|
||||
*
|
||||
* @return array{name:string,description:?string,site_url:?string,directory_urls:list<string>,is_federal:bool,provenance:array{via:string,source:?string}}
|
||||
*/
|
||||
private function toCandidate(ResolvedCompetitor $c): array
|
||||
{
|
||||
return [
|
||||
'name' => $c->name,
|
||||
'description' => $c->description,
|
||||
'site_url' => $c->siteUrl,
|
||||
'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [],
|
||||
'is_federal' => $c->isFederal,
|
||||
'provenance' => ['via' => 'name-search', 'source' => $c->source],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
/**
|
||||
* Разбор карточки филиала 2ГИС (/<город>/firm/<id>) в {@see ResolvedCompetitor}.
|
||||
* Чистый: на вход — уже отрендеренный HTML карточки, на выход — карточка конкурента
|
||||
* (имя/сайт/телефоны/город/рубрики) либо null, если данных карточки нет (пустая «оболочка» 2ГИС).
|
||||
*
|
||||
* Источники полей в HTML 2ГИС:
|
||||
* - имя/город — <title> вида «Имя, адрес…, Город — 2ГИС» (первый/последний сегмент);
|
||||
* - сайт — contact_groups, объект type=website, поле url (ЧИСТЫЙ адрес, НЕ редирект link.2gis в value);
|
||||
* - телефоны — contact_groups, объекты type=phone, поле value (нормализуем к 7XXXXXXXXXX);
|
||||
* - описание — rubrics[].name (для эмбеддинг-похожести на следующих под-блоках).
|
||||
*/
|
||||
final class TwoGisResolver
|
||||
{
|
||||
public function parse(string $html, string $url): ?ResolvedCompetitor
|
||||
{
|
||||
[$name, $city] = DirectoryFields::nameAndCity($html);
|
||||
if ($name === null) {
|
||||
return null; // нет имени фирмы в title — это не карточка филиала
|
||||
}
|
||||
|
||||
return new ResolvedCompetitor(
|
||||
name: $name,
|
||||
siteUrl: $this->website($html),
|
||||
phones: DirectoryFields::phones($html),
|
||||
directoryUrl: $this->cleanUrl($url),
|
||||
source: '2ГИС',
|
||||
region: $city,
|
||||
description: $this->description($html),
|
||||
isFederal: false, // найдена местная карточка справочника
|
||||
);
|
||||
}
|
||||
|
||||
/** Чистый сайт из contact_groups (url перед type=website), а НЕ редирект link.2gis из value. */
|
||||
private function website(string $html): ?string
|
||||
{
|
||||
if (preg_match('/"url":"(https?:\/\/[^"]+)"[^{}]*?"type":"website"/i', $html, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Описание = названия рубрик карточки (rubrics[].name). */
|
||||
private function description(string $html): ?string
|
||||
{
|
||||
if (! preg_match('/"rubrics":\s*\[(.*?)\]/s', $html, $rm)) {
|
||||
return null;
|
||||
}
|
||||
if (! preg_match_all('/"name":"([^"]+)"/', $rm[1], $nm)) {
|
||||
return null;
|
||||
}
|
||||
$names = [];
|
||||
foreach ($nm[1] as $n) {
|
||||
$n = trim($n);
|
||||
if ($n !== '' && ! in_array($n, $names, true)) {
|
||||
$names[] = $n;
|
||||
}
|
||||
}
|
||||
|
||||
return $names === [] ? null : implode(', ', $names);
|
||||
}
|
||||
|
||||
/** Прямая ссылка карточки без хвоста ?stat=… (2ГИС дописывает к firm/<id>). */
|
||||
private function cleanUrl(string $url): string
|
||||
{
|
||||
$q = strpos($url, '?');
|
||||
|
||||
return $q === false ? $url : substr($url, 0, $q);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Resolve;
|
||||
|
||||
/**
|
||||
* Разбор карточки организации Яндекс.Карт (/maps/org/<seo>/<id>) в {@see ResolvedCompetitor}.
|
||||
* Чистый: на вход — отрендеренный HTML карточки + ожидаемое имя и город (для отбраковки
|
||||
* чужой фирмы того же имени из другого города/профиля). Возвращает null, если это не наша
|
||||
* карточка (имя/город не совпали) или данных карточки нет.
|
||||
*
|
||||
* Источники полей:
|
||||
* - имя/город — <title> «Имя, рубрика, адрес…, Город — Яндекс Карты» (первый/последний сегмент);
|
||||
* - сайт — business-urls, тег itemprop="url" (ЧИСТЫЙ адрес, без utm-хвоста action-кнопки);
|
||||
* - телефоны — phones[].value (нормализуем к 7XXXXXXXXXX);
|
||||
* - описание — categories[].name.
|
||||
*/
|
||||
final class YandexResolver
|
||||
{
|
||||
public function parse(string $html, string $url, string $expectName, string $city): ?ResolvedCompetitor
|
||||
{
|
||||
$name = DirectoryFields::titleName($html);
|
||||
if ($name === null) {
|
||||
return null; // не карточка организации
|
||||
}
|
||||
// Отбраковка чужой фирмы того же имени: имя должно совпасть, и заголовок карточки
|
||||
// должен содержать ожидаемый город. Город у Яндекса в заголовке НЕ на фикс. месте
|
||||
// («Имя, рубрика, Город, улица, дом»), поэтому проверяем по наличию, а не по сегменту.
|
||||
if (! $this->namesMatch($name, $expectName)) {
|
||||
return null;
|
||||
}
|
||||
if (! DirectoryFields::titleHasCity($html, $city)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ResolvedCompetitor(
|
||||
name: $name,
|
||||
siteUrl: $this->website($html),
|
||||
phones: DirectoryFields::phones($html),
|
||||
directoryUrl: $this->cleanUrl($url),
|
||||
source: 'Яндекс.Карты',
|
||||
region: $city, // искомый город (подтверждён в заголовке карточки)
|
||||
description: $this->description($html),
|
||||
isFederal: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function namesMatch(string $cardName, string $expect): bool
|
||||
{
|
||||
$a = mb_strtolower(trim($cardName));
|
||||
$b = mb_strtolower(trim($expect));
|
||||
|
||||
return $a !== '' && $b !== '' && (str_contains($a, $b) || str_contains($b, $a));
|
||||
}
|
||||
|
||||
/** Чистый сайт из business-urls (itemprop=url), без utm-хвоста action-кнопки. */
|
||||
private function website(string $html): ?string
|
||||
{
|
||||
if (preg_match('/itemprop="url"[^>]*href="(https?:\/\/[^"?]+)/i', $html, $m)) {
|
||||
return rtrim($m[1], '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Описание = названия рубрик карточки (categories[].name). */
|
||||
private function description(string $html): ?string
|
||||
{
|
||||
if (! preg_match('/"categories":\s*\[(.*?)\]/s', $html, $cm)) {
|
||||
return null;
|
||||
}
|
||||
if (! preg_match_all('/"name":"([^"]+)"/', $cm[1], $nm)) {
|
||||
return null;
|
||||
}
|
||||
$names = [];
|
||||
foreach ($nm[1] as $n) {
|
||||
$n = trim($n);
|
||||
if ($n !== '' && ! in_array($n, $names, true)) {
|
||||
$names[] = $n;
|
||||
}
|
||||
}
|
||||
|
||||
return $names === [] ? null : implode(', ', $names);
|
||||
}
|
||||
|
||||
/** Прямая ссылка /maps/org/<seo>/<id> без хвоста ?ll=… и завершающего слеша. */
|
||||
private function cleanUrl(string $url): string
|
||||
{
|
||||
$q = strpos($url, '?');
|
||||
if ($q !== false) {
|
||||
$url = substr($url, 0, $q);
|
||||
}
|
||||
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Search;
|
||||
|
||||
/**
|
||||
* Разбор страниц ВЫДАЧИ справочников (канал А, §12.1): категория-поиск → список ссылок на
|
||||
* фирмы/организации + имя-подсказка. Имя — лишь подсказка; авторитетное имя/поля даёт резолвер
|
||||
* (под-блок A), который открывает каждую карточку. Чистый: на вход — отрендеренный HTML.
|
||||
*/
|
||||
final class SearchResultsParser
|
||||
{
|
||||
/**
|
||||
* Фирмы из выдачи 2ГИС: ссылка-путь /<город>/firm/<id> + имя из вложенного <span>.
|
||||
* Дедуп по пути (одна фирма — один раз).
|
||||
*
|
||||
* @return list<array{path:string,name:?string}>
|
||||
*/
|
||||
public function twoGis(string $html): array
|
||||
{
|
||||
$out = [];
|
||||
$seen = [];
|
||||
if (preg_match_all('#<a href="(/[a-z0-9_-]+/firm/\d+)"[^>]*>(.*?)</a>#is', $html, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $hit) {
|
||||
$path = $hit[1];
|
||||
if (isset($seen[$path])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$path] = true;
|
||||
$name = preg_match('#<span>([^<]{2,})</span>#u', $hit[2], $nm)
|
||||
? trim(html_entity_decode($nm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'))
|
||||
: null;
|
||||
$out[] = ['path' => $path, 'name' => $name !== '' ? $name : null];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Организации из выдачи Яндекс.Карт: прямая ссылка /maps/org/<seo>/<id> + имя из aria-label.
|
||||
* Дедуп по ссылке.
|
||||
*
|
||||
* @return list<array{url:string,name:string}>
|
||||
*/
|
||||
public function yandex(string $html): array
|
||||
{
|
||||
$out = [];
|
||||
$seen = [];
|
||||
if (preg_match_all('#class="link-overlay" href="(/maps/org/[a-z0-9_-]+/\d+)/?"[^>]*aria-label="([^"]+)"#i', $html, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $hit) {
|
||||
$url = $hit[1];
|
||||
if (isset($seen[$url])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$url] = true;
|
||||
$out[] = ['url' => $url, 'name' => trim(html_entity_decode($hit[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'))];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Similarity;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* Живой {@see Embedder} через AITUNNEL (OpenAI-совместимый, text-embedding-3-small, §12.5/§12.9).
|
||||
* POST {base}/embeddings {model, input:[...]} → {data:[{index, embedding:[...]}]}.
|
||||
* Ключ — из конфига (.env), НИКОГДА в коде/гите. Без ключа/при ошибке возвращает пустые векторы
|
||||
* (движок тогда даёт 0% похожести, но не падает).
|
||||
*/
|
||||
final class AitunnelEmbedder implements Embedder
|
||||
{
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function embed(array $texts): array
|
||||
{
|
||||
$texts = array_values($texts);
|
||||
if ($texts === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cfg = (array) config('services.aitunnel');
|
||||
$key = (string) ($cfg['key'] ?? '');
|
||||
$empty = array_map(static fn (): array => [], $texts);
|
||||
if ($key === '') {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = $this->http
|
||||
->withToken($key)
|
||||
->timeout((int) ($cfg['timeout_sec'] ?? 30))
|
||||
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/embeddings', [
|
||||
'model' => $cfg['embed_model'] ?? 'text-embedding-3-small',
|
||||
'input' => $texts,
|
||||
]);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$out = $empty;
|
||||
foreach ((array) $resp->json('data') as $row) {
|
||||
$i = $row['index'] ?? null;
|
||||
if (is_int($i) && isset($out[$i]) && is_array($row['embedding'] ?? null)) {
|
||||
$out[$i] = array_map('floatval', $row['embedding']);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
} catch (\Throwable) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Similarity;
|
||||
|
||||
/**
|
||||
* Тонкая граница «получить эмбеддинги текстов» — за ней живой AITUNNEL
|
||||
* (text-embedding-3-small, §12.5/§12.9). Позволяет считать похожесть офлайн на фикстурах.
|
||||
*/
|
||||
interface Embedder
|
||||
{
|
||||
/**
|
||||
* Векторные представления для каждого текста (порядок сохраняется).
|
||||
*
|
||||
* @param list<string> $texts
|
||||
* @return list<list<float>>
|
||||
*/
|
||||
public function embed(array $texts): array;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Similarity;
|
||||
|
||||
/**
|
||||
* Похожесть кандидата на профиль клиента ЭМБЕДДИНГАМИ, а не «мнением модели» (§12.5 движка v4).
|
||||
* Профиль клиента (примеры: имя+описание) → центроид; каждый кандидат (имя+описание) → косинус
|
||||
* к центроиду → relevance_pct [0..100]; сортировка по убыванию. Описание важно — иначе меряется
|
||||
* «красота имени», а не суть.
|
||||
*
|
||||
* Векторы берутся через {@see Embedder} (живой AITUNNEL за границей) — логика тестируема офлайн.
|
||||
*/
|
||||
final class EmbeddingRelevance
|
||||
{
|
||||
public function __construct(private readonly Embedder $embedder) {}
|
||||
|
||||
/**
|
||||
* @param list<string> $clientExamples тексты-примеры клиента (имя+описание)
|
||||
* @param array<int, array{name?:string,description?:?string}> $candidates
|
||||
* @return array<int, array> кандидаты с relevance_pct, отсортированы по убыванию
|
||||
*/
|
||||
public function rank(array $clientExamples, array $candidates): array
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$centroid = $clientExamples === []
|
||||
? []
|
||||
: $this->centroid($this->embedder->embed(array_values($clientExamples)));
|
||||
|
||||
$candTexts = array_map(fn (array $c): string => $this->text($c), $candidates);
|
||||
$candVecs = $this->embedder->embed(array_values($candTexts));
|
||||
|
||||
$scored = [];
|
||||
foreach (array_values($candidates) as $i => $c) {
|
||||
$cos = $centroid === [] ? 0.0 : $this->cosine($candVecs[$i], $centroid);
|
||||
$c['relevance_pct'] = (int) round(max(0.0, min(1.0, $cos)) * 100);
|
||||
$scored[] = $c;
|
||||
}
|
||||
|
||||
// стабильная сортировка по убыванию похожести (исходный порядок при равенстве)
|
||||
usort($scored, fn (array $a, array $b): int => $b['relevance_pct'] <=> $a['relevance_pct']);
|
||||
|
||||
return $scored;
|
||||
}
|
||||
|
||||
/** Текст кандидата для эмбеддинга: имя + описание. */
|
||||
private function text(array $c): string
|
||||
{
|
||||
return trim((string) ($c['name'] ?? '').' '.(string) ($c['description'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Покомпонентный центроид (среднее) набора векторов.
|
||||
*
|
||||
* @param list<list<float>> $vectors
|
||||
* @return list<float>
|
||||
*/
|
||||
private function centroid(array $vectors): array
|
||||
{
|
||||
$vectors = array_values(array_filter($vectors, fn (array $v): bool => $v !== []));
|
||||
if ($vectors === []) {
|
||||
return [];
|
||||
}
|
||||
$dim = count($vectors[0]);
|
||||
$sum = array_fill(0, $dim, 0.0);
|
||||
foreach ($vectors as $v) {
|
||||
for ($j = 0; $j < $dim; $j++) {
|
||||
$sum[$j] += (float) ($v[$j] ?? 0.0);
|
||||
}
|
||||
}
|
||||
$n = count($vectors);
|
||||
|
||||
return array_map(fn (float $x): float => $x / $n, $sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Косинусная близость двух векторов (0, если любой нулевой/пустой).
|
||||
*
|
||||
* @param list<float> $a
|
||||
* @param list<float> $b
|
||||
*/
|
||||
private function cosine(array $a, array $b): float
|
||||
{
|
||||
$dim = min(count($a), count($b));
|
||||
if ($dim === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
$dot = 0.0;
|
||||
$na = 0.0;
|
||||
$nb = 0.0;
|
||||
for ($i = 0; $i < $dim; $i++) {
|
||||
$x = (float) $a[$i];
|
||||
$y = (float) $b[$i];
|
||||
$dot += $x * $y;
|
||||
$na += $x * $x;
|
||||
$nb += $y * $y;
|
||||
}
|
||||
if ($na <= 0.0 || $nb <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $dot / (sqrt($na) * sqrt($nb));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сервис списания за прогон автоподбора конкурентов.
|
||||
*
|
||||
* Контракт:
|
||||
* - Списание только при готовом результате (by-success).
|
||||
* - Атомарное: весь flow в одной DB-транзакции.
|
||||
* - Идемпотентное: повторный вызов с тем же run не изменяет баланс
|
||||
* (guard по balance_transaction_id).
|
||||
* - bcmath: никаких float-арифметик.
|
||||
*
|
||||
* @throws InsufficientBalanceException если balance_rub < priceRub.
|
||||
* До throw баланс и транзакции не меняются.
|
||||
*/
|
||||
final class AutopodborChargeService
|
||||
{
|
||||
public function chargeForRun(AutopodborRun $run, string $priceRub): void
|
||||
{
|
||||
DB::transaction(function () use ($run, $priceRub): void {
|
||||
// Блокируем run первым — guard идемпотентности
|
||||
/** @var AutopodborRun $locked */
|
||||
$locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($locked->balance_transaction_id !== null) {
|
||||
// Уже списано — идемпотентный возврат без второго списания
|
||||
return;
|
||||
}
|
||||
|
||||
if (bccomp($priceRub, '0', 2) === 0) {
|
||||
// Бесплатный прогон — без ledger-строки; фиксируем факт нулевой стоимости.
|
||||
if ($locked->price_rub_charged === null) {
|
||||
$locked->price_rub_charged = '0.00';
|
||||
$locked->save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Блокируем tenant для атомарного изменения баланса
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
// bcmath: сравниваем с точностью 2 знака
|
||||
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($priceRub, '100', 0),
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
|
||||
|
||||
// Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal
|
||||
DB::table('tenants')
|
||||
->where('id', $tenant->id)
|
||||
->update(['balance_rub' => $newBalance]);
|
||||
|
||||
// Записываем транзакцию
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
|
||||
'amount_rub' => '-'.$priceRub,
|
||||
'amount_leads' => null,
|
||||
'balance_rub_after' => $newBalance,
|
||||
'balance_leads_after' => null,
|
||||
'related_type' => AutopodborRun::class,
|
||||
'related_id' => $locked->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Фиксируем на run идемпотентный маркер
|
||||
$locked->balance_transaction_id = $tx->id;
|
||||
$locked->price_rub_charged = $priceRub;
|
||||
$locked->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
final class AutopodborDedup
|
||||
{
|
||||
public function __construct(private AutopodborNormalizer $norm) {}
|
||||
|
||||
/**
|
||||
* Ищет существующий проект арендатора с тем же типом и нормализованным идентификатором.
|
||||
* Возвращает id найденного проекта или null.
|
||||
*/
|
||||
public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int
|
||||
{
|
||||
$needle = $signalType === 'call'
|
||||
? $this->norm->phone($identifier)
|
||||
: $this->norm->domainHead($identifier);
|
||||
|
||||
return Project::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('signal_type', $signalType)
|
||||
->where('signal_identifier', $needle)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация источников внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{signal_type: string, identifier: string}> $sources
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupSources(array $sources): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($sources as $s) {
|
||||
$key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$s['dedup_key'] = $key;
|
||||
$out[] = $s;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация конкурентов внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{name: string, site_url?: string|null}> $competitors
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupCompetitors(array $competitors): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($competitors as $c) {
|
||||
$key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$c['dedup_key'] = $key;
|
||||
$out[] = $c;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сильное слияние конкурентов из 3 каналов (§12 движка v4): union-find по ЛЮБОМУ общему
|
||||
* ключу — корню имени / корню домена / телефону. Так один конкурент под разными
|
||||
* написаниями, доменом-vs-именем или общим номером схлопывается в одну карточку.
|
||||
* Дополнительно вычитает самого клиента (его имя/сайт не должны попасть в конкуренты).
|
||||
*
|
||||
* Сильнее {@see dedupCompetitors} (одиночный ключ) — для финальной сборки findCompetitors.
|
||||
*
|
||||
* @param array<int, array{name?:string,site_url?:?string,description?:?string,is_federal?:bool,directory_urls?:array<int,string>,phones?:array<int,string>}> $candidates
|
||||
* @param list<string> $clientKeys сырые идентификаторы клиента (имя и/или сайт) — для вычета себя
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function mergeCompetitors(array $candidates, array $clientKeys = []): array
|
||||
{
|
||||
$candidates = array_values($candidates);
|
||||
$n = count($candidates);
|
||||
if ($n === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Словарь слов-категорий из рубрик ВСЕХ кандидатов — им стрижём имена («Яричъ Ломбард» → «Яричъ»).
|
||||
// Из данных прогона, не из зашитого списка — универсально для любой отрасли.
|
||||
$rubricVocab = $this->rubricVocab($candidates);
|
||||
|
||||
$keysOf = [];
|
||||
foreach ($candidates as $i => $c) {
|
||||
$keysOf[$i] = $this->candidateKeys($c, $rubricVocab);
|
||||
}
|
||||
|
||||
// union-find: общий ключ → одна группа
|
||||
$parent = range(0, $n - 1);
|
||||
$find = function (int $x) use (&$parent): int {
|
||||
while ($parent[$x] !== $x) {
|
||||
$parent[$x] = $parent[$parent[$x]];
|
||||
$x = $parent[$x];
|
||||
}
|
||||
|
||||
return $x;
|
||||
};
|
||||
$keyToIdx = [];
|
||||
foreach ($keysOf as $i => $keys) {
|
||||
foreach ($keys as $k) {
|
||||
if (isset($keyToIdx[$k])) {
|
||||
$a = $find($i);
|
||||
$b = $find($keyToIdx[$k]);
|
||||
if ($a !== $b) {
|
||||
$parent[$a] = $b;
|
||||
}
|
||||
} else {
|
||||
$keyToIdx[$k] = $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ключи клиента (для вычета самого себя)
|
||||
$client = [];
|
||||
foreach ($clientKeys as $ck) {
|
||||
foreach ($this->candidateKeys(['name' => $ck, 'site_url' => $ck], []) as $k) {
|
||||
$client[$k] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($candidates as $i => $c) {
|
||||
$groups[$find($i)][] = $i;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($groups as $members) {
|
||||
// если любая часть группы — это сам клиент, выкидываем всю группу
|
||||
$isClient = false;
|
||||
foreach ($members as $i) {
|
||||
foreach ($keysOf[$i] as $k) {
|
||||
if (isset($client[$k])) {
|
||||
$isClient = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($isClient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $this->mergeGroup(array_map(fn ($i) => $candidates[$i], $members));
|
||||
}
|
||||
|
||||
return array_values($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключи кандидата для union-find: корень имени, имя-минус-словарь-рубрик, корень домена,
|
||||
* телефоны, коды справочников. Всё под общим префиксом 'k:' (кроме телефонов 'p:').
|
||||
*
|
||||
* @param array{name?:string,site_url?:?string,description?:?string,phones?:array<int,string>,directory_keys?:array<int,string>} $c
|
||||
* @param list<string> $rubricVocab словарь слов-категорий из рубрик всех кандидатов
|
||||
* @return list<string>
|
||||
*/
|
||||
private function candidateKeys(array $c, array $rubricVocab = []): array
|
||||
{
|
||||
$keys = [];
|
||||
$name = isset($c['name']) ? (string) $c['name'] : '';
|
||||
if ($name !== '') {
|
||||
$nk = $this->norm->nameKey($name);
|
||||
if ($nk !== '') {
|
||||
$keys[] = 'k:'.$nk;
|
||||
}
|
||||
// Ключ имени без слов-категорий из словаря рубрик: «Яричъ Ломбард» сцепляется с «Яричъ».
|
||||
// Слово-категория — из данных прогона (словарь рубрик), не из зашитого списка ниш.
|
||||
$stripped = $this->norm->nameKeyMinusWords($name, $rubricVocab);
|
||||
if ($stripped !== '' && $stripped !== $nk) {
|
||||
$keys[] = 'k:'.$stripped;
|
||||
}
|
||||
}
|
||||
if (! empty($c['site_url'])) {
|
||||
$dr = $this->norm->domainRoot((string) $c['site_url']);
|
||||
if ($dr !== '') {
|
||||
$keys[] = 'k:'.$dr;
|
||||
}
|
||||
}
|
||||
foreach ($c['phones'] ?? [] as $p) {
|
||||
$pp = $this->norm->phone((string) $p);
|
||||
if ($pp !== '') {
|
||||
$keys[] = 'p:'.$pp;
|
||||
}
|
||||
}
|
||||
// Код фирмы от справочника (напр. slug Яндекса «ya:korund») — опознавательный признак
|
||||
// источника: склеивает разные написания одной фирмы, которые ключ имени не поймал.
|
||||
foreach ($c['directory_keys'] ?? [] as $dk) {
|
||||
$dk = trim((string) $dk);
|
||||
if ($dk !== '') {
|
||||
$keys[] = 'd:'.$dk;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Словарь слов-категорий из рубрик (описаний) ВСЕХ кандидатов — уникальные слова.
|
||||
* Из данных прогона, не из зашитого списка ниш (универсально для любой отрасли).
|
||||
*
|
||||
* @param array<int, array{description?:?string}> $candidates
|
||||
* @return list<string>
|
||||
*/
|
||||
private function rubricVocab(array $candidates): array
|
||||
{
|
||||
$vocab = [];
|
||||
foreach ($candidates as $c) {
|
||||
foreach ($this->norm->words((string) ($c['description'] ?? '')) as $w) {
|
||||
$vocab[$w] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($vocab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сливает группу совпавших кандидатов в одну карточку: имя/сайт/описание — первое непустое,
|
||||
* ссылки справочников и телефоны — объединением, is_federal — местная карточка перевешивает.
|
||||
*
|
||||
* @param array<int, array> $group
|
||||
*/
|
||||
private function mergeGroup(array $group): array
|
||||
{
|
||||
$name = null;
|
||||
$site = null;
|
||||
$desc = null;
|
||||
$isFederal = true;
|
||||
$hasFederalFlag = false;
|
||||
$dirs = [];
|
||||
$phones = [];
|
||||
|
||||
foreach ($group as $c) {
|
||||
if ($name === null && ! empty($c['name'])) {
|
||||
$name = $c['name'];
|
||||
}
|
||||
if ($site === null && ! empty($c['site_url'])) {
|
||||
$site = $c['site_url'];
|
||||
}
|
||||
if ($desc === null && ! empty($c['description'])) {
|
||||
$desc = $c['description'];
|
||||
}
|
||||
if (array_key_exists('is_federal', $c)) {
|
||||
$hasFederalFlag = true;
|
||||
if (! $c['is_federal']) {
|
||||
$isFederal = false; // нашлась местная карточка — группа местная
|
||||
}
|
||||
}
|
||||
foreach ($c['directory_urls'] ?? [] as $d) {
|
||||
if (! in_array($d, $dirs, true)) {
|
||||
$dirs[] = $d;
|
||||
}
|
||||
}
|
||||
foreach ($c['phones'] ?? [] as $p) {
|
||||
if (! in_array($p, $phones, true)) {
|
||||
$phones[] = $p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$merged = [
|
||||
'name' => $name ?? '',
|
||||
'site_url' => $site,
|
||||
'directory_urls' => $dirs,
|
||||
'phones' => $phones,
|
||||
];
|
||||
if ($desc !== null) {
|
||||
$merged['description'] = $desc;
|
||||
}
|
||||
if ($hasFederalFlag) {
|
||||
$merged['is_federal'] = $isFederal;
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Support\PhoneNormalizer;
|
||||
|
||||
/**
|
||||
* Нормализует домены и телефоны для дедупликации конкурентов и источников.
|
||||
*/
|
||||
final class AutopodborNormalizer
|
||||
{
|
||||
/**
|
||||
* Возвращает «голову» домена: без схемы, www, пути, порта, нижний регистр.
|
||||
* Примеры:
|
||||
* https://www.Okna-Komfort.RU/contacts → okna-komfort.ru
|
||||
* http://site.ru:8080/path?x=1 → site.ru
|
||||
*/
|
||||
public function domainHead(string $raw): string
|
||||
{
|
||||
$s = trim(mb_strtolower($raw));
|
||||
// Убираем схему (http://, https://, ftp:// и т.п.)
|
||||
$s = preg_replace('#^[a-z]+://#', '', $s);
|
||||
// Убираем www.
|
||||
$s = preg_replace('#^www\.#', '', $s);
|
||||
// Берём только host часть (до первого /)
|
||||
$s = explode('/', $s)[0];
|
||||
// Убираем query string если вдруг осталась
|
||||
$s = explode('?', $s)[0];
|
||||
// Убираем порт
|
||||
$s = explode(':', $s)[0];
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует телефон к виду 7xxxxxxxxxx (11 цифр, без плюса).
|
||||
* Использует существующий PhoneNormalizer::normalize, который возвращает +7XXXXXXXXXX.
|
||||
*/
|
||||
public function phone(string $raw): string
|
||||
{
|
||||
$normalized = PhoneNormalizer::normalize($raw);
|
||||
|
||||
if ($normalized === null) {
|
||||
// Fallback: оставить только цифры и привести к 7xxxxxxxxxx
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
|
||||
return '7'.substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) === 10) {
|
||||
return '7'.$digits;
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
// PhoneNormalizer возвращает +7XXXXXXXXXX — срезаем ведущий '+'
|
||||
return ltrim($normalized, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для источника (сайт или звонок).
|
||||
* Формат: «type:нормализованный_идентификатор»
|
||||
*/
|
||||
public function sourceKey(string $type, string $identifier): string
|
||||
{
|
||||
$id = $type === 'call'
|
||||
? $this->phone($identifier)
|
||||
: $this->domainHead($identifier);
|
||||
|
||||
return $type.':'.$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Срезает хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
* Если значка нет — строка возвращается без изменений.
|
||||
* Примеры:
|
||||
* 'Окна Комфорт ✓' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт 🎭' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт' → 'Окна Комфорт'
|
||||
* 'Балкон-Сервис 16' → 'Балкон-Сервис 16'
|
||||
*/
|
||||
public function stripBadge(string $name): string
|
||||
{
|
||||
// Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
// Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный).
|
||||
return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для конкурента.
|
||||
* Если есть сайт — «site:домен», иначе «name:имя_в_нижнем_регистре».
|
||||
*/
|
||||
public function competitorKey(string $name, ?string $siteUrl): string
|
||||
{
|
||||
if ($siteUrl !== null) {
|
||||
return 'site:'.$this->domainHead($siteUrl);
|
||||
}
|
||||
|
||||
// Нижний регистр + схлопываем пробелы
|
||||
$normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name)));
|
||||
|
||||
return 'name:'.$normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сжатый ключ имени для union-find слияния: нижний регистр, ё→е, только буквы/цифры.
|
||||
* Намеренно совпадает по форме с {@see domainRoot}, чтобы «Драйв займ» (имя) сцепился
|
||||
* с «драйвзайм.рф» (корень домена). Примеры: «Драйв займ» → «драйвзайм»; «ОКНА-КОМФОРТ» → «окнакомфорт».
|
||||
*/
|
||||
public function nameKey(string $name): string
|
||||
{
|
||||
return $this->alnumKey($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Корень домена для union-find: «голова» домена без TLD, только буквы/цифры.
|
||||
* Примеры: «драйвзайм.рф» → «драйвзайм»; «https://okna-komfort.ru/contacts» → «окнакомфорт» (лат.).
|
||||
*/
|
||||
public function domainRoot(string $site): string
|
||||
{
|
||||
$host = $this->domainHead($site);
|
||||
$parts = explode('.', $host);
|
||||
if (count($parts) > 1) {
|
||||
array_pop($parts); // срезаем зону (.ru/.рф/...)
|
||||
}
|
||||
|
||||
return $this->alnumKey(implode('.', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ имени БЕЗ слов-категорий из СЛОВАРЯ рубрик — чтобы «Яричъ Ломбард» сцепился с «Яричъ».
|
||||
* Слова-категории берутся ИЗ ДАННЫХ прогона (словарь рубрик справочника), а НЕ из зашитого
|
||||
* списка ниш — работает для любой отрасли («Улыбка Стоматология» → «Улыбка»). Слово может
|
||||
* прийти из рубрики ДРУГОЙ фирмы (общий словарь), поэтому ловит и имена без своей рубрики.
|
||||
* Ключ создаём ТОЛЬКО если после стрижки остался РОВНО ОДИН слово-токен (бренд типа «Яричъ»),
|
||||
* а НЕ описательная фраза из нескольких слов («займы под залог») — генерики не склеиваем.
|
||||
* Иначе (0 или >1 слова) → '' (ключа нет).
|
||||
*
|
||||
* @param list<string> $vocabWords словарь слов-категорий (уже нормализованы через words())
|
||||
*/
|
||||
public function nameKeyMinusWords(string $name, array $vocabWords): string
|
||||
{
|
||||
if ($vocabWords === []) {
|
||||
return '';
|
||||
}
|
||||
$remove = array_flip($vocabWords);
|
||||
$kept = array_values(array_filter($this->words($name), static fn (string $w): bool => ! isset($remove[$w])));
|
||||
|
||||
return count($kept) === 1 ? $kept[0] : '';
|
||||
}
|
||||
|
||||
/** @return list<string> слова строки: нижний регистр, ё→е, разбивка по не-буквам/цифрам. */
|
||||
public function words(string $s): array
|
||||
{
|
||||
$s = str_replace('ё', 'е', mb_strtolower(trim($s)));
|
||||
|
||||
return array_values(preg_split('/[^\p{L}\p{N}]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: []);
|
||||
}
|
||||
|
||||
/** Нижний регистр + ё→е + только буквы/цифры (Unicode). */
|
||||
private function alnumKey(string $s): string
|
||||
{
|
||||
$s = str_replace('ё', 'е', mb_strtolower(trim($s)));
|
||||
|
||||
return preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AutopodborProjectCreator
|
||||
{
|
||||
public function __construct(private ProjectService $projects) {}
|
||||
|
||||
/**
|
||||
* @param int[] $sourceIds
|
||||
* @param array{regions:int[],daily_limit_target:int,delivery_days_mask:int} $common
|
||||
* @return Project[]
|
||||
*/
|
||||
public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $sourceIds, $common, $launch) {
|
||||
$sources = AutopodborSource::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $sourceIds)->with('competitor')->get();
|
||||
|
||||
$created = [];
|
||||
foreach ($sources as $src) {
|
||||
$name = $this->uniqueName($tenantId, $this->displayName($src));
|
||||
// Каждый раз свежий tenant — чтобы кумулятивный гейт внутри
|
||||
// ProjectService::create видел уже запущенные проекты из предыдущих итераций.
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$project = $this->projects->create($tenant, [
|
||||
'name' => $name,
|
||||
'signal_type' => $src->signal_type,
|
||||
'signal_identifier' => $src->identifier,
|
||||
'daily_limit_target' => $common['daily_limit_target'],
|
||||
'regions' => $common['regions'],
|
||||
'delivery_days_mask' => $common['delivery_days_mask'],
|
||||
], $launch);
|
||||
$src->update(['created_project_id' => $project->id]);
|
||||
$created[] = $project;
|
||||
}
|
||||
|
||||
return $created;
|
||||
});
|
||||
}
|
||||
|
||||
private function displayName(AutopodborSource $s): string
|
||||
{
|
||||
$n = $s->competitor->name;
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'real') {
|
||||
return $n.' ✓';
|
||||
}
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') {
|
||||
return $n.' 🎭';
|
||||
}
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
private function uniqueName(int $tenantId, string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) {
|
||||
$i++;
|
||||
$name = $base.' '.$i;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\SystemSettings;
|
||||
|
||||
final class AutopodborRunService
|
||||
{
|
||||
public function __construct(
|
||||
private AutopodborNormalizer $normalizer = new AutopodborNormalizer,
|
||||
) {}
|
||||
|
||||
private function assertNoInFlight(int $tenantId, string $kind): void
|
||||
{
|
||||
$exists = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->where('kind', $kind)
|
||||
->whereIn('status', ['queued', 'running'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new RunInFlightException;
|
||||
}
|
||||
}
|
||||
|
||||
private function priceGate(int $tenantId, string $key): string
|
||||
{
|
||||
$price = (string) (SystemSettings::get($key) ?? '0');
|
||||
$balance = (string) Tenant::whereKey($tenantId)->value('balance_rub');
|
||||
|
||||
if (bccomp($balance, $price, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($price, '100', 0),
|
||||
balanceRub: $balance,
|
||||
);
|
||||
}
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
public function startSearch(
|
||||
int $tenantId,
|
||||
int $regionCode,
|
||||
array $examples,
|
||||
array $aboutSelf,
|
||||
bool $includeFederal,
|
||||
): AutopodborRun {
|
||||
$this->assertNoInFlight($tenantId, 'search');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_search_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'search',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => [
|
||||
'examples' => $examples,
|
||||
'about_self' => $aboutSelf,
|
||||
'include_federal' => $includeFederal,
|
||||
],
|
||||
]);
|
||||
|
||||
RunAutopodborSearchJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startStudy(int $tenantId, int $competitorId): AutopodborRun
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
|
||||
|
||||
if ($comp->studied_at !== null) {
|
||||
return $comp->studyRun;
|
||||
}
|
||||
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $comp->searchRun?->region_code,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
|
||||
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
|
||||
*
|
||||
* @param array{name:string, site_url:?string, directory_urls:array} $competitorData
|
||||
*/
|
||||
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'search_run_id' => null,
|
||||
'name' => $competitorData['name'],
|
||||
'origin' => 'manual',
|
||||
'relevance_pct' => null,
|
||||
'site_url' => $competitorData['site_url'] ?? null,
|
||||
'directory_urls' => $competitorData['directory_urls'] ?? [],
|
||||
'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null),
|
||||
]);
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'resolve');
|
||||
// resolve бесплатный — без priceGate
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'resolve',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => ['name' => $name],
|
||||
]);
|
||||
|
||||
RunAutopodborResolveJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
/**
|
||||
* Устойчивые опознавалки конкурента для сверки «та же фирма»: голова домена сайта (`s:`) +
|
||||
* ключи карточек справочника (`d:2gis:<id>` / `d:ya:<slug>`). ИМЯ НЕ участвует — клиент называет
|
||||
* фирму как хочет, совпадение ищем только по сайту/карточкам (спека §3).
|
||||
*/
|
||||
final class CompetitorIdentity
|
||||
{
|
||||
public function __construct(private readonly AutopodborNormalizer $norm) {}
|
||||
|
||||
/**
|
||||
* @param array{site_url?:?string,directory_urls?:array<int,string>} $competitor
|
||||
* @return list<string> уникальные ключи-опознавалки (пусто, если ни сайта, ни карточек)
|
||||
*/
|
||||
public function keys(array $competitor): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
$site = $competitor['site_url'] ?? null;
|
||||
if (is_string($site) && trim($site) !== '') {
|
||||
$head = $this->norm->domainHead($site);
|
||||
if ($head !== '') {
|
||||
$keys[] = 's:'.$head;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($competitor['directory_urls'] ?? [] as $url) {
|
||||
$k = $this->directoryKey((string) $url);
|
||||
if ($k !== null) {
|
||||
$keys[] = $k;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/** Ссылка справочника → устойчивый ключ фирмы (id 2ГИС / slug Яндекса) или null. */
|
||||
private function directoryKey(string $url): ?string
|
||||
{
|
||||
if (preg_match('#/firm/(\d+)#', $url, $m) === 1) {
|
||||
return 'd:2gis:'.$m[1];
|
||||
}
|
||||
if (preg_match('#/maps/org/([a-z0-9_-]+)#i', $url, $m) === 1) {
|
||||
return 'd:ya:'.mb_strtolower($m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
/**
|
||||
* Делит находки-предложения относительно состояния клиента (поле/предложения/архив) на группы
|
||||
* (спека §3): new / actualize / archived / hidden. «Та же фирма» — по пересечению опознавалок
|
||||
* {@see CompetitorIdentity} (сайт+карточки, имя не участвует).
|
||||
*/
|
||||
final class ProposalClassifier
|
||||
{
|
||||
public function __construct(private readonly CompetitorIdentity $identity) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name?:string,site_url?:?string,directory_urls?:array}> $finds
|
||||
* @param array<int, array{id:int,box:string,name?:string,site_url?:?string,directory_urls?:array}> $existing
|
||||
* @return list<array{find:array,group:string,matched_id:?int,delta_keys:list<string>}>
|
||||
*/
|
||||
public function classify(array $finds, array $existing): array
|
||||
{
|
||||
// Индекс: ключ-опознавалка → список [existing_id, активна ли] (для приоритета активного).
|
||||
$index = [];
|
||||
$keysById = [];
|
||||
foreach ($existing as $e) {
|
||||
$keys = $this->identity->keys($e);
|
||||
$keysById[$e['id']] = $keys;
|
||||
$active = $e['box'] !== 'archived';
|
||||
foreach ($keys as $k) {
|
||||
$index[$k][] = ['id' => $e['id'], 'active' => $active];
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($finds as $find) {
|
||||
$findKeys = $this->identity->keys($find);
|
||||
|
||||
// Первое активное и первое архивное совпадение по общим ключам.
|
||||
$matchActive = null;
|
||||
$matchArchived = null;
|
||||
foreach ($findKeys as $k) {
|
||||
foreach ($index[$k] ?? [] as $hit) {
|
||||
if ($hit['active'] && $matchActive === null) {
|
||||
$matchActive = $hit['id'];
|
||||
}
|
||||
if (! $hit['active'] && $matchArchived === null) {
|
||||
$matchArchived = $hit['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchActive !== null) {
|
||||
// Дельта = ключи находки, которых нет у совпавшей активной фирмы.
|
||||
$have = array_flip($keysById[$matchActive] ?? []);
|
||||
$delta = array_values(array_filter($findKeys, static fn (string $k): bool => ! isset($have[$k])));
|
||||
$out[] = [
|
||||
'find' => $find,
|
||||
'group' => $delta === [] ? 'hidden' : 'actualize',
|
||||
'matched_id' => $matchActive,
|
||||
'delta_keys' => $delta,
|
||||
];
|
||||
} elseif ($matchArchived !== null) {
|
||||
$out[] = ['find' => $find, 'group' => 'archived', 'matched_id' => $matchArchived, 'delta_keys' => []];
|
||||
} else {
|
||||
$out[] = ['find' => $find, 'group' => 'new', 'matched_id' => null, 'delta_keys' => []];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
final readonly class GateResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $passes,
|
||||
public int $capacityLeads,
|
||||
public int $committedLeads,
|
||||
public int $requiredLeads,
|
||||
public int $deficitLeads,
|
||||
public string $topupRub,
|
||||
public string $balanceRub,
|
||||
) {}
|
||||
|
||||
/** @return array{current_balance_rub:string,current_capacity_leads:int,would_be_required_leads:int,deficit_leads:int,topup_rub:string} */
|
||||
public function toBalancePayload(): array
|
||||
{
|
||||
return [
|
||||
'current_balance_rub' => $this->balanceRub,
|
||||
'current_capacity_leads' => $this->capacityLeads,
|
||||
'would_be_required_leads' => $this->requiredLeads,
|
||||
'deficit_leads' => $this->deficitLeads,
|
||||
'topup_rub' => $this->topupRub,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
|
||||
final class LaunchBalanceGate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BalancePreflightService $preflight = new BalancePreflightService,
|
||||
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int[] $excludeProjectIds проекты, чьи лимиты НЕ учитывать в committed (напр. сам активируемый)
|
||||
*/
|
||||
public function evaluate(Tenant $tenant, int $additionalLeads, array $excludeProjectIds = []): GateResult
|
||||
{
|
||||
$balanceRub = (string) $tenant->balance_rub;
|
||||
$deliveredInMonth = (int) $tenant->delivered_in_month;
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
$committed = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->when($excludeProjectIds !== [], fn ($q) => $q->whereNotIn('id', $excludeProjectIds))
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$required = $committed + max(0, $additionalLeads);
|
||||
|
||||
if ($tiers->isEmpty()) {
|
||||
$failClosed = (bool) config('billing.launch_requires_active_tiers', false);
|
||||
|
||||
return new GateResult(
|
||||
passes: ! $failClosed,
|
||||
capacityLeads: $failClosed ? 0 : PHP_INT_MAX,
|
||||
committedLeads: $committed,
|
||||
requiredLeads: $required,
|
||||
deficitLeads: $failClosed ? $required : 0,
|
||||
topupRub: '0.00',
|
||||
balanceRub: $balanceRub,
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->preflight->evaluate($balanceRub, $deliveredInMonth, $required, $tiers);
|
||||
|
||||
return new GateResult(
|
||||
passes: $result->passes,
|
||||
capacityLeads: $result->capacityLeads,
|
||||
committedLeads: $committed,
|
||||
requiredLeads: $required,
|
||||
deficitLeads: $result->deficitLeads,
|
||||
topupRub: $this->topupRub($balanceRub, $deliveredInMonth, $tiers, $result->deficitLeads),
|
||||
balanceRub: $balanceRub,
|
||||
);
|
||||
}
|
||||
|
||||
/** Сколько ₽ пополнить, чтобы дефицит-лиды поместились (по цене текущей ступени). */
|
||||
private function topupRub(string $balanceRub, int $deliveredInMonth, $tiers, int $deficitLeads): string
|
||||
{
|
||||
if ($deficitLeads <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
$priceRub = $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['current_tier']['price_rub'] ?? '0.00';
|
||||
|
||||
return bcmul($priceRub, (string) $deficitLeads, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
/** Итог обработки вопроса. escalate=true → после текста зовём живого оператора. @param list<int> $matchedChunkIds */
|
||||
class BotAnswer
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $text,
|
||||
public readonly bool $escalate,
|
||||
public readonly array $matchedChunkIds = [],
|
||||
) {}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
/**
|
||||
* Мозг ответа (спека §§4–5,7): стоп-темы → эскалация до LLM; пустой поиск →
|
||||
* честное «не знаю»; иначе YandexGPT строго по найденным фрагментам инструкции.
|
||||
* Tour-ссылка «Показать на портале» — из frontmatter самой релевантной статьи,
|
||||
* только под флагом tours_enabled (включается этапом 3).
|
||||
*/
|
||||
class BotAnswerService
|
||||
{
|
||||
/** Личные данные, деньги конкретного клиента, скидки, юр-темы, просьба человека. */
|
||||
private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|счет|счёт|деньг|проект|заявк|сделк)|у меня (на )?(балансе|счете|счёте)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu';
|
||||
|
||||
private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.';
|
||||
|
||||
private const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Передаю живому специалисту, он ответит здесь же.';
|
||||
|
||||
public function __construct(
|
||||
private readonly KnowledgeSearch $search,
|
||||
private readonly YandexGptClient $gpt,
|
||||
) {}
|
||||
|
||||
public function answer(string $question): BotAnswer
|
||||
{
|
||||
if (preg_match(self::STOP_PATTERN, $question) === 1) {
|
||||
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$chunks = $this->search->search($question, 3);
|
||||
if ($chunks === []) {
|
||||
return new BotAnswer(self::UNKNOWN_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$context = implode("\n\n---\n\n", array_map(
|
||||
fn ($c) => "### {$c->title}\n{$c->content}",
|
||||
$chunks
|
||||
));
|
||||
|
||||
$system = <<<PROMPT
|
||||
Ты — консультант техподдержки портала Лидерра (лиды для бизнеса). Отвечай кратко
|
||||
(2–5 предложений), простым русским языком, дружелюбно и на «вы».
|
||||
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
|
||||
если ответа в них нет — скажи честно «в инструкции этого нет». Ничего не выдумывай.
|
||||
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
|
||||
о данных конкретного клиента (баланс, его проекты) — предложи позвать специалиста.
|
||||
|
||||
Фрагменты инструкции:
|
||||
|
||||
{$context}
|
||||
PROMPT;
|
||||
|
||||
$text = $this->gpt->complete($system, $question);
|
||||
if ($text === null) {
|
||||
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$tour = $chunks[0]->tour;
|
||||
if ($tour !== null && (bool) config('services.jivo_bot.tours_enabled')) {
|
||||
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
|
||||
}
|
||||
|
||||
return new BotAnswer($text, escalate: false, matchedChunkIds: array_map(fn ($c) => (int) $c->id, $chunks));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Исходящие события в Jivo Bot API. outbound_url выдаёт Jivo письмом при
|
||||
* подключении бота (протокол, О-2); пустой URL = dev/CI, событие только в лог.
|
||||
* Формат событий — Jivo Bot API: BOT_MESSAGE (текст клиенту), INVITE_AGENT
|
||||
* (позвать живого оператора).
|
||||
*/
|
||||
class JivoBotClient
|
||||
{
|
||||
public function sendMessage(string $chatId, string $clientId, string $text): void
|
||||
{
|
||||
$this->post([
|
||||
'event' => 'BOT_MESSAGE',
|
||||
'id' => (string) Str::uuid(),
|
||||
'chat_id' => $chatId,
|
||||
'client_id' => $clientId,
|
||||
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => now()->getTimestamp()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function inviteAgent(string $chatId, string $clientId): void
|
||||
{
|
||||
$this->post([
|
||||
'event' => 'INVITE_AGENT',
|
||||
'id' => (string) Str::uuid(),
|
||||
'chat_id' => $chatId,
|
||||
'client_id' => $clientId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function post(array $payload): void
|
||||
{
|
||||
$url = (string) config('services.jivo_bot.outbound_url');
|
||||
if ($url === '') {
|
||||
Log::info('JivoBot outbound skipped (no outbound_url)', ['event' => $payload['event']]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(5)->post($url, $payload)->throw();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('JivoBot outbound failure', ['event' => $payload['event'], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Поиск по базе знаний бота: PostgreSQL FTS (russian) по generated-колонке
|
||||
* search_tsv (title+topics+content), ранжирование ts_rank. websearch_to_tsquery
|
||||
* терпим к пользовательскому вводу (спецсимволы не ломают запрос).
|
||||
* Интерфейс намеренно узкий — замена на pgvector позже не тронет вызывающих.
|
||||
*
|
||||
* @return list<KnowledgeChunk>
|
||||
*/
|
||||
class KnowledgeSearch
|
||||
{
|
||||
public function search(string $question, int $limit = 3): array
|
||||
{
|
||||
$question = trim($question);
|
||||
if ($question === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Collection<int, KnowledgeChunk> $hits */
|
||||
$hits = KnowledgeChunk::query()
|
||||
->selectRaw(
|
||||
"knowledge_chunks.*, ts_rank(search_tsv, websearch_to_tsquery('russian', ?)) AS rank",
|
||||
[$question]
|
||||
)
|
||||
->whereRaw("search_tsv @@ websearch_to_tsquery('russian', ?)", [$question])
|
||||
->orderByDesc('rank')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $hits->all();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (протокол, решение 8).
|
||||
* Возвращает null при любой беде (нет ключа, таймаут, 5xx) — решение об эскалации
|
||||
* принимает вызывающий. Таймаут 8 сек — бюджет скорости из спеки §6.
|
||||
*/
|
||||
class YandexGptClient
|
||||
{
|
||||
public function complete(string $systemPrompt, string $userText): ?string
|
||||
{
|
||||
$cfg = (array) config('services.yandexgpt');
|
||||
if (($cfg['api_key'] ?? '') === '' || ($cfg['folder_id'] ?? '') === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout((int) ($cfg['timeout_seconds'] ?? 8))
|
||||
->withHeaders(['Authorization' => 'Api-Key '.$cfg['api_key']])
|
||||
->post((string) $cfg['endpoint'], [
|
||||
'modelUri' => sprintf('gpt://%s/%s', $cfg['folder_id'], $cfg['model']),
|
||||
'completionOptions' => ['stream' => false, 'temperature' => 0.2, 'maxTokens' => 500],
|
||||
'messages' => [
|
||||
['role' => 'system', 'text' => $systemPrompt],
|
||||
['role' => 'user', 'text' => $userText],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('YandexGPT non-OK', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('result.alternatives.0.message.text');
|
||||
|
||||
return is_string($text) && $text !== '' ? $text : null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('YandexGPT failure', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dashboard;
|
||||
|
||||
/**
|
||||
* Чистая логика светофора балансов внешних сервисов: «хватит на N дней» + цвет.
|
||||
* Без БД/сети — unit-тестируема. Светофор по ДВУМ правилам (решение владельца 28.06):
|
||||
* 🔴 баланс < red_floor ИЛИ дней_осталось < 3
|
||||
* 🟡 баланс < amber_floor ИЛИ дней_осталось < 7
|
||||
* 🟢 иначе
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
||||
*/
|
||||
class BalanceHealth
|
||||
{
|
||||
/**
|
||||
* @return array{days_left:?int,light:string}
|
||||
*/
|
||||
public static function evaluate(
|
||||
float $balance,
|
||||
?float $dailySpend,
|
||||
float $redFloor,
|
||||
float $amberFloor,
|
||||
): array {
|
||||
// Отрицательный/нулевой баланс → денег уже нет: 0 дней (не отрицательное «−1 дн.»).
|
||||
$days = ($dailySpend !== null && $dailySpend > 0)
|
||||
? max(0, (int) floor($balance / $dailySpend))
|
||||
: null;
|
||||
|
||||
$light = 'green';
|
||||
if ($balance < $amberFloor || ($days !== null && $days < 7)) {
|
||||
$light = 'amber';
|
||||
}
|
||||
if ($balance < $redFloor || ($days !== null && $days < 3)) {
|
||||
$light = 'red';
|
||||
}
|
||||
|
||||
return ['days_left' => $days, 'light' => $light];
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
/**
|
||||
* Переходник на один внешний платный сервис: читает его баланс.
|
||||
* Изоляция: fetch() НЕ бросает — любую ошибку (сеть/доступ/парсинг) заворачивает
|
||||
* в BalanceReading::fail(), чтобы падение одного сервиса не роняло плитку.
|
||||
*/
|
||||
interface BalanceProvider
|
||||
{
|
||||
/** dadata | supplier | yandex_cloud */
|
||||
public function serviceKey(): string;
|
||||
|
||||
public function fetch(): BalanceReading;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user