fix(docs): EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

Тот же баг что в 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-29 16:57:48 +05:00
parent 4c2841db5b
commit 4e15359378
8 changed files with 735 additions and 23 deletions

View file

@ -208,8 +208,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
if (input.Lines is not null && input.Lines.Count > 0) 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 byProduct = doc.Lines.ToDictionary(l => l.ProductId);
var existingCount = doc.Lines.Count;
foreach (var ln in input.Lines) foreach (var ln in input.Lines)
{ {
if (byProduct.TryGetValue(ln.ProductId, out var existing)) if (byProduct.TryGetValue(ln.ProductId, out var existing))
@ -220,10 +223,12 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
else else
{ {
// Новая строка — подгружаем book на момент изменения. // Новая строка — подгружаем 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) var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; .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); 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, InventoryDocId = doc.Id,
ProductId = ln.ProductId, ProductId = ln.ProductId,
@ -231,7 +236,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] InventoryInput input
ActualQty = ln.ActualQty, ActualQty = ln.ActualQty,
Diff = ln.ActualQty - b, Diff = ln.ActualQty - b,
UnitCost = c, UnitCost = c,
SortOrder = doc.Lines.Count, SortOrder = existingCount++,
}); });
} }
} }

View file

@ -198,22 +198,27 @@ public async Task<IActionResult> Update(Guid id, [FromBody] LossInput input, Can
loss.Reason = input.Reason; loss.Reason = input.Reason;
loss.Notes = input.Notes; loss.Notes = input.Notes;
_db.LossLines.RemoveRange(loss.Lines); // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и
loss.Lines.Clear(); // 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; var order = 0;
decimal total = 0;
foreach (var l in input.Lines) 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, LossId = loss.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitCost = l.UnitCost, UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost, LineTotal = lineTotal,
SortOrder = order++, SortOrder = order++,
}); });
total += lineTotal;
} }
loss.Total = loss.Lines.Sum(x => x.LineTotal); loss.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();

View file

@ -198,22 +198,27 @@ public async Task<IActionResult> Update(Guid id, [FromBody] TransferInput input,
t.ToStoreId = input.ToStoreId; t.ToStoreId = input.ToStoreId;
t.Notes = input.Notes; t.Notes = input.Notes;
_db.TransferLines.RemoveRange(t.Lines); // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и
t.Lines.Clear(); // 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; var order = 0;
decimal total = 0;
foreach (var l in input.Lines) 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, TransferId = t.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitCost = l.UnitCost, UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost, LineTotal = lineTotal,
SortOrder = order++, SortOrder = order++,
}); });
total += lineTotal;
} }
t.Total = t.Lines.Sum(x => x.LineTotal); t.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();

View file

@ -192,22 +192,27 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EnterInput input, Ca
enter.CurrencyId = input.CurrencyId; enter.CurrencyId = input.CurrencyId;
enter.Notes = input.Notes; enter.Notes = input.Notes;
_db.EnterLines.RemoveRange(enter.Lines); // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается
enter.Lines.Clear(); // и 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; var order = 0;
decimal total = 0;
foreach (var l in input.Lines) 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, EnterId = enter.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitCost = l.UnitCost, UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost, LineTotal = lineTotal,
SortOrder = order++, SortOrder = order++,
}); });
total += lineTotal;
} }
enter.Total = enter.Lines.Sum(x => x.LineTotal); enter.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();

View file

@ -221,22 +221,27 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplierReturnInput
r.ReferenceSupplyId = input.ReferenceSupplyId; r.ReferenceSupplyId = input.ReferenceSupplyId;
r.Notes = input.Notes; r.Notes = input.Notes;
_db.SupplierReturnLines.RemoveRange(r.Lines); // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и
r.Lines.Clear(); // 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; var order = 0;
decimal total = 0;
foreach (var l in input.Lines) 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, SupplierReturnId = r.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitPrice = l.UnitPrice, UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice, LineTotal = lineTotal,
SortOrder = order++, SortOrder = order++,
}); });
total += lineTotal;
} }
r.Total = r.Lines.Sum(x => x.LineTotal); r.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();

View file

@ -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 редактора.

View file

@ -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<Org> {
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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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 редактора.',
)
}
}

View file

@ -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."