Files
portal/app/app/Console/Commands/SeedShowcaseCommand.php
T
Дмитрий b1b1686b0e fix(bot): прогон 250 вопросов × 2 режима — 11 находок закрыто, все проверены перепрогоном
Владелец: «проверь их обоих на тех 250 вопросах» → «правь все».
Разбор: docs/superpowers/findings/2026-07-14-bot-250-both-modes-run.md

Цены оказались вылечены полностью (0 выдуманных цен на 500 ответов), но вылез другой
пласт — бот путал гостя с вошедшим клиентом и импровизировал там, где правды нет в статьях.

ЧТО ПОЧИНЕНО (было → стало на перепрогоне):
- Гостю приписывали счёт и подарок («у вас уже есть 1000 ₽, они лежат на счёте») 2 → 0.
  У гостя нет ни счёта, ни подарка: он не зарегистрирован.
- Вошедшему называли его баланс подарком портала 2 → 0 и подсовывали сценарий новичка
  («живите на подарок», «создайте первый проект») — подарок он потратил давно.
- Вошедшему врали про состояние проектов («два работают», а работает один) 4 → 0
  и про заявки («сегодня 12», а сегодня 0) 1 → 0.
- «Сотая заявка уже дешевле» 2 → 0: ступень меняется по ОБЪЁМУ (первая — 500 заявок).
- «1000 ₽ хватит на несколько десятков заявок» 1 → 0. По решению владельца число заявок
  на подарок НЕ называем вообще: сколько придёт, зависит от источника.
- «Поднимите лимит — заявок будет больше» 1 → 0: лимит это ПОТОЛОК, а не источник.
- «Такого не бывает» про списание без заявки 1 → 0: на проде фантомные списания БЫЛИ.
- «Для Москвы — Московскую область» 1 → 0: Москва и СПб — самостоятельные субъекты.
- Обрубки фраз после чистки сторожем («Они лежат…», «Нажмите её…») 3 → 0.

ЗАМЕЧАНИЕ ВЛАДЕЛЬЦА ПО ЖИВОМУ ЛОГУ: «номера это и есть заявки, а он их делит».
Фраза «мы не продаём номера поштучно» была ВЫДУМКОЙ бота — в статьях её нет.
Теперь: номер = заявка = лид = контакт, продаём любой объём (хоть одну штуку),
минимального заказа нет; спросили цену — бот называет цифру ПЕРВОЙ фразой.
Живьём: «Сколько стоит один номер?» → «Заявка стоит от 55 ₽ до 25 ₽».

ПРАВДА ПРО ВОЗВРАТ ДЕНЕГ вынесена в статью vozvrat-deneg.md — дословно из «Политики
возврата» (/legal/refund): остаток по заявлению на почту, до 3 рабочих дней, за вычетом
комиссий; стоимость переданных заявок не возвращается. Раньше бот придумывал процедуру.

Плюс: сторож больше не считает выдумкой число, названное САМИМ клиентом («можно купить
всего 5 номеров?»); демо-стенд перестал сам себе противоречить (счётчик проекта «сегодня»
разошёлся с числом сделок); добавлен прогонщик bot:run-questions для таких проверок.

Тесты: бот 258/258, весь бэкенд 2738/2744 (0 падений). Сторож не перестарался: из 250
старых одобренных ответов режет целиком 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:32:40 +03:00

623 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\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,
// Счётчик проекта обязан сходиться с реальными сделками за сегодня, иначе
// карточка фактов бота противоречит сама себе: «сегодня 12» у проекта и
// «Заявки: сегодня 0» общей строкой (прогон 250×2, 14.07.2026). Сделки демо
// датированы вчерашним днём — значит сегодня по проекту ноль.
'delivered_today' => 0,
'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'],
]
);
}
}
}