Vizen Shop

Promotions, combo sets, coupons and promo codes

/docs/promotionscurrentМашинный перевод с английского· проверено 2026-08-22

Статус: current · проверенный: 2026-08-22, full Интеграция против 18 изолированный PostgreSQL базы данных (46 интеграционных данных) files) · владелец: гладиатр API документация Служить: GET /docs/promotions (live: 200 text/markdown) · Машинный справочник: promotions.BuildPromoReference() — Типы, kind × class Матрицы, лестницы, Отклонить причины, округление, договор field sets и limits Все они происходят из одного и того же константы, валидатор и движок, на которых работает сервер.

Каждый из перечисленных ниже списков находится между <!-- gate:… --> маркеры, и reference_test.go Сравнение с set в document против ссылки — Список здесь cannot Что проверяют ворота, так это факт, а не то, что они заслонились. В тексте: The set коды, то есть set из fields, тот order из steps. Проза Между маркерами - ваше, чтобы переписать.

1.Что вы можете сделать здесь

Читать, что такое правила a shop имеет, создает и редактирует их, прикрепляет промо-коды к rule, несколько правил с одним бюджетом кампании, имеют shop Предоставить личный купон для покупки или вручить одному поименованному покупателю самостоятельно, применить code Покупателю's cart, Читайте о ценах, которые.

Сервер владеет всеми price: клиент never Отправляет денежные суммы на cart или тот order, И скидка, которую вы вычислите сами, не выживет. checkout. Это стоит интернализировать before Писать здесь все что угодно — большинство ошибок в этой области приходят от попытки воспроизвести арифметику на клиенте.

Одно и то же рассуждение дает вам два готовых ответа вместо вычисления: shop's - полная скидка с уже ранжированными предложениями (§3.7), а также price из одного product внутри а combo set на его card (§3.8).

2. Решать first (вилки)

Если ты хочешь…Возьми это pathСтоимость
А. price Уменьшение применяется сам по себеpromotion с class item или orderНичего. extra. Показанный на cards и в том, cart автоматически.
А. price урезать покупателя типы вpromotion с class: "key" + один или один more promo_keysThe rule Молчите до тех пор, пока code это applied. Два объекта, которые нужно создать вместо одного.
Скидка для одного человека (Лояльность, извинения, награда)один Покупатель, прямо сейчас → POST /promo-keys с owner_user_id (§4.10); Все кто квалифицируется a promotion из kind: "issue_key", который дает купон, когда order Завершено (§4.6))В любом случае купон's Скидка описывается отдельной class: "key" rule тот key Разблокировки — написаны один раз, повторно используются каждым купоном, который указывает на это. code: разделяемый code cannot Ограничьтесь одним покупателем надежно.
Автоматический promotion Только для покупателя roleset Promotion.audience_id для многоразового использования shop audienceПлатформа account остается глобальным, в то время как членство и роли клиентов изолированы в зависимости от их потребностей; shop. Гости, заблокированные клиенты и покупатели без согласования role Не получить и не обнаружить promotion.
А. ограничение расходов более чем несколько promotions ("Мы выдали миллион долларов")кампания (/promo-campaigns) с budget_kind spend или countПравила keep Работать до тех пор, пока бюджет не иссякнет, тогда они остановятся сами. Ограничение на кампанию, а не на rule.
А "3" items для price из 2" style bundlepromotion kind: "bundle" (а rule)Цены остаются на единицу-product; Скидка приземляется на участвующих линиях.
А. set Продается как вещь с его own page и фотоа combo product (product_combo_items) — смотреть catalogue.mdЭто а product, Не скидка: у него есть own card, его own price, один cart line.
«Что продается»" page или Лучшие предложения blockGET /promotions/landscape — Серверные ряды (§3.7))Один звонок. Альтернатива - читайте правила и играйте products Для себя – значит, реализуя item → order → key лестница, и несогласие с cart на first product Два правила касаются.
А. "в тройке , в тройке ," line на product cardin_combos[].item_price / item_regular_price / item_times_cheaper_x100 (§3.8)Ничего. extra, Цифры уже готовы. own Разметка это единственный вариант: замещения не имеют выражений, поэтому ничто там не может разделить одно. price другим.
Покажи промо price в платформа widgetsНичего не делать — работает сегодня—
Покажи промо price в твой own разметкасерверная цена scope fields — /docs/own-markup §2 и §8 нижеДоступно; используйте готовое fields, never пересчитать.

Последний row Это вилка, которая стоила целое восстановление один раз. own-markup.md before Вы решили сдать-render цены.

3. Объекты и где они находятся live

3.1. rule — /promotions

GET    /promotions            list of this shop's rules      → promotions:read
GET    /promotions/{id}       one rule (foreign → 404)       → promotions:read
POST   /promotions            body {"item": {...}}           → promotions:write
PUT    /promotions/{id}       body {"id":…, "item": {...}}   → promotions:write
DELETE /promotions/{id}       body {}                        → promotions:write

Правила проходят через draft; Ключи и кампании не. А. rule написанный из тот draft contour — а draft (dev) API key, Или кабинет перешел на DEV — земля в shop's draft: тот draft витрина (<slug>--dev) цены cards с ним, тот live shop Не видя его, пока POST /v1/dev/publish, и перекатывая это Публикация возвращается off тот live shop Ключи (§3.2) и кампании (§3.3) перевозить live счетчики (used, budget_used), чтобы они оставались live и являются написанный с помощью a full-доступ key только; а key прикреплен к а rule существует только в тот draft cannot быть created еще (PROMOTION_NOT_FOUND) — publish тот rule first. Карт и checkout always использовать live правила; /promotions/landscape последующий contour Правила (§3.7)).

А. rule У него есть две независимые оси. Запутывание их - наиболее распространенное явление. Ошибки в этой области:

  • `kind` — *что* делает: product_discount, order_discount, bundle, gift, и issue_key — Что не является скидкой, см. таблицу ниже.
  • `class` — *когда* он стреляет: item (за line) → order (целое order) → key (Только когда code это applied). пустой class означает " class это kind иметь before Существовали классы": order_discount → order, все else → item.

Внутри одного class Только лучшие из лучших rule Классы стекаются в стеке order выше. class: "key" В настоящее время требуется kind: "order_discount" — тот key шаг читать Ан order-имеющий форму condition, и другие комбинации отклоняются в письменной форме.

Три класса являются first три steps из одного пятиступенчатая лестница. Другой Два не являются правилами и не имеют class, который является именно why Они забываются:

Шаг впередЧто происходитПочему это важно для вас
itemЛучшее на каждый-line rule (product_discount или уволенный bundle) за lineЭто единственный шаг, который затрагивает unit_price
orderлучший order rule, вычисляется на subtotal after itemа min_subtotal 6 000 человек не будут стрелять в cart Это стоит 5 000 after а line скидка
keyтот applied code, вычисляется на subtotal after order; порог измеряется там, где order's это, after itemчерез default тот base Исключает линии, которые уже получили скидку (applies_to_discounted: false)
shop_capтот shop-Широкий пограничный охранник (max_total_discount_percent) отделка top-вниз: тот code first, затем order скидказа-line Скидки есть never Снято с производства — см. §4.7
giftgift Линии материализуются последнимиОни не находятся в subtotal, Нет порога и нет applied[]

⚠️ `condition` и `effect` есть `bytes` на проволоке — основание 64 сырого JSON, не Это один из наиболее распространенных 400 в этой области. Форма зависит от kind; unknown ключи отклоняются, а не ignored:

kindclassconditioneffect
product_discountitem{product_ids?, category_ids?, excluded_product_ids?} (empty = все products){percent} XOR {amount} (в расчете на единицу)
order_discountorder, key{min_subtotal?, min_items?} (порог измеряется after за-line скидки){percent} XOR {amount}, плюс optional {max_discount}
bundleitem{sets: [{product_ids?, category_ids?, min_qty}]} — каждый set должны быть удовлетворены, и каждый set Необходимо хотя бы одно не-empty id список{percent} XOR {amount} на участвующих линиях
giftitemто же самое, что bundle{gift_product_id, quantity}
issue_keyitem{min_subtotal?, min_items?} — измеряется тем, что было на самом деле paid{grant_promotion_id, valid_days} — тот class: "key" rule Выданный купон разблокирует

А. bundle или gift set с product_ids: [] и category_ids: [] отказано на бумаге, не хранится и тихо matched Ни против чего. class колонка является вся матрица: любая другая комбинация — 400 — эталон машины несет как Kinds[].Classes, и ворота test Переигрывает каждую пару через реальную Валидатор, вот эта колонка cannot Стать желанием.

Флаги совместимости на rule: stack_with_key (default true; false Промо-коды не применяются, пока это rule пожары), exclude_combo (default false; пропустить combo products — на order скидка combo линии count ни к min_items/min_subtotal ни в какой percent base), applies_to_discounted (class key только, default false: тот code вычисляется только из строк без-line скидка).

