diff --git a/app/app/Console/Commands/SeedShowcaseCommand.php b/app/app/Console/Commands/SeedShowcaseCommand.php index ed9269a5..20895b46 100644 --- a/app/app/Console/Commands/SeedShowcaseCommand.php +++ b/app/app/Console/Commands/SeedShowcaseCommand.php @@ -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], [ diff --git a/app/public/chat-widget.js b/app/public/chat-widget.js index b075359e..d320b236 100644 --- a/app/public/chat-widget.js +++ b/app/public/chat-widget.js @@ -13,6 +13,12 @@ if (window.__liderraChat) return; window.__liderraChat = true; + // Само открываться окошко имеет право ТОЛЬКО там, где страница об этом просит: + //