Перейти к содержанию

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).

Практические следствия, из-за которых логику нужно переносить:

  1. Правила невозможно переиспользовать. Сайт, Telegram mini app и мобильное приложение вынуждены реализовывать одни и те же правила заново, каждый со своими багами.
  2. Правила нельзя изменить без релиза фронта. Тексты уведомлений, шаг слотов, лимиты бесплатных соусов, SEO-описания категорий — всё это константы в бандле.
  3. Правила не защищены. Минимальная сумма, попадание в зону, доступность времени — проверки на клиенте; заказ, собранный в обход UI, их не проходит.
  4. Часы и часовые пояса. Расчёты смешивают серверное время (services/PizzeriaTime.js) с локальным временем браузера (Date.getHours(), getTimezoneOffset()), причём смещение берётся по модулю (Math.abs(...) в services/utils/get-working-times-delivery.js:12). Клиент в другом часовом поясе получает другой набор слотов и другой статус ресторана.
  5. Одно правило — несколько реализаций. График работы 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).

В сервисе нет ни зон, ни расписаний, ни минимальных сумм: миграции — 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

Ключевой факт, меняющий постановку задачи: зоны доставки не рисуются руками в админке, они приходят из iikoPOST /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 в цикле по всем городам всем городам пишется одна и та же карта
Отдача целиком в CityResourceGET /cities полигоны и ограничения уезжают в браузер

Отсюда следуют два дефекта, которые нельзя воспроизводить в новой системе:

  1. Разрез ограничений теряется. В iiko запись restrictions задана в разрезе (зона, день недели, интервал fromto), плюс есть значения по умолчанию и флаги «одинаково ли на всю неделю». Витрина делает restrictions.find(i => i.zone === zoneName) — берёт первую запись по имени зоны, игнорируя день и интервал. Если в iiko настроены разные минималки или сроки доставки по дням/часам, сайт их не увидит.
  2. Связь «зона ↔ параметры» — по строке. Полигон и запись ограничений сцеплены совпадением названия зоны (properties.descriptionrestrictions[].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

  1. Зоны — данные iiko, а не ручной справочник. По ADR-002/003 их место — схема iiko (зеркало источника, для человека read-only), а модуль delivery резолвит адрес по зеркалу и хранит только витринные атрибуты зоны (отображаемое имя, стиль на карте, тексты). Эскиз в §8.8 поправлен соответственно: delivery.zone не заводится в админке, а импортируется вместе с организациями и терминалами.
  2. Импорт зон нужно чинить при переносе: обрабатывать все организации, а не [0]; хранить зону идентификатором, а не названием; сохранять разрез (день недели, интервал) для minSum и deliveryDurationInMinutes; учитывать default* и флаги useSame*.
  3. Геометрия — в PostGIS, резолв адреса — ST_Contains в SQL. Полигоны перестают выгружаться в браузер целиком; карте достаточно упрощённого контура.
  4. Двойная валидация с iiko — это нормально. iiko проверяет адрес и зону при создании доставки. Наш резолв нужен, чтобы показать минималку и ETA до оформления; расхождение «мы приняли — iiko отклонила» логируется как инцидент, а не замалчивается.
  5. Маршрутизация через 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 всех зон города, туда же — таблица ограничений. Далее клиент:

  1. геокодирует адрес через Яндекс (ключ лежит в бандле — services/config.js:8);
  2. ищет полигон, содержащий точку, через ymaps.geoQuery (storage/modules/Geo/actions.js);
  3. берёт имя зоны из свойства description полигона, обрезает пробелы и ищет по этому имени строку ограничений (getZoneRestrictions) — связь зоны и её параметров держится на совпадении строк;
  4. получает 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:getCategoriesgetProducts по каждой категории), затем ищет и фильтрует в памяти браузера (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».