feat(bot): чат растягивается + демо-стенд на боевых ценах

Окошко чата: уголок для растягивания и кнопка «развернуть», размер
запоминается — длинный ответ в маленьком окне читать неудобно. Само
открывается только там, где страница просит (лендинг): на странице входа
оно распахивалось и закрывало кнопку «Войти».

Демо-стенд (demo:showcase) — цены как на боевом: 55 ₽ за заявку, сетка
55→25 ₽, сбор конкурентов 50 ₽, сбор источников 10 ₽. Кошелёк теперь
сходится: пополнено 12 400 ₽ − 25 заявок × 55 ₽ = 11 025 ₽, пополнения
датированы раньше списаний. Поле конкурентов пересобирается начисто (опыты
со склейкой ломали порядок карточек), отчёты чистятся (портал держит не
больше трёх в работе и гасит кнопку).

Журнал денег переписать нельзя — только дописать (защита от подделки),
поэтому firstOrCreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 08:58:53 +03:00
parent 261a3cd46f
commit ee701f8d3a
2 changed files with 217 additions and 21 deletions
+143 -17
View File
@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Console\Commands;
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;
@@ -17,6 +19,7 @@ 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;
/**
@@ -48,6 +51,14 @@ class SeedShowcaseCommand extends Command
/** Статусы воронки — реальные 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') {
@@ -184,6 +195,7 @@ class SeedShowcaseCommand extends Command
$projects = [$projectWorking, $projectPaused, $projectBlocked];
$cities = ['Москва', 'Санкт-Петербург', 'Казань'];
$charged = [];
$today = Carbon::now();
$yesterday = $today->copy()->subDay();
@@ -214,22 +226,34 @@ class SeedShowcaseCommand extends Command
]
);
if ($n <= 6) {
LeadCharge::query()->firstOrCreate(
['tenant_id' => $tenant->id, 'deal_id' => $deal->id],
[
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 20_00,
'charged_at' => $deal->received_at,
'created_at' => $deal->received_at,
]
);
}
// Списываем за КАЖДУЮ заявку — как в жизни. Раньше списаний было только шесть:
// в карточке заявки и в шапке колонки «Канбана» стоимость показывалась прочерком,
// а история кошелька не сходилась (пополнено 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,
]
);
$charged[] = $deal;
}
foreach ([5000, 7400] as $amount) {
// Кошелёк должен СХОДИТЬСЯ: пополнено 12 400 ₽ − списано 25 × 55 ₽ = остаток.
$chargesRub = count($charged) * (self::PRICE_PER_LEAD_KOPECKS / 100);
$balanceRub = self::TOPUPS_RUB - $chargesRub;
// 🔴 Журнал денег 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,
@@ -238,17 +262,57 @@ class SeedShowcaseCommand extends Command
],
[
'amount_leads' => 0,
'balance_rub_after' => $tenant->balance_rub,
'balance_rub_after' => sprintf('%.2f', $runningBalance),
'balance_leads_after' => null,
'description' => 'Демо-пополнение для съёмки разборов',
'created_at' => now(),
// Пополнение должно быть РАНЬШЕ первого списания, иначе в истории кошелька
// деньги списываются с пустого счёта — и это видно клиенту на кадрах.
'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 заявок, конкурентное поле.');
$this->info('demo:showcase готово — тенант «Стоматология Улыбка», 3 проекта, 25 заявок, конкурентное поле, БОЕВЫЕ цены.');
return self::SUCCESS;
}
@@ -279,6 +343,57 @@ class SeedShowcaseCommand extends Command
* фильтрует как пустышку баг 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') —
@@ -293,6 +408,17 @@ class SeedShowcaseCommand extends Command
]
);
// 🔴 Поле конкурентов пересобираем НАЧИСТО. Оно живое: в портале конкурентов
// склеивают, добавляют вручную, удаляют — и демо-поле после первого же опыта
// перестаёт совпадать с тем, что ждут сценарии съёмки (они целятся в карточки по
// номеру: «вторая», «третья»). Реальный случай 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],
[
+74 -4
View File
@@ -13,6 +13,12 @@
if (window.__liderraChat) return;
window.__liderraChat = true;
// Само открываться окошко имеет право ТОЛЬКО там, где страница об этом просит:
// <script src="/chat-widget.js" data-autoopen>. Иначе оно распахивалось и на странице
// входа в кабинет, перекрывая кнопку «Войти» (поймано роботом-съёмщиком 14.07.2026).
var selfScript = document.currentScript;
var AUTO_OPEN = !!(selfScript && selfScript.dataset && 'autoopen' in selfScript.dataset);
var POLL_MS = 1000;
var POLL_LIMIT = 25; // 25 секунд — дальше сдаёмся и говорим честно
@@ -23,7 +29,7 @@
var GREETING_USER = 'Здравствуйте! Я помощник Лидерры. Спрашивайте — отвечу по делу.';
var QUICK_GUEST = [
'📞 Как получить телефоны тех, кто был у конкурента?',
'📞 Как получить телефоны тех, кто заходил на мой сайт?',
'💰 Сколько стоит один номер?',
'🎁 Можно попробовать бесплатно?',
'⏱ Когда придут первые заявки?',
@@ -85,7 +91,9 @@
'.lch-panel{position:fixed;right:20px;bottom:88px;width:360px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100vh - 120px);background:#fff;border-radius:16px;box-shadow:0 12px 40px rgba(1,32,25,.28);transform:translateZ(0);will-change:transform;display:none;flex-direction:column;overflow:hidden;z-index:2147483000;font-family:Inter,system-ui,sans-serif}',
'.lch-panel.lch-open{display:flex}',
'.lch-head{background:#012019;color:#fff;padding:14px 16px;font-weight:600;display:flex;justify-content:space-between;align-items:center}',
'.lch-tools{display:flex;gap:2px;align-items:center}',
'.lch-head button{background:none;border:0;color:#fff;font-size:20px;cursor:pointer;line-height:1;padding:4px 6px;border-radius:6px}',
'.lch-head button:hover{background:rgba(255,255,255,.12)}',
'.lch-head button:focus-visible{outline:2px solid #fff;outline-offset:2px}',
'.lch-body{flex:1;overflow-y:auto;padding:12px;background:#F6F3EC}',
'.lch-msg{max-width:85%;padding:10px 12px;border-radius:12px;margin-bottom:8px;white-space:pre-wrap;line-height:1.45;font-size:14px}',
@@ -99,6 +107,10 @@
'.lch-msg a{color:#0F6E56;text-decoration:underline;word-break:break-all}',
'.lch-me a{color:#fff}',
'.lch-typing{opacity:.6;font-style:italic}',
// Уголок для растягивания: окошко «прибито» к правому нижнему углу, поэтому тянем
// за ЛЕВЫЙ ВЕРХНИЙ угол — окно растёт влево и вверх, не убегая за край экрана.
'.lch-grip{position:absolute;left:0;top:0;width:22px;height:22px;cursor:nwse-resize;z-index:2;background:linear-gradient(135deg,rgba(255,255,255,.55) 0 45%,transparent 45%);border-top-left-radius:16px}',
'.lch-grip:hover{background:linear-gradient(135deg,rgba(255,255,255,.9) 0 45%,transparent 45%)}',
].join('');
document.head.appendChild(css);
@@ -115,7 +127,10 @@
panel.setAttribute('aria-modal', 'false');
panel.setAttribute('aria-label', 'Чат поддержки Лидерры');
panel.innerHTML =
'<div class="lch-head"><span>Лидерра — помощник</span><button type="button" aria-label="Закрыть чат">×</button></div>' +
'<div class="lch-grip" title="Потяните, чтобы растянуть окно"></div>' +
'<div class="lch-head"><span>Лидерра — помощник</span><span class="lch-tools">'
+ '<button type="button" class="lch-zoom" aria-label="Развернуть чат" title="Развернуть">⤢</button>'
+ '<button type="button" class="lch-close" aria-label="Закрыть чат">×</button></span></div>' +
'<div class="lch-body" id="lch-body" role="log" aria-live="polite"></div>' +
'<form class="lch-foot"><input type="text" maxlength="1000" placeholder="Напишите вопрос…" aria-label="Сообщение"><button type="submit" aria-label="Отправить">→</button></form>';
@@ -125,7 +140,62 @@
var body = panel.querySelector('#lch-body');
var form = panel.querySelector('form');
var input = panel.querySelector('input');
var closeBtn = panel.querySelector('.lch-head button');
var closeBtn = panel.querySelector('.lch-close');
var zoomBtn = panel.querySelector('.lch-zoom');
var grip = panel.querySelector('.lch-grip');
// Размер окна клиент задаёт сам (тянет за уголок или жмёт «развернуть») — и мы его
// ЗАПОМИНАЕМ: длинный ответ бота в маленьком окошке читать неудобно.
var MIN_W = 320, MIN_H = 380;
function maxW() { return Math.max(MIN_W, window.innerWidth - 40); }
function maxH() { return Math.max(MIN_H, window.innerHeight - 120); }
function applySize(w, h) {
w = Math.min(Math.max(Math.round(w), MIN_W), maxW());
h = Math.min(Math.max(Math.round(h), MIN_H), maxH());
panel.style.width = w + 'px';
panel.style.height = h + 'px';
try { localStorage.setItem('liderra_chat_size', w + 'x' + h); } catch (e) { /* приватный режим */ }
}
(function restoreSize() {
var saved = null;
try { saved = localStorage.getItem('liderra_chat_size'); } catch (e) { /* приватный режим */ }
if (!saved || !/^\d+x\d+$/.test(saved)) return;
var parts = saved.split('x');
applySize(parseInt(parts[0], 10), parseInt(parts[1], 10));
})();
zoomBtn.onclick = function () {
var big = panel.offsetWidth >= Math.min(700, maxW()) - 1;
if (big) {
applySize(360, 520);
zoomBtn.textContent = '⤢';
zoomBtn.title = 'Развернуть';
} else {
applySize(Math.min(700, maxW()), maxH());
zoomBtn.textContent = '⤡';
zoomBtn.title = 'Свернуть до обычного';
}
};
grip.addEventListener('pointerdown', function (e) {
e.preventDefault();
var startX = e.clientX, startY = e.clientY;
var startW = panel.offsetWidth, startH = panel.offsetHeight;
grip.setPointerCapture(e.pointerId);
function move(ev) {
applySize(startW + (startX - ev.clientX), startH + (startY - ev.clientY));
}
function up() {
grip.removeEventListener('pointermove', move);
grip.removeEventListener('pointerup', up);
}
grip.addEventListener('pointermove', move);
grip.addEventListener('pointerup', up);
});
function bubble(text, mine, extraClass) {
var el = document.createElement('div');
@@ -273,7 +343,7 @@
// Гостю на лендинге окошко открываем САМИ: он пришёл посмотреть, а не искать
// кнопку чата. Вошедшему в кабинете — не открываем: он работает, а не знакомится.
// Фокус в поле ввода при авто-открытии не ставим — иначе страница прыгнет к окошку.
if (isGuest()) {
if (AUTO_OPEN && isGuest()) {
setTimeout(function () {
if (!panel.classList.contains('lch-open')) {
panel.classList.add('lch-open');