Спецификация разработки · HoReCa-поставщик
Предложение под меню
Как система собирает для заведения общепита персональное коммерческое предложение о поставке продуктов: из разобранного меню, номенклатуры и прайса 1С, через редактируемый черновик, в PDF.
Оговорка
Спецификация описывает поведение продукта так, как оно согласовано 4 сентября 2026. Она не догма: если при реализации обнаружится, что решение противоречит docs/PRODUCT.md или данным заказчика, документ правится первым, код вторым. Всё, что здесь не описано, считается не решённым, а не разрешённым.
1Резюме
Менеджер поставщика открывает карточку заведения, у которого уже распарсено меню, нажимает «Создать КП», выбирает колонку прайса и получает черновик документа в визуальном редакторе. Черновик собран детерминированно: из матчинга меню на номенклатуру взяты ингредиенты, которые поставщик может закрыть, и блюда, которые заведение могло бы добавить; под каждый ингредиент подставлены до трёх товаров с ценой выбранной колонки. Менеджер правит текст и таблицы как в Word, нажимает «Скачать PDF», файл сохраняется и уходит заведению.
Три вещи, которые отличают эту версию от удалённой: документ говорит от лица поставщика продуктов, а не собирает фуршет из меню заведения; языковая модель в сборке не участвует; источник истины предложения — HTML, который правит человек, а не структура с галочками.
2Контекст
Проект ищет и раскладывает меню заведений HoReCa, чтобы поставщик продуктов мог предложить себя точечно: «вы готовите карбонару, у нас есть гуанчале, пекорино и паста». Раскладка блюд, матчинг на номенклатуру, импорт номенклатуры и прайса из 1С уже работают и лежат в app/core/nomenclature и app/core/onec.
Прежний блок КП был написан на раннем этапе, когда клиентом считался кейтеринг: он собирал «фуршет на 50 гостей» из блюд самого заведения и показывал ему его же еду с итоговой суммой. 4 сентября 2026 блок удалён целиком вместе с таблицами (миграция 0027), копия данных снята вне стенда. Задел для правильной версии сохранён: match_menu отдаёт покрытие по блюдам, список «уже можем поставлять» и блюда расширения, а resolve_price выбирает цену по колонке прайса.
Ответы Юлии от 27 августа задают рамку: история продаж на первом этапе не нужна, колонку прайса определяет потенциал клиента и его выбирает менеджер, в таблице товаров нужны девять колонок из её списка, итоговая сумма не нужна.
3Цели и не-цели
Цели
- Г1. Менеджер получает готовый к отправке PDF за одну сессию редактирования, не собирая таблицы руками.
- Г2. Каждая цифра и каждый товар в документе прослеживаются до данных системы: меню, раскладка, номенклатура, прайс.
- Г3. Заведение видит, что его меню прочитали: ингредиенты названы вместе с его блюдами, предложенные новые блюда соответствуют его кухне.
- Г4. Администратор меняет формулировки, реквизиты и логотип без релиза.
- Г5. Ни одна из девяти колонок прайса, кроме выбранной, не утекает в документ.
Не-цели
- Отправка предложения по почте или в мессенджер, статусы «открыто / прочитано». Экспорт заканчивается скачиванием файла.
- Предложение для заведений без распарсенного меню, только по кухне. Кнопка недоступна, пока меню нет.
- Генерация текста языковой моделью. Все тексты берутся из шаблона и профиля поставщика.
- Несколько шаблонов и их привязка к категориям клиентов. Шаблон один.
- Аналитика по составу отправленных предложений. Источник истины — HTML, см.
docs/adr/0001.
- Кросс-селл «использует, но не покупает» и признак «действующий клиент». Отложено заказчиком до второго этапа.
- Формат DOCX. Зависимость
python-docx остаётся в проекте, но экспорт только в PDF.
4Словарь
Полный словарь лежит в CONTEXT.md в корне репозитория. Здесь только термины, без которых текст ниже не читается.
| Термин | Значение | Не говорим |
| Поставщик | Наш клиент, продавец продуктов HoReCa. Один на инсталляцию. | клиент, компания |
| Заведение | Ресторан или кафе, чьё меню разобрано и кому адресовано предложение. | клиент, организация, лид |
| Контрагент | Запись о заведении в 1С: юридическое имя, ИНН, договор. Может быть сверен с заведением. | партнёр |
| Товар | Позиция номенклатуры с артикулом и ценами по колонкам прайса. | SKU, позиция |
| Ингредиент | Канонический продукт-тип («сыр», «креветки»), общий язык между блюдом и товаром. | категория |
| Колонка прайса | Тип цены 1С. Заведению показывают ровно одну, выбирает менеджер. | тип цены, тариф |
| Предложение | Документ поставщика для заведения. Живёт как HTML, экспортируется в PDF. «КП» допустимо как сокращение в UI. | оффер, смета |
| Поставляем сейчас | Блок предложения: ингредиенты из меню заведения, которые есть у поставщика, с товарами под каждый. | cross-sell, блок 1 |
| Расширение меню | Блок предложения: блюда кухни заведения, которых нет в меню, но которые можно готовить на товарах поставщика. | апсейл, блок 2 |
| Профиль поставщика | Реквизиты, логотип, «о компании», условия, менеджер. Правится в настройках, подставляется как значения по умолчанию. | реквизиты |
| Шаблон | HTML с плейсхолдерами, из которого собирается черновик. Один, правит администратор. | макет |
| Черновик | Предложение после сборки и до экспорта. Правится менеджером. | драфт |
| Пересборка | Повторная сборка из шаблона по актуальным данным. Всегда создаёт новое предложение. | регенерация |
5Сценарии
Персонажи выдуманы, заведение «Траттория Da Vinci» и все цифры в примерах условные. Сценарии показывают, как продукт ведёт себя от начала до конца; требования в разделах 7–12 на них ссылаются.
С1. Марина делает первое предложение
Марина, менеджер по продажам поставщика, открывает карточку «Траттория Da Vinci». Меню распарсено вчера: 64 блюда, покрытие 0,71. Она нажимает «Создать КП». В форме уже стоит колонка прайса «Цена КК», которую она выбрала на карточке неделю назад, и лимит «3 товара на ингредиент». Ниже форма показывает: «64 блюда, под 49 есть продукты, 31 ингредиент, 87 товаров с ценой по этой колонке». Она нажимает «Собрать».
Через две секунды открывается редактор. В блоке «Поставляем сейчас» первым идёт «Сыр — в ваших блюдах: Маргарита, Кватро формаджи, Карбонара и ещё 9», под ним три товара с ценами. Марина удаляет строку с дорогим пармезаном, потому что знает, что заведение берёт бюджетнее, и правит одну фразу во вступлении. Нажимает «Скачать PDF». Файл KP-2026-0007_Trattoria-Da-Vinci.pdf скачивается, статус меняется на «Экспортировано».
С2. Прайс обновился
Через месяц из 1С пришёл новый прайс. Марина открывает старое предложение и видит на нём дату экспорта и предупреждение: «Прайс обновлялся после сборки». Она нажимает «Пересобрать», форма открывается с прежними параметрами, она подтверждает. Появляется новое предложение с номером КП-2026-0041, старое остаётся в списке нетронутым. Её правки из старого не переносятся: это осознанное решение, редактор предупреждает об этом до пересборки.
С3. Юлия меняет условия доставки
Юлия, администратор, открывает «Настройки → Предложения». В профиле поставщика меняет минимальный заказ с 5 000 на 7 000 рублей и загружает новый логотип. Открывает шаблон, переставляет блок «О компании» выше условий, сохраняет. Система проверяет шаблон: все плейсхолдеры известны, синтаксис цел. Кнопка «Предпросмотр на примере» показывает, как будет выглядеть черновик для любого заведения с меню. Уже собранные черновики не меняются.
С4. У заведения нет меню
Марина открывает карточку кафе, найденного через агрегатор: кухня «грузинская», меню не найдено. Кнопка «Создать КП» неактивна, рядом подсказка: «Сначала нужно меню. Запустите разбор сайта или добавьте ссылку на меню». Блок «Что можем поставлять (по кухне)» на карточке остаётся как подсказка для звонка, но документ из него не собирается.
6Обзор решения
Путь предложения: карточка заведения → форма параметров → сборщик → HTML-черновик → редактор → экспорт → PDF в хранилище. Сборщик читает четыре источника: меню заведения, кэш раскладки блюд, номенклатуру с ценами 1С и настройки (профиль, шаблон).
flowchart LR
A[Карточка заведения
есть меню] --> B[Форма: колонка прайса,
лимит товаров, поля профиля]
B -->|POST /proposals| C[Сборщик]
M[(menu_items +
dish_decomposition)] --> C
N[(client_products +
onec_product_prices)] --> C
S[(settings: профиль,
шаблон)] --> C
C -->|контекст + блоки| T[Рендер шаблона
Jinja2 sandbox]
T -->|санитайзер| D[(proposals.body_html
статус draft)]
D --> E[Редактор TipTap]
E -->|PATCH body_html| D
E -->|POST export| P[WeasyPrint → PDF]
P --> F[(MinIO
proposals/id/номер.pdf)]
P -->|статус exported| D
E -->|Пересобрать| B
Сборка выполняется синхронно в запросе API: на меню в 300 блюд она укладывается в секунды, потому что раскладка уже закэширована, а LLM не вызывается. Экспорт тоже синхронный: WeasyPrint на документе в 10 страниц работает единицы секунд. Очередь ARQ не задействуется.
7Документ PDF
Порядок разделов согласован и следует канону коммерческого предложения: сначала польза для адресата, потом кто мы, потом условия и призыв. Ниже макет первой страницы, затем требования к каждому разделу.
ЛОГОТИП
ООО «Глобал Фудс» · ИНН 4632000000 · КПП 463201001
305000, Курск, ул. Примерная, 1 · +7 (4712) 00-00-00 · sales@example.ru · example.ru
Коммерческое предложение
№ КП-2026-0007 от 04.09.2026
Действительно до 18.09.2026
Для: ООО «Да Винчи» (ИНН 4632111111), Траттория Da Vinci, Курск, ул. Ленина, 10
Предложение о поставке продуктов для Траттории Da Vinci
Мы изучили ваше меню: 64 блюда, под 49 из них у нас есть продукты, это 31 ингредиент. Ниже то, что мы готовы поставлять уже сейчас под ваши блюда, и несколько блюд итальянской кухни, которые вы могли бы добавить на наших продуктах.
Поставляем сейчас
Сыр
В ваших блюдах: Пицца Маргарита, Кватро формаджи, Карбонара, Лазанья, Тирамису и ещё 9
| Наименование | Артикул | Фасовка | Ед. | Цена, ₽ | НДС | Трансп. упак. | Бренд | Страна |
| Сыр Моцарелла для пиццы 40% | 05.04.30-118 | — | кг | 689,00 | — | — | Bonfesto | Россия |
| Сыр Пармезан 6 мес. 32% | 05.04.30-042 | — | кг | 1 245,00 | — | — | — | — |
| Сыр Маскарпоне 80% | 05.04.30-077 | — | кг | 712,50 | — | — | Unagrande | Россия |
Креветки
В ваших блюдах: Паста с креветками, Ризотто с морепродуктами, Салат с креветками
| Наименование | Артикул | Фасовка | Ед. | Цена, ₽ | НДС | Трансп. упак. | Бренд | Страна |
| Креветка ваннамей очищенная 26/30 | 02.01.10-011 | — | кг | 1 090,00 | — | — | — | Индия |
Цены указаны в рублях с НДССтр. 1 из 6
Макет первой страницы. Данные условные, товары и артикулы вымышлены.
7.1Разделы документа
| ID | Раздел | Содержание и правила |
| ДОК-01 | Шапка | Логотип поставщика слева (если загружен), под ним реквизиты одной-двумя строками: юридическое имя, ИНН, КПП, адрес, телефон, почта, сайт. Справа: «Коммерческое предложение», номер, дата, «Действительно до». Пустые реквизиты пропускаются без прочерков. |
| ДОК-02 | Адресат | Если заведение сверено с контрагентом 1С: юридическое имя и ИНН из контрагента, затем название заведения и адрес. Иначе только название и адрес заведения из карточки. КПП добавляется, если есть. |
| ДОК-03 | Заголовок | «Предложение о поставке продуктов для {название заведения}». Падеж не склоняем: название вставляется как есть. |
| ДОК-04 | Вступление | 3–5 строк из шаблона со счётчиками: блюд в меню (без напитков), блюд, под которые есть продукты, число закрытых ингредиентов, топ-кухня. Процент покрытия в документ не попадает никогда. |
| ДОК-05 | Поставляем сейчас | Для каждого закрытого ингредиента: подзаголовок с названием ингредиента, строка «В ваших блюдах: …» (до 5 блюд и «и ещё N»), таблица товаров по ДОК-09. Порядок ингредиентов по разделу 8.3. Ингредиенты без товаров с ценой не печатаются. |
| ДОК-06 | Расширение меню | Вводная фраза из шаблона («Блюда {кухня}, которые вы могли бы добавить»), затем для каждого блюда подзаголовок с названием и таблица по ДОК-09: один товар на каждый ингредиент блюда, который есть у поставщика. Ингредиенты, которых нет, не упоминаются. Блюдо печатается, только если под него нашлось не меньше двух товаров. |
| ДОК-07 | О компании | Текст из профиля поставщика, форматированный (абзацы, списки). Если пуст, раздел не печатается целиком, включая заголовок. |
| ДОК-08 | Условия | Текст из профиля: доставка, минимальный заказ, оплата. Плюс строка «Предложение действительно до {дата}». Если текст пуст, печатается только срок действия. |
| ДОК-09 | Таблица товаров | Всегда девять колонок в этом порядке: Наименование, Артикул, Фасовка, Ед., Цена ₽, НДС, Транспортная упаковка, Бренд, Страна. Пустое значение печатается прочерком «—». Цена с двумя знаками, разделитель тысяч пробел, десятичный разделитель запятая. Итоговой суммы и колонки «Кол-во» нет. Название колонки прайса в документе не появляется. |
| ДОК-10 | Призыв и контакт | Выделенный блок: фраза из шаблона («Готовы привезти образцы и обсудить условия») и контакт менеджера из формы: имя, телефон, почта. |
| ДОК-11 | Колонтитул | Внизу каждой страницы: строка про НДС из профиля слева, «Стр. N из M» справа. В PDF реализуется через @page и counter(page). |
| ДОК-12 | Печать | Формат A4, поля 15 мм, шрифт с кириллицей (см. НФТ-06), заголовок таблицы повторяется на каждой странице, строка таблицы не разрывается между страницами, заголовок ингредиента не отрывается от таблицы. |
Решение владельца проекта
Объём документа не ограничен: если в меню 40 закрытых ингредиентов, будет 40 таблиц. Ограничение живёт в форме, через лимит товаров на ингредиент, и в руках менеджера в редакторе.
8Сборка черновика
Сборка — чистая функция над снимком данных плюс тонкий слой ввода-вывода. Вход: заведение, параметры формы, снимок меню и матчинга, снимок номенклатуры с ценами, профиль, шаблон. Выход: HTML тела и JSON-снимок входов для отладки.
8.1Предусловия
| ID | Правило | Иначе |
| СБ-01 | У заведения есть хотя бы одна запись меню. | 409 no_menu, кнопка на карточке неактивна. |
| СБ-02 | Колонка прайса передана и существует в onec_price_types. | 422 price_type_required / price_type_unknown. |
| СБ-03 | Лимит товаров на ингредиент от 1 до 10. | 422. |
| СБ-04 | Шаблон проходит валидацию (раздел 9.3). | 500 template_broken с текстом ошибки; администратору подсказка починить шаблон или вернуть стандартный. |
8.2Счётчики вступления
Считаются из результата match_menu по блюдам без напитков (фильтр is_drink, как в _compute_match).
| Счётчик | Определение |
menu.dishes_total | Число блюд-еды в меню. |
menu.dishes_covered | Число блюд, у которых закрыт хотя бы один ингредиент с основным весом (match ≠ none и weight ≥ 1.0). |
menu.ingredients_covered | Число различных ингредиентов, вошедших в блок «Поставляем сейчас» после отбора товаров (то есть с хотя бы одним товаром с ценой). |
menu.cuisine | Название топ-кухни из cuisines[0], в родительном падеже по словарю кухонь; если словарь не знает падеж, именительный. Пусто, если кухня не определена. |
8.3Блок «Поставляем сейчас»
- Из
dishes[].ingredients[] собираем множество закрытых ингредиентов и для каждого список блюд, где он встречается (по названию блюда, без дублей).
- Для каждого ингредиента отбираем товары по правилу 8.5 с лимитом из формы.
- Ингредиенты без товаров с ценой отбрасываем.
- Сортировка ингредиентов: сначала основные (вес 1.0), потом второстепенные (0.4); внутри группы по числу блюд убыв., затем по числу товаров в номенклатуре убыв., затем по алфавиту.
- Строка «В ваших блюдах»: первые 5 названий в порядке появления в меню, дальше «и ещё N».
8.4Блок «Расширение меню»
- Берём
upsell[] из результата матчинга (до 8 блюд топ-кухни, которых нет в меню, с покрытием ≥ 0,6).
- Для каждого закрытого ингредиента блюда отбираем один товар по правилу 8.5 с лимитом 1.
- Блюдо остаётся, если нашлось не меньше двух товаров. Порядок как в матчинге (по доле покрытия).
- Если после фильтра блюд не осталось или кухня не определена, блок не печатается целиком, включая заголовок и вводную фразу.
8.5Отбор товаров под ингредиент
Кандидаты: товары, у которых ingredient_canonical относится к ингредиенту по тем же правилам, что Index.classify: точное совпадение, семейство по первому слову, подстрока. Каждому кандидату резолвим цену через resolve_price с выбранной колонкой.
| Шаг | Правило |
| ОТ-01 | Товары без цены (reason = none) исключаются. Товары с reason = fallback (взята ближайшая колонка дороже) допускаются, факт пишется в снимок сборки и в лог. |
| ОТ-02 | Ранг точности: 0 = точное совпадение канона, 1 = семейство, 2 = подстрока. Меньше лучше. |
| ОТ-03 | Сортировка кандидатов: ранг точности возр., цена возр., название по алфавиту. Берём первые N. |
| ОТ-04 | Один и тот же товар может попасть под разные ингредиенты (например «сыр» и «сыр моцарелла»). Дубли внутри одного блока не убираем: заведение читает по ингредиентам. |
| ОТ-05 | Именной тип цены контрагента (personal_type) на первом этапе не передаётся: правило приоритета именных прайсов не подтверждено заказчиком (см. раздел 19). |
8.6Ячейки таблицы товаров
| Колонка | Источник | Если пусто |
| Наименование | client_products.title | Не бывает пустым. |
| Артикул | client_products.code; для товаров сайта, сшитых с 1С, onec_code | — |
| Фасовка | raw_json.packing, если поле появится в выгрузке 1С; сейчас нет | — |
| Ед. | client_products.unit | — |
| Цена, ₽ | resolve_price(...).price | Строка в таблицу не попадает (ОТ-01). |
| НДС | Ставка из 1С, когда появится; сейчас нет | — |
| Транспортная упаковка | raw_json.transport_pack, когда появится | — |
| Бренд | client_products.brand | — |
| Страна | client_products.country | — |
8.7Прочие поля
- Номер. Глобальная последовательность Postgres
proposal_number_seq; номер «КП-{ГГГГ}-{NNNN}», где NNNN — значение последовательности с ведущими нулями до четырёх знаков. Последовательность по годам не сбрасывается: уникальность важнее красоты.
- Дата. Дата сборки в часовом поясе стенда, формат «ДД.ММ.ГГГГ».
- Действительно до. Дата сборки плюс
validity_days из формы.
- Логотип. Файл из профиля читается из MinIO и встраивается в HTML как
data:-URI, чтобы редактор и PDF не ходили в сеть. Лимит файла 300 КБ, форматы PNG, JPEG, SVG.
- Снимок сборки. В
build_snapshot пишутся: параметры формы, счётчики, список ингредиентов с идентификаторами выбранных товаров и причинами цены, идентификатор контрагента, версия шаблона (хеш) и профиля. Это отладочная информация, не источник истины.
9Шаблон и плейсхолдеры
Шаблон — HTML с плейсхолдерами Jinja2, который администратор редактирует в том же визуальном редакторе, что и менеджер черновик. Скалярные плейсхолдеры подставляются с экранированием, блочные заменяются готовым HTML. Блочный плейсхолдер должен стоять в отдельном абзаце: рендерер заменяет весь абзац целиком.
9.1Скалярные плейсхолдеры
| Плейсхолдер | Значение |
| {{ venue.name }} | Название заведения из карточки. |
| {{ venue.legal_name }} | Юридическое имя контрагента 1С или пусто. |
| {{ venue.inn }} | ИНН контрагента или пусто. |
| {{ venue.address }} | Адрес из карточки. |
| {{ venue.recipient }} | Готовая строка адресата по ДОК-02. |
| {{ proposal.number }} | Номер предложения. |
| {{ proposal.date }} | Дата сборки. |
| {{ proposal.valid_until }} | Дата окончания действия. |
| {{ menu.dishes_total }} | Счётчики из 8.2. |
| {{ menu.dishes_covered }} |
| {{ menu.ingredients_covered }} |
| {{ menu.cuisine }} |
| {{ supplier.legal_name }} | Поля профиля поставщика (раздел 11.2). |
| {{ supplier.inn }} |
| {{ supplier.kpp }} |
| {{ supplier.address }} |
| {{ supplier.phone }} |
| {{ supplier.email }} |
| {{ supplier.website }} |
| {{ supplier.price_note }} |
| {{ manager.name }} | Контакт менеджера из формы. |
| {{ manager.phone }} |
| {{ manager.email }} |
9.2Блочные плейсхолдеры
| Плейсхолдер | Что вставляется |
| {{ block.logo }} | <img> с логотипом или ничего. |
| {{ block.supply }} | Раздел «Поставляем сейчас» без общего заголовка H2: заголовок пишет администратор в шаблоне, чтобы мог переименовать. |
| {{ block.expansion }} | Раздел «Расширение меню» без общего заголовка. Если блок пуст, рендерер удаляет и ближайший предшествующий заголовок H2, чтобы не оставлять пустой раздел. |
| {{ block.about }} | Текст «о компании» из профиля. То же правило про пустой блок. |
| {{ block.terms }} | Условия из профиля плюс строка о сроке действия. |
9.3Валидация шаблона
- Рендер через
jinja2.sandbox.SandboxedEnvironment с autoescape=True и undefined=StrictUndefined: неизвестный плейсхолдер — ошибка с именем и позицией.
- При сохранении шаблон прогоняется на тестовом контексте (все поля заполнены условными значениями). Ошибка синтаксиса или неизвестного имени возвращается как 422 с сообщением, шаблон не сохраняется.
- Обязательных плейсхолдеров нет: администратор вправе убрать любой блок. Предупреждение в UI, если отсутствуют
block.supply или manager.phone.
- Шаблон по умолчанию лежит в файле
backend/app/templates/proposal_default.html (заменяет старый файл с тем же именем). Кнопка «Вернуть стандартный» удаляет ключ настройки, и система снова читает файл.
10Экраны
10.1Карточка заведения
| ID | Требование |
| ЭК-01 | Кнопка «Создать КП» в шапке карточки для ролей editor и admin. Активна, только если у заведения есть записи меню. При недоступности показывается подсказка из сценария С4. |
| ЭК-02 | Блок «Предложения» со списком КП этого заведения: номер, дата, статус, кто создал, ссылка в редактор, ссылка «PDF» для экспортированных. Пустое состояние: «Предложений ещё нет». |
| ЭК-03 | Блок «Что можем поставлять (по кухне)» остаётся как есть; из него ничего не создаётся. |
10.2Форма «Новое предложение»
Модальное окно поверх карточки. Открывается кнопкой «Создать КП» и кнопкой «Пересобрать» в редакторе (во втором случае поля предзаполнены параметрами исходного предложения).
| Поле | Тип | По умолчанию | Правила |
| Колонка прайса | Выпадающий список канонических типов цен (kind = canonical) в порядке позиции | organizations.price_type_id, если задан; иначе пусто | Обязательно. Рядом: «Сохранить на карточке заведения» (галочка, по умолчанию включена). |
| Товаров на ингредиент | Число 1–10 | 3 | Обязательно. |
| Срок действия, дней | Число 1–90 | profile.validity_days | Обязательно. |
| Менеджер: имя, телефон, почта | Текст | Из профиля | Имя и телефон обязательны. |
| Условия | Многострочный текст | profile.terms_html как текст | Необязательно. |
| Обновить значения по умолчанию | Галочка | Выключена | Доступна только admin. При включении поля менеджера, срок и условия записываются в профиль. |
Под полями форма показывает предварительный расчёт из GET /proposals/preflight: «64 блюда, под 49 есть продукты, 31 ингредиент, 87 товаров с ценой по этой колонке». Расчёт обновляется при смене колонки. Если товаров с ценой ноль, кнопка «Собрать» остаётся активной, но выводится предупреждение «По этой колонке цен нет: таблицы будут пустыми».
10.3Редактор предложения
H1H2H3ЖКЧ• список1. список⊞ строка +/−⊞ столбец +/−↶↷
Сохранено 12:03
ПересобратьСкачать PDF
№ КП-2026-0007 от 04.09.2026
Предложение о поставке продуктов для Траттории Da Vinci
Мы изучили ваше меню: 64 блюда, под 49 из них у нас есть продукты…
Поставляем сейчас
Сыр
В ваших блюдах: Пицца Маргарита, Кватро формаджи, Карбонара и ещё 11
| Наименование | Артикул | Ед. | Цена, ₽ |
| Сыр Моцарелла для пиццы 40% | 05.04.30-118 | кг | 689,00 |
Макет редактора: панель инструментов, холст шириной A4, статус сохранения. Таблица в макете сокращена до четырёх колонок ради ширины.
| ID | Требование |
| РЕД-01 | Маршрут /proposals/:id. Шапка: номер, заведение (ссылка на карточку), статус, дата экспорта, кнопки «Пересобрать», «Скачать PDF», «Удалить». |
| РЕД-02 | Редактор TipTap 2 с расширениями: StarterKit, Underline, TextAlign, Link, Image, Table, TableRow, TableHeader, TableCell. Панель: заголовки H1–H3, жирный, курсив, подчёркивание, списки, выравнивание, строки и столбцы таблицы, удалить таблицу, отмена, повтор. |
| РЕД-03 | Холст стилизован тем же CSS, что и PDF (proposal_print.css отдаётся фронту и встраивается в редактор), ширина 210 мм, чтобы вид в редакторе совпадал с печатью. Разбиение на страницы в редакторе не показывается. |
| РЕД-04 | Автосохранение: PATCH через 2 секунды после последнего изменения и при уходе со страницы. Индикатор «Сохранено ЧЧ:ММ» / «Сохраняю…» / «Не сохранено: {ошибка}». Роль viewer видит документ только для чтения. |
| РЕД-05 | Конфликт правок: PATCH передаёт updated_at, которое клиент видел; при расхождении сервер отвечает 409, редактор предлагает «Загрузить свежую версию» (текущие правки теряются, об этом сказано прямо). |
| РЕД-06 | «Скачать PDF» сначала сохраняет несохранённые правки, затем вызывает экспорт, затем скачивает файл. Статус меняется на «Экспортировано», дата экспорта обновляется. Повторный экспорт перезаписывает файл. |
| РЕД-07 | «Пересобрать» открывает форму 10.2 с параметрами из build_snapshot и предупреждением: «Будет создано новое предложение. Ваши правки в этом не переносятся». После сборки переход в новый редактор. |
| РЕД-08 | Над холстом показывается предупреждение, если прайс (max(onec_product_prices.updated_at)) или меню заведения обновлялись после created_at предложения. |
| РЕД-09 | «Удалить» с подтверждением. Удаляет запись и PDF из хранилища. Доступно editor и admin. |
| РЕД-10 | Вставка из буфера обмена очищается до разрешённых тегов (раздел 14); картинки, кроме уже встроенного логотипа, вставить нельзя. |
10.4Список предложений
Маршрут /proposals, пункт «КП» в главном меню. Таблица: номер, заведение, статус, колонка прайса, создано (кто, когда), экспортировано. Фильтры: статус, поиск по номеру и названию заведения. Сортировка по дате создания убыв. Пагинация по 50. Строка ведёт в редактор; для экспортированных дополнительно ссылка «PDF».
10.5Настройки: профиль и шаблон
Маршрут /settings/proposal, доступен только admin, ссылка со страницы «Настройки». Две вкладки.
- Профиль поставщика. Форма с полями из 11.2, поля «о компании» и «условия» в том же визуальном редакторе без таблиц. Загрузка логотипа с превью и кнопкой «Удалить логотип». Кнопка «Сохранить».
- Шаблон. Визуальный редактор с шаблоном, справа панель «Плейсхолдеры» со списком из 9.1–9.2 и кнопкой «Вставить». Кнопки «Сохранить» (с валидацией 9.3), «Предпросмотр на примере» (выбор заведения с меню, рендер в модальном окне), «Вернуть стандартный» (с подтверждением).
10.6Роли
| Действие | viewer | editor | admin |
| Смотреть список и предложение, скачать PDF экспортированного | да | да | да |
| Создать, править, экспортировать, пересобрать, удалить | нет | да | да |
| Профиль, логотип, шаблон, «обновить значения по умолчанию» | нет | нет | да |
11Данные
11.1Таблица proposals
Миграция 0028_proposals_v2. Тип proposal_status был удалён в 0027, создаётся заново с новыми значениями.
| Колонка | Тип | Описание |
id | uuid PK | |
organization_id | uuid FK organizations, on delete cascade, index | Заведение. |
number | text unique | «КП-2026-0007». |
title | text | Название для списков; по умолчанию «Предложение для {venue.name}». |
status | enum proposal_status: draft, exported | |
price_type_id | int FK onec_price_types, on delete set null | Колонка прайса. |
price_type_name | text | Снимок имени на момент сборки; переживает удаление типа. |
products_per_ingredient | int | Лимит из формы. |
body_html | text | Источник истины. Всегда после санитайзера. Лимит 2 МБ. |
build_snapshot | jsonb | Входы сборки (8.7). |
pdf_s3_key | text null | proposals/{id}/{number}.pdf. |
exported_at, exported_by | timestamptz null, uuid FK users set null | |
created_by | uuid FK users, on delete set null | |
created_at, updated_at | timestamptz | updated_at участвует в проверке конфликта (РЕД-05). |
Индексы: organization_id, status, created_at desc. Последовательность proposal_number_seq создаётся в той же миграции. Downgrade удаляет таблицу, тип и последовательность.
11.2Настройки
Хранятся в существующей таблице settings как JSON, читаются и пишутся через новые эндпоинты (раздел 12), а не через общий PUT /settings, потому что нужна валидация.
| Ключ | Значение |
proposal.supplier_profile | Объект: legal_name, short_name, inn, kpp, address, phone, email, website, manager_name, manager_phone, manager_email, about_html, terms_html, price_note (по умолчанию «Цены указаны в рублях с НДС»), validity_days (14), logo_s3_key. |
proposal.template_html | Строка HTML. Отсутствие ключа означает стандартный шаблон из файла. |
11.3Хранилище файлов
- Логотип:
proposal/logo/{uuid}.{ext}. При замене старый объект удаляется.
- PDF:
proposals/{proposal_id}/{number}.pdf, content-type application/pdf. При удалении предложения объект удаляется. При удалении заведения записи каскадно исчезают, файлы остаются сиротами: чистка сирот вне охвата, отмечено в разделе 19.
12API
Префикс /proposals, роутер app/api/proposals.py, подключается в main.py. Ошибки в формате остальных роутеров: {"detail": "..."}, для машинно-различимых случаев поле code.
| Метод и путь | Роль | Вход | Выход и ошибки |
GET /proposals | viewer | query: organization_id?, status?, q?, page, limit≤100 | {items:[ProposalListItem], total}. Без body_html. |
GET /proposals/preflight | viewer | query: organization_id, price_type_id? | {has_menu, dishes_total, dishes_covered, ingredients_covered, products_with_price, cuisine}. Ничего не пишет. |
POST /proposals | editor | {organization_id, price_type_id, products_per_ingredient, validity_days, manager:{name,phone,email}, terms_html?, remember_price_type: bool, update_defaults: bool} | 201 Proposal с body_html. 404 заведение; 409 no_menu; 422 price_type_required, price_type_unknown; 500 template_broken. update_defaults учитывается только для admin. |
GET /proposals/{id} | viewer | | Proposal целиком плюс stale: {menu: bool, prices: bool} для РЕД-08. |
PATCH /proposals/{id} | editor | {title?, body_html?, expected_updated_at} | 200 Proposal без тела. 409 stale; 413 если тело больше 2 МБ. |
POST /proposals/{id}/export | editor | | 200 {status, exported_at, pdf_size}. 500 render_failed с сообщением WeasyPrint. |
GET /proposals/{id}/pdf | viewer | | Байты PDF, Content-Disposition: attachment; filename*=UTF-8''.... 404 если не экспортировано. |
DELETE /proposals/{id} | editor | | 204. |
GET /proposals/settings | viewer | | {profile, template_html, is_default_template, logo_url, placeholders:[{name, kind, description}]}. Профиль нужен форме менеджера, поэтому viewer. |
PUT /proposals/settings/profile | admin | SupplierProfile | 200. HTML-поля проходят санитайзер. |
PUT /proposals/settings/template | admin | {template_html} | 200. 422 template_invalid с message, line. |
DELETE /proposals/settings/template | admin | | 204, возврат к стандартному. |
POST /proposals/settings/template/preview | admin | {organization_id, template_html?} | {html} черновика без сохранения. Использует профиль и лимит 3; колонка прайса — из карточки заведения или первая каноническая. |
POST /proposals/settings/logo | admin | multipart file | {logo_url}. 413 больше 300 КБ; 415 не PNG/JPEG/SVG. |
DELETE /proposals/settings/logo | admin | | 204. |
GET /proposals/print.css | viewer | | Текст CSS для холста редактора (РЕД-03). |
Дополнение к существующему API: GET /organizations/{id} отдаёт menu_items_count и proposals_count, чтобы карточка знала, активна ли кнопка, без второго запроса.
13Модули и код
Принцип тот же, что в matching_core и onec/pricing: чистое ядро без БД, тонкий слой ввода-вывода, тесты на ядро без Postgres.
| Файл | Назначение | Зависит от |
app/models/proposal.py | Модель Proposal, enum статуса. | SQLAlchemy |
alembic/versions/0028_proposals_v2.py | Таблица, тип, последовательность. | |
app/core/proposal/select.py | Чистый отбор товаров под ингредиент (8.5) и группировка блоков (8.3, 8.4) над снимками данных. | matching_core.Index, onec.pricing.resolve_price |
app/core/proposal/context.py | Чистая сборка контекста шаблона: счётчики, адресат, даты, номер, форматирование цен. | |
app/core/proposal/blocks.py | Чистый рендер блочных плейсхолдеров в HTML (таблицы ДОК-09). Собственные Jinja2-макросы, не шаблон администратора. | Jinja2 |
app/core/proposal/template.py | Sandboxed-рендер шаблона, валидация, список плейсхолдеров, чтение стандартного шаблона из файла. | Jinja2 sandbox |
app/core/proposal/sanitize.py | Санитайзер HTML на nh3, единая точка для тела, профиля, шаблона. | nh3 |
app/core/proposal/render_pdf.py | WeasyPrint с безопасным url_fetcher, встроенный proposal_print.css. | WeasyPrint |
app/core/proposal/build.py | Слой ввода-вывода: загрузить меню, матчинг, товары, цены, профиль, шаблон; вызвать чистые функции; сохранить Proposal. | nomenclature.matching.match_menu, onec.quote.price_options_for, storage |
app/core/proposal/settings.py | Чтение и запись профиля и шаблона с дефолтами. | models.setting |
app/api/proposals.py | Роутер из раздела 12, схемы Pydantic. | |
app/templates/proposal_default.html | Стандартный шаблон (заменяет старый файл). | |
app/templates/proposal_print.css | Печатные стили: @page, таблицы, колонтитул, шрифты. | |
frontend/src/pages/Proposals.tsx | Список 10.4. | |
frontend/src/pages/ProposalEditor.tsx | Редактор 10.3. | TipTap |
frontend/src/pages/ProposalSettings.tsx | Профиль и шаблон 10.5. | TipTap |
frontend/src/components/proposal/NewProposalDialog.tsx | Форма 10.2. | |
frontend/src/components/proposal/RichEditor.tsx | Обёртка TipTap с панелью, режимом «без таблиц» и «только чтение»; используется тремя экранами. | TipTap |
Зависимости
- Backend:
nh3 добавить в pyproject.toml. weasyprint, jinja2, minio, pypdfium2 уже есть.
- Frontend:
@tiptap/react, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-table-row, @tiptap/extension-table-header, @tiptap/extension-table-cell, @tiptap/extension-image, @tiptap/extension-text-align, @tiptap/extension-underline, @tiptap/extension-link. Одна мажорная версия (2.x) для всех пакетов, версии закрепить точно.
- Docker: в
backend/Dockerfile добавить fonts-liberation и fonts-dejavu-core; сейчас установлен только pango, и кириллица в PDF не гарантирована.
Заметка для разработчика
Правило из test_onec_quote.py действует и здесь: JSONB и текстовые поля не мутировать на месте, иначе SQLAlchemy не увидит изменение и UPDATE не уйдёт. build_snapshot собирать как новый словарь.
14Безопасность
| ID | Угроза | Мера |
| БЕЗ-01 | XSS через HTML предложения, профиля или шаблона: HTML показывается в редакторе и в предпросмотре другим пользователям. | Санитайзер на сервере при каждой записи. Разрешены: p, h1–h4, br, hr, strong, em, u, s, ul, ol, li, table, thead, tbody, tr, th, td, colgroup, col, img, a, span, div. Атрибуты: href (http, https, mailto), src только data:image/png;base64, data:image/jpeg, data:image/svg+xml, alt, colspan, rowspan, style только text-align и width. Всё остальное вырезается. Скрипты, обработчики событий, iframe запрещены. |
| БЕЗ-02 | SSRF через WeasyPrint: <img src="http://minio:9000/..."> или @import в стилях заставят сервер ходить по внутренней сети. | Собственный url_fetcher, который принимает только data:-URI и выбрасывает исключение на любой другой схеме. base_url=None. Стили только встроенные. |
| БЕЗ-03 | Инъекция кода через шаблон Jinja2 администратором или через утёкший admin-токен. | SandboxedEnvironment, без доступа к __class__ и импортам, в контекст передаются только строки и простые объекты. |
| БЕЗ-04 | Утечка всех колонок прайса заведению. | В контекст шаблона передаётся только одна разрешённая цена на товар; имя колонки в контекст не попадает. Проверяется тестом: рендер стандартного шаблона не содержит подстрок «Цена КП», «Цена ОК» и т.д. |
| БЕЗ-05 | Утечка PDF по прямой ссылке. | PDF отдаётся через API с проверкой роли, не через presigned URL. Ссылки на MinIO наружу не публикуются. |
| БЕЗ-06 | Исчерпание ресурсов: огромный HTML или SVG-бомба в логотипе. | Лимит тела 2 МБ (413), лимит логотипа 300 КБ, SVG проходит санитайзер (без скриптов и внешних ссылок), таймаут экспорта 60 секунд. |
15Нефункциональные требования
| ID | Требование | Как проверяем |
| НФТ-01 | Сборка черновика на меню до 300 блюд и номенклатуре до 5 000 товаров выполняется не дольше 5 секунд, обычно до 2. | Замер на стенде на трёх реальных заведениях; лог с длительностью каждого шага. |
| НФТ-02 | Экспорт PDF до 30 страниц не дольше 15 секунд. | Замер на самом длинном предложении стенда. |
| НФТ-03 | Сборка не вызывает LLM и не ставит задачи в очередь. | Проверка кода на ревью; llm_usage не растёт при сборке. |
| НФТ-04 | Сборка детерминирована: два вызова с теми же данными дают одинаковый HTML с точностью до номера и дат. | Юнит-тест на фикстуре. |
| НФТ-05 | Интерфейс и документ на русском; форматы дат «ДД.ММ.ГГГГ», чисел «1 234,50». | Ревью, юнит-тест форматирования. |
| НФТ-06 | Кириллица в PDF отображается корректно, шрифт встроен в файл. | Тест: извлечь текст через pypdfium2 и найти слово «Предложение». |
| НФТ-07 | Все шаги сборки и экспорта логируются через structlog с proposal_id, organization_id, длительностью и числом строк в таблицах. | Ревью. |
| НФТ-08 | Документация обновлена в том же PR: docs/PIPELINE.md (шаг 6), docs/OPERATOR.md (настройки, роли), docs/DATA_MODEL.md (таблица), docs/PROGRESS.md. | Чек-лист PR. |
16Крайние случаи и ошибки
| Ситуация | Поведение |
| В меню только напитки | Как в _compute_match: матчим всё, что есть. Черновик, скорее всего, почти пустой, форма предупредит нулём товаров. |
| Ни один ингредиент не закрыт | Сборка проходит. Блок «Поставляем сейчас» выводит абзац из шаблона «По вашему меню совпадений с нашим ассортиментом мы не нашли, но будем рады обсудить поставки». Форма заранее показывает нули. |
| Выбранной колонки нет ни у одного товара | Работает fallback вверх по лестнице; если и его нет, товар исключается (ОТ-01). Форма предупреждает. |
| Кухня не определена | Блок «Расширение меню» не печатается, menu.cuisine пусто, во вступлении фраза с кухней опускается через условие в шаблоне. |
| Контрагент сверен, но без ИНН | В адресате только юридическое имя, затем заведение. |
| Логотип не загружен | Шапка без картинки, без пустого места. |
| Профиль не заполнен вовсе | Форма 10.2 показывает предупреждение «Реквизиты поставщика не заполнены» со ссылкой на настройки для admin; сборка разрешена, пустые поля пропускаются. |
| Шаблон сломан | 500 template_broken, сообщение с именем плейсхолдера; черновик не создаётся. |
| Тип цены удалён после сборки | price_type_id обнуляется, price_type_name остаётся; пересборка потребует выбрать колонку заново. |
| Два менеджера правят одно предложение | Второй получает 409 и предложение загрузить свежую версию (РЕД-05). |
| Экспорт упал в WeasyPrint | Статус не меняется, файл не пишется, менеджер видит сообщение с текстом ошибки, в лог уходит полный traceback. |
| Заведение удалено | Предложения каскадно удаляются, PDF-файлы остаются в хранилище (раздел 11.3). |
| Меню перепарсили, но прайс тот же | Редактор показывает предупреждение РЕД-08 «Меню обновлялось после сборки»; менеджер решает, пересобирать ли. |
17Тестирование и приёмка
17.1Автотесты
Тесты в проекте не поднимают Postgres (tests/conftest.py), поэтому основная нагрузка на чистое ядро.
| Файл | Что проверяет |
test_proposal_select.py | Ранг точности, сортировка по цене и названию, исключение товаров без цены, лимит, поведение при fallback-цене, группировка ингредиентов и порядок (8.3), фильтр блюд расширения по двум товарам (8.4). |
test_proposal_context.py | Счётчики 8.2 на фикстуре матчинга, адресат для трёх случаев (контрагент с ИНН, без ИНН, без контрагента), формат номера, дат и цен, «и ещё N». |
test_proposal_template.py | Рендер стандартного шаблона на тестовом контексте; ошибка на неизвестном плейсхолдере; удаление заголовка перед пустым блоком; sandbox блокирует __class__; в результате нет имён колонок прайса (БЕЗ-04); детерминизм (НФТ-04). |
test_proposal_sanitize.py | Скрипт, обработчик события, iframe, img src=http вырезаются; таблицы, списки, data:image/png сохраняются. |
test_proposal_pdf.py | Рендер минимального документа с кириллицей: файл начинается с %PDF, текст извлекается, url_fetcher отвергает http://. |
test_proposal_api.py | Схемы Pydantic: валидация формы (диапазоны, обязательность), коды ошибок через подмену слоя ввода-вывода. |
17.2Критерии приёмки
- Дано заведение с распарсенным меню и выбранная колонка прайса, когда менеджер собирает КП, тогда черновик открывается в редакторе, в нём есть все разделы ДОК-01…ДОК-11, каждый ингредиент сопровождается названиями блюд из меню этого заведения, а в таблицах ровно девять колонок.
- Дано тот же черновик, когда менеджер удаляет строку таблицы и меняет абзац, а затем скачивает PDF, тогда в PDF нет удалённой строки и есть новый текст, статус «Экспортировано», файл виден в списке.
- Дано экспортированное предложение, когда менеджер пересобирает, тогда появляется новое предложение с новым номером, старое не изменилось.
- Дано заведение без меню, тогда кнопка недоступна, а прямой вызов API возвращает 409.
- Дано форма без колонки прайса, тогда кнопка «Собрать» недоступна, API возвращает 422.
- Дано администратор изменил шаблон и профиль, когда менеджер собирает новое КП, тогда изменения видны, а старые черновики прежние.
- Дано любой собранный документ, тогда поиском по HTML и PDF не находится ни одно название колонки прайса и слово «Итого».
- Дано три реальных заведения из списка заказчика, тогда владелец проекта подтверждает, что документ выглядит как предложение поставщика продуктов и его можно отправить без переделки структуры.
17.3Ручной чек-лист перед сдачей
- PDF открывается в Acrobat, Chrome и Preview; кириллица, знак ₽, длинные названия товаров переносятся, таблица не выходит за поля.
- Редактор: таблица с 9 колонками помещается в холст, добавление и удаление строк, отмена, вставка текста из Word не приносит мусорных стилей.
- Тёмная тема интерфейса: холст редактора остаётся белым, как бумага.
- Роль viewer: редактор только для чтения, кнопок изменения нет, скачать PDF можно.
18План работ
Этапы последовательны, каждый заканчивается зелёными тестами и рабочим приложением. Оценка в рабочих днях одного разработчика; итог по совету источников удвоен на неизвестное.
| Этап | Содержание | Результат | Дни |
| 1. Основа | Модель и миграция 0028, профиль и шаблон в настройках, стандартный шаблон и печатный CSS, санитайзер, шрифты в Dockerfile. | Настройки читаются и пишутся через API, шаблон валидируется. | 2 |
| 2. Сборщик | Отбор товаров, контекст, блоки, рендер шаблона, снимок сборки, тесты ядра. | POST /proposals создаёт черновик на стенде. | 3 |
| 3. Экспорт и API | WeasyPrint с безопасным fetcher, хранение PDF, остальные эндпоинты, preflight, конфликт правок. | Полный API по разделу 12, PDF с кириллицей. | 2 |
| 4. Редактор и список | Обёртка TipTap, страница редактора, форма, список, блок на карточке, пункт меню, маршруты. | Сценарии С1, С2, С4 проходят руками. | 4 |
| 5. Настройки | Профиль, логотип, шаблон с панелью плейсхолдеров и предпросмотром. | Сценарий С3 проходит. | 2 |
| 6. Приёмка | Три реальных заведения, замеры НФТ-01/02, правки по замечаниям, документация. | Критерии 17.2 подтверждены владельцем. | 1 |
| Итого по оценке | 14 |
| Плановый срок с запасом ×2 | 28 |
Этапы 1–3 не зависят от фронта и могут идти параллельно с этапом 4, если разработчиков двое.
19Открытые вопросы
Не блокирует старт
Ни один вопрос ниже не мешает начать этапы 1–4. Ответы влияют на содержимое ячеек таблицы и на тексты, которые администратор заполнит сам.
| Кому | Вопрос | Что делаем, пока нет ответа |
| Юлия | Цены в прайсе с НДС или без, и какие ставки по категориям? | Строка «Цены указаны в рублях с НДС» редактируется в профиле; колонка «НДС» печатает прочерк. |
| Юлия | Откуда брать фасовку и транспортную упаковку: есть ли поля в 1С? | Прочерки. Как только поля появятся в выгрузке, они подхватываются из raw_json без миграции. |
| Юлия | Что означают «(−)» и «(+)» внутри пары одного сегмента прайса? | Менеджер видит обе колонки в списке и выбирает сам. |
| Юлия | Приоритет именных прайсов над колонкой по потенциалу. | Именные прайсы не участвуют (ОТ-05). |
| Юлия | Текст «о компании», условия доставки, логотип, 1–2 реальных КП как образец тона. | Стандартный шаблон с нейтральными формулировками; администратор заменит. |
| Владелец проекта | Три заведения для приёмки с известным ожидаемым результатом. | Берём три заведения стенда с наибольшим покрытием. |
| Команда | Чистка PDF-сирот после удаления заведения. | Отложено; объём файлов мал. |
20Отвергнутые пути
- Структурированный состав с галочками (как в удалённой версии). Отвергнут: менеджер хочет свободно править формулировки и таблицы. Зафиксировано в
docs/adr/0001.
- Текст от языковой модели во вступлении. Отвергнут владельцем: детерминизм и предсказуемость важнее, к тому же документ должен быть одинаково хорош при недоступности LLM.
- Предложение по кухне без меню. Отвергнуто: без меню персонализация превращается в общий прайс.
- Markdown-редактор. Отвергнут: таблицы с девятью колонками в Markdown править неудобно.
- Колонка прайса по умолчанию из настроек. Отложено до ответа заказчика: неверная колонка в документе хуже, чем лишний клик в форме.
- Ограничение объёма документа двумя страницами. Отвергнуто владельцем: пусть длина определяется меню, лимит регулируется числом товаров на ингредиент.
- Асинхронная сборка через ARQ. Не нужна: сборка укладывается в секунды, а очередь усложнила бы форму ожиданием и опросом.
21Источники
Структура документа опирается на практики ниже: сценарии, не-цели, открытые вопросы и оговорка из «Painless Functional Specifications»; резюме, контекст, границы, подход и удвоенная оценка из «минимально жизнеспособного» дизайн-документа; измеримые формулировки и словарь из руководств по SRS. Порядок разделов PDF взят из русских руководств по коммерческим предложениям.