audience_id является optional ссылка на shop's многоразовый покупатель audience. Опущенный на создание средств public. В редактировании сохраняется current связывающий, Старший клиент cannot publish частный promotion переименовывать его; явно 0 делает public. А. foreign/deleted audience Отклоняется вместе с PROMOTION_AUDIENCE_INVALID. Фильтрация аудитории происходит в репозитории before Правила достигают ценового двигателя и повторяются для cards, список, marketplace/wishlist Проекции, гостевая цитата, подпись cart, рекламный ролик-code Поиск, выпуск купонов и checkout. The owner-только список и landscape Покажи еще Все правила управления. customer_audiences_enabled shop Флаг есть Закрытое: пока off, только public promotions применять.

Ни один из этих трех флагов не достигает gift, И это то, что structural Вместо того, чтобы

  • Проверка. gift Это не скидка: двигатель материализует его как виртуальный.

line *after* тот key Шаг, так что его value в ней нет subtotal, В отсутствие порога, в applied[], и ни в одном из base рекламное объявление code Можно вычислить из - с applies_to_discounted или off. stack_with_key: false на gift rule Поэтому ничего не меняет (§ 5). order Вот что делает этот безопасный: Компьютерные подарки на шаг раньше сделают каждый gift Убей каждое рекламное объявление code в тот shop, потому что gift line не несет stack_with_key его own.

Денежный страж: а class: "key" rule с {amount} должен перевозить condition.min_subtotal строго более чем это amount, В противном случае писать купон стоимостью 10 000 без пороговых рук на товары для free.

3.2. key — /promo-keys

А. key не define Скидка; он открывает rule. Промо code и купонами являются Один объект: a key без owner Это совместное промо code, а key с ан owner это Купоны появляются двумя способами, и оба заканчиваются одним и тем же. row из Та же таблица: платформа предоставляет один для завершенной покупки (§ 4.6) или продавец передает один покупателю по имени (§ 4.10)).

GET    /promo-keys?promotion_id=&with_coupons=   → promotions:read
POST   /promo-keys      body {"item": {...}}     → promotions:write
PUT    /promo-keys/{id} body {"id":…,"item":{…}} → promotions:write
DELETE /promo-keys/{id} body {}                  → promotions:write

PromoKey — Вся эта field set: id, promotion_id (а class: "key" rule, неизменный after создание), code, owner_user_id, expires_at, usage_limit (0 = неограниченное), per_customer_limit, active, и только для чтения used, created_at, issued_by_order_id. id и promotion_id Прибытие как струны (int64 на проводе).

owner_user_id Вся разница между двумя видами key: 0 (или Опущено) создает совместное рекламное code, настоящий account id Создаем персональный купон для этого account. Это Проверено на создание — ан id Который никому не принадлежит Отказ, а не хранение, потому что купон никто не может выкупить все еще занимает свое место. code в shop — и Неизменный впоследствии: Отправить обратно без изменений на PUT, и move купон другому лицу удалить его и выдать new Один. Купон. created без limits получать usage_limit: 1 и per_customer_limit: 1; разделяемый code не получает ни.

Коды бывают matched бесчувственно-белое пространство (upper(trim())), и являются Уникальный по shop.

3.3 Кампания — /promo-campaigns

Кампания является ограничение расходов по нескольким правилам: "Мы выдали миллион долларов Когда бюджет выходит из-под контроля, его правила перестают применяться сами по себе..

GET    /promo-campaigns          → promotions:read
POST   /promo-campaigns          → promotions:write
PUT    /promo-campaigns/{id}     → promotions:write
DELETE /promo-campaigns/{id}     → promotions:write

PromoCampaign: name, budget_kind (none | spend | count), budget_limit (0 для none), active, starts_at?, ends_at?, и только для чтения budget_used, budget_warned (true 80% - вы хотите знать before Он останавливается, а не after) и promotions_count.

Прикрепить rule устанавливать Promotion.campaign_id. Удаление кампании делает не отключить его правила — он снимает крышку.

Две вещи, которые стоит знать before Вы полагаетесь на него:

  • Бюджет растет за счет каждый applied rule кампании, а не только из промо-коды;
  • Только промо-code Шаг может быть cancelled И только он откажется от order Когда бюджет короткий (CAMPAIGN_BUDGET_SPENT). Для обычного promotion тот order Пройдет еще - покупатель уже видел, что price — так Бюджет может перерасходовать максимум на одну продажу, а следующую orders Больше не вижу rule.

3.4 Покупатель's купоны — /my-coupons

GET /my-coupons?company_id=&include_used=   → the buyer's own coupons only

MyCoupon — Вся эта field set: id, code, name и description (взятый от rule купон разблокируется — покупатель читает ту же формулировку, которую увидит в cart), expires_at?, used, expired, issued_by_order_id. Кто-то else's Купон не указан и не может быть найден. issued_by_order_id: 0 означает купон был не предоставленная покупкой — продавец передал ее (§ 4.10); не render "для order #0".

3.5 Покупатель side — /cart/promo, и /cart/quote before login

POST   /cart/promo    {"company_id":…, "code":"SUMMER25"}   → the whole cart
DELETE /cart/promo    {"company_id":…}                      → the whole cart
POST   /cart/quote    {"company_id":…, "items":[…], "promo_code":"SUMMER25"}
                                                            → the whole cart, no token

Оба return то же самое body как GET /cart/items. Два fields вопрос: result.promo_code (applied code, empty если none) и result.promo_rejected — стабильный reason code, не message:

NOT_FOUND · EXPIRED · EXHAUSTED · PER_CUSTOMER · RULE_INACTIVE · BELOW_THRESHOLD · NO_ELIGIBLE · NOT_STACKABLE · NO_EFFECT · BUDGET_SPENT

Авторитетный список формируется — promotions.BuildPromoReference().RejectReasons — и ворота test Если сервер посылает code это document не знают, или если document Обещает, что code сервер never Этот чек существует потому, что Обе ошибки уже произошли здесь..

The first Четыре и последние шесть Различные виды отказов, и интерфейс должен сказать, что (RejectReasons[].stage): NOT_FOUND, EXPIRED, EXHAUSTED и PER_CUSTOMER являются приговорами по code Сама по себе — cart cannot Остальные шесть - от двигателя и это приговоры по делу. это cart: "Добавить 1 500 more" Это честный совет, " code является недействительным" не.

Отказ является не ан HTTP error: тот cart это always Вернулся, значит покупатель И видеть их постоянно items. Иностранный личный купон ответ NOT_FOUND на Цель — различимая reason Подтвердится, что code существует.

Гость слышит одни и те же ответы.. POST /cart/quote (orders.md §4.4. принимает promo_code Без галочки и заполняет то же самое promo_code / promo_rejected Пары из одного и того же code path, Приговор по делу о code Не меняется при login — Это точка конечной точки: посетителю не нужно входить, чтобы найти Если бы не code Из бюллетеня работает. Два последствия для design для:

  • тот code это показанный, never потраченный. Цитата не движется ни promo_keys.used ни promo_redemptions, Сколько бы раз это ни называлось, искупление происходит внутри. тот checkout Сделка и нигде else. А. "code applied" Поэтому экран не оговорка — последнее использование может быть кем-то else, и expected_total в checkout Что защищает покупателя от выяснения молчаливо (§3.6));
  • а личный ответы на вопросы (§3.4) NOT_FOUND Нет покупателя, нет гостя. соответствовать owner_user_id Против, и маска та же самая, иностранный купон Скажи "подпиши, чтобы использовать это". code", не "недействительным" code" — посетитель Для них ношение купона – это единственный человек, для которого маска является вводящий в заблуждение.

3.6 Проверка

POST /cart/confirm принимать expected_total — Сумма, которую покупатель видел. пересчитанный order Различаются (а) promotion Последним использованием а code был взят кем-то else), Звонок не справляется с PRICE_CHANGED вместо того, чтобы спокойно Создавая order В другой раз price. Оставь это себе keep Старое поведение (0 Сравнение с любой другой суммой. more Отказы могут быть получены здесь, оба FailedPrecondition: PROMO_CODE_EXHAUSTED (тот code между ними был установлен cart и order) и CAMPAIGN_BUDGET_SPENT (Бюджет кампании иссяк в том же окне).

The order Возвращаясь, делает не распространение order-level Скидки на его Прочитайте §3.9 before Вы суммируете что-либо на нем.

3.7 Вся картина в одном звонке — GET /promotions/landscape

GET /promotions/landscape?limit=24     → promotions:read

Ответы "Что продается в этом shop "Прямо сейчас" без твоих правил products. The shop Это тот, кто стоит за токеном — есть нет `company_id` параметр и нет public variant: Это - cabinet/agent конечная точка и токен Без компании получить PermissionDenied.

result:

ПолеЧто это такое
promotions[]active Правила, которые пожарят сами по себе — то же самое Promotion объекты /promotions возврат
code_promotions[]active правила class: "key". Отдельно по назначению: печатание их в качестве скидки обещает покупателю price Они не будут уходить без того, чтобы не набрать code
top_offers[]предложения, ранжированные по серверу, см. ниже
scanned_productsСколько опубликовано products Фактически они пробегали через двигатель.
truncatedtrue когда сканирование ударило по потолку — может быть скидка products это never смотреть

PromoOffer: kind (product | combo), product_id, name, slug, price, old_price, benefit (деньги), benefit_percent (целое percent, округленный DOWN — а benefit Покупателю это обещание, и его округление будет Рекламировать скидку The shop не дает), promotion_name (тот rule который дал порез; empty для combo). preview заполнены: product's own preview Если он имеет один, то в противном случае first image Галерея — то же самое rule тот card и списки используют, так что те же product везде та же картина. Это empty Только когда product иметь И фотографии тоже. resolved after рейтинг сократился, поэтому большой каталог стоит одного пакет за предложения фактически возвращены, не за все отсканированные.

Рейтинг является детерминированным и принадлежит серверу.: benefit_percent desc → benefit desc → product_id ac. % leads Потому что «-50%» — это предложение., Если «-5 000 ⁇ » без base Ничего не значит.. limit 0...100 (0 или опущено = 24); выше 100 → 400, является валидированным field, не зажим).

