Как мы считаем недельное меню в Pikni Food: пачки, остатки и solver вместо списка рецептов
- четверг, 18 июня 2026 г. в 00:00:08
Когда мы только начали собирать Pikni Food, идея выглядела довольно простой: пользователь отвечает на несколько вопросов, мы подбираем блюда, считаем калории и показываем список покупок.
На первый взгляд это похоже на обычное приложение с рецептами. Но довольно быстро стало понятно, что самая интересная часть начинается не в рецептах, а в довольно базовой продуктовой корзине.
Например, меню требует 620 г гречки, а в магазине она продаётся пачкой 900 г. Рецепту нужно 180 г творога, а упаковка может быть 400 г. Огурец в салате можно посчитать как 70 г, но купить его всё равно придётся штукой или лотком. Если считать только съеденные граммы, неделя выглядит аккуратно и недорого, ну а если считать чек, то… получается совсем другая история.
Так мы пришли к выводу, что мы делаем не просто «генератор рецептов», а планировщик продуктовой корзины. А в нём важно не только КБЖУ, но и многое другое, например: упаковки, остатки, сроки годности, цены магазинов, техника на кухне, время готовки и тд.
Ниже расскажу как это всё устроено, какие алгоритмы пробовали, с какими проблемами столкнулись и как мы их решали.

Pikni Food сейчас работает как PWA на piknifood.ru и как Telegram Mini App. Нативное приложение пока не делали: для MVP важнее было дать человеку ссылку и быстро проверить, есть ли польза, чем сначала пройти историю со сторами.
В onboarding пользователь задает:
сколько людей ест из одной корзины;
цель: похудеть, поддерживать вес или набрать;
рост, вес, возраст и активность;
бюджет на день;
количество дней и приемов пищи;
аллергены и нелюбимые продукты;
технику на кухне;
ограничение по времени готовки.
На выходе получается меню по дням, КБЖУ, стоимость, список покупок, остатки и план готовки.

