From 4e153593789b1caa8191ca80d9f3e32dcc13d80f Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 16:57:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(docs):=20EF8=20nav-collection=20bug=20?= =?UTF-8?q?=D0=B2=20Enters/Losses/Transfers/SupplierReturns/Inventories.Up?= =?UTF-8?q?date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же баг что в TD-6 чинили на Supplies/Demands/RetailSales и в pt 2 на Products: добавление/замена line'ов через nav-collection даёт DbUpdateConcurrencyException «0 rows affected» при следующем UPDATE родителя. На документах без xmin это становится 500, на InventoryDoc (с xmin от TD-6) — 409. Переводим Enters/Losses/Transfers/SupplierReturns.Update на ExecuteDelete + DbSet.Add (как Supplies). InventoriesController дополнительно: добавление новых строк через _db.InventoryLines.Add вместо doc.Lines.Add (RemoveRange/Clear там не было — merge-in-place по ProductId). Воспроизведение (на Enters): 1. POST /api/inventory/enters {lines:[A]} 2. PUT … {lines:[A,B]} (одна оставлена, одна новая) → было 500 DbUpdateConcurrencyException ; стало 204. stage-enter (10 шагов): CRUD + Post + Unpost + edge + multi-tenant + concurrent PUT — все зелёные. Co-Authored-By: Claude Opus 4.7 --- .../Inventory/InventoriesController.cs | 11 +- .../Controllers/Inventory/LossesController.cs | 15 +- .../Inventory/TransfersController.cs | 15 +- .../Controllers/Purchases/EntersController.cs | 15 +- .../Purchases/SupplierReturnsController.cs | 15 +- .../stage-enter-2026-05-29T11-57-30-531Z.md | 119 ++++ tests/e2e/scenarios/stage-enter.steps.ts | 533 ++++++++++++++++++ tests/e2e/scenarios/stage-enter.yml | 35 ++ 8 files changed, 735 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md create mode 100644 tests/e2e/scenarios/stage-enter.steps.ts create mode 100644 tests/e2e/scenarios/stage-enter.yml diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs index 649cb13..3f4ae4a 100644 --- a/src/food-market.api/Controllers/Inventory/InventoriesController.cs +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -208,8 +208,11 @@ public async Task Update(Guid id, [FromBody] InventoryInput input if (input.Lines is not null && input.Lines.Count > 0) { - // Обновление actualQty по существующим строкам. + // Обновление actualQty по существующим строкам — in-place правка + // (без Add/Remove на навигации, чтобы не словить EF8 bug на + // nav-collection, см. фикс на Products/Enters/Losses.Update). var byProduct = doc.Lines.ToDictionary(l => l.ProductId); + var existingCount = doc.Lines.Count; foreach (var ln in input.Lines) { if (byProduct.TryGetValue(ln.ProductId, out var existing)) @@ -220,10 +223,12 @@ public async Task Update(Guid id, [FromBody] InventoryInput input else { // Новая строка — подгружаем book на момент изменения. + // Добавляем через DbSet, не через doc.Lines.Add, чтобы EF + // не путал nav-collection и xmin-токен на InventoryDoc. var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId) .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; var c = await _db.Products.Where(p => p.Id == ln.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct); - doc.Lines.Add(new InventoryLine + _db.InventoryLines.Add(new InventoryLine { InventoryDocId = doc.Id, ProductId = ln.ProductId, @@ -231,7 +236,7 @@ public async Task Update(Guid id, [FromBody] InventoryInput input ActualQty = ln.ActualQty, Diff = ln.ActualQty - b, UnitCost = c, - SortOrder = doc.Lines.Count, + SortOrder = existingCount++, }); } } diff --git a/src/food-market.api/Controllers/Inventory/LossesController.cs b/src/food-market.api/Controllers/Inventory/LossesController.cs index 27c74f7..fe029b5 100644 --- a/src/food-market.api/Controllers/Inventory/LossesController.cs +++ b/src/food-market.api/Controllers/Inventory/LossesController.cs @@ -198,22 +198,27 @@ public async Task Update(Guid id, [FromBody] LossInput input, Can loss.Reason = input.Reason; loss.Notes = input.Notes; - _db.LossLines.RemoveRange(loss.Lines); - loss.Lines.Clear(); + // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и + // UPDATE losses падает с DbUpdateConcurrencyException «0 rows affected» + // даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update. + await _db.LossLines.Where(l => l.LossId == loss.Id).ExecuteDeleteAsync(ct); var order = 0; + decimal total = 0; foreach (var l in input.Lines) { - loss.Lines.Add(new LossLine + var lineTotal = l.Quantity * l.UnitCost; + _db.LossLines.Add(new LossLine { LossId = loss.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, - LineTotal = l.Quantity * l.UnitCost, + LineTotal = lineTotal, SortOrder = order++, }); + total += lineTotal; } - loss.Total = loss.Lines.Sum(x => x.LineTotal); + loss.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); diff --git a/src/food-market.api/Controllers/Inventory/TransfersController.cs b/src/food-market.api/Controllers/Inventory/TransfersController.cs index 1ebadec..d820cd7 100644 --- a/src/food-market.api/Controllers/Inventory/TransfersController.cs +++ b/src/food-market.api/Controllers/Inventory/TransfersController.cs @@ -198,22 +198,27 @@ public async Task Update(Guid id, [FromBody] TransferInput input, t.ToStoreId = input.ToStoreId; t.Notes = input.Notes; - _db.TransferLines.RemoveRange(t.Lines); - t.Lines.Clear(); + // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и + // UPDATE transfers падает с DbUpdateConcurrencyException «0 rows». + // Тот же паттерн что в Supplies/Demands/RetailSales/Enters/Losses.Update. + await _db.TransferLines.Where(l => l.TransferId == t.Id).ExecuteDeleteAsync(ct); var order = 0; + decimal total = 0; foreach (var l in input.Lines) { - t.Lines.Add(new TransferLine + var lineTotal = l.Quantity * l.UnitCost; + _db.TransferLines.Add(new TransferLine { TransferId = t.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, - LineTotal = l.Quantity * l.UnitCost, + LineTotal = lineTotal, SortOrder = order++, }); + total += lineTotal; } - t.Total = t.Lines.Sum(x => x.LineTotal); + t.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); diff --git a/src/food-market.api/Controllers/Purchases/EntersController.cs b/src/food-market.api/Controllers/Purchases/EntersController.cs index 6825b11..518b6bb 100644 --- a/src/food-market.api/Controllers/Purchases/EntersController.cs +++ b/src/food-market.api/Controllers/Purchases/EntersController.cs @@ -192,22 +192,27 @@ public async Task Update(Guid id, [FromBody] EnterInput input, Ca enter.CurrencyId = input.CurrencyId; enter.Notes = input.Notes; - _db.EnterLines.RemoveRange(enter.Lines); - enter.Lines.Clear(); + // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается + // и UPDATE enters падает с DbUpdateConcurrencyException «0 rows affected» + // даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update. + await _db.EnterLines.Where(l => l.EnterId == enter.Id).ExecuteDeleteAsync(ct); var order = 0; + decimal total = 0; foreach (var l in input.Lines) { - enter.Lines.Add(new EnterLine + var lineTotal = l.Quantity * l.UnitCost; + _db.EnterLines.Add(new EnterLine { EnterId = enter.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, - LineTotal = l.Quantity * l.UnitCost, + LineTotal = lineTotal, SortOrder = order++, }); + total += lineTotal; } - enter.Total = enter.Lines.Sum(x => x.LineTotal); + enter.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); diff --git a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs index ac5f807..9b9b0bf 100644 --- a/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs +++ b/src/food-market.api/Controllers/Purchases/SupplierReturnsController.cs @@ -221,22 +221,27 @@ public async Task Update(Guid id, [FromBody] SupplierReturnInput r.ReferenceSupplyId = input.ReferenceSupplyId; r.Notes = input.Notes; - _db.SupplierReturnLines.RemoveRange(r.Lines); - r.Lines.Clear(); + // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и + // UPDATE supplier_returns падает с DbUpdateConcurrencyException «0 rows». + // Тот же паттерн что в Supplies/Demands/RetailSales/Enters/Losses.Update. + await _db.SupplierReturnLines.Where(l => l.SupplierReturnId == r.Id).ExecuteDeleteAsync(ct); var order = 0; + decimal total = 0; foreach (var l in input.Lines) { - r.Lines.Add(new SupplierReturnLine + var lineTotal = l.Quantity * l.UnitPrice; + _db.SupplierReturnLines.Add(new SupplierReturnLine { SupplierReturnId = r.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = l.UnitPrice, - LineTotal = l.Quantity * l.UnitPrice, + LineTotal = lineTotal, SortOrder = order++, }); + total += lineTotal; } - r.Total = r.Lines.Sum(x => x.LineTotal); + r.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); diff --git a/tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md b/tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md new file mode 100644 index 0000000..adb9645 --- /dev/null +++ b/tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md @@ -0,0 +1,119 @@ +# E2E report: stage-enter + +Запущен: 2026-05-29T11:57:15.604Z +Длительность: 14.9с + +**Итог:** 10 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 10) + +## ✓ Step ent01_setup: Создать org с admin + продукт (для строк) + storeId — все ref'ы для теста + +Длительность: 9827мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Org A с product+store готов | ✓ 283b6739-4377-4c6e-b6e6-2ea27c4a9f49 | +| api | Org B с product+store готов | ✓ 7ada5e66-efcb-441c-9d9f-df8a7c4b4055 | + +## ✓ Step ent02_create_draft: POST /api/inventory/enters → Draft с 2 строками, номер сгенерирован "О-YYYY-NNNNNN" + +Длительность: 611мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/inventory/enters → 201 | ✓ 201 {"id":"262e47c2-1cd5-466c-8f8d-6496c9e88225","number":"О-2026-000001","date":"2026-05-29T11:57:25.554Z","status":0,"storeId":"45769058-4482-4e85-8052-02255a2bb060","storeName":"Основной склад","cu | +| api | Total = sum(qty*cost) = 3750 | ✓ total=3750 vs 3750 | +| api | Номер сгенерирован формата О-YYYY-NNNNNN | ✓ number=О-2026-000001 | +| api | Lines.Count = 2 | ✓ lines=2 | +| api | Status = Draft (0) или строка "Draft" | ✓ status=0 | + +## ✓ Step ent03_update_draft: PUT — поменять Notes, заменить строки, проверить пересчёт Total + +Длительность: 464мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | PUT enter Draft → 204 | ✓ 204 | +| api | GET после PUT: Total = 20*150 = 3000, Lines.Count = 1 | ✓ total=3000 lines=1 | +| api | PUT restore с 2 строками для post-теста → 204 | ✓ 204 | + +## ✓ Step ent04_post: POST {id}/post → Status=Posted, Stock.Quantity += sum(line.Quantity), StockMovement[type=Enter] + +Длительность: 982мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST {id}/post → 204 | ✓ 204 | +| api | Stock.Quantity продукта 1 += 10 | ✓ delta=10 | +| api | Stock.Quantity продукта 2 += 5 | ✓ delta=5 | +| api | Создано минимум 2 StockMovement с DocumentId = enterIdA | ✓ movs=2, types=Enter,Enter | +| api | После Post: Status = Posted (1), PostedAt не null | ✓ status=1 postedAt=2026-05-29T11:57:26.83119Z | + +## ✓ Step ent05_post_idempotent_or_conflict: Повторный POST/post проведённого → 409 «Документ уже проведён» + +Длительность: 155мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Повторный POST {id}/post → 409 «Документ уже проведён» | ✓ 409 {"error":"Документ уже проведён."} | + +## ✓ Step ent06_unpost: POST {id}/unpost → Status=Draft, StockMovement[type=enter-reversal] с -Quantity + +Длительность: 508мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST {id}/unpost → 204 | ✓ 204 | +| api | После Unpost: stock продукта 1 -= 10 | ✓ delta=-10 | +| api | После Unpost: stock продукта 2 -= 5 | ✓ delta=-5 | +| api | После Unpost: Status = Draft (0), PostedAt = null | ✓ status=0 postedAt=null | + +## ✓ Step ent07_unpost_negative_guard: Если после Post часть товара продана (Stock < Quantity) — Unpost = 409 с conflicts + +Длительность: 1041мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Unpost после расхода → 409 с conflicts (остаток уйдёт в минус) | ✓ 409 {"error":"Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).","lines":[{"productId":"878b46c7-83d0-4042-81ed-0472117c32c7","productName":"Enter Prod a 1780055835604" | + +## ✓ Step ent08_validation_edge_cases: Edge — пустые Lines → 400; POST пустого draft → 400 «без строк» + +Длительность: 345мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST с пустыми lines → 400 | ✓ 400 {"error":"Оприходование должно содержать хотя бы одну позицию."} | +| api | POST с Guid.Empty storeId → 400 | ✓ 400 {"error":"Поле StoreId обязательно.","field":"StoreId"} | +| api | POST с qty<0 → 400 (FluentValidation Range) | ✓ 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Lines[0].Quantity":["The field Quantity must be between 0 and | + +## ✓ Step ent09_multi_tenant_isolation: Org B не видит enter org A (list пустой; GET by id → 404; POST/post → 404) + +Длительность: 354мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Org B GET /enters НЕ содержит enter org A | ✓ total=0 sees=false | +| api | Org B GET /enters/{id-чужой} → 404 | ✓ 404 | +| api | Org B POST /enters/{id-чужой}/post → 404 | ✓ 404 | + +## ✓ Step ent10_concurrent_put_no_version: Два параллельных PUT (без RowVersion) — оба 204. Logic gap: Enter без IVersionedEntity, транзакция не защищает от lost-update. + +Длительность: 637мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Поведение под двумя параллельными PUT (without RowVersion) — last-write-wins | ✓ statuses=[204, 204] | + +## Summary + +- Passed: 10 +- Failed: 0 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. + +## Logic gaps + +- Enter не реализует IVersionedEntity → нет xmin concurrency-токена. При параллельных PUT обоих в Draft выигрывает последний (lost-update). TD-6 добавил xmin на Supply/Demand/RetailSale/Transfer/InventoryDoc — Enter/Loss/SupplierReturn/CustomerReturn остались без защиты. Это logic gap, желательно добавить, как минимум вызывается из UI редактора. diff --git a/tests/e2e/scenarios/stage-enter.steps.ts b/tests/e2e/scenarios/stage-enter.steps.ts new file mode 100644 index 0000000..3fe2669 --- /dev/null +++ b/tests/e2e/scenarios/stage-enter.steps.ts @@ -0,0 +1,533 @@ +/** + * Stage Enter: CRUD + post + unpost + edge + multi-tenant. Создаём + * 2 org через signup, под каждой делаем по продукту, затем проверяем + * Enter-pipeline и изоляцию. + */ +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 + enterIdA?: string + enterNumberA?: string + qty1?: number + qty2?: number + cost1?: number + cost2?: 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-enter-${suffix}-${TS}@food-market.local` + const password = 'StageEnt12345!' + const orgName = `Enter 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} failed: ${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: `Enter Prod ${suffix} ${TS}`, + article: `A-${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(`product ${suffix} create failed: ${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, + } +} + +// --------------------------------------------------------------------------- + +export async function ent01_setup({ ctx, step, report }: StepCtx) { + ctx.ts = TS + ctx.orgA = await bootstrapOrg('a', '+77011110001', 11) + await new Promise(r => setTimeout(r, 900)) + ctx.orgB = await bootstrapOrg('b', '+77011110002', 12) + ctx.qty1 = 10 + ctx.qty2 = 5 + ctx.cost1 = 200 + ctx.cost2 = 350 + check(step, { kind: 'api', description: 'Org A с product+store готов', ok: !!ctx.orgA.productId, detail: `${ctx.orgA.orgId}` }) + check(step, { kind: 'api', description: 'Org B с product+store готов', ok: !!ctx.orgB.productId, detail: `${ctx.orgB.orgId}` }) +} + +// --------------------------------------------------------------------------- + +export async function ent02_create_draft({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + + const product2 = await api.post('/api/catalog/products', { + name: `Enter Prod A2 ${TS}`, + article: `A2-${TS}`, + unitOfMeasureId: a.unitKgId, vat: 0, vatEnabled: false, + productGroupId: a.groupId, + barcodes: [{ code: generateEan13(13), type: 1, isPrimary: true }], + prices: [{ priceTypeId: a.priceTypeRetailId, amount: 500, currencyId: a.currencyId }], + }) + const product2Id = product2.data.id + ;(ctx as Ctx & { product2Id?: string }).product2Id = product2Id + + const res = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), + storeId: a.storeId, + currencyId: a.currencyId, + notes: 'тестовый енторинг', + lines: [ + { productId: a.productId, quantity: ctx.qty1, unitCost: ctx.cost1 }, + { productId: product2Id, quantity: ctx.qty2, unitCost: ctx.cost2 }, + ], + }) + check(step, { + kind: 'api', + description: 'POST /api/inventory/enters → 201', + ok: res.status === 201, + detail: `${res.status} ${asString(res.data).slice(0, 200)}`, + }) + if (res.status !== 201) return + ctx.enterIdA = res.data.id + ctx.enterNumberA = res.data.number + const expectedTotal = ctx.qty1! * ctx.cost1! + ctx.qty2! * ctx.cost2! + check(step, { + kind: 'api', + description: `Total = sum(qty*cost) = ${expectedTotal}`, + ok: Math.abs((res.data.total ?? 0) - expectedTotal) < 0.001, + detail: `total=${res.data.total} vs ${expectedTotal}`, + }) + check(step, { + kind: 'api', + description: 'Номер сгенерирован формата О-YYYY-NNNNNN', + ok: /^О-\d{4}-\d{6}$/.test(res.data.number ?? ''), + detail: `number=${res.data.number}`, + }) + check(step, { + kind: 'api', + description: 'Lines.Count = 2', + ok: (res.data.lines?.length ?? 0) === 2, + detail: `lines=${res.data.lines?.length}`, + }) + check(step, { + kind: 'api', + description: 'Status = Draft (0) или строка "Draft"', + ok: res.data.status === 0 || res.data.status === 'Draft', + detail: `status=${res.data.status}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent03_update_draft({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const product2Id = (ctx as Ctx & { product2Id?: string }).product2Id! + + // Меняем 1 строку: только product, qty=20, без 2-й + const upd = await api.put(`/api/inventory/enters/${ctx.enterIdA}`, { + date: new Date().toISOString(), + storeId: a.storeId, + currencyId: a.currencyId, + notes: 'обновлено', + lines: [ + { productId: a.productId, quantity: 20, unitCost: 150 }, + ], + }) + check(step, { + kind: 'api', + description: 'PUT enter Draft → 204', + ok: upd.status === 204, + detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`, + }) + if (upd.status !== 204) { + report.bug({ + step: 'ent03', severity: 'high', + title: 'PUT enter не работает — возможно EF8 nav-collection bug', + detail: `EntersController.Update использует RemoveRange + Clear + nav.Add, тот же паттерн что был в ProductsController. После фикса в pt 2 этот контроллер тоже надо переводить на ExecuteDelete+DbSet.Add. body=${asString(upd.data).slice(0, 200)}`, + }) + return + } + // Проверим что total пересчитан + const got = await api.get(`/api/inventory/enters/${ctx.enterIdA}`) + check(step, { + kind: 'api', + description: 'GET после PUT: Total = 20*150 = 3000, Lines.Count = 1', + ok: got.data?.total === 3000 && got.data?.lines?.length === 1, + detail: `total=${got.data?.total} lines=${got.data?.lines?.length}`, + }) + // Возвращаем 2-строчное состояние для post-теста + const restore = await api.put(`/api/inventory/enters/${ctx.enterIdA}`, { + date: new Date().toISOString(), + storeId: a.storeId, + currencyId: a.currencyId, + notes: 'restored', + lines: [ + { productId: a.productId, quantity: ctx.qty1, unitCost: ctx.cost1 }, + { productId: product2Id, quantity: ctx.qty2, unitCost: ctx.cost2 }, + ], + }) + check(step, { + kind: 'api', + description: 'PUT restore с 2 строками для post-теста → 204', + ok: restore.status === 204, + detail: `${restore.status}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent04_post({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const product2Id = (ctx as Ctx & { product2Id?: string }).product2Id! + + // Запомним stock до проведения + const stockBefore = await api.get(`/api/inventory/stock?storeId=${a.storeId}`) + const beforeMap = new Map() + for (const row of (stockBefore.data?.items ?? [])) beforeMap.set(row.productId, row.quantity ?? 0) + + const post = await api.post(`/api/inventory/enters/${ctx.enterIdA}/post`, {}) + check(step, { + kind: 'api', + description: 'POST {id}/post → 204', + ok: post.status === 204, + detail: `${post.status} ${asString(post.data).slice(0, 200)}`, + }) + + // Stock после + const stockAfter = await api.get(`/api/inventory/stock?storeId=${a.storeId}`) + const afterMap = new Map() + for (const row of (stockAfter.data?.items ?? [])) afterMap.set(row.productId, row.quantity ?? 0) + + const delta1 = (afterMap.get(a.productId) ?? 0) - (beforeMap.get(a.productId) ?? 0) + const delta2 = (afterMap.get(product2Id) ?? 0) - (beforeMap.get(product2Id) ?? 0) + check(step, { + kind: 'api', + description: `Stock.Quantity продукта 1 += ${ctx.qty1}`, + ok: Math.abs(delta1 - ctx.qty1!) < 0.001, + detail: `delta=${delta1}`, + }) + check(step, { + kind: 'api', + description: `Stock.Quantity продукта 2 += ${ctx.qty2}`, + ok: Math.abs(delta2 - ctx.qty2!) < 0.001, + detail: `delta=${delta2}`, + }) + + // StockMovement[type=Enter] на оба продукта + const movs = await api.get(`/api/inventory/movements?storeId=${a.storeId}&take=200`) + const myMovs = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.enterIdA) + check(step, { + kind: 'api', + description: `Создано минимум 2 StockMovement с DocumentId = enterIdA`, + ok: myMovs.length >= 2, + detail: `movs=${myMovs.length}, types=${myMovs.map((m: { type: number | string }) => m.type).join(',')}`, + }) + + // Статус документа = Posted + const got = await api.get(`/api/inventory/enters/${ctx.enterIdA}`) + check(step, { + kind: 'api', + description: 'После Post: Status = Posted (1), PostedAt не null', + ok: (got.data?.status === 1 || got.data?.status === 'Posted') && got.data?.postedAt != null, + detail: `status=${got.data?.status} postedAt=${got.data?.postedAt}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent05_post_idempotent_or_conflict({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const api = makeClient(ctx.orgA.token) + const post2 = await api.post(`/api/inventory/enters/${ctx.enterIdA}/post`, {}) + check(step, { + kind: 'api', + description: 'Повторный POST {id}/post → 409 «Документ уже проведён»', + ok: post2.status === 409 && /уже провед/i.test(asString(post2.data)), + detail: `${post2.status} ${asString(post2.data).slice(0, 200)}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent06_unpost({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const product2Id = (ctx as Ctx & { product2Id?: string }).product2Id! + + const stockBefore = await api.get(`/api/inventory/stock?storeId=${a.storeId}`) + const beforeMap = new Map() + for (const row of (stockBefore.data?.items ?? [])) beforeMap.set(row.productId, row.quantity ?? 0) + + const unpost = await api.post(`/api/inventory/enters/${ctx.enterIdA}/unpost`, {}) + check(step, { + kind: 'api', + description: 'POST {id}/unpost → 204', + ok: unpost.status === 204, + detail: `${unpost.status} ${asString(unpost.data).slice(0, 200)}`, + }) + + const stockAfter = await api.get(`/api/inventory/stock?storeId=${a.storeId}`) + const afterMap = new Map() + for (const row of (stockAfter.data?.items ?? [])) afterMap.set(row.productId, row.quantity ?? 0) + const delta1 = (afterMap.get(a.productId) ?? 0) - (beforeMap.get(a.productId) ?? 0) + const delta2 = (afterMap.get(product2Id) ?? 0) - (beforeMap.get(product2Id) ?? 0) + check(step, { + kind: 'api', + description: `После Unpost: stock продукта 1 -= ${ctx.qty1}`, + ok: Math.abs(delta1 + ctx.qty1!) < 0.001, + detail: `delta=${delta1}`, + }) + check(step, { + kind: 'api', + description: `После Unpost: stock продукта 2 -= ${ctx.qty2}`, + ok: Math.abs(delta2 + ctx.qty2!) < 0.001, + detail: `delta=${delta2}`, + }) + + const got = await api.get(`/api/inventory/enters/${ctx.enterIdA}`) + check(step, { + kind: 'api', + description: 'После Unpost: Status = Draft (0), PostedAt = null', + ok: (got.data?.status === 0 || got.data?.status === 'Draft') && got.data?.postedAt == null, + detail: `status=${got.data?.status} postedAt=${got.data?.postedAt}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent07_unpost_negative_guard({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + + // Re-post enter + await api.post(`/api/inventory/enters/${ctx.enterIdA}/post`, {}) + + // Сделаем «расход» этого товара через ещё один пост enter с минус-вьюхой + // нельзя — Enter не принимает отрицательные qty. Делаем Loss (списание) + // продукта 1 на весь введённый qty, чтобы stock стал = 0 → unpost должен + // упасть 409. + // LossReason — enum, не FK. Defect=0. + const loss = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), + storeId: a.storeId, currencyId: a.currencyId, + reason: 0, + notes: 'forced consumption for unpost guard test', + lines: [{ productId: a.productId, quantity: ctx.qty1, unitCost: 1 }], + }) + if (loss.status !== 201) { + step.notes.push(`Loss.create unexpected: ${loss.status} ${asString(loss.data).slice(0, 200)}`) + return + } + const lossPost = await api.post(`/api/inventory/losses/${loss.data.id}/post`, {}) + if (lossPost.status !== 204) { + step.notes.push(`Loss.post unexpected: ${lossPost.status} ${asString(lossPost.data).slice(0, 200)}`) + return + } + + // Теперь stock первого продукта = 0. Try unpost + const unpost = await api.post(`/api/inventory/enters/${ctx.enterIdA}/unpost`, {}) + check(step, { + kind: 'api', + description: 'Unpost после расхода → 409 с conflicts (остаток уйдёт в минус)', + ok: unpost.status === 409 && /минус|расход/i.test(asString(unpost.data)), + detail: `${unpost.status} ${asString(unpost.data).slice(0, 200)}`, + }) + + // Cleanup: unpost loss → unpost enter + await api.post(`/api/inventory/losses/${loss.data.id}/unpost`, {}).catch(() => {}) +} + +// --------------------------------------------------------------------------- + +export async function ent08_validation_edge_cases({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + + // Empty lines + const emptyLines = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + lines: [], + }) + check(step, { + kind: 'api', + description: 'POST с пустыми lines → 400', + ok: emptyLines.status === 400, + detail: `${emptyLines.status} ${asString(emptyLines.data).slice(0, 200)}`, + }) + + // No storeId + const noStore = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: '00000000-0000-0000-0000-000000000000', + currencyId: a.currencyId, + lines: [{ productId: a.productId, quantity: 1, unitCost: 1 }], + }) + check(step, { + kind: 'api', + description: 'POST с Guid.Empty storeId → 400', + ok: noStore.status === 400, + detail: `${noStore.status} ${asString(noStore.data).slice(0, 200)}`, + }) + + // Negative qty (Range(0, 1e10) — отрицательные блокирует валидатор) + const neg = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + lines: [{ productId: a.productId, quantity: -5, unitCost: 100 }], + }) + check(step, { + kind: 'api', + description: 'POST с qty<0 → 400 (FluentValidation Range)', + ok: neg.status === 400, + detail: `${neg.status} ${asString(neg.data).slice(0, 200)}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent09_multi_tenant_isolation({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.orgB || !ctx.enterIdA) { step.status = 'skip'; return } + const apiB = makeClient(ctx.orgB.token) + + const list = await apiB.get('/api/inventory/enters?pageSize=200') + const sees = (list.data?.items ?? []).some((e: { id: string }) => e.id === ctx.enterIdA) + check(step, { + kind: 'api', + description: 'Org B GET /enters НЕ содержит enter org A', + ok: !sees, + detail: `total=${list.data?.total} sees=${sees}`, + }) + if (sees) { + report.bug({ + step: 'ent09', severity: 'critical', + title: 'P0 multi-tenant утечка: enter Org A виден из org B', + detail: `enterId=${ctx.enterIdA}`, + }) + } + + const get = await apiB.get(`/api/inventory/enters/${ctx.enterIdA}`) + check(step, { + kind: 'api', + description: 'Org B GET /enters/{id-чужой} → 404', + ok: get.status === 404, + detail: `${get.status}`, + }) + + const post = await apiB.post(`/api/inventory/enters/${ctx.enterIdA}/post`, {}) + check(step, { + kind: 'api', + description: 'Org B POST /enters/{id-чужой}/post → 404', + ok: post.status === 404, + detail: `${post.status}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function ent10_concurrent_put_no_version({ ctx, step, report }: StepCtx) { + if (!ctx.orgA || !ctx.enterIdA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const product2Id = (ctx as Ctx & { product2Id?: string }).product2Id! + + // Enter ранее проведён в ent07, потом по идее unpost не прошёл (409). Если документ всё ещё Posted — + // unpost. Затем дублирующий PUT в гонке. + const got = await api.get(`/api/inventory/enters/${ctx.enterIdA}`) + if (got.data?.status === 1 || got.data?.status === 'Posted') { + // Сначала восстановим stock: создадим enter +qty1, проведём — тогда unpost сработает. + const tmp = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + notes: 'restock for concurrent test', + lines: [{ productId: a.productId, quantity: ctx.qty1, unitCost: 1 }], + }) + if (tmp.status === 201) { + await api.post(`/api/inventory/enters/${tmp.data.id}/post`, {}) + } + // Теперь try unpost оригинал + await api.post(`/api/inventory/enters/${ctx.enterIdA}/unpost`, {}) + } + + // Параллельный PUT — два запроса одновременно с разными notes + const body = (notes: string) => ({ + date: new Date().toISOString(), + storeId: a.storeId, currencyId: a.currencyId, notes, + lines: [ + { productId: a.productId, quantity: ctx.qty1, unitCost: ctx.cost1 }, + { productId: product2Id, quantity: ctx.qty2, unitCost: ctx.cost2 }, + ], + }) + const [r1, r2] = await Promise.all([ + api.put(`/api/inventory/enters/${ctx.enterIdA}`, body('parallel-1')), + api.put(`/api/inventory/enters/${ctx.enterIdA}`, body('parallel-2')), + ]) + const statuses = [r1.status, r2.status].sort((a, b) => a - b) + // Enter — НЕ IVersionedEntity. Поэтому оба PUT'a проходят (204+204) с + // last-write-wins семантикой. Если в будущем Enter получит IVersionedEntity, + // ожидаем [204, 409]. Сейчас фиксируем фактическое поведение и помечаем + // gap, если оба прошли. + check(step, { + kind: 'api', + description: 'Поведение под двумя параллельными PUT (without RowVersion) — last-write-wins', + ok: statuses[0] === 204 && (statuses[1] === 204 || statuses[1] === 409), + detail: `statuses=[${statuses.join(', ')}]`, + }) + if (statuses[0] === 204 && statuses[1] === 204) { + report.gap( + 'Enter не реализует IVersionedEntity → нет xmin concurrency-токена. ' + + 'При параллельных PUT обоих в Draft выигрывает последний (lost-update). ' + + 'TD-6 добавил xmin на Supply/Demand/RetailSale/Transfer/InventoryDoc — ' + + 'Enter/Loss/SupplierReturn/CustomerReturn остались без защиты. Это logic gap, ' + + 'желательно добавить, как минимум вызывается из UI редактора.', + ) + } +} diff --git a/tests/e2e/scenarios/stage-enter.yml b/tests/e2e/scenarios/stage-enter.yml new file mode 100644 index 0000000..22cfca5 --- /dev/null +++ b/tests/e2e/scenarios/stage-enter.yml @@ -0,0 +1,35 @@ +name: stage-enter +description: | + Enter (Оприходование) на test.admin.food-market.kz: CRUD драфт, + проведение → Stock + StockMovement, отмена проведения (Unpost), + edge — negative qty/zero qty в строке, post пустого, multi-tenant + изоляция (org B не видит документы org A), параллельный PUT + («второй должен получить 409 Conflict»). Enter сам по себе не + IVersionedEntity — два параллельных PUT успешны без конфликта; + если нашей модели нужен concurrency-токен на Enter, это logic gap. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: ent01_setup + title: Создать org с admin + продукт (для строк) + storeId — все ref'ы для теста + - id: ent02_create_draft + title: POST /api/inventory/enters → Draft с 2 строками, номер сгенерирован "О-YYYY-NNNNNN" + - id: ent03_update_draft + title: PUT — поменять Notes, заменить строки, проверить пересчёт Total + - id: ent04_post + title: POST {id}/post → Status=Posted, Stock.Quantity += sum(line.Quantity), StockMovement[type=Enter] + - id: ent05_post_idempotent_or_conflict + title: Повторный POST/post проведённого → 409 «Документ уже проведён» + - id: ent06_unpost + title: POST {id}/unpost → Status=Draft, StockMovement[type=enter-reversal] с -Quantity + - id: ent07_unpost_negative_guard + title: Если после Post часть товара продана (Stock < Quantity) — Unpost = 409 с conflicts + - id: ent08_validation_edge_cases + title: Edge — пустые Lines → 400; POST пустого draft → 400 «без строк» + - id: ent09_multi_tenant_isolation + title: Org B не видит enter org A (list пустой; GET by id → 404; POST/post → 404) + - id: ent10_concurrent_put_no_version + title: "Два параллельных PUT (без RowVersion) — оба 204. Logic gap: Enter без IVersionedEntity, транзакция не защищает от lost-update."