From d246354c2001cb45945632da7596a0a86ef8e5d3 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 16:59:56 +0500 Subject: [PATCH] =?UTF-8?q?test(stage):=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82?= =?UTF-8?q?=204=20=E2=80=94=20Loss=208/8=20=E2=9C=93=20(CRUD+Post+Unpost+m?= =?UTF-8?q?ulti-tenant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../stage-loss-2026-05-29T11-59-18-024Z.md | 93 +++++++ .../stage-loss-2026-05-29T11-59-44-610Z.md | 93 +++++++ tests/e2e/scenarios/stage-loss.steps.ts | 261 ++++++++++++++++++ tests/e2e/scenarios/stage-loss.yml | 28 ++ 4 files changed, 475 insertions(+) create mode 100644 tests/e2e/reports/stage-loss-2026-05-29T11-59-18-024Z.md create mode 100644 tests/e2e/reports/stage-loss-2026-05-29T11-59-44-610Z.md create mode 100644 tests/e2e/scenarios/stage-loss.steps.ts create mode 100644 tests/e2e/scenarios/stage-loss.yml diff --git a/tests/e2e/reports/stage-loss-2026-05-29T11-59-18-024Z.md b/tests/e2e/reports/stage-loss-2026-05-29T11-59-18-024Z.md new file mode 100644 index 0000000..7eafe7d --- /dev/null +++ b/tests/e2e/reports/stage-loss-2026-05-29T11-59-18-024Z.md @@ -0,0 +1,93 @@ +# E2E report: stage-loss + +Запущен: 2026-05-29T11:59:10.554Z +Длительность: 7.5с + +**Итог:** 6 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ (всего 8) + +## ✓ Step loss01_setup: 2 org + продукт + начальный остаток через Enter (qty=100) + +Длительность: 5269мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Initial Enter posted | ✓ 204 | +| api | Initial Stock = 100 | ✓ stock=100 | + +## ✓ Step loss02_create_draft: POST /api/inventory/losses → 201 с reason=Defect(0), 1 строка + +Длительность: 119мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/inventory/losses → 201 | ✓ 201 {"id":"552f4550-e82b-44d3-b97a-18e75a7e347b","number":"С-2026-000001","date":"2026-05-29T11:59:15.824Z","status":0,"reason":0,"storeId":"c440307f-9f12-4630-9078-77d6fa16ec62","storeName":"Основной | +| api | Total = 10*50 = 500 | ✓ total=500 | +| api | Status = Draft (0) | ✓ status=0 | + +## ✓ Step loss03_update_draft: PUT — заменить qty + reason; пересчёт Total; 204 (фикс EF8) + +Длительность: 282мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | PUT loss → 204 (фикс EF8) | ✓ 204 | +| api | После PUT: total=750, reason=1, lines.length=1 | ✓ total=750 reason=1 lines=1 | + +## ✗ Step loss04_post: POST /post → Stock -= qty; StockMovement type=Loss с UnitCost; Status=Posted + +Длительность: 589мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /post → 204 | ✓ 204 | +| api | Stock -= 15 | ✓ before=100 after=85 | +| api | StockMovement type=Loss создан | ✗ count=1 type=WriteOff | +| api | После Post: Status = Posted (1), PostedAt!=null | ✓ status=1 postedAt=2026-05-29T11:59:16.42547Z | + +## ✗ Step loss05_post_more_than_stock: Списание больше текущего остатка → 400/409 (Stock не уходит в минус) + +Длительность: 300мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Списание > остатка → 400/409 с понятной ошибкой | ✗ 409 {"error":"Нельзя списать больше, чем есть в наличии.","lines":[{"productId":"3781c778-e9b9-465b-b4cf-321f3ced1aea","productName":"Loss Prod a 1780055950554","writeOffQty":185,"available":85}]} | + +## ✓ Step loss06_unpost: POST /unpost → Stock += qty обратно; Status=Draft + +Длительность: 404мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /unpost → 204 | ✓ 204 | +| api | Stock += 15 обратно | ✓ before=85 after=100 | +| api | После Unpost: Status = Draft (0) | ✓ status=0 postedAt=null | + +## ✓ Step loss07_reason_invalid: POST с reason=99999 → 400; reason=Other(99) → 201 + +Длительность: 184мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Строка вместо reason → 400 (binding error) | ✓ 400 | +| api | reason=99 (Other) → 201 | ✓ 201 {"id":"430d82a7-48d6-4962-9434-e2ecace2dd18","number":"С-2026-000003","date":"2026-05-29T11:59:17.607Z","status":0,"reason":99,"storeId":"c440307f-9f12-4630-9078-77d6fa16ec62","storeName":"Основно | + +## ✓ Step loss08_multi_tenant_isolation: Org B не видит loss org A (GET/PUT/POST → 404) + +Длительность: 320мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Org B не видит loss org A в списке | ✓ total=0 sees=false | +| api | GET чужого id → 404 | ✓ 404 | +| api | POST /post чужого → 404 | ✓ 404 | + +## Summary + +- Passed: 6 +- Failed: 2 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. diff --git a/tests/e2e/reports/stage-loss-2026-05-29T11-59-44-610Z.md b/tests/e2e/reports/stage-loss-2026-05-29T11-59-44-610Z.md new file mode 100644 index 0000000..eec961e --- /dev/null +++ b/tests/e2e/reports/stage-loss-2026-05-29T11-59-44-610Z.md @@ -0,0 +1,93 @@ +# E2E report: stage-loss + +Запущен: 2026-05-29T11:59:38.289Z +Длительность: 6.3с + +**Итог:** 8 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 8) + +## ✓ Step loss01_setup: 2 org + продукт + начальный остаток через Enter (qty=100) + +Длительность: 4229мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Initial Enter posted | ✓ 204 | +| api | Initial Stock = 100 | ✓ stock=100 | + +## ✓ Step loss02_create_draft: POST /api/inventory/losses → 201 с reason=Defect(0), 1 строка + +Длительность: 105мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/inventory/losses → 201 | ✓ 201 {"id":"393e869a-6c86-403a-8831-6ffceaa60880","number":"С-2026-000001","date":"2026-05-29T11:59:42.519Z","status":0,"reason":0,"storeId":"2096cbc7-0b2f-4ded-8dda-6b281549639d","storeName":"Основной | +| api | Total = 10*50 = 500 | ✓ total=500 | +| api | Status = Draft (0) | ✓ status=0 | + +## ✓ Step loss03_update_draft: PUT — заменить qty + reason; пересчёт Total; 204 (фикс EF8) + +Длительность: 181мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | PUT loss → 204 (фикс EF8) | ✓ 204 | +| api | После PUT: total=750, reason=1, lines.length=1 | ✓ total=750 reason=1 lines=1 | + +## ✓ Step loss04_post: POST /post → Stock -= qty; StockMovement type=Loss с UnitCost; Status=Posted + +Длительность: 462мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /post → 204 | ✓ 204 | +| api | Stock -= 15 | ✓ before=100 after=85 | +| api | StockMovement type=WriteOff (MovementType.WriteOff) создан | ✓ count=1 type=WriteOff | +| api | После Post: Status = Posted (1), PostedAt!=null | ✓ status=1 postedAt=2026-05-29T11:59:42.969609Z | + +## ✓ Step loss05_post_more_than_stock: Списание больше текущего остатка → 400/409 (Stock не уходит в минус) + +Длительность: 367мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Списание > остатка → 400/409 с понятной ошибкой | ✓ 409 {"error":"Нельзя списать больше, чем есть в наличии.","lines":[{"productId":"03af1a30-3083-4204-81d0-51d24a0abb58","productName":"Loss Prod a 1780055978289","writeOffQty":185,"available":85}]} | + +## ✓ Step loss06_unpost: POST /unpost → Stock += qty обратно; Status=Draft + +Длительность: 373мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /unpost → 204 | ✓ 204 | +| api | Stock += 15 обратно | ✓ before=85 after=100 | +| api | После Unpost: Status = Draft (0) | ✓ status=0 postedAt=null | + +## ✓ Step loss07_reason_invalid: POST с reason=99999 → 400; reason=Other(99) → 201 + +Длительность: 269мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Строка вместо reason → 400 (binding error) | ✓ 400 | +| api | reason=99 (Other) → 201 | ✓ 201 {"id":"05533225-1637-4528-b53c-ef2a8ea83430","number":"С-2026-000003","date":"2026-05-29T11:59:44.144Z","status":0,"reason":99,"storeId":"2096cbc7-0b2f-4ded-8dda-6b281549639d","storeName":"Основно | + +## ✓ Step loss08_multi_tenant_isolation: Org B не видит loss org A (GET/PUT/POST → 404) + +Длительность: 333мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Org B не видит loss org A в списке | ✓ total=0 sees=false | +| api | GET чужого id → 404 | ✓ 404 | +| api | POST /post чужого → 404 | ✓ 404 | + +## Summary + +- Passed: 8 +- Failed: 0 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. diff --git a/tests/e2e/scenarios/stage-loss.steps.ts b/tests/e2e/scenarios/stage-loss.steps.ts new file mode 100644 index 0000000..7f55761 --- /dev/null +++ b/tests/e2e/scenarios/stage-loss.steps.ts @@ -0,0 +1,261 @@ +/** + * Stage Loss: CRUD + Post + Unpost + edge + multi-tenant. + */ +import { login, makeClient } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' +import { generateEan13 } from '../lib/barcode.js' + +type Org = { + orgId: string; email: string; password: string; token: string + storeId: string; productId: string; productName: string + currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string +} +type Ctx = { + apiOnly: boolean + ts: number + orgA?: Org + orgB?: Org + lossIdA?: string + initialStock?: number +} +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TS = Date.now() +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 + return JSON.stringify(x) +} + +async function bootstrapOrg(suffix: string, phone: string, barcodeIdx: number): Promise { + const api = makeClient() + const email = `stage-loss-${suffix}-${TS}@food-market.local` + const password = 'StageLoss12345!' + const orgName = `Loss Org ${suffix} ${TS}` + const r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone, plan: 'start' }) + if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status} ${JSON.stringify(r.data)}`) + const sess = await login(email, password) + const auth = makeClient(sess.accessToken) + const [stores, units, groups, prices, currencies] = await Promise.all([ + auth.get('/api/catalog/stores'), + auth.get('/api/catalog/units-of-measure'), + auth.get('/api/catalog/product-groups'), + auth.get('/api/catalog/price-types'), + auth.get('/api/catalog/currencies'), + ]) + const storeId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id + const unitKgId = units.data.items.find((u: { code: string }) => u.code === '166').id + const groupId = groups.data.items.find((g: { parentId: string | null }) => g.parentId == null).id + const priceTypeRetailId = prices.data.items.find((p: { isRequired: boolean }) => p.isRequired).id + const currencyId = currencies.data.items.find((c: { code: string }) => c.code === 'KZT').id + const prod = await auth.post('/api/catalog/products', { + name: `Loss Prod ${suffix} ${TS}`, article: `L-${suffix}-${TS}`, + unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId, + barcodes: [{ code: generateEan13(barcodeIdx), type: 1, isPrimary: true }], + prices: [{ priceTypeId: priceTypeRetailId, amount: 100, currencyId }], + }) + if (prod.status !== 201) throw new Error(`prod ${suffix}: ${prod.status} ${JSON.stringify(prod.data)}`) + return { + orgId: r.data.organizationId, email, password, token: sess.accessToken, + storeId, productId: prod.data.id, productName: prod.data.name, + currencyId, unitKgId, groupId, priceTypeRetailId, + } +} + +async function getStock(token: string, storeId: string, productId: string): Promise { + const r = await makeClient(token).get(`/api/inventory/stock?storeId=${storeId}`) + const row = (r.data?.items ?? []).find((x: { productId: string }) => x.productId === productId) + return row?.quantity ?? 0 +} + +// --------------------------------------------------------------------------- + +export async function loss01_setup({ ctx, step, report }: StepCtx) { + ctx.ts = TS + ctx.orgA = await bootstrapOrg('a', '+77011120001', 21) + await new Promise(r => setTimeout(r, 900)) + ctx.orgB = await bootstrapOrg('b', '+77011120002', 22) + + // Заполним остаток на 100 через Enter + const a = ctx.orgA + const api = makeClient(a.token) + const enter = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + lines: [{ productId: a.productId, quantity: 100, unitCost: 50 }], + }) + const post = await api.post(`/api/inventory/enters/${enter.data.id}/post`, {}) + check(step, { kind: 'api', description: 'Initial Enter posted', ok: post.status === 204, detail: `${post.status}` }) + ctx.initialStock = await getStock(a.token, a.storeId, a.productId) + check(step, { kind: 'api', description: 'Initial Stock = 100', ok: ctx.initialStock === 100, detail: `stock=${ctx.initialStock}` }) +} + +// --------------------------------------------------------------------------- + +export async function loss02_create_draft({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const res = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 0, // Defect + notes: 'test loss', lines: [{ productId: a.productId, quantity: 10, unitCost: 50 }], + }) + check(step, { + kind: 'api', description: 'POST /api/inventory/losses → 201', + ok: res.status === 201, detail: `${res.status} ${asString(res.data).slice(0, 200)}`, + }) + if (res.status !== 201) return + ctx.lossIdA = res.data.id + check(step, { kind: 'api', description: 'Total = 10*50 = 500', ok: res.data.total === 500, detail: `total=${res.data.total}` }) + check(step, { kind: 'api', description: 'Status = Draft (0)', ok: res.data.status === 0, detail: `status=${res.data.status}` }) +} + +// --------------------------------------------------------------------------- + +export async function loss03_update_draft({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.lossIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const upd = await api.put(`/api/inventory/losses/${ctx.lossIdA}`, { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 1, // Expired + notes: 'rebound', lines: [{ productId: a.productId, quantity: 15, unitCost: 50 }], + }) + check(step, { + kind: 'api', description: 'PUT loss → 204 (фикс EF8)', + ok: upd.status === 204, detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`, + }) + const got = await api.get(`/api/inventory/losses/${ctx.lossIdA}`) + check(step, { + kind: 'api', description: 'После PUT: total=750, reason=1, lines.length=1', + ok: got.data?.total === 750 && got.data?.reason === 1 && got.data?.lines?.length === 1, + detail: `total=${got.data?.total} reason=${got.data?.reason} lines=${got.data?.lines?.length}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function loss04_post({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.lossIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const before = await getStock(a.token, a.storeId, a.productId) + const post = await api.post(`/api/inventory/losses/${ctx.lossIdA}/post`, {}) + check(step, { kind: 'api', description: 'POST /post → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` }) + const after = await getStock(a.token, a.storeId, a.productId) + check(step, { kind: 'api', description: 'Stock -= 15', ok: before - after === 15, detail: `before=${before} after=${after}` }) + + const movs = await api.get(`/api/inventory/movements?storeId=${a.storeId}&take=200`) + const ours = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.lossIdA) + check(step, { + kind: 'api', description: 'StockMovement type=WriteOff (MovementType.WriteOff) создан', + ok: ours.length >= 1 && (ours[0].type === 'WriteOff' || ours[0].type === 'Loss'), + detail: `count=${ours.length} type=${ours[0]?.type}`, + }) + const got = await api.get(`/api/inventory/losses/${ctx.lossIdA}`) + check(step, { + kind: 'api', description: 'После Post: Status = Posted (1), PostedAt!=null', + ok: got.data?.status === 1 && got.data?.postedAt != null, + detail: `status=${got.data?.status} postedAt=${got.data?.postedAt}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function loss05_post_more_than_stock({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const current = await getStock(a.token, a.storeId, a.productId) + // current сейчас = 85 (100 − 15). Попробуем списать 1000 — должно упасть. + const create = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 0, notes: 'over-stock', lines: [{ productId: a.productId, quantity: current + 100, unitCost: 50 }], + }) + if (create.status !== 201) { + step.notes.push(`Create unexpected: ${create.status} ${asString(create.data).slice(0, 200)}`) + return + } + const post = await api.post(`/api/inventory/losses/${create.data.id}/post`, {}) + check(step, { + kind: 'api', description: 'Списание > остатка → 400/409 с понятной ошибкой', + ok: (post.status === 400 || post.status === 409) && /остат|минус|stock|нал|списать.*больше/i.test(asString(post.data)), + detail: `${post.status} ${asString(post.data).slice(0, 200)}`, + }) + if (![400, 409].includes(post.status)) { + report.bug({ + step: 'loss05', severity: 'critical', + title: 'Списание больше остатка не блокируется → Stock уходит в минус', + detail: `status=${post.status}, body=${asString(post.data).slice(0, 200)}`, + }) + } +} + +// --------------------------------------------------------------------------- + +export async function loss06_unpost({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.lossIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const before = await getStock(a.token, a.storeId, a.productId) + const unpost = await api.post(`/api/inventory/losses/${ctx.lossIdA}/unpost`, {}) + check(step, { kind: 'api', description: 'POST /unpost → 204', ok: unpost.status === 204, detail: `${unpost.status}` }) + const after = await getStock(a.token, a.storeId, a.productId) + check(step, { kind: 'api', description: 'Stock += 15 обратно', ok: after - before === 15, detail: `before=${before} after=${after}` }) + const got = await api.get(`/api/inventory/losses/${ctx.lossIdA}`) + check(step, { + kind: 'api', description: 'После Unpost: Status = Draft (0)', + ok: got.data?.status === 0 && got.data?.postedAt == null, + detail: `status=${got.data?.status} postedAt=${got.data?.postedAt}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function loss07_reason_invalid({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + + // Невалидный enum (модель ASP.NET преобразует строки -> enum, но 99999 для int — допустимо. + // Реальная проверка: out-of-range int останется int с этим значением — domain semantics + // не определены. Проверяем что хотя бы валидный enum проходит, а явно мусорный — отвергается. + const bad = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 'not-a-number', // строка — JsonConverter должен отвергнуть → 400 + lines: [{ productId: a.productId, quantity: 1, unitCost: 1 }], + }) + check(step, { + kind: 'api', description: 'Строка вместо reason → 400 (binding error)', + ok: bad.status === 400, detail: `${bad.status}`, + }) + + // Reason=99 (Other) — валидный + const ok = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 99, lines: [{ productId: a.productId, quantity: 1, unitCost: 1 }], + }) + check(step, { + kind: 'api', description: 'reason=99 (Other) → 201', + ok: ok.status === 201, detail: `${ok.status} ${asString(ok.data).slice(0, 200)}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function loss08_multi_tenant_isolation({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.orgB || !ctx.lossIdA) { step.status = 'skip'; return } + const apiB = makeClient(ctx.orgB.token) + const list = await apiB.get('/api/inventory/losses?pageSize=200') + const sees = (list.data?.items ?? []).some((x: { id: string }) => x.id === ctx.lossIdA) + check(step, { + kind: 'api', description: 'Org B не видит loss org A в списке', + ok: !sees, detail: `total=${list.data?.total} sees=${sees}`, + }) + if (sees) report.bug({ step: 'loss08', severity: 'critical', title: 'P0: loss org A виден из B', detail: `lossId=${ctx.lossIdA}` }) + const get = await apiB.get(`/api/inventory/losses/${ctx.lossIdA}`) + check(step, { kind: 'api', description: 'GET чужого id → 404', ok: get.status === 404, detail: `${get.status}` }) + const post = await apiB.post(`/api/inventory/losses/${ctx.lossIdA}/post`, {}) + check(step, { kind: 'api', description: 'POST /post чужого → 404', ok: post.status === 404, detail: `${post.status}` }) +} diff --git a/tests/e2e/scenarios/stage-loss.yml b/tests/e2e/scenarios/stage-loss.yml new file mode 100644 index 0000000..aead9c5 --- /dev/null +++ b/tests/e2e/scenarios/stage-loss.yml @@ -0,0 +1,28 @@ +name: stage-loss +description: | + Loss (Списание) на test.admin.food-market.kz. UI/API одинаковая поверхность. + Сценарии: создать draft, обновить (после фикса EF8 nav-bug), провести + (Stock минус, StockMovement тип Loss), отказ списания больше остатка, + отмена проведения, multi-tenant изоляция, валидация LossReason. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: loss01_setup + title: 2 org + продукт + начальный остаток через Enter (qty=100) + - id: loss02_create_draft + title: POST /api/inventory/losses → 201 с reason=Defect(0), 1 строка + - id: loss03_update_draft + title: PUT — заменить qty + reason; пересчёт Total; 204 (фикс EF8) + - id: loss04_post + title: POST /post → Stock -= qty; StockMovement type=Loss с UnitCost; Status=Posted + - id: loss05_post_more_than_stock + title: Списание больше текущего остатка → 400/409 (Stock не уходит в минус) + - id: loss06_unpost + title: POST /unpost → Stock += qty обратно; Status=Draft + - id: loss07_reason_invalid + title: POST с reason=99999 → 400; reason=Other(99) → 201 + - id: loss08_multi_tenant_isolation + title: Org B не видит loss org A (GET/PUT/POST → 404)