Skip to content

ТЗ — Калькулятор/смета (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_calculatorsid, slug, title, locale, city_id (nullable)сам калькулятор
cms_calculator_sectionsid, calculator_id, title, sort_orderсекция полей
cms_calculator_fieldsid, section_id, field_type, key, options (json), sort_orderполе (движок полей ядра)
cms_calculator_formulasid, calculator_id, expression, coefficients (json), version, lock_versionбезопасное выражение расчёта
cms_calculator_resultsid, 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_idconstrained()->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_enabledbooltrueнетГенерация PDF-сметы по результату
calculator.create_lead_on_submitbooltrueнетАвтосоздание лида при отправке расчёта
calculator.max_fields_per_calculatorint50нетЛимит полей на калькулятор (защита конструктора)
calculator.max_formula_depthint20нетЛимит вложенности/сложности выражения формулы
calculator.max_expression_lengthint500нетЛимит длины выражения в символах — защита парсера от чрезмерно долгого разбора
calculator.pdf_link_ttl_minutesint60нетTTL подписанной ссылки на скачивание PDF-сметы
calculator.result_retention_daysint730нетСрок хранения input расчёта до обезличивания (ПДн-паспорт)
calculator.kill_switchboolfalseдаАварийное отключение расчёта на всех калькуляторах без выключения модуля (форма остаётся видна, кнопка расчёта — заглушка «временно недоступно»)

API

МетодПутьДоступНазначение
GET/api/v1/calculators/{slug}publicJSON-схема калькулятора (секции, поля) для рендера
POST/api/v1/calculators/{slug}/calculatepublic (rate-limit, Idempotency-Key)Расчёт по введённым значениям → результат (+ лид)
GET/api/v1/calculators/{slug}/results/{public_id}/pdfpublic (подписанная ссылка, TTL)Скачивание PDF-сметы по публичному ULID результата
CRUD/api/v1/admin/calculators…admin (calculator.manage)Конструктор: калькулятор, секции, поля
POST/PUT/api/v1/admin/calculators/{id}/formulaadmin (calculator.manage_formulas)Формула и коэффициенты, optimistic lock (lock_version, 409)
POST/api/v1/admin/calculators/{id}/formula/previewadmin (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=trueLeadService::create() с деталями расчёта в payload заявки; при false или недоступности сервиса расчёт сохраняется без лида (см. «Крайние случаи»)
MediaService (ядро)сервис-вызов по requires (канал 4)outСохранение сгенерированного PDF, отдача по подписанной ссылке
CacheTags (ядро)сервис-вызов по requires (канал 4)outИнвалидация тега схемы калькулятора при изменении конструктора/формулы
RequestContext (ядро)сервис-вызов по requires (канал 4)inlocale/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-ответы используют его вместо PK id; внутренний 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_calculatorscms_calculator_sectionscms_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 запрещены

Внутренняя база знаний студии