Спецификация разработки · HoReCa-поставщик

Предложение под меню

Как система собирает для заведения общепита персональное коммерческое предложение о поставке продуктов: из разобранного меню, номенклатуры и прайса 1С, через редактируемый черновик, в PDF.

Статусчерновик на согласование
Версия1.0 · 4 сентября 2026
АвторClaude, по итогам интервью с владельцем проекта
Заменяетблок КП, удалённый в коммите 168f5d2
Связанные документыdocs/PRODUCT.md, CONTEXT.md, docs/adr/0001, docs/QUESTIONS_YULIA.md
Оговорка

Спецификация описывает поведение продукта так, как оно согласовано 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Цели и не-цели

Цели

Не-цели

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/3002.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Блок «Поставляем сейчас»

  1. Из dishes[].ingredients[] собираем множество закрытых ингредиентов и для каждого список блюд, где он встречается (по названию блюда, без дублей).
  2. Для каждого ингредиента отбираем товары по правилу 8.5 с лимитом из формы.
  3. Ингредиенты без товаров с ценой отбрасываем.
  4. Сортировка ингредиентов: сначала основные (вес 1.0), потом второстепенные (0.4); внутри группы по числу блюд убыв., затем по числу товаров в номенклатуре убыв., затем по алфавиту.
  5. Строка «В ваших блюдах»: первые 5 названий в порядке появления в меню, дальше «и ещё N».

8.4Блок «Расширение меню»

  1. Берём upsell[] из результата матчинга (до 8 блюд топ-кухни, которых нет в меню, с покрытием ≥ 0,6).
  2. Для каждого закрытого ингредиента блюда отбираем один товар по правилу 8.5 с лимитом 1.
  3. Блюдо остаётся, если нашлось не меньше двух товаров. Порядок как в матчинге (по доле покрытия).
  4. Если после фильтра блюд не осталось или кухня не определена, блок не печатается целиком, включая заголовок и вводную фразу.

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Прочие поля

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Валидация шаблона

10Экраны

10.1Карточка заведения

IDТребование
ЭК-01Кнопка «Создать КП» в шапке карточки для ролей editor и admin. Активна, только если у заведения есть записи меню. При недоступности показывается подсказка из сценария С4.
ЭК-02Блок «Предложения» со списком КП этого заведения: номер, дата, статус, кто создал, ссылка в редактор, ссылка «PDF» для экспортированных. Пустое состояние: «Предложений ещё нет».
ЭК-03Блок «Что можем поставлять (по кухне)» остаётся как есть; из него ничего не создаётся.

10.2Форма «Новое предложение»

Модальное окно поверх карточки. Открывается кнопкой «Создать КП» и кнопкой «Пересобрать» в редакторе (во втором случае поля предзаполнены параметрами исходного предложения).

ПолеТипПо умолчаниюПравила
Колонка прайсаВыпадающий список канонических типов цен (kind = canonical) в порядке позицииorganizations.price_type_id, если задан; иначе пустоОбязательно. Рядом: «Сохранить на карточке заведения» (галочка, по умолчанию включена).
Товаров на ингредиентЧисло 1–103Обязательно.
Срок действия, днейЧисло 1–90profile.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, ссылка со страницы «Настройки». Две вкладки.

10.6Роли

Действиеviewereditoradmin
Смотреть список и предложение, скачать PDF экспортированногодадада
Создать, править, экспортировать, пересобрать, удалитьнетдада
Профиль, логотип, шаблон, «обновить значения по умолчанию»нетнетда

11Данные

11.1Таблица proposals

Миграция 0028_proposals_v2. Тип proposal_status был удалён в 0027, создаётся заново с новыми значениями.