Как строится список, потому что он объясняет каждый сюрприз ниже: products есть не selected через SQL "где есть скидка" - скидка производится по движок, не хранящийся в столбце. Сервер принимает опубликованные products в новенькие, новейшие first (ORDER BY id DESC), И каждый проходит через одно и то же Двигатель, который ценит product card. Потолки: 500 products и 200 combo sets. Они сообщают (scanned_products, truncated) Потому что молчаливый порез Это будет читаться как "это" shop ничего не имеет else в продаже".

Что? never появляется в top_offers, Чтобы не ходить на охоту:

  • правила class order или key — Предложение является одним из-product price, и тех, и тех Два вычисляются против целого. cart;
  • kind: "issue_key" Правила — они отсутствуют оба rule Списки также: Купон, предоставленный на завершенном order Это выпуск, а не price кто кого резать Сегодня можно увидеть;
  • неопубликованным или deleted products, и combo sets с недоступной составляющей или без benefit вообще.

Один more Что это такое? true Сегодня и не стоит ничего знать: в draft contour Правила исходят из draft, тот products от live shop. А. draft (dev) key или кабинет в DEV Видит его draft Правила в promotions[] / code_promotions[] и в предложениях; products created Только в том, draft не являются сканированный.

Предложение не должно исходить от rule. The test это "old_price выше price", так product Продавец, помеченный рукой (его own base_old_price) Это тоже предложение, с empty promotion_name — Что именно является его card И шоу тоже. product предложение, price / old_price байт за байтом, то есть card's Номера: тот же двигатель, тот же base. А. combo Предложение проходит через отдельный path, И обе они теперь измеряют «сумму частей» одинаково — см. ниже.

Два дефекта этого section Предупреждение о том, что fixed (2026-08-20, совершить ac88c77), и warnings Они уезжают с ними. Они стоят одного абзаца. Потому что оба они были kind что читается как правильное data:

  1. тот rule Списки фильтровались active только бюджет, а значит и rule чей ends_at Вчера было объявлено, что live. Списки теперь работают В этом же году двигатель выдает - а rule Это закончилось или не закончилось Начало, не в списке.: TestB5Landscape_ExpiredRuleIsNotAdvertisedAsActive;
  2. а combo предложение измеряемой «суммы частей» от base Цены пока что card измеряется от выставка цены, поэтому дисконтированная составляющая сделала landscape рекламировать set Это было more Это дороже, чем покупать запчасти отдельно.. Оба теперь измеряют его по витринным ценам.: TestB5Landscape_ComboBenefitMatchesCardWhenComponentIsDiscounted.

3.8. price из одного product внутри set — in_combos[].item_*

Над product card (GET /products/{id}, GET /products/slug/{slug}) in_combos[] Список sets это product Рядом с ним. set-level combo_price / components_total / benefit, три fields описание это product Внутри этого set:

Полезначение
item_priceчто это такое product Расходы внутри set, единица — его price_mode / price_value applied в base price (inherit → base, fixed → value, percent → этаж(base·(100−p)/100), free → 0)
item_regular_priceСколько это стоит на его own Сейчас — витрина price, i.e. after за-item promotions
item_times_cheaper_x100item_regular_price / item_price × 100. 250 = "2.5 х дешевле". 0 = Не говорите о дешевых
other_items[]тот отдыхать из этого set — каждый item кроме этого product, в составе order, Каждый из них product_id, name, preview и quantity. Пусто для одного-item set

other_items[] Ответы "Что делать" I «Сделай это», который ничего не else Могло: set's own preview Это картина того, set, не в своих частях. Он не несет Цена нарочно — деньги уже есть item_* (это product) и combo_price / components_total (тот set); Третья фигура рядом с ними приглашает Для этого нужно собрать все части вручную, а также собрать сумму, сделанную вручную. cart.

Почему fields Арифметика: замещения в own Маркировка не имеет выражений — {{ }} cannot разделить или compare — "включительно с "включительно "" line cannot быть написаны любым другим способом; и второе вычисление на витрине магазина будет дрейфовать от cart на обогревающем, потому что в-set price Серверный этаж-side.

item_times_cheaper_x100 это 0 В тех случаях, когда «дешевле» будет Ложь: в-set price не ниже обычного, либо price равна нулю, или Компонент является price_mode: "free" — а gift Не имеет отношения. gift из item_price == 0, Не из соотношения.

The value Целое число, масштабируемое на 100 по целевому назначению (деньги и акции являются целыми числами) Агент, который генерирует разметку, читает ее и пишет формулировку один раз., в момент строительства; а template Заменить можно только место item_price и item_regular_price ("? n ⁇ M ⁇ ) и рассматривать отношение как сигнал, не как text — Ничто на витрине не переформатирует сотые для вас.

Одна асимметрия, которую стоит помнить: тот shop owner видеть sets без benefit в все (benefit может быть отрицательным — а promotion Компоненты, которые их делают Это более дешево, чем вместе. Это преднамеренно: admin Список "Вхспорт в ке" Он должен точно показать sets которые были нарушены. public посетитель never Посмотреть их. Так "in_combos не является-empty" Это не значит, что есть benefit" Когда звонящий тот shop.

3.9 Чтение денег на order

Ан order line это snapshot Что такое position стоимость — Факт сделки, Не бегать total. Order-level steps (ан order Скидка и промо code) уменьшать items_total но являются не Распространён по линиям. first вещь Каждый импортер — сумма line_total — возвращает обратно total *before* тот code, и Ничего в этом payload кричать об этом.

Это преднамеренно. Переписывая line snapshot after Этот факт переписывает price Покупатель согласился использовать указанные ниже идентификационные данные; они держат все, что угодно. Сочетание механики выстреливает.

Рассказывая steps отдельно — через kind внутри promotions[]:

Вступлениезначение
kind: "order_discount", code emptyorder скидка — не В линиях
kind: "order_discount", code setрекламный ролик code / купон — не В линиях
любой другой kind (product_discount, bundle)за-line — Внутри каждого из них line's unit_price / line_total

kind надежный дискриминатор, а не совпадение: валидатор отказывается class: "order" и class: "key" на любой kind но order_discount, такой order-level Вход может never приходить в ношении product_discount или bundle.

Рассказывать gift отдельно — promotion.kind == "gift" на line. А. gift иметь unit_price: 0, line_total: 0, и base_unit_price удерживая его *value* — а number который появляется в нет total из order. Исключить gift строки из каждого Сводка ниже. cart Об этом говорится в посвященном is_gift флаг; order делать Пока еще нет ни одного.)

Позволь R = Линии, которые не являются подарками, K = promotions[] входы с kind == "order_discount", I = оставшаяся promotions[] ввод.

#ИдентичностьЧто вам дает
1Σ_R line_total − Σ_K amount == items_totalЕдинственный правильный способ добраться до товара total after Все скидки с линий; сумма, подлежащая выплате, total (+ delivery и надбавка, /docs/orders)
2Σ_R (base_unit_price or unit_price) × quantity == items_total + discount_totalтот total before Скидки на любые. order не имеет subtotal_before В общем и целом, и на cart это field не из того, из чего следует читать, одна формула служит обоим контурам.
3Σ promotions[].amount == discount_totalтот promotion snapshot Счета для скидки без остатка
4Σ_R promotion.amount == Σ_I amountпер-rule Соответствует агрегации per-line акции

base_unit_price: 0 означает "нет"-line Скидка здесь, а не price был нулевой" — вернуться назад unit_price.

The total before Скидки — это сумма, которую вы вычисляете, а не сумма, которую вы рассчитываете. field Ты читала. На одном order Это items_total + discount_total (удостоверение личности 2); на cart, то же самое вещь subtotal + discount_total. The cart нести в себе field из этого name, И это неправильно number Пройти через: сервер заполняется subtotal_before При этом он вычисляет скидки, а shop без live promotion never Прогоняет В любом случае, такой шаг — cart может ответить subtotal_before: 0 с реальными товарами в нем, и витрина, которая верит, что она пробьет ноль и рекламирует "-100 %". The field начинает нести number момент момент shop имеет один active rule, даже Вот это cart не совпадает, поэтому следует за продавцом's settings Вместо того, чтобы cart's Содержание: "Правильно, когда I Проверено" ничего не говорит о следующем shop. Сумма этих двух fields Он есть на каждом сервере, и он единственный. form тот order side предложение вообще — так что напишите формулу один раз и используйте ее в обоих местах.

Пример работы (-15% на одном из них) position, −10% на данную order, −10% от code):

