Merge gitea/main into feat/sales-finder — сведение с боевым перед выкатом

Ветка разошлась с боевым 28.06 (334 коммита в main / 215 у нас). Влито ВСЁ боевое:
автоподбор конкурентов, мобильный адаптив портала, свой учёт посетителей, мониторинг
внешних сервисов, фиксы поставщика/биллинга/бота/разборов.

ПОБАЙТОВАЯ СВЕРКА: из 1008 файлов, изменённых боевым, 989 совпадают точно;
19 отличаются — все с нашей законной работой (обе стороны внутри). Затёртых — 0.

24 конфликта разобраны вручную. Ключевое:
- VerifySupplierOrderJob — взята БОЕВАЯ версия (фикс инцидента 11-12.07: площадка
  берётся из src, а не из имени; наша была старой и вернула бы баг, терявший заявки).
- SyncSupplierProjectsJobTest — 15 боевых тестов + наш уникальный (limit-1 → только B1).
- routes/web, router/index, config/services, bootstrap/app — обе стороны сложены.
- NewProjectDialog — зелёные дни недели (наше) + мобильная раскладка (боевое).
- CHANGELOG схемы — номера версий столкнулись, наши перенумерованы в v8.67-v8.70.
- composer — обе зависимости (laravel-dompdf наш + geoip2 боевой).

Гейты: бэкенд 2907/2911 (0 падений), Larastan 0, фронт 1333/1333, сборка OK.
Baseline статанализа принял пре-существующий долг боевого кода (автоподбор/чат).
@mixin в 26 моделях — требование статанализа, dev-докблок, на рантайм не влияет.

