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:
@@ -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
|
||||
));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ topics: собрать источники, изучить конкурента,
|
||||
для меня»**. Лидерра пройдёт по его сайтам и карточкам справочников (а если это
|
||||
холдинг — по всем его элементам) и принесёт найденные сайты и номера.
|
||||
|
||||
**Цена — 50 ₽ за одно изучение** (точная цена написана в окне). Важное различие:
|
||||
**Цена — {{цена_изучения}} за одно изучение** (точная цена написана в окне). Важное различие:
|
||||
|
||||
- **Первый сбор** по конкуренту — деньги спишутся, **только если источники
|
||||
нашлись**. Пусто — бесплатно.
|
||||
|
||||
@@ -16,7 +16,7 @@ topics: конкурентное поле, поле, автоподбор, ра
|
||||
конкурентов и их источники, а из найденного вы в один клик сделаете те же самые
|
||||
проекты. Заявки в итоге приходят одинаково — в «Сделки», и платите вы за них
|
||||
по одним и тем же тарифам. Разница только в том, кто ищет источник: вы или мы
|
||||
(и за нашу работу берём 300 ₽ за подбор и 50 ₽ за изучение конкурента).
|
||||
(и за нашу работу берём {{цена_подбора}} за подбор и {{цена_изучения}} за изучение конкурента).
|
||||
|
||||
Раздел устроен в три экрана:
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ topics: предложения, черновик, разобрать предл
|
||||
а не заброшенный. В поле берите только проверенных.
|
||||
|
||||
Почему это важно: **поле — основа всей дальнейшей работы.** По каждому конкуренту
|
||||
из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег (50 ₽).
|
||||
из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег ({{цена_изучения}}).
|
||||
Если в поле попала чужая или мёртвая карточка, вы заплатите за поиск источников,
|
||||
которые вам не нужны, а проекты по ним приведут не тех клиентов. Полминуты
|
||||
на проверку ссылки экономят и деньги, и качество заявок.
|
||||
|
||||
@@ -18,7 +18,7 @@ topics: собрать конкурентов, подбор конкуренто
|
||||
ссылка на 2ГИС / Яндекс.Карты. Чем больше примеров — тем точнее подбор.
|
||||
- **Включать федеральных конкурентов** — да или нет (по умолчанию да).
|
||||
|
||||
**Цена — 300 ₽ за подбор** (точная цена всегда написана прямо в окне). Деньги
|
||||
**Цена — {{цена_подбора}} за подбор** (точная цена всегда написана прямо в окне). Деньги
|
||||
списываются **только если что-то нашли**: пустой результат — бесплатно.
|
||||
|
||||
Пока идёт подбор, окно показывает «Идёт подбор…» — вкладку можно закрыть,
|
||||
@@ -32,7 +32,7 @@ topics: собрать конкурентов, подбор конкуренто
|
||||
откройте его сайт и карточку справочника по ссылкам: тот ли это бизнес, ваш ли
|
||||
город, живой ли адрес. ИИ ошибается — решение всегда за вами. Чистое поле —
|
||||
основа всего дальнейшего: по каждому конкуренту из поля мы ищем источники
|
||||
(50 ₽ за изучение), и по грязной карточке вы заплатите за ненужный результат.
|
||||
({{цена_изучения}} за изучение), и по грязной карточке вы заплатите за ненужный результат.
|
||||
|
||||
Можно и **без ИИ, вручную и бесплатно** — кнопка «+ Добавить вручную»
|
||||
(см. отдельную статью про ручное добавление).
|
||||
|
||||
@@ -13,6 +13,8 @@ topics: тариф, цена, сколько стоит заявка, цена
|
||||
опускается на следующую ступень, и так далее. В новом месяце счёт начинается
|
||||
заново.
|
||||
|
||||
Сейчас сетка такая: {{тарифы}}.
|
||||
|
||||
Всю сетку видно в разделе **«Биллинг»** — панель «Цены за лид (7 ступеней)».
|
||||
Она сворачивается; разверните — увидите все ступени, а ваша текущая помечена
|
||||
«вы здесь». Там же видно, сколько заявок осталось до следующей, более дешёвой
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('{{');
|
||||
});
|
||||
Reference in New Issue
Block a user