{ "items_total": 3130, "discount_total": 870,
  "items": [
    {"product_id":1,"unit_price":850,"base_unit_price":1000,"quantity":2,"line_total":1700,
     "promotion":{"id":7,"kind":"product_discount","amount":300}},
    {"product_id":3,"unit_price":2000,"base_unit_price":0,"quantity":1,"line_total":2000}
  ],
  "promotions": [
    {"id":7,"kind":"product_discount","amount":300},
    {"id":8,"kind":"order_discount","amount":370},
    {"id":9,"kind":"order_discount","amount":200,"code":"SUMFIX"}
  ] }

3700 − (370 + 200) = 3130. Подводя итоги, строки сами дают 3700 — 570 more чем order стоит.

Нет никакого per-line доля в одной order Скидка в договоре, нарочно. Платформа вычисляет один, но только для фискальной квитанции, где закон Требуется сумма позиций, чтобы соответствовать amount Заряженный на копейку: это пропорциональный line_total, с остатком округления, помещенным в последний ненулевой position. Нужен такой раздел и вычислите его таким же образом, и рассматривать его как производное: разная округлость дает разные числа, и только 1-4 согласны с обеими сторонами.

Ан order Замораживание каждой суммы checkout и является never когда пересчитываются правила Меняться после этого, так что эти личности все еще остаются в силе год спустя. currency единицы (рубли, а не копейки)).

проверенный internal/api/catalog/promo_order_sums_integration_test.go (TestB5Sum*): рекламное объявление code плюс order Скидка, а gift line, и а shop маржинальная квота, которая урезает order шаг.

3.10 Округление — куда идет фракция

Деньги здесь целые currency единиц, так что каждый процент должен приземлиться где-то. The rule Не везде одно и то же, и разница в точности одна единица. — невидимый в договоре, видимый в cart. Вычисление a price на витрине магазина С «очевидным» округлением это как page и cart Начните цитировать два числа Для того же item.

ПравилоРежимЧто такое округлаяГде?
line_percentполуразвитиетот price Всякое, что есть, то и остается.promotions.perUnitDiscount
order_percentполуразвитиетот скидка на orderpromotions.orderDiscountAmount
key_percentполуразвитиетот скидка из промо code — Тот же помощник, что и order шагpromotions.cappedAmount
shop_cap_percentвнизтот надбавка тот shop терпеть (max_total_discount_percent), Не сама скидкаpromotions.clampByShopCap
combo_component_percentвнизтот price компонент внутри a set, от своего base pricedomain.EffectiveComponentUnitPrice
benefit_percentвнизтот percent напечатанный на предложенииcatalog.percentOf
times_cheaper_x100вниз"Временами дешевле в set", ×100catalog.timesCheaperX100

Два последствия, которые стоит прочитать дважды:

  • пер-line Шаг за шагом price, тот order и key steps round тот скидка — противоположные половины одного и того же вычитания. Тот же "-50%" на 3 ₽ position 1. ₽ off как пер-line rule и 2 ₽ off как order rule. И не является ошибкой; вопрос о том, какой из них «правильный», является вопросом о том, какой из двух. цифры shop обещанный;
  • все в одном месте Покупатель читает как обещание Скриншоты из игры Rounds Down: The Offer's percent, тот "В два раза дешевле, в-set price. 49,6% печатают как «-49%», потому что shop Что рекламирует more Чем он уступает, тем он уступает. first Человек, который добавляет их cart.

Ворота test Назовите три достижимые формулы на number где два режима Не согласен, поэтому стол cannot Описывать округление code больше не является.

3.11 Один cart Через каждый layer

Каждый section На этот вопрос отвечают продавцы, которые задают вопросы. Действительно, спросить: *Что будет платить мой покупатель?* Один cart, четыре paid линии, пробегая Все семь price Слои, с помощью которых number На каждом шагу и арифметике, что Произвел его. number Тот, кто дошел до.

The shop. Каталог:

Продуктнастройкаценность
Диван "Норд""base_price30 000
Диван "Норд""variant "XL" price40 000
Настройка "Корнер""кресло 20 000 при -50 % в set + 10 000 в его own priceполученный 20 000, "отдельно - 30 000
лампаbase_price10 000
гардеробbase_price — "от"" price на card8 000
креслоbase_price10 000 (never Добавить к этому cart)
простаяbase_price = его value как gift5 000

Правила:

ПравилоДобрыйСостояниеЭффект
−25 % на диване «Норд»"product_discountэто product только−25% от единицы
−30% на креслахproduct_discountcategory "Кресло"−30% от единицы
−10% на orders от 70 000order_discountmin_subtotal: 70000−10 %
Клетка с диваномgiftДиван в cartодин плед, price 0
VIZEN20order_discount, class keymin_subtotal: 60000−20 %, max_discount 7 000
Маржинальная маржа в магазинеcompanies.max_total_discount_percent—25 %

The cart. Четыре paid линии, каждая из которых оценивается по разным layer:

ЛинияЧто это такоеКоторый layer sets тот priceЦена
А.Диван "Норд"", variant XL2 — тот variant переопределение base_price 30 00040 000
Быкset "угол"4 — Вычисляется сервером из компонента base цены20 000
Взятьлампа1 — plain base_price10 000
Глазгардероб, настроенный в 3D3 — конфигурация snapshot переопределение base_price 8 00010 000

Линия G - это та, которая удивляет людей. card 8000 объявлений и cart 10 000 рублей, и ничего не сломано: base_price на конфигурируемом product это "от"" price, Покупатель платит за то, что собрал — data.price.total от конфигуратора. snapshot Это не намек. Это price, И это то, что number которые втекают в каждый layer Ниже: вглубь subtotal before скидки, в точку, где измеряются оба порога, и в точку, где измеряются оба порога; рекламный ролик code base. Слои 2 и 3 являются взаимоисключающими. на одном line — а product имеет либо variants или конфигуратора, но не в одном cart, который является why А и Г здесь находятся.

Шаг за шагом.

#Шаг впередЧислоОткуда он берется
1subtotal before скидки80 00040 000 + 20 000 + 10 000 + 10 000
2за-line rule, line А.−10 00025% от общего числа 40 000 — тот variant price, не base_price 30 000
3line А. after тот rule30 00040 000 − 10 000
4subtotal after за-line правила70 00030 000 + 20 000 + 10 000 + 10 000. Оба порога измеряются здесь.
5order rule −10 %−7 00010% от 70 000; порог 70 000 точно соблюдается
6бегать total63 00070 000 − 7 000
7рекламный ролик code base40 000Линии без пера-line скидка: Б 20 000 + В 10 000 + Г 10 000. Линия А выходит — code не скапливается на дисконтированных линиях default
8тот code отдавать8 00020% от 40 000
9тот rule's own капелька7 000max_discount: 7000 8 000 руб.
10total если shop не имеет маржинальной капитализации56 00063 000 − 7 000
11Скидки, которые просят24 00010 000 + 7 000 + 7 000
12Что? shop терпеть20 00025% от 80 000, округлено
13тот shop отделка крышек4 00024 000 - 20 000, взятые top вниз: тот code first
14тот code, наконец3 0007 000 - 4 000. order Шаг (7 000) и пер-line Шаг (10 000) не тронут
15discount_total20 000В точности пособие
16subtotal — Что платит покупатель60 00080 000 − 20 000

Плюс. gift line: плед, unit_price: 0, base_unit_price: 5000 (его value), is_gift: true. Появляется в нет total, не move любой порог, И не входить applied[]. The cart's count 5 — четыре paid линии и их gift.

Изменения в Checkout none Из-за этого. order Возвращается с items_total: 60000 и discount_total: 20000, Три входа в promotions[] (10 000 / 7 000 / 3 000 — последний, несущий code: "VIZEN20"), и всех четырех идентичностей §3.9 Держитесь за него. snapshot из line Г. Путешествуя в order нетронутый, price и breakdown оба: unit_price Что вы взимаете, data это Что строят.

Что не сгорело, и why. «-30 % на креслах»" rule это live для Все это время cart существует. Возвращается GET /promotions/landscape как active promotion, И кресло действительно предлагается в 7 000 в этом. shop. Это просто never прикоснуться к этому cart, Потому что в нем нет кресла, а лампа Поэтому платит свою full 10 000, с promotion отсутствующий и base_unit_price: 0 на его line. Это самый полезный line В целом пример: скидка является rule из shop с а condition, Не является собственностью какого-либо line. "The rule находится" и "всё" rule applied" Это два разных факта, и только второй из них имеет applied[].

Два more вещи, которые не стреляли, и их отсутствие измеряется: ничто не сливается line А с line Глаз (layer 0, product_groups, never прикосновения price — только Переключает покупателя между cards), и не второй order rule Конкурировали, потому что "«Лучшее» решается внутри class.

