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. | ||
| Знать кто Переместить акции | недоступной через API | stock_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_quantity | INTEGER, NULL = не отслеживается, >= 0 исполненный chk_products_stock_quantity | PUT /products/{id}/stock; каждый order сделка |
stock_low_threshold | INTEGER, NULL = Предупреждение только при нулевом, >= 0 исполненный chk_products_stock_threshold | PUT /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)
| колонна | значение |
|---|---|
id | BIGINT личность. `int64` → а JSON струна на проводе. |
company_id | арендатор, NOT NULL — Каждый столик знает свое shop |
product_id | ON DELETE CASCADE |
order_id | тот order что вызвало; NULL для manual редактировать |
delta | подписанный, CHECK (delta <> 0); минус = оставленная полка |
balance_after | запас after Это движение |
reason | См. § 4.3 |
round | тот "cancel → Вернуться в «Работа», >= 0, Добавлено миграцией 0116 |
comment | TEXT; ан 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 | Написано, когда | Написано |
|---|---|---|
order | checkout Забирает единицы off | CreateOrderFromCart |
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 Опять же, но секундаorderrow Для того же 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:
mergeStockMovesВсе движения одного product в одном, сохраняя first-видневшийся order (Человек читает книгу, и она следует за ним. order линии).- Двигаться вместе с
delta == 0есть skipped. SELECT stock_quantity … FOR UPDATEна product row. Для return (delta > 0) тотdeleted_after IS NULLfilter Упал: ан order держать а deleted product должна быть отменена, иначе строгая проверка Неудачи всей status переходный период и order Он застрял бы навсегда. return для product которое больше не существует, является skipped, не является error.stock_quantity IS NULL→ Неотслеженный products не производить ни одного rows.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 Только два звонка. setGiftUnitsОба они забирают возвращенный список.: checkout, от gift линии Он вот-вот вставит, и открывать, от gift линии order уже Они отличаются только тем, что делают с удержанным. product — checkout удаляет егоorder_itemsrow, Открытый держит его.- Включить движение 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. TheidТай-брейк сохраняет список стабильным по звонкам, когда акции и 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_stockbefore Ограничение может стрелять, поэтому отрицательный Недосягаемость через 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, нет row | The 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 с суммированным delta | mergeStockMoves. Ожидать одного 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_threshold | TestTZM5_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 limit | 50 | pg/stock.go:307 | yes — limit 0/−7/201/100000 все → 50 rows |
stock/history max limit | 200 | pg/stock.go:307 | yes — limit=200 → 200 rows |
low-stock default limit | 100 | pg/stock.go:277 | из source |
low-stock max limit | 500 | pg/stock.go:277 | из source |
stock_quantity range | NULL или >= 0 | chk_products_stock_quantity (0084) | yes — 2147483647 принятый |
stock_low_threshold range | NULL или >= 0 | chk_products_stock_threshold (0084) | из source |
delta | <> 0 | CHECK (0084) | из source; нулевые дельты skipped before вставить |
round | >= 0 | chk_stock_movements_round (0116) | из source |
reason | одного из order, cancel, return, manual, import, reorder | stock_movements_reason_check (0092) | yes — прямой вставкой из 'wat' → SQLSTATE 23514 |
comment | TEXT, неограниченный; '' храниться как NULL | NULLIF в ApplyStockMoves | из source |
| cart line quantity | 9999 | LEAST(…, 9999) в AddToCart | из source |
stock/history paging | none — limit Только, без курсора, нет offset | pg/stock.go:306-320 | из source |
low-stock paging | none | pg/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, 7 | TestTZM5_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, 10 | grep '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 Прочтение не запирается | 9 | giftProductsQuery SQL не содержит FOR UPDATE (pg/promotions.go:588-591). Преднамеренное: запирание там зашло бы в тупик две противоположные точки. orders (§9). | ||
| Удержанный gift листья нет бухгалтерский учет row | 9 | Писать 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 row | 9, 13 | grep -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 не обслуживается | header | grep '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'sdeleted_after IS NULLfilter Не находит row и order Умирает вместе сErrNotFound— Тот же самый " order Потерянный над free line", Достижимо только продавцом, удаляющим product середина-checkout. The То же отверстие существует на повторном открытии, куда легче попасть, потому что order Можно сидеть cancelled в течение нескольких недель: измерено 2026-08-20reopen_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Запрет отрицательных акций (== 0vs<= 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 вероятность:
- The cart держать more единицы, чем product Есть. cart Только проверяет, что product не находится на нуле; never Проверить amount ничего не оставляет (§ 10)).
- Тот же самый product появляется на двух cart линии и их *сумма* превышает запас — движения сливаются в product (§4.5).
- А. 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