fix(docs): EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update
Some checks are pending
Some checks are pending
Тот же баг что в 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:
parent
4c2841db5b
commit
4e15359378
|
|
@ -208,8 +208,11 @@ public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Update(Guid id, [FromBody] InventoryInput input
|
|||
ActualQty = ln.ActualQty,
|
||||
Diff = ln.ActualQty - b,
|
||||
UnitCost = c,
|
||||
SortOrder = doc.Lines.Count,
|
||||
SortOrder = existingCount++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,22 +198,27 @@ public async Task<IActionResult> 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();
|
||||
|
|
|
|||
|
|
@ -198,22 +198,27 @@ public async Task<IActionResult> 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();
|
||||
|
|
|
|||
|
|
@ -192,22 +192,27 @@ public async Task<IActionResult> 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();
|
||||
|
|
|
|||
|
|
@ -221,22 +221,27 @@ public async Task<IActionResult> 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();
|
||||
|
|
|
|||
119
tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md
Normal file
119
tests/e2e/reports/stage-enter-2026-05-29T11-57-30-531Z.md
Normal 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 редактора.
|
||||
533
tests/e2e/scenarios/stage-enter.steps.ts
Normal file
533
tests/e2e/scenarios/stage-enter.steps.ts
Normal 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 редактора.',
|
||||
)
|
||||
}
|
||||
}
|
||||
35
tests/e2e/scenarios/stage-enter.yml
Normal file
35
tests/e2e/scenarios/stage-enter.yml
Normal 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."
|
||||
Loading…
Reference in a new issue