Откат: git reset --hard pre-merge-main-20260714

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 17:49:28 +03:00
916 changed files with 80460 additions and 1130 deletions
+12 -1
View File
@@ -83,7 +83,9 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
BOT_TOURS_ENABLED=false
YANDEX_GPT_API_KEY=
YANDEX_GPT_FOLDER_ID=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -96,3 +98,12 @@ VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
# Автоподбор шаг1: модель ИИ-фильтра «поставщик или площадка-агрегатор».
# ОБЯЗАТЕЛЬНО gpt-4o (сильная): слабая gpt-4o-mini наугад штампует живые
# федеральные МФО (Екапуста/Webbankir/СрочноДеньги…) как «агрегаторы» и режет
# их из подбора. Пусто → фолбэк на gpt-4o-mini (баг «режет федералов», 07.07.2026).
AITUNNEL_AGGREGATOR_MODEL=gpt-4o
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use Illuminate\Console\Command;
class AutopodborStudyTickCommand extends Command
{
protected $signature = 'autopodbor:study-tick';
protected $description = 'Страховка: если дорожка шага 2 свободна, а очередь не пуста — запустить следующего.';
public function handle(AutopodborStudyScheduler $scheduler): int
{
$scheduler->tick();
return self::SUCCESS;
}
}
@@ -0,0 +1,58 @@
<?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;
}
}
@@ -10,10 +10,16 @@ use Illuminate\Support\Carbon;
/**
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
* на N месяцев вперёд от текущей даты.
* на N месяцев вперёд от текущей даты и на M месяцев назад.
*
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
*
* behind=1 (12.07.2026): нарезалось только «текущий + вперёд», поэтому на свежей базе
* партиции за прошлый месяц не было никогда. Между тем строки с датой в прошлом месяце
* штатное явление: тридцатидневное окно списаний (runway) и вставки на стыке месяцев.
* На проде не всплывало (партиции копятся с самого начала), но на свежей базе и в CI
* INSERT падал «no partition found» в первой половине каждого месяца.
*
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
* без сборки из исходников). Запускается ежесуточно через Windows Task
* Scheduler / cron идемпотентна (проверяет pg_class перед CREATE).
@@ -27,20 +33,23 @@ use Illuminate\Support\Carbon;
class PartitionsCreateMonths extends Command
{
/** @var string */
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
protected $signature = 'partitions:create-months
{--ahead=2 : Сколько месяцев вперёд создать партиций}
{--behind=1 : Сколько месяцев назад создать партиций (окно последних 30 дней, стык месяцев)}';
/** @var string */
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц: N месяцев вперёд и M назад (idempotent)';
public function handle(MonthlyPartitionManager $manager): int
{
$ahead = max(1, (int) $this->option('ahead'));
$behind = max(0, (int) $this->option('behind'));
$now = Carbon::now()->startOfMonth();
$created = 0;
$skipped = 0;
for ($i = 0; $i <= $ahead; $i++) {
for ($i = -$behind; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
@@ -57,7 +66,7 @@ class PartitionsCreateMonths extends Command
}
$this->newLine();
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}, behind={$behind}).");
return self::SUCCESS;
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SaasTransaction;
use App\Services\Billing\PaymentSettlementService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Сверка «зависших» онлайн-пополнений со шлюзом.
*
* Зачем (14.07.2026): мы узнаём судьбу платежа только из webhook'а. Если он не дошёл
* платёж навсегда остаётся pending: отменённый висит «ожидает оплаты» (кабинет врёт
* клиенту), а ОПЛАЧЕННЫЙ не зачисляется вовсе (потеря денег клиента). Эта команда
* сама спрашивает шлюз о каждом pending старше 15 минут и доводит учёт до правды.
*
* Расписание каждые 5 минут (routes/console.php). Идемпотентна: зачисление
* атомарно занимает pending→success, повторный прогон ничего не задваивает.
*/
final class ReconcilePendingPayments extends Command
{
/** Платежи моложе этого возраста не трогаем — человек ещё стоит на странице оплаты. */
private const MIN_AGE_MINUTES = 15;
/** Дальше этого возраста платёж не воскреснет: шлюз давно его закрыл, а мы — нет. Не долбим впустую. */
private const MAX_AGE_DAYS = 7;
/** Потолок на прогон: не растягиваем сверку дольше пятиминутного окна крона. */
private const BATCH_LIMIT = 50;
protected $signature = 'billing:reconcile-payments {--age=15 : не трогать платежи моложе N минут}';
protected $description = 'Спросить шлюз о судьбе зависших онлайн-пополнений (pending) и закрыть их: зачислить или пометить неуспешными';
public function handle(PaymentSettlementService $settlement): int
{
$age = max((int) $this->option('age'), self::MIN_AGE_MINUTES);
// Cross-tenant выборка под BYPASSRLS-ролью (как webhook — tenant заранее неизвестен).
$stale = DB::connection('pgsql_supplier')->table('saas_transactions')
->where('status', SaasTransaction::STATUS_PENDING)
->where('type', SaasTransaction::TYPE_TOPUP)
->whereNotNull('gateway_payment_id')
->where('created_at', '<=', now()->subMinutes($age))
->where('created_at', '>=', now()->subDays(self::MAX_AGE_DAYS))
->orderBy('id')
->limit(self::BATCH_LIMIT)
->get();
$settled = 0;
foreach ($stale as $tx) {
try {
$outcome = $settlement->settleByPaymentId($tx);
} catch (Throwable $e) {
// Шлюз недоступен / платёж не найден — не роняем остальные, попробуем в следующий прогон.
Log::warning('billing.reconcile_payment_failed', [
'tx_id' => $tx->id,
'tenant_id' => $tx->tenant_id,
'error' => $e->getMessage(),
]);
continue;
}
if ($outcome !== 'not_paid') {
$settled++;
}
$this->line(sprintf('tx #%d (tenant %d, %s ₽) → %s', $tx->id, $tx->tenant_id, $tx->amount_rub, $outcome));
}
$this->info(sprintf('Проверено зависших: %d, закрыто: %d', $stale->count(), $settled));
return self::SUCCESS;
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Разовая починка после прод-инцидента 1112.07.2026.
*
* Кабинет поставщика дописывает метку канала «B<n> к имени строки только при её
* СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал
* голый ключ каждый прогон стирал метку. Последствия: (1) итоговая проверка заказа
* переставала узнавать строку и слала ложное «нет заказа»; (2) лид от такой строки
* приходил с project без метки webhook не мог определить канал и писал
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
*
* Корень закрыт в SupplierPortalClient::toPayload (на update имя уходит с меткой)
* эта команда возвращает метку тем строкам, у которых её уже стёрли.
*
* По умолчанию сухой прогон. Реальная запись в кабинет только с --apply.
*/
final class RepairSupplierProjectNamesCommand extends Command
{
private const DB_CONNECTION = 'pgsql_supplier';
/** Канал у поставщика — служебное поле src (rt=Ростелеком, bl=Билайн, mt=МТС). */
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
protected $signature = 'supplier:repair-project-names
{--apply : Записать исправленные имена в кабинет (без флага только показать)}
{--only-active : Чинить лишь строки, включённые у нас (выключенные лидов не носят)}';
protected $description = 'Вернуть метку канала B1_/B2_/B3_ в имена наших строк у поставщика';
public function handle(SupplierPortalClient $client): int
{
$apply = (bool) $this->option('apply');
$query = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id');
if ($this->option('only-active')) {
$query->whereNull('inactive_since');
}
/** @var array<string, true> $ourIds */
$ourIds = $query->pluck('supplier_external_id')
->mapWithKeys(fn ($id): array => [(string) $id => true])
->all();
$repaired = 0;
$failed = 0;
foreach ($client->listProjects() as $row) {
$externalId = (string) ($row['id'] ?? '');
if (! isset($ourIds[$externalId])) {
continue;
}
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
if ($platform === null) {
continue;
}
$name = (string) ($row['name'] ?? '');
if (preg_match('/^B[123]_/', $name) === 1) {
continue; // Метка на месте — не трогаем.
}
$newName = $platform.'_'.(string) ($row['content'] ?? '');
$this->line(sprintf('%s %s «%s» → «%s»', $externalId, $platform, $name, $newName));
if (! $apply) {
$repaired++;
continue;
}
try {
$client->renameProject($row, $newName);
$repaired++;
} catch (Throwable $e) {
$failed++;
$this->error(sprintf(' не удалось: %s', $e->getMessage()));
}
}
$this->newLine();
$this->info($apply
? sprintf('Починено строк: %d, ошибок: %d.', $repaired, $failed)
: sprintf('Сухой прогон: строк с потерянной меткой — %d. Записать: --apply.', $repaired));
return self::SUCCESS;
}
}
@@ -0,0 +1,618 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ActivityLog;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborMergeEvent;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* Заводит одного красивого демо-клиента для съёмки пошаговых разборов портала
* (скриншоты со стрелками на лендинге). Данные выдуманные: телефоны только из
* несуществующего диапазона +7 999 000-XX-XX, имена «Клиент 1»…«Клиент 25».
*
* Идемпотентна: повторный запуск не плодит дубли. Тенант/пользователь
* firstOrCreate по естественным ключам (subdomain, email). Проекты
* updateOrCreate по (tenant_id, name), чтобы повторный запуск ОБНОВЛЯЛ вид
* (сигнал/пауза/блокировка) уже созданных проектов.
*
* 🔴 Сделки ТОЛЬКО firstOrCreate (не updateOrCreate). Deal партиционирована
* по received_at и переопределяет setKeysForSaveQuery() на композитный
* WHERE id=? AND received_at=?: если updateOrCreate() перезапишет received_at
* новым значением перед save(), WHERE будет искать несуществующую пару и
* молча обновит 0 строк, а последующая вставка lead_charges со «свежим»
* (уже несуществующим) received_at упадёт с нарушением FK
* lead_charges_deals_fk (проверено вживую на dev-стенде 13.07.2026).
*
* 🔴 НИКОГДА не запускать на боевом окружении (проверка ниже).
*/
class SeedShowcaseCommand extends Command
{
protected $signature = 'demo:showcase';
protected $description = 'Завести демо-клиента с красивыми данными для съёмки разборов портала (никогда не на проде)';
/** Статусы воронки — реальные slug'и из lead_statuses (db/schema.sql). */
private const STATUSES = ['new', 'viewed', 'in_progress', 'won', 'lost'];
/** Цена первой ступени — как на боевом (55 ₽ за заявку), см. seedPrices(). */
private const PRICE_PER_LEAD_KOPECKS = 55_00;
/** Пополнения демо-кошелька, по порядку. */
private const TOPUPS = [5000, 7400];
private const TOPUPS_RUB = 12400;
public function handle(): int
{
if (config('app.env') === 'production') {
$this->error('demo:showcase запрещена на боевом окружении.');
return self::FAILURE;
}
$tenant = Tenant::query()->firstOrCreate(
['subdomain' => 'demo-ulybka'],
[
'organization_name' => 'Стоматология Улыбка',
'contact_email' => 'demo@liderra.ru',
'timezone' => 'Europe/Moscow',
'locale' => 'ru',
'is_trial' => false,
'balance_rub' => '12400.00',
'delivered_in_month' => 180,
'api_key_limit' => 5,
]
);
User::query()->firstOrCreate(
['tenant_id' => $tenant->id, 'email' => 'demo@liderra.ru'],
[
'password_hash' => Hash::make('DemoLiderra#2026'),
'first_name' => 'Демо',
'last_name' => 'Клиент',
'timezone' => 'Europe/Moscow',
'is_active' => true,
'totp_enabled' => false,
'sound_enabled' => true,
'email_verified_at' => now(),
'notification_preferences' => [
'new_lead' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]
);
// Карточка проекта берёт плашку статуса из Project::aggregateSyncStatus() (ProjectCard.vue
// syncStatusLabel: ok → «Собирает заявки», pending → «Готовим к запуску», failed → ошибка).
// aggregateSyncStatus() читает sync_status у связанных supplier_projects (B1/B2/B3, см.
// resolvedSupplierProjects()) — без привязки к поставщику проект всегда «pending», сколько
// бы is_active/daily_limit_target ни было выставлено. Чтобы демо-проект выглядел РЕАЛЬНО
// работающим (а не просто «не сломанным»), заводим один демо supplier_projects под B1 с
// sync_status='ok' и линкуем его как supplier_b1_project_id — честный путь через то же
// поле, что использует настоящая синхронизация с поставщиком (SyncSupplierProjectsJob),
// а не подмена текста в UI. unique_key/supplier_external_id — заведомо демошные, никаких
// настоящих идентификаторов поставщика crm.bp-gr.ru.
$supplierProjectWorking = SupplierProject::query()->updateOrCreate(
[
'platform' => 'B1',
'unique_key' => 'stomatologiya-ulybka-demo.ru',
'subject_code' => null,
],
[
'signal_type' => 'site',
'supplier_external_id' => 'DEMO-B1-000001',
'current_limit' => 20,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => null,
]
);
$projectWorking = Project::query()->updateOrCreate(
['tenant_id' => $tenant->id, 'name' => 'Стоматология — Москва'],
[
'type' => 'webhook',
'signal_type' => 'site',
'signal_identifier' => 'stomatologiya-ulybka.ru',
'is_active' => true,
'daily_limit_target' => 20,
'delivered_today' => 12,
'region_mask' => 255,
'region_mode' => 'include',
'delivery_days_mask' => 127,
'assignment_strategy' => 'manual',
'ttfr_target_minutes' => 15,
'delivered_in_month' => 0,
'supplier_b1_project_id' => $supplierProjectWorking->id,
]
);
$projectPaused = Project::query()->updateOrCreate(
['tenant_id' => $tenant->id, 'name' => 'Импланты — Санкт-Петербург'],
[
'type' => 'webhook',
'signal_type' => 'site',
'signal_identifier' => 'implanty-ulybka-spb.ru',
// Реальный инвариант (ProjectService::pause() строка ~292):
// paused_at ВСЕГДА идёт вместе с is_active=false — иначе портал
// не покажет карточку как «на паузе» (UI смотрит только на is_active).
'is_active' => false,
'paused_at' => now(),
'daily_limit_target' => 15,
'region_mask' => 255,
'region_mode' => 'include',
'delivery_days_mask' => 127,
'assignment_strategy' => 'manual',
'ttfr_target_minutes' => 15,
'delivered_in_month' => 0,
]
);
$projectBlocked = Project::query()->updateOrCreate(
['tenant_id' => $tenant->id, 'name' => 'Отбеливание — Казань'],
[
'type' => 'webhook',
'signal_type' => 'site',
'signal_identifier' => 'otbelivanie-ulybka-kazan.ru',
// Реальный инвариант (ProjectService::activate() строка ~307): проект,
// не прошедший преfflight по балансу, «остаётся на паузе» — is_active=false.
'is_active' => false,
'preflight_blocked_at' => now(),
'daily_limit_target' => 10,
'region_mask' => 255,
'region_mode' => 'include',
'delivery_days_mask' => 127,
'assignment_strategy' => 'manual',
'ttfr_target_minutes' => 15,
'delivered_in_month' => 0,
]
);
$projects = [$projectWorking, $projectPaused, $projectBlocked];
$cities = ['Москва', 'Санкт-Петербург', 'Казань'];
$charged = [];
$today = Carbon::now();
$yesterday = $today->copy()->subDay();
// 🔴 Держим received_at в ТЕКУЩЕМ месяце — партиции deals помесячные,
// и «сегодня/вчера» в конце месяца может уехать в прошлый месяц без партиции.
if ($yesterday->month !== $today->month) {
$yesterday = $today->copy()->startOfMonth();
}
for ($n = 1; $n <= 25; $n++) {
$phone = '7999000'.str_pad((string) $n, 4, '0', STR_PAD_LEFT);
$projectIndex = ($n - 1) % count($projects);
$project = $projects[$projectIndex];
$status = self::STATUSES[($n - 1) % count(self::STATUSES)];
$receivedAt = $n <= 12 ? $today->copy() : $yesterday->copy();
$deal = Deal::query()->firstOrCreate(
['tenant_id' => $tenant->id, 'source_crm_id' => 900_000_000 + $n],
[
'project_id' => $project->id,
'phone' => $phone,
'status' => $status,
'contact_name' => 'Клиент '.$n,
'city' => $cities[$projectIndex],
'received_at' => $receivedAt,
'escalated_count' => 0,
'is_test' => false,
]
);
// Списываем за КАЖДУЮ заявку — как в жизни. Раньше списаний было только шесть:
// в карточке заявки и в шапке колонки «Канбана» стоимость показывалась прочерком,
// а история кошелька не сходилась (пополнено 12 400 ₽, списаний нет, баланс 12 400).
LeadCharge::query()->firstOrCreate(
['tenant_id' => $tenant->id, 'deal_id' => $deal->id],
[
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'charge_source' => 'rub',
// Цена первой ступени — как на боевом (55 ₽), см. seedPrices().
'price_per_lead_kopecks' => self::PRICE_PER_LEAD_KOPECKS,
'charged_at' => $deal->received_at,
'created_at' => $deal->received_at,
]
);
// Лента «Активность» в карточке заявки была пустой: событий у демо-заявок не было
// вовсе, а разбор обещает «когда создана, кто менял статус, когда смотрели».
// Пишем те же события, что пишет сам портал (activity_log).
$events = [
[ActivityLog::EVENT_DEAL_CREATED, null, null, $deal->received_at],
[ActivityLog::EVENT_DEAL_STATUS_CHANGED, 'new', $deal->status, $deal->received_at->copy()->addMinutes(35)],
];
foreach ($events as [$event, $old, $new, $at]) {
if ($event === ActivityLog::EVENT_DEAL_STATUS_CHANGED && $deal->status === 'new') {
continue; // статус не меняли — врать в ленте не будем
}
ActivityLog::query()->firstOrCreate(
['tenant_id' => $tenant->id, 'deal_id' => $deal->id, 'event' => $event],
[
'user_id' => null,
'old_value' => $old,
'new_value' => $new,
'context' => null,
'created_at' => $at,
]
);
}
$charged[] = $deal;
}
// Кошелёк должен СХОДИТЬСЯ: пополнено 12 400 ₽ − списано 25 × 55 ₽ = остаток.
$chargesRub = count($charged) * (self::PRICE_PER_LEAD_KOPECKS / 100);
$balanceRub = self::TOPUPS_RUB - $chargesRub;
$this->resetMoneyJournal($tenant->id);
// Списания в журнале — по порядку прихода заявок, иначе в истории кошелька у более
// ранней даты стоят более поздние номера, и гость видит перевёрнутую хронологию.
usort($charged, fn (Deal $a, Deal $b) => $a->received_at <=> $b->received_at);
// 🔴 Журнал денег append-only (триггер `audit log is append-only`): строку нельзя ни
// переписать, ни удалить — только добавить. Поэтому firstOrCreate, а не updateOrCreate.
$runningBalance = 0.0;
foreach (self::TOPUPS as $amount) {
$runningBalance += $amount;
BalanceTransaction::query()->firstOrCreate(
[
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TOPUP,
'amount_rub' => sprintf('%.2f', $amount),
],
[
'amount_leads' => 0,
'balance_rub_after' => sprintf('%.2f', $runningBalance),
'balance_leads_after' => null,
'description' => 'Пополнение баланса',
// Пополнение должно быть РАНЬШЕ первого списания, иначе в истории кошелька
// деньги списываются с пустого счёта — и это видно клиенту на кадрах.
'created_at' => $today->copy()->subDays(3),
]
);
}
foreach ($charged as $deal) {
$runningBalance -= self::PRICE_PER_LEAD_KOPECKS / 100;
BalanceTransaction::query()->firstOrCreate(
[
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'related_type' => 'deal',
'related_id' => $deal->id,
],
[
'amount_rub' => sprintf('-%.2f', self::PRICE_PER_LEAD_KOPECKS / 100),
'amount_leads' => 0,
'balance_rub_after' => sprintf('%.2f', $runningBalance),
'balance_leads_after' => null,
'description' => 'Заявка №'.$deal->id,
'created_at' => $deal->received_at,
]
);
}
$tenant->forceFill(['balance_rub' => sprintf('%.2f', $balanceRub)])->save();
// Отчёты, заказанные прошлыми съёмками, копятся: портал держит не больше трёх штук
// «в работе» и на четвёртой гасит кнопку «Сформировать» — робот-съёмщик утыкался в
// мёртвую кнопку (14.07.2026). Чистим их вместе с остальным демо-стендом.
DB::table('report_jobs')->where('tenant_id', $tenant->id)->delete();
$this->seedPrices();
// Списания, заведённые прежними прогонами по старой (тестовой) цене, подтягиваем к
// боевой: иначе на кадре «Списания» у демо-клиента останется чужой ценник. Правим
// построителем запросов, а не моделью: LeadCharge партиционирована по deal_received_at,
// и сохранение через модель трогает ключ партиции.
LeadCharge::query()
->where('tenant_id', $tenant->id)
->where('price_per_lead_kopecks', '!=', 55_00)
->update(['price_per_lead_kopecks' => 55_00]);
$this->seedCompetitorField($tenant);
$this->info('demo:showcase готово — тенант «Стоматология Улыбка», 3 проекта, 25 заявок, конкурентное поле, БОЕВЫЕ цены.');
return self::SUCCESS;
}
/**
* Стирает журнал кошелька ТОЛЬКО у демо-тенанта и ТОЛЬКО на локальной машине.
*
* 🔴 Журнал денег защищён от подделки: триггер `audit log is append-only` запрещает и
* UPDATE, и DELETE и это правильно. Но демо-стенд из-за этого копил кривую историю:
* записи прошлых прогонов оставались с прежними датами, и на кадрах получалось, что
* заявки оплачены РАНЬШЕ, чем пришли деньги (списание с пустого счёта). Переснять кадры
* начисто без сброса журнала невозможно.
*
* Защита от глупости: на боевом окружении команда вообще не запускается (проверка в
* handle()), а здесь дополнительно требуем локальную среду. Отключение триггеров живёт
* ровно на время удаления и возвращается назад в том же соединении.
*/
private function resetMoneyJournal(int $tenantId): void
{
if (! in_array(config('app.env'), ['local', 'testing'], true)) {
$this->warn('Журнал кошелька не сброшен: это не локальная среда. История демо-денег может выглядеть непоследовательно.');
return;
}
DB::transaction(function () use ($tenantId) {
DB::statement("SET LOCAL session_replication_role = 'replica'");
DB::table('balance_transactions')->where('tenant_id', $tenantId)->delete();
});
}
/**
* «Конкурентное поле» для того же демо-тенанта конкуренты-стоматологии (выдуманные, но
* правдоподобные), чтобы съёмка семи разборов автоподбора шла на ОДНОМ клиенте вместе с
* остальными тринадцатью, а не на постороннем демо-тенанте займов (было: OmegaDemoFieldSeeder).
*
* Идемпотентно: updateOrCreate по (tenant_id, search_run_id, dedup_key) для конкурентов и
* (competitor_id, dedup_key) для источников те же естественные ключи, что и в остальной
* команде.
*
* Пометки у номеров (три штатных вида, §14 спеки): 'real' настоящий, 'substitute'
* подменный (коллтрекинг), 'uncertain' «на сайте, проверьте» (не подтверждено ссылкой).
* У «Белый Зуб» все три пометки на одной карточке (тема «Пометки у найденных номеров»),
* у «Улыбка Плюс» «uncertain» как единственная пометка (вторая карточка для той же темы).
*
* 🔴 «Белый Зуб (доп. филиал)» НАСТОЯЩИЙ дубль «Белый Зуб» (тот же site_url): нужен теме
* «Дубли конкурентов». Совпадение ищет CompetitorIdentity::keys() ТОЛЬКО по site_url и
* directory_urls самого конкурента (не по источникам) поэтому у дубля намеренно НЕТ ни
* одного источника: слияние в «Белый Зуб» не добавит и не переставит его карточки источников,
* порядок //🎭 для темы «Пометки у номеров» остаётся стабильным независимо от того, в каком
* порядке идёт съёмка тем «Дубли конкурентов» и «Пометки у номеров» в одном прогоне.
*
* directory_urls: id карточек 2ГИС/Яндекса РАЗНЫЕ и НЕ «0» у каждого конкурента (кроме
* намеренного дубля, где сходится site_url). id «0» CompetitorIdentity::directoryKey()
* фильтрует как пустышку баг 13.07.2026 в OmegaDemoFieldSeeder (все фирмы на /firm/0 ложно
* склеились в дубли) здесь не воспроизводим: используем несовпадающие числовые id.
*/
/**
* Цены на стенде = цены боевого liderra.ru (снимок админки 14.07.2026): сетка за лид
* 55/50/45/40/35/30/25 и допуслуги «Конкурентного поля» подбор конкурентов 50 ,
* изучение конкурента 10 .
*
* Зачем: кадры разборов снимаются с НАСТОЯЩЕГО портала, и на них видны цены. Локальные
* тестовые тарифы (500→250 из PricingTierSeeder, допуслуги 300/50 ) в десять раз выше
* боевых гость на лендинге увидел бы чужой ценник и ушёл. Правится тут, а не в
* PricingTierSeeder: тот общий дефолт для тестов, его трогать нельзя.
*/
private function seedPrices(): void
{
$grid = [
['tier_no' => 1, 'leads_in_tier' => 500, 'price_per_lead_kopecks' => 55_00],
['tier_no' => 2, 'leads_in_tier' => 500, 'price_per_lead_kopecks' => 50_00],
['tier_no' => 3, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 45_00],
['tier_no' => 4, 'leads_in_tier' => 2500, 'price_per_lead_kopecks' => 40_00],
['tier_no' => 5, 'leads_in_tier' => 5000, 'price_per_lead_kopecks' => 35_00],
['tier_no' => 6, 'leads_in_tier' => 5000, 'price_per_lead_kopecks' => 30_00],
['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25_00],
];
// Портал берёт действующую версию сетки по дате (effective_from ≤ сегодня). Прежние
// версии гасим, чтобы на кадрах не всплыл старый тестовый ценник.
PricingTier::query()->update(['is_active' => false]);
foreach ($grid as $row) {
PricingTier::query()->updateOrCreate(
['tier_no' => $row['tier_no'], 'effective_from' => '2026-01-01'],
$row + ['is_active' => true]
);
}
$services = [
'autopodbor_price_search_rub' => ['50', 'Сбор конкурентов — ₽ за успешный подбор'],
'autopodbor_price_study_rub' => ['10', 'Сбор источников — ₽ за изучение конкурента'],
];
foreach ($services as $key => [$value, $description]) {
SystemSetting::query()->updateOrCreate(
['key' => $key],
[
'value' => $value,
'type' => 'string',
'description' => $description,
'updated_at' => now(),
]
);
}
}
private function seedCompetitorField(Tenant $tenant): void
{
// Фича выключена системным флагом по умолчанию (миграция 2026_06_28_110000, value='0') —
// без этого /autopodbor у демо-клиента был бы недоступен. Флаг общесистемный, не per-tenant.
SystemSetting::query()->updateOrCreate(
['key' => 'autopodbor_enabled'],
[
'value' => '1',
'type' => 'bool',
'description' => 'Автоподбор конкурентов: вкл/выкл вкладку',
'updated_at' => now(),
]
);
// 🔴 Поле конкурентов пересобираем НАЧИСТО. Оно живое: в портале конкурентов
// склеивают, добавляют вручную, удаляют — и демо-поле после первого же опыта
// перестаёт совпадать с тем, что ждут сценарии съёмки (они целятся в карточки по
// номеру: «вторая», «третья»). Реальный случай 14.07.2026: после проверки склейки
// «Дента-Люкс» исчезла, завелись чужие карточки — и робот-съёмщик не нашёл легенду
// пометок, потому что вторая карточка оказалась без источников. Правки касаются
// ТОЛЬКО демо-тенанта; проекты на автоподбор не ссылаются (внешних ключей нет).
AutopodborSource::query()->where('tenant_id', $tenant->id)->delete();
AutopodborCompetitor::query()->where('tenant_id', $tenant->id)->delete();
AutopodborMergeEvent::query()->where('tenant_id', $tenant->id)->delete();
$run = AutopodborRun::query()->updateOrCreate(
['tenant_id' => $tenant->id, 'kind' => 'search', 'region_code' => 77],
[
'status' => 'done',
'params' => ['region' => 'Москва'],
'started_at' => now(),
'finished_at' => now(),
]
);
$gis = fn (int $id): string => "https://2gis.ru/msk/firm/{$id}";
$ya = fn (string $slug): string => "https://yandex.ru/maps/org/{$slug}";
// box=field — конкуренты, которых клиент уже взял в работу. Экран сортирует карточки по
// убыванию relevance_pct (FieldWorkspaceScreen), поэтому порядок этого массива (relevance
// по убыванию) даёт тот же порядок карточек на экране — сценарии съёмки полагаются на
// конкретные позиции (nth-of-type).
$field = [
[
'name' => 'Дента-Люкс', 'site' => 'denta-lux-demo.ru', 'federal' => false, 'rel' => 96,
'desc' => 'Сеть стоматологий, брекеты и импланты',
'dirs' => [$gis(500001), $ya('dentalux')],
'sources' => [
['site', 'denta-lux-demo.ru', null, null],
['call', '79990000101', 'real', 'city'],
],
],
[
// Тема «Пометки у номеров», карточка 1: все три пометки сразу.
'name' => 'Белый Зуб', 'site' => 'belyi-zub-demo.ru', 'federal' => false, 'rel' => 91,
'desc' => 'Стоматология полного цикла, отбеливание',
'dirs' => [$gis(500002), $ya('belyizub')],
'sources' => [
['call', '79990000102', 'real', 'mobile'],
['call', '79990000103', 'uncertain', 'city'],
['call', '79990000104', 'substitute', 'city'],
],
],
[
// Тема «Пометки у номеров», карточка 2: «на сайте — проверьте» отдельной строкой.
'name' => 'Улыбка Плюс', 'site' => 'ulybka-plus-demo.ru', 'federal' => true, 'rel' => 86,
'desc' => 'Федеральная сеть, детская и взрослая стоматология',
'dirs' => [$gis(500003)],
'sources' => [
['site', 'ulybka-plus-demo.ru', null, null],
['call', '79990000105', 'uncertain', 'mobile'],
],
],
[
// Никогда не изучался — нужен теме «Собрать источники конкурента» (первый сбор).
'name' => 'Стома-Эксперт', 'site' => 'stoma-expert-demo.ru', 'federal' => true, 'rel' => 81,
'desc' => 'Федеральная сеть стоматологических клиник',
'dirs' => [$ya('stomaekspert')],
'sources' => [],
],
[
// Источник без проекта — нужен теме «Проект из найденного источника».
'name' => 'Мега-Дент', 'site' => 'mega-dent-demo.ru', 'federal' => true, 'rel' => 76,
'desc' => 'Федеральная сеть, импланты под ключ',
'dirs' => [$gis(500005)],
'sources' => [
['site', 'mega-dent-demo.ru', null, null],
['call', '79990000106', 'real', 'tollfree'],
],
],
[
// Настоящий дубль «Белый Зуб» — см. докблок метода. Источников нарочно нет.
'name' => 'Белый Зуб (доп. филиал)', 'site' => 'belyi-zub-demo.ru', 'federal' => false, 'rel' => 74,
'desc' => 'Второй кабинет той же сети — филиал на Взлётке',
'dirs' => [$gis(500006)],
'sources' => [],
'dedupSuffix' => ':branch',
],
];
// box=proposal — найдено движком, клиент ещё не разобрал. Сайты нарочно не пересекаются
// ни с одним конкурентом поля — единственная запланированная дублирующаяся пара выше.
$proposals = [
['name' => 'Смайл Студио', 'site' => 'smile-studio-demo.ru', 'federal' => false, 'rel' => 68, 'desc' => 'Студия эстетической стоматологии'],
['name' => 'Дентал Ай Ти', 'site' => 'dental-it-demo.ru', 'federal' => true, 'rel' => 62, 'desc' => 'Сеть стоматологий с онлайн-записью'],
];
foreach ($field as $c) {
$studied = $c['sources'] !== [];
$comp = AutopodborCompetitor::query()->updateOrCreate(
[
'tenant_id' => $tenant->id,
'search_run_id' => $run->id,
'dedup_key' => 'site:'.$c['site'].($c['dedupSuffix'] ?? ''),
],
[
'name' => $c['name'],
'description' => $c['desc'],
'is_federal' => $c['federal'],
'relevance_pct' => $c['rel'],
'origin' => 'auto',
'box' => 'field',
'site_url' => $c['site'],
'directory_urls' => $c['dirs'],
'elements' => [['name' => $c['name'], 'sites' => [$c['site']], 'cards' => $c['dirs']]],
'study_run_id' => $studied ? $run->id : null,
'studied_at' => $studied ? now() : null,
]
);
foreach ($c['sources'] as [$type, $ident, $kind, $ptype]) {
AutopodborSource::query()->updateOrCreate(
['competitor_id' => $comp->id, 'dedup_key' => $type.':'.$ident],
[
'tenant_id' => $tenant->id,
'study_run_id' => $run->id,
'signal_type' => $type,
'identifier' => $ident,
'phone_kind' => $kind,
'phone_type' => $ptype,
'box' => 'field',
'provenance_label' => $type === 'site' ? 'сайт компании' : 'карточка в 2ГИС',
]
);
}
}
foreach ($proposals as $c) {
AutopodborCompetitor::query()->updateOrCreate(
['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'dedup_key' => 'site:'.$c['site']],
[
'name' => $c['name'],
'description' => $c['desc'],
'is_federal' => $c['federal'],
'relevance_pct' => $c['rel'],
'origin' => 'auto',
'box' => 'proposal',
'site_url' => $c['site'],
]
);
}
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Чистка учёта посетителей: гости старше N дней (по умолчанию 180 срок из спеки).
* Держим срок хранения коротким, потому что в site_visitors лежит сырой IP (ПДн).
* Партиции site_events удаляет partitions:drop-expired по retention-настройке.
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §6, §9
*/
class VisitorsPrune extends Command
{
/** @var string */
protected $signature = 'visitors:prune {--days=180 : Сколько дней хранить гостей}';
/** @var string */
protected $description = 'Удаляет посетителей старше N дней (учёт посещений, 152-ФЗ)';
public function handle(): int
{
$days = (int) $this->option('days');
$deleted = DB::connection('pgsql_supplier')
->table('site_visitors')
->where('last_seen_at', '<', now()->subDays($days))
->delete();
$this->info("Удалено гостей: {$deleted} (старше {$days} дней)");
return self::SUCCESS;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
use RuntimeException;
final class BatchBudgetException extends RuntimeException
{
public function __construct(public readonly string $topupRub, public readonly int $maxAffordable)
{
parent::__construct('insufficient_balance_for_batch');
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
class RunInFlightException extends \RuntimeException {}
@@ -411,6 +411,7 @@ class AdminDashboardController extends Controller
'dadata' => (string) config('services.dadata.topup_url') ?: null,
'supplier' => (string) config('services.supplier.topup_url') ?: null,
'yandex_cloud' => $this->ycTopupUrl(),
'email' => (string) config('services.yandex360.topup_url') ?: null,
default => null,
};
}
@@ -31,12 +31,21 @@ use Illuminate\Support\Facades\DB;
*/
class AdminSystemSettingsController extends Controller
{
/** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */
private const SENSITIVE_KEYS = ['supplier_webhook_secret'];
/** GET /api/admin/system-settings */
public function index(): JsonResponse
{
return response()->json([
'settings' => SystemSetting::orderBy('key')->get(),
]);
$settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) {
if (in_array($s->key, self::SENSITIVE_KEYS, true)) {
$s->value = $s->value === '' ? '' : '••• (скрыто)';
}
return $s;
});
return response()->json(['settings' => $settings]);
}
/** PUT /api/admin/system-settings/{key} */
@@ -0,0 +1,142 @@
<?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\Carbon;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin «Посетители» read-only агрегаты своего счётчика.
* Под группой ['saas-admin','admin-db'] default-коннект = pgsql_admin.
* Роботы в site_visitors не попадают вовсе (они не исполняют JS); гости
* с is_datacenter=true (хостинг/VPN) в воронку не входят, но показываются отдельно.
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §8
*/
class AdminVisitorsController extends Controller
{
/** @return array{0:Carbon,1:Carbon} */
private function periodRange(Request $request): array
{
$from = match ((string) $request->query('period', '7d')) {
'today' => now()->startOfDay(),
'30d' => now()->subDays(30),
default => now()->subDays(7),
};
return [$from, now()];
}
/** Гости-люди периода (без хостингов/VPN). */
private function peopleIds(Carbon $from, Carbon $to): Builder
{
return DB::table('site_visitors')
->whereBetween('first_seen_at', [$from, $to])
->where('is_datacenter', false)
->select('id');
}
public function funnel(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$counts = [];
foreach (['view', 'alive', 'cta_click', 'register_open', 'register_done', 'login_done'] as $e) {
$counts[$e] = (int) DB::table('site_events')
->whereBetween('occurred_at', [$from, $to])
->where('event', $e)
->whereIn('visitor_id', $this->peopleIds($from, $to))
->distinct('visitor_id')
->count('visitor_id');
}
$vpn = (int) DB::table('site_visitors')
->whereBetween('first_seen_at', [$from, $to])
->where('is_datacenter', true)
->count();
return response()->json([
'period' => (string) $request->query('period', '7d'),
'funnel' => $counts,
'vpn_or_hosting' => $vpn,
]);
}
public function channels(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$rows = DB::table('site_visitors as v')
->whereBetween('v.first_seen_at', [$from, $to])
->where('v.is_datacenter', false)
->groupBy('v.channel')
->select([
'v.channel',
DB::raw('COUNT(*)::int AS people'),
DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'cta_click'))::int AS cta_clicks"),
DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'register_done'))::int AS registrations"),
DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'login_done'))::int AS logins"),
])
->orderByDesc('people')
->get();
return response()->json(['channels' => $rows]);
}
public function visits(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$rows = DB::table('site_visitors as v')
->whereBetween('v.first_seen_at', [$from, $to])
->orderByDesc('v.first_seen_at')
->limit(200)
->select([
'v.id', 'v.first_seen_at', 'v.channel', 'v.device', 'v.city', 'v.asn_org',
'v.is_datacenter', 'v.is_human', 'v.ip', 'v.tenant_id',
DB::raw('(SELECT array_agg(DISTINCT e.event) FROM site_events e WHERE e.visitor_id = v.id) AS steps'),
])
->get()
->map(function (object $r) {
$r->steps = $r->steps ? explode(',', trim((string) $r->steps, '{}')) : [];
$r->is_datacenter = (bool) $r->is_datacenter;
$r->is_human = (bool) $r->is_human;
return $r;
});
return response()->json(['visits' => $rows]);
}
public function portalActivity(Request $request): JsonResponse
{
[$from, $to] = $this->periodRange($request);
$screens = DB::table('site_events')
->whereBetween('occurred_at', [$from, $to])
->where('event', 'portal_screen')
->whereNotNull('screen')
->groupBy('screen')
->select(['screen', DB::raw('COUNT(*)::int AS opens'), DB::raw('COUNT(DISTINCT visitor_id)::int AS people')])
->orderByDesc('opens')
->limit(20)
->get();
$clients = DB::table('site_visitors as v')
->join('tenants as t', 't.id', '=', 'v.tenant_id')
->whereBetween('v.last_seen_at', [$from, $to])
->whereNotNull('v.tenant_id')
->groupBy('t.id', 't.organization_name', 't.subdomain')
->select(['t.organization_name as name', 't.subdomain', DB::raw('MAX(v.last_seen_at) AS last_seen_at')])
->orderByDesc('last_seen_at')
->limit(50)
->get();
return response()->json(['screens' => $screens, 'clients' => $clients]);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\External\Yandex360BalanceStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* SaaS-admin Система: ручной ввод баланса Яндекс 360 (почта).
* Владелец периодически вписывает сумму из кабинета (у Яндекса нет API баланса).
*/
class AdminYandex360BalanceController extends Controller
{
public function __construct(private readonly Yandex360BalanceStore $store) {}
/** GET /api/admin/settings/yandex360-balance */
public function show(): JsonResponse
{
return response()->json($this->store->status());
}
/** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */
public function update(Request $request): JsonResponse
{
$raw = $request->input('balance');
$balance = ($raw === null || $raw === '') ? null : (float) $raw;
if ($balance !== null && $balance < 0) {
return response()->json([
'message' => 'Баланс не может быть отрицательным.',
'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']],
], 422);
}
$this->store->set($balance);
return response()->json($this->store->status());
}
}
@@ -12,6 +12,7 @@ use App\Models\ImpersonationToken;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use App\Services\Tracking\VisitTracker;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -144,6 +145,9 @@ class AuthController extends Controller
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
app(UserSessionTracker::class)->record($request, $user->id);
// Учёт посетителей (spec 2026-07-13): связываем гостя с лендинга и вход в кабинет.
app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done');
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Repositories\PricingTierRepository;
@@ -94,6 +95,40 @@ class BillingController extends Controller
* считается на лету через BalanceToLeadsConverter (точный расчёт по
* ступеням, не «по текущей»). Тариф унифицирован до name+features.
*/
/**
* GET /api/billing/last-payment судьба последней онлайн-оплаты за сутки.
*
* Кабинет обязан говорить правду: если шлюз отменил платёж (человек не довёл оплату
* до конца), клиент должен увидеть «оплата не завершена» и кнопку «Оплатить снова»,
* а не вечное «платёж обрабатывается, баланс скоро обновится» (инцидент 14.07.2026:
* платёж на 10 000 отменён ЮKassa, а кабинет обещал зачисление).
*
* Успешные платежи не возвращаем там и так виден пополненный баланс.
*/
public function lastPayment(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$tx = SaasTransaction::query()
->where('tenant_id', (int) $user->tenant_id)
->where('type', SaasTransaction::TYPE_TOPUP)
->whereIn('status', [SaasTransaction::STATUS_PENDING, SaasTransaction::STATUS_FAILED])
->where('created_at', '>=', Carbon::now()->subDay())
->latest('id')
->first();
return response()->json([
'data' => $tx === null ? null : [
'id' => $tx->id,
'status' => $tx->status,
'amount_rub' => (string) $tx->amount_rub,
'failure_reason' => $tx->failure_reason,
'created_at' => $tx->created_at?->toIso8601String(),
],
]);
}
public function wallet(Request $request): JsonResponse
{
/** @var User $user */
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessChatMessageJob;
use App\Models\BotDialog;
use App\Services\Bot\ChatRateLimiter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Свой чат (спека 2026-07-13-own-chat-widget-design §5). Заменяет вебхук Jivo.
* Реплику клиента пишем ЗДЕСЬ и возвращаем её номер: окошко опрашивает ответы
* «после» него и не получает обратно собственное сообщение.
* Публичный адрес (лендинг открыт всему интернету) защита в ChatRateLimiter (Task 4).
*/
class ChatController extends Controller
{
public function send(Request $request): JsonResponse
{
$data = $request->validate([
'chat_id' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'],
'text' => ['required', 'string', 'max:1000'],
]);
$text = trim($data['text']);
if ($text === '') {
return response()->json(['message' => 'Пустое сообщение.'], 422);
}
$userId = Auth::id();
if ($this->deniesAccess($data['chat_id'])) {
return response()->json(['message' => 'Разговор не найден.'], 404);
}
$refusal = app(ChatRateLimiter::class)
->check($data['chat_id'], (string) $request->ip(), $userId !== null);
if ($refusal !== null) {
return response()->json(['message' => $refusal], 429);
}
$row = BotDialog::create([
'chat_id' => $data['chat_id'],
'direction' => 'in',
'message' => $text,
'source' => $userId !== null ? 'portal' : 'landing',
'user_id' => $userId,
'ip' => $request->ip(),
'created_at' => now(),
]);
ProcessChatMessageJob::dispatch($data['chat_id'], (int) $row->id, $text);
return response()->json(['message_id' => (int) $row->id]);
}
/**
* Опрос окошка: «что нового в разговоре после реплики N?».
* after=0 вся переписка (клиент перезагрузил страницу и вернулся).
*/
public function messages(Request $request, string $chat): JsonResponse
{
if ($this->deniesAccess($chat)) {
return response()->json(['message' => 'Разговор не найден.'], 404);
}
$after = max(0, (int) $request->query('after', '0'));
$rows = BotDialog::query()
->where('chat_id', $chat)
->where('id', '>', $after)
->orderBy('id')
->limit(100)
->get(['id', 'direction', 'message', 'created_at']);
return response()->json([
'messages' => $rows->map(fn (BotDialog $d) => [
'id' => (int) $d->id,
'direction' => $d->direction,
'text' => $d->message,
'at' => $d->created_at?->toIso8601String(),
])->all(),
]);
}
/**
* Владелец разговора: первый вошедший клиент, писавший в него. Личные цифры (баланс,
* проекты) уходят в переписку значит, чужому её видеть нельзя, а ключ разговора
* живёт в браузере и может попасть в чужие руки.
*/
private function ownerOf(string $chatId): ?int
{
$owner = BotDialog::query()
->where('chat_id', $chatId)
->whereNotNull('user_id')
->orderBy('id')
->value('user_id');
return $owner === null ? null : (int) $owner;
}
/** Чужому не подтверждаем даже существование разговора — 404, не 403. */
private function deniesAccess(string $chatId): bool
{
$owner = $this->ownerOf($chatId);
return $owner !== null && $owner !== Auth::id();
}
}
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
@@ -13,6 +14,7 @@ use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use App\Support\SupplierProjectName;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -104,7 +106,7 @@ class DealController extends Controller
$query = Deal::query()
->select('deals.*')
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -206,12 +208,31 @@ class DealController extends Controller
});
}
// Автоподбор: по каждой сделке — конкурент + источник (связь
// deal.project_id → autopodbor_sources.created_project_id → competitor).
// Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с
// app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк.
$sourcesByProject = collect();
$projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all();
if ($projectIds !== []) {
$sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return AutopodborSource::query()
->where('tenant_id', $tenantId)
->whereIn('created_project_id', $projectIds)
->with('competitor:id,name')
->get()
->groupBy('created_project_id');
});
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => $d->project?->name,
'project_name' => SupplierProjectName::strip($d->project?->name),
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
@@ -230,6 +251,21 @@ class DealController extends Controller
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'cost_kopecks' => $costByDeal[$d->id] ?? null,
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
...(function () use ($d, $sourcesByProject) {
$group = $sourcesByProject[$d->project_id] ?? collect();
$repr = $group->firstWhere('box', 'field') ?? $group->first();
return [
'competitor_id' => $repr?->competitor_id,
'competitor_name' => $repr?->competitor?->name,
'source_identifier' => $repr?->identifier,
'source_signal_type' => $repr?->signal_type,
'source_project_id' => $repr ? $d->project_id : null,
'source_is_active' => $repr ? (bool) $d->project?->is_active : null,
'source_extra_count' => max(0, $group->count() - 1),
];
})(),
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -308,7 +344,7 @@ class DealController extends Controller
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => $deal->project?->name,
'project_name' => SupplierProjectName::strip($deal->project?->name),
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -121,7 +122,7 @@ class DealExportController extends Controller
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
@@ -5,10 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\PaymentSettlementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -34,7 +32,7 @@ class PaymentWebhookController extends Controller
{
public function __construct(
private readonly PaymentGatewayDriver $driver,
private readonly BillingTopupService $topupService,
private readonly PaymentSettlementService $settlement,
) {}
public function receive(Request $request): JsonResponse
@@ -60,60 +58,11 @@ class PaymentWebhookController extends Controller
return response()->json(['status' => 'unknown'], 200);
}
$gateway = $this->gatewayFor($tx);
// Server-to-server сверка — источник правды о статусе. Дальше судьбу платежа
// (зачислить / закрыть как отменённый / ждать) решает PaymentSettlementService —
// тот же путь, что и у команды billing:reconcile-payments.
$verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId);
// Server-to-server сверка — источник правды о статусе.
$verify = $this->driver->verifyPayment($gateway, $paymentId);
if (! $verify->isSucceeded()) {
return response()->json(['status' => 'not_paid'], 200);
}
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
if ($verify->gatewayPaymentId !== $paymentId) {
return response()->json(['status' => 'ignored'], 200);
}
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
if ($verify->currency !== 'RUB') {
return response()->json(['status' => 'currency_mismatch'], 200);
}
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
return response()->json(['status' => 'amount_mismatch'], 200);
}
DB::transaction(function () use ($tx, $verify) {
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
$claimed = SaasTransaction::where('id', $tx->id)
->where('status', SaasTransaction::STATUS_PENDING)
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
if ($claimed === 0) {
return; // идемпотентный no-op
}
$balanceTx = $this->topupService->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, null
);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'payment_method' => $verify->paymentMethod,
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
]);
});
return response()->json(['status' => 'ok'], 200);
}
private function gatewayFor(object $tx): PaymentGateway
{
return $tx->gateway_id !== null
? PaymentGateway::findOrFail($tx->gateway_id)
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200);
}
}
@@ -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 */
@@ -12,13 +12,14 @@ use App\Http\Requests\Auth\ResendCodeRequest;
use App\Models\User;
use App\Services\Auth\RegistrationException;
use App\Services\Auth\RegistrationService;
use App\Services\Tracking\VisitTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 300 при подтверждении.
* pending_email_confirm, активируется и получает 1000 при подтверждении.
*/
class RegistrationController extends Controller
{
@@ -69,6 +70,10 @@ class RegistrationController extends Controller
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
// Учёт посетителей (spec 2026-07-13): регистрация состоялась именно здесь —
// на /register клиента ещё нет, есть только ожидающая подтверждения заявка.
app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'register_done');
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -7,8 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -38,16 +37,37 @@ class TenantChargesController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
// Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG
// superuser BYPASSRLS, и без явного where() запрос вернул бы строки
// других тенантов (см. InAppNotificationController паттерн).
$query = LeadCharge::query()
$period = $request->query('period');
$source = $request->query('charge_source');
// Explicit tenant_id фильтр — defense-in-depth поверх RLS (в тестах PG superuser BYPASSRLS).
// Лид-списания.
$lead = DB::table('lead_charges')
->where('tenant_id', $tenantId)
->orderBy('charged_at', 'desc');
->selectRaw("id, charged_at, 'lead' as kind, deal_id, tier_no, charge_source, price_per_lead_kopecks, cast(null as text) as label");
$this->applyPeriodTo($lead, 'charged_at', $period);
if (is_string($source) && $source !== '') {
$lead->where('charge_source', $source);
}
$this->applyFilters($query, $request);
// Списания автоподбора (balance_transactions) — тоже показываем в «Списаниях», иначе клиент
// видит их только в «Истории транзакций». Фильтр charge_source (prepaid/rub) к автоподбору
// неприменим → при выбранном источнике автоподбор не подмешиваем.
if (! is_string($source) || $source === '') {
$auto = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->selectRaw("id, created_at as charged_at, 'autopodbor' as kind, cast(null as bigint) as deal_id, cast(null as integer) as tier_no, cast(null as varchar) as charge_source, cast(round(abs(amount_rub) * 100) as bigint) as price_per_lead_kopecks, cast('Автоподбор конкурентов' as text) as label");
$this->applyPeriodTo($auto, 'created_at', $period);
$rows = $lead->unionAll($auto);
} else {
$rows = $lead;
}
$page = $query->paginate(20);
$page = DB::query()->fromSub($rows, 'c')
->orderByDesc('charged_at')
->orderByDesc('id')
->paginate(20);
return response()->json([
'data' => $page->items(),
@@ -141,6 +161,29 @@ class TenantChargesController extends Controller
}
});
// Списания автоподбора — тоже в CSV (после лид-строк). charge_source к ним неприменим,
// поэтому при выбранном источнике их не выгружаем (как и в списке).
if ($source === null || $source === '') {
$autoq = DB::table('balance_transactions')
->where('tenant_id', $tenantId)
->where('type', 'autopodbor_charge')
->orderByDesc('created_at')
->orderByDesc('id');
$this->applyPeriodTo($autoq, 'created_at', $period);
$autoq->chunk(500, function ($rows) use ($out) {
foreach ($rows as $r) {
fputcsv($out, [
Carbon::parse($r->created_at)->toIso8601String(),
'Автоподбор конкурентов',
'',
'',
number_format(abs((float) $r->amount_rub), 2, '.', ''),
$r->balance_rub_after ?? '',
]);
}
});
}
fclose($out);
});
}, 200, [
@@ -150,22 +193,12 @@ class TenantChargesController extends Controller
}
/**
* @param Builder<LeadCharge> $query
* Фильтр периода на произвольную колонку/билдер (lead_charges.charged_at либо
* balance_transactions.created_at) единый для лид- и автоподбор-веток union.
*
* @param Builder $query
*/
private function applyFilters($query, Request $request): void
{
$this->applyPeriodFilter($query, $request->query('period'));
$source = $request->query('charge_source');
if (is_string($source) && $source !== '') {
$query->where('charge_source', $source);
}
}
/**
* @param Builder<LeadCharge> $query
*/
private function applyPeriodFilter($query, mixed $period): void
private function applyPeriodTo($query, string $column, mixed $period): void
{
if (! is_string($period) || $period === '') {
return;
@@ -174,14 +207,14 @@ class TenantChargesController extends Controller
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('charged_at', '>=', $now->copy()->startOfMonth());
$query->where($column, '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('charged_at', [
$query->whereBetween($column, [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('charged_at', '>=', $now->copy()->subDays(90));
$query->where($column, '>=', $now->copy()->subDays(90));
}
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Tracking\VisitTracker;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Публичный приёмник маячка. Робот сюда не постучится: запрос шлёт JS страницы.
* Кука lid_vid на домен .liderra.ru, поэтому лендинг и кабинет видят одного гостя.
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §4-5
*/
class TrackController extends Controller
{
public function store(Request $request, VisitTracker $tracker): Response
{
$data = $request->validate([
'event' => 'required|string|in:'.implode(',', VisitTracker::EVENTS),
'host' => 'nullable|string|max:64',
'path' => 'nullable|string|max:500',
'screen' => 'nullable|string|max:64',
'referrer' => 'nullable|string|max:1000',
'utm' => 'nullable|array',
'utm.*' => 'nullable|string|max:128',
]);
// register_done / login_done пишет только backend — с фронта не принимаем.
if (in_array($data['event'], ['register_done', 'login_done'], true)) {
abort(422, 'событие пишется сервером');
}
$vid = $tracker->ensureVisitor($request->cookie('lid_vid'), [
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'referrer' => $data['referrer'] ?? $request->header('referer'),
'path' => $data['path'] ?? null,
'utm' => $data['utm'] ?? [],
]);
$tracker->record($vid, $data['event'], [
'host' => $data['host'] ?? null,
'path' => $data['path'] ?? null,
'screen' => $data['screen'] ?? null,
]);
return response()->noContent()->cookie(
'lid_vid', $vid, 60 * 24 * 365, '/', '.liderra.ru', true, true, false, 'lax'
);
}
}
@@ -10,6 +10,7 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
use App\Models\User;
use App\Models\UserRecoveryCode;
use App\Services\Tracking\VisitTracker;
use App\Services\UserSessionTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
@@ -100,6 +101,9 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
// Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет.
app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done');
$this->logAuthEvent(
'2fa_verify_success',
$user->id,
@@ -204,6 +208,9 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
app(UserSessionTracker::class)->record($request, $user->id);
// Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет.
app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done');
$this->logAuthEvent(
'2fa_recovery_used',
$user->id,
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Support\SupplierProjectName;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -87,7 +88,7 @@ class DealsController extends Controller
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => $d->project?->name,
'project' => SupplierProjectName::strip($d->project?->name),
])->all(),
'next_cursor' => $next,
]);
@@ -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 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,
'elements' => $this->elements ?? [],
'studied_at' => $this->studied_at?->toIso8601String(),
'study_run_id' => $this->study_run_id,
'search_run_id' => $this->search_run_id,
];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Autopodbor;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborSource;
use App\Services\Autopodbor\AutopodborQueue;
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,
// Клиенту показать, что идёт работа: место в общей очереди (пока queued) и текущий этап (пока running).
'queue_position' => $this->status === 'queued' ? app(AutopodborQueue::class)->pending() : null,
'progress' => $this->progress,
'result' => $this->result,
'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,113 @@
<?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, public ?int $tenantId = null)
{
// Резолв по имени — тоже шаг 1 (ИИ-поиск кандидатов), поэтому на одной очереди с поиском.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай).
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
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,216 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Autopodbor;
use App\Mail\AutopodborReadyMail;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\CompetitorIdentity;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class RunAutopodborSearchJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// ОДИН прогон без авто-ретраев: движок платный (xfetch+ИИ+EXA), и повтор всего прогона при
// таймауте = тройная цена и время. Медленный-но-настоящий ответ теперь ждём большими таймаутами
// стадий (config services.aitunnel/exa), а не гоняем движок трижды.
public int $tries = 1;
// Живой поиск (2ГИС+Яндекс через антибот + канал В sonar ×2 + EXA + «вскрыть всё»: параллельное
// открытие карточек без лимита) идёт минутами — большой запас, чтобы не убить прогон на середине.
public int $timeout = 3600;
public function __construct(public int $runId, public ?int $tenantId = null)
{
// Шаг 1 «собрать поле» (ИИ + внешние сервисы) — своя очередь, отдельный воркер-процесс,
// чтобы тяжёлый поиск не стоял в одной линии с быстрым шагом 2 (изучением) другого клиента.
$this->onQueue('autopodbor-find');
}
/**
* Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом
* (ретрай/повторный dispatch). Ключ runId.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new WithoutOverlapping((string) $this->runId)];
}
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity, AutopodborNormalizer $normalizer, RunProgressChannel $progress): void
{
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок будет отмечать этапы именно сюда.
$progress->bind($run->id);
try {
$p = $run->params;
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
$request = new FindCompetitorsRequest(
regionCode: (int) $run->region_code,
examples: $p['examples'] ?? [],
aboutSelf: $p['about_self'] ?? [],
includeFederal: (bool) ($p['include_federal'] ?? false),
maxCompetitors: $max,
);
// Авто-повтор при ПУСТОМ сборе: внешние источники (2ГИС/Яндекс/EXA) иногда моргают и
// отдают пусто — вместо «ничего не нашлось» пробуем ещё раз (по умолчанию до 2 доп. попыток).
// Реально пустая ниша (все попытки пусты) идёт дальше штатным путём «empty» — без списания.
$retries = max(0, (int) config('autopodbor.search_retry_on_empty', 2));
$attempt = 0;
do {
$attempt++;
$res = $agent->findCompetitors($request);
} while ($res->competitors === [] && $attempt <= $retries);
$unique = $dedup->dedupCompetitors($res->competitors);
// На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же
// опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с
// АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы —
// при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои
// результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем.
$activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
->where('box', '!=', 'archived')
->where(function ($q) use ($run) {
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
})
->get(['site_url', 'directory_urls'])
->map(fn ($c) => array_flip($identity->keys([
'site_url' => $c->site_url,
'directory_urls' => $c->directory_urls ?? [],
])))
->all();
$unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool {
$keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]);
if ($keys === []) {
return true;
}
foreach ($activeKeySets as $have) {
$shares = false;
$hasNew = false;
foreach ($keys as $k) {
if (isset($have[$k])) {
$shares = true;
} else {
$hasNew = true;
}
}
if ($shares && ! $hasNew) {
return false; // полный дубль активной фирмы → скрыть
}
}
return true;
}));
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
$this->notifyReady($run, 0);
return;
}
// $max === 0 → «не режем»: сохраняем ВСЕХ найденных. Иначе array_slice(…, 0, 0) обнулил бы
// непустой результат (баг: прогон сохранял 0 конкурентов, но всё равно списывал).
$saved = $max > 0 ? array_slice($unique, 0, $max) : $unique;
foreach ($saved as $c) {
AutopodborCompetitor::updateOrCreate(
[
'tenant_id' => $run->tenant_id,
'search_run_id' => $run->id,
'dedup_key' => $c['dedup_key'],
],
[
'name' => $c['name'],
'description' => $c['description'] ?? null,
'is_federal' => (bool) ($c['is_federal'] ?? false),
'relevance_pct' => $c['relevance_pct'] ?? null,
'origin' => 'auto',
'site_url' => $c['site_url'] ?? null,
'directory_urls' => $c['directory_urls'] ?? [],
// Телефоны (нормализованные) — для показа совпадений клиенту на «Предложениях».
// В тихую склейку НЕ идут (решение 03.07) — только клиентская «Найти дубли».
'phones' => array_values(array_unique(array_filter(array_map(
static fn ($p): string => $normalizer->phone((string) $p),
$c['phones'] ?? [],
)))),
'provenance' => $c['provenance'] ?? [],
]
);
}
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
$charge->chargeForRun($run, $price);
$run->update(['status' => 'done', 'finished_at' => now()]);
$this->notifyReady($run, count($saved));
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
/**
* Письмо клиенту «подбор готов» чтобы он не ждал у экрана. Не роняет успешный подбор,
* если почта недоступна (try/catch + report).
*/
private function notifyReady(AutopodborRun $run, int $found): void
{
try {
$email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email');
if (is_string($email) && $email !== '') {
Mail::to($email)->send(new AutopodborReadyMail($run, $found));
}
} catch (\Throwable $e) {
report($e);
}
}
}
@@ -0,0 +1,179 @@
<?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\Services\Autopodbor\AutopodborStudyScheduler;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
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, public ?int $tenantId = null)
{
// Шаг 2 «изучение / дайте источники» — тупой слой (без ИИ). ОТДЕЛЬНАЯ очередь + свой воркер,
// чтобы изучение шло параллельно поиску, а не ждало его в общей линии.
$this->onQueue('autopodbor-study');
}
/**
* Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай).
*
* @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,
RunProgressChannel $progress,
AutopodborStudyScheduler $scheduler,
): void {
// Внешний try/finally: распорядитель должен взять следующего кандидата ПОСЛЕ ЛЮБОГО
// завершения этого прогона — done/empty/failed, включая ранние return'ы и проброшенное
// исключение из внутреннего catch. Дорожка шага 2 одна на весь портал (см.
// AutopodborStudyScheduler) — если не звать tick() здесь, освободившаяся дорожка простаивала
// бы до следующего крон-тика (autopodbor:study-tick, страховка раз в минуту).
try {
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
$progress->bind($run->id);
try {
$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 ?? [],
'elements' => $comp->elements ?? [],
'is_federal' => (bool) $comp->is_federal,
],
regionCode: (int) $run->region_code,
));
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
}
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
$newCount = 0;
$refoundDeletedIds = [];
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
$existing = AutopodborSource::where('competitor_id', $comp->id)
->where('dedup_key', $s['dedup_key'])
->first();
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,
]
);
if ($existing === null) {
$newCount++; // совсем новый → в предложения
} elseif ($existing->box === 'archived') {
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
}
}
$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(),
'result' => [
'new_sources' => $newCount,
'refound_deleted' => count($refoundDeletedIds),
'refound_deleted_ids' => array_values($refoundDeletedIds),
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
} finally {
$scheduler->tick();
}
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Bot;
use App\Mail\BotContactRequestMail;
use App\Models\BotDialog;
use App\Services\Bot\BotAnswer;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\ClientFacts;
use App\Services\Bot\ContactCapture;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Оркестратор ответа бота. Очередь `bot` отдельный worker, чтобы поток лидов
* не задерживал ответы чата (скорость требование №1). $tries=1: ретраить разговор
* бессмысленно. Реплику клиента пишет ChatController; сюда приходит её номер ($inId),
* чтобы история разговора не включала текущий вопрос дважды.
*
* Эскалация (правило владельца 12.07.2026): живого оператора в чате нет бот просит
* телефон, и контакт уходит письмом в поддержку.
*/
class ProcessChatMessageJob implements ShouldQueue
{
use Queueable;
public int $timeout = 12;
public int $tries = 1;
public function __construct(
public readonly string $chatId,
public readonly int $inId,
public readonly string $text,
) {
$this->onQueue('bot');
}
/**
* Сколько прошлых реплик отдаём боту как память разговора (5 пар вопрос-ответ).
* Было 3 пары в живом диалоге бот «забывал» цифру, названную им же в начале
* (прогон диалогами 12.07.2026).
*/
private const HISTORY_LIMIT = 10;
public function handle(): void
{
$startedAt = hrtime(true);
$history = BotDialog::query()
->where('chat_id', $this->chatId)
->where('id', '<', $this->inId)
->orderByDesc('id')
->limit(self::HISTORY_LIMIT)
->get(['direction', 'message'])
->reverse()
->map(fn (BotDialog $d) => [
'role' => $d->direction === 'in' ? 'user' : 'assistant',
'text' => trim((string) preg_replace('/\n*👉 Показать на портале:.*$/su', '', $d->message)),
])
->values()
->all();
// Кто пишет: вошедший клиент или гость с лендинга. Вошедшему собираем карточку
// фактов (баланс, ступень, проекты, заявки) — цифры считает КОД, не модель.
$row = BotDialog::query()->whereKey($this->inId)->first(['source', 'user_id']);
$fromPortal = ($row?->source ?? 'portal') !== 'landing';
$userId = $row?->user_id === null ? null : (int) $row->user_id;
// Карточка — не критичный путь: если посчитать не удалось (клиент удалён из базы,
// база моргнула), клиент НЕ должен остаться без ответа — отвечаем как гостю,
// но отвечаем, а не роняем всю задачу (tries=1 — повтора не будет).
$facts = null;
if ($userId !== null) {
try {
$facts = app(ClientFacts::class)->card($userId);
} catch (\Throwable $e) {
Log::error('Не удалось собрать карточку фактов клиента для бота', [
'chat_id' => $this->chatId,
'user_id' => $userId,
'error' => $e->getMessage(),
]);
}
}
// Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА,
// просил бот телефон или нет (прогон диалогами 12.07.2026: контакт терялся).
$answer = ($this->awaitsContact() || app(ContactCapture::class)->extractPhone($this->text) !== null)
? $this->captureContact($history, $fromPortal)
: app(BotAnswerService::class)->answer($this->text, $history, $fromPortal, $facts);
BotDialog::create([
'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(),
]);
}
/** Ждём ли мы от клиента телефон: последний ответ бота был просьбой оставить номер. */
private function awaitsContact(): bool
{
$lastOut = BotDialog::query()
->where('chat_id', $this->chatId)
->where('direction', 'out')
->where('id', '<', $this->inId)
->latest('id')
->value('message');
return is_string($lastOut) && str_contains($lastOut, 'номер телефона');
}
/**
* @param list<array{role: string, text: string}> $history
*/
private function captureContact(array $history = [], bool $fromPortal = true): BotAnswer
{
$phone = app(ContactCapture::class)->extractPhone($this->text);
if ($phone === null) {
// Клиент передумал и задал новый вопрос — отвечаем С ПАМЯТЬЮ разговора.
return app(BotAnswerService::class)->answer($this->text, $history, $fromPortal);
}
$rows = BotDialog::query()
->where('chat_id', $this->chatId)
->orderBy('id')
->get(['direction', 'message', 'source', 'user_id']);
$question = $rows
->filter(fn (BotDialog $d) => $d->direction === 'in')
->slice(0, -1)
->last()?->message ?? $this->text;
$transcript = $rows
->map(fn (BotDialog $d) => ($d->direction === 'in' ? 'Клиент: ' : 'Бот: ').$d->message)
->all();
$client = $rows->firstWhere(fn (BotDialog $d) => $d->direction === 'in');
$source = (string) ($client?->source ?? 'portal');
$userId = $client?->user_id !== null ? (int) $client?->user_id : null;
$to = (string) config('services.support.email');
if ($to !== '') {
// Письмо — В ОЧЕРЕДЬ. Прогон 12.07.2026: почта не ответила → упала вся задача
// (tries=1), и клиент, оставивший номер, не получил НИЧЕГО.
try {
Mail::to($to)->queue(new BotContactRequestMail($phone, $question, $this->chatId, $transcript, $source, $userId));
} catch (\Throwable $e) {
Log::error('Не удалось поставить в очередь письмо с контактом', [
'chat_id' => $this->chatId,
'error' => $e->getMessage(),
]);
}
}
return new BotAnswer(BotAnswerService::CONTACT_TAKEN_TEXT, escalate: true);
}
}
+87
View File
@@ -4,17 +4,23 @@ declare(strict_types=1);
namespace App\Jobs\External;
use App\Mail\ExternalServiceDownMail;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\LivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use App\Services\External\YooKassaLivenessProbe;
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;
use Illuminate\Support\Facades\Mail;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
@@ -37,11 +43,48 @@ class RefreshExternalBalancesJob implements ShouldQueue
DadataBalanceProvider::class,
SupplierBalanceProvider::class,
YandexCloudBalanceProvider::class,
Yandex360BalanceProvider::class,
];
}
/** @var array<int,LivenessProbe>|null Подмена списка проб в тестах; null → дефолт. */
private static ?array $livenessOverride = null;
/** @param array<int,LivenessProbe> $probes */
public static function useLivenessProbes(array $probes): void
{
self::$livenessOverride = $probes;
}
public static function resetLivenessProbes(): void
{
self::$livenessOverride = null;
}
/** @return array<int,LivenessProbe> */
private function livenessProbes(): array
{
if (self::$livenessOverride !== null) {
return self::$livenessOverride;
}
return [
app(YooKassaLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
public function handle(): void
{
// Прежние цвета (для edge-trigger алерта): service_key => light.
$priorLights = DB::connection(self::DB_CONNECTION)
->table('external_service_balances')
->pluck('light', 'service_key')
->all();
/** @var array<int,array{key:string,detail:string}> $newlyRed */
$newlyRed = [];
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
@@ -85,6 +128,46 @@ class RefreshExternalBalancesJob implements ShouldQueue
'updated_at' => now(),
],
);
if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе'];
}
}
// Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу.
foreach ($this->livenessProbes() as $probe) {
$reading = $probe->check(); // не бросает
$key = $probe->serviceKey();
// Свежий builder на каждую итерацию (как в балансовом цикле).
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => null,
'currency' => 'RUB',
'daily_spend_estimate' => null,
'days_left' => null,
'light' => $reading->light,
// ok=true у green/red (статус определённый), false у grey (не смогли/не применимо).
'ok' => $reading->light !== 'grey',
// error несёт человеческую подпись для red/grey (для плитки); у green — null.
'error' => $reading->light === 'green' ? null : $reading->detail,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') {
$newlyRed[] = ['key' => $key, 'detail' => $reading->detail];
}
}
// Edge-trigger: одно письмо, если появились новые красные сервисы.
if ($newlyRed !== []) {
$to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now()));
}
}
@@ -104,6 +187,10 @@ class RefreshExternalBalancesJob implements ShouldQueue
(float) config('services.supplier.red_floor_rub', 5000),
(float) config('services.supplier.amber_floor_rub', 15000),
],
'email' => [
(float) config('services.yandex360.red_floor_rub', 500),
(float) config('services.yandex360.amber_floor_rub', 2000),
],
default => [0.0, 0.0],
};
}
@@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -192,6 +196,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$runId = $this->recordRunSummary(
startedAt: $startedAt,
@@ -210,6 +221,103 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
@@ -111,10 +111,18 @@ final class VerifySupplierOrderJob implements ShouldQueue
}
}
/** Канал у поставщика — служебное поле src, не имя. rt=Ростелеком, bl=Билайн, mt=МТС. */
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
/**
* Портальные строки формат SupplierOrderVerifier. «Наши» = те, чей портальный id
* совпадает с нашим supplier_external_id (tag НЕ используется робот шлёт регион).
*
* Площадка берётся из `src`, а НЕ из префикса имени: метку «B<n> портал ставит
* только при создании строки и теряет при обновлении (прод-инцидент 1112.07.2026
* строки в кабинете были, но проверка объявляла их missing). `src` то же поле,
* по которому матчит saveProjectMultiFlag; оно от имени не зависит.
*
* @param array<int, array<string,mixed>> $rows
* @param list<string> $ourExternalIds
* @return list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}>
@@ -127,8 +135,8 @@ final class VerifySupplierOrderJob implements ShouldQueue
if (! isset($ours[(string) ($row['id'] ?? '')])) {
continue;
}
$name = (string) ($row['name'] ?? '');
if (preg_match('/^(B[123])_/', $name, $mm) !== 1) {
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
if ($platform === null) {
continue;
}
$signalType = match ($row['type'] ?? null) {
@@ -140,7 +148,7 @@ final class VerifySupplierOrderJob implements ShouldQueue
$out[] = [
'signal_type' => $signalType,
'identifier' => (string) ($row['content'] ?? ''),
'platform' => $mm[1],
'platform' => $platform,
'limit' => (int) ($row['lim'] ?? 0),
'on' => (string) ($row['status'] ?? '') === '1',
];
+47
View File
@@ -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',
],
);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Контакт клиента из чата бота: бот не смог ответить (личный вопрос, стоп-тема,
* незнание) попросил телефон клиент оставил. Письмо уходит в поддержку
* (config services.support.email), чтобы человек перезвонил.
*/
class BotContactRequestMail extends Mailable
{
use Queueable;
use SerializesModels;
/**
* @param string $phone нормализованный номер (79161234567)
* @param string $question вопрос, на котором бот спасовал
* @param list<string> $transcript вся переписка чата («Клиент: …», «Бот: …»)
* @param string $source 'portal' (личный кабинет) или 'landing' (гость)
* @param int|null $userId ID клиента, если он вошёл в кабинет; null для гостя
*/
public function __construct(
public readonly string $phone,
public readonly string $question,
public readonly string $chatId,
public readonly array $transcript = [],
public readonly string $source = 'portal',
public readonly ?int $userId = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Клиент из чата просит связаться — '.$this->phone,
);
}
public function content(): Content
{
return new Content(
view: 'emails.bot_contact_request',
with: [
'phone' => $this->phone,
'question' => $this->question,
'chatId' => $this->chatId,
'transcript' => $this->transcript,
'source' => $this->source,
'userId' => $this->userId,
],
);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Services\Bot\ChatRateLimiter;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Суточный потолок ответов чата пробит: либо нас ломают, либо неожиданно повалил народ. */
class ChatBudgetAlertMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(public readonly int $used) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Лидерра. Чат: пробит суточный потолок ответов');
}
public function content(): Content
{
return new Content(
view: 'emails.chat_budget_alert',
with: [
'used' => $this->used,
'limit' => ChatRateLimiter::GLOBAL_PER_DAY,
],
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-алерт: один или несколько внешних сервисов впервые перешли в «красный»
* (упали или деньги на исходе). Шлётся из RefreshExternalBalancesJob по edge-trigger.
*/
final class ExternalServiceDownMail extends Mailable
{
use Queueable;
use SerializesModels;
/** @param array<int,array{key:string,detail:string}> $services */
public function __construct(
public readonly array $services,
public readonly CarbonInterface $checkedAt,
) {}
public function envelope(): Envelope
{
$names = implode(', ', array_map(fn ($s) => $s['key'], $this->services));
return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names);
}
public function content(): Content
{
return new Content(view: 'emails.external_service_down');
}
}
+63
View File
@@ -0,0 +1,63 @@
<?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;
/**
* @mixin IdeHelperAutopodborCompetitor
*/
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',
'phones',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
'elements',
'dismissed_actualize_keys',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'elements' => 'array',
'phones' => 'array',
'provenance' => 'array',
'dismissed_actualize_keys' => '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');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Событие слияния конкурентов: что во что слили + снимок поглощённых карточек.
*
* Журнал делает склейку прозрачной и обратимой (вернуть поглощённую карточку из снимка).
*
* @mixin IdeHelperAutopodborMergeEvent
*/
class AutopodborMergeEvent extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'survivor_id',
'survivor_name',
'reason',
'absorbed',
'absorbed_sources',
'restored',
];
protected $casts = [
'absorbed' => 'array',
// Снимок источников (autopodbor_sources) каждого поглощённого конкурента — по id конкурента,
// чтобы «Вернуть» мог отдать источники (и через них — проекты) назад воскрешённой карточке,
// а не оставлять их у выжившей. NULL у записей журнала, созданных до этой возможности
// (см. миграцию 2026_07_14_090000) — такие события восстанавливаются частично (без источников).
'absorbed_sources' => 'array',
'restored' => 'bool',
'created_at' => 'datetime',
];
public function survivor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'survivor_id');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @mixin IdeHelperAutopodborRun
*/
class AutopodborRun extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'kind',
'status',
'region_code',
'params',
'competitor_id',
'batch_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
'progress',
'result',
];
protected $casts = [
'params' => 'array',
'progress' => 'array',
'result' => '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');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @mixin IdeHelperAutopodborSource
*/
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');
}
}
+2
View File
@@ -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 = [
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Строка журнала диалога бота (direction: in/out). created_at only updated_at нет.
*
* @mixin IdeHelperBotDialog
*/
class BotDialog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Чанк базы знаний бота. search_tsv generated column, в PHP не трогаем.
*
* @mixin IdeHelperKnowledgeChunk
*/
class KnowledgeChunk extends Model
{
protected $guarded = [];
}
+15
View File
@@ -6,6 +6,9 @@ use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
use App\Models\User;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Bot\AitunnelChatClient;
use App\Services\Bot\LlmClient;
use App\Services\Bot\YandexGptClient;
use App\Services\Billing\Gateway\PaymentGatewayManager;
use App\Services\Captcha\CaptchaVerifier;
use App\Services\Captcha\NullCaptchaVerifier;
@@ -40,6 +43,14 @@ class AppServiceProvider extends ServiceProvider
SymfonyProcessFactory::class,
);
// Мозг бота: российская модель или зарубежная через шлюз AITunnel (services.bot.llm).
$this->app->bind(
LlmClient::class,
fn ($app) => config('services.bot.llm') === 'aitunnel'
? $app->make(AitunnelChatClient::class)
: $app->make(YandexGptClient::class),
);
// Резерв канала миграции проектов: SupplierProjectChannel резолвится в
// декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue).
// Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
@@ -120,6 +131,10 @@ class AppServiceProvider extends ServiceProvider
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
// сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP.
// track (spec 2026-07-13): публичный маячок учёта посетителей — 60/мин с IP,
// защита от накрутки счётчика.
RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown'));
RateLimiter::for('api-v1', function (Request $request) {
$header = (string) $request->header('Authorization', '');
$bearer = str_starts_with($header, 'Bearer ')
@@ -0,0 +1,240 @@
<?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\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\CompetitorCardEnricher;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\CachingPageFetcher;
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\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\PlaywrightPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\RoutingBatchFetcher;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\Agent\Study\CompositePhoneReverse;
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
use App\Services\Autopodbor\Agent\Study\ParallelPhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverseExpander;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexCardReader;
use App\Services\Autopodbor\Agent\Study\Yandex\PlaywrightYandexPhoneSearch;
use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// Один канал прогресса на процесс: джоба привязывает его к своему прогону, движок пишет этапы.
$this->app->singleton(RunProgressChannel::class);
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru ИЛИ свой рендер-сервис.
//
// Слой добычи 2ГИС/Яндекс переключается флагом autopodbor.self_render (§4, §12):
// ВКЛ → SelfRenderClient (виртуалка+прокси; 2ГИС отдаёт initialState) + TwoGisJsonReader.
// ВЫКЛ → XfetchClient (xf4) + HtmlTwoGisReader — как сейчас.
// Фетчер и 2ГИС-ридер меняются ПАРОЙ (иначе JSON уйдёт в HTML-парсер и вернёт пусто).
//
// Шаг 1 (поиск): при autopodbor.real_find — ЖИВОЙ движок; без ИИ-ключа отсев/похожесть отключены
// (деградация). Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку.
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
$selfRender = (bool) config('autopodbor.self_render');
$sr = $selfRender ? $this->selfRenderClient() : null;
// Загрузчик xf4 (нужен всегда для xf4-пути и как база RoutingBatchFetcher под Яндекс).
$xfetch = new XfetchClient(
apiKey: config('services.xfetch.key'),
endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'),
concurrency: (int) config('services.xfetch.concurrency', 4),
);
// Основной загрузчик КАРТОЧЕК: self-render ИЛИ xf4 — обёрнут кэшем (§render_cache).
// Ключ по URL в Redis → шаг 1 и шаг 2 делят ОДИН кэш; кэшируются только карточки
// (/firm/, /maps/org/), поиск/ленты (/search/) идут мимо (passthrough). Флаг ВЫКЛ → как есть.
$pages = $this->wrapCache($sr ?? $xfetch);
// В self-render режиме обёрнутый загрузчик = обёрнутый self-render (тот же экземпляр $pages).
$srCards = $sr !== null ? $pages : null;
$reader = $this->twoGisReader();
// Таймаут 15с (было 45): сайт конкурента отвечает за 1-3с; режем только висяки, не теряя живых.
$siteFetcher = new CurlPlaywrightFetcher(timeout: 15);
$fetcher = new CompositeFetcher(
siteFetcher: $siteFetcher,
directoryFetcher: new XfetchDirectoryFetcher($pages),
);
// Сторож валидности телефонов: слой 1 Россвязь (всегда, бесплатно) + слой 2
// ДаДата за флагом (платно, снимает подтверждённый мусор среди прошедших реестр).
// Общий на оба пути шага 2 (обычный и глубокий через SiteOpener).
$validityGate = new PhoneValidityGate(
new RossvyazPrefixLookup,
config('autopodbor.phone_dadata_enabled')
? $app->make(DaDataValidityLayer::class)
: null,
);
$deep = config('autopodbor.deep_study')
? new DeepStudyCollector(
xfetch: $pages,
opener: new SiteOpener($siteFetcher, validityGate: $validityGate),
reverse: config('autopodbor.phone_reverse')
? $this->buildPhoneReverse($pages, $reader)
: null,
reader: $reader,
// self-render: Яндекс-справочник шага 2 — через виртуалку+прокси; иначе (null) движок
// строит дефолт (локальный Playwright). §5.3/§5.7. Загрузчик карточек — обёрнутый кэшем.
yandexDir: $srCards !== null ? new XfetchDirectoryFetcher($srCards, new DirectoryParser) : null,
)
: null;
$liveFind = config('autopodbor.real_find')
? $this->buildLiveFind($xfetch, $srCards, $reader)
: null;
return new RealCompetitorAgent(
$fetcher,
new FakeCompetitorAgent,
validityGate: $validityGate,
liveFind: $liveFind,
deep: $deep,
progress: $app->make(RunProgressChannel::class),
);
});
}
/**
* Обернуть загрузчик КАРТОЧЕК общим кэшем (§render_cache). ВКЛ {@see CachingPageFetcher}
* (карточки /firm/, /maps/org/ в Redis на N дней; поиск/ленты мимо). ВЫКЛ фетчер как есть.
* Ключ по URL любые обёрнутые экземпляры делят один кэш (шаг 1 шаг 2 повторный прогон).
*/
public function wrapCache(BatchPageFetcher $inner): BatchPageFetcher
{
return config('autopodbor.render_cache')
? new CachingPageFetcher($inner, (int) config('autopodbor.render_cache_ttl_days', 5))
: $inner;
}
/** Клиент своего рендер-сервиса (виртуалка). Без endpoint/key деградирует в пусто (§5.6). */
private function selfRenderClient(): SelfRenderClient
{
return new SelfRenderClient(
endpoint: (string) config('services.self_render.endpoint', ''),
key: (string) config('services.self_render.key', ''),
concurrency: (int) config('services.self_render.concurrency', 8),
);
}
/** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */
public function twoGisReader(): TwoGisReader
{
return config('autopodbor.self_render') ? new TwoGisJsonReader : new HtmlTwoGisReader;
}
/** Источник ленты Яндекс.Карт: виртуалка+прокси (self-render) ИЛИ локальный Playwright (xf4). */
public function yandexListSource(): YandexListSource
{
if (config('autopodbor.self_render')) {
return new RenderServiceYandexListSource(
(string) config('services.self_render.endpoint', ''),
(string) config('services.self_render.key', ''),
);
}
return new LocalYandexListSource((string) config('autopodbor.node_bin', 'node'));
}
private function buildPhoneReverse(BatchPageFetcher $pages, TwoGisReader $reader): PhoneReverse
{
$gis = new PhoneReverseExpander($pages, $reader);
if (! config('autopodbor.phone_reverse_yandex')) {
return $gis;
}
$node = (string) config('autopodbor.node_bin', 'node');
// Яндекс-разворот: под self_render — виртуалка+прокси (лента + чтение карточки), иначе локальный Playwright.
// Чтение карточек (/maps/org/) под self_render оборачиваем общим кэшем; Playwright (xf4) — нет.
$cardFetcher = config('autopodbor.self_render')
? $this->wrapCache($this->selfRenderClient())
: new PlaywrightPageFetcher($node);
$yandex = new YandexPhoneReverseExpander(
new PlaywrightYandexPhoneSearch($this->yandexListSource()),
new PlaywrightYandexCardReader($cardFetcher),
);
// phone_reverse_parallel: 2ГИС и Яндекс крутятся ОДНОВРЕМЕННО (итог = max, оба источника целы).
// Иначе — последовательный композит (как раньше). По умолчанию ВЫКЛ, откат — флаг в false.
return config('autopodbor.phone_reverse_parallel')
? new ParallelPhoneReverse($gis, $yandex)
: new CompositePhoneReverse([$gis, $yandex]);
}
/**
* Живой движок поиска шага 1 ФИНАЛ v4: АНАЛИЗ (мелкая модель запросы-рубрики) КАНАЛ А (скрейп
* категории 2ГИС с пагинацией карточки) КАНАЛ В (модель ИМЕНА федералов; их САЙТ через EXA)
* сборка (отсев агрегаторов + дедуп + похожесть + DTO). Все ИИ/exa/фетч-клиенты деградируют при пустом
* ключе. Слой 2ГИС/Яндекс self-render ($sr) ИЛИ xf4 ($xfetch), пара с $reader.
*/
private function buildLiveFind(XfetchClient $xfetch, ?BatchPageFetcher $srCards, TwoGisReader $reader): LiveFindCompetitors
{
$http = $this->app->make(HttpFactory::class);
$node = (string) config('autopodbor.node_bin', 'node');
// Скрейп листинга 2ГИС (страницы /search/ — не кэшируются): self-render ИЛИ LivePageFetcher(xfetch).
$catPages = $srCards ?? new LivePageFetcher($xfetch);
// Обогатитель КАРТОЧЕК: self-render (обёрнут кэшем) ИЛИ RoutingBatchFetcher, где 2ГИС-база
// обёрнута кэшем (/firm/ из кэша), а карточки Яндекса — локальный Playwright (кэш не трогаем).
$enrichPages = $srCards ?? new RoutingBatchFetcher($this->wrapCache($xfetch), ['yandex' => new PlaywrightPageFetcher($node)]);
$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),
// 2ГИС: 4 страницы на рубрику (5-я — «хвост» низкоранговых, шум). Кол-во режем на старте.
new CategoryScraper($catPages, $reader, maxPages: 4),
new PlaywrightYandexDirectory($this->yandexListSource()),
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
new ExaSiteFinder($http),
$assembler,
enricher: new CompetitorCardEnricher($enrichPages, new DirectoryParser, $reader),
// Канал прогресса (синглтон): джоба привяжет его к прогону, движок отметит этапы.
progress: $this->app->make(RunProgressChannel::class),
);
}
}
+9 -6
View File
@@ -13,8 +13,11 @@ use InvalidArgumentException;
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
* цепочку (RLS не скоупит его SELECT) per-tenant изоляция ADR-018 никогда не
* была реализована. Verify/rebuild приведены к реальности (partition => '').
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
@@ -43,7 +46,7 @@ final class AuditChainConfig
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'tenant_operations_log' => [
'columns' => [
@@ -51,7 +54,7 @@ final class AuditChainConfig
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'balance_transactions' => [
'columns' => [
@@ -60,7 +63,7 @@ final class AuditChainConfig
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'pd_processing_log' => [
'columns' => [
@@ -68,7 +71,7 @@ final class AuditChainConfig
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'saas_admin_audit_log' => [
'columns' => [
@@ -35,7 +35,7 @@ class RegistrationService
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '300.00';
private const START_BALANCE_RUB = '1000.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
@@ -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,189 @@
<?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' => $this->model($cfg),
'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;
}
/**
* Модель для отсева агрегаторов: aggregator_model (посильнее, вариант А) фолбэк на общий
* chat_model gpt-4o-mini. Отдельный ключ, чтобы усилить точность классификации, не трогая
* остальные ИИ-вызовы.
*
* @param array<string,mixed> $cfg
*/
private function model(array $cfg): string
{
$m = (string) ($cfg['aggregator_model'] ?? '');
return $m !== '' ? $m : (string) ($cfg['chat_model'] ?? 'gpt-4o-mini');
}
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' => $this->model($cfg),
'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,135 @@
<?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, string $selfSite = '', array $competitorExamples = []): array
{
$description = trim($description);
$fallback = $description !== '' ? [$description] : [];
$cfg = (array) config('services.aitunnel');
$key = (string) ($cfg['key'] ?? '');
if ($key === '' || $description === '') {
return $fallback;
}
// Контекст для модели: наш сайт + сайты/карточки конкурентов из запроса (условно — если переданы).
$context = $this->contextClause($selfSite, $competitorExamples);
// Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой
// сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и
// запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки).
$prompt = "Описание деятельности клиента: «{$description}». {$context}Регион: {$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;
}
}
/**
* Контекстная фраза «посмотри наш сайт X, а также наших главных конкурентов условно:
* каждая часть добавляется, только если данные есть. Пусто ''.
*
* @param list<string> $competitorExamples
*/
private function contextClause(string $selfSite, array $competitorExamples): string
{
$selfSite = trim($selfSite);
$examples = array_values(array_filter(array_map(
static fn ($v): string => trim((string) $v),
$competitorExamples,
), static fn (string $v): bool => $v !== ''));
$parts = [];
if ($selfSite !== '') {
$parts[] = "посмотри наш сайт {$selfSite}";
}
if ($examples !== []) {
$parts[] = 'а также наших главных конкурентов '.implode(', ', $examples);
}
return $parts !== [] ? implode(', ', $parts).' ' : '';
}
/**
* 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,134 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
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,
// Разбор списка 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к фетчеру.
private readonly TwoGisReader $reader,
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->reader->listing($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,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use App\Services\Autopodbor\Agent\Fetch\LocalYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
/**
* Живой {@see YandexDirectory}: по каждой рубрике рендерит ленту `yandex.ru/maps/?text=<запрос> <город>`,
* скроллит и берёт организации (имя + карточка). Сам рендер за {@see YandexListSource}: локальный
* Playwright (xf4-путь) ИЛИ виртуалка+прокси (self-render). Дедуп по SLUG в {@see YandexListParser}.
*
* Источник за интерфейсом оркестрация (URL по рубрике, потолок, слияние) тестируется офлайн фейком.
*/
final class PlaywrightYandexDirectory implements YandexDirectory
{
public function __construct(
private readonly YandexListSource $source = new LocalYandexListSource,
// Берём ВСЕ рубрики (аналитик отдаёт до 8): каждая рубрика первой даёт самые релевантные фирмы.
private readonly int $maxQueries = 8,
// Сколько раз прокрутить ленту в каждой рубрике. Мало = быстро (верх выдачи = самые релевантные).
private readonly int $scrolls = 6,
private readonly YandexListParser $parser = new YandexListParser,
) {}
public function collect(string $city, array $queries): array
{
// Сырьё по всем рубрикам; дедуп/опознание фирмы по SLUG — в парсере («Корунд ×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->source->fetch($url, $this->scrolls) as $org) {
$orgs[] = $org;
}
}
return $this->parser->parse($orgs);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Шаг АНАЛИЗ канала А: из описания деятельности клиента НЕСКОЛЬКО коротких запросов-рубрик для
* скрейпа категории 2ГИС/Яндекса (ZAFIKSIROVANO §0-БИС / §12.7 в тесте Омеги было ~6 запросов).
* Реализация решает, какой моделью (по умолчанию мелкая chat-модель). За границей для офлайн-теста.
*/
interface QueryAnalyzer
{
/**
* @param string $selfSite сайт клиента из запроса на поиск (пусто не добавляем в промт)
* @param list<string> $competitorExamples сайты/карточки конкурентов из запроса (пусто не добавляем)
* @return list<string> короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»).
*/
public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): 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.
* Сайт каждого имени добывает ПОТОМ EXA (нет сайта нет якоря имя выкидывается).
*
* Дедуп по имени (ё→е, без не-букв/цифр). Модель за границей {@see ResearcherClient} (офлайн-тест).
*/
final class ChannelBSearch
{
/** Промт канала В: ТОЛЬКО НАЗВАНИЯ+тип федералов, ведущих деятельность в регионе. Сайт находит EXA. */
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}> только НОВЫЕ фирмы (имя + тип; сайт найдёт EXA)
*/
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,176 @@
<?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.411.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])
->withOptions($this->proxyOptions())
->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])
->withOptions($this->proxyOptions())
->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'];
}
/**
* Опции Guzzle для запроса EXA: прокси (services.exa.proxy), если задан. EXA за Cloudflare
* режет российские IP, поэтому на боевом канал В ходит через зарубежный выход. Пусто напрямую.
*
* @return array<string, mixed>
*/
private function proxyOptions(): array
{
$proxy = trim((string) (config('services.exa.proxy') ?? ''));
return $proxy !== '' ? ['proxy' => $proxy] : [];
}
/** Первый «настоящий» домен из ответа 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,65 @@
<?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; // без имени — мусор
}
// ТОЛЬКО имя+тип. Если модель по инерции положит site/card — игнорируем: сайт ищет EXA.
$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,119 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
/**
* «Вскрыть всё» на этапе сбора: открываем карточку справочника у КАЖДОГО кандидата с карточкой и без
* сайта, и с сайтом (максимум качества). Из вскрытой карточки берём телефоны + описание всегда, а сайт
* только если его ещё нет (свой сайт из листинга надёжнее, его не затираем). Это нужно для чистой склейки
* дублей (Автодом24 без сайта AutoMoney) и богатого описания. БЕЗ лимита на число карточек открываем
* ВСЕ ПАЧКОЙ (параллельно), если фетчер умеет ({@see BatchPageFetcher}); каждый запрос с таймаутом, поэтому
* зависнуть не может. Кол-во режем на старте (страницы/рубрики), а не гейтом на вскрытии. Единственное
* реальное ограничение частотный лимит самого 2ГИС (карточка вернёт пусто тогда просто пропускаем).
*
* Остальные дубли (по вскрывшемуся при ИЗУЧЕНИИ телефону/адресу) добирает кнопка «Найти дубли».
* Фетчер за границей {@see PageFetcher} тестируется офлайн.
*/
final class CompetitorCardEnricher
{
public function __construct(
private readonly PageFetcher $pages,
private readonly DirectoryParser $parser,
// Разбор карточек 2ГИС: HtmlTwoGisReader (xf4) ИЛИ TwoGisJsonReader (self-render) — пара к
// фетчеру. Яндекс-карточки остаются на $parser->parseYandexCard (свой формат, не 2ГИС).
private readonly TwoGisReader $twoGis = new HtmlTwoGisReader,
) {}
/**
* @param array<int, array> $candidates
* @return array<int, array>
*/
public function enrich(array $candidates): array
{
// Цели = ВСЕ кандидаты, у которых есть карточка справочника (гейта «сайт есть → пропустить»
// больше нет — максимум качества: вскрываем всё, кол-во режем на старте страницами/рубриками).
// idx => первая карточка. Без лимита.
$targets = [];
foreach ($candidates as $idx => $c) {
foreach ($c['directory_urls'] ?? [] as $u) {
$u = trim((string) $u);
if ($u !== '') {
$targets[$idx] = $u;
break;
}
}
}
if ($targets === []) {
return $candidates;
}
// Пачкой (параллельно) если умеет, иначе по одной.
$urls = array_values(array_unique(array_values($targets)));
$htmls = $this->pages instanceof BatchPageFetcher
? $this->pages->htmlBatch($urls)
: $this->fetchSequential($urls);
foreach ($targets as $idx => $url) {
$html = $htmls[$url] ?? '';
if ($html === '') {
continue;
}
$c = $candidates[$idx];
foreach ($this->parseCards($html, $url) as $card) {
if (empty($c['site_url']) && $card->siteUrl !== null && $card->siteUrl !== '') {
$c['site_url'] = $card->siteUrl;
}
if (empty($c['description']) && $card->description !== null && $card->description !== '') {
$c['description'] = $card->description;
}
if ($card->number !== '') {
$c['phones'][] = $card->number;
}
}
$c['phones'] = array_values(array_unique($c['phones'] ?? []));
$candidates[$idx] = $c;
}
return $candidates;
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
private function fetchSequential(array $urls): array
{
$out = [];
foreach ($urls as $u) {
$out[$u] = $this->pages->html($u);
}
return $out;
}
/**
* Разбор карточки по типу справочника: Яндекс parseYandexCard, 2ГИС parse2gisCard.
*
* @return list<DirectoryCard>
*/
private function parseCards(string $html, string $url): array
{
if (str_contains($url, 'yandex')) {
return $this->parser->parseYandexCard($html, $url);
}
if (str_contains($url, '2gis')) {
return $this->twoGis->card($html, $url);
}
return [];
}
}
@@ -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,136 @@
<?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,
];
}
}
} else {
// без коллтрекинга: номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ (не tel:/schema,
// не отрендеренные) — не теряем, а отдаём клиенту с пометкой «проверьте». На
// верифицированном сайте фирмы это чаще её линии, но без tel:-ссылки за 100%
// настоящие не выдаём. (Число вхождений не важно — номер уже в видимом тексте.)
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if (in_array($number, $codeNumbers, true) || in_array($number, $visible, true)) {
continue;
}
$out[] = [
'number' => $number, 'kind' => 'text', 'label' => 'в тексте сайта — проверьте',
'url' => $site->url, 'office' => null, 'tracker' => false,
];
}
}
// номера филиалов со страницы «Контакты» (рендер)
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,352 @@
<?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;
}
/**
* Карточка организации Яндекс.Карт: телефоны + сайт + адрес из заголовка.
*
* Приоритеты парсинга:
* 1. Телефоны из <div class="orgpage-phones-view__phone-number">
* 2. Резервные номера из wa.me-ссылок (если не совпадают по цифрам с уже найденными)
* 3. Сайт из <a class="business-urls-view__link" href="">
* 4. Адрес офиса через officeFromTitle()
*
* Если телефонов нет, но сайт есть одна карточка с number=''.
* Если нет ни телефона, ни сайта [].
*
* @return list<DirectoryCard>
*/
public function parseYandexCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается» — рубрика из заголовка карточки Яндекса («Имя, РУБРИКА, адрес…, Город»).
$description = $this->categoryFromTitle($html);
// Телефоны из <div class="orgpage-phones-view__phone-number"…>ТЕЛЕФОН</div>
$phones = [];
if (preg_match_all(
'/class="orgpage-phones-view__phone-number"[^>]*>\s*([^<]+?)\s*</',
$html,
$m,
)) {
foreach ($m[1] as $raw) {
$raw = trim($raw);
if ($raw !== '') {
$phones[] = $raw;
}
}
}
// Телефоны из скрытого JSON (Яндекс прячет 2-й и последующие за раскрывашкой,
// но все объекты {"number":"...","type":"phone","value":"..."} лежат в HTML).
// Берём поле value — это чистый номер в международном формате.
if (preg_match_all(
'/"number":"[^"]+","type":"phone","value":"(\+?\d{10,15})"/u',
$html,
$jm,
)) {
foreach ($jm[1] as $jsonNum) {
$jsonDigits = preg_replace('/\D/', '', $jsonNum);
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $jsonDigits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = $jsonNum;
}
}
}
// Резервные: wa.me/7XXXXXXXXXX — добавляем только если цифры нового
if (preg_match_all('/wa\.me\/(\d{10,15})/i', $html, $wm)) {
foreach ($wm[1] as $digits) {
$alreadyHave = false;
foreach ($phones as $existing) {
if (preg_replace('/\D/', '', $existing) === $digits) {
$alreadyHave = true;
break;
}
}
if (! $alreadyHave) {
$phones[] = '+'.$digits;
}
}
}
// Сайт
$siteUrl = null;
if (preg_match('/class="business-urls-view__link"\s+href="([^"]+)"/i', $html, $sm)) {
$siteUrl = $sm[1];
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
$seenDigits = [];
foreach ($phones as $raw) {
$digits = preg_replace('/\D/', '', $raw);
if (isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: 'Яндекс.Карты',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Карточка фирмы 2ГИС «вскрыть всё»: сайт + ВСЕ телефоны + описание из карточки.
*
* 2ГИС хранит контакты JSON-массивом объектов:
* - сайт объект {"url":"","type":"website",} берём поле `url` (НЕ `value`:
* там редирект link.2gis.ru, а не настоящий сайт);
* - телефоны объекты {"type":"phone","value":"+7…"} берём `value`;
* - описание «чем занимается» best-effort из og:description (стабильнее хэш-классов 2ГИС).
*
* Мусор-домены (счётчики/трекеры в <script src>) сюда не попадают: сайт берём ТОЛЬКО из
* объекта-контакта website, а не «первый домен на странице».
*
* Если телефонов нет, но сайт есть одна карточка с number=''. Нет ни того, ни другого [].
*
* @return list<DirectoryCard>
*/
public function parse2gisCard(string $html, string $url): array
{
$office = $this->officeFromTitle($html);
// «Чем занимается»: сначала своё описание (og), если оно не заглушка; иначе — рубрика из
// заголовка карточки («Имя, РУБРИКА, адрес…, Город»). Рекламный текст 2ГИС грузится отдельно
// (не в HTML) — его тут нет, рубрика — надёжный минимум.
$description = $this->descriptionFromMeta($html) ?? $this->categoryFromTitle($html);
// Сайт: в объекте-контакте url идёт ДО "type":"website" (порядок ключей url,text,code,type).
// Берём первый такой объект; [^}]*? не даёт «перепрыгнуть» в соседний объект.
$siteUrl = null;
if (preg_match('/"url":"([^"]+)"[^}]*?"type":"website"/u', $html, $sm)) {
$siteUrl = $sm[1];
}
// Телефоны: value у объектов {"type":"phone","value":"+7…"}; дедуп по цифрам.
// ВАЖНО: разбираем ПООБЪЕКТНО (не сквозной регуляркой по всей странице), чтобы отсеять
// РЕКЛАМНЫЕ кнопки-действия. 2ГИС вшивает в карточку рекламу чужих фирм («Связаться с
// менеджером») — это объект с caption/platforms и БЕЗ code/print_text; его value — телефон
// другой компании. Если его взять, чужой номер попадает в контакты фирмы и даёт ложную
// склейку/«объединить?» (живой случай: телефон Айклауда прилетал в Ломбард Бодрый).
$phones = [];
$seenDigits = [];
if (preg_match_all('/\{[^{}]*?"type":"phone"[^{}]*?\}/u', $html, $om)) {
foreach ($om[0] as $obj) {
// Рекламная кнопка-действие, а не контакт фирмы → пропускаем.
if (str_contains($obj, '"platforms"') || str_contains($obj, '"caption"')) {
continue;
}
if (! preg_match('/"value":"(\+?\d{10,15})"/u', $obj, $vm)) {
continue;
}
$digits = preg_replace('/\D/', '', $vm[1]);
if ($digits === '' || isset($seenDigits[$digits])) {
continue;
}
$seenDigits[$digits] = true;
$phones[] = $vm[1];
}
}
if ($phones === [] && $siteUrl === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(
number: '',
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(
number: $raw,
office: $office,
url: $url,
source: '2ГИС',
siteUrl: $siteUrl,
description: $description,
);
}
return $out;
}
/**
* Описание «чем занимается» из og:description / meta description (best-effort, может быть null).
*/
private function descriptionFromMeta(string $html): ?string
{
if (preg_match('/<meta[^>]+property="og:description"[^>]+content="([^"]+)"/i', $html, $m)
|| preg_match('/<meta[^>]+name="description"[^>]+content="([^"]+)"/i', $html, $m)) {
$d = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return ($d !== '' && ! $this->isJunkDescription($d)) ? $d : null;
}
return null;
}
/**
* Служебная заглушка справочника (не «чем занимается»): «Оценка N, N фото, N отзывов. Вся
* информация об этом месте есть в 2ГИС» и т.п. Такое в описание не пишем это мусор для
* релевантности и отсева агрегаторов.
*/
private function isJunkDescription(string $d): bool
{
$low = mb_strtolower($d, 'UTF-8');
// Упоминает сам справочник (2ГИС/Яндекс) или это блок «оценка … фото … отзыв».
return str_contains($low, '2гис')
|| str_contains($low, '2gis')
|| (str_contains($low, 'фото') && str_contains($low, 'отзыв'))
|| (bool) preg_match('/^оценка\s+\d/u', $low);
}
/**
* Рубрика «чем занимается» из <title> вида «Имя, РУБРИКА, адрес…, Город 2ГИС/Яндекс».
* Рубрика 2-й кусок, НО только если он не похож на адрес (иначе это «Имя, адрес…, Город» без рубрики).
*/
private function categoryFromTitle(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');
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // «Имя, Город» — отдельной рубрики нет
}
$cand = $parts[1] ?? '';
return ($cand !== '' && ! $this->looksLikeAddress($cand)) ? $cand : null;
}
/** Кусок заголовка похож на адрес (улица/проспект/дом), а не на рубрику. */
private function looksLikeAddress(string $s): bool
{
$low = mb_strtolower($s, 'UTF-8');
return (bool) preg_match('/улиц|ул\.|проспект|пр-т|пр\.|переул|пер\.|бульвар|шоссе|набереж|площад|проезд|тракт|\d/u', $low);
}
/**
* Адрес офиса из <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,171 @@
<?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' (не телефон) — игнорируем
}
// Номера в теле берём из ВИДИМОГО текста: вырезаем <script>/<style>, чтобы не тянуть
// технический мусор из конфигов/аналитики. Реальные номера из JSON-LD уже пойманы выше (schema).
$bodyHtml = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $bodyHtml, $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;
}
/**
* Классифицирует короткую (67 цифр) форму номера из явного телефонного контекста.
* Возвращает [статус, значение]:
* - 'built' + 7XXXXXXXXXX локальный номер достроен кодом города;
* - 'fragment' + null обрезок полного номера (страна+код), выкидываем;
* - 'uncertain' + цифры код города не определить, не теряем, помечаем к проверке;
* - 'skip' + null не 67-значная форма, не наше.
* Пример: «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,38 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* xf4-путь шва {@see TwoGisReader}: тонкий делегат к нынешним HTML-парсерам. Поведение 1:1 с прямым
* вызовом {@see CategoryListingParser}/{@see DirectoryParser} старые парсеры не трогаются, xf4-путь
* остаётся целым. Развилка HTML↔JSON на уровне провайдера (какой ридер инжектится), не внутри парсеров.
*/
final class HtmlTwoGisReader implements TwoGisReader
{
public function __construct(
private readonly CategoryListingParser $listing = new CategoryListingParser,
private readonly DirectoryParser $directory = new DirectoryParser,
) {}
public function listing(string $payload): array
{
return $this->listing->parse($payload);
}
/** @return list<DirectoryCard> */
public function card(string $payload, string $url): array
{
return $this->directory->parse2gisCard($payload, $url);
}
/** @return list<string> */
public function branchList(string $payload): array
{
return $this->directory->parseBranchList($payload);
}
}
@@ -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,90 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Support\Facades\DB;
/**
* Сторож валидности телефонов, пойманных при «вскрытии» кода страницы конкурента.
*
* Экстрактор ({@see HtmlPhoneScanner}) ловит любую последовательность из 11 цифр,
* начинающуюся на 7/8 и сгруппированную «по-телефонному» но НЕ проверяет, что это
* реальный российский номер. Со страниц-агрегаторов (отзовики, каталоги) так пролезает
* мусор: ID/коды/чужие блоки цифр с несуществующими кодами города (227, 303, 453, 613, 761).
* Этот гейт отсекает такой мусор ДО показа клиенту (spec §12.4-подбор).
*
* Слой 1 (здесь, бесплатно): реестр нумерации Россвязи (`phone_ranges`, 453k диапазонов на
* проде). Полный номер 7XXXXXXXXXX, которого нет ни в одном диапазоне выкидываем молча.
* Слой 2 (ДаДата, платно, за флагом) {@see DaDataValidityLayer}, добивает сомнительные.
*
* Защита: если реестр пуст (dev/тесты или сбой загрузки) НЕ режем ничего (fail-open),
* чтобы незагруженный справочник не выкосил все номера. Короткие/локальные номера
* (kind=uncertain, код города не определён) не трогаем их нельзя сверить с реестром.
*/
final class PhoneValidityGate
{
private ?bool $registryHasData = null;
public function __construct(
private readonly RossvyazPrefixLookup $rossvyaz = new RossvyazPrefixLookup,
private readonly ?PhoneValidityLayer $dadata = null,
) {}
/**
* @param list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
*/
public function filter(array $candidates): array
{
// Реестр не загружен → fail-open: не режем ничего.
if (! $this->registryHasData()) {
return array_values($candidates);
}
return array_values(array_filter($candidates, fn (array $c): bool => $this->keep((string) $c['number'])));
}
private function keep(string $number): bool
{
// Сверяем только полные российские номера. Короткие/локальные (7-значные и т.п.)
// нельзя проверить по реестру — не теряем.
if (preg_match('/^7\d{10}$/', $number) !== 1) {
return true;
}
// Слой 1 — Россвязь (авторитетный полный план нумерации): номера нет ни в одном
// диапазоне → это не настоящий российский номер → мусор, выкидываем молча (бесплатно).
if ($this->rossvyaz->find($number) === null) {
return false;
}
// Слой 2 — ДаДата (платно, за флагом): перепроверяет уже прошедший реестр номер и
// снимает подтверждённый мусор/иностранца (qc 2/7). Платим только за реальных выживших.
if ($this->dadata !== null) {
return $this->dadata->isValid($number);
}
return true;
}
private function registryHasData(): bool
{
if ($this->registryHasData !== null) {
return $this->registryHasData;
}
try {
return $this->registryHasData = DB::connection(RossvyazPrefixLookup::CONNECTION)
->table('phone_ranges')
->limit(1)
->exists();
} catch (\Throwable) {
// Сбой БД/соединения при проверке реестра → fail-open (не фильтруем ничего).
// Иначе внешний catch в SiteOpener::open() принял бы это за «нет номеров».
return $this->registryHasData = false;
}
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
/**
* Второй слой проверки валидности телефона (после реестра Россвязи).
* Реализация {@see DaDataValidityLayer} (платно, за флагом).
*/
interface PhoneValidityLayer
{
/** true — номер годен (оставляем); false — мусор/иностранец (выкидываем). */
public function isValid(string $number): bool;
}
@@ -0,0 +1,85 @@
<?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);
$hasText = in_array('text', $kinds, true);
// короткий локальный номер без определимого кода города — «требует проверки»
$isUncertain = ! $hasTrusted && ! $hasDisplayed && ! $hasText && in_array('uncertain', $kinds, true);
// номер только из текста сайта (не подтверждён tel:/справочником) — показываем «проверьте»
$isTextOnly = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && $hasText;
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && ! $isTextOnly; // только pool-свалка
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
if ($isUncertain) {
$phoneKind = 'uncertain';
$phoneType = null; // тип не выдумываем — номер ещё не достроен
} elseif ($isTextOnly) {
$phoneKind = 'unverified'; // «на сайте — проверьте»
$phoneType = PhoneType::of($n); // номер полный — тип знаем
} 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,269 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* self-render путь шва {@see TwoGisReader}: разбирает встроенное состояние 2ГИС (`window.initialState`,
* приходит строкой-JSON от рендер-сервиса) в ТОТ ЖЕ контракт, что HTML-парсеры. Карта JSON-путей и
* рецепт рендера docs/superpowers/findings/2026-07-05-2gis-initialstate-spike.md.
*
* Структура: упорядоченный список результатов `data.search.pagination.{searchId}.{page}.data`
* (id фирм по рангу) профили `data.entity.profile.{id}.data` (имя/город/рубрика/сайт/телефоны).
* 🔴 Ходим ТОЛЬКО по pagination-списку, НЕ по ключам profile (там бывают посторонние сущности).
*/
final class TwoGisJsonReader implements TwoGisReader
{
/** Тип «фирма» в 2ГИС — branch (филиал организации). Здания/улицы/маршруты = не фирма, отсекаем. */
private const FIRM_TYPE = 'branch';
/**
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function listing(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue; // не фирма (здание/улица/маршрут) или нет профиля — пропускаем
}
$name = trim((string) ($d['name'] ?? ''));
if ($name === '') {
continue;
}
$out[] = [
'name' => $name,
'card_url' => $this->cardUrl($d, $id),
'site' => $this->siteOf($d),
];
}
return $out;
}
/**
* @return list<DirectoryCard>
*/
public function card(string $payload, string $url): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
// Целевая фирма — по id из URL /firm/{id}; если не нашли — единственный профиль страницы.
$id = null;
if (preg_match('#/firm/(\d+)#', $url, $m)) {
$id = $m[1];
}
$d = $id !== null ? $this->profileData($state, $id) : null;
if ($d === null) {
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$first = $profiles !== [] ? array_key_first($profiles) : null;
$d = $first !== null ? ($profiles[$first]['data'] ?? null) : null;
}
if (! is_array($d)) {
return [];
}
$site = $this->siteOf($d);
$office = isset($d['address_name']) ? (string) $d['address_name'] : null;
$description = $this->firstRubric($d);
// Телефоны из contact_groups → contacts type=phone → поле value (чистый +7…). Дедуп по цифрам.
$phones = [];
$seen = [];
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) !== 'phone') {
continue;
}
$value = (string) ($c['value'] ?? $c['text'] ?? '');
$digits = preg_replace('/\D/', '', $value) ?? '';
if ($digits === '' || isset($seen[$digits])) {
continue;
}
$seen[$digits] = true;
$phones[] = $value;
}
// Семантика 1:1 с parse2gisCard: нет ни телефона, ни сайта → []; нет телефонов, есть сайт →
// одна карточка с number=''; иначе карточка на каждый телефон (общий сайт/описание/офис).
if ($phones === [] && $site === null) {
return [];
}
if ($phones === []) {
return [new DirectoryCard(number: '', office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description)];
}
$out = [];
foreach ($phones as $raw) {
$out[] = new DirectoryCard(number: $raw, office: $office, url: $url, source: '2ГИС', siteUrl: $site, description: $description);
}
return $out;
}
/**
* @return list<string>
*/
public function branchList(string $payload): array
{
$state = $this->decode($payload);
if ($state === null) {
return [];
}
$out = [];
foreach ($this->resultIds($state) as $id) {
$d = $this->profileData($state, $id);
if ($d === null || ($d['type'] ?? null) !== self::FIRM_TYPE) {
continue;
}
$path = '/'.($d['city_alias'] ?? '').'/firm/'.$id;
if (! in_array($path, $out, true)) {
$out[] = $path;
}
}
return $out;
}
// --- helpers ---
/** @return array<mixed>|null */
private function decode(string $payload): ?array
{
$payload = trim($payload);
if ($payload === '' || $payload[0] !== '{') {
return null;
}
$state = json_decode($payload, true);
return is_array($state) ? $state : null;
}
/**
* Упорядоченный список id результатов: первый searchId все страницы по числовому ключу data.
*
* @param array<mixed> $state
* @return list<string>
*/
private function resultIds(array $state): array
{
$pagination = $this->arr($state, ['data', 'search', 'pagination']);
if ($pagination === []) {
return [];
}
$searchId = array_key_first($pagination);
$pages = is_array($pagination[$searchId] ?? null) ? $pagination[$searchId] : [];
// Страницы по возрастанию номера (payload обычно = одна страница, но подстрахуемся).
$keys = array_keys($pages);
sort($keys, SORT_NATURAL);
$ids = [];
$seen = [];
foreach ($keys as $k) {
foreach (($pages[$k]['data'] ?? []) as $id) {
$id = (string) $id;
if ($id !== '' && ! isset($seen[$id])) {
$seen[$id] = true;
$ids[] = $id;
}
}
}
return $ids;
}
/**
* @param array<mixed> $state
* @return array<mixed>|null
*/
private function profileData(array $state, string $id): ?array
{
$profiles = $this->arr($state, ['data', 'entity', 'profile']);
$d = $profiles[$id]['data'] ?? null;
return is_array($d) ? $d : null;
}
/** @param array<mixed> $d */
private function cardUrl(array $d, string $id): string
{
return 'https://2gis.ru/'.($d['city_alias'] ?? '').'/firm/'.$id;
}
/**
* Сайт из contact_groups contact type=website поле url (НЕ value: там редирект link.2gis.ru).
*
* @param array<mixed> $d
*/
private function siteOf(array $d): ?string
{
foreach ($this->contacts($d) as $c) {
if (($c['type'] ?? null) === 'website') {
$url = (string) ($c['url'] ?? '');
if ($url !== '') {
return $url;
}
}
}
return null;
}
/** @param array<mixed> $d @return list<array<mixed>> */
private function contacts(array $d): array
{
$out = [];
foreach (($d['contact_groups'] ?? []) as $group) {
foreach (($group['contacts'] ?? []) as $c) {
if (is_array($c)) {
$out[] = $c;
}
}
}
return $out;
}
/** @param array<mixed> $d */
private function firstRubric(array $d): ?string
{
foreach (($d['rubrics'] ?? []) as $r) {
$name = trim((string) ($r['name'] ?? ''));
if ($name !== '') {
return $name;
}
}
return null;
}
/**
* @param array<mixed> $state
* @param list<string> $path
* @return array<mixed>
*/
private function arr(array $state, array $path): array
{
$node = $state;
foreach ($path as $key) {
if (! is_array($node) || ! array_key_exists($key, $node)) {
return [];
}
$node = $node[$key];
}
return is_array($node) ? $node : [];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* Шов разбора 2ГИС: единый контракт поверх «сырья» страницы (HTML от xf4 ИЛИ initialState-JSON
* от своего рендера). Реализации: {@see HtmlTwoGisReader} (xf4-путь, делегирует старым парсерам) и
* {@see TwoGisJsonReader} (self-render, разбирает initialState). Выход по форме идентичен нынешним
* парсерам, поэтому потребители (шаг 1/2, разворот) не замечают разницы источника.
*/
interface TwoGisReader
{
/**
* Список фирм со страницы поиска-рубрики (как {@see CategoryListingParser::parse}).
*
* @return list<array{name:string,card_url:string,site:?string}>
*/
public function listing(string $payload): array;
/**
* Карточка фирмы: телефоны+сайт+описание (как {@see DirectoryParser::parse2gisCard}).
*
* @return list<DirectoryCard>
*/
public function card(string $payload, string $url): array;
/**
* Пути карточек филиалов со страницы списка/поиска (как {@see DirectoryParser::parseBranchList}).
*
* @return list<string>
*/
public function branchList(string $payload): array;
}
@@ -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,93 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Support\Facades\Cache;
/**
* Декоратор над реальным загрузчиком карточек ({@see SelfRenderClient} / {@see XfetchClient}).
* Кэширует ОТРИСОВАННЫЕ карточки в общем хранилище (Redis) по ключу URL на N дней чтобы не
* дёргать 2ГИС/Яндекс/прокси повторно за одним и тем же. Кэш общий для шага 1 (поиск) и шага 2
* (изучение) и для повторных прогонов: ключ по URL в Redis любые экземпляры делят один кэш.
*
* 🔴 Кэшируем ТОЛЬКО карточки (2ГИС `…/firm/…`, Яндекс `…/maps/org/…`). Поиск (`…/search/…`) и
* ленты всегда мимо кэша (passthrough): добывание новых фирм должно оставаться свежим, иначе
* повторный сбор не найдёт новые фирмы. Свежесть ограничивается ТОЛЬКО у уже найденного контента.
*
* 🔴 Пустой ответ НЕ кэшируем разовый сбой (прокси/антибот) не должен отравить кэш на N дней.
*/
final class CachingPageFetcher implements BatchPageFetcher
{
public function __construct(
private BatchPageFetcher $inner,
private int $ttlDays = 5,
) {}
public function html(string $url): string
{
if (! $this->cacheable($url)) {
return $this->inner->html($url);
}
$hit = Cache::get($this->key($url));
if (is_string($hit) && $hit !== '') {
return $hit;
}
$value = $this->inner->html($url);
$this->remember($url, $value);
return $value;
}
/**
* @param list<string> $urls
* @return array<string, string>
*/
public function htmlBatch(array $urls): array
{
$result = [];
$misses = [];
foreach (array_values(array_unique($urls)) as $url) {
if ($this->cacheable($url)) {
$hit = Cache::get($this->key($url));
if (is_string($hit) && $hit !== '') {
$result[$url] = $hit;
continue;
}
}
$misses[] = $url;
}
if ($misses !== []) {
foreach ($this->inner->htmlBatch($misses) as $url => $value) {
$result[$url] = $value;
$this->remember($url, $value);
}
}
return $result;
}
/** Класть в кэш только карточку с НЕПУСТЫМ содержимым. */
private function remember(string $url, string $value): void
{
if ($value !== '' && $this->cacheable($url)) {
Cache::put($this->key($url), $value, now()->addDays($this->ttlDays));
}
}
/** Кэшируем только карточки: 2ГИС `/firm/`, Яндекс `/maps/org/`. Поиск и ленты — нет. */
private function cacheable(string $url): bool
{
return str_contains($url, '/firm/') || str_contains($url, '/maps/org/');
}
private function key(string $url): string
{
return 'autopodbor:render:'.sha1($url);
}
}
@@ -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,209 @@
<?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,
// Сиды (seams) для тестов: подмена curl и запуска node-рендера, без реальной сети/подпроцесса.
// null → боевое поведение. fn(string $url): string и fn(string $script, string $url): ?array.
private ?\Closure $curlRunner = null,
private ?\Closure $nodeRunner = null,
) {
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
{
if ($this->curlRunner !== null) {
return (string) ($this->curlRunner)($url);
}
$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
{
// Рендер — НЕ фатальный: если node висит/падает по таймауту (Playwright ждёт networkidle
// на «шумном» сайте), Symfony Process бросает ProcessTimedOutException. Гасим его здесь,
// чтобы уже добытый curl-HTML (с номерами «в коде сайта») не потерялся. Возврат null =
// «видимых/рендер-номеров нет», обычные номера из curl остаются.
try {
return $this->runNode($this->renderScript, $url);
} catch (\Throwable) {
return null;
}
}
/** @return array{html:string}|null */
private function renderFirm(string $url): ?array
{
try {
return $this->runNode($this->firmScript, $url);
} catch (\Throwable) {
return null;
}
}
/**
* Запуск node-скрипта рендера. Реальный Process при висе (Playwright ждёт networkidle на
* «шумном» сайте) бросает ProcessTimedOutException.
*
* @return array<string,mixed>|null
*/
private function runNode(string $script, string $url): ?array
{
if ($this->nodeRunner !== null) {
return ($this->nodeRunner)($script, $url);
}
$p = new Process([$this->nodeBin, $script, $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,17 @@
<?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, // сайт, указанный в карточке
public readonly ?string $description = 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,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Symfony\Component\Process\Process;
/**
* xf4-путь {@see YandexListSource}: локальный Playwright через `scripts/render-yandex-list.cjs`
* (Firecrawl Яндекс.Карты не рендерит). Печатает JSON `{orgs:[...]}`. Сеть/браузер за Process
* офлайн не юнит-тестим (как раньше PlaywrightYandexDirectory).
*/
final class LocalYandexListSource implements YandexListSource
{
private string $script;
public function __construct(
private readonly string $nodeBin = 'node',
string $script = '',
private readonly int $timeout = 120,
) {
$this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs');
}
public function fetch(string $url, int $scrolls): array
{
try {
$p = new Process([$this->nodeBin, $this->script, $url, (string) $scrolls]);
$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,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
/**
* Тонкая граница «достать HTML страницы» за ней может стоять xfetch, локальный
* Playwright и т.п. Позволяет тестировать обход справочников без сети.
*/
interface PageFetcher
{
/** Вернуть HTML страницы (пустую строку при неудаче — не кидаем исключений). */
public function html(string $url): string;
}

Some files were not shown because too many files have changed in this diff Show More