В текущем каталоге 110 ингредиентов, 741 рецепт и 120 готовых рационов. Для ингредиента мало знать «гречка, 343 ккал». Нужны поля, которые кажутся бухгалтерией, но без них план быстро начинает врать:
{ "key": "buckwheat_raw", "pack_size_g": 900, "price_per_pack": 80, "shelf_life_days": 365, "food_group": "grain" }
Такие поля отвечают на вопросы, которые обычно появляются только после красивой демки, а именно:
Сколько человек реально купит? Что останется? Можно ли этот остаток использовать завтра? Насколько больно открыть пачку ради одного блюда?
Самая очевидная архитектура выглядит так:
рецепты -> ингредиенты -> список покупок
Мы с нее и начали. Берем блюда, складываем ингредиенты, получаем корзину.
Проблема в том, что рецепт живет в граммах, а магазин живет в упаковках. Если меню съедает 620 г гречки, пользователь покупает 900 г. Если меню съедает 180 г творога, остаток надо либо встроить в следующие блюда, либо честно показать, что он останется.
После этого схема стала менее красивой, зато ближе к реальной жизни:
профиль пользователя + бюджет + КБЖУ + аллергены + техника + цены + фасовки + реальные остатки -> допустимое меню -> покупка целых упаковок -> прогноз остатков -> план готовки
Главный сдвиг: продукт перестал быть строкой внутри рецепта. Он стал полноценным ресурсом! Если дома есть 280 г риса, это должно влиять на выбор блюд. Если открыт творог, его лучше доесть быстро, а если осталось масло, можно не паниковать, ведь оно подождет.
Бюджет тоже пришлось считать аккуратнее. Можно собрать меню, где «съеденная часть» укладывается в 480 рублей в день, а на кассе получается 760, потому что закупка идет пачками. Такое меню может выглядеть корректно в расчётах, но на практике пользователь платит за реальные упаковки в корзине, а не за условную себестоимость использованных граммов.
Внутри у нас есть сетка слотов:
день 1 / завтрак день 1 / обед день 1 / ужин день 2 / завтрак ...
В каждый слот надо поставить блюдо. При этом часть ограничений жесткая:
— аллергены; — стоп-лист ингредиентов; — доступная техника; — базовая совместимость блюда и приема пищи.
Если у человека аллергия на молочку, блюдо с творогом не должно попасть в план. Тут не нужен компромисс.
А часть ограничений мягкая:
— белок; — жиры и углеводы; — бюджет; — разнообразие; — использование остатков; — штраф за скоропорт.
С ними бывает неприятнее. Если человек ставит очень жесткий бюджет и высокий белок, можно честно сказать «решений нет». Но для бытового планировщика иногда полезнее показать меню с небольшим недобором белка и явно отметить компромисс, чем оставить пустой экран.
Первый работающий вариант был pack-aware greedy. Идея простая: выбирать следующее блюдо так, чтобы оно использовало уже купленные продукты и меньше плодило остатки.
Примерная логика:
ингредиент уже есть дома → плюс нужно открыть новую пачку → считаем будущий остаток остаток скоропортящийся → штрафуем сильнее блюдо уже было недавно → штрафуем
Плюс такого подхода в скорости. На мобильном интерфейсе это важно: нажал кнопку, меню появилось почти сразу.
Минус тоже ожидаемый: greedy хорошо выбирает «следующее» блюдо, но может испортить неделю целиком. Сегодня он радостно доедает курицу, а через два дня из-за этого не остается нормального ужина под бюджет и белок.
После пары таких сценариев стало понятно, что меню надо уметь трясти целиком, а не собирать только слева направо.
Следующим слоем стал simulated annealing. Он умеет брать текущее меню, менять одно блюдо, пересчитывать энергию и иногда принимать ухудшение, чтобы выбраться из локальной ямы.
Упрощенно энергия плана выглядит так:
отклонение от КБЖУ + выход за бюджет + повторы блюд + скоропортящиеся остатки - использование продуктов из холодильника - любимые ингредиенты
SA не пытается идеально выровнять каждый отдельный день. Внутри дня он допускает небольшие отклонения по калориям, белкам, жирам и углеводам, но при этом следит, чтобы средние значения за весь период оставались в нужном диапазоне. Поэтому один день может быть чуть калорийнее или беднее по белку, если это компенсируется другими днями недели.
Отдельная бытовая деталь — совместимость «слотов». Если для ужина мало вариантов, иногда можно взять обеденное блюдо. А вот тяжелый обед на завтрак чаще выглядит странно. Формально это мелочь, но без таких правил меню становится математически допустимым, но глупым.
Еще один слой — MIP на HiGHS. Его мы используем не на каждый пользовательский клик, а для офлайн-генерации и проверки готовых рационов.
Там выбор блюда в конкретный слот превращается в бинарную переменную:
x[recipe, day, meal] = 0 или 1
Ограничения держат калории, белок, бюджет, повторы, совместимость слотов и фильтры по аллергенам/технике/времени. В документации к оптимизатору у нас есть оценка: меню на 3 дня и 3 приема пищи дает около 4000 binary vars и обычно решается за 1-3 секунды на MacBook M-серии. Для рантайма это уже многовато, а для офлайн-батча нормально.
Так получился гибрид без красивой легенды про «один правильный алгоритм»:
готовые рационы для частых сценариев;
выбор и починка этих рационов в pikni_rations.js;
SA как запасной путь, когда шаблонов не хватает;
MIP для офлайн-генерации;
отдельный расчет упаковок, закупки и остатков.
На практике такая приземленная схема удобнее. Если сломался retail-слой, мы чиним retail-слой. Если плохо подбираются замены, смотрим scoring замен. Если нормальный рацион отваливается только из-за закупки целых пачек, появляется отдельный путь вроде pack_budget_relaxed, а не переписывается весь solver.
Сначала список покупок казался финальной табличкой после меню. Оказалось, это почти отдельная подсистема. Надо сложить ингредиенты по всем людям, вычесть то, что уже есть дома, округлить до целых пачек, посчитать стоимость, показать остатки и не перепутать реальные остатки с прогнозными.