Изменить одно дело. То же самое shop, Те же правила, те же settings — Удалить лампу:

#Шаг впередЧислоПротив cart выше
1subtotal before скидки70 000−10 000
2за-line rule, line А.−10 000неизмененный
3subtotal after за-line правила60 000ниже порога 70 000 → тот order Шаг не стреляет вообще
4рекламный ролик code порогпроход60 000 ≥ 60 000, встречались точно
5рекламный ролик code base30 000Б 20 000 + Г 10 000
6тот code дарить6 00020% от 30 000, ниже max_discount 7 000 → тот rule Кап не делает ничего
7Что? shop терпеть17 50025% от 70 000; только 16 000 просят → тот shop Кап не делает ничего
8discount_total16 00010 000 + 6 000
9subtotal54 000

Читать две корзины side через side и машина становится видимой. 10 000 ламп снижают счет 6 000, не более 10 000, потому что эта лампа была Он выдержал три отдельных вещи: он нес cart Более 70 000 order Порог, он внес четверть промо code base, И он сделал это Скидки достаточно большие, чтобы оба ограничителя могли укусить. Продавец, который оценил это cart Вычитая лампу's price будет 4 000 из.

Порог 70 000 - самый острый край здесь. Обе тележки имеют subtotal before скидки 80 000 и 70 000 — измеряются там, order rule был бы Огонь в обоих случаях. after пер-line шаг, Огонь в одном и Не другой. Промо code Порог измеряется в той же самой точке., 70 000 вместо 63 000, которые остались after тот order Шаг — что стоит Знать, что before ты set а code's min_subtotal Рядом с одним order rule's.

проверенный internal/api/catalog/promo_worked_example_integration_test.go (TestB5Doc_WorkedExample): каждый number выше, чем в случае, когда live Постгре — 96 утверждений по обеим телегам, включая два числа, которые section вычисления, но cart Ответ не является return как fields (порог Справочная точка и промо code base), тот amount тот shop обрезанный колпак (измеряется путем переключения крышки off На том же cart), и точка, в которой code's Порог измеряется (измеряется путем его поднятия в gap между 63 000 И 70 000. Измените двигатель и сборка скажет вам, что line из этого section несвежий.

3.12, то есть gift на product card — product_promotions[].gift

А. product card (GET /products/{id}, GET /products/slug/{slug}) перевозить product_promotions[] — тот live Правила, которые касаются этого product, для "why это Это интересно" block. Никаких денег в них: {id, name, description, kind, theme, icon}. А. kind: "gift" Вступление несет в себе одну вещь more — тот gift product само собой, так card может показать, как set Показать все компоненты вместо Описывать его в прозе:

Поле giftзначение
product_idтот product который выдается
nameего name
slugдля link к его own card; empty = publish нет link
preview{id, url} — его own preview, else тот first галерея image (то же самое rule combo_items[] Использование, §3.8)
valueего base_price — тот number Чтобы пробить его, тот же самый cart вставляется в gift line's base_unit_price (§3.1)
quantityСколько единиц rule руки — 2 «Х2», а не два отдельных подарка

gift это отсутствие В трех случаях, и все три преднамеренно.:

  • тот rule не является а gift. Ни один другой kind руки product Выходите, так что никакой другой kind Есть что туда положить;
  • это card Это gift само собой — Затем бляшка переносит обратное side, triggers[] (ниже, вместо того, чтобы показать product самому себе;
  • тот gift недоступен прямо сейчас — неопубликованным, или не cover тот rule's own quantity (stock_quantity: null означает, что акции не отслеживаются, Следовательно, доступно - § 5. Сама табличка остается, description и все, что Сроки – это обещание конкретного item. Доступность определяется тем же Решитель, который раздает подарки в cart и в том, order, так card cannot от них отойти к рекламным gift checkout будет сдерживаться.

Одна асимметрия, которую стоит знать: тот card cannot Смотри. cart. Если покупатель уже оплачивает те же единицы product, Акции The Stock gift потребности растут (giftNeed, §5), и a gift тот card Показано, что можно удержать на checkout — без движения ни одного total, Покупатель ничего не платит else. The card мерами наименьшей возможной необходимости, level ниже которого rule может never Огонь, противоположный error Было бы и хуже, и невидимее — молчать о том, что а gift Это работает.

Другой side той же самой пластины — triggers, на gift's own card

А. gift product до сих пор является product: Покупатели приземляются на card Из поиска, из category, из одного link. Интересный вопрос - обратный. — что делать I Нужно купить, чтобы получить это free? Так что card из gift сам по себе то же самое kind: "gift" Бляшка прибывает с gift empty и три fields заполненный вместо:

Полезначение
triggers[]триггер products, то же самое GiftPreview форма как gift. value Вот вам триггер's own base_price — Что такое покупатель платить, Не то, что они получают. quantity не используется (0)
triggers_totalСколько триггеров products есть в total — triggers[] заколоченный в 12
trigger_categories[]Имена этих categories тот condition Имена («любые» product "С диванов". category это never Расширенный в products

Читайте пару как: показать пару полос в строке, и если triggers_total это больше, чем то, что вы показали, предложите остальное — массив держит максимум 12, и если triggers_total > len(triggers) Даже ваш список «показать все» является образцом, а не целое set.

Правила обратного хода side:

  • один side в то время. gift и triggers never Прийти вместе на одном и том же Мемориальная доска: card либо говорит вам, что вы получаете, либо что купить для него;
  • а product Это и триггер, и gift ("Купить два, третий - free") Оставаться на gift side — gift заполненный, triggers empty. The обратный side Там будет полоска, указывающая на card сам по себе;
  • только опубликованный, live triggers Они перечислены и подсчитаны, для того же reason тот gift Пропадает, когда не опубликовано: полоска - приглашение купить, и slug empty по-прежнему означает publish нет link;
  • а category который больше не решает (deleted, или принадлежащих другому shop — products.categories не имеет FK) Вместо того, чтобы показывать безымянный.

Проверено против live Постгреги internal/api/catalog/promo_gift_money_integration_test.go: TestB5Gift_CardShowsTheGiftItself (name, value, quantity, галерея-fallback preview и slug на триггер's card — и тот же самый product и value в cart line тот rule Затем производит), TestB5Gift_CardKeepsTextPlaqueWhenGiftIsOutOfStock (одной единицы в запасе против одной единицы rule Это дает два: gift Ушли, бляшки и description до сих пор там, cart Руки прочь ничего), TestB5Gift_CardHidesTheGiftOfAnUnpublishedProduct, TestB5Gift_CardCarriesNoGiftForOtherKinds, TestB5Gift_CardOfTheGiftShowsWhatEarnsIt (тот gift's own card: gift empty, Один триггер с покупкой price, галерея-fallback preview и slug — и Ничего сразу, что триггер не опубликован), TestB5Gift_CardOfTheGiftCapsTheTriggerList (15 triggers → 12 перечисленных, triggers_total: 15), TestB5Gift_CardOfTheGiftNamesTheTriggerCategory (а category condition Прибывает как а name, не как products), TestB5Gift_CardOfTheSelfGiftStaysOnTheGiftSide.


4. Рецепты

4.1 "Который products Сейчас они в продаже?"

Нет единой конечной точки "продажи". Задайте два вопроса:

  1. GET /promotions → Правила с active: true и а live Период. empty хэштег означает, что shop У нас нет правил, См. §5.
  2. Для rule из kind: "product_discount", декодировать condition читать product_ids / category_ids. Цены, как их видит покупатель, читайте в разделе products: price.price Уже сейчас скидка и price.old_price перевозить предварительная скидка value, с price.promotion_name называть rule.

проверять: выбрать product id от rule's condition, GET /products/{id}, проверить price.old_price > price.price. Если они равны, то rule не совпадающий с product — проверить category ids, Не конечная точка.

4.2 Создать 15% category продажа

COND=$(printf '{"category_ids":[42]}' | base64)
EFF=$(printf '{"percent":15}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Summer −15%\",\"description\":\"On sofas until 31.08\",
    \"kind\":\"product_discount\",\"active\":true,
    \"condition\":\"$COND\",\"effect\":\"$EFF\"}}"

проверять: GET /products?filter.category_id=42 и проверить это price.old_price Появился на items. Смотреть 200 на POST Доказывает только то, что rule был хранить.

4.3 Создать промо code

# 1) a rule that waits for a code: −10% on orders from 5 000, at most 2 000 off
COND=$(printf '{"min_subtotal":5000}' | base64)
EFF=$(printf '{"percent":10,"max_discount":2000}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Promo −10%\",\"kind\":\"order_discount\",\"class\":\"key\",
    \"active\":true,\"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → note result.id

# 2) the code itself
curl -X POST "$API/promo-keys" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "promotion_id":<RULE_ID>,"code":"SUMMER25","active":true,
    "usage_limit":100,"per_customer_limit":1}}'

проверять: Как покупатель с cart выше порога, POST /cart/promo {"company_id":…,"code":"summer25"} (нарочно в более низком положении) и проверить result.discount_total > 0 и result.promo_code == "SUMMER25". Если promo_rejected Вместо этого он вернулся, code Говорит, что именно не так.

4.4 Order с а code

Применять code, затем POST /cart/confirm с expected_total = тот Сумма, которую вы в последний раз показывали (result.subtotal; с checkout методы, order total с delivery — /docs/orders §5.1). проверять: читать created order — это перевозить discount_total и promotions[] как snapshot; Позже редактировать на rule never изменить положенный order.

4.5 Размеры promotions с одним бюджетом

Кампания - единственный способ сказать: "Мы выпустили скидки на миллион". тот order: Создать кампанию first, Затем наведите правила на него.

# 1) the budget itself
curl -X POST "$API/promo-campaigns" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "name":"Summer campaign","budget_kind":"spend","budget_limit":1000000,"active":true}}'
# → note result.id

