Решение задания для интернет-магазина «Петрушка Зеленая».
- Задание 1. Анализ требований
- Задание 2. Проектирование API
- Задание 3. Архитектура PUSH-уведомлений
- Принятые допущения
| № | Фрагмент ТЗ | Проблема | Предлагаемое решение |
|---|---|---|---|
| 1 | П. 2: количество изменяется не менее чем до 1. П. 9: при уменьшении до 0 товар удаляется | Неясно, является ли 0 допустимым количеством или отдельной командой удаления |
Количество позиции хранится только в диапазоне 1..10. Ввод/действие «уменьшить до 0» трактуется как удаление позиции; отдельная кнопка удаления также остается |
| 2 | П. 1: от 1 до 10 единиц одного товара | Не определено, относится лимит к одной операции добавления или к суммарному количеству SKU в корзине | Лимит 1..10 относится к итоговому количеству одного SKU после операции. Операция, превышающая лимит, отклоняется целиком |
| 3 | П. 3 и п. 4 | Есть два лимита, но не описано, что происходит при нарушении каждого из них и учитываются ли уже добавленные позиции | Явно считать уникальные SKU и суммарное количество по корзине; при нарушении любого лимита не менять корзину и вернуть код причины |
| 4 | П. 1, п. 3 и п. 4 вместе | Пункты логически совместимы: это перекрывающиеся ограничения, а не противоречие, и выполняться они должны одновременно. Недочет в другом - не описаны порядок проверки лимитов, приоритет между ними и результат операции при одновременном нарушении нескольких ограничений. Пользователь, нарушивший сразу два лимита, увидит недетерминированное сообщение об ошибке | Сохранить все три лимита и зафиксировать их совместное действие. Проверять лимиты в фиксированном порядке: количество уникальных SKU, количество единиц одного SKU, суммарное количество единиц. Возвращать код первого нарушенного лимита |
| 5 | П. 5: «Товары могут быть разные» | Формулировка не задает понятие «разный товар»: SKU, product_id, вариант размера/вкуса или карточка товара | Лимит уникальных товаров считать по sku_id; разные варианты одного продукта считаются разными SKU |
| 6 | П. 6: «Лимит корзины превышен» | Одно сообщение не объясняет, какой лимит нарушен; не задано поведение частичного добавления | Возвращать единый пользовательский текст и машинный reason: MAX_UNIQUE_ITEMS, MAX_TOTAL_QUANTITY или MAX_SKU_QUANTITY; частичное добавление запрещено |
| 7 | П. 7 и п. 13 | Прямое противоречие: цена фиксируется при добавлении, но затем автоматически меняется из каталога | Для данной версии выбрать фиксацию цены в момент добавления. Изменение каталожной цены не меняет сохраненную цену в корзине; исключения должны быть отдельным бизнес-решением |
| 8 | П. 8 | Недостаточно полей для экрана: не указаны название, SKU, изображение, валюта, итог корзины, скидка и доступность | Зафиксировать состав ответа и правила расчета: product, quantity, unit_price, line_total, currency, cart_total |
| 9 | П. 10 и п. 11 | «Может быть» и «должна быть» задают разные обязательности; неясно, идет ли речь о показе блока или о рассылке | Сделать рекламу опциональным контентом маркетинговой кампании. Показывать ее только при наличии активной кампании, согласии пользователя и соблюдении расписания |
| 10 | П. 11 | Не определены «утро», «вечер», часовой пояс, частота показов, место размещения и правила для часовых поясов | Зафиксировать расписание, например 09:00 и 18:00 по часовому поясу пользователя, не более одного рекламного блока за слот; значения вынести в настройки кампании |
| 11 | Нумерация: 1–11, затем 13 | Отсутствует п. 12; невозможно понять, потерян ли функциональный сценарий | В восстановленной версии использовать последовательную нумерацию и отдельно отметить отсутствие исходного п. 12 |
| 12 | П. 6 | Не задано, что происходит при добавлении товара, которого уже нет в продаже или который недоступен в нужном количестве | Перед добавлением проверять активность SKU и доступность; возвращать отдельные ошибки PRODUCT_UNAVAILABLE и STOCK_LIMIT_EXCEEDED |
| 13 | Все пункты | Не определены пользователь, срок жизни корзины, синхронизация между устройствами и поведение гостевой корзины | Зафиксировать идентификатор корзины, правила авторизации, хранение и объединение гостевой корзины после входа |
| 14 | Все пункты | Не описаны конкурирующие изменения: две вкладки, два устройства или параллельные запросы могут превысить лимиты | Проверять лимиты атомарно на стороне сервера и использовать версию корзины/optimistic locking |
| 15 | П. 7 и п. 13 | Не описано, как цена связана с валютой, скидками, налогами и округлением | Хранить денежные значения в минимальных единицах валюты или decimal, передавать валюту и правило округления; не использовать binary floating point |
| 16 | П. 8 | Не описано, что видит пользователь при пустой корзине, ошибке загрузки или частично удаленном товаре | Добавить состояния empty, loading, error, а также явное сообщение при недоступности товара |
Исходный фрагмент нельзя сделать логически завершенным, не приняв решений, которых в нем нет. Ниже приведен один из возможных вариантов; все добавленные бизнес-правила требуют подтверждения заказчика и вынесены в вопросы раздела 1.3.
Бизнес-правила, которых не было в исходном ТЗ: алгоритм слияния гостевой корзины и отбрасывание непоместившихся позиций (п. 1), порядок расчета скидок, промокода и налога (п. 9), расписание показа рекламы в 09:00 и 18:00 (п. 12), требование аудита (п. 15). Это предложения аналитика, а не решения бизнеса: например, вместо отбрасывания излишка при слиянии заказчик может предпочесть экран разрешения конфликта.
- Корзина принадлежит пользователю или анонимной сессии. При авторизации гостевая корзина сливается с корзиной пользователя по следующему алгоритму:
- Позиции с одинаковым
sku_idобъединяются: итоговое количество равно сумме количеств, но не более 10 единиц; излишек отбрасывается. - Позиции обрабатываются в порядке убывания времени последнего изменения, чтобы при переполнении сохранялись те, с которыми пользователь работал недавно.
- Если после объединения нарушен лимит уникальных SKU или суммарного количества, позиции, не поместившиеся в лимит, в корзину не переносятся.
- Цена объединенной позиции берется из более позднего снимка цены, а момент фиксации обновляется на его значение.
- Если хотя бы одна позиция была отброшена или урезана, сервер возвращает список затронутых SKU, а клиент показывает пользователю сообщение о том, что часть товаров не перенесена.
- Слияние идемпотентно: повторный вход с тем же идентификатором гостевой сессии не изменяет корзину повторно. После успешного слияния гостевая корзина удаляется.
- Позиции с одинаковым
- В корзине может находиться от 0 до 5 различных SKU. Различными считаются разные значения
sku_id; варианты одного продукта с разными характеристиками считаются разными SKU. - Для одной позиции количество составляет от 1 до 10 единиц. Суммарное количество единиц всех позиций составляет от 0 до 20. Ограничения перекрываются и действуют одновременно: 10 единиц одного SKU допустимы, только пока остальные позиции занимают не более 10 единиц. Ни одно из ограничений не отменяет другое.
- При добавлении SKU клиент передает желаемое количество. Количество - целое число. До проверки лимитов сервер валидирует его формат и отклоняет запрос с кодом
INVALID_QUANTITY, не изменяя корзину, если значение дробное, отрицательное, отсутствует, равноnull, передано строкой или имеет иной некорректный тип. Значение0не является ошибкой формата: оно трактуется как команда удаления позиции согласно п. 7. После успешной валидации сервер рассчитывает итоговое количество с учетом уже добавленного SKU и проверяет все лимиты атомарно. - Если операция нарушает хотя бы один лимит, она отклоняется полностью: состояние корзины не меняется. Пользователь получает сообщение «Лимит корзины превышен» и код причины. Лимиты проверяются в фиксированном порядке, и возвращается код первого нарушенного, чтобы при нарушении сразу нескольких лимитов ответ был детерминированным: количество уникальных SKU (
MAX_UNIQUE_ITEMS), затем количество единиц одного SKU (MAX_SKU_QUANTITY), затем суммарное количество единиц (MAX_TOTAL_QUANTITY). - Если SKU недоступен, удален из каталога или недостаточно остатка, операция отклоняется с отдельным кодом ошибки. Сервер не добавляет доступное частичное количество без явного выбора пользователя.
- Количество позиции можно изменить на значение от 1 до 10. Попытка установить 0 считается командой удаления позиции. Позицию также можно удалить отдельной кнопкой.
- При добавлении позиции в корзину сохраняется цена за единицу, валюта и момент фиксации цены. Последующие изменения цены в каталоге не изменяют цену уже сохраненной позиции.
- Расчет стоимости выполняется в следующем порядке, а все денежные значения хранятся в минимальных единицах валюты (копейках) и возвращаются вместе с кодом валюты:
line_total = unit_price * quantityпо зафиксированной цене позиции.- К позиции применяются скидки на товар; результат округляется до копейки по правилу «половина вверх».
cart_subtotalравен сумме округленныхline_total.- К
cart_subtotalприменяются сначала корзинные скидки, затем промокод - промокод считается от уже уцененной суммы, а не от исходной. Промокод пересчитывается при каждом изменении состава корзины и снимается, если корзина перестала удовлетворять его условиям, о чем пользователь уведомляется. - Налог рассчитывается по ставке SKU от стоимости позиции после скидок и включается в отображаемую цену; отдельной строкой показывается только справочная сумма налога.
cart_totalравенcart_subtotalза вычетом корзинных скидок и скидки по промокоду. Промокод учитывается отдельной суммой и не входит в состав корзинных скидок. Доставка в стоимость корзины не входит и рассчитывается на шаге оформления заказа.- Скидки, промокод и налог зависят от актуального состояния каталога и не фиксируются в момент добавления - в отличие от
unit_price. Итог корзины пересчитывается на сервере при каждом чтении.
- На странице корзины отображаются: название товара, изображение, SKU/характеристики, цена за единицу, количество, стоимость позиции, доступность, итог корзины и доступные действия.
- При пустой корзине отображается специальное состояние с предложением перейти в каталог. При ошибке загрузки отображается состояние ошибки с возможностью повторить запрос.
- Рекламный блок в корзине является необязательным. Он возвращается только при наличии активной кампании, подходящей пользователю и экрану. Кампания задает содержимое, период действия, часовой пояс и расписание. По умолчанию рекламный блок может показываться в будни в 09:00 и 18:00 по часовому поясу пользователя, не более одного раза за слот.
- Рекламный блок не меняет состав, цены и лимиты корзины. Переход по рекламе выполняется только по URL, прошедшему серверную валидацию.
- Сервер является источником истины для корзины, ценовых снимков и лимитов. Все операции изменения выполняются атомарно; при конфликте версии клиент перечитывает корзину.
- Сервер ведет аудит добавления, изменения количества, удаления, отказов по лимитам и переходов по рекламным блокам без сохранения лишних персональных данных.
- Корзина доступна гостю или только авторизованному пользователю?
- Как долго хранится гостевая и авторизованная корзина?
- Что происходит при входе пользователя, у которого уже есть корзина на другом устройстве?
- Нужно ли объединять одинаковые SKU или оставлять отдельные позиции?
- Что делать с позициями, которые не поместились в лимиты при слиянии корзин: отбрасывать их, показывать экран разрешения конфликта или сохранять как отложенные?
- Считается ли один
product_idс разными вариантами одной или несколькими разновидностями товара?
- Лимит 10 относится к итоговому количеству SKU или к одной операции добавления?
- При превышении лимита нужно отклонять всю операцию или добавлять максимально доступное количество?
- Разрешено ли добавление нескольких одинаковых SKU из разных карточек/источников?
- Что делать при недостаточном остатке: запретить изменение, уменьшить количество или показать предупреждение?
- Нужно ли резервировать остаток товара в момент добавления в корзину?
- Какие значения количества считать допустимыми: только целые числа? Как система должна реагировать на дробные, отрицательные и нечисловые значения?
- Действительно ли цена должна быть зафиксирована до оформления заказа, и есть ли срок фиксации?
- Учитываются ли скидки, промокоды, бонусы, доставка, налоги и минимальная сумма заказа?
- В какой валюте хранятся и отображаются цены? Как выполняется округление?
- Что происходит, если SKU удален из каталога после добавления в корзину?
- Речь идет о рекламном блоке на экране корзины или о PUSH-рассылке?
- Каковы точные часы «утра» и «вечера» и какой часовой пояс использовать?
- Нужны ли персонализация, частотный лимит, A/B-тесты и возможность отключить рекламу?
- Какие URL разрешены для внешнего перехода и нужно ли вести пользователя во внешний браузер?
- Какие события нужны аналитике: показ, клик, переход, покупка после клика?
- Какие SLA, максимальное время ответа и ожидаемая нагрузка?
- Нужны ли web/mobile-контракты с версионированием и обратной совместимостью?
- Как обрабатываются параллельные изменения корзины с двух устройств?
- Какие требования к логированию, аудиту, персональным данным и удалению данных?
| Сценарий | Ожидаемый результат |
|---|---|
| В пустую корзину добавляют 1 единицу SKU | В корзине одна позиция с количеством 1 и зафиксированной ценой на момент добавления |
| Пользователь устанавливает количество позиции в 0 | Позиция удаляется; в корзине не хранится позиция с количеством 0 |
| В корзине уже 5 разных SKU, добавляется шестой | Операция отклонена, состояние корзины не изменено, код причины MAX_UNIQUE_ITEMS |
| Пользователь пытается установить 11 единиц одного SKU | Операция отклонена с причиной MAX_SKU_QUANTITY |
| В корзине 20 единиц, добавляется еще одна | Операция отклонена с причиной MAX_TOTAL_QUANTITY |
| В корзине 3 SKU по 5 единиц (всего 15), пользователь добавляет 10 единиц четвертого SKU | Операция отклонена с причиной MAX_TOTAL_QUANTITY: лимит в 10 единиц на SKU не нарушен, но суммарный лимит в 20 единиц связывающий |
| Операция нарушает сразу и лимит уникальных SKU, и суммарный лимит | Возвращается код первого нарушенного по порядку проверки - MAX_UNIQUE_ITEMS; ответ детерминирован |
Клиент передает quantity = 2.5, -1, "3" или null |
Запрос отклонен с кодом INVALID_QUANTITY до проверки лимитов; корзина не изменена |
Клиент передает quantity = 0 |
Не ошибка формата: позиция удаляется из корзины |
| Цена SKU изменилась в каталоге после добавления | Цена этой позиции в корзине не меняется |
| SKU удален из каталога или закончился на складе | Новое добавление/увеличение количества отклонено с кодом доступности; уже добавленная позиция явно помечена на экране |
| Два устройства одновременно меняют корзину | Сервер атомарно проверяет лимиты; конфликтующая операция не приводит к превышению лимита |
| Гость с 7 единицами SKU входит под аккаунтом, где у того же SKU 5 единиц | Позиции объединяются до 10 единиц, излишек в 2 единицы отбрасывается, пользователь получает уведомление о неперенесенных товарах |
| Слияние корзин переполняет лимит в 5 уникальных SKU | Переносятся позиции с наиболее поздним временем изменения; остальные не переносятся и перечисляются в ответе |
| Повторная авторизация с тем же идентификатором гостевой сессии | Корзина не изменяется: слияние идемпотентно |
| Промокод перестал удовлетворять условиям после удаления позиции | Промокод снимается, cart_total пересчитывается, пользователь уведомлен |
Экран «Выберите магазин» состоит из заголовка и вертикального списка плашек. Каждая плашка содержит четыре элемента:
- название магазина - основной видимый текст (
METRO,Ашан,ВкусВилл,ВИКТОРИЯ); - логотип на цветной подложке, у каждого партнера своей;
- строку доставки в одном из двух вариантов: «Ближайшая доставка / сегодня 21:00-23:00» либо «Быстрая доставка / от 20 до 60 минут», причем второй вариант выделен акцентным цветом;
- переход на внешний ресурс по нажатию.
Строка доставки - это половина содержимого плашки, поэтому она входит в контракт наравне с названием и логотипом.
GET /api/v1/mobile/partner-stores?city_id=moscow HTTP/1.1
Host: api.petrushka-zelenaya.example
Accept: application/json
Accept-Language: ru-RU
X-Timezone: Europe/MoscowВ задании местоположение не упомянуто - это допущение. Но сроки доставки на макете зависят от зоны, поэтому запрос должен содержать хотя бы один из параметров: address_id, если у пользователя есть сохраненный адрес, иначе city_id. При передаче обоих приоритет у address_id как у более точного. Если не передан ни один и местоположение не удалось определить по токену авторизации, сервер отвечает 400 LOCATION_REQUIRED. В OpenAPI оба параметра помечены необязательными, потому что спецификация 3.0 не выражает условие «хотя бы один»; правило проверяется на сервере и описано в контракте.
Все временные величины ответа - границы слотов, поле slot.date и относительные слова «сегодня»/«завтра» - вычисляются в одном часовом поясе: часовом поясе зоны доставки. Именно в нем происходит доставка, поэтому «сегодня 21:00-23:00» всегда означает вечер того дня, когда курьер приедет по адресу.
X-Timezone передает часовой пояс устройства и служит ровно одной цели: сервер сравнивает его с поясом зоны доставки. Если они совпадают, в value используются относительные слова. Если нет - пользователь в поездке, и «сегодня» на его часах и «сегодня» в зоне доставки могут быть разными днями, поэтому сервер возвращает явную дату («9 июля, 21:00-23:00»). Клиент в обоих случаях выводит value как есть и никакой конвертации не делает. Если заголовок не передан, сервер считает, что пояса совпадают.
| Элемент экрана | Поле ответа |
|---|---|
| Заголовок экрана | data.screen_title |
| Название магазина | name |
| Логотип и его подложка | logo.url, logo.background_color |
| Первая строка доставки | delivery.label |
| Вторая строка доставки (жирная) | delivery.value |
| Выделение строки акцентным цветом | delivery.style = ACCENT |
| Слот доставки для сортировки и аналитики | delivery.slot |
| Интервал экспресс-доставки | delivery.express |
| Нажатие на плашку | external_url и open_mode |
| Порядок плашек | порядок элементов массива items |
Текст строки доставки приходит с сервера готовым к отображению (label, value), а машинные значения (slot, express) передаются рядом. Так клиент не воспроизводит логику форматирования дат, но аналитика, сортировка и локальная проверка актуальности остаются возможны. Цвет акцента задается темой клиента: сервер передает только style, но не hex.
Два варианта строки доставки разведены в схеме через oneOf, а kind служит дискриминатором для кодогенераторов. Смешать варианты нельзя: внутри каждого из них стоит not: required на чужое поле, поэтому объект со slot и express одновременно отклонит валидатор, а не код клиента. Одного oneOf для этого недостаточно - additionalProperties: false не наследуется через allOf, и лишнее поле прошло бы проверку.
{
"data": {
"screen_title": "Выберите магазин",
"items": [
{
"id": "partner-metro",
"name": "METRO",
"logo": {
"url": "https://cdn.petrushka-zelenaya.example/partners/metro/logo.png",
"background_color": "#0A2C6E"
},
"delivery": {
"kind": "NEAREST_SLOT",
"label": "Ближайшая доставка",
"value": "сегодня 21:00-23:00",
"style": "DEFAULT",
"slot": {
"date": "2026-07-09",
"starts_at": "2026-07-09T18:00:00Z",
"ends_at": "2026-07-09T20:00:00Z"
}
},
"external_url": "https://partner.example/metro",
"open_mode": "external_browser"
},
{
"id": "partner-auchan",
"name": "Ашан",
"logo": {
"url": "https://cdn.petrushka-zelenaya.example/partners/auchan/logo.png",
"background_color": "#FFFFFF"
},
"delivery": {
"kind": "NEAREST_SLOT",
"label": "Ближайшая доставка",
"value": "сегодня 18:00-20:00",
"style": "DEFAULT",
"slot": {
"date": "2026-07-09",
"starts_at": "2026-07-09T15:00:00Z",
"ends_at": "2026-07-09T17:00:00Z"
}
},
"external_url": "https://partner.example/auchan",
"open_mode": "external_browser"
},
{
"id": "partner-vkusvill",
"name": "ВкусВилл",
"logo": {
"url": "https://cdn.petrushka-zelenaya.example/partners/vkusvill/logo.png",
"background_color": "#FFFFFF"
},
"delivery": {
"kind": "EXPRESS",
"label": "Быстрая доставка",
"value": "от 20 до 60 минут",
"style": "ACCENT",
"express": {
"min_minutes": 20,
"max_minutes": 60
}
},
"external_url": "https://partner.example/vkusvill",
"open_mode": "external_browser"
},
{
"id": "partner-victoria",
"name": "ВИКТОРИЯ",
"logo": {
"url": "https://cdn.petrushka-zelenaya.example/partners/victoria/logo.png",
"background_color": "#C4E538"
},
"delivery": {
"kind": "NEAREST_SLOT",
"label": "Ближайшая доставка",
"value": "сегодня 17:00-19:00",
"style": "DEFAULT",
"slot": {
"date": "2026-07-09",
"starts_at": "2026-07-09T14:00:00Z",
"ends_at": "2026-07-09T16:00:00Z"
}
},
"external_url": "https://partner.example/victoria",
"open_mode": "external_browser"
}
]
},
"meta": {
"request_id": "req-01J7EXAMPLE9K2",
"generated_at": "2026-07-09T09:00:00Z"
}
}Ответ сформирован в 09:00 UTC, то есть в 12:00 по Москве: все слоты в примере еще не наступили. Сервер не возвращает истекший слот - «ближайшим» всегда становится следующий доступный, при необходимости на завтра.
Полный контракт - в api/partner-stores.openapi.yaml, ответ со всеми четырьмя магазинами макета - в api/partner-stores-response.json.
- Если для зоны срок доставки неизвестен, поле
deliveryне передается вовсе, и плашка показывается без строки доставки. Отсутствие поля предпочтительнееnull: клиенту не нужно различать «нет данных» и «пустая строка». - Пагинация не добавлена: список партнеров в одном городе исчисляется десятками и умещается в один ответ. При росте каталога добавляется курсорная пагинация без ломающих изменений.
- Авторизация не обязательна для просмотра списка, но при наличии токена сервер может подставить
address_idпользователя сам. В контракте это выражено схемойbearerAuthи объявлениемsecurity: [{}, {bearerAuth: []}]: пустой объект разрешает анонимный вызов, второй вариант - вызов с токеном. open_modeобъявлен перечислением с единственным значениемexternal_browser. Булев флаг пришлось бы заменять при появлении второго способа открытия (например,in_app_webview), а расширение перечисления обратной совместимости не ломает, если клиент игнорирует плашки с неизвестным значением.- Внешняя ссылка формируется и allowlist-проверяется на сервере; клиент открывает только HTTPS-ссылку в системном браузере.
- Названия и логотипы партнеров в примере взяты из макета и служат демонстрацией; домены
.exampleне являются рабочими.
Верхнеуровневая схема и пояснения находятся в architecture/push-notifications.md.
Основной поток:
- Мобильное приложение получает push-токен у APNs/FCM и регистрирует его в Token Service.
- Бизнес-микросервисы публикуют доменные события через outbox в Kafka: отмена заказа, неактивность корзины, изменение статуса доставки.
- Marketing/Campaign Service запускает рекламные кампании, а Scheduler создает события для отложенных сценариев. Сегмент кампании разворачивается в отдельные команды отправки батчами: Audience Service отдает
user_idстраницами, Fan-out Workers публикуют их в Kafka с ключом партиционированияuser_id. - Notification Orchestrator читает события, проверяет настройки пользователя, частотные лимиты, дедупликацию и шаблон сообщения, после чего запрашивает у Device Token Service список активных устройств пользователя.
- Provider Adapter отправляет уведомление на каждый активный токен в APNs или FCM и нормализует ответ провайдера. Исходящий поток удерживается в пределах квот провайдера отдельным лимитером; при их исчерпании сообщение уходит в Retry Queue, а не теряется.
- Временные ошибки уходят в Retry Queue и возвращаются в адаптер воркером с backoff; постоянные ошибки и исчерпанные попытки попадают в DLQ, а невалидные токены деактивируются в Device Token Service.
Обязательные свойства решения:
- идемпотентность на двух уровнях: решение о рассылке по
(event_id, notification_type, user_id), отправка по(event_id, notification_type, device_id); - отдельные Retry Queue с воркером и backoff и Dead Letter Queue для постоянных ошибок;
- отсутствие повторной отправки после успешного ответа провайдера;
- разворачивание рекламных кампаний батчами с партиционированием по
user_id, квотами провайдера и приоритетом транзакционного трафика над рекламным; - учет часового пояса, opt-in/opt-out и quiet hours;
- шифрование токенов, минимизация PII и аудит действий;
- метрики принятия провайдером, открытия, ошибок и задержки.
- Состав полей ответа выведен из макета: заголовок экрана, название магазина, логотип с подложкой, строка доставки в двух вариантах и переход по внешней ссылке. Названия и цвета подложек взяты из макета как демонстрационные и не являются реальными данными каталога партнеров.
- Строка доставки формируется на сервере как готовый текст, потому что правила «сегодня/завтра», округление интервалов и локализация относятся к бизнес-логике, а не к отображению. Машинные поля
slotиexpressпередаются рядом для аналитики и сортировки. - Единственным часовым поясом для расчета слотов, поля
slot.dateи слов «сегодня»/«завтра» выбран часовой пояс зоны доставки, а не устройства: доставка происходит в зоне, поэтому «сегодня» должно означать день приезда курьера.X-Timezoneиспользуется только для того, чтобы обнаружить расхождение и в этом случае вернуть явную дату вместо относительного слова. - В API используются демонстрационные домены
.example; в рабочей системе URL должны приходить из конфигурации/каталога партнеров и проходить allowlist-проверку. - Пагинация в контракт не включена: список партнеров в пределах одного города умещается в один ответ. Авторизация не обязательна - экран доступен до входа, а токен лишь позволяет серверу подставить адрес пользователя. Оба решения меняются без ломающих изменений контракта.
- Для шины событий выбран Kafka: она подходит для надежной публикации доменных событий, повторного чтения и независимой подписки нескольких потребителей. Точный выбор брокера должен быть подтвержден эксплуатационными требованиями.
- Статус
ACCEPTED_BY_PROVIDERозначает, что APNs/FCM приняли запрос, но не доказывает показ уведомления на устройстве. Открытие фиксируется отдельным событием мобильного приложения. - Размер аудитории рекламной кампании в задании не задан. Схема рассчитана на миллионы получателей: сегмент разворачивается батчами, а не одним запросом. Для небольшой аудитории те же компоненты работают без изменений.
- Цены в корзине фиксируются согласно п. 7 исходного ТЗ; исходный п. 13 помечен как противоречащий и исключен из исправленной версии.