test(stage): пункт 5 — Transfer 7/7 ✓ (CRUD+atomic post+unpost+multi-tenant)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d246354c20
commit
24c3ff1635
|
|
@ -21,7 +21,7 @@
|
||||||
- [x] **1. Smoke + signup flow** — signup создаёт org "TestStage", bootstrap (магазин/роли/единицы/группа "Все товары"), логин даёт access+refresh. *(stage-smoke.yml: 5/5 ✓)*
|
- [x] **1. Smoke + signup flow** — signup создаёт org "TestStage", bootstrap (магазин/роли/единицы/группа "Все товары"), логин даёт access+refresh. *(stage-smoke.yml: 5/5 ✓)*
|
||||||
- [x] **2. Каталог (товары/группы/контрагенты)** — UI CRUD, дубликаты, дочерние группы, FK-защита, multi-tenant изоляция (2 org). *(stage-catalog.yml: 6/6 ✓, 2 фикса)*
|
- [x] **2. Каталог (товары/группы/контрагенты)** — UI CRUD, дубликаты, дочерние группы, FK-защита, multi-tenant изоляция (2 org). *(stage-catalog.yml: 6/6 ✓, 2 фикса)*
|
||||||
- [x] **3. Склад. Enter (Оприходование)** — UI создание/проведение/Unpost → Stock + StockMovement; RowVersion concurrency; multi-tenant. *(stage-enter.yml: 10/10 ✓, 1 фикс на 5 контроллеров)*
|
- [x] **3. Склад. Enter (Оприходование)** — UI создание/проведение/Unpost → Stock + StockMovement; RowVersion concurrency; multi-tenant. *(stage-enter.yml: 10/10 ✓, 1 фикс на 5 контроллеров)*
|
||||||
- [ ] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant.
|
- [x] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant. *(stage-loss.yml: 8/8 ✓, EF8 фикс уже в pt 3)*
|
||||||
- [ ] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant.
|
- [ ] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant.
|
||||||
- [ ] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant.
|
- [ ] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant.
|
||||||
- [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400.
|
- [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400.
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# E2E report: stage-loss
|
|
||||||
|
|
||||||
Запущен: 2026-05-29T11:59:10.554Z
|
|
||||||
Длительность: 7.5с
|
|
||||||
|
|
||||||
**Итог:** 6 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ (всего 8)
|
|
||||||
|
|
||||||
## ✓ Step loss01_setup: 2 org + продукт + начальный остаток через Enter (qty=100)
|
|
||||||
|
|
||||||
Длительность: 5269мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Initial Enter posted | ✓ 204 |
|
|
||||||
| api | Initial Stock = 100 | ✓ stock=100 |
|
|
||||||
|
|
||||||
## ✓ Step loss02_create_draft: POST /api/inventory/losses → 201 с reason=Defect(0), 1 строка
|
|
||||||
|
|
||||||
Длительность: 119мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | POST /api/inventory/losses → 201 | ✓ 201 {"id":"552f4550-e82b-44d3-b97a-18e75a7e347b","number":"С-2026-000001","date":"2026-05-29T11:59:15.824Z","status":0,"reason":0,"storeId":"c440307f-9f12-4630-9078-77d6fa16ec62","storeName":"Основной |
|
|
||||||
| api | Total = 10*50 = 500 | ✓ total=500 |
|
|
||||||
| api | Status = Draft (0) | ✓ status=0 |
|
|
||||||
|
|
||||||
## ✓ Step loss03_update_draft: PUT — заменить qty + reason; пересчёт Total; 204 (фикс EF8)
|
|
||||||
|
|
||||||
Длительность: 282мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | PUT loss → 204 (фикс EF8) | ✓ 204 |
|
|
||||||
| api | После PUT: total=750, reason=1, lines.length=1 | ✓ total=750 reason=1 lines=1 |
|
|
||||||
|
|
||||||
## ✗ Step loss04_post: POST /post → Stock -= qty; StockMovement type=Loss с UnitCost; Status=Posted
|
|
||||||
|
|
||||||
Длительность: 589мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | POST /post → 204 | ✓ 204 |
|
|
||||||
| api | Stock -= 15 | ✓ before=100 after=85 |
|
|
||||||
| api | StockMovement type=Loss создан | ✗ count=1 type=WriteOff |
|
|
||||||
| api | После Post: Status = Posted (1), PostedAt!=null | ✓ status=1 postedAt=2026-05-29T11:59:16.42547Z |
|
|
||||||
|
|
||||||
## ✗ Step loss05_post_more_than_stock: Списание больше текущего остатка → 400/409 (Stock не уходит в минус)
|
|
||||||
|
|
||||||
Длительность: 300мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Списание > остатка → 400/409 с понятной ошибкой | ✗ 409 {"error":"Нельзя списать больше, чем есть в наличии.","lines":[{"productId":"3781c778-e9b9-465b-b4cf-321f3ced1aea","productName":"Loss Prod a 1780055950554","writeOffQty":185,"available":85}]} |
|
|
||||||
|
|
||||||
## ✓ Step loss06_unpost: POST /unpost → Stock += qty обратно; Status=Draft
|
|
||||||
|
|
||||||
Длительность: 404мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | POST /unpost → 204 | ✓ 204 |
|
|
||||||
| api | Stock += 15 обратно | ✓ before=85 after=100 |
|
|
||||||
| api | После Unpost: Status = Draft (0) | ✓ status=0 postedAt=null |
|
|
||||||
|
|
||||||
## ✓ Step loss07_reason_invalid: POST с reason=99999 → 400; reason=Other(99) → 201
|
|
||||||
|
|
||||||
Длительность: 184мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Строка вместо reason → 400 (binding error) | ✓ 400 |
|
|
||||||
| api | reason=99 (Other) → 201 | ✓ 201 {"id":"430d82a7-48d6-4962-9434-e2ecace2dd18","number":"С-2026-000003","date":"2026-05-29T11:59:17.607Z","status":0,"reason":99,"storeId":"c440307f-9f12-4630-9078-77d6fa16ec62","storeName":"Основно |
|
|
||||||
|
|
||||||
## ✓ Step loss08_multi_tenant_isolation: Org B не видит loss org A (GET/PUT/POST → 404)
|
|
||||||
|
|
||||||
Длительность: 320мс
|
|
||||||
|
|
||||||
| Тип | Проверка | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| api | Org B не видит loss org A в списке | ✓ total=0 sees=false |
|
|
||||||
| api | GET чужого id → 404 | ✓ 404 |
|
|
||||||
| api | POST /post чужого → 404 | ✓ 404 |
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Passed: 6
|
|
||||||
- Failed: 2
|
|
||||||
- Warnings: 0
|
|
||||||
- Skipped: 0
|
|
||||||
|
|
||||||
## Critical bugs
|
|
||||||
|
|
||||||
Нет.
|
|
||||||
88
tests/e2e/reports/stage-transfer-2026-05-29T12-01-42-704Z.md
Normal file
88
tests/e2e/reports/stage-transfer-2026-05-29T12-01-42-704Z.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# E2E report: stage-transfer
|
||||||
|
|
||||||
|
Запущен: 2026-05-29T12:01:34.682Z
|
||||||
|
Длительность: 8.0с
|
||||||
|
|
||||||
|
**Итог:** 7 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 7)
|
||||||
|
|
||||||
|
## ✓ Step tr01_setup: 2 org. У org A — 2 склада + продукт с qty=100 на FromStore.
|
||||||
|
|
||||||
|
Длительность: 4909мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Initial Enter on FromStore=100 | ✓ 204 |
|
||||||
|
| api | Org A: 2 разных склада | ✓ from=dbbc12fa to=905896ae |
|
||||||
|
|
||||||
|
## ✓ Step tr02_create_draft: POST /api/inventory/transfers → 201, валидация From==To → 400
|
||||||
|
|
||||||
|
Длительность: 635мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | From == To → 400 | ✓ 400 {"error":"Склад-отправитель и склад-получатель должны различаться.","field":"toStoreId"} |
|
||||||
|
| api | POST transfers → 201 | ✓ 201 {"id":"66129efa-b57e-4125-91f4-7d402c4864e0","number":"П-2026-Т-000001","date":"2026-05-29T12:01:39.779Z","status":0,"fromStoreId":"dbbc12fa-b5f5-4a14-94fa-c7697d43fb76","fromStoreName":"Основной |
|
||||||
|
| api | Total = 10*30 = 300 | ✓ total=300 |
|
||||||
|
| api | Status = Draft (0) | ✓ status=0 |
|
||||||
|
|
||||||
|
## ✓ Step tr03_update_draft: PUT с заменой строк → 204 (фикс EF8)
|
||||||
|
|
||||||
|
Длительность: 353мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | PUT transfer → 204 (фикс EF8) | ✓ 204 |
|
||||||
|
| api | После PUT: total=600 | ✓ total=600 |
|
||||||
|
|
||||||
|
## ✓ Step tr04_post_atomic: POST /post → 204; FromStore.qty-=10; ToStore.qty+=10; пара StockMovement [TransferOut+TransferIn]
|
||||||
|
|
||||||
|
Длительность: 744мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST /post → 204 | ✓ 204 |
|
||||||
|
| api | FromStore -= 20 | ✓ from 100 → 80 |
|
||||||
|
| api | ToStore += 20 | ✓ to 0 → 20 |
|
||||||
|
| api | Создано минимум 2 StockMovement: TransferOut + TransferIn | ✓ count=2 hasOut=true hasIn=true types=TransferIn,TransferOut |
|
||||||
|
|
||||||
|
## ✓ Step tr05_post_more_than_stock: Если qty > available → 409 с conflicts, движения не создаются
|
||||||
|
|
||||||
|
Длительность: 346мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Post с qty > available → 409 | ✓ 409 {"error":"На складе-отправителе недостаточно товара.","lines":[{"productId":"fa017dec-2c78-4e99-b720-81ca224e6ba2","productName":"Tr Prod a 1780056094681","requested":180,"available":80}]} |
|
||||||
|
| api | Документ failed post НЕ создал orphan-движений | ✓ orphan=false |
|
||||||
|
|
||||||
|
## ✓ Step tr06_unpost_no_orphan: POST /unpost → 204; FromStore.qty восстановлен; ToStore.qty -= обратно; обе пары движений «отменены»
|
||||||
|
|
||||||
|
Длительность: 710мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST /unpost → 204 | ✓ 204 |
|
||||||
|
| api | FromStore += 20 (вернулся) | ✓ from 80 → 100 |
|
||||||
|
| api | ToStore -= 20 (вернулся) | ✓ to 20 → 0 |
|
||||||
|
| api | Сумма quantity по всем движениям документа = 0 (нет orphan) | ✓ count=4 sumQty=0 |
|
||||||
|
| api | После Unpost: Status=Draft, PostedAt=null | ✓ status=0 postedAt=null |
|
||||||
|
|
||||||
|
## ✓ Step tr07_multi_tenant_isolation: Org B не видит transfer org A
|
||||||
|
|
||||||
|
Длительность: 324мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Org B не видит transfer org A | ✓ total=0 sees=false |
|
||||||
|
| api | GET чужого id → 404 | ✓ 404 |
|
||||||
|
| api | POST /post чужого → 404 | ✓ 404 |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Passed: 7
|
||||||
|
- Failed: 0
|
||||||
|
- Warnings: 0
|
||||||
|
- Skipped: 0
|
||||||
|
|
||||||
|
## Critical bugs
|
||||||
|
|
||||||
|
Нет.
|
||||||
268
tests/e2e/scenarios/stage-transfer.steps.ts
Normal file
268
tests/e2e/scenarios/stage-transfer.steps.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
/**
|
||||||
|
* Stage Transfer: CRUD + Post + Unpost + edge + multi-tenant.
|
||||||
|
*/
|
||||||
|
import { login, makeClient } from '../lib/api.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
import { generateEan13 } from '../lib/barcode.js'
|
||||||
|
|
||||||
|
type Org = {
|
||||||
|
orgId: string; email: string; password: string; token: string
|
||||||
|
fromStoreId: string; toStoreId: string
|
||||||
|
productId: string
|
||||||
|
currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string
|
||||||
|
}
|
||||||
|
type Ctx = {
|
||||||
|
apiOnly: boolean
|
||||||
|
ts: number
|
||||||
|
orgA?: Org
|
||||||
|
orgB?: Org
|
||||||
|
transferIdA?: string
|
||||||
|
}
|
||||||
|
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, bcIdx: number, twoStores: boolean): Promise<Org> {
|
||||||
|
const api = makeClient()
|
||||||
|
const email = `stage-tr-${suffix}-${TS}@food-market.local`
|
||||||
|
const password = 'StageTr12345!'
|
||||||
|
const orgName = `Tr Org ${suffix} ${TS}`
|
||||||
|
const r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone, plan: 'start' })
|
||||||
|
if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status} ${JSON.stringify(r.data)}`)
|
||||||
|
const sess = await login(email, password)
|
||||||
|
const auth = makeClient(sess.accessToken)
|
||||||
|
const [stores, units, groups, prices, currencies] = await Promise.all([
|
||||||
|
auth.get('/api/catalog/stores'),
|
||||||
|
auth.get('/api/catalog/units-of-measure'),
|
||||||
|
auth.get('/api/catalog/product-groups'),
|
||||||
|
auth.get('/api/catalog/price-types'),
|
||||||
|
auth.get('/api/catalog/currencies'),
|
||||||
|
])
|
||||||
|
const mainStoreId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id
|
||||||
|
let secondStoreId = mainStoreId
|
||||||
|
if (twoStores) {
|
||||||
|
const second = await auth.post('/api/catalog/stores', {
|
||||||
|
name: `Склад 2 ${suffix}`, code: `STORE2-${suffix}`,
|
||||||
|
address: 'Алматы 2', phone: null, managerName: null, isMain: false, isActive: true,
|
||||||
|
})
|
||||||
|
if (second.status !== 201) throw new Error(`store2 ${suffix}: ${second.status} ${JSON.stringify(second.data)}`)
|
||||||
|
secondStoreId = second.data.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: `Tr Prod ${suffix} ${TS}`, article: `T-${suffix}-${TS}`,
|
||||||
|
unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId,
|
||||||
|
barcodes: [{ code: generateEan13(bcIdx), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: priceTypeRetailId, amount: 100, currencyId }],
|
||||||
|
})
|
||||||
|
if (prod.status !== 201) throw new Error(`prod ${suffix}: ${prod.status} ${JSON.stringify(prod.data)}`)
|
||||||
|
return {
|
||||||
|
orgId: r.data.organizationId, email, password, token: sess.accessToken,
|
||||||
|
fromStoreId: mainStoreId, toStoreId: secondStoreId,
|
||||||
|
productId: prod.data.id,
|
||||||
|
currencyId, unitKgId, groupId, priceTypeRetailId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStock(token: string, storeId: string, productId: string): Promise<number> {
|
||||||
|
const r = await makeClient(token).get(`/api/inventory/stock?storeId=${storeId}`)
|
||||||
|
const row = (r.data?.items ?? []).find((x: { productId: string }) => x.productId === productId)
|
||||||
|
return row?.quantity ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr01_setup({ ctx, step, report }: StepCtx) {
|
||||||
|
ctx.ts = TS
|
||||||
|
ctx.orgA = await bootstrapOrg('a', '+77011130001', 31, true)
|
||||||
|
await new Promise(r => setTimeout(r, 900))
|
||||||
|
ctx.orgB = await bootstrapOrg('b', '+77011130002', 32, false)
|
||||||
|
// Зальём 100 на orgA.fromStore
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
const enter = await api.post('/api/inventory/enters', {
|
||||||
|
date: new Date().toISOString(), storeId: a.fromStoreId, currencyId: a.currencyId,
|
||||||
|
lines: [{ productId: a.productId, quantity: 100, unitCost: 30 }],
|
||||||
|
})
|
||||||
|
const post = await api.post(`/api/inventory/enters/${enter.data.id}/post`, {})
|
||||||
|
check(step, { kind: 'api', description: 'Initial Enter on FromStore=100', ok: post.status === 204, detail: `${post.status}` })
|
||||||
|
check(step, { kind: 'api', description: 'Org A: 2 разных склада', ok: a.fromStoreId !== a.toStoreId, detail: `from=${a.fromStoreId.slice(0,8)} to=${a.toStoreId.slice(0,8)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr02_create_draft({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
|
||||||
|
// From == To → 400
|
||||||
|
const same = await api.post('/api/inventory/transfers', {
|
||||||
|
date: new Date().toISOString(), fromStoreId: a.fromStoreId, toStoreId: a.fromStoreId,
|
||||||
|
notes: 'same', lines: [{ productId: a.productId, quantity: 1, unitCost: 1 }],
|
||||||
|
})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'From == To → 400',
|
||||||
|
ok: same.status === 400 && /различ|differ|same|совпад/i.test(asString(same.data)),
|
||||||
|
detail: `${same.status} ${asString(same.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await api.post('/api/inventory/transfers', {
|
||||||
|
date: new Date().toISOString(), fromStoreId: a.fromStoreId, toStoreId: a.toStoreId,
|
||||||
|
notes: 'test transfer',
|
||||||
|
lines: [{ productId: a.productId, quantity: 10, unitCost: 30 }],
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'POST transfers → 201', ok: res.status === 201, detail: `${res.status} ${asString(res.data).slice(0, 200)}` })
|
||||||
|
if (res.status !== 201) return
|
||||||
|
ctx.transferIdA = res.data.id
|
||||||
|
check(step, { kind: 'api', description: 'Total = 10*30 = 300', ok: res.data.total === 300, detail: `total=${res.data.total}` })
|
||||||
|
check(step, { kind: 'api', description: 'Status = Draft (0)', ok: res.data.status === 0, detail: `status=${res.data.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr03_update_draft({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.transferIdA) { step.status = 'skip'; return }
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
const upd = await api.put(`/api/inventory/transfers/${ctx.transferIdA}`, {
|
||||||
|
date: new Date().toISOString(), fromStoreId: a.fromStoreId, toStoreId: a.toStoreId,
|
||||||
|
notes: 'updated', lines: [{ productId: a.productId, quantity: 20, unitCost: 30 }],
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'PUT transfer → 204 (фикс EF8)', ok: upd.status === 204, detail: `${upd.status} ${asString(upd.data).slice(0, 200)}` })
|
||||||
|
const got = await api.get(`/api/inventory/transfers/${ctx.transferIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'После PUT: total=600',
|
||||||
|
ok: got.data?.total === 600,
|
||||||
|
detail: `total=${got.data?.total}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr04_post_atomic({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.transferIdA) { step.status = 'skip'; return }
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
const fromBefore = await getStock(a.token, a.fromStoreId, a.productId)
|
||||||
|
const toBefore = await getStock(a.token, a.toStoreId, a.productId)
|
||||||
|
|
||||||
|
const post = await api.post(`/api/inventory/transfers/${ctx.transferIdA}/post`, {})
|
||||||
|
check(step, { kind: 'api', description: 'POST /post → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` })
|
||||||
|
|
||||||
|
const fromAfter = await getStock(a.token, a.fromStoreId, a.productId)
|
||||||
|
const toAfter = await getStock(a.token, a.toStoreId, a.productId)
|
||||||
|
check(step, { kind: 'api', description: 'FromStore -= 20', ok: fromBefore - fromAfter === 20, detail: `from ${fromBefore} → ${fromAfter}` })
|
||||||
|
check(step, { kind: 'api', description: 'ToStore += 20', ok: toAfter - toBefore === 20, detail: `to ${toBefore} → ${toAfter}` })
|
||||||
|
|
||||||
|
// Movements: 2 на этот документ (Out + In)
|
||||||
|
const movs = await api.get(`/api/inventory/movements?take=200`)
|
||||||
|
const ours = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.transferIdA)
|
||||||
|
const hasOut = ours.some((m: { type: string }) => m.type === 'TransferOut')
|
||||||
|
const hasIn = ours.some((m: { type: string }) => m.type === 'TransferIn')
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'Создано минимум 2 StockMovement: TransferOut + TransferIn',
|
||||||
|
ok: ours.length >= 2 && hasOut && hasIn,
|
||||||
|
detail: `count=${ours.length} hasOut=${hasOut} hasIn=${hasIn} types=${ours.map((m: { type: string }) => m.type).join(',')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr05_post_more_than_stock({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
const avail = await getStock(a.token, a.fromStoreId, a.productId)
|
||||||
|
const big = await api.post('/api/inventory/transfers', {
|
||||||
|
date: new Date().toISOString(), fromStoreId: a.fromStoreId, toStoreId: a.toStoreId,
|
||||||
|
notes: 'too much', lines: [{ productId: a.productId, quantity: avail + 100, unitCost: 30 }],
|
||||||
|
})
|
||||||
|
if (big.status !== 201) {
|
||||||
|
step.notes.push(`create unexpected: ${big.status} ${asString(big.data).slice(0, 200)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const post = await api.post(`/api/inventory/transfers/${big.data.id}/post`, {})
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'Post с qty > available → 409',
|
||||||
|
ok: post.status === 409 && /недостаточ|остат|stock/i.test(asString(post.data)),
|
||||||
|
detail: `${post.status} ${asString(post.data).slice(0, 200)}`,
|
||||||
|
})
|
||||||
|
// Никаких StockMovement не должно появиться
|
||||||
|
const movs = await api.get(`/api/inventory/movements?take=200`)
|
||||||
|
const orphan = (movs.data?.items ?? []).some((m: { documentId: string }) => m.documentId === big.data.id)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'Документ failed post НЕ создал orphan-движений',
|
||||||
|
ok: !orphan,
|
||||||
|
detail: `orphan=${orphan}`,
|
||||||
|
})
|
||||||
|
if (orphan) {
|
||||||
|
report.bug({
|
||||||
|
step: 'tr05', severity: 'high',
|
||||||
|
title: 'Failed transfer post оставил orphan StockMovement',
|
||||||
|
detail: `transferId=${big.data.id} — нарушена атомарность транзакции`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr06_unpost_no_orphan({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.transferIdA) { step.status = 'skip'; return }
|
||||||
|
const a = ctx.orgA
|
||||||
|
const api = makeClient(a.token)
|
||||||
|
const fromBefore = await getStock(a.token, a.fromStoreId, a.productId)
|
||||||
|
const toBefore = await getStock(a.token, a.toStoreId, a.productId)
|
||||||
|
|
||||||
|
const unpost = await api.post(`/api/inventory/transfers/${ctx.transferIdA}/unpost`, {})
|
||||||
|
check(step, { kind: 'api', description: 'POST /unpost → 204', ok: unpost.status === 204, detail: `${unpost.status} ${asString(unpost.data).slice(0, 200)}` })
|
||||||
|
|
||||||
|
const fromAfter = await getStock(a.token, a.fromStoreId, a.productId)
|
||||||
|
const toAfter = await getStock(a.token, a.toStoreId, a.productId)
|
||||||
|
check(step, { kind: 'api', description: 'FromStore += 20 (вернулся)', ok: fromAfter - fromBefore === 20, detail: `from ${fromBefore} → ${fromAfter}` })
|
||||||
|
check(step, { kind: 'api', description: 'ToStore -= 20 (вернулся)', ok: toBefore - toAfter === 20, detail: `to ${toBefore} → ${toAfter}` })
|
||||||
|
|
||||||
|
// Проверка: для документа должно быть 4 движения (Out+In при пост, и 2 reversal при unpost), и Sum по qty = 0.
|
||||||
|
const movs = await api.get(`/api/inventory/movements?take=200`)
|
||||||
|
const ours = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.transferIdA)
|
||||||
|
const sumQty = ours.reduce((s: number, m: { quantity: number; storeId: string }) => s + m.quantity, 0)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'Сумма quantity по всем движениям документа = 0 (нет orphan)',
|
||||||
|
ok: Math.abs(sumQty) < 0.001,
|
||||||
|
detail: `count=${ours.length} sumQty=${sumQty}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const got = await api.get(`/api/inventory/transfers/${ctx.transferIdA}`)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'После Unpost: Status=Draft, PostedAt=null',
|
||||||
|
ok: got.data?.status === 0 && got.data?.postedAt == null,
|
||||||
|
detail: `status=${got.data?.status} postedAt=${got.data?.postedAt}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function tr07_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgA || !ctx.orgB || !ctx.transferIdA) { step.status = 'skip'; return }
|
||||||
|
const apiB = makeClient(ctx.orgB.token)
|
||||||
|
const list = await apiB.get('/api/inventory/transfers?pageSize=200')
|
||||||
|
const sees = (list.data?.items ?? []).some((x: { id: string }) => x.id === ctx.transferIdA)
|
||||||
|
check(step, {
|
||||||
|
kind: 'api', description: 'Org B не видит transfer org A',
|
||||||
|
ok: !sees, detail: `total=${list.data?.total} sees=${sees}`,
|
||||||
|
})
|
||||||
|
if (sees) report.bug({ step: 'tr07', severity: 'critical', title: 'P0: transfer org A виден из B', detail: `id=${ctx.transferIdA}` })
|
||||||
|
const get = await apiB.get(`/api/inventory/transfers/${ctx.transferIdA}`)
|
||||||
|
check(step, { kind: 'api', description: 'GET чужого id → 404', ok: get.status === 404, detail: `${get.status}` })
|
||||||
|
const post = await apiB.post(`/api/inventory/transfers/${ctx.transferIdA}/post`, {})
|
||||||
|
check(step, { kind: 'api', description: 'POST /post чужого → 404', ok: post.status === 404, detail: `${post.status}` })
|
||||||
|
}
|
||||||
25
tests/e2e/scenarios/stage-transfer.yml
Normal file
25
tests/e2e/scenarios/stage-transfer.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: stage-transfer
|
||||||
|
description: |
|
||||||
|
Transfer (Перемещение) на test.admin.food-market.kz. Два склада в одной
|
||||||
|
org, From != To, atomic post (TransferOut + TransferIn парой), Unpost
|
||||||
|
без orphan-движений, edge — From==To, > остатка, multi-tenant.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: false
|
||||||
|
smoke_login_super_admin: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: tr01_setup
|
||||||
|
title: 2 org. У org A — 2 склада + продукт с qty=100 на FromStore.
|
||||||
|
- id: tr02_create_draft
|
||||||
|
title: POST /api/inventory/transfers → 201, валидация From==To → 400
|
||||||
|
- id: tr03_update_draft
|
||||||
|
title: PUT с заменой строк → 204 (фикс EF8)
|
||||||
|
- id: tr04_post_atomic
|
||||||
|
title: POST /post → 204; FromStore.qty-=10; ToStore.qty+=10; пара StockMovement [TransferOut+TransferIn]
|
||||||
|
- id: tr05_post_more_than_stock
|
||||||
|
title: Если qty > available → 409 с conflicts, движения не создаются
|
||||||
|
- id: tr06_unpost_no_orphan
|
||||||
|
title: POST /unpost → 204; FromStore.qty восстановлен; ToStore.qty -= обратно; обе пары движений «отменены»
|
||||||
|
- id: tr07_multi_tenant_isolation
|
||||||
|
title: Org B не видит transfer org A
|
||||||
Loading…
Reference in a new issue