# 2) point an existing rule at it (PUT is full-replace — send the whole item)
curl -X PUT "$API/promotions/<RULE_ID>" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":<RULE_ID>,"item":{
    "name":"Summer −15%","kind":"product_discount","active":true,
    "condition":"'"$COND"'","effect":"'"$EFF"'","campaign_id":<CAMPAIGN_ID>}}'

проверять: место test order Это rule Скидки, затем GET /promo-campaigns и проверить это budget_used вырос на скидку amount. Если он остается на 0, то rule На самом деле, это не в кампании — перечитайте rule и проверить campaign_id.

Два типа поведения, которые удивляют людей:

  • budget_used Также растет из обычного promotions, Не только промо-коды;
  • Если бюджет короткий, то только промо-code Скидка отказывает в order (CAMPAIGN_BUDGET_SPENT). Обычный promotion Применяется к тому, что order тот Покупатель раскладывает — уже видели, что price — бюджет может быть превышен В большинстве случаев одна продажа и rule Остановка на следующем order.

4.6 Раздать купон на покупку

Два правила и order вопросы: first описание Что дает купон, затем тот rule это дарить его.

# 1) what the coupon gives: −15% off an order, unlocked by a key
COND=$(printf '{"min_subtotal":1000}' | base64); EFF=$(printf '{"percent":15}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Thanks for the purchase −15%\",\"kind\":\"order_discount\",
    \"class\":\"key\",\"active\":true,\"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → GRANT_ID

# 2) who gets it: buyers whose completed order is 50 000 or more
COND2=$(printf '{"min_subtotal":50000}' | base64)
EFF2=$(printf "{\"grant_promotion_id\":$GRANT_ID,\"valid_days\":30}" | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Coupon for a purchase from 50 000\",\"kind\":\"issue_key\",
    \"active\":true,\"condition\":\"$COND2\",\"effect\":\"$EFF2\"}}"

проверять: место order выше порога, move в том числе status 3 (done), затем Как звонит покупатель GET /my-coupons?company_id=<ID> — Купон должен быть там с помощью name дарение (пожертвование) rule. Ничего не появляется в checkout Время: купон выдается по завершении, а не по заказу, так что "order → Купоны купить → cancel" Не работает.

Если купон не появляется: порог измеряется на amount на самом деле выплачиваемый (after Скидки, а не на предварительную скидку total.

Куда идет уведомление о купоне. На выдачу платформы отправляет электронные письма покупатель в их account address — Не в том contact email введённый в order. А. купон является личным: он связан с owner_user_id И только это account может И отсылать его в другое место, address Третья сторона, заполнившая checkout был бы кто-то подает руку else's Скидочный инструмент не тому человеку. тот code, Что это дает, срок годности и link Покупателю's купоны page. А. key.issued Webhook возгорается параллельно shop's own системы.

Оба принадлежат к это path. Купон, который вы раздаете сами (§ 4.10) Ни письма, ни веб-хук — вы уже разговариваете с этим покупателем, поэтому скажите им, что вы уже не можете найти его. тот code.

4.7 Защита поля целого shop

Слои, benefit стек (combo → promotion → рекламный ролик code), Единственный встроенный этаж является price Нулевой. shop-Широкий колпачок - это отдельная настройка:

curl -X PUT "$API/v1/orgs/<ID>" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":<ID>,"max_total_discount_percent":40}'

проверять: Прочитайте орг назад и проверьте field; Затем построить cart где Складные скидки превысят лимит и подтвердят в GET /cart/items это discount_total Это именно колпачок's percent из subtotal + discount_total — предварительная скидка total, вычисляется, а не читается (§3.9)). 0 снимает крышку.

Обратите внимание на преднамеренный limit: Сверху лестница отклоняется от top (рекламный ролик code, затем order Скидка) и делает не прикосновение-line Скидки — урезание тех, которые будут требует перераспределения денег по линиям и десинхронизирует-line snapshot хранимые в order.

4.8 Построить "то, что продается" page для всего shop

Один звонок, и рейтинг уже есть done. Спрашивайте, что вы покажете, — default 24, и a page 8 шт., которые должны запрашивать 8.

curl -s "$API/promotions/landscape?limit=8" -H "Authorization: Bearer $TOKEN" | jq '{
  scanned: .result.scanned_products,
  truncated: .result.truncated,
  live_rules: [.result.promotions[].name],
  code_only:  [.result.code_promotions[].name],
  offers: [.result.top_offers[] | {kind, product_id, name, price, old_price, benefit_percent}]}'

Проверяйте — три чека, все дешево, и каждый ловит различную ложь:

  1. взять first предложение kind == "product" и читать его card: GET /products/{product_id} должен return точно такой же price.price и price.old_price. Если они отличаются, то page и card Процитировать два цены одному и тому же покупателю — не отгружай, сообщи об этом.
  2. Проверьте арифметику одного row рукой: benefit == old_price - price, и benefit_percent == floor(benefit * 100 / old_price) — DOWN, never выше: 49,6% отпечатков как «−49%», так что shop never претензии more чем это дает. вот что Ты будешь print в большом type, Поэтому стоит одно вычитание.
  3. читать truncated. true (с scanned_products: 500) означает, что ответ "Лучшие из 500 самых новых products", Не "лучший в мире" shop" — или говорить в формулировке "обещать или не обещать"".

Не ставить code_promotions[] В том же списке, что и promotions[]. Честный Их формулировка - "-10"% Взять хотя бы": без code тот price на card не изменится, и покупатель, который не найдет скидку в cart Винить в этом shop, Не копия.

Ан empty top_offers[] с не-empty promotions[] Это реальное, правильное состояние. — Правила существуют, но не публикуются (типичная причина): category_ids из одного category товары которых еще являются черновиками. scanned_products > 0 before ты Винить конечную точку: 0 означает shop не публикуется products вообще.

4.9 Написать "в чине е) "в качестве "сокращения" на product card

curl -s "$API/products/$PID" -H "Authorization: Bearer $TOKEN" | jq '.result.in_combos[] | {
  set: .name, combo_price, components_total, benefit,
  item_price, item_regular_price, item_times_cheaper_x100}'

Потом render от fields Как они есть: item_price и item_regular_price дать «в поле 500 ⁇ 1 000 ⁇ », и item_times_cheaper_x100 говорит вам Допускается ли вообще более сильная формулировка.

проверять с номерами, которые вы контролируете, а не с status code. Положите product вводить а set с price_mode: "percent", price_value: 50, на base price из 1 000:

  • item_price должен быть точно 500 (этаж base·(100−p)/100 — то же самое формула cart использования, которая является why Вы не должны повторять это на витрине магазина);
  • item_times_cheaper_x100 должен быть точно 200, i.e. "в";
  • Теперь начинаем product_discount -20% на этот компонент и читать card снова: item_regular_price падать до 800 И соотношение падает до 160. Это Это правильно и это является точкой field — снаружи set Покупатель уже 800, так что "вдвое дешевле" больше не будет true.

Если item_times_cheaper_x100 Возвращаться 0, не print Соотношение: либо set Это дает product Ничего, или это free компонент (item_price == 0) это читать "В"".

4.10 Рука купона одному покупателю

Для одного-off — Извинения за опоздание delivery, Спасибо за регулярное — вы делаете не нуждается в rule Это дает купоны всем, кто имеет право. (в том числе и после его повторного использования), а затем key на одну account. Вам нужен этот покупатель's account id, и их orders Носить его как buyer_id (тот продавец's order Список есть POST /orders, не GET). А. гость order листья buyer_id: 0 — Нет никакого account наложить купон и выдать 0 Создает общий code, Не купон.

# 1) what the coupon gives — skip if you already have a class:"key" rule to reuse
COND=$(printf '{"min_subtotal":1000}' | base64); EFF=$(printf '{"percent":10}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Sorry for the delay −10%\",\"kind\":\"order_discount\",
    \"class\":\"key\",\"active\":true,
    \"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → RULE_ID

# 2) the coupon itself, onto one account
curl -X POST "$API/promo-keys" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "promotion_id":<RULE_ID>,"code":"SORRY-2026","active":true,
    "owner_user_id":<BUYER_USER_ID>,
    "expires_at":"2026-12-31T23:59:59Z"}}'

The limits Нарочно не хватает: а key created с ан owner и без Они выдаются как один-off (usage_limit: 1, per_customer_limit: 1). Отправить Явные цифры только если вы действительно имеете в виду "три посещения"". expires_at это optional И без него купон живет столько, сколько rule Он разблокирует.

