b1b1686b0e
Владелец: «проверь их обоих на тех 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>
623 lines
36 KiB
PHP
623 lines
36 KiB
PHP
<?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'],
|
||
]
|
||
);
|
||
}
|
||
}
|
||
}
|