From 47afefbb10409ee48a381c364776e5b4e619d8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 12 Jul 2026 14:06:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20=D1=86=D0=B5=D0=BD=D1=8B=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=20=D1=82=D1=8F=D0=BD=D0=B5=D1=82=20=D1=81=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B8=D0=B7=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=20=E2=80=94=20=D0=B2=D1=80=D1=83=D1=87=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B4=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вопрос владельца: «а цены в нём надо актуализировать руками или он сам тянет?» Ответ был неприятный: руками. Цифры (300/50 руб, тарифные ступени) стояли текстом в статьях — поменяли бы цену в админке, бот продолжил бы врать про деньги. Теперь в статьях стоят метки {{цена_подбора}}, {{цена_изучения}}, {{тарифы}}, а сервис LivePrices подставляет живые значения из system_settings и pricing_tiers В МОМЕНТ ОТВЕТА — без переиндексации базы знаний. Нет цены в настройках — метка не протекает клиенту, бот отсылает к экрану, где цена написана всегда. Из текстов экскурсий цифры цен тоже убраны (фронт не знает БД): шаг подсвечивает строку с ценой в самом окне — она всегда актуальная. Проверено живьём: поменял цену в настройках на 450 — бот сразу назвал 450. Pest 42/42 (3 новых теста), Vitest каталога 8/8. Co-Authored-By: Claude Fable 5 --- app/app/Services/Bot/BotAnswerService.php | 5 +- app/app/Services/Bot/LivePrices.php | 70 +++++++++++++++++++ app/resources/help/izuchenie-istochnikov.md | 2 +- app/resources/help/konkurentnoe-pole.md | 2 +- app/resources/help/predlozheniya.md | 2 +- app/resources/help/sbor-konkurentov.md | 4 +- app/resources/help/tarify.md | 2 + app/resources/js/tours/catalog.ts | 4 +- app/tests/Feature/Bot/LivePricesTest.php | 77 +++++++++++++++++++++ 9 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 app/app/Services/Bot/LivePrices.php create mode 100644 app/tests/Feature/Bot/LivePricesTest.php diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index a0ed1ba5..80dd642b 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -89,8 +89,11 @@ class BotAnswerService return new BotAnswer(self::UNKNOWN_TEXT, escalate: true); } + // Цены в статьях стоят метками ({{цена_подбора}} и т.п.) — подставляем ЖИВЫЕ + // значения из настроек прямо сейчас: поменяли цену в админке — бот называет новую. + $prices = app(LivePrices::class); $context = implode("\n\n---\n\n", array_map( - fn ($c) => "### {$c->title}\n{$c->content}", + fn ($c) => "### {$c->title}\n".$prices->substitute((string) $c->content), $chunks )); diff --git a/app/app/Services/Bot/LivePrices.php b/app/app/Services/Bot/LivePrices.php new file mode 100644 index 00000000..3c2ef5eb --- /dev/null +++ b/app/app/Services/Bot/LivePrices.php @@ -0,0 +1,70 @@ + $this->rub(SystemSettings::get('autopodbor_price_search_rub')), + '{{цена_изучения}}' => $this->rub(SystemSettings::get('autopodbor_price_study_rub')), + '{{тарифы}}' => $this->tiers(), + ]); + } + + private function rub(?string $value): string + { + if ($value === null || $value === '' || ! is_numeric($value)) { + return self::FALLBACK_PRICE; + } + + return rtrim(rtrim(number_format((float) $value, 2, ',', ' '), '0'), ',').' ₽'; + } + + /** Сетка ступеней словами: «1-я — 500 ₽ за заявку (первые 100 заявок месяца)». */ + private function tiers(): string + { + $rows = DB::table('pricing_tiers') + ->where('is_active', true) + ->orderBy('tier_no') + ->get(['tier_no', 'leads_in_tier', 'price_per_lead_kopecks']); + + if ($rows->isEmpty()) { + return 'сетка ступеней показана в разделе «Биллинг»'; + } + + $parts = []; + foreach ($rows as $r) { + $price = $this->rub((string) ((int) $r->price_per_lead_kopecks / 100)); + $count = (int) $r->leads_in_tier; + $parts[] = $count > 0 + ? "ступень {$r->tier_no} — {$price} за заявку (следующие {$count} заявок месяца)" + : "ступень {$r->tier_no} — {$price} за заявку (весь объём сверх предыдущих)"; + } + + return implode('; ', $parts); + } +} diff --git a/app/resources/help/izuchenie-istochnikov.md b/app/resources/help/izuchenie-istochnikov.md index a1d8633f..6d7cdaf9 100644 --- a/app/resources/help/izuchenie-istochnikov.md +++ b/app/resources/help/izuchenie-istochnikov.md @@ -11,7 +11,7 @@ topics: собрать источники, изучить конкурента, для меня»**. Лидерра пройдёт по его сайтам и карточкам справочников (а если это холдинг — по всем его элементам) и принесёт найденные сайты и номера. -**Цена — 50 ₽ за одно изучение** (точная цена написана в окне). Важное различие: +**Цена — {{цена_изучения}} за одно изучение** (точная цена написана в окне). Важное различие: - **Первый сбор** по конкуренту — деньги спишутся, **только если источники нашлись**. Пусто — бесплатно. diff --git a/app/resources/help/konkurentnoe-pole.md b/app/resources/help/konkurentnoe-pole.md index 00ba3829..7286d961 100644 --- a/app/resources/help/konkurentnoe-pole.md +++ b/app/resources/help/konkurentnoe-pole.md @@ -16,7 +16,7 @@ topics: конкурентное поле, поле, автоподбор, ра конкурентов и их источники, а из найденного вы в один клик сделаете те же самые проекты. Заявки в итоге приходят одинаково — в «Сделки», и платите вы за них по одним и тем же тарифам. Разница только в том, кто ищет источник: вы или мы -(и за нашу работу берём 300 ₽ за подбор и 50 ₽ за изучение конкурента). +(и за нашу работу берём {{цена_подбора}} за подбор и {{цена_изучения}} за изучение конкурента). Раздел устроен в три экрана: diff --git a/app/resources/help/predlozheniya.md b/app/resources/help/predlozheniya.md index cbbc53c7..a271d6b6 100644 --- a/app/resources/help/predlozheniya.md +++ b/app/resources/help/predlozheniya.md @@ -13,7 +13,7 @@ topics: предложения, черновик, разобрать предл а не заброшенный. В поле берите только проверенных. Почему это важно: **поле — основа всей дальнейшей работы.** По каждому конкуренту -из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег (50 ₽). +из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег ({{цена_изучения}}). Если в поле попала чужая или мёртвая карточка, вы заплатите за поиск источников, которые вам не нужны, а проекты по ним приведут не тех клиентов. Полминуты на проверку ссылки экономят и деньги, и качество заявок. diff --git a/app/resources/help/sbor-konkurentov.md b/app/resources/help/sbor-konkurentov.md index c3da0c7f..b2fca531 100644 --- a/app/resources/help/sbor-konkurentov.md +++ b/app/resources/help/sbor-konkurentov.md @@ -18,7 +18,7 @@ topics: собрать конкурентов, подбор конкуренто ссылка на 2ГИС / Яндекс.Карты. Чем больше примеров — тем точнее подбор. - **Включать федеральных конкурентов** — да или нет (по умолчанию да). -**Цена — 300 ₽ за подбор** (точная цена всегда написана прямо в окне). Деньги +**Цена — {{цена_подбора}} за подбор** (точная цена всегда написана прямо в окне). Деньги списываются **только если что-то нашли**: пустой результат — бесплатно. Пока идёт подбор, окно показывает «Идёт подбор…» — вкладку можно закрыть, @@ -32,7 +32,7 @@ topics: собрать конкурентов, подбор конкуренто откройте его сайт и карточку справочника по ссылкам: тот ли это бизнес, ваш ли город, живой ли адрес. ИИ ошибается — решение всегда за вами. Чистое поле — основа всего дальнейшего: по каждому конкуренту из поля мы ищем источники -(50 ₽ за изучение), и по грязной карточке вы заплатите за ненужный результат. +({{цена_изучения}} за изучение), и по грязной карточке вы заплатите за ненужный результат. Можно и **без ИИ, вручную и бесплатно** — кнопка «+ Добавить вручную» (см. отдельную статью про ручное добавление). diff --git a/app/resources/help/tarify.md b/app/resources/help/tarify.md index 3d19bba8..c5d7e753 100644 --- a/app/resources/help/tarify.md +++ b/app/resources/help/tarify.md @@ -13,6 +13,8 @@ topics: тариф, цена, сколько стоит заявка, цена опускается на следующую ступень, и так далее. В новом месяце счёт начинается заново. +Сейчас сетка такая: {{тарифы}}. + Всю сетку видно в разделе **«Биллинг»** — панель «Цены за лид (7 ступеней)». Она сворачивается; разверните — увидите все ступени, а ваша текущая помечена «вы здесь». Там же видно, сколько заявок осталось до следующей, более дешёвой diff --git a/app/resources/js/tours/catalog.ts b/app/resources/js/tours/catalog.ts index eceb7a8f..0bf70ba5 100644 --- a/app/resources/js/tours/catalog.ts +++ b/app/resources/js/tours/catalog.ts @@ -614,7 +614,7 @@ export const TOURS: TourScenario[] = [ route: '/autopodbor', target: '[data-tour="field-collect-price"]', title: 'Цена', - text: '300 ₽ за подбор. Деньги спишутся только если что-то найдём — пустой результат бесплатный. Вкладку можно закрыть, результат сохранится.', + text: 'Цена подбора написана прямо в этой строке — всегда актуальная. Деньги спишутся, только если что-то найдём: пустой результат бесплатный. Вкладку можно закрыть, результат сохранится.', }, { route: '/autopodbor', @@ -652,7 +652,7 @@ export const TOURS: TourScenario[] = [ open: '[data-tour="competitor-study"]', target: '[data-tour="competitor-study-price"]', title: 'Цена и важное отличие', - text: '50 ₽ за изучение. Первый сбор — платим только если нашли. Повторный — платим в любом случае, за работу; уже найденное при этом не задваивается.', + text: 'Цена изучения написана прямо здесь. Первый сбор — платим, только если нашли. Повторный — платим в любом случае, за работу; уже найденное при этом не задваивается.', }, { route: '/autopodbor', diff --git a/app/tests/Feature/Bot/LivePricesTest.php b/app/tests/Feature/Bot/LivePricesTest.php new file mode 100644 index 00000000..4a602ca6 --- /dev/null +++ b/app/tests/Feature/Bot/LivePricesTest.php @@ -0,0 +1,77 @@ +set('services.yandexgpt', [ + 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', + 'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion', + 'timeout_seconds' => 8, + ]); +}); + +function setPrice(string $key, string $value): void +{ + DB::table('system_settings')->updateOrInsert( + ['key' => $key], + ['value' => $value, 'type' => 'int', 'updated_at' => now()] + ); +} + +it('подставляет живую цену сбора и изучения из настроек', function () { + setPrice('autopodbor_price_search_rub', '450'); + setPrice('autopodbor_price_study_rub', '70'); + + $prices = app(LivePrices::class); + + expect($prices->substitute('Подбор — {{цена_подбора}}, изучение — {{цена_изучения}}.')) + ->toBe('Подбор — 450 ₽, изучение — 70 ₽.'); +}); + +it('цена изменилась в админке — бот называет новую без переиндексации', function () { + setPrice('autopodbor_price_search_rub', '300'); + KnowledgeChunk::create([ + 'source_path' => 'help/k.md', 'title' => 'Собрать конкурентов', 'tour' => 'collect-competitors', + 'topics' => 'собрать конкурентов, цена подбора', 'chunk_index' => 0, + 'content' => 'Цена — {{цена_подбора}} за подбор. Деньги спишутся только при результате.', + ]); + + // владелец поднял цену в админке + setPrice('autopodbor_price_search_rub', '500'); + + Http::fake(['*' => Http::response([ + 'result' => ['alternatives' => [['message' => ['text' => 'Подбор стоит 500 ₽.']]]], + ], 200)]); + + app(BotAnswerService::class)->answer('сколько стоит собрать конкурентов?'); + + Http::assertSent(function ($request) { + $system = $request['messages'][0]['text']; + + // В ИИ уходит живая цена, а не старая и не сама метка. + return str_contains($system, '500 ₽') + && ! str_contains($system, '300 ₽') + && ! str_contains($system, '{{'); + }); +}); + +it('цены нет в настройках — метка не протекает клиенту', function () { + DB::table('system_settings')->where('key', 'autopodbor_price_search_rub')->delete(); + + expect(app(LivePrices::class)->substitute('Цена — {{цена_подбора}}.')) + ->not->toContain('{{'); +});