feat(bot): цены бот тянет сам из настроек — вручную актуализировать больше не надо

Вопрос владельца: «а цены в нём надо актуализировать руками или он сам тянет?»
Ответ был неприятный: руками. Цифры (300/50 руб, тарифные ступени) стояли текстом
в статьях — поменяли бы цену в админке, бот продолжил бы врать про деньги.

Теперь в статьях стоят метки {{цена_подбора}}, {{цена_изучения}}, {{тарифы}},
а сервис LivePrices подставляет живые значения из system_settings и pricing_tiers
В МОМЕНТ ОТВЕТА — без переиндексации базы знаний. Нет цены в настройках — метка
не протекает клиенту, бот отсылает к экрану, где цена написана всегда.

Из текстов экскурсий цифры цен тоже убраны (фронт не знает БД): шаг подсвечивает
строку с ценой в самом окне — она всегда актуальная.

Проверено живьём: поменял цену в настройках на 450 — бот сразу назвал 450.
Pest 42/42 (3 новых теста), Vitest каталога 8/8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-12 14:06:04 +03:00
parent fddba0a616
commit 47afefbb10
9 changed files with 160 additions and 8 deletions
+4 -1
View File
@@ -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
));
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use App\Support\SystemSettings;
use Illuminate\Support\Facades\DB;
/**
* Живые цены для ответов бота (вопрос владельца 12.07.2026: «цены надо
* актуализировать руками или он сам тянет?»).
*
* В статьях инструкции цены стоят МЕТКАМИ, а не цифрами: {{цена_подбора}},
* {{цена_изучения}}, {{тарифы}}. Подстановка происходит в момент ответа
* поменяли цену в админке, бот называет новую сразу, без переиндексации.
*
* Если цены в настройках нет метку не показываем клиенту, а мягко отсылаем
* к экрану, где цена написана всегда.
*/
class LivePrices
{
private const FALLBACK_PRICE = 'по цене, указанной в самом окне';
public function substitute(string $text): string
{
if (! str_contains($text, '{{')) {
return $text;
}
return strtr($text, [
'{{цена_подбора}}' => $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);
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ topics: собрать источники, изучить конкурента,
для меня»**. Лидерра пройдёт по его сайтам и карточкам справочников (а если это
холдинг — по всем его элементам) и принесёт найденные сайты и номера.
**Цена — 50 ₽ за одно изучение** (точная цена написана в окне). Важное различие:
**Цена — {{цена_изучения}} за одно изучение** (точная цена написана в окне). Важное различие:
- **Первый сбор** по конкуренту — деньги спишутся, **только если источники
нашлись**. Пусто — бесплатно.
+1 -1
View File
@@ -16,7 +16,7 @@ topics: конкурентное поле, поле, автоподбор, ра
конкурентов и их источники, а из найденного вы в один клик сделаете те же самые
проекты. Заявки в итоге приходят одинаково — в «Сделки», и платите вы за них
по одним и тем же тарифам. Разница только в том, кто ищет источник: вы или мы
(и за нашу работу берём 300 ₽ за подбор и 50 ₽ за изучение конкурента).
(и за нашу работу берём {{цена_подбора}} за подбор и {{цена_изучения}} за изучение конкурента).
Раздел устроен в три экрана:
+1 -1
View File
@@ -13,7 +13,7 @@ topics: предложения, черновик, разобрать предл
а не заброшенный. В поле берите только проверенных.
Почему это важно: **поле — основа всей дальнейшей работы.** По каждому конкуренту
из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег (50 ₽).
из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег ({{цена_изучения}}).
Если в поле попала чужая или мёртвая карточка, вы заплатите за поиск источников,
которые вам не нужны, а проекты по ним приведут не тех клиентов. Полминуты
на проверку ссылки экономят и деньги, и качество заявок.
+2 -2
View File
@@ -18,7 +18,7 @@ topics: собрать конкурентов, подбор конкуренто
ссылка на 2ГИС / Яндекс.Карты. Чем больше примеров — тем точнее подбор.
- **Включать федеральных конкурентов** — да или нет (по умолчанию да).
**Цена — 300 ₽ за подбор** (точная цена всегда написана прямо в окне). Деньги
**Цена — {{цена_подбора}} за подбор** (точная цена всегда написана прямо в окне). Деньги
списываются **только если что-то нашли**: пустой результат — бесплатно.
Пока идёт подбор, окно показывает «Идёт подбор…» — вкладку можно закрыть,
@@ -32,7 +32,7 @@ topics: собрать конкурентов, подбор конкуренто
откройте его сайт и карточку справочника по ссылкам: тот ли это бизнес, ваш ли
город, живой ли адрес. ИИ ошибается — решение всегда за вами. Чистое поле —
основа всего дальнейшего: по каждому конкуренту из поля мы ищем источники
(50 ₽ за изучение), и по грязной карточке вы заплатите за ненужный результат.
({{цена_изучения}} за изучение), и по грязной карточке вы заплатите за ненужный результат.
Можно и **без ИИ, вручную и бесплатно** — кнопка «+ Добавить вручную»
(см. отдельную статью про ручное добавление).
+2
View File
@@ -13,6 +13,8 @@ topics: тариф, цена, сколько стоит заявка, цена
опускается на следующую ступень, и так далее. В новом месяце счёт начинается
заново.
Сейчас сетка такая: {{тарифы}}.
Всю сетку видно в разделе **«Биллинг»** — панель «Цены за лид (7 ступеней)».
Она сворачивается; разверните — увидите все ступени, а ваша текущая помечена
«вы здесь». Там же видно, сколько заявок осталось до следующей, более дешёвой
+2 -2
View File
@@ -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',
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\LivePrices;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
/**
* Цены в инструкции метками, а не цифрами (вопрос владельца 12.07.2026:
* «цены надо актуализировать руками или он сам тянет?»). Поменяли цену в админке
* бот обязан назвать новую в ту же секунду, без переиндексации базы знаний.
*/
beforeEach(function () {
config()->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('{{');
});