КолонкаТипОписание
iduuid PK
organization_iduuid FK organizations, on delete cascade, indexЗаведение.
numbertext unique«КП-2026-0007».
titletextНазвание для списков; по умолчанию «Предложение для {venue.name}».
statusenum proposal_status: draft, exported
price_type_idint FK onec_price_types, on delete set nullКолонка прайса.
price_type_nametextСнимок имени на момент сборки; переживает удаление типа.
products_per_ingredientintЛимит из формы.
body_htmltextИсточник истины. Всегда после санитайзера. Лимит 2 МБ.
build_snapshotjsonbВходы сборки (8.7).
pdf_s3_keytext nullproposals/{id}/{number}.pdf.
exported_at, exported_bytimestamptz null, uuid FK users set null
created_byuuid FK users, on delete set null
created_at, updated_attimestamptzupdated_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Хранилище файлов

12API

Префикс /proposals, роутер app/api/proposals.py, подключается в main.py. Ошибки в формате остальных роутеров: {"detail": "..."}, для машинно-различимых случаев поле code.

Метод и путьРольВходВыход и ошибки
GET /proposalsviewerquery: organization_id?, status?, q?, page, limit≤100{items:[ProposalListItem], total}. Без body_html.
GET /proposals/preflightviewerquery: organization_id, price_type_id?{has_menu, dishes_total, dishes_covered, ingredients_covered, products_with_price, cuisine}. Ничего не пишет.
POST /proposalseditor{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}viewerProposal целиком плюс 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}/exporteditor200 {status, exported_at, pdf_size}. 500 render_failed с сообщением WeasyPrint.
GET /proposals/{id}/pdfviewerБайты PDF, Content-Disposition: attachment; filename*=UTF-8''.... 404 если не экспортировано.
DELETE /proposals/{id}editor204.
GET /proposals/settingsviewer{profile, template_html, is_default_template, logo_url, placeholders:[{name, kind, description}]}. Профиль нужен форме менеджера, поэтому viewer.
PUT /proposals/settings/profileadminSupplierProfile200. HTML-поля проходят санитайзер.
PUT /proposals/settings/templateadmin{template_html}200. 422 template_invalid с message, line.
DELETE /proposals/settings/templateadmin204, возврат к стандартному.
POST /proposals/settings/template/previewadmin{organization_id, template_html?}{html} черновика без сохранения. Использует профиль и лимит 3; колонка прайса — из карточки заведения или первая каноническая.
POST /proposals/settings/logoadminmultipart file{logo_url}. 413 больше 300 КБ; 415 не PNG/JPEG/SVG.
DELETE /proposals/settings/logoadmin204.
GET /proposals/print.cssviewerТекст 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.pySandboxed-рендер шаблона, валидация, список плейсхолдеров, чтение стандартного шаблона из файла.Jinja2 sandbox
app/core/proposal/sanitize.pyСанитайзер HTML на nh3, единая точка для тела, профиля, шаблона.nh3
app/core/proposal/render_pdf.pyWeasyPrint с безопасным 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

Зависимости

Заметка для разработчика

Правило из test_onec_quote.py действует и здесь: JSONB и текстовые поля не мутировать на месте, иначе SQLAlchemy не увидит изменение и UPDATE не уйдёт. build_snapshot собирать как новый словарь.

14Безопасность

IDУгрозаМера
БЕЗ-01XSS через 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 запрещены.
БЕЗ-02SSRF через 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Критерии приёмки

  1. Дано заведение с распарсенным меню и выбранная колонка прайса, когда менеджер собирает КП, тогда черновик открывается в редакторе, в нём есть все разделы ДОК-01…ДОК-11, каждый ингредиент сопровождается названиями блюд из меню этого заведения, а в таблицах ровно девять колонок.
  2. Дано тот же черновик, когда менеджер удаляет строку таблицы и меняет абзац, а затем скачивает PDF, тогда в PDF нет удалённой строки и есть новый текст, статус «Экспортировано», файл виден в списке.
  3. Дано экспортированное предложение, когда менеджер пересобирает, тогда появляется новое предложение с новым номером, старое не изменилось.
  4. Дано заведение без меню, тогда кнопка недоступна, а прямой вызов API возвращает 409.
  5. Дано форма без колонки прайса, тогда кнопка «Собрать» недоступна, API возвращает 422.
  6. Дано администратор изменил шаблон и профиль, когда менеджер собирает новое КП, тогда изменения видны, а старые черновики прежние.
  7. Дано любой собранный документ, тогда поиском по HTML и PDF не находится ни одно название колонки прайса и слово «Итого».
  8. Дано три реальных заведения из списка заказчика, тогда владелец проекта подтверждает, что документ выглядит как предложение поставщика продуктов и его можно отправить без переделки структуры.