проверять — Три проверки, потому что каждый ответ на разные способы ошибиться:

  • Прочитайте его обратно с GET /promo-keys?with_coupons=true И найди свою owner_user_id На нем. Без него. with_coupons=true Список показывает общие коды Только, и купон, который был created Правильно выглядит как звонок, который failed;
  • как это покупатель, GET /my-coupons?company_id=<ID> — Купон находится под предоставление rule's name, Что покупатель будет читать в cart;
  • как любой else, POST /cart/promo {"company_id":…,"code":"SORRY-2026"} ответы promo_rejected: "NOT_FOUND". Личный купон не является носителем Справка, и ответ даже не признает code существует.

Два отказа, которые стоит знать before ты script Это: a owner_user_id принадлежит не account это отвергнутый (PROMO_KEY_OWNER_NOT_FOUND) Вместо того чтобы производить code Никто не может выкупить и owner из существующих key cannot быть отредактированным (PROMO_KEY_OWNER_IMMUTABLE) — Перемещение купона другому человеку — это удаление плюс а new Вопрос, а не PUT.

5.Безмолвно ignored

Случаи, когда платформа отвечает на 200 и ничего не делает. section является обязательным И написаны они из code, Не из памяти.

Что посылаетеЧто происходитПочему
usage_limit: 0 / per_customer_limit: 0 Вместе с тем, ан owner_user_idОба становятся 1Личный купон – это один-off по смыслу, и до 2026-08-20 платформа была единственной, выдавшей их — always как 1/1. На выданном вручную купоне «0 = неограниченно» забыт field далеко more часто, чем решение, и решение все еще доступно: отправьте number В смысле, общий. code (нет owner) 0 = неограниченное, неизменное.
used, created_at, issued_by_order_id писатьignoredТолько факты, не settings.
expected_total отсутствует /cart/confirmprice проверить skippedСтарые клиенты keep работа. 0 не «отсутствует» — сравнивается с любой другой суммой;.
пустой class на POST /promotionsзаполненный из kindСовместимость с клиентами, написанными before Существовали классы.
пустой class на PUT /promotions/{id}тот rule Сохраняет свои current classКлиент, который не знает о классах, не должен иметь возможность превратить купон. rule входить в public Скидка при редактировании name.
stack_with_key опущенный на PUTКолонна не тронутаThe field это optional Именно так, что «не отправлено» отличается от «переключено» off".
А. rule из class: "key" Без ключей, прикрепленныхrule хранится, never пожарыОжидаемый: он ждет а code. Не дефект — прикрепите key.
campaign_id опущенный на PUT /promotions/{id}Кампания продолжаетсяThe field это optional Для того же reason как stack_with_key: Клиент, который не знает о бюджетах, не должен поднимать лимит, редактируя бюджет. name. Отправить явное 0 отсоединять.
audience_id опущенный на PUT /promotions/{id}тот audience хранитсяСтарший клиент не должен превращаться в role-только promotion public Редактировать другой field. Отправить явное 0 чтобы сделать это public.
budget_used Написано в кампанииignoredЭто факт продаж, а не сеттинг. limit Прощение того, что уже было потрачено, не является — иначе бюджет ничего бы не значил..
stack_with_key: false на kind: "gift" ruleignored — промо-коды keep Работая в то время как gift выдаетсяThe key Шаговые ворота сами по себе на линиях, которые уже несут promotion, и а gift line В этом случае не существует (§3.1). Кроме того, нечего складывать: gift Снижает не total. Поклонение флагу, вычисляя подарки раньше, сделает *каждый* gift block *каждый* code, потому что gift line не несет stack_with_key его own.
campaign_id на kind: "gift" ruleкампания's active И даты все еще врата rule, Но его budget_used never растиБюджет взимается из списка скидок, а также gift Сегодня в нем нет ничего, что могло бы привести к budget_kind: "count" кампания над gift rule Дарить подарки без limit 80%-ное предупреждение never Огонь.Известный gap, Не решение — TestB5Gift_CampaignBudgetDoesNotCountGifts Красный цвет в день, когда он закрыт.
А. kind: "gift" rule чей gift product Исчерпаны запасытот gift это не выданный — cart и order Оба ценятся без него, и то, что платит покупатель, не меняется.Акция является condition сдать а gift Внешне, как и в публикации. gift line принимается off Акции, как купленный, так что короткие акции, используемые для аборта целое order с out of stock — более free line покупатель never выбираемый и cannot Удалить (это виртуально, это не в виртуальном мире) cart "Выбегай" измеряется по тому, что это cart По сути, нуждается: rule's own quantity, плюс все единицы одного и того же product Покупатель оплачивает (листья в одном движении за единицу товара) product, "Возьмите два, третий - это free" Требуется три, плюс подарки одного и того же product предоставленных другими правилами кто стрелял в этом cart. А. rule Огонь не удерживает ничего. stock_quantity: null означает «запас не отслеживается», а не ноль — что gift выдается как before. **Тот же ответ содержится, когда последний блок идет к кому-то. else *в то время как* это order пишется: проверка и вычет не являются одним атомным шагом, поэтому дефицит может возникнуть при записи.-off Вместо этого — и там gift И тоже уронили. gift line удаляется из order, тот paid Линии вычитаются, order Пройдет, и нет total ходы (a) gift в none Нехватка в одной из них paid** line И все же прервать все order, как бы always Так что окно - это факт времени без последствий для покупателя: медиана 0,12-0,15 мс (один из них). paid line, неотслеженный запас) до 1,67-1,71 мс (десять линий, отслеживаемый запас); медиана верхней границы 0,36-2,04 мс в четырех конфигурациях, ее p99 до 7,3 мс за пробег. line count и отслеживание запасов (TestB5GiftRace_WindowMeasure, 8 пробегов × 200 orders на клетку, сохраняемую в виде регрессии на окне's *width*). Закрыть окно с помощью замка на gift-Решительность была отвергнутый: Разрешить и написать-off брать product rows В разных точках checkout Сделка, так две противоположные orders ("gift G плюс плюс paid P. Против "gift P плюс плюс paid G) потребуется два rows в противоположном order тупик. TestB5Gift_OutOfStockGiftIsWithheldAndOrderPasses, TestB5Gift_UnfiredSecondRuleDoesNotEatTheStock, TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5GiftRace_MixedMoveDeductsThePaidPartOnly, TestB5GiftRace_PaidLineShortageStillKillsTheOrder, TestB5Gift_WithheldGiftInTheWindowMovesNoMoney.
Секунду. UpdateOrderStatus(done) На том же orderВторой купон неВыдача является идемпотентной, осуществляемой уникальным index Вместо чека — двойной клик в противном случае предоставит два купона на одну покупку..
Применять а code с ан empty cartcart возвращенный, promo_rejected: "BELOW_THRESHOLD" или NO_ELIGIBLEThe code не хранится на cart В этом случае только если key Сама по себе мертва; а live key Они остаются и начинают работать, когда cart расти.
limit опущенный или 0 на /promotions/landscape24 предложения, не "все из них""Неограниченный список «лучших предложений» — это page Никто не делает и a query Что растет вместе с каталогом.
А. shop с more Более 500 опубликованных productsтолько 500 новых сканируются; truncated: trueПотолок - это price Запуск реального двигателя вместо подделки SQL "где дисконтировано". Об этом сообщается, а не hidden — См. § 3.7.
А. rule которые соответствуют только черновикам или deleted productsтот rule перечислены в promotions[], но производит Нет предложенийСканирование ходит только опубликованные товары. rule Это хорошо, товар не публикуется..
А. rule из kind: "issue_key"отсутствует у обоих rule список из landscapeОн предоставляет купон на завершенный order. Перечислить его среди live Скидки будут рекламировать price Которого пока не существует.
А. rule который уже закончился (или не начался) /promotions/landscapeотсутствие из promotions[] / code_promotions[]Списки работают в том же предикате периода, что и двигатель, поэтому rule и цены, которые он производит always До 2026-08-20 они не делали, а конечная точка рекламировалась "вчера"'s −50%" рядом с недисконтированными ценами - §3.7.
А. dev-contour PAT на /promotions/landscapedraft правила, но live productsПравила следуют за contour с тех пор draft Для них (миграция 0174) product Сканирование все еще ходит live каталог, то есть product существует только в том draft Доходность без предложения.
Ожидание preview на PromoOffer из одного product без image вообщеemptyЗаполняется с 2026-08-20 гг. из product's preview, откат назад к first галерея image. Пустота означает product Сама по себе картина отсутствует, но конечная точка не опускает ее..
подведение итогов items[].line_total чтобы получить order totalТы получишь total before тот order Скидка и промо codeЛинии есть price фотоснимки; order-level steps Не распространяется на них. Ничего не ошибается. number Просто кто-то else's. Идентификатор §3.9 1.
подведение итогов base_unit_price × quantity над все order линииСумма завышена на value каждого giftА. gift Это реально order_items row с line_total: 0 и base_unit_price = его value, и это value в ней нет total из order. Filter через promotion.kind == "gift" — тот order не имеет is_gift флаг.
читать subtotal_before на cart из одного shop с нет live promotionможет ответить 0 с товарами в пределах cartНичего не подсчитывалось: field Заполняется шагом дисконтирования, и шаг не выполняется, когда shop У него нет правил, он просыпается, как только кто-то active rule существует, даже один cart Не совпадает — так что value Отслеживает продавца's settings, Не то cart. Пробить забастовку subtotal + discount_total вместо этого (§3.9)).
А. free компонент в in_combos[].item_*item_price: 0 и item_times_cheaper_x100: 0Нет никакого отношения к нулю. gift Из него выводится item_price == 0, Не из соотношения.
читать in_combos[] как shop ownersets с benefit <= 0 Также перечисленыThe owner Нужно видеть сломанный sets их исправление; а public посетитель never Не читайте "список" как "выгодный" в кабинете..

