Vizen Shop

Stock: the number, the ledger and the rounds of a cancelled order

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

Статус: draft · проверенный: 2026-08-20 — 13 интеграционных сценариев Настоящие постгре (tz_m5), 4 существующих + 9 написанных для этого document, плюс SQL измерения по отношению к полученному data · владелец: гладиатр API документация Служить: GET /docs/stock — Подаваемая копия соответствует этой ссылке. мастерство: none еще · Машинный справочник: none См. § 13, это Самый большой сингл gap из этого document.


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

Включите отслеживание акций для a product и set тот number, Прекратите отслеживать, set порог «бега на низком уровне», читать книгу движения одного product ("где это было Пять единиц идут » и перечислить, что заканчивается через shop. Все else Что касается акций, то они случаются то Ты, не ты. через Вы: An order Занимает единицы off, Отмена возвращает их обратно, вновь открывая cancelled order забирает их off снова, и а gift line принимается off Точно так же, как и paid Один — только когда он бежит где дефицит в том, *gift* падает gift Вместо операции. checkout тот gift row это deleted; на открывать Он остается в order как Это исторический факт, и он просто не зарезервирован (§ 9). paid line Но оба провалились.

Ты never Напишите движение напрямую. POST /stock-movements. The Реестр является следствием только двух вещей: продавец устанавливает number, и ан order меняться status.

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

Если ты хочешь…Возьми это pathСтоимость
Управление запасами с личным API токенСегодня это невозможноSetProductStock, GetProductStockHistory и GetLowStock отсутствует в services.DefaultMethodScopes, И эта карта является default-отрицать (guard.go:851-854). А. PAT получать 403 PAT_METHOD_NOT_ALLOWED. измеряется `grep 'SetProductStock\GetLowStock\GetProductStockHistory' internal/core/services/guard.go` → 0 hits. Используйте кабинетную сессию (JWT) — Все, что ниже, предполагает один.
Установить number единицыPUT /products/{id}/stockЕдинственный path кто пишет книгу row. Забирает product row под FOR UPDATE.
Установить только пороглибо PUT /products/{id}/stock с low_threshold самостоятельно или PUT /products/{id} с stock_low_thresholdТо же самое result, Разный взрыв radius: конечная точка запасов Также стираете порог, когда вы его опускаете. (§10, row 2. product Конечная точка касается только того, что вы отправляете. PUT /products/{id} только для пороговых правок.
Установить number от product formты cannot — stock_quantity на PUT /products/{id} принимается и ignoredПреднамеренное: form посылает value Когда он был открыт, и все, что продавалось, было бы переписано молча и прошло бы через бухгалтерскую книгу. (edit_product.go:512-522). Прото comment на field Описывает он и ignored Поведение как работа — §14, D6.
А. product это never выбегать (Цифровой товар, сделанный для order)уходить stock_quantity незаполненный или отправить quantity: -1 очиститьНичто не проверяет его нигде.: cart, checkout, gift правомерности и low-stock Список всех пропущенных. default состояние каждого new product.
Знать кто Переместить акциинедоступной через APIstock_movements.created_by Писать, но StockMovement message не имеет такого field (catalog.proto:1741-1754). В бухгалтерской книге говорится "что случилось", а не "кто это сделал".".
Восстановить number из книгине — не складывается§7, личность 2. stock_quantity, или balance_after Новейшее движение.
Резервный запас на "добавить" cart" времяневозможноThe cart Отказаться от a product уже на нулевом уровне, но не проверяет amount И ничего не держит. checkout Занимает единицы off. §10, row 8.

3. The main факт

`stock_quantity: null` Это значит "это" product Не отслеживается, не "ноль оставлено"".

Все в этой области вытекает из этого. product это always покупной, never появляется в low-stock Список, не выпускает регистр rows, ничего не возвращает при отмене и все еще раздается как gift. Есть "Не существует отдельного "следового запаса"": yes/no" Переключатель — наличие number *это* тот Переключатель, который why Прочистка нуждается в own сигнал (отрицательный) quantity, §4). Это также означает stock_quantity: 0 Реальное, значимое состояние: распродан, Заметный, видимый в low-stock список.

Второй факт, который люди неправильно понимают more Часто: это number Это рекорд; В книге это объясняется, но cannot Восстановить.

products.stock_quantity Это source Истина. stock_movements написанный в том же месте, в том же месте, и balance_after Именно так что несоответствие может быть исследовано без повторения истории. Неполнота: включение отслеживания на пишет нет row, И не расчищает его. Взять хотя бы дельты а product и вы не получите его запаса - измерено, § 7.

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

4.1. number — products

колонназначениеНаписано
stock_quantityINTEGER, NULL = не отслеживается, >= 0 исполненный chk_products_stock_quantityPUT /products/{id}/stock; каждый order сделка
stock_low_thresholdINTEGER, NULL = Предупреждение только при нулевом, >= 0 исполненный chk_products_stock_thresholdPUT /products/{id}/stock, PUT /products/{id}

Оба возвращаются на GET /products/{id} и GET /products (stock_quantity только в списке, вместе с сервером in_stock. in_stock это domain.InStock(qty, 1): true когда не отслеживается, true когда >= 1, false только при отслеживании и нуле. Витрина магазина не должна повторно получать его - rule "неотслеживаемые средства always «Жизнь в одном месте специально».

Оба columns есть исключаемый из row Версия и от dev/prod наложение (миграция 0089): Акция является operational value из live contour. Два дефекты paid для этого line — Все продажи, которые раньше были на грани products.row_version и Дайте продавцу а false publish конфликт и публикация draft Воскрешение (Воскрешение) Заморозка акций, когда row был раздвоен.

Последствия, подтвержденные чтением `internal/api/catalog/stock.go`: Нет никакого dev ветвь в любом из трех кураторов (grep -c 'contourEligible\|devOverlay\|dc.dev' → 0). Сессия, которая редактирует проекты везде else Вести записи прямо в The live product.

4.2 Регистрационный журнал — stock_movements (миграция 0084)

колонназначение
idBIGINT личность. `int64` → а JSON струна на проводе.
company_idарендатор, NOT NULL — Каждый столик знает свое shop
product_idON DELETE CASCADE
order_idтот order что вызвало; NULL для manual редактировать
deltaподписанный, CHECK (delta <> 0); минус = оставленная полка
balance_afterзапас after Это движение
reasonСм. § 4.3
roundтот "cancel → Вернуться в «Работа», >= 0, Добавлено миграцией 0116
commentTEXT; ан empty Строка хранится как NULL (NULLIF)
created_atвремя транзакции — rows одного order делиться
created_byактер. Не разоблаченный API. Для одного order Движение это есть *покупатель*; для cancel/reorder тот *продавец* кто передвигал status; для manual продавец.

GET /products/{id}/stock/history возврат id, delta, balance_after, reason, comment, order_id, created_at, новый first, по приказу created_at DESC, id DESC. The id тай-брейк load-ношения: движения, записанные на одной доле транзакции created_at микросекунды, так что без нее order из одного order row и его cancel row было бы undefined.

4.3 Причины

reasonНаписано, когдаНаписано
ordercheckout Забирает единицы offCreateOrderFromCart
cancelтот order перемещаться *cancelled*, единицы возвращаютсяrestoreOrderStock
reorderа cancelled order Его снова отправили на работу, подразделения снова вышли на улицу.deductReopenedOrderStock
manualПродавец пересчитал коробкиSetStock
returnНичего не пишет — постоянная domain.StockReasonReturn не имеет не-test вызывающий—
importНичего не пишет — постоянная domain.StockReasonImport не имеет не-test вызывающий—

⚠️ Этот стол написан от руки; никаких ворот его еще не охраняет. (§В нем содержится две претензии являются отрицательными и зарегистрированы в §12.

измеряется tz_m5 after тот full люкс: cancel 11, manual 304, order 12, reorder 8, return 0, import 0. База данных CHECK принимать return и import (прямой INSERT успех; 'wat' Отказано в SQLSTATE 23514) — Они зарезервированы, а не live. Служил OpenAPI списки контрактов `reason` как `order | cancel | return | manual | import` — Опускает тот, который reason а вновь открыт order Он создает и рекламирует два never Появились (§ 14), D5).

4.4 Почему reorder является его own reason, и why round существует

Идемпотенция key В бухгалтерском учете есть (order_id, product_id, reason, round), а частичный уникальный index WHERE order_id IS NOT NULL. The reason Он вырос в два раза:

  • миграция `0092` добавленный `reorder`. The key был (order, product, reason). Открытие A cancelled order Пришлось брать подразделения off Опять же, но секунда order row Для того же order сталкиваясь с first, и ON CONFLICT DO NOTHING - молчаливо. - Акции будут оставаться надутыми всем order.
  • миграция `0116` добавленный `round`. В двух случаях по одной причине order, тот key покрыто точно один Второй цикл отмены того же order писать Тот же тройной, был сброшен, и подразделения never возвращался:

`` stock 5, order of 2 → 3; cancel → 5; back to work → 3; cancel → 3 (!) ``

Два блока, запертые внутри cancelled order Вечность и каждый следующий цикл Замков, которые многие more. Витрина магазина показывает «из запаса» из-за order которого больше не существует.

round отсчитываются от противоположный reason (stockMoveRound, pg/stock.go:158-191), Не его own, И это тонкая часть. Отмена's round Это number из reorder rows; вновь открыть's round Это number из cancel rows минус один. его own Счетчик разорвет двойной щелчок: "cancelled → cancelled" является законным переходный период, достигает запаса layer в full, Второй проход будет вычислять тот *следующий* round — а new key, а new row, И эти отряды вернутся дважды.. Противоположный счетчик не move на повторе, так что второй проход вычисляет то же самое round и hits уникальный index, Что именно является тем, для чего оно.

Тот же выбор дает правильное поведение на краю: повторное открытие, которое не удается для того, чтобы сделать это. отсутствие стоковых роллов назад целыми, не оставляет reorder row, и следующей отменой Поэтому вычислить *предыдущий* round Вместо того чтобы придумывать return единицы Никто не взял.

The round вычисляется под product row замок и before вставить, Два одновременных клика «Отменить» cannot Прочитайте книгу в тот же момент, Несогласный относительно round и return единицы дважды.

Заполнение: none, сознательно. DEFAULT 0 Старый дар rows точно round тот new code Написал бы (написал бы) first Цикл равен нулю).

4.5 Писать path — ApplyStockMoves

Order-side движениями являются applied внутри кто-то else's сделка (pg/stock.go:41), И в этом суть: вычитание запасов должно быть атомным с создавать order. Неустойка приведет к исчезновению товаров, не В нем будет все, что угодно, а потом будет высечено. order Товары, которые исчезли.

Пер. product, в order:

  1. mergeStockMoves Все движения одного product в одном, сохраняя first-видневшийся order (Человек читает книгу, и она следует за ним. order линии).
  2. Двигаться вместе с delta == 0 есть skipped.
  3. SELECT stock_quantity … FOR UPDATE на product row. Для return (delta > 0) тот deleted_after IS NULL filter Упал: ан order держать а deleted product должна быть отменена, иначе строгая проверка Неудачи всей status переходный период и order Он застрял бы навсегда. return для product которое больше не существует, является skipped, не является error.
  4. stock_quantity IS NULL → Неотслеженный products не производить ни одного rows.
  5. after = stock + delta; after < 0 → domain.ErrOutOfStock → FailedPrecondition out_of_stock. Если только не move перевозить gift единицы (StockMove.GiftUnits): затем paid часть вычитается, то есть gift Часть не является, тот product id Возвращается обратно в качестве удержанного gift, и операция Если это так, то paid И одна только часть не подходит, она ErrOutOfStock как before — Деградация связана с free line Только два звонка. set GiftUnits Оба они забирают возвращенный список.: checkout, от gift линии Он вот-вот вставит, и открывать, от gift линии order уже Они отличаются только тем, что делают с удержанным. product — checkout удаляет его order_items row, Открытый держит его.
  6. Включить движение first, После этого обновите number. Уникальный index Это Единственное, что делает повторение безопасным: нет row Вставленное означает, что это повторение, и number Не следует трогать.

Почему слияние имеет значение для денег. Один product Может дойти до запаса layer как два Линии — А paid line и а gift line, или того же product В двух 3D конфигурация. rows будет иметь такую же идемпотентность key, Второй будет быть заброшенным ON CONFLICT, и Один блок должен был покинуть полку вместо двух.. Резюме на product делает key Уникальный по конструкции. Измеряется: а cart с Две линии одного и того же product (2 + 3, производит ровно одно движение, order/-5.

⚠️ mergeStockMoves сливаться ProductID только и держит first move's Reason, Comment и Author. Каждый человек сегодня создает партию с одним униформа reason, Так что ничего не происходит в настоящее время, но будущий звонок смешивается. В одной партии причины заставили бы их молча рухнуть под first. §14, D7.

5. Устанавливая number — PUT /products/{id}/stock

{ "quantity": 12, "low_threshold": 3 }

Три страны должны вписаться в один optional целым числом, так что знак несет в себе смысл:

quantityЭффект
опущенныйтот number это не тронутый (setQty == false)
>= 0тот number это set; 0 является законным "проданным""
< 0Отслеживание очищается (stock_quantity = NULL)

А. *int один cannot Отдельно «не послан» от «очистить его», что why SetStock Принимает отдельное setQty Флаг — отслеживание клиринга случайно — способ продажи Что не существует.

low_threshold Следуя тому же знаку rule (< 0 очищает, но имеет нет "не отправлено" государство - см. §10, row 2.Эта асимметрия является дефектом, а не design.

Реестр row написанный только когда number Действительно, изменения и отслеживание были уже в:

if setQty && quantity != nil && current != nil && *quantity != *current { … }

Включите отслеживание, очистите его и перезагрузите то же самое number Все пишут Ничего, включить отслеживание и off Это изменение mode, Не движение товаров; а row «12 единиц прибыли» — это ложь о складе.

Ответ: quantity, low_threshold, in_stock — перечитывать из product, не отсылка к запросу.

6.Читать

  • GET /products/{id}/stock/history?limit=N — Справочник одного из product, новый first. Арендатор приходит с сессии, never от запроса: в противном случае Это был бы способ читать склады других магазинов. product id. иностранный shop получает empty Список, а не error (Измеренный. Пользователь без shop получать PermissionDenied forbidden.
  • GET /products/low-stock?limit=N — {product_id, name, quantity, low_threshold} для products где stock_quantity IS NOT NULL AND stock_quantity <= COALESCE(stock_low_threshold, 0), по приказу stock_quantity, name, id. The id Тай-брейк сохраняет список стабильным по звонкам, когда акции и name Включены проекты; только мягкий-deleted products исключаются.

Без порога сообщается только ноль. С порогом 10 шкаф Иначе бы ворчало на всех product "Только десять"".

7. Идентичность

1.  stock_quantity  ==  balance_after of the newest movement of that product
       — holds whenever the product has at least one movement AND tracking was
         never cleared since the last one. Measured on 7 products: 7/7.

2.  Σ delta  ==  stock_quantity
       — FALSE. Do not use it. Measured on the same 7 products: 0/7.
         Product 1: Σ delta = -2, stock = 3.  Product 12: Σ delta = 0, stock = 5.
         The gap is the opening balance: turning tracking on writes no movement.

3.  balance_after(n) - balance_after(n-1)  ==  delta(n)
       — holds within an unbroken stretch of tracking. Clearing tracking and
         re-counting breaks it, and the ledger is then internally inconsistent
         on purpose (§10, row 10): measured chain `order/-3/bal7` → `cancel/+3/bal5`.

4.  per (order_id, product_id, reason, round): at most one row
       — enforced by `uq_stock_movements_order_reason_round`, partial on
         `order_id IS NOT NULL`. A manual movement has NO idempotency key at all;
         it is idempotent by value instead (setting the same number twice writes
         nothing) and serialised by the `FOR UPDATE` on the product row.

5.  one order line + one gift line of the same product  ==  ONE movement
       — `mergeStockMoves`. Measured: cart 2 + 3 of one product → `order/-5`.

Как примирить несоответствие. Читать balance_after Новейшее движение и compare с ним stock_quantity (Если они отличаются, кто-то написал тот number снаружи SetStock/ApplyStockMoves — Свидетельство является доказательством, number Это претензия. не сумма дельт.

8. Целые числа и границы

Целые единицы - это конец: INTEGER в базе данных, int32 на проводе, int В Го нет округления где-либо в этой области и нет места фракции может появиться — единственная арифметика after = stock + delta.

Границы, которые существуют:

  • stock_quantity >= 0 Это база данных CHECK; Двигатель отказывается от отрицательного result с out_of_stock before Ограничение может стрелять, поэтому отрицательный Недосягаемость через API.
  • quantity: 2147483647 принимается (измеряется), а затем допускается следующее 0 пишет manual/-2147483647 без overflow — delta это int Вперед и INTEGER в графе, и разность двух неотрицательных int32 прилипать.
  • Карт quantity зажатый в 9999 (LEAST(cart.quantity + …, 9999)), который является Единственный потолок, на котором line Можно попытаться взять off полка.

9.Как закрепляется товар promotions

Это то, что удивляет людей, потому что gift это free И все же это стоит инвентаризация.

А. gift line Вычитается точно так же, как и paid один. CreateOrderFromCart строить один StockMove за paid line и один на gift line, со всеми reason order, и Они передают их вместе (pg/orders.go:277-305). The gift материализуется как реальное order_items row (unit_price: 0, base_unit_price = его value), Так что это a Реальная вещь, которая покидает склад.

`mergeStockMoves` И они их разоряют. product, Что делает то, что делает "Купи два, получи третий free" работа: три единицы уходят в одном движении, а не два движения двух и одного.

Акция является condition отдавать в руки gift как и публикация. В promotions/apply.go:370:

if gc.Stock != nil && *gc.Stock < giftNeed(lines, res.Lines, gc.ProductID, qty) {
    continue // the rule silently does not fire
}

До того, как эта проверка существовала, короткий запас на *free* line прерванный целое order с out_of_stock — более line покупатель never выбираемый и cannot Удалить, потому что это виртуально, а не в cart стол.

""Выбывание" измеряется по отношению к THIS cart потребности (giftNeed): тот rule's own quantity, плюс каждая единица одного и того же product Покупатель платит за, плюс Дары одного и того же product из других правил кто стрелял в этом cart. А. rule что не стреляет ничего не сдерживает. решение принимается внутри двигателя, а не в решитель, именно потому, что решитель работает before ценообразование и не Знать, что gift Правила будут стрелять — это могло бы только подвести аппетит *все* live Правила, и a rule что это cart Не удовлетворяет молча ест запас Тот, кто делает.

Stock == nil (незамеченные руки gift как будто always.

Окно гонки реально и все еще открыто, но оно больше не стоит денег. order. The gift's Акции читаются giftProductsQuery (pg/promotions.go:582-591) с а plain SELECT, нет `FOR UPDATE`; Замок берется позже, по письму path. Оба находятся внутри order Сделка, и сделка остается pg_advisory_xact_lock(userID, companyID) — Сериалы, которые сериализуют одного покупателя's own двойной submit и Между разными покупателями нет ничего. И действительно, другой покупатель может быть использована последняя единица между gift проверка и вычет.

Что происходит потом (с 2026-08-20): gift Он был отброшен, а не order. Писать path Знать, какие единицы move есть gift единицы (§4.5, шаг 5), так что Недостаток в них вычитает paid Часть, не пишет движения для gift часть и возвращает обратно product id; checkout После этого удаляют gift order_items row (pg/orders.go). Покупатель получает результат, который бы произвел решатель. Он читал запас на миллисекунду позже — именно так для одного gift rule, И посмотреть за-rule Исключение ниже: тот order Проходит, нет никакого gift, и нет total изменения — а gift 0 и входит в none из items_total, discount_total или promotions[]. Смешанные движения скорее разделены чем skipped: "Бери два, получи третий free" Надпись на двух верблюдах (два вертикаля) paid единиц и удерживает free Один. gift из двух человек, из одного удерживаемый целое — Все или ничего, как у разрешителя. paid line Все еще прерывает весь order.

Эквивалентность с растворителем держится на _продукт_, не в расчете _правило_. Два gift Правила, предоставляющие *то же самое* product Слиться в одно движение (mergeStockMoves), Нехватка там удерживает оба, когда ресвер читает меньшее number Он бы выдал один и skipped второй (giftNeed Подсчитывает подарки уже предоставленный, rule через rule). направлением движения error Безопасный — это Покупатель - это never дано more единицы, чем полка, но их можно дать меньше, чем решение в одном и том же number Не запирается, не запирается, см. D10.

И вновь открывается, и сохраняет line. deductReopenedOrderStock строить его движения из регистра, где выдан gift's единицы неразличимы из paid Для тех, кто gift единицы взяты из order вместо (order_items.promotion->>'kind' = 'gift', суммировать в product) и перешел в GiftUnits В том же письме path. А. gift проданы, пока order сидеть cancelled Поэтому Не зарезервировано снова, И вновь открывающийся проходит через (pg/orders.go:574-680, owner's Решение No6 от 2026-08-20).

Единственное отличие от checkout Вот что происходит с order_items row. Проверка удалять Это: row Он был в возрасте нескольких секунд, за ним ничего не стояло и Если он откажется, покупатель пообещает product Это было never взятый off полка. Открыть заново держать это: gift был выдан, вычтен и возвращен Отмена — это факт размещения order, и ан order's snapshot это never переписанный (§3) orders.md). Что делать при доставке — пополнить, заменить Это или объяснить — это продавец's звонить и удалять row Примем факт От них отмахнуться. product ids Записаны, не проглочены.

Регистрационный баланс остается сбалансированным, но два различных механизмов, в зависимости от Если бы gift это *отдельный* product или *то же самое* product который был куплен.

*отдельно product.* Удержанный блок пишет нет `reorder` row В общем, и то round Очередной отмену счета reorder rows (§4.4., чтобы отмена Также не продвигаются, hits уникальный index и ничего не возвращает. full cancel↔Возобновление циклов after с отложенным повторным выходом из gift's в бухгалтерском учете точно order/-1, cancel/+1 (TestB54Reopen_WithheldGiftKeepsLedgerBalanced).

*То же самое product как paid line* ("Купить два, третий - free" — а first-class форма, giftNeed в services/promotions/apply.go:395 Считает paid Линии одного и того же product, и checkout покрывает его TestB5Gift_GiftOfTheBoughtProductCountsBothLines). Здесь mergeStockMoves обвал paid и free вводить один движение, поэтому скрыл gift пишется в reorder row — просто *меньше* один (-2 где checkout писать -3). Ничего о том, round Две вещи несут его вместо этого:

  • restoreOrderStock возвращает обратно последний вычет на единицу product (order или reorder, В каком бы из них ни был, но не в checkout row. Чтение checkout row Сдавал полку +3 где order проведенный 2 — единица, отчеканенная из ничего, на Каждый такой цикл (TestB54Reopen_MixedGiftOfSameProductLedger; бухгалтерский учет order/-3, cancel/+3, reorder/-2, cancel/+2).
  • deductReopenedOrderStock выводить GiftUnits как *Движения минус единицы купленных*, висевший на gift quantity — делает не читать gift quantity прямо из order_items. Со второго цикла движения больше не содержит gift В общем, и сырая марка переименовывала бы paid единица как free: тот Нехватка будет молчаливой drop и вновь открыть order Один отряд против двух проданный (TestB54Reopen_SecondRoundKeepsPaidUnitsPaid).

Недостаток в a paid line Все еще не удается открыться заново — нечего делать судно (TestStockRound_SoldOutReopenChangesNothing).

⚠️ **А. gift product Это было *deleted*, Вместо того, чтобы продавать, все еще не удается открывать** — И с другой, error. Писать-off фильтры на deleted_after IS NULL для любого вывода, не находит row, и возвраты domain.ErrNotFound *before* тот gift Ветви всегда достигаются. Измеряется на reopen_fix 2026-08-20 временным зондом: gift product мягкий-deleted пока order сидеть cancelled → UpdateOrderStatus: Not found, order все еще status 4, paid Неподвижный запас в 5. Это снова открытая нога D10 (а); это не запирается, И не было совершено ни одного.

Замок в gift-Решение было отклонено, не отложено. Решайте и пишите-off брать product rows в *разные точки* - в той же сделке. Решительность позволяет двум противоположным orders — "gift G плюс плюс paid P. Против "gift P плюс плюс paid G" - взять два rows в противоположном order Деградация не добавляет замков В общем, и покупатель получает то же самое. result.

окно size: медиана 0,12-0,15 мс (один paid line, неотслеженный запас) до 1,67-1,71 мс (десять линий, отслеживаемый запас); медиана верхней границы 0,36-2,04 мс в четырех конфигурациях, ее p99 до 7,3 мс за пробег. line count и отслеживание запасов (TestB5GiftRace_WindowMeasure, 8 пробегов × 200 orders На каждую клетку. number Теперь это *Охранник регрессии на width из окна*, не мера воздействия: следствие закрыто, интервал нет.Поведение внутри него доказано детерминистически путем TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5GiftRace_MixedMoveDeductsThePaidPartOnly, TestB5GiftRace_PartialGiftStockInTheWindowWithholdsAll и TestB5GiftRace_PaidLineShortageStillKillsTheOrder.

Все else в промо-лестнице - фондово-слепая. Цены, пороги, бюджеты и выпуск купонов never читать stock_quantity; только gift Шаг делает.

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

Случаи, когда платформа отвечает на 200 и ничего не делает, или делает что-то другое чем то, что field name Обещания, все row Он был убит, но не был отозван. зонд name дается там, где существует.

Что посылаетеЧто происходитПочему и как измеряется
stock_quantity на PUT /products/{id}ignored — тот number Неизменный, без регистра rowпреднамеренный (edit_product.go:512-522): тот product form посылает value Он был открыт, и все, что продавалось между ними, было бы переписано молча и прошло бы мимо регистра.. TestTZM5_EditProductIgnoresQuantity: 7, отправлено 999, осталось 7, книга empty, stock_low_threshold по той же просьбе applied.
low_threshold опущенный на PUT /products/{id}/stockРанее сконфигурированный порог стирается (set то NULL)Не решение — пропавший флаг. quantity иметь setQty Отделить «не отправлено» от «очистить»"; low_threshold не имеет аналогов, и обе ветви UPDATE Пишите это безоговорочно (pg/stock.go:237-248). TestTZM5_OmittedThresholdIsErased: 5-й порог - отправить {"quantity":19} → порог NULL. Ан empty body {} И стирает его тоже. дефект D1 (§14).
quantity на product чьи акции не отслеживаетсятот number это set, нет rowThe condition требует current != nil (pg/stock.go:249-259). Включение отслеживания - это изменение mode; а row "12 прибыло" - это заявление о складе, который никто не делал. reason Идентификатор 2 не работает. TestTZM5_TurnTrackingOnWritesNoMovement, TestTZM5_ManualOnUntrackedWritesNothing.
The то же самое number снова включаю PUT /products/{id}/stockнет row, updated_at все еще набитыйThe row написана только на реальных изменениях. value, с тех пор как manual Движение не имеет уникального key (личность 4). TestTZM5_ManualEditWritesDelta: три звонка (10, 7, 7) → ровно один row manual/-3/bal7.
quantity: -1 (Любое негативное)Отслеживание очищенный, нет row, in_stock Возвращаться `true`Знак — единственный способ выразить «ясно» через один optional целое число. TestTZM5_NegativeQuantityUntracks. The in_stock: true Правильно и удивляет людей: неотслеженный product never выбегать.
low_threshold: -1порог является очищенныйТот же знак rule. Измеряется на обеих конечных точках: PUT /products/{id}/stock и PUT /products/{id}.
А. Второй клик на "Отменить""секунда return единицы"cancelled → cancelled" Это переходный период и достигает запасов. layer в full; уникальный index на (order, product, reason, round) Отбрасывает дубликат. TestStockRound_ThreeCyclesReturnStock щелчки каждый Переход трех циклов дважды и утверждает запас after каждый.
limit над потолком на /products/{id}/stock/history или /products/low-stockвозвращается к default, Не до потолка, а ответ 200`if limit <= 0 \\limit > 200 { limit = 50 } (История) и > 500 { limit = 100 } (low-stock). TestTZM5_LimitFallsBackToDefault более 300 движений: limit=200 → 200 rows, limit=201 → 50, limit=100000 → 50, limit=0 → 50, limit=-7 → 50`. просить о more Чем потолок тебя зацепит меньше rows чем просить потолок.
Добавить more единиц, которые существуют в cartпринятый, только checkout отказыватьсяThe cart проверка stock_quantity IS NULL OR stock_quantity > 0 — Присутствие, не amount (pg/cart.go:23-32), ничего не резервирует. TestTZM5_AddToCartOutOfStock: Акции 1, добавлено 5, cart 5-й трюм, ConfirmCart → out of stock, акции без изменений на уровне 1. Акции 0 отказывают в добавлении FailedPrecondition out of stock.
Отслеживание клиринга когда order Открытый, затем отменить егоединицы являются не возвращены и потеряныrestoreOrderStock Возврат из книги Возвращение из книги, ApplyStockMoves неотслеженные промахи products. TestTZM5_UntrackedProductCancelReturnsNothing: запас 5 → order 2 Четкое отслеживание → cancel → запас NULL, Ведущий ведет только order/-2. Ничего не ошибается.
Отслеживание клиринга, пересчет, Отменить открытие orderтот order's единицы добавляются в new number — фантомный запасTestTZM5_UntrackMidOrderBreaksTheChain: акции 10 → order 3 (склад 7) → ясное → пересчет на 2 → cancel → запас 5. В бухгалтерской книге написано: order/-3/bal7 затем cancel/+3/bal5, Внутренне непоследовательные по конструкции. Три единицы продавец уже исключил из count Назад в магазине. дефект D2 (§14).
А. kind: "gift" rule чей gift product выбегатьтот gift это молчаливо скрываемый; тот cart, тот order И то, что платит покупатель, все оценивается без него.Акция является condition как и в случае публикации (§9)). TestB5Gift_OutOfStockGiftIsWithheldAndOrderPasses, TestB5Gift_PartialStockWithholdsTheWholeGift, TestB5Gift_UnfiredSecondRuleDoesNotEatTheStock — Все зеленое на tz_m5 2026-08-20.
А. gift product что заканчивается между Проверка и вычетИ молчаливо скрывается — gift row это deleted от order, тот paid Линии вычитаются, order Успехов и нет total движениеПисать path ухудшается на gift (§ 4.5 шаг 5, § 9). Покупателю ничего не говорит о том, что gift Он был отозван — такая же тишина, как и в gift Это было вне запасов с самого начала.. TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5Gift_WithheldGiftInTheWindowMovesNoMoney — зеленый race_close 2026-08-20.
Открытие A cancelled order чей gift Продано тем временемвновь открыть успех; тот gift Не зарезервировано снова, paid Линии есть, gift row оставаться в order и нет total движениевладелец's Решение No6 от 2026-08-20 (§9). gift line из размещённых order Это исторический факт, столь непохожий на checkout это не deleted — Продавец принимает решение о доставке. gift Теперь он не обеспечен, кроме продавца.'s own Скриншоты из игры The Uheld product ids Пойти в бревно. TestB54Reopen_SoldOutGiftDoesNotBlockReopen, TestB54Reopen_WithheldGiftKeepsLedgerBalanced — зеленый reopen_fix 2026-08-20.
Два cart линии один product (двух конфигураций, или paid + gift)один бухгалтерский учет row с суммированным deltamergeStockMoves. Ожидать одного row за order line Это естественное чтение, и оно неправильное.. TestTZM5_MergeStockMovesOneRowPerProduct: Линии 2 + 3 → order/-5, count(*) = 1.
Ожидание reason: "return" или "import" из книгиnever появляетсяНичего не пишет их; см. § 4.3 и § 12. OpenAPI Объявляют и то, и другое, и опущение reorder.
Ожидание created_by на StockMovementНе в том message вообщеНаписано на стол, never картографический (catalog.proto:1741-1754).
Редактирование акций из a dev-contour сессияпишет в live productУ кураторов нет contour филиал (`grep -c 'contourEligible\devOverlay\dc.dev' internal/api/catalog/stock.go → 0), миграция 0089` Он умышленно вынул из оверлея: это operational value из live contour, как views и рейтинг.
читать stock_quantity анонимнотот exact number возвращается, и так же stock_low_thresholdTestTZM5_ExactAnonAndEdges, без сессии: GET /products/{id} → stock_quantity: 3, stock_low_threshold: 2. GET /products возврат stock_quantity И это тоже. Это вопрос к тому, что мы хотим сделать. owner — §14, D3.

Громкие неудачи, так что вы заметите: id: 0 → 400 invalid SetProductStockRequest.Id; ан unknown, deleted иностранный product → 404 not_found Написано: Покупатель без shop Читать книгу → 403 forbidden; а checkout Недостаток запасов → 412 out_of_stock; Недостаток запасов вновь открыт на paid line → одинаковым, и тот status не move (TestStockRound_SoldOutReopenChangesNothing). Открыть заново Недостаток запасов на a *gift* line Это не является провалом вообще - см. § 9 и row выше.

11. Пределы

Не генерируется — пока не существует ссылки на машину для этой области (§ 13)). Каждый value Ниже было прочитано source 2026-08-20 и, где зонд колонки говорит Так, казненный.

LimitценностьГде живетизмеряемый
stock/history default limit50pg/stock.go:307yes — limit 0/−7/201/100000 все → 50 rows
stock/history max limit200pg/stock.go:307yes — limit=200 → 200 rows
low-stock default limit100pg/stock.go:277из source
low-stock max limit500pg/stock.go:277из source
stock_quantity rangeNULL или >= 0chk_products_stock_quantity (0084)yes — 2147483647 принятый
stock_low_threshold rangeNULL или >= 0chk_products_stock_threshold (0084)из source
delta<> 0CHECK (0084)из source; нулевые дельты skipped before вставить
round>= 0chk_stock_movements_round (0116)из source
reasonодного из order, cancel, return, manual, import, reorderstock_movements_reason_check (0092)yes — прямой вставкой из 'wat' → SQLSTATE 23514
commentTEXT, неограниченный; '' храниться как NULLNULLIF в ApplyStockMovesиз source
cart line quantity9999LEAST(…, 9999) в AddToCartиз source
stock/history pagingnone — limit Только, без курсора, нет offsetpg/stock.go:306-320из source
low-stock pagingnonepg/stock.go:276-300из source

12. Реестр отрицаний (§3.9 руководящих спецификаций))

Каждое негативное высказывание в этом document, И как это честно храниться. Сегодня здесь охраняют ворота, вся колонна "manual сканирование after каждый «Волна», и это само по себе является точкой §14., D4.

Заявление§Как это проверяется
А. PAT cannot Назовите три конечных точки акций2`grep 'SetProductStock\GetLowStock\GetProductStockHistory' internal/core/services/guard.go` → 0 hits. Повороты false День в line Добавлено там.
Σ delta never равный запасу3, 7TestTZM5_TurnTrackingOnWritesNoMovement Утверждает, что бухгалтерия empty after Отслеживание включено. false когда вводится движение открытия, и test красный.
Ничего не пишет reason: "return"4.3, 10`grep -rn 'StockReasonReturn' --include='*.go' internal/ \grep -v _test` → только заявление. Нет. test. Щебет item.
Ничего не пишет reason: "import"4.3, 10Тот же грабитель, тот же result. Нет. test. Щебет item.
created_by не подвергаться4.2, 10grep 'created_by' api/catalog/catalog.proto в StockMovement block → Нет, не надо. item.
У биржевых торговцев нет dev-contour филиал4.1, 10`grep -c 'contourEligible\devOverlay\dc.dev' internal/api/catalog/stock.go` → 0. Сладкая item.
The gift Прочтение не запирается9giftProductsQuery SQL не содержит FOR UPDATE (pg/promotions.go:588-591). Преднамеренное: запирание там зашло бы в тупик две противоположные точки. orders (§9).
Удержанный gift листья нет бухгалтерский учет row9Писать path continues before тот INSERT INTO stock_movements Когда все вместе move был gift Взятые на борт. checkout через TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder (читается order's движения, требует ровно одного, на paid product) и вновь открываясь TestB54Reopen_WithheldGiftKeepsLedgerBalanced (два дальше cancel↔Возобновляемые циклы покидают gift's бухгалтерия order/-1, cancel/+1). Это имеет значение для отмены — а return Он построен из книги (§ 4.4), поэтому фантом row Он положил бы на полку единицу, которую order never принимать.
Открыть заново never Удалить An order_items row9, 13grep -n 'DELETE FROM order_items' internal/adapters/repositories/pg/orders.go → Один удар, внутри CreateOrderFromCart. обнаженный: TestB54Reopen_SoldOutGiftDoesNotBlockReopen перечитывает gift row after - Неоткрытый снова. false День, когда вновь открылся path Растет удаление — которое перепишет размещенный order's snapshot.
Ничего, кроме gift step reads stock in the promo engine (недоступная ссылка)9`grep -n 'Stock' internal/core/services/promotions/*.go \grep -v _test → 4 hits, все GiftCandidate.Stock и его использование в apply.go:370`. Щебет item.
Нет никакого paging на любом из прочитанных конечных точек11Источник: Sweep item.
Нет никаких машинных ссылок / нет ворот для этой областиheader, 4.3, 11Правда, пока internal/api/discovery/ не имеет stock_reference.go. Щебет item — и closing Он удаляет это row.
/docs/stock не обслуживаетсяheadergrep 'mux.Get("/docs' internal/api/discovery/discovery.go. Повороты false День, когда маршрут будет добавлен.

13 Почему так происходит

Короткие ответы на вопросы, которые заставляют следующего разработчика «исправить» что-то, что может быть использовано для решения проблемы. Не разбиты.

Почему number Жизнь на product И не в бухгалтерской книге. Каждый читает path в тот shop — listing, card, cart, gift Право на участие - потребности "сколько их существует" под page load. Получение его из реестра означает SUM за product за page. Регистрационный журнал существует для *объяснять* тот number, Не держать его. price из этого Выбор — это идентичность 2, и она утверждается вслух, а не вслух. hidden.

Почему вводится движение before тот number это updated. Уникальный index Это единственное, что делает повторение безопасным, и оно живет в базе данных. "Одно место, которое выживает после перезапуска и второй капсулы. Решение "это повторение" В процессе память будет работать, пока их не будет два..

Почему round считать противоположным reason. См. § 4.4. Это наименьшее очевидный line В этом районе и сломается то, что называется «упрощением». first: с его own счетчик, двойной клик на "Отменить" возвращает единицы дважды.

Почему отмена строится из реестра, а не из order линии. Если часть из order Не были отслежены, те products не имеет бухгалтерского учета rows И есть еще ничего не делать return. Чтение order Вместо этого линии изобретали бы запас для услуг. Цифровые товары.

Почему открытая книга читает только новейшую `cancel` row за product (DISTINCT ON). Ан order Теперь у него столько же отмен, сколько циклов., mergeStockMoves И если бы они были открыты, то все бы было бы вычтено. order когда-либо возвращался на протяжении всей истории.

Почему не удержали gift это deleted в checkout и вновь открыта для. В checkout тот row Он стар, и за ним ничего не стоит — оставив его, он обещает Покупатель a product это never На открытии того же самого row рекорды Что-то, что произошло на самом деле: gift был выдан, вычтен и возвращен Если бы он был отменен, то его бы переписали. snapshot из размещённых order, которые не могут быть использованы в данной платформе (§ 3) orders.md), и будет Скрыть от продавца тот факт, что он нуждается в order Решать, что доставить.

Почему? return не требует a live product. Ан order держать deleted product должен оставаться отменяемым; строгая проверка не сработает status переход и выход из order постоянно застрял.

Почему? gift Вычитается как а paid line. Он действительно покидает склад. Альтернатива — раздача подарков off Книги — делают number неправильная для один product Продавец, скорее всего, иссякнет.

Почему gift доступность измеряется этим cart's необходимость и не суммируется по всем правилам. А. rule В противном случае, огонь не будет зарезервировать запас вдали от Поэтому проверка живет внутри двигателя., after известно, какие правила увольняют.

14. Технический долг, названный вслух

Долг, названный в document Это некий план, безымянный, это сюрприз для любого, кто его не знает. Заказывается по стоимости error, Не в легкости фиксации.

  • D1 — `low_threshold` стирается, когда опущен. Тихо data Потери на самом Обычный звонок, {"quantity": N}. SetStock нуждается в setThreshold флаг Симметричный с setQty. измеряемый: TestTZM5_OmittedThresholdIsErased.
  • D2 — Отслеживание клиринга в середине-order складские запасы. Чистый - пересчет → cancel Добавить в order's единицы продавцу's new count, и книга становится внутренне непоследовательный (bal7 сопровождаемый +3 → bal5). Либо отказаться от Четкое отслеживание во время product находится внутри нетерминального order, или написать компенсирующее движение.: TestTZM5_UntrackMidOrderBreaksTheChain.
  • D3 — exact запас есть public. stock_quantity и stock_low_threshold приходить Вернуться к анонимному читателю GET /products/{id} и GET /products. А. Конкурент может смотреть shop's инвентарь и выводить его продажи. in_stock один будет служить витрине. владелец's Необходимость принятия решения — Это может быть умышленно ("3 левый!" значки). Измеряется: TestTZM5_ExactAnonAndEdges.
  • D4 — В этом районе нет ворот и нет ссылки на машину. promotions.md хранится честный BuildPromoReference() и байт-за-байт сравнение встроенных Ничего из этого. sort существует для акций: reason Список, limits и Все отрицания в §12 написаны от руки и могут затухнуть в тишине. предварительным условием для Status: current.
  • D5 — Поданный контракт лжет о `reason`. The OpenAPI description из StockMovement.reason читать order | cancel | return | manual | import: это опущение reorder, который является reason вновь открыт order На самом деле пишет и реклама return и import, Который ничего не пишет. cmd/core/core-api.swagger.json и internal/pb/api/core-api.swagger.json. Ремонт находится в api/catalog/catalog.proto:1748 и потребности make gen — из scope для этого бега.
  • D6 — Прото comment на `EditProductRequest.stock_quantity` описание поведение, которое обработчик не осуществляет. Говорит, что отрицательный value ясность Отслеживание и неотрицательный sets Для того, чтобы не скрывать, что он field полностью (edit_product.go:512-522). Либо drop тот field или drop тот comment.
  • D7 — `mergeStockMoves` сливаться `ProductID` только и держит first move's Reason, Comment и Author. Нет. current Caller смешивает причины в одной партии, Сегодня нет ничего плохого, будущее будет иметь свое. rows молча рухнул.
  • D8 — Продавец, обращенный к письменности path не имеет test охвата вообще. grep -rln 'SetProductStock\|SetStock\|GetLowStock\|StockHistory' --include='*_test.go' на протяжении всего tree возвращенный ничего before это document. Все в §10 Что касается этих трех конечных точек, то они были измерены зондами, написанными для этого прогона. и выполнены под -overlay, i.e. Зонды не находятся в хранилище. Они должны быть совершены, или rows Поддерживаемые снова становятся претензиями.
  • D9 — бухгалтерский учет cannot Ответить Кто". created_by хранится и never возвращенный; round хранится и never Конечная точка называется "где Пять единиц "уехали" и cannot Скажи, кто их взял.
  • D10 — тот gift Окно расы измеряется; его следствие закрывается, интервал не является. С 2026-08-20 гг. обнаружен дефицит при написании-off падает gift Вместо того чтобы order (§9), так что окно ничего не стоит покупателю. Сам интервал остается открытым по решению: FOR UPDATE В установленный срок будет Замок повернуть вспять order между двумя противоположными orders и тупик. Цифры в §9 Оставайтесь в качестве регрессионного щитка на окне's width. Три вещи до сих пор открытый, none И они были заперты: (а) если gift product это *deleted* (Не продано) В этом окне, в этом письме-off's deleted_after IS NULL filter Не находит row и order Умирает вместе с ErrNotFound — Тот же самый " order Потерянный над free line", Достижимо только продавцом, удаляющим product середина-checkout. The То же отверстие существует на повторном открытии, куда легче попасть, потому что order Можно сидеть cancelled в течение нескольких недель: измерено 2026-08-20 reopen_fix — gift product мягкий-deleted в то время как cancelled → UpdateOrderStatus: Not found, status оставаться 4, paid Неподвижный запас. filter бегать before тот gift филиал, Решение No6 не cover Не установлена, и зонд не был совершен.; ~~(b) повторное открытие не ухудшается~~ — закрыто 2026-08-20 гг. owner's Решение No6: Восстановление занимает gift единицы order_items и деградирует таким же образом, хранить line как исторический факт (§9), закреплённый TestB54Reopen_SoldOutGiftDoesNotBlockReopen и TestB54Reopen_WithheldGiftKeepsLedgerBalanced; (с) два gift правила один product слиться в одно движение, поэтому нехватка удерживает оба когда решение одновременно number Он должен был бы выдать один (§ 9)).
  • D11 — `reason` `return` и `import` Заповедник и мертвый. Частичные возвраты (MarkOrderRefunded без отмены) не касаться запасов вообще; Матрица пересечения уже перечисляет это как дыру, ожидающую своего часа. owner's решение. Каталог import не set либо.
  • D12 — тот low-stock rule Реализуется дважды. domain.StockLow Вперед и тот WHERE из LowStock в SQL. Сегодня они согласны только потому, что CHECK Запрет отрицательных акций (== 0 vs <= 0). domain.StockLow и domain.StockTracked иметь Нет, нет-test звонящие, и domain.StockUpdate.Quantity это never населенный кем угодно.
  • D13 — нет paging на любом из прочитанных конечных точек. А. product С долгой историей это читать только 200 rows Глубоко, без курсора.

15. Рецепты

$API Это shop's API base. $SESSION это Токен заседания кабинета, не PAT — См. §2, а PAT Отказано в PAT_METHOD_NOT_ALLOWED.

15.1 Начать отслеживать product и set тот number

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": 12, "low_threshold": 3}'

проверять: Ответ несет {"quantity":12,"low_threshold":3,"in_stock":true}, перечитывать из product Вместо того, чтобы отозваться. GET /products/$PRODUCT_ID/stock/history — Это empty, И это правильно: включение отслеживания не является движением. (§10.Список начинается с вашего first Реальные перемены.

⚠️ Всегда посылать low_threshold Вместе с тем, quantity. И если он стирает порога, set Ранее (§ 10), row 2).

15.2 Исправить number after пересчет

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": 9, "low_threshold": 3}'

проверять: GET /products/$PRODUCT_ID/stock/history Теперь у одного row — {"delta":-3,"balance_after":9,"reason":"manual","comment":"ручная правка остатка"}. Отправка 9 Видеть 200 доказывает только то, что запрос был принятый.

15.3 Прекратить отслеживание (услуга, цифровой товар, сделанный для того, чтобы order)

curl -X PUT "$API/products/$PRODUCT_ID/stock" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"quantity": -1, "low_threshold": -1}'

проверять: GET /products/$PRODUCT_ID возврат нет stock_quantity field (отсутствует, не 0) и in_stock: true. The product теперь never Не вылезай из книги. row - Отправка. 0 вместо того, чтобы -1 означает обратное — «отслежено, продано» «Выйти» — и взять product off витрина магазина.

15.4 Установить только порог "бега на низком уровне"

curl -X PUT "$API/products/$PRODUCT_ID" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"item": {"stock_low_threshold": 5}}'

умышленно не Конечная точка акции: эта ничего не касается else, тогда как PUT …/stock без quantity Переписывает порог и бугорки updated_at. stock_quantity в этом body будет принят и ignored.

проверять: GET /products/$PRODUCT_ID шоу stock_low_threshold: 5 и ан неизмененный stock_quantity; В бухгалтерской книге нет new row.

15.5 "Куда делись пять единиц"

curl "$API/products/$PRODUCT_ID/stock/history?limit=50" \
  -H "Authorization: Bearer $SESSION"

Читайте как рассказ, новый first:

[
  {"id":"38","delta":2,"balance_after":8,"reason":"cancel","order_id":18,"comment":"отмена заказа"},
  {"id":"37","delta":-2,"balance_after":6,"reason":"order","order_id":18},
  {"id":"36","delta":-2,"balance_after":8,"reason":"manual","comment":"ручная правка остатка"}
]

id Прибывает как а струна (int64). order_id это 0 Когда движение было manual. А. reorder row Продавец ставит на cancelled order Вернуться к работе.

Проверить number само собой: balance_after из top row должен быть равным stock_quantity на product (Идентификатор 1). не сумма delta — См. § 7.

Не просите о more 200; 201 дает вам 50 (§ 10)).

15.6 Что заканчивается

curl "$API/products/low-stock?limit=100" -H "Authorization: Bearer $SESSION"

проверять: а product в 0 без порога, а product в 2 без пороговое значение не; а product в 2 с low_threshold: 2 это (TestTZM5_LowStockRules). Неотслеженный products never Появились. Проекты есть.

15.7 Понять, что checkout это failed с out_of_stock

The order Отказывали целиком — ничего не вычитали и никакой бухгалтерской книги row был написан (TestTZM5_AddToCartOutOfStock Перечитывает запас after Неудача: без изменений). Три причины, в order вероятность:

  1. The cart держать more единицы, чем product Есть. cart Только проверяет, что product не находится на нуле; never Проверить amount ничего не оставляет (§ 10)).
  2. Тот же самый product появляется на двух cart линии и их *сумма* превышает запас — движения сливаются в product (§4.5).
  3. А. gift из одного product Покупатель также покупает: gift's Необходимость включает в себя paid единиц (§9). gift Сама бы была скрыта молча, а не провалена тот order, Это проявляется как обычная нехватка paid line.

В checkout а gift не является причиной. В момент принятия решения не является Выписано; из запаса на запись-off время, когда оно выпадает из order (§9). Отказ называется a product покупатель never Выбрано, это paid line из этого product, Не то gift.

Ответы снова открыты, и для того же reason. UpdateOrderStatus отойти от cancelled пересчитывает книгу, но читает gift единицы off тот order Сама по себе, а gift проданный Во время отмены просто не Если ответ снова открыт, то он будет выполнен (§9). out_of_stock, тот product Названия это a paid line. А. free line может быть остановлена вновь в Один из способов, и выглядит он по-другому: gift product Это было *deleted* ответы not_found, не out_of_stock (§9, D10 (a)) Продавец's own Экран является единственным место незащищенное gift Появляется: line До сих пор в order, Полка не является.

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

  • 2026-08-20, база данных tz_m5 на localhost:5433, schema построенный goose из sql/migrations ветвь feature/promo-platform (миграция через 0116), Бинарная система, построенная из этой ветви.
  • Существующее покрытие, повторное использование и зеленый (4/4): TestStockRound_ThreeCyclesReturnStock, TestStockRound_LedgerHasOnePairPerCycle, TestStockRound_SoldOutReopenChangesNothing, TestStockRound_ParallelCancelsReturnStockOnce (internal/api/catalog/stock_second_round_integration_test.go).
  • The reopen/gift Деградация §9 (owner's Решение No6), добавлено 2026-08-20 база данных `reopen_fix`: TestB54Reopen_SoldOutGiftDoesNotBlockReopen (тот вновь открытые проходы, paid line вычитается, то есть gift Нет, не так. gift row и оба выживают и TestB54Reopen_WithheldGiftKeepsLedgerBalanced (два дальше cancel↔Возобновление циклов: paid бухгалтерский учет order/-2, cancel/+2, reorder/-2, cancel/+2, reorder/-2; gift бухгалтерский учет order/-1, cancel/+1 И ничего more). Оба были красный before изменение с UpdateOrderStatus: out of stock (internal/api/catalog/promo_reopen_integration_test.go).
  • Тот же самый-product gift форма, добавленная 2026-08-20 в базу данных `reopen_skeptic11` after Обзор найдено решение No6 об утечке запасов в нем: TestB54Reopen_MixedGiftOfSameProductLedger — красный на first Срез No6 с остаток 3, ждали 2 … журнал: [order/-3 cancel/+3 reorder/-2 cancel/+3], i.e. Стелла получила единицу, которая не order когда-либо удержанный; TestB54Reopen_SecondRoundKeepsPaidUnitsPaid — красный раз restoreOrderStock одиноко был fixed, с заказ держит 1 единиц из двух оплаченных … журнал: [order/-3 cancel/+3 reorder/-2 cancel/+2 reorder/-1]. Каждый из ворот покрывает половину И не только половина из них –.
  • Подарок-side покрытие, повторное закрытие и зеленый (9/9): TestB5Gift_*, включая OutOfStockGiftIsWithheldAndOrderPasses, PartialStockWithholdsTheWholeGift, UnfiredSecondRuleDoesNotEatTheStock, GiftOfTheBoughtProductCountsBothLines, UntrackedStockStillHandsTheGiftOut.
  • Написано для этого document и выполнено (9 сценариев, все зеленые)): TestTZM5_TurnTrackingOnWritesNoMovement, TestTZM5_ManualEditWritesDelta, TestTZM5_OmittedThresholdIsErased, TestTZM5_NegativeQuantityUntracks, TestTZM5_ManualOnUntrackedWritesNothing, TestTZM5_HistoryIsExposedAndTenantScoped, TestTZM5_LowStockRules, TestTZM5_EditProductIgnoresQuantity, TestTZM5_AddToCartOutOfStock; и второй партии: TestTZM5_LimitFallsBackToDefault, TestTZM5_UntrackMidOrderBreaksTheChain, TestTZM5_UntrackedProductCancelReturnsNothing, TestTZM5_AnonymousSeesExactStock, TestTZM5_MergeStockMovesOneRowPerProduct, TestTZM5_ReasonsActuallyWritten, TestTZM5_ExactAnonAndEdges. ⚠️ Эти зонды пробежали через `go test -overlay` и являются NOT в репозитории — Этот забег был рассчитан на один file. Пока они не будут совершены, § 10 покоится на измерение, которое cannot Повторяем всем else. Это D8.
  • 1 и 2 были измерены в SQL, не обосновывается, а над data Все сумки остались позади: 7 products с движениями, balance_after из новый row равный stock_quantity в 7/7, и Σ delta в равной степени 0/7.
  • The reason перепись населения (cancel 11, manual 304, order 12, reorder 8, return 0, import 0) это GROUP BY по той же базе данных, плюс прямой Вставки, доказывающие CHECK принимать return/import и отказывает 'wat'.
  • The PAT Заявление §2 приходит из чтения internal/core/services/guard.go: getRequiredRoles матчи "/vizenpro.api.catalog.v1.Catalog/*" → {RoleUser}, так public-метод не используется, и DefaultMethodScopes Не имеет входа Для любого из трех методов → PermissionDenied PAT_METHOD_NOT_ALLOWED. Это было не выполненный над live HTTP; Это самый слабый link в этом document и тот first Что нужно подтвердить на стенде.
  • Окно гонки number в §9 заполнено 2026-08-20 Измерение test Названы они там; before Это placeholder был преднамеренным (§3.9: нет) value без измерения).
  • Деградация §9 была измерена в тот же день., база данных race_close, Четыре детерминированных сценария гонки плюс один денежный, каждый красный before изменение зеленый after: TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder, TestB5GiftRace_MixedMoveDeductsThePaidPartOnly, TestB5GiftRace_PartialGiftStockInTheWindowWithholdsAll, TestB5Gift_WithheldGiftInTheWindowMovesNoMoney, и TestB5GiftRace_PaidLineShortageStillKillsTheOrder, который был зеленым before и after через design — Именно охрана не распространила деградацию на paid Его зубы были проверены мутацией: расширение дефицитной ветви до любой product Соседи снова запускают зеленый в той же базе данных: -run 'TestB5|TestB49|TestB54|TestStock'.

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

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