Тема
ТЗ — Калькулятор/смета (cms/calculator)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: artel-23ru, universal Статус: ТЗ к разработке
Назначение и возможности
Конструктор калькуляторов из секций и полей движка полей ядра, безопасные формулы (парсер выражений без eval), результат расчёта конвертируется в лид, PDF-смета собственным рендером (не зависит от cms/commerce-invoices).
- Конструктор калькулятора: секции → поля (числа, select, checkbox — движок полей ядра)
- Формулы с переменными полей, безопасный парсер выражений (whitelist операций, без
eval) - Многотиповые коэффициенты (материалы/работы/фиксированная наценка) в формуле
- Результат расчёта сохраняется и конвертируется в лид (с деталями расчёта в payload)
- PDF-смета — собственный рендер (не переиспользует движок
cms/commerce-invoices) - Валидация формул при сохранении (защита от циклических ссылок между полями)
- Превью расчёта в конструкторе (без сохранения) для проверки формулы редактором
- Версионирование формулы: изменение формулы не переигрывает задним числом уже сохранённые расчёты посетителей
Зависимости и выключение
requires: ядро · suggests: cms/commerce-invoices — не как зависимость по PDF (смета всегда рендерится собственным движком калькулятора), а как опциональная кнопка «выставить счёт по смете» в карточке результата: при включённом cms/commerce-invoices доступна ссылка «превратить смету в счёт» (сервис-вызов по provides-контракту модуля счетов, без requires в манифесте), при выключенном — кнопка скрыта, смета самодостаточна и без счёта.
Поведение при выключении: блок калькулятора скрывается на страницах (fallback — заглушка «сервис временно недоступен»), сохранённые расчёты и лиды не удаляются, конструктор и API становятся недоступны, ранее выданные подписанные ссылки на PDF не отдают файл (404, а не 500).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_calculators | id, slug, title, locale, city_id (nullable) | сам калькулятор |
cms_calculator_sections | id, calculator_id, title, sort_order | секция полей |
cms_calculator_fields | id, section_id, field_type, key, options (json), sort_order | поле (движок полей ядра) |
cms_calculator_formulas | id, calculator_id, expression, coefficients (json), version, lock_version | безопасное выражение расчёта |
cms_calculator_results | id, public_id (ulid), calculator_id, formula_version, lead_id (nullable), input (json), result_minor, currency_code, pdf_media_id (nullable), external_id (nullable) | сохранённый расчёт |
calculator_id/section_id — constrained()->cascadeOnDelete()->index() по всей цепочке; options/coefficients/input — JSONB с GIN-индексом где нужна выборка по ключам; field_type — PHP Enum. Деньги — result_minor (integer minor units) + currency_code, float запрещён (§4 стандарта). public_id (ULID, unique-индекс) — публичный идентификатор результата для API/URL, автоинкрементный id в публичных ответах не светится (см. «Крайние случаи»). external_id — nullable, unique в рамках calculator_id — идемпотентность legacy-импорта (§16 стандарта). version на cms_calculator_formulas — новая версия при каждом сохранении формулы; formula_version на cms_calculator_results фиксирует, по какой версии посчитан конкретный расчёт (задним числом не переигрывается). lock_version на cms_calculator_formulas — optimistic lock конкурентного редактирования (409).
ПДн-паспорт. cms_calculator_results.input может содержать контактные данные, если в калькулятор включены поля «телефон»/«email» для последующего лида (типовой сценарий сметы на услугу). Срок хранения — calculator.result_retention_days (настройка ниже), по истечении — плановая джоба обезличивает input (контактные ключи заменяются на null, числовые параметры расчёта остаются для статистики). Участие в «выгрузить всё по субъекту»/«забыть по запросу» (152-ФЗ): выгрузка — все расчёты с lead_id, ведущие к субъекту, через LeadService; забыть — обезличивание input конкретного результата + удаление привязанного PDF вместе с медиа (PDF содержит те же контактные данные в свёрстанном виде).
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
POST /api/v1/calculators/{slug}/calculate (публичный) | input (json) — строго по ключам полей текущего калькулятора | FormRequest whitelist по ключам полей + rules движка полей (тип, min/max, обязательность); лишние/неизвестные ключи отклоняются 422 целиком, не игнорируются |
POST/PUT /api/v1/admin/calculators/{id}/formula (конструктор, админка) | expression (string), coefficients (json) | FormRequest + парсер-валидатор выражений ядра: whitelist AST-узлов, лимит calculator.max_expression_length/calculator.max_formula_depth, lock_version на конфликт (409) |
POST /api/v1/admin/calculators/{id}/formula/preview (конструктор, превью) | expression (черновая, может быть ещё не сохранена), тестовые значения input | тот же валидатор выражений; расчёт выполняется in-memory, ничего не пишет в БД |
CRUD /api/v1/admin/calculators… (секции/поля) | структура калькулятора | FormRequest + calculator.max_fields_per_calculator |
cms:calculator:import-legacy --source=<профиль> (legacy-импорт) | старые калькуляторы/сметы (CalculatorSection, CalculatorItem, Estimate* донора) | маппинг профиля источника, идемпотентность по external_id, --dry-run с отчётом расхождений |
Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта).
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Посетитель (ответ на calculate) | result_minor, currency_code, public_id расчёта, статус PDF | конверт {data, meta} |
LeadService::create() | входные значения + результат расчёта как payload заявки | вызов сервиса ядра (канал 4, requires) |
| PDF по подписанной ссылке | свёрстанная смета | бинарный файл, ссылка с TTL (calculator.pdf_link_ttl_minutes) |
Событие CalculatorResultSubmitted/CalculatorFormulaInvalid | факт для подписчиков (cms/audit и др.) | payload — раздел «События и обмен» |
| Filament-конструктор | JSON-схема калькулятора, результат превью-расчёта | конверт {data, meta} |
Отчёт import-legacy --dry-run | прочитано/создано/обновлено/пропущено + построчные расхождения | JSON/скачиваемый отчёт |
Настройки (группа calculator)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
calculator.pdf_enabled | bool | true | нет | Генерация PDF-сметы по результату |
calculator.create_lead_on_submit | bool | true | нет | Автосоздание лида при отправке расчёта |
calculator.max_fields_per_calculator | int | 50 | нет | Лимит полей на калькулятор (защита конструктора) |
calculator.max_formula_depth | int | 20 | нет | Лимит вложенности/сложности выражения формулы |
calculator.max_expression_length | int | 500 | нет | Лимит длины выражения в символах — защита парсера от чрезмерно долгого разбора |
calculator.pdf_link_ttl_minutes | int | 60 | нет | TTL подписанной ссылки на скачивание PDF-сметы |
calculator.result_retention_days | int | 730 | нет | Срок хранения input расчёта до обезличивания (ПДн-паспорт) |
calculator.kill_switch | bool | false | да | Аварийное отключение расчёта на всех калькуляторах без выключения модуля (форма остаётся видна, кнопка расчёта — заглушка «временно недоступно») |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/calculators/{slug} | public | JSON-схема калькулятора (секции, поля) для рендера |
| POST | /api/v1/calculators/{slug}/calculate | public (rate-limit, Idempotency-Key) | Расчёт по введённым значениям → результат (+ лид) |
| GET | /api/v1/calculators/{slug}/results/{public_id}/pdf | public (подписанная ссылка, TTL) | Скачивание PDF-сметы по публичному ULID результата |
| CRUD | /api/v1/admin/calculators… | admin (calculator.manage) | Конструктор: калькулятор, секции, поля |
| POST/PUT | /api/v1/admin/calculators/{id}/formula | admin (calculator.manage_formulas) | Формула и коэффициенты, optimistic lock (lock_version, 409) |
| POST | /api/v1/admin/calculators/{id}/formula/preview | admin (calculator.manage_formulas) | Превью расчёта по (черновой) формуле без сохранения |
Компоненты
Блоки (BlockRegistry): «Калькулятор» (рендер секций/полей, кнопка расчёта, demo-props для галереи блоков). Filament: конструктор калькулятора (секции/поля drag&drop), отдельный редактор формул с превью расчёта и индикатором lock_version-конфликта. Команды: cms:calculator:validate-formulas --json (проверка на циклические ссылки), cms:calculator:import-legacy --source=<профиль> --dry-run --json, cms:calculator:anonymize-expired --json (плановое обезличивание по result_retention_days).
Фронтенд-бюджет. Расчёт — чисто серверный (AJAX на кнопку/debounced на изменение поля): дублирующий парсер формул на клиенте не заводится намеренно — иначе клиентская и серверная версии логики расходятся (типовой источник багов «на превью одно, в смете другое»), а парсер и так безопасен и быстр (in-memory, без раунд-трипов к БД). Клиент получает мгновенный отклик за счёт debounce + skeleton вместо тяжёлого JS-движка вычислений. Блок без CLS (резерв высоты под секции/результат), поля — с label, доступны с клавиатуры (Tab, Enter на кнопке расчёта). Демо-сидер калькулятора (2–3 секции, простая формула) — обязателен для /_gallery и playground.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CalculatorResultSubmitted | расчёт отправлен и сохранён | calculator_id, result_id, formula_version, lead_id (nullable) |
CalculatorFormulaInvalid | формула не прошла валидацию при сохранении | calculator_id, formula_id, reason, position |
Слушает: —. Provides-контрактов не реализует, FilterBus не использует.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
LeadService (ядро) | сервис-вызов по requires (канал 4) | out | При отправке расчёта и calculator.create_lead_on_submit=true — LeadService::create() с деталями расчёта в payload заявки; при false или недоступности сервиса расчёт сохраняется без лида (см. «Крайние случаи») |
MediaService (ядро) | сервис-вызов по requires (канал 4) | out | Сохранение сгенерированного PDF, отдача по подписанной ссылке |
CacheTags (ядро) | сервис-вызов по requires (канал 4) | out | Инвалидация тега схемы калькулятора при изменении конструктора/формулы |
RequestContext (ядро) | сервис-вызов по requires (канал 4) | in | locale/city_id/site_id для скоупа калькулятора |
cms/commerce-invoices (suggests) | сервис-вызов по provides-контракту (канал 3, при включении) | out | Опциональная кнопка «выставить счёт по смете» в карточке результата; выключен — кнопка скрыта |
cms/audit (если включён) | слушает события (канал 1) | in для audit | Фиксирует факт сохранения/изменения формулы, отправку расчёта |
Очередь calculator | очереди (канал 5) | out | Асинхронная генерация PDF после CalculatorResultSubmitted |
Фоновая работа
Джоба генерации PDF-сметы (очередь calculator) — асинхронно после CalculatorResultSubmitted, не блокирует HTTP-ответ на расчёт; идемпотентна (повторный запуск не создаёт дублей PDF). Плановая джоба cms:calculator:anonymize-expired (расписание через ScheduleRegistrar) обезличивает input расчётов старше calculator.result_retention_days и удаляет привязанные PDF — идемпотентна, безопасна к повторному запуску.
Производительность и кеш
Ожидаемые объёмы (managed-парк студии): десятки калькуляторов на сайт, от сотен до нескольких тысяч расчётов в месяц на активный сайт (форма сметы — не витринный трафик уровня каталога).
Горячий путь — POST /calculate на каждую отправку: схема калькулятора (секции, поля, формула) читается из кеша (0 запросов к БД на схему), вычисление формулы — чистая in-memory операция над AST (без обращений к БД на каждый шаг вычисления, без N+1 по коэффициентам — они уже часть JSON-схемы формулы), запись результата — один INSERT, издание события — после коммита (afterCommit), постановка PDF-джобы в очередь. Итого бюджет на успешный расчёт: 1 чтение схемы из кеша + 1 INSERT + (опционально) 1 вызов LeadService::create().
Критичные индексы: cms_calculator_results (calculator_id, created_at) — листинг расчётов в админке; уникальный public_id; уникальный (calculator_id, external_id) — идемпотентность импорта; cms_calculator_formulas (calculator_id, version).
Теги кеша: calculator:schema:{calculator_id} — JSON-схема (секции, поля, действующая формула), инвалидация при изменении конструктора или сохранении новой версии формулы. Результаты расчётов не кешируются — персональные данные, читаются напрямую по public_id/id.
Безопасность
Границы входа: FormRequest-whitelist на отправку расчёта (input — строго по ключам полей калькулятора) и на редактор формул (админка). Rate-limit на публичном расчёте и на скачивании PDF. PDF — по подписанной ссылке с TTL (calculator.pdf_link_ttl_minutes), путь адресуется по public_id (ULID), не по автоинкрементному id.
Парсер формул — конкретно. Whitelist AST-узлов: числовой литерал, ссылка на поле/ коэффициент, бинарные операции + - * /, операции сравнения, ограниченный список функций (min, max, round) — вызов любой другой функции/метода, доступ к классам, суперглобалам или файловой системе синтаксически невозможен (не «фильтруется», а отсутствует в грамматике парсера — тот же принцип, что ReDoS-валидатор ядра §11: whitelist, а не blacklist). Лимиты calculator.max_expression_length (длина строки) и calculator.max_formula_depth (глубина дерева) — защита от чрезмерно долгого парсинга/вычисления заведомо избыточных выражений. Деление на ноль перехватывается вычислителем как доменная ошибка расчёта, не как PHP DivisionByZeroError — 500 недопустим.
Матрица ролей. Формулы и коэффициенты — бизнес-критичная часть модуля: ошибка в формуле немедленно меняет стоимость расчёта для всех посетителей и лидов на проде, в отличие от правки текста секции. Поэтому редактирование формул — отдельное право calculator.manage_formulas, не входящее в calculator.manage (структура калькулятора: секции/поля).
| Действие | admin | менеджер | редактор | studio |
|---|---|---|---|---|
Просмотр расчётов (calculator.view) | ✅ | ✅ | ✅ | ✅ |
Конструктор секций/полей (calculator.manage) | ✅ | ✅ | ✅ | ✅ |
Формулы и коэффициенты (calculator.manage_formulas) | ✅ | ✅ | — | ✅ |
| Скачивание/экспорт PDF | ✅ | ✅ | ✅ | ✅ |
Права: calculator.view, calculator.manage, calculator.manage_formulas.
UX-требования
Админ:
- пустое состояние списка калькуляторов — «Калькуляторов ещё нет» с кнопкой «Создать калькулятор», не пустая таблица без объяснения;
- превью расчёта прямо в редакторе формулы («проверить на тестовых значениях») перед публикацией — без сохранения черновика;
- человеческая ошибка при сохранении формулы с синтаксической ошибкой/циклической ссылкой — конкретное поле/позиция символа («поле
area: недопустимый вызовexec()на позиции 24»), не общее «invalid formula»; - подтверждение удаления калькулятора, у которого есть сохранённые расчёты, — модальное окно с числом расчётов и явным предупреждением, что расчёты и лиды при этом не удаляются (soft-поведение, не каскад);
- конфликт
lock_versionпри сохранении формулы — явное сообщение «формулу успели изменить, обновите страницу», не молчаливая перезапись.
Посетитель:
- форма расчёта сохраняет введённые значения при ошибке валидации (422 не сбрасывает поля);
- результат считается быстро — синхронный ответ без ощутимой задержки (in-memory вычисление), PDF генерируется асинхронно и не задерживает отображение результата;
- понятная ошибка при некорректном вводе (например, отрицательное число там, где не должно быть) — рядом с конкретным полем, не общий баннер наверху формы.
Крайние случаи и типовые баги
- недопустимая конструкция в формуле (вызов функции вне whitelist, обращение к классу/ суперглобали) → отклоняется на сохранении, 422 с точным местом ошибки (поле/позиция), не general «invalid formula»;
- деление на ноль в формуле (например, коэффициент площади = 0) → вычислитель перехватывает как доменную ошибку расчёта, посетитель получает понятное сообщение («не удалось рассчитать при введённых значениях»), не 500 и не
NaN/Infinityв результате; - невалидные диапазоны входных значений (отрицательные величины, вне
min/maxполя) → отклоняются FormRequest на этапе валидации ключей полей, до подстановки в формулу — формула никогда не видит заведомо некорректный ввод; - версия формулы vs старые сметы → изменение формулы создаёт новую
versionвcms_calculator_formulas; уже сохранённыеcms_calculator_resultsхранят использованныйformula_versionи не пересчитываются задним числом — открытие старой сметы показывает результат по формуле, действовавшей на момент отправки (аналог_v-версионирования блоков §3 стандарта); create_lead_on_submit=falseлибоLeadServiceнедоступен → расчёт всё равно сохраняется (cms_calculator_resultsсlead_id = null),CalculatorResultSubmittedиздаётся сlead_id: null— отправка сметы не теряется даже без лида;- циклическая ссылка между полями/секциями → обнаруживается на сохранении формулы построением графа зависимостей (поле → поле по ссылкам в
expression) и топологической сортировкой; найденный цикл — 422 с перечислением полей, входящих в цикл, до записи в БД; - PDF-генерация в очереди упала (внешний рендер-движок недоступен) → расчёт и лид уже созданы синхронно до постановки джобы,
pdf_media_idостаётсяnull, ретраи с backoff (правило data-exchange.md), после исчерпания — повторная генерация доступна отдельным действием без пересчёта самого расчёта; основной флоу «расчёт → лид» не блокируется; - конкурентное редактирование формулы двумя админами →
lock_version(optimistic lock), второе сохранение с устаревшим значением — 409 и человеческое сообщение, не «последний победил» молча; - лимит
max_fields_per_calculator→ попытка добавить поле сверх лимита — 422 с текущим значением лимита, поле не создаётся; - пустой/переполненный input на публичном расчёте → пустой
input(обязательные поля пропущены) — 422 по whitelist, не расчёт с нулями по умолчанию; ключи вне схемы текущего калькулятора — отклоняются целиком (whitelist), частичный игнор запрещён; - ⚠️ Противоречие: путь скачивания PDF в исходной версии ТЗ адресовался по автоинкрементному
idрезультата (results/{id}/pdf), что нарушает §4 стандарта («автоинкрементныйidв публичных URL и API-ответах запрещён») — подпись ссылки снижает риск подбора конкретного файла, но не снимает утечку по формату пути (соседние номера раскрывают объём расчётов). Разрешение:cms_calculator_resultsнесёт публичныйpublic_id(ULID), путь и все публичные API-ответы используют его вместо PKid; внутреннийidостаётся PK только для FK/производительности соединений — отражено в модели данных и API этого файла.
Донорский код
| Что взять | Путь |
|---|---|
| Движок расчёта сметы, коэффициенты | universal/src/app/Services/EstimateCalculator.php (свежая версия) · первоисточник artel-23ru/src/app/Services/EstimateCalculator.php |
| Модели сметы (Estimate*) | universal/src/app/Models/Estimate{Project,Room,RoomPoint,Line,Work,WorkCategory,Material,MaterialCategory}.php · конструктор калькуляторов: CalculatorSection.php, CalculatorItem.php |
Legacy-импорт. cms:calculator:import-legacy --source=<профиль> (доноры: artel-23ru, universal — модели Estimate*, CalculatorSection/CalculatorItem): маппинг старых калькуляторов/секций/полей и, где есть, сохранённых смет на новую схему (cms_calculators → cms_calculator_sections → cms_calculator_fields, cms_calculator_formulas, опционально cms_calculator_results); идемпотентность по external_id (повторный прогон обновляет, не дублирует); поддерживает --dry-run с отчётом расхождений (прочитано/создано/обновлено/пропущено и почему). Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: расчёт по фиксированному набору входных значений даёт ожидаемый результат
- [ ] Парсер формул отклоняет попытки использовать произвольный код (нет
eval, whitelist операций) - [ ] Деление на ноль в формуле даёт контролируемую ошибку расчёта, не 500 и не
NaN - [ ] При выключении модуля блок калькулятора отдаёт fallback без 500
- [ ] Циклическая ссылка между полями формулы отклоняется на этапе сохранения
- [ ] Изменение формулы не меняет
formula_version/результат уже сохранённых расчётов - [ ]
create_lead_on_submit=falseсохраняет расчёт без создания лида - [ ] Конкурентное редактирование формулы двумя админами даёт 409 (
lock_version), не оверврайт - [ ] Публичные URL/ответы используют
public_id(ULID), не автоинкрементныйid - [ ] Legacy-импорт идемпотентен по
external_id,--dry-runне пишет в БД - [ ] Права
calculator.manage_formulasразграничены отcalculator.manageи публичного расчёта - [ ] Нет N+1 при рендере секций/полей калькулятора (
->with('sections.fields')) - [ ] PDF-смета генерируется асинхронно (очередь), не блокирует ответ на расчёт; сбой рендера не теряет расчёт/лид
- [ ] Плановое обезличивание
inputпоcalculator.result_retention_daysпокрыто тестом - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (схема, расчёт, превью формулы, PDF, admin CRUD)
- [ ] Тестовая БД только
calculator_test;migrate:fresh/refresh/reset/db:wipeзапрещены