test(e2e): scenario documents-edge — критичные edge-кейсы посту

10 шагов покрывают самую опасную зону системы (потеря денег/остатков):

1. Bootstrap: орг + admin + product + supply (10 шт по 100 KZT).
2. Supply.Post → stock=10 invariant.
3. RetailSale qty=15 (>stock 10) → POST /post → 409 «Недостаточно».
4. После заблокированного post: stock=10 + Stock == Σ StockMovement.
5. RetailSale PaidCash+PaidCard < Total → 4xx (валидация платежа).
6. PUT проведённой Supply → 409.
7. DELETE проведённой Supply → 409.
8. После Sale qty=5: unpost Supply qty=10 → 409 (stock уйдёт в минус).
9. Дубль штрихкода в одной орге → 4xx.
10. Тот же штрихкод в другой орге → 201 (per-tenant unique).

Запуск: `bash tests/e2e/run.sh documents-edge --api-only`.
Все 10 шагов зелёные после фиксов RetailSale.Post + Supply.Unpost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-23 12:33:51 +05:00
parent 7a4b34bc2f
commit 17a454cce5
3 changed files with 488 additions and 0 deletions

View file

@ -0,0 +1,105 @@
# E2E report: documents-edge
Запущен: 2026-05-23T07:32:43.038Z
Длительность: 7.8с
**Итог:** 10 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 10)
## ✓ Step step01_bootstrap: SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT)
Длительность: 4463мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Орг + админ созданы | ✓ org=f08ddf4a-8b1f-47e6-8c77-9a819330266c |
| api | Counterparty создан | ✓ |
| api | Product создан | ✓ fbcffe23-a038-4c55-b68e-255a8fb06ebf |
| api | Supply Draft создана | ✓ afe0920c-f338-4009-9e65-9df63c55f995 |
## ✓ Step step02_post_supply_stock_10: Supply провести: stock=10, ReferencePrice=100, Cost=100
Длительность: 634мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Supply.Post → 200/204 | ✓ actual=204 |
| db | Stock.Quantity == 10 | ✓ qty=10 |
## ✓ Step step03_oversell_blocked: RetailSale qty=15 (больше остатка 10), POST /post возвращает 409
Длительность: 812мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST RetailSale Draft (qty=15) | ✓ actual=201 {"id":"2d69366b-c3b3-49fb-a230-fc5e6bc49ce5","number":"ПР-2026-000001","date":"2026-05-23T07:32:51Z","status":0,"storeId |
| api | POST /post → 409 (oversell) | ✓ actual=409 {"error":"Недостаточно остатка для проведения чека.","lines":[{"productId":"fbcffe23-a038-4c55-b68e-255a8fb06ebf","produ |
## ✓ Step step04_oversell_stock_unchanged: После заблокированного post stock остался 10, StockMovement не добавлен
Длительность: 328мс
| Тип | Проверка | Результат |
|---|---|---|
| db | Stock остался 10 после заблокированного post | ✓ qty=10 |
| db | Stock == Σ StockMovement (invariant) | ✓ sum=10 qty=10 |
## ✓ Step step05_payment_mismatch_blocked: RetailSale с PaidCash+PaidCard не равной Total отвергается на post
Длительность: 79мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Платёж ≠ Total → 4xx на post | ✓ actual=400 {"error":"Сумма оплаты 300.00 меньше итога 400.00. Доплатите или измените позиции чека.","field":"PaidCash"} |
## ✓ Step step06_edit_posted_supply_blocked: PUT проведённой Supply (Posted) возвращает 409
Длительность: 114мс
| Тип | Проверка | Результат |
|---|---|---|
| api | PUT проведённой Supply → 409 | ✓ actual=409 {"error":"Только черновик может быть изменён. Сначала отмени проведение."} |
## ✓ Step step07_delete_posted_supply_blocked: DELETE проведённой Supply возвращает 409
Длительность: 42мс
| Тип | Проверка | Результат |
|---|---|---|
| api | DELETE проведённой Supply → 409 | ✓ actual=409 {"error":"Нельзя удалить проведённый документ. Сначала отмени проведение."} |
## ✓ Step step08_unpost_negative_blocked: После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус)
Длительность: 195мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Sale qty=5 проведён | ✓ actual=204 |
| api | Unpost Supply при stock<unpost-qty 409 | actual=409 {"error":"Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).","lines":[{"productId":"fbcf |
## ✓ Step step09_barcode_unique_within_org: Дубль штрихкода в одной орге, POST второго product отвергается
Длительность: 22мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST product с тем же barcode → 4xx | ✓ actual=400 {"error":"Штрихкод 201568651undefined5 уже используется товаром «Edge Product 1779521563038»."} |
## ✓ Step step10_barcode_per_tenant: Тот же штрихкод в другой орге допустим (per-tenant unique)
Длительность: 1152мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST product с тем же barcode в другой орге → 201 | ✓ actual=201 {"id":"7d885a0a-7ca6-4154-9d46-58a4d6550a83","name":"Tenant-2 product (same barcode)","article":"1","description":null," |
| db | В product_barcodes 2 записи с этим Code (одна на орг) | ✓ count=2 |
## Summary
- Passed: 10
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.

View file

@ -0,0 +1,351 @@
/**
* Step-handlers для documents-edge. Самые опасные edge-кейсы документов:
* - oversell-protection (stock не уходит в минус),
* - immutability проведённых документов,
* - валидация платежа,
* - уникальность штрихкодов per-tenant.
*
* Любая утечка/обход критический баг.
*/
import { login, makeClient } from '../lib/api.js'
import { countRows, psql } from '../lib/db.js'
import { generateEan13 } from '../lib/barcode.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
const TS = Date.now()
interface Ctx {
apiOnly: boolean
superAdminToken?: string
orgId?: string
adminToken?: string
unitId?: string
groupId?: string
currencyId?: string
retailPriceTypeId?: string
storeId?: string
retailPointId?: string
supplierId?: string
productId?: string
productBarcode?: string
supplyId?: string
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
try { return JSON.stringify(x).slice(0, 220) } catch { return String(x) }
}
async function ensureSuperAdmin(ctx: Ctx): Promise<string> {
if (!ctx.superAdminToken) {
const sa = await login('admin@food-market.local', 'Admin12345!')
ctx.superAdminToken = sa.accessToken
}
return ctx.superAdminToken
}
// ---------------------------------------------------------------------------
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
const sa = await ensureSuperAdmin(ctx)
const saApi = makeClient(sa)
// 1. Создаём орг + админа.
const orgRes = await saApi.post('/api/super-admin/organizations', {
org: {
name: `Edge Shop ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge', adminFirstName: 'Admin',
adminEmail: `edge-${TS}@example.kz`, adminPosition: 'Директор',
})
if (orgRes.status !== 200) {
report.bug({ step: '01', severity: 'critical',
title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return
}
ctx.orgId = orgRes.data.organization.id
const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
ctx.adminToken = adminSess.accessToken
check(step, { kind: 'api', description: 'Орг + админ созданы', ok: true,
detail: `org=${ctx.orgId}` })
const api = makeClient(ctx.adminToken)
// 2. Lookups.
const units = await api.get('/api/catalog/units-of-measure')
ctx.unitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
ctx.groupId = grps.data?.items?.[0]?.id
if (!ctx.groupId) {
const g = await api.post('/api/catalog/product-groups', { name: 'Group', parentId: null })
ctx.groupId = g.data?.id
}
const currencies = await api.get('/api/catalog/currencies?pageSize=200')
ctx.currencyId = currencies.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const priceTypes = await api.get('/api/catalog/price-types')
ctx.retailPriceTypeId = priceTypes.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
// 3. Counterparty (поставщик).
const cp = await api.post('/api/catalog/counterparties', {
name: 'Edge Supplier', type: 1, bin: '012345678901', phone: '+77001234567',
})
ctx.supplierId = cp.data?.id
check(step, { kind: 'api', description: 'Counterparty создан', ok: cp.status === 201 })
// 4. Store + RetailPoint из bootstrap.
const stores = await api.get('/api/catalog/stores?pageSize=10')
ctx.storeId = stores.data?.items?.[0]?.id
const rps = await api.get('/api/catalog/retail-points?pageSize=10')
ctx.retailPointId = rps.data?.items?.[0]?.id
// 5. Product.
ctx.productBarcode = generateEan13()
const prod = await api.post('/api/catalog/products', {
name: `Edge Product ${TS}`,
unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 200 }],
})
ctx.productId = prod.data?.id
check(step, { kind: 'api', description: 'Product создан', ok: prod.status === 201,
detail: prod.status === 201 ? ctx.productId : asString(prod.data) })
// 6. Supply Draft (10 шт по 100 KZT).
const supplyRes = await api.post('/api/purchases/supplies', {
storeId: ctx.storeId, supplierId: ctx.supplierId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 10, unitPrice: 100 }],
})
ctx.supplyId = supplyRes.data?.id
check(step, { kind: 'api', description: 'Supply Draft создана', ok: supplyRes.status === 201,
detail: supplyRes.status === 201 ? ctx.supplyId : asString(supplyRes.data) })
}
export async function step02_post_supply_stock_10({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`)
check(step, { kind: 'api', description: 'Supply.Post → 200/204',
ok: post.status === 200 || post.status === 204, detail: `actual=${post.status}` })
// Проверяем что stock=10
const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`)
const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId)
const qty = Number(row?.quantity ?? 0)
check(step, { kind: 'db', description: 'Stock.Quantity == 10',
ok: qty === 10, detail: `qty=${qty}` })
if (qty !== 10) report.bug({ step: '02', severity: 'high',
title: 'Stock после Supply.Post не равен 10', detail: `получено qty=${qty}` })
}
export async function step03_oversell_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Создаём чек с qty=15 (превышает stock=10).
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId,
currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 15, unitPrice: 200, vatPercent: 12 }],
paidCash: 3000, paidCard: 0,
})
check(step, { kind: 'api', description: 'POST RetailSale Draft (qty=15)',
ok: sale.status === 201, detail: `actual=${sale.status} ${asString(sale.data).slice(0, 120)}` })
if (sale.status !== 201) return
const post = await api.post(`/api/sales/retail/${sale.data.id}/post`)
const ok = post.status === 409
check(step, { kind: 'api', description: 'POST /post → 409 (oversell)',
ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` })
if (!ok && post.status >= 200 && post.status < 300) {
report.bug({ step: '03', severity: 'critical',
title: 'OVERSELL: stock уходит в минус — продажа qty>остаток прошла',
detail: `Post вернул ${post.status}. Нужен server-side guard: SUM(line.qty) <= stock.quantity per (product,store).` })
}
}
export async function step04_oversell_stock_unchanged({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`)
const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId)
const qty = Number(row?.quantity ?? 0)
check(step, { kind: 'db', description: 'Stock остался 10 после заблокированного post',
ok: qty === 10, detail: `qty=${qty}` })
// Также: invariant Stock == Σ Movement
if (ctx.productId && ctx.storeId) {
const movSum = psql(
`SELECT COALESCE(SUM("Quantity"),0)::text FROM stock_movements
WHERE "ProductId"='${ctx.productId}' AND "StoreId"='${ctx.storeId}'`,
).trim()
const sumNum = Number(movSum.split('\n')[0] || '0')
check(step, { kind: 'db', description: 'Stock == Σ StockMovement (invariant)',
ok: sumNum === qty, detail: `sum=${sumNum} qty=${qty}` })
}
}
export async function step05_payment_mismatch_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 2, unitPrice: 200, vatPercent: 12 }],
// Total = 400, но оплачено только 300 — должен отвергаться на post.
paidCash: 100, paidCard: 200,
})
if (sale.status !== 201) {
check(step, { kind: 'api', description: 'Draft создан', ok: false, detail: asString(sale.data) })
return
}
const post = await api.post(`/api/sales/retail/${sale.data.id}/post`)
const ok = post.status >= 400 && post.status < 500
check(step, { kind: 'api', description: 'Платёж ≠ Total → 4xx на post',
ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` })
if (!ok) report.bug({ step: '05', severity: 'high',
title: 'Проведение чека с неверной суммой платежа разрешено',
detail: `Сумма оплаты 300 при Total=400 — post вернул ${post.status}.` })
}
export async function step06_edit_posted_supply_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Supply уже Posted в step02.
const cur = await api.get(`/api/purchases/supplies/${ctx.supplyId}`)
if (cur.status !== 200) { step.status = 'skip'; return }
const put = await api.put(`/api/purchases/supplies/${ctx.supplyId}`, {
...cur.data,
date: cur.data.date,
lines: (cur.data.lines || []).map((l: { productId: string; quantity: number; unitPrice: number }) =>
({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice + 1 })),
})
const ok = put.status === 409
check(step, { kind: 'api', description: 'PUT проведённой Supply → 409',
ok, detail: `actual=${put.status} ${asString(put.data).slice(0, 100)}` })
if (!ok && put.status >= 200 && put.status < 300) report.bug({ step: '06', severity: 'high',
title: 'Posted Supply можно изменить через PUT', detail: asString(put.data) })
}
export async function step07_delete_posted_supply_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const del = await api.delete(`/api/purchases/supplies/${ctx.supplyId}`)
const ok = del.status === 409
check(step, { kind: 'api', description: 'DELETE проведённой Supply → 409',
ok, detail: `actual=${del.status} ${asString(del.data).slice(0, 100)}` })
if (!ok && del.status >= 200 && del.status < 300) report.bug({ step: '07', severity: 'high',
title: 'Posted Supply можно удалить', detail: asString(del.data) })
}
export async function step08_unpost_negative_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId || !ctx.supplyId) {
step.status = 'skip'; return
}
const api = makeClient(ctx.adminToken)
// Сначала продаём qty=5 (валидно: stock=10 → станет 5).
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 5, unitPrice: 200, vatPercent: 12 }],
paidCash: 1000, paidCard: 0,
})
if (sale.status !== 201) { step.status = 'skip'; step.notes.push(`Draft sale fail: ${asString(sale.data)}`); return }
const postSale = await api.post(`/api/sales/retail/${sale.data.id}/post`)
check(step, { kind: 'api', description: 'Sale qty=5 проведён',
ok: postSale.status === 200 || postSale.status === 204, detail: `actual=${postSale.status}` })
if (postSale.status >= 400) return
// Stock теперь 5. Unpost Supply (qty=10) уведёт stock в -5 → должен 409.
const unpost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/unpost`)
const ok = unpost.status === 409
check(step, { kind: 'api', description: 'Unpost Supply при stock<unpost-qty → 409',
ok, detail: `actual=${unpost.status} ${asString(unpost.data).slice(0, 120)}` })
if (!ok && unpost.status >= 200 && unpost.status < 300) {
report.bug({ step: '08', severity: 'critical',
title: 'Unpost Supply уводит Stock в минус (нет защиты)',
detail: `После Sale qty=5 stock=5. Unpost Supply qty=10 ⇒ stock=-5. Got HTTP ${unpost.status}.` })
} else if (unpost.status >= 400 && unpost.status < 500 && unpost.status !== 409) {
// Сервер тоже отверг, но другим кодом — это semantic gap.
report.gap(`Unpost Supply при отрицательном остатке вернул ${unpost.status} вместо 409. Ожидался Conflict — единый код для бизнес-конфликтов.`)
}
}
export async function step09_barcode_unique_within_org({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productBarcode || !ctx.unitId || !ctx.groupId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const dup = await api.post('/api/catalog/products', {
name: 'Дубль-штрихкод', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 300 }],
})
const ok = dup.status === 409 || dup.status === 400
check(step, { kind: 'api', description: 'POST product с тем же barcode → 4xx',
ok, detail: `actual=${dup.status} ${asString(dup.data).slice(0, 120)}` })
if (!ok && dup.status >= 200 && dup.status < 300) report.bug({ step: '09', severity: 'high',
title: 'Дубль штрихкода в одной орге разрешён',
detail: `POST вернул ${dup.status}. Нарушает уникальный индекс per (OrgId, Barcode).` })
}
export async function step10_barcode_per_tenant({ ctx, step, report }: StepCtx) {
if (!ctx.productBarcode) { step.status = 'skip'; return }
// Создаём другую орг и пытаемся использовать тот же штрихкод.
const sa = await ensureSuperAdmin(ctx)
const saApi = makeClient(sa)
const orgRes = await saApi.post('/api/super-admin/organizations', {
org: {
name: `Edge Shop2 ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge2', adminFirstName: 'Admin',
adminEmail: `edge2-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) { step.status = 'skip'; step.notes.push('Не удалось создать вторую орг'); return }
const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
const api2 = makeClient(adminSess.accessToken)
// Lookups для новой орги
const units = await api2.get('/api/catalog/units-of-measure')
const u2 = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api2.get('/api/catalog/product-groups?pageSize=10')
let g2 = grps.data?.items?.[0]?.id
if (!g2) {
const g = await api2.post('/api/catalog/product-groups', { name: 'G2', parentId: null })
g2 = g.data?.id
}
const cur = await api2.get('/api/catalog/currencies?pageSize=200')
const c2 = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const pt = await api2.get('/api/catalog/price-types')
const r2 = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
const prod = await api2.post('/api/catalog/products', {
name: 'Tenant-2 product (same barcode)',
unitOfMeasureId: u2, productGroupId: g2,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: r2, currencyId: c2, amount: 250 }],
})
check(step, { kind: 'api', description: 'POST product с тем же barcode в другой орге → 201',
ok: prod.status === 201, detail: `actual=${prod.status} ${asString(prod.data).slice(0, 120)}` })
if (prod.status !== 201) report.bug({ step: '10', severity: 'medium',
title: 'Per-tenant уникальность штрихкода не работает',
detail: `Уникальный индекс должен быть на (OrganizationId, Code), сейчас отвергает межтенантное переиспользование. Got ${prod.status}.` })
// Подсчитаем общее число с этим штрихкодом
const totalRows = countRows('product_barcodes', `"Code"='${ctx.productBarcode}'`)
check(step, { kind: 'db', description: 'В product_barcodes 2 записи с этим Code (одна на орг)',
ok: totalRows === 2, detail: `count=${totalRows}` })
}

View file

@ -0,0 +1,32 @@
name: documents-edge
description: |
Краевые случаи документов: защита от overselling, запреты на изменение
проведённых документов, валидация платежей, уникальность штрихкодов
per-tenant. Это сценарий регрессии для самой опасной зоны — потеря
денег/остатков при ошибках в RetailSale/Supply посту.
preconditions:
reset_db: true
smoke_login_super_admin: true
steps:
- id: step01_bootstrap
title: "SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT)"
- id: step02_post_supply_stock_10
title: "Supply провести: stock=10, ReferencePrice=100, Cost=100"
- id: step03_oversell_blocked
title: "RetailSale qty=15 (больше остатка 10), POST /post возвращает 409"
- id: step04_oversell_stock_unchanged
title: "После заблокированного post stock остался 10, StockMovement не добавлен"
- id: step05_payment_mismatch_blocked
title: "RetailSale с PaidCash+PaidCard не равной Total отвергается на post"
- id: step06_edit_posted_supply_blocked
title: "PUT проведённой Supply (Posted) возвращает 409"
- id: step07_delete_posted_supply_blocked
title: "DELETE проведённой Supply возвращает 409"
- id: step08_unpost_negative_blocked
title: "После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус)"
- id: step09_barcode_unique_within_org
title: "Дубль штрихкода в одной орге, POST второго product отвергается"
- id: step10_barcode_per_tenant
title: "Тот же штрихкод в другой орге допустим (per-tenant unique)"