08. Логика витрины, переезжающая на бэкенд¶
Документ фиксирует, какая бизнес-логика сейчас живёт в коде фронтенда (../frontend — Vue 2 + SSR,
src/), почему она не может там остаться и в каком виде переезжает на новую платформу. Отдельный
раздел (§8.4) — что по доставке, зонам и графику работы уже умеют legacy-бэкенды ../site
(PHP/Laravel) и ../menu (Go), и откуда эти данные берутся на самом деле.
Разбор сделан по коду, а не по описанию: ссылки ниже — на реальные файлы и функции. Это одновременно и опись «что нельзя потерять при переписывании», и список требований к API.
8.1. Зачем этот документ¶
Фронтенд витрины — это не только отображение. Сейчас в нём принимаются решения, которые определяют исполнимость и стоимость заказа:
- открыт ли ресторан и принимает ли он доставку/самовывоз прямо сейчас;
- в какие интервалы можно заказать «ко времени» на сегодня и завтра;
- какой ресторан обслуживает адрес клиента и попадает ли адрес в зону доставки вообще;
- какова минимальная сумма заказа для этого адреса и достигнута ли она;
- сколько минут займёт доставка/приготовление, и на сколько это время увеличено «из-за загрузки»;
- какие служебные позиции добавить в заказ (бесплатный соевый соус, «оставить у двери»);
- сколько приборов/палочек/сахара положено к составу корзины.
Всё это вычисляется в браузере, по данным, выгруженным на клиента целиком (график работы,
полигоны зон, ограничения по зонам), и результат — включая pizzeria_id, delivery_at,
delivery_type — отправляется на бэкенд как готовое решение, которое бэкенд принимает на веру
(storage/modules/Order/actions/create-order.js).
Практические следствия, из-за которых логику нужно переносить:
- Правила невозможно переиспользовать. Сайт, Telegram mini app и мобильное приложение вынуждены реализовывать одни и те же правила заново, каждый со своими багами.
- Правила нельзя изменить без релиза фронта. Тексты уведомлений, шаг слотов, лимиты бесплатных соусов, SEO-описания категорий — всё это константы в бандле.
- Правила не защищены. Минимальная сумма, попадание в зону, доступность времени — проверки на клиенте; заказ, собранный в обход UI, их не проходит.
- Часы и часовые пояса. Расчёты смешивают серверное время (
services/PizzeriaTime.js) с локальным временем браузера (Date.getHours(),getTimezoneOffset()), причём смещение берётся по модулю (Math.abs(...)вservices/utils/get-working-times-delivery.js:12). Клиент в другом часовом поясе получает другой набор слотов и другой статус ресторана. - Одно правило — несколько реализаций. График работы
work_timeразбирается независимо в пяти местах (см. §8.3), с разными краевыми случаями и разными ответами про «сейчас открыто».
8.2. Критерий: что считается логикой бэкенда¶
Логика переезжает, если верно хотя бы одно:
| Критерий | Пример из витрины |
|---|---|
| Влияет на деньги или на исполнимость заказа | минимальная сумма, зона доставки, окно приёма заказов |
| Требует единого времени и единого источника истины | статус «открыто», слоты «ко времени», ETA |
| Дублируется между клиентами | статус ресторана в шапке сайта, в мини-аппе, в приложении |
| Настраивается не разработчиком | тексты уведомлений, шаг слотов, лимиты бесплатных позиций, SEO-тексты |
| Обязано проверяться повторно при приёме заказа | всё вышеперечисленное |
Остаётся на фронте: рендеринг, анимации, состояние UI-модалок, аналитика, локальный черновик корзины до синхронизации, форматирование уже посчитанных значений.
8.3. Карта логики во фронтенде¶
Что и где лежит сейчас (пути относительно frontend/frontend/src):
| Блок | Файлы | Строк |
|---|---|---|
| Статус ресторана (открыт / принимает доставку / самовывоз) | services/TimeWorking/PizzeriaStatus.js, storage/modules/Pizzeria/* |
112 + ~60 |
| Тесты на статус — фактическая спецификация правил | services/TimeWorking/PizzeriaStatus.spec.js |
442 |
| Слоты «ко времени» на сегодня/завтра | services/utils/get-working-times-delivery.js, storage/modules/Order/mutations/get-create-order-dates.js |
300 + 47 |
| Человекочитаемое расписание («Пн-Пт с 10:00 до 23:00») | services/utils/get-pizzeria-working-hours.js |
58 |
| Оставшееся время до закрытия | interface/components/all/working-time/WorkingTime.vue (remainingTimeForClose) |
в составе 345 |
| Уведомления «закрыто / скоро закроется / много заказов» | interface/components/all/working-time/WorkingTime.vue + NOTIFICATIONS.md |
345 |
| Время доставки/приготовления, ETA заказа | services/Delivery/DeliveryInfo.js |
59 |
| Повышенное ожидание (глобальное и по категориям) | storage/modules/Pizzeria/getters.js, storage/modules/Cart/getters/cart.js |
~50 |
| Определение зоны доставки по адресу/координатам | storage/modules/Geo/actions.js (Яндекс geoQuery по GeoJSON в браузере) |
160 |
| Минимальная сумма заказа, привязка адреса к ресторану | storage/modules/Order/actions/get-minimal-sum.js, storage/modules/Profile/actions/set-active-pizzeria-by-active-address.js, .../create-address.js |
128 + 55 + 45 |
| Гейт оформления заказа (что блокирует кнопку) | interface/components/pages/Order.vue, interface/components/order/checkout-action/CheckoutAction.vue, interface/components/order/time-select/TimeSelect.vue |
977 + 181 + 403 |
| Итоги корзины, количество позиций | storage/modules/Cart/mutations/get-cart.js, storage/modules/Cart/getters/* |
~200 |
| Бесплатные соус/васаби, персоны, приборы | storage/modules/Cart/actions/validate-free-soy.js, validate-free-wasabi.js, getters/max-*.js |
65 + 65 + ~20 |
| Пересчёт цены позиции при изменении меню | storage/modules/Cart/actions/validate-cart.js (getActualPrice) |
90 |
| Тексты «блюдо стало недоступно» по стоп-листу | storage/modules/Cart/mutations/set-inaccessible-dishes.js |
94 |
Купоны по ссылке (?resolve-coupon=) |
services/utils/unique-coupons.js |
56 |
| Сортировка и доступность способов оплаты | storage/index.js (GET_PIZZERIAS), Order.vue (canLeaveAtDoor, isCash) |
~60 |
SEO-тексты категорий, schema.org |
services/Seo/categorySeoContent.js, services/utils/create-schema-org.js |
325 + 75 |
| Поиск и фильтры по каталогу | composables/useProductsSearch.js, useCategoryFilter.js, storage/modules/Products/getters.js (filterParams) |
137 + 102 + ~80 |
| Синхронизация времени с сервером | services/PizzeriaTime.js |
22 |
Итого около 2 700 строк бизнес-правил в клиентском коде — без учёта их вкраплений в компоненты.
Входные данные, на которых всё это работает¶
Витрина получает справочники целиком и считает по ним локально:
GET /cities → city.delivery_map.map — GeoJSON полигонов зон
city.delivery_map.restrictions — [{ zone, minSum,
deliveryDurationInMinutes,
organizationId }]
GET /city/pizzerias → work_time[7][4] — по дню недели JS (0 = воскресенье):
[0] rest_start, [1] rest_end,
[2] delivery_start, [3] delivery_end
time_cooking_pickup, delivery_time_open_delay,
delivery_time_close_delay, rest_time_open_delay,
rest_time_close_delay, order_time_disable,
increased_expectation, increased_expectation_duration,
expectation_category[{category_id, duration}],
enable_pickup_delivery, crm_id, payments[], time (часы ресторана)
Это и есть будущий контракт справочников — только вычисления по нему должны выполняться на сервере.
8.4. Что по доставке уже есть в legacy-бэкендах¶
Прежде чем проектировать перенос, нужно знать, что бэкенд уже умеет и откуда берутся данные.
Разобраны ../site (PHP/Laravel) и ../menu (Go).
menu (Go) — доставки нет вообще¶
В сервисе нет ни зон, ни расписаний, ни минимальных сумм: миграции — organizations,
external_menus, price_categories, categories, terminals, stop_lists, products,
product_sizes, product_size_prices, modifier_*; API (api.yml) — только категории, меню по
категории, блюдо, стоп-лист. Единственные упоминания доставки — «терминалы доставки»
(DeliveryTerminal{Id, OrgId, Name}) и приём вебхука DeliveryOrderUpdate о статусе заказа.
Вывод: вся доставка живёт в site.
site (PHP/Laravel) — зоны импортируются из iiko¶
Ключевой факт, меняющий постановку задачи: зоны доставки не рисуются руками в админке, они
приходят из iiko — POST /api/1/delivery_restrictions
(app/Services/Integrations/IikoTransport/IikoTransport.php:109), импорт ежечасно
(import:iiko:map в app/Console/Kernel.php).
Что делает импорт (app/UseCases/Import/Map.php, 88 строк):
| Шаг | Что происходит | Замечание |
|---|---|---|
| Запрос | deliveryRestrictions(organizationIds) — передаются все организации |
|
| Отбор | берётся deliveryRestrictions[0] |
только первая организация, остальные отбрасываются |
| Базовые поля | Arr::only(...): defaultMinSum, defaultDeliveryDurationInMinutes, defaultSelfServiceDurationInMinutes, defaultFrom, defaultTo, useSameMinSum, useSameDeliveryDuration, useSameRestrictionsOnAllWeek, useSameWorkTimeInterval, restrictions |
сохраняются в JSON, но никем не читаются — ни бэкендом, ни витриной |
| Геометрия | deliveryZones[].coordinates → GeoJSON FeatureCollection, стили захардкожены (fill: #ffd21e), имя зоны кладётся в properties.description |
отсюда и берёт имя фронт |
| Запись | city.delivery_map = $map в цикле по всем городам |
всем городам пишется одна и та же карта |
| Отдача | целиком в CityResource → GET /cities |
полигоны и ограничения уезжают в браузер |
Отсюда следуют два дефекта, которые нельзя воспроизводить в новой системе:
- Разрез ограничений теряется. В iiko запись
restrictionsзадана в разрезе (зона, день недели, интервалfrom–to), плюс есть значения по умолчанию и флаги «одинаково ли на всю неделю». Витрина делаетrestrictions.find(i => i.zone === zoneName)— берёт первую запись по имени зоны, игнорируя день и интервал. Если в iiko настроены разные минималки или сроки доставки по дням/часам, сайт их не увидит. - Связь «зона ↔ параметры» — по строке. Полигон и запись ограничений сцеплены совпадением
названия зоны (
properties.description↔restrictions[].zone). Переименование зоны в iiko рвёт связь, и адрес перестаёт резолвиться.
Что бэкенд site проверяет при создании заказа¶
app/Domain/Order/OrderService.php:createOrder():
| Проверка | Есть? |
|---|---|
| корзина не пуста | да |
| время заказа внутри графика | нет — см. ниже |
| адрес указан при доставке | да (только факт наличия client_address_id) |
| антифрод: высокорисковая оплата + неподтверждённый прошлый заказ | да |
тип доставки включён на точке (enable_pickup_delivery / enable_courier_delivery) |
да |
| адрес попадает в зону доставки | нет |
| минимальная сумма для зоны достигнута | нет |
| выбранная точка действительно обслуживает эту зону | нет |
Валидация времени физически отключена: App\Models\Pizzeria::validateOrderTime() начинается с
public function validateOrderTime(Carbon $date): bool
{
// отключение валидации, логика на фронтенде
return true;
…
Ниже — около 60 строк мёртвого, но корректного кода: разбор перехода через полночь, сдвиг графика к дате заказа, и закомментированный блок буферов с пояснением «фронтенд присылает время к которому заказ будет готов, поэтому на бекенде не нужно добавлять смещения к графику». То есть текущее распределение ответственности зафиксировано в коде осознанно — и именно его мы отменяем.
Куда уходит заказ¶
- Заказ создаётся на организацию call-центра:
pizzeria_id = app('call_center'), а выбранная клиентом точка сохраняется вpizzeria_payment_id(«кто получает оплату»). - В iiko отправляются
organizationIdиterminalGroupIdотpizzeria_payment(IikoDelivery::organizationId()), то есть решение браузера о том, какая точка обслуживает адрес, определяет и организацию заказа в iiko, и получателя оплаты. - В
deliveryPointуходит адрес (street.id— идентификатор улицы из iiko, либо имя; дом, корпус, квартира, подъезд, этаж, домофон) и координаты; при заказе ко времени —completeBefore. Зона в iiko не передаётся: iiko определяет её сам по адресу. - Приборы, сахар, сдача и количество персон уезжают текстом в комментарий заказа.
График работы на бэкенде¶
pizzerias.work_time— один JSON-столбец, 7 дней × 4 времени (restTimeOpen/Close,deliveryTimeOpen/Close,App\Structs\Pizzeria\PizzeriaWorkTime), редактируется в Orchid. Таблиц под исключения (праздники, санитарные дни) в схеме нет.- В API график отдаётся позиционным массивом, начиная с воскресенья (
toApiArray()) — именно этот формат фронт и разбираетslice()-ами. - Задержки открытия/закрытия отдаются из
PizzeriaResourceс захардкоженными умолчаниями 60 / 40 / 60 / 30 минут, если поля не заданы. - У точки есть
timezone, и API отдаётtime= текущее время в этой зоне — на этом построенPizzeriaTimeво фронте. - Повышенное ожидание: флаг и длительность на точке + категорийные надбавки
(
pizzeria_category_expectations). Автосброс режима через 6 часов реализован (ResetIncreasedExpectationModeCommand), но закомментирован в планировщике; вместо него ежечасно уходят напоминания в Telegram.
Адреса — уже серверные¶
Улицы импортируются из iiko (import:iiko:streets, ежедневно), экспортируются в Elasticsearch
(export:elastic:streets), поиск — POST /city/streets. Адрес клиента хранит street_crm_id,
iiko_street_name, iiko_city_name, широту и долготу. Это единственная часть адресного контура,
которую переносить не нужно — только перевести на новую модель.
Как это меняет решения по §8.5 D и §8.8¶
- Зоны — данные iiko, а не ручной справочник. По ADR-002/003 их место — схема
iiko(зеркало источника, для человека read-only), а модульdeliveryрезолвит адрес по зеркалу и хранит только витринные атрибуты зоны (отображаемое имя, стиль на карте, тексты). Эскиз в §8.8 поправлен соответственно:delivery.zoneне заводится в админке, а импортируется вместе с организациями и терминалами. - Импорт зон нужно чинить при переносе: обрабатывать все организации, а не
[0]; хранить зону идентификатором, а не названием; сохранять разрез (день недели, интервал) дляminSumиdeliveryDurationInMinutes; учитыватьdefault*и флагиuseSame*. - Геометрия — в PostGIS, резолв адреса —
ST_Containsв SQL. Полигоны перестают выгружаться в браузер целиком; карте достаточно упрощённого контура. - Двойная валидация с iiko — это нормально. iiko проверяет адрес и зону при создании доставки. Наш резолв нужен, чтобы показать минималку и ETA до оформления; расхождение «мы приняли — iiko отклонила» логируется как инцидент, а не замалчивается.
- Маршрутизация через call-центр — конфигурация, а не логика клиента. Схема «заказ в организацию call-центра, оплата на организацию точки» переносится явной настройкой маршрутизации, и её нужно подтвердить у клиента.
8.5. Блоки, которые переезжают¶
A. График работы и статус точки¶
Сейчас. PizzeriaStatus.get() принимает work_time, time_cooking_pickup и текущее ожидание
и возвращает { working, canDeliveryOrder, canPickupOrder }. Разбор графика — посимвольный
(slice(0,2), slice(3,5)), с ручной обработкой перехода через полночь и «вчерашней» смены.
Пересчёт — таймером раз в 15 секунд (storage/modules/Pizzeria/actions.js).
Проблемы, видимые прямо в коде:
workingиcanDeliveryOrderсчитаются одним и тем же выражением по окну доставки (индексы 2/3), аcanPickupOrder— по окну ресторана (индексы 0/1) со сдвигом на время приготовления; то есть «ресторан работает» на витрине означает «идёт доставка», что не одно и то же;- параллельно существуют ещё четыре разбора того же
work_time: слоты (get-working-times-delivery.js), человекочитаемое расписание (get-pizzeria-working-hours.js), «сколько осталось до закрытия» (WorkingTime.vue) иopeningHoursдляschema.org(create-schema-org.js, который берёт толькоwork_time[0]и объявляет его как «Ежедневно»); - нет понятия исключений: праздники, санитарные дни, аварийная остановка приёма заказов.
На бэкенд. Модуль org: расписание точки как данные (регулярная неделя + исключения по датам),
одна реализация календаря, часовой пояс организации, и один endpoint статуса. Ответ включает не
только флаги, но и причину и следующее изменение — фронту нечего вычислять:
GET /api/v1/organizations/{id}/status
{
"serverTime": "2026-08-15T21:14:03+03:00",
"open": true, // точка работает
"delivery": { "accepting": true, "opensAt": null, "closesAt": "2026-08-15T23:00:00+03:00" },
"pickup": { "accepting": false, "reason": "CLOSING_SOON",
"opensAt": null, "closesAt": "2026-08-15T22:30:00+03:00" },
"closingInMinutes": 106,
"schedule": [ { "days": "Пн–Чт", "delivery": "10:00–23:00", "pickup": "10:00–22:30" } ]
}
schedule в готовом к показу виде закрывает get-pizzeria-working-hours.js, а serverTime
делает ненужным трюк с офсетом (PizzeriaTime).
B. Слоты «ко времени»¶
Сейчас. get-working-times-delivery.js (300 строк, самый сложный файл витрины) строит два
списка — «Сегодня» и «Завтра» — перебором с шагом 30 минут для доставки и 10 минут для самовывоза,
с поправками delivery_time_open_delay / delivery_time_close_delay /
rest_time_open_delay / rest_time_close_delay, временем доставки до адреса и текущим ожиданием.
Диапазон в UI («18:00 – 18:30») дорисовывается отдельно в TimeSelect.vue. Пункт «Как можно скорее»
добавляется/убирается по флагам статуса, а если выбранное время «протухло», компонент молча
подменяет его на ближайшее или на «сейчас» — внутри computed-свойства.
Проблемы: смешение серверного времени и локального getHours(); Math.abs() над
getTimezoneOffset(); горизонт жёстко «сегодня + завтра»; шаг и задержки — константы бандла;
выбранный слот отправляется в заказ как строка delivery_at, никем не проверяемая.
На бэкенд. Слоты выдаёт сервер — как список валидных значений с явным идентификатором:
GET /api/v1/delivery/slots?organizationId=..&type=DELIVERY&cartId=..
{
"asap": { "available": true, "etaMinutes": 65, "etaRange": [60, 75] },
"timedOrdersEnabled": true,
"days": [
{ "date": "2026-08-15", "label": "Сегодня",
"slots": [ { "id": "2026-08-15T18:00/PT30M", "from": "18:00", "to": "18:30" } ] },
{ "date": "2026-08-16", "label": "Завтра", "slots": [ /* … */ ] }
]
}
В заказ уходит slotId (или asap), а не вычисленный клиентом timestamp; сервер проверяет слот
повторно в момент создания заказа. Шаг, горизонт и задержки — настройки точки в админке.
C. Время приготовления и повышенное ожидание¶
Сейчас. ETA собирается из трёх слагаемых в разных местах: время доставки по зоне
(DeliveryInfo.getTimeOfDelivery), время готовки самовывоза (time_cooking_pickup) и «ожидание»
(Cart/cart.expectation), которое вычисляется как максимум из глобальной надбавки и надбавок по
категориям, присутствующим в корзине (storage/modules/Cart/getters/cart.js). Категории при этом
опознаются по захардкоженным id: 1 — пицца, 15 — роллы (WorkingTime.vue:initNotifications).
Тексты семи вариантов уведомления собраны конкатенацией строк там же; правила их показа описаны
отдельным документом working-time/NOTIFICATIONS.md (триггеры, 5 минут бездействия, 60 секунд
показа, 5 минут игнорирования после закрытия).
На бэкенд. ETA и надбавки считает сервер и отдаёт готовым числом/диапазоном вместе со статусом
и слотами. Правила повышенного ожидания — данные: { scope: GLOBAL | CATEGORY, categoryId,
durationMinutes, activeFrom, activeTo }, редактируются в админке, а не выбираются по id 1/15.
Тексты уведомлений — шаблоны в админке; фронт получает notices: [{ code, severity, title, text,
ttlSeconds }] и только отображает их. Логика «когда показывать» (первый заход, бездействие,
изменение ETA) остаётся на клиенте — это UX, а не бизнес-правило.
D. Зоны доставки, адрес и минимальная сумма¶
Сейчас. Самое тяжёлое место. В браузер выгружается GeoJSON всех зон города, туда же — таблица ограничений. Далее клиент:
- геокодирует адрес через Яндекс (ключ лежит в бандле —
services/config.js:8); - ищет полигон, содержащий точку, через
ymaps.geoQuery(storage/modules/Geo/actions.js); - берёт имя зоны из свойства
descriptionполигона, обрезает пробелы и ищет по этому имени строку ограничений (getZoneRestrictions) — связь зоны и её параметров держится на совпадении строк; - получает
minSum,deliveryDurationInMinutesиorganizationId, после чего сам меняет активный ресторан (CHANGE_ACTIVE_PIZZERIA) и подставляет минималку и ETA в заказ.
Если зона не найдена — throw new Error('Zone not found'), который в UI превращается в текст
«Мы ещё не доставляем заказы сюда» (Order.vue:checkOderMinimalSum). При создании заказа зона
подставляется с запасным значением-строкой: state.orderInfo.zone || 'Центральный район'
(create-order.js).
На бэкенд. Модуль delivery: зоны как полигоны в PostGIS-совместимом виде, параметры зоны как
атрибуты (минимальная сумма, время доставки, стоимость доставки на будущее, обслуживающая
организация, приоритет при пересечении). Источник зон — импорт из iiko, а не ручной ввод в
админке: см. §8.4 — legacy уже тянет их из delivery_restrictions, теряя по дороге разрез по дням
и интервалам. Один endpoint резолвинга:
POST /api/v1/delivery/resolve
{ "address": { "city": "Обнинск", "street": "Ленина", "house": "10" } } // или { "coords": [lon, lat] }
→
{ "resolved": true,
"zoneId": "…", "zoneName": "Центральный",
"organizationId": "…",
"minOrderSum": 700,
"deliveryDurationMinutes": 60,
"coords": [36.61, 55.09],
"addressPrecision": "HOUSE" } // HOUSE | STREET | CITY — точность геокодера
Следствия: ключи геокодера уходят с клиента, ответы геокодера кешируются на сервере,
подсказки улиц (city/streets уже серверные) и резолв работают по одному справочнику, а зона
перестаёт быть строкой. Выбор обслуживающей организации становится решением сервера, а не браузера.
E. Гейт оформления заказа¶
Сейчас. Условия «можно оформлять» рассыпаны по компонентам:
| Проверка | Где |
|---|---|
| адрес выбран (для доставки) / точка выбрана (для самовывоза) | Order.vue:moveToPay, TimeSelect.vue:hold |
| сумма ≥ минимальной (только для доставки; при самовывозе минималка принудительно 0) | Order.vue:checkOderMinimalSum, Order/mutations/set-minimal-sum.js |
| выбранное время ещё актуально | Order.vue:isOrderTimeExist, timeAsActual, TimeSelect.vue:text |
| время в прошлом → подменить на «сейчас» | Order.vue:confirmOrder |
| промокод не выполняет условия → снять промокод | Order.vue:confirmOrder |
| «оставить у двери» доступно только для онлайн-оплаты | Order.vue:canLeaveAtDoor — по подстроке в названии способа оплаты ('онлайн', 'на сайте') |
| корзина не содержит недоступных блюд | Cart/actions/validate-cart.js + cart/check |
| предыдущий неоплаченный заказ — удалить | Order.vue:createOrder |
Текст «Минимальная сумма заказа N ₽. Добавьте товары на M ₽» тоже собирается на клиенте
(CheckoutAction.vue).
На бэкенд. Один вызов предпроверки, единый для всех клиентов, и он же — обязательная часть создания заказа:
POST /api/v1/checkout/preflight
{ "cartId": "…", "fulfillment": { "type": "DELIVERY", "addressId": "…", "slotId": "asap" },
"paymentMethod": "CARD_ONLINE", "options": { "leaveAtDoor": true } }
→
{ "canSubmit": false,
"total": { "items": 1240, "discount": 120, "delivery": 0, "toPay": 1120 },
"eta": { "minutes": 65, "at": "2026-08-15T22:20:00+03:00" },
"blockers": [
{ "code": "MIN_SUM_NOT_REACHED", "minSum": 1500, "shortfall": 380,
"message": "Добавьте товары на 380 ₽" }
],
"warnings": [ { "code": "PROMO_CONDITIONS_UNMET", "promocode": "ПИЦЦА10" } ],
"adjustments": [ { "code": "SLOT_EXPIRED", "newSlotId": "asap" } ] }
Коды блокеров — часть контракта; тексты приходят с сервера, но клиент вправе показать свои.
Те же проверки выполняются при POST /orders — обход UI перестаёт работать.
F. Состав заказа: служебные позиции и лимиты¶
Сейчас. Бизнес-правила комплектации выражены поиском товара по названию строкой:
| Правило | Реализация |
|---|---|
| «Оставить заказ у двери» — это скрытое блюдо из категории «Скрытые», добавляемое/удаляемое из корзины при переключении тумблера | Order.vue:toggleLeaveAtDoor, config.leaveAtDoor |
Бесплатный соевый соус: maxSoyFree = персоны − 1 |
Cart/getters/max-soy-free.js, actions/validate-free-soy.js |
Бесплатный васаби: maxWasabiFree = ceil((персоны − 1) / 2) |
getters/max-wasabi-free.js, actions/validate-free-wasabi.js |
«Персоны» = сумма person_count × count по позициям с person_splitting_enabled, плюс 1 |
getters/max-persons.js |
| Платный соус автоматически уменьшается на количество бесплатных | validate-free-soy.js |
Приборы/палочки/сахар ограничены суммой tool_count/sugar_count по корзине |
Cart/mutations/get-cart.js |
Позиции исключаются из счётчика корзины по именам 'Соевый соус бесплатно', 'Васаби бесплатно', 'Оставить заказ у двери' |
Cart/getters/cart.js, cart-button-is-visible.js, get-cart.js |
Сами товары находятся по точному имени: 'Соевый соус', 'Васаби', 'Имбирь' |
Products/mutations.js:244-248 |
Переименование блюда в iiko ломает любое из этих правил молча.
На бэкенд. В каталоге появляется роль позиции (service_role: LEAVE_AT_DOOR,
FREE_SOY, PAID_SOY, FREE_WASABI, CUTLERY, SUGAR, …), назначаемая в админке явно, и набор
правил комплектации: «на каждые N персон — M бесплатных единиц позиции с ролью X», «позиции с
ролью Y скрыты в списке корзины и не входят в счётчик». Пересчёт делает сервер при каждом
изменении корзины и возвращает готовый состав; клиент не добавляет и не удаляет служебные позиции
сам. Это же снимает расхождение «локальная корзина ≠ корзина на сервере».
G. Итоги корзины и цены¶
Сейчас. Сервер считает корзину (POST cart/calc/{id} — скидки, промокоды, подарки), но клиент
параллельно считает свою сумму (get-cart.js: sum += price × count), выбирает из ответа
минимальную цену по нескольким расчётам и склеивает подарки
(Cart/actions/get-cart-calculation.js), а также переоценивает позиции при обновлении меню:
getActualPrice() в validate-cart.js заново складывает цену размера, топпингов и допсоусов.
Итог для кнопки берётся то из расчёта сервера, то из локальной суммы (getters/total-price.js).
На бэкенд. Единственный источник итогов — ответ корзины: subtotal, discounts[], gifts[],
deliveryFee, total, и построчно — unitPrice, lineTotal, priceChanged. Клиент отображает.
Правило «выбрать лучший из вариантов расчёта» — серверное, оно про деньги.
H. Стоп-листы и недоступные позиции¶
Сейчас. Бэкенд возвращает список недоступного (cart/check/{id}), а клиент собирает из него
текст: удаляет блюдо, обнуляет топпинги и соусы, склеивает фразы вида «Дополнительные соусы "X" и
топпинги "Y" недоступны и были удалены из блюда» (set-inaccessible-dishes.js, 94 строки
конкатенации с падежами и TextFormat.toUpperCaseFirstLetter).
На бэкенд. Сервер возвращает структурированные изменения корзины —
{ itemId, action: REMOVED | MODIFIERS_REMOVED, removedModifiers[], reason: STOP_LIST | NOT_IN_MENU,
message } — и уже применённый новый состав корзины. Клиент показывает список сообщений.
Это согласуется с ОВ-4 (06-decisions.md): решение «скрывать или показывать неактивным» должно
приниматься на сервере и приходить в меню флагом available.
I. Промокоды, купоны и персональные скидки¶
Сейчас. Проверка промокода серверная (promocode/check), но:
- список «купонов по ссылке» захардкожен в бандле:
['ПИЦЦА10', 'РОЛЛЫ10', 'КОМБО10'](services/utils/unique-coupons.js), а право на купон хранится вlocalStorage— очищаемом пользователем; - отмена невыполнимого промокода перед отправкой заказа — на клиенте (
Order.vue:confirmOrder); - персональная скидка применяется/снимается клиентом через несколько последовательных
updateCart(Profile/actions/apply-personal-dicount.js).
На бэкенд. Право на купон — свойство клиента/сессии на сервере (переход по ссылке фиксируется
серверным endpoint, а не localStorage), выдача и снятие скидок — операции над корзиной, ответ
корзины содержит применённые и отклонённые скидки с причинами.
J. Способы оплаты¶
Сейчас. Список способов приходит с точкой, но клиент переупорядочивает и подменяет его:
СБП и SberPay поднимаются наверх и получают синтетические id 901/900 (storage/index.js:85-107);
«наличность» определяется поиском подстрок 'аличны', 'курьер', 'пиццерии' в названии
(CheckoutAction.vue), а доступность «оставить у двери» — поиском подстрок 'онлайн', 'на сайте'
(Order.vue). Идентификаторы способов оплаты — UUID-константы в config.js.
На бэкенд. Способ оплаты — справочник с кодом (CASH, CARD_COURIER, CARD_ONLINE, SBP,
SBERPAY), признаками (isCash, isOnline, allowsLeaveAtDoor, requiresChange), порядком
сортировки и доступностью в разрезе организации и типа получения. Никаких строковых догадок.
K. SEO и микроразметка¶
Сейчас. Тексты, title, description, h1 и FAQ для категорий лежат в бандле фронта —
services/Seo/categorySeoContent.js, 325 строк захардкоженного контента с привязкой к id категорий
(pizza: { id: 1 }, rolly: { id: 15 }). schema.org собирается в браузере после загрузки меню
(create-schema-org.js), причём часы работы объявляются как «Ежедневно» по work_time[0].
На бэкенд. SEO-поля уже предусмотрены в модели каталога (02-catalog-domain.md) и
редактируются в админке — контент переносится туда, а schema.org (Restaurant, Menu,
Offer) отдаётся сервером в составе ответа страницы/меню, а не собирается клиентом после
гидрации. Это же снимает риск расхождения микроразметки с реальным меню и расписанием.
L. Поиск и фильтры каталога¶
Сейчас. Витрина грузит все категории и все товары (Products/actions.js:getCategories →
getProducts по каждой категории), затем ищет и фильтрует в памяти браузера
(useProductsSearch.js, useCategoryFilter.js), включая раскладку клавиатуры
(auto-keyboard-lang). Наборы фильтров (лейблы/теги/ингредиенты) вычисляются из загруженных
товаров и только для категорий с типом pizza и rolly (Products/getters.js:filterParams).
На бэкенд. Фасеты (доступные фильтры с количествами) и поиск — часть публичного API меню
(05-catalog-api.md), с нормализацией запроса и опечаток на сервере. Клиентский поиск по
загруженному снапшоту допустим как быстрый путь, но перечень фасетов должен приходить из
каталога, а не выводиться из выдачи.
M. Время сервера¶
Сейчас. services/PizzeriaTime.js берёт строку time из объекта ресторана, парсит её как
локальное время браузера и хранит разницу с Date.now(). Все расчёты расписаний идут через
этот сдвиг, но часы/минуты извлекаются локальными getHours()/getMinutes(). Конструкция
работает, пока клиент в том же часовом поясе, что и ресторан.
На бэкенд. Все ответы, содержащие время, отдают ISO-8601 со смещением организации; статус, слоты и ETA приходят посчитанными. Клиенту остаётся форматирование, синхронизация часов не нужна.
8.6. Сводка целевых контрактов¶
Публичное API витрины (дополняет 05-catalog-api.md; часть относится к этапу заказа):
| Endpoint | Назначение | Заменяет во фронте |
|---|---|---|
GET /api/v1/context?organizationId= |
город, организация, серверное время, статус, ETA, способы оплаты, активные уведомления | requestBaseData, PizzeriaTime, часть WorkingTime.vue |
GET /api/v1/organizations |
точки с адресом, координатами, флагами самовывоза, расписанием | GET_PIZZERIAS + пересборка payments |
GET /api/v1/organizations/{id}/status |
открыто / принимает доставку / принимает самовывоз, причины, ближайшее изменение | PizzeriaStatus, Pizzeria/*, remainingTimeForClose |
POST /api/v1/delivery/resolve |
зона, обслуживающая точка, минимальная сумма, время доставки по адресу/координатам | Geo/actions.js, get-minimal-sum.js, set-active-pizzeria-by-active-address.js |
GET /api/v1/delivery/slots |
слоты «ко времени» + доступность «как можно скорее» + ETA | get-working-times-delivery.js, get-create-order-dates.js |
GET /api/v1/addresses/suggest |
подсказки адресов (прокси геокодера) | Geo/getSuggestions + ключ Яндекса в бандле |
GET/POST /api/v1/cart |
состав, итоги, служебные позиции, скидки, недоступные позиции | Cart/* (расчёты), validate-cart, validate-free-*, set-inaccessible-dishes |
POST /api/v1/checkout/preflight |
единый гейт оформления: блокеры, предупреждения, автокоррекции | Order.vue, CheckoutAction.vue, TimeSelect.vue |
POST /api/v1/orders |
создание заказа по cartId + fulfillment + paymentMethod |
create-order.js (клиент больше не считает delivery_at и не выбирает pizzeria_id) |
Принцип для всех: клиент присылает намерение (адрес, слот, способ оплаты), а не вычисленный результат. Сервер повторяет все проверки при создании заказа.
8.7. Что остаётся на фронте¶
- Рендеринг, роутинг, SSR-гидрация, состояние модалок, карты и пины, drag/scroll-поведение.
- Локальный черновик корзины до синхронизации и оптимистичные обновления счётчиков.
- Правила показа уведомлений (первый визит, 5 минут бездействия, 60 секунд показа, игнорирование после закрытия) — сами тексты и условия «уместно ли сообщение» приходят с сервера.
- Аналитика и её события.
- Форматирование:
minutesToTime, склонения, цены, «Сегодня/Завтра» по данным сервера.
8.8. Что нужно завести в модели данных¶
Эскиз (детализируется в документе этапа заказа):
org.organization — точка: код, адрес, координаты, timezone, iiko organizationId,
флаги (доставка, самовывоз, приём заказов ко времени)
org.schedule — регулярное расписание: (organization_id, day_of_week, channel
DELIVERY|PICKUP|DINE_IN, opens_at, closes_at, crosses_midnight)
org.schedule_exception — исключения по датам: закрыт / особые часы / стоп приёма заказов
org.fulfillment_config — шаг слота, горизонт, задержки open/close, время готовки самовывоза
org.expectation_rule — повышенное ожидание: scope GLOBAL|CATEGORY, category_id, minutes,
active_from, active_to
iiko.delivery_zone — зеркало iiko: connection_id, organization_id, имя зоны,
geometry(Polygon, 4326), сырьё в jsonb, revision импорта
iiko.delivery_restriction— зеркало iiko: зона × день недели × интервал from–to →
min_sum, delivery_duration_minutes, self_service_duration_minutes,
terminal_group_id; отдельно значения default* и флаги useSame*
delivery.zone_view — витринные атрибуты зоны: отображаемое имя, стиль на карте,
приоритет при пересечении, тексты; ссылка на iiko.delivery_zone
delivery.zone_override — переопределения (если потребуются): min_order_sum, delivery_fee,
действует с/по — nullable-колонки поверх зеркала (ADR-002)
catalog.service_role — роль служебной позиции (LEAVE_AT_DOOR, FREE_SOY, CUTLERY …)
ordering.composition_rule— «N персон → M бесплатных единиц позиции с ролью X»
Ключевые отличия от текущего состояния: зоны — импортируемое зеркало iiko с сохранением разреза
по дням и интервалам (а не «первая запись по имени зоны»); связь зоны и её параметров — по
идентификатору, а не по совпадению строки из properties.description; служебные позиции — по роли,
а не по названию блюда; расписание допускает исключения.
Разделение слоёв то же, что и в каталоге (ADR-002): iiko.* пишется только импортом, витринные
атрибуты и возможные переопределения живут отдельными таблицами и читаются через coalesce.
8.9. Новые открытые вопросы¶
ОВ-8. Стоимость доставки. Сейчас в модели зон есть только minSum и deliveryDurationInMinutes
— платной доставки нет. Планируется ли платная доставка / бесплатная от суммы? От ответа зависит,
появляется ли deliveryFee в расчёте корзины на первом же этапе заказа.
ОВ-9. Пересечение зон. Текущий код берёт первый найденный полигон (.get(0)). Нужны ли
пересекающиеся зоны с приоритетом (например, «дальняя» поверх «центральной»), или полигоны
гарантированно не пересекаются?
ОВ-10. Горизонт заказа ко времени. Сейчас жёстко «сегодня + завтра». Нужен ли заказ на несколько дней вперёд (банкеты, предзаказы)?
ОВ-11. Праздники и внеплановые остановки. Как сейчас закрывают точку вне графика? Если через
правку work_time руками — исключения в расписании и «стоп приёма заказов» на кнопку в админке
обязательны в первой версии.
ОВ-12. Точность адреса. Резолв опирается на геокодер Яндекса по строке «город, ул. X, д. Y». Нужно решить, что делать при неточном совпадении (улица без дома, неоднозначный дом): блокировать оформление, спрашивать подтверждение на карте или принимать заказ с пометкой оператору.
ОВ-13. Купоны по ссылке. Механика ?resolve-coupon= с правом в localStorage — обходится
тривиально. Заменяем на серверную выдачу права (по сессии/клиенту) или отказываемся от механики?
ОВ-14. Разрез минималок и сроков доставки. В iiko ограничения задаются в разрезе
(зона, день недели, интервал), плюс есть default* и флаги useSame*. Сайт этот разрез
игнорирует (§8.4). Нужно посмотреть боевую выгрузку delivery_restrictions и подтвердить у
клиента: используется ли разное значение минималки/срока по дням и часам, или всюду одно.
От этого зависит, нужен ли резолв «на момент времени» или достаточно резолва «по зоне».
ОВ-15. Кто обслуживает адрес — мы или iiko. Сейчас зону и обслуживающую организацию выбирает браузер, а iiko при создании доставки определяет зону заново по адресу. Расхождение никак не отслеживается. Предложение: наш резолв — только для показа минималки и ETA, а организацию-приёмника определяет правило маршрутизации на сервере; расхождение с iiko пишется в инциденты.
ОВ-16. Маршрутизация через call-центр. В legacy все заказы создаются на организацию
call-центра (pizzeria_id = app('call_center')), а точка клиента используется только как
получатель оплаты. Сохраняем эту схему, или заказ должен уходить сразу в обслуживающую точку?
Ответ влияет на модель заказа и на настройки организаций.
ОВ-17. Приборы, сахар, сдача и персоны в iiko. Сейчас они уезжают текстом в комментарий заказа. Есть ли в iiko штатные поля/модификаторы под это (комплекты приборов как позиции меню)? Текст в комментарии не даёт ни отчётности, ни контроля списаний.
8.10. Влияние на план работ¶
Блоки A–C (расписание, статус, слоты, ожидание) и D (зоны, резолв адреса, минималка) относятся к этапу заказа, но их данные нужны витрине уже на первом этапе: виджет режима работы, уведомления «закрыто / скоро закроется», выбор точки на карте, страница ресторанов. Поэтому:
| # | Работа | Когда | Оценка |
|---|---|---|---|
| 19 | org: организации, расписание (регулярное + исключения), статус, endpoint /status и /context |
этап каталога, параллельно п. 8–9 | 4–5 дн |
| 19а | Импорт зон и ограничений доставки из iiko (delivery_restrictions): все организации, разрез по дням и интервалам, PostGIS-геометрия |
этап каталога, вместе с импортом организаций (п. 3) | 2 дн |
| 20 | delivery: витринные атрибуты зон, резолв адреса/координат (ST_Contains), прокси геокодера с кешем |
начало этапа заказа | 4–5 дн |
| 21 | Слоты и ETA (/delivery/slots), настройки шага/горизонта/задержек в админке |
начало этапа заказа | 4 дн |
| 22 | Правила ожидания и шаблоны уведомлений в админке | этап заказа | 3 дн |
| 23 | Роли служебных позиций и правила комплектации | этап заказа | 3 дн |
| 24 | checkout/preflight + повторная проверка при создании заказа |
этап заказа | 4 дн |
| 25 | Перенос SEO-контента категорий в каталог, серверный schema.org |
этап каталога, после п. 12 | 2 дн |
Пункты 19, 19а и 25 попадают в этап каталога: без 19 и 25 новая витрина не покажет режим работы и
потеряет SEO, а 19а — это тот же самый iiko-клиент, что и импорт организаций (п. 3 плана в
06-decisions.md), и делать его вторым заходом дороже, чем сразу. Остальное — первый спринт
этапа заказа.
Отдельно: PizzeriaStatus.spec.js (442 строки, 21 сценарий с переходами через полночь и
«вчерашней» сменой) — готовый набор приёмочных тестов для календаря на бэкенде. Его стоит
перенести в тесты модуля org дословно, включая сценарии 04:30 → 03:30 и
«вчера закрылись в 03:30, сейчас 03:29».