17.3Ручной чек-лист перед сдачей

18План работ

Этапы последовательны, каждый заканчивается зелёными тестами и рабочим приложением. Оценка в рабочих днях одного разработчика; итог по совету источников удвоен на неизвестное.

ЭтапСодержаниеРезультатДни
1. ОсноваМодель и миграция 0028, профиль и шаблон в настройках, стандартный шаблон и печатный CSS, санитайзер, шрифты в Dockerfile.Настройки читаются и пишутся через API, шаблон валидируется.2
2. СборщикОтбор товаров, контекст, блоки, рендер шаблона, снимок сборки, тесты ядра.POST /proposals создаёт черновик на стенде.3
3. Экспорт и APIWeasyPrint с безопасным fetcher, хранение PDF, остальные эндпоинты, preflight, конфликт правок.Полный API по разделу 12, PDF с кириллицей.2
4. Редактор и списокОбёртка TipTap, страница редактора, форма, список, блок на карточке, пункт меню, маршруты.Сценарии С1, С2, С4 проходят руками.4
5. НастройкиПрофиль, логотип, шаблон с панелью плейсхолдеров и предпросмотром.Сценарий С3 проходит.2
6. ПриёмкаТри реальных заведения, замеры НФТ-01/02, правки по замечаниям, документация.Критерии 17.2 подтверждены владельцем.1
Итого по оценке14
Плановый срок с запасом ×228

Этапы 1–3 не зависят от фронта и могут идти параллельно с этапом 4, если разработчиков двое.

19Открытые вопросы

Не блокирует старт

Ни один вопрос ниже не мешает начать этапы 1–4. Ответы влияют на содержимое ячеек таблицы и на тексты, которые администратор заполнит сам.

КомуВопросЧто делаем, пока нет ответа
ЮлияЦены в прайсе с НДС или без, и какие ставки по категориям?Строка «Цены указаны в рублях с НДС» редактируется в профиле; колонка «НДС» печатает прочерк.
ЮлияОткуда брать фасовку и транспортную упаковку: есть ли поля в 1С?Прочерки. Как только поля появятся в выгрузке, они подхватываются из raw_json без миграции.
ЮлияЧто означают «(−)» и «(+)» внутри пары одного сегмента прайса?Менеджер видит обе колонки в списке и выбирает сам.
ЮлияПриоритет именных прайсов над колонкой по потенциалу.Именные прайсы не участвуют (ОТ-05).
ЮлияТекст «о компании», условия доставки, логотип, 1–2 реальных КП как образец тона.Стандартный шаблон с нейтральными формулировками; администратор заменит.
Владелец проектаТри заведения для приёмки с известным ожидаемым результатом.Берём три заведения стенда с наибольшим покрытием.
КомандаЧистка PDF-сирот после удаления заведения.Отложено; объём файлов мал.

20Отвергнутые пути

21Источники

Структура документа опирается на практики ниже: сценарии, не-цели, открытые вопросы и оговорка из «Painless Functional Specifications»; резюме, контекст, границы, подход и удвоенная оценка из «минимально жизнеспособного» дизайн-документа; измеримые формулировки и словарь из руководств по SRS. Порядок разделов PDF взят из русских руководств по коммерческим предложениям.