Простой пример:
нужно по меню: 620 г гречки фасовка: 900 г покупаем: 1 пачку остаток: 280 г
Для гречки это спокойный остаток. Для творога такой же остаток уже аргумент в пользу блюд с творогом в ближайшие дни.
Самый неприятный класс багов был вокруг «pending» остатков. Есть реальные продукты в холодильнике. А есть прогноз: что останется после текущей закупки, которую пользователь еще не сделал. Если эти два типа смешать, следующий план начинает оптимизироваться вокруг фантомных продуктов.
У нас был именно такой случай: пользователь утверждает меню, приложение считает будущие остатки, а потом при повторной генерации эти остатки уже могли выглядеть как настоящие. Пришлось явно вести pending- лоты и снимать этот флаг только тогда, когда покупка действительно стала прошлым событием.
Похожая история случалась и с обновлением цен. После изменения priceVersion нельзя было пересчитывать уже утвержденную покупку из «мутировавшего» холодильника: легко было получить двойное вычитание нашего так сказать, инвентаря. Поэтому появился snapshot утвержденной покупки: что именно надо было купить в момент approval, сколько граммов и по каким пачкам.
В тестовом каталоге можно написать:
{ "chicken_breast": 520, "buckwheat_raw": 80 }
В реальности «курица стоит 520» — почти бессмысленная фраза. Цена зависит от города, сети, магазина, акции, наличия, канала доставки и конкретного SKU (Stock Keeping Unit — уникальный идентификатор конкретной товарной позиции: бренд, вес, упаковка, фасовка). «Куриное филе» в нашем каталоге и конкретная упаковка на сайте магазина — разные сущности, которые еще надо сматчить.
Поэтому появился слой price snapshot. Локальный dev-сервер отдает snapshot, внутри которого есть catalog_patch - он обновляет цены и фасовки в нашем каталоге, после чего меню и корзина пересчитываются.
Форма патча примерно такая:
{ "ingredient_id": "buckwheat_raw", "retailer_sku_id": "...", "sku_name": "Крупа гречневая, 900 г", "price_per_pack": 80, "pack_size_g": 900, "price_captured_at": "..." }
В dev-сервере snapshot кэшируется на 30 минут. Есть stale-if-error до 12 часов: если live-источник упал, лучше показать устаревшую, но явно помеченную цену, чем превратить меню в пустой экран.
Сейчас это нельзя честно продавать как «точные цены ближайшего магазина». Если источник дает city-level web-каталог, значит это ориентировочные цены по городу. Для точности до конкретного darkstore нужен официальный API, партнерский фид или стабильный delivery endpoint, а этого у нас, к сожалению, нет…
Так вот, это то самое место где продукт может очень легко обмануть пользователя. Поэтому мы стараемся оставлять в интерфейсе корректные формулировки, например — не «точная цена», а «ориентир по городу».
Меню может быть нормальным по цифрам и плохим по жизни.
Если каждый вечер пользователю нужно готовить с нуля отдельное блюдо почти час, такой план быстро перестаёт быть удобным — и человек скорее закажет доставку, чем будет следовать меню. Поэтому появился экран готовки: блюда группируются в сессии, показывается примерное время и порядок действий.

Идея не в том, чтобы всех превратить в фанатов meal prep. Скорее так: если рис уже варится, курицу можно готовить рядом, а часть блюда просто разогреть завтра. Это не такая эффектная часть продукта, но без неё недельное меню быстро становится просто красивой таблицей, а не рабочим сценарием на неделю.
Технически фронт довольно простой: React, PWA, сборка через esbuild, сервис-воркер, локальное состояние, отдельные скрипты для retail-слоя и SEO-страниц.
Telegram Mini App добавил свою россыпь деталей: BackButton, fullscreen, safe area, initData, внешние ссылки и CloudStorage. У CloudStorage лимит 4096 байт на ключ, поэтому состояние приходится чанковать. Не самая героическая инженерия, но ровно из таких вещей и складывается ощущение, что приложение нормально живет в WebView)
PWA тоже не бесплатная галочка. Сервис-воркер легко начинает мешать API, если случайно кэшировать не то. Поэтому статику, фотографии и динамические ручки пришлось явно развести. Например, API не должен внезапно получать HTML приложения вместо JSON (казалось бы очевидно, но всё же).
Самые неприятные баги были не про кнопки, а про корректность расчётов. Несколько примеров из тех, что запомнились:
после изменения рецептов устаревали метрики готовых рационов, и бюджет начинал расходиться с пересчётом;
в семейном сценарии цель могла подтянуться от первого человека, а не от текущего профиля;
dislike по chicken_breast сначала не всегда блокировал близкие куриные варианты вроде бедра;
нормальный рацион мог проиграть только потому, что закупка целыми пачками выглядела дороже съеденной части;
прогнозные остатки текущей покупки пытались стать реальным инвентарем слишком рано;
после обновления цен утвержденная корзина могла пересчитаться из уже измененного состояния.
Часть таких вещей сейчас закрыта smoke-тестами. Например, есть проверки на allergy leak, tech leak, disliked affinity, сезонность, stale recipe cache, pending leftovers и другие сценарии, которые звучат конечно скучно, но только до тех пор, пока один из них не начинает портить реальные меню.
Ближайшие задачи это:
— надёжнее подключить retail-цены; — улучшить качество рецептов и шагов готовки; — собирать реальные сигналы: что заменяют, что готовят, что выбрасывают; — проверять retention, а не бежать сразу в монетизацию
Pikni Food пока MVP. У него хватает ещё незакрытых проблем, и не хочется делать вид, что мы уже решили вопрос о питании на неделю раз и навсегда.
Но сама задача оказалась сильно интереснее, чем выглядела в начале. Снаружи это «сделайте мне меню». Внутри — пачки, цены, остатки, soft constraints, WebView, retail, сервис-воркер и много мелких решений, которые либо помогают человеку реально купить продукты, либо превращают планировщик в красивую игрушку.
В общем, было довольно здорово поделиться своим опытом здесь. Надеюсь, когда-нибудь, мы закроем вопрос о быстром и удобном питанием на неделю раз и навсегда!