Громкие сбои (400, так что вы заметите)): unknown Ключи внутри condition/effect; max_discount на пер-line rule; а key Прикреплен к не-key rule; изменение key's promotion_id или его owner_user_id (PROMO_KEY_OWNER_IMMUTABLE — включая посылать 0 Для купона, который бы тихо превратил его в code Для всех); ан owner_user_id который не принадлежит account (PROMO_KEY_OWNER_NOT_FOUND); а fixed-amount купон без порога выше его value; а bundle/gift set с обоими id список empty; а kind × class пара снаружи Матрица в §3.1 (class: "key" На все, кроме order_discount, class другой чем item на gift или issue_key); limit выше 100 на /promotions/landscape (подтвержденный field — Отказано, не зажато); Создать, отредактировать или удалить key или кампания с заседания кабинета министров, переключенного в draft contour (DEV_MODE_UNAVAILABLE) — они live снаружи draft, так Это письмо изменит live shop сразу же (a) draft API key получать PAT_CONTOUR_MISMATCH Для того же reason). Сами правила переходят в draft.

6. Пределы

генерируемый — каждый row Ниже находится key из promotions.BuildPromoReference().Limits, чей value Вещь, которая исходит от постоянного валидатора (или обработчика), приводит в действие. test Сравнение с key set в этой таблице со ссылкой, так что limit cannot Добавляется в code Забытый здесь, или изобретенный здесь и never принужденный.

КлючценностьГде кусается
name_max_len255rule и кампании name
description_max_len2000rule description
percent_max100effect.percent, который начинается с 1
condition_max_bytes16384сырой condition
effect_max_bytes16384сырой effect
ids_per_list_max1000каждый id Список внутри condition
sets_max10sets из одного bundle/gift
set_min_qty_max1000min_qty одного set
gift_quantity_max10gift quantity, который начинается с 1
code_min_len3рекламный ролик code, letters/digits/-/_
code_max_len64рекламный ролик code
usage_limit_max1000000usage_limit и per_customer_limit
issue_valid_days_min1valid_days Изданный купон
issue_valid_days_max365valid_days Изданный купон
campaign_name_max_len255кампания name
campaign_warn_percent80budget_warned перевернуть отсюда
combo_items_max20компонентов в a combo product
combo_item_quantity_min1quantity одного компонента
combo_item_quantity_max100quantity одного компонента
landscape_limit_default24limit Опущено или 0 на /promotions/landscape
landscape_limit_max100limit выше этого - 400; подтвержденный field, не зажим
landscape_scan_products500products а landscape Звонок проходит через двигатель
landscape_scan_combo_sets200combo sets а landscape меры по призыву

не генерируется — Это SQL Буквы в репозитории layer и ставка-limit ведро, так что ничто не проверяет их механически; читать по состоянию на 2026-08-20 pg/promo_keys.go, pg/promo_issue.go и перехватчик AUTH:

Limitценность
GET /promo-keysВернуть максимум 500 ключей, нет paging еще
GET /my-couponsВернуть не более 200 купонов
POST /cart/promoограниченный по ставке IP (его own Ведро, отдельно от login), 30/minute через default
POST /cart/quoteограниченный по ставке IP в третий ведро, 60/minute через default (API_RATELIMIT_QUOTE_PER_MIN) — Это единственное место, где code можно исследовать без account вообще

7.Как это было проверено

  • 2026-08-20, live HTTP против изолированного случая (own порт, own база данных, Бинарные построены из этой ветви: маршруты существуют и закрыты (/promo-keys и /cart/promo Ответ 401 анонимно, а не 404); rule и key создание return формы, описанные выше; дубликаты code → 409; key на не-key rule → 400; fixed-amount купон без порога → 400; max_discount на за-line rule → 400.
  • 39 Сценарии интеграции над реальными статграми (-run TestB5), В том числе и Счетчик выкупа и его откат на order Отмена, пер-клиент limit, тот price-Изменился охранник, бюджет кампании остановился на rule и быть refunded об аннулировании, выдаче купона по завершении (и его идемпотентности)), Иностранные купоны, оставаясь невидимыми 18 more для combo sets.
  • The int64 → string Подробности прослушки были подтверждены чтением реальных ответов, а не Размышления о прото.
  • Независимый аудит всего района на 2026-08-20 обнаружил четыре денежных дефекта что это document В противном случае он бы описал неправильно: редактирование promotion из кабинета министров молча сняли предвыборный кеп; бюджет был только Заряженный за промо-коды, так что кепка над обычным promotion ничего не сделал; key.issued Web-хостинг может never И был он, и был, и был, и был. Идемпотентный только против последовательных повторов. fixed и покрытый Тесты — приведенные выше утверждения описывают fixed поведение, а не предполагаемое.
  • Ручные купоны (§4.10, `owner_user_id` § 3.2 и в соответствии с Отказы в соответствии с §5) были написаны 2026-08-20 вместе с code Это позволяет Каждый из них имеет свой сценарий в internal/api/catalog/promo_keys_integration_test.go, Зеленый над настоящим Постгреги: TestB53_ManualCouponRedeemedByOwner Один купон после выпуска Деньги (продавец видит их, покупатель видит их, незнакомец получает их). NOT_FOUND, тот owner's order несет скидку, used 1. вторая попытка является EXHAUSTED); TestB53_ManualCouponUnknownOwnerRejected Доказательство мертвого купона вообще не хранится; TestB53_ManualCouponOwnerImmutableOnEdit Доказывает оба Отказы от них owner field И что честный монтаж все еще проходит; TestB53_PublicCodeKeepsNoLimits Это регрессия, которую сделали общие промо-коды. Не молчаливо становитесь одним-off.
  • Перечислимый content (классы, классы, отвергать причины, limits) это не написанный Здесь вручную: это происходит от promotions.BuildPromoReference(), и испытания ворот Если не получится, document Сервер не согласен в любом направлении..
  • The gift Заявления §3.1, §3.9 и §5 перестать быть претензией на 2026-08-20 и стал чеком. TestB5Gift_* в internal/core/services/promotions/apply_gift_money_test.go утверждает идентичность над двигателем result Вместо того, чтобы fixed Числа — Пробитый total это построенные только из реальных линий, подлежащих оплате total Это те линии минус order и key steps, Σ applied равной скидке, то есть key base То же самое с applies_to_discounted и так далее. off, и shop маржинальная капитализация измеряется без тот gift; а gift ценный more чем весь cart используется нарочно. internal/api/catalog/promo_gift_money_integration_test.go последующий gift За исключением двигателя: 54-FZ квитанция, которой order-остаток дисконта не должен быть сбрасывается на нулевую цену gift line (Это было, и квитанция тогда failed то Строить вообще, и в order Без денег отказываясь производить квитанцию вместо из одного empty Первый. Кампания row В §5 это то же самое. file's Третий сценарий, Писать, чтобы не сорваться, когда gap закрывать.
  • §3.7, §3.8, §4.8, §4.9 и landscape rows §5: написаны 2026-08-20 от source (internal/api/catalog/get_promo_landscape.go, internal/adapters/repositories/pg/promo_landscape.go, и inCombosDesc / timesCheaperX100 в internal/api/catalog/combo_repack.go) и затем бегать В тот же день против настоящих стражников: TestB5Landscape* — 9 Сценарий, все зеленый; TestB49_InComboItemPrice — зеленая (компонент на уровне -50% внутри); set ответы item_price 250, item_regular_price 500, item_times_cheaper_x100 200, и компонент, который set Не сбрасывать со счетов ответы 0). Две из девяти Красный, когда это section был first написаны; оба дефекта были fixed то же самое День (обязательно) ac88c77) и это document описывает fixed поведение.

8. Повышение в должности own разметка

Промо цены и rule Имена уже занесены в page scope: оба product и products[] подвергать price, old_price и promotion_name. The product page Также разоблачает product.promotions[] с помощью rule’s name, description и kind. Это сервер results, Не клиентские расчеты. канонический scope field списки и display Рецепты есть /docs/own-markup §2 и GET /docs/vz-keys.json → scope; использовать их вместо API гнездование такое как price.promotion_name в разметке. extra Транспорт необходим.

Исходник: https://api.vizen.shop/docs/promotions?full=1

Работает в демо-режиме