test(stage): пункт 6 — Inventory 8/8 ✓ + logic gap по CSV-импорту
Auto-populate (lines=null), explicit lines с пересчётом diff, PUT с изменением actualQty (фикс EF8 nav-collection теперь работает), Post → корректирующие StockMovement type=InventoryAdjustment, Unpost, multi-tenant. + Информационный gap: нет CSV-импорта фактических qty, для оператора склада ввод через JSON-API неудобен. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
24c3ff1635
commit
7d69006a94
|
|
@ -22,7 +22,7 @@
|
|||
- [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] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant. *(stage-loss.yml: 8/8 ✓, EF8 фикс уже в pt 3)*
|
||||
- [ ] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant.
|
||||
- [x] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant. *(stage-transfer.yml: 7/7 ✓)*
|
||||
- [ ] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant.
|
||||
- [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400.
|
||||
- [ ] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
# E2E report: stage-inventory
|
||||
|
||||
Запущен: 2026-05-29T12:05:09.680Z
|
||||
Длительность: 6.6с
|
||||
|
||||
**Итог:** 8 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 8)
|
||||
|
||||
## ✓ Step inv01_setup: 2 org. У org A — продукт1 (qty=50) + продукт2 (qty=80) через Enter
|
||||
|
||||
Длительность: 4502мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Enter posted (initial stock) | ✓ 204 |
|
||||
| api | Stock: p1=50, p2=80 | ✓ p1=50 p2=80 |
|
||||
|
||||
## ✓ Step inv02_create_draft_autopopulate: POST /api/inventory/inventories без Lines → автозаполнение всех товаров склада с book = текущий stock
|
||||
|
||||
Длительность: 172мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST inventories без Lines → 201 | ✓ 201 {"id":"8e1e2ef2-65c0-4a91-9fca-1e6f79f4f952","number":"И-2026-000001","date":"2026-05-29T12:05:14.183Z","status":0,"storeId":"4b4962e8-3e70-41ea-becf-e46331095da9","storeName":"Основной склад","no |
|
||||
| api | Авто-Lines содержат оба товара с bookQty = текущий stock | ✓ p1.book=50 p2.book=80 |
|
||||
| api | Auto: ActualQty=0, Diff=-bookQty | ✓ p1.actual=0 p1.diff=-50 |
|
||||
|
||||
## ✓ Step inv03_create_draft_explicit_lines: POST с явными Lines — book подгружается, diff = actual - book
|
||||
|
||||
Длительность: 190мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST с явными Lines → 201 | ✓ 201 |
|
||||
| api | p1: book=50, actual=48, diff=-2 | ✓ book=50 actual=48 diff=-2 |
|
||||
| api | p2: book=80, actual=85, diff=+5 | ✓ book=80 actual=85 diff=5 |
|
||||
|
||||
## ✓ Step inv04_update_actual_quantities: PUT — поменять ActualQty по строкам, Diff пересчитан (фикс EF8)
|
||||
|
||||
Длительность: 181мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | PUT inventory → 204 (фикс EF8 на nav-collection) | ✓ 204 |
|
||||
| api | После PUT: p1.actual=49,diff=-1; p2.actual=82,diff=+2 | ✓ p1.actual=49 diff=-1; p2.actual=82 diff=2 |
|
||||
|
||||
## ✓ Step inv05_post_creates_adjustment_movements: POST /post → 204; StockMovement type=InventoryAdjustment с qty=diff; Stock приведён к фактическому
|
||||
|
||||
Длительность: 548мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST /post → 204 | ✓ 204 |
|
||||
| api | p1: stock -=1 (был 50→49) | ✓ 50 → 49 |
|
||||
| api | p2: stock +=2 (был 80→82) | ✓ 80 → 82 |
|
||||
| api | Создано 2 StockMovement type=InventoryAdjustment | ✓ count=2 types=InventoryAdjustment |
|
||||
|
||||
## ✓ Step inv06_unpost: POST /unpost → 204; Stock восстановлен; Status=Draft
|
||||
|
||||
Длительность: 594мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST /unpost → 204 | ✓ 204 |
|
||||
| api | p1: stock +1 обратно (49→50) | ✓ 49 → 50 |
|
||||
| api | p2: stock -2 обратно (82→80) | ✓ 82 → 80 |
|
||||
| api | Status=Draft (0) | ✓ status=0 |
|
||||
|
||||
## ✓ Step inv07_multi_tenant_isolation: Org B не видит inventory org A
|
||||
|
||||
Длительность: 169мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Org B не видит inventory org A | ✓ total=0 sees=false |
|
||||
| api | GET чужого id → 404 | ✓ 404 |
|
||||
|
||||
## ✓ Step inv08_csv_import_gap_check: CSV-импорт фактического — endpoint существует? (logic gap если нет)
|
||||
|
||||
Длительность: 231мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | CSV-import endpoint существует ИЛИ отсутствует (см. gap) | ✓ not found — см. logic gap |
|
||||
|
||||
## Summary
|
||||
|
||||
- Passed: 8
|
||||
- Failed: 0
|
||||
- Warnings: 0
|
||||
- Skipped: 0
|
||||
|
||||
## Critical bugs
|
||||
|
||||
Нет.
|
||||
|
||||
## Logic gaps
|
||||
|
||||
- CSV-импорт фактических количеств в Inventory не реализован. Все строки задаются через JSON-API POST/PUT inventories. Для оператора склада это неудобно: реальная инвентаризация — это сканер штрихкодов или Excel-таблица, а не ручной JSON. Желательно добавить POST /api/inventory/inventories/{id}/import-actual с multipart CSV { barcode|article, actualQty }.
|
||||
302
tests/e2e/scenarios/stage-inventory.steps.ts
Normal file
302
tests/e2e/scenarios/stage-inventory.steps.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Stage Inventory: CRUD + Post + Unpost + edge + multi-tenant.
|
||||
*/
|
||||
import { login, makeClient } from '../lib/api.js'
|
||||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||
import { generateEan13 } from '../lib/barcode.js'
|
||||
|
||||
type Org = {
|
||||
orgId: string; email: string; password: string; token: string
|
||||
storeId: string; productId: string; product2Id: string
|
||||
currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string
|
||||
}
|
||||
type Ctx = {
|
||||
apiOnly: boolean
|
||||
ts: number
|
||||
orgA?: Org
|
||||
orgB?: Org
|
||||
invIdA?: string
|
||||
invIdAuto?: 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, bc1: number, bc2: number): Promise<Org> {
|
||||
const api = makeClient()
|
||||
const email = `stage-inv-${suffix}-${TS}@food-market.local`
|
||||
const password = 'StageInv12345!'
|
||||
// Retry on 429 — rate-limit на signup может задержать
|
||||
let r = await api.post('/api/auth/signup', { email, password, organizationName: `Inv ${suffix} ${TS}`, phone, plan: 'start' })
|
||||
for (let i = 0; i < 5 && r.status === 429; i++) {
|
||||
await new Promise(res => setTimeout(res, 15000))
|
||||
r = await api.post('/api/auth/signup', { email, password, organizationName: `Inv ${suffix} ${TS}`, phone, plan: 'start' })
|
||||
}
|
||||
if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status} ${JSON.stringify(r.data)}`)
|
||||
const sess = await login(email, password)
|
||||
const auth = makeClient(sess.accessToken)
|
||||
const [stores, units, groups, prices, currencies] = await Promise.all([
|
||||
auth.get('/api/catalog/stores'),
|
||||
auth.get('/api/catalog/units-of-measure'),
|
||||
auth.get('/api/catalog/product-groups'),
|
||||
auth.get('/api/catalog/price-types'),
|
||||
auth.get('/api/catalog/currencies'),
|
||||
])
|
||||
const storeId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id
|
||||
const unitKgId = units.data.items.find((u: { code: string }) => u.code === '166').id
|
||||
const groupId = groups.data.items.find((g: { parentId: string | null }) => g.parentId == null).id
|
||||
const priceTypeRetailId = prices.data.items.find((p: { isRequired: boolean }) => p.isRequired).id
|
||||
const currencyId = currencies.data.items.find((c: { code: string }) => c.code === 'KZT').id
|
||||
const p1 = await auth.post('/api/catalog/products', {
|
||||
name: `Inv P1 ${suffix} ${TS}`, article: `INV-${suffix}-${TS}-1`,
|
||||
unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId,
|
||||
barcodes: [{ code: generateEan13(bc1), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: priceTypeRetailId, amount: 100, currencyId }],
|
||||
})
|
||||
const p2 = await auth.post('/api/catalog/products', {
|
||||
name: `Inv P2 ${suffix} ${TS}`, article: `INV-${suffix}-${TS}-2`,
|
||||
unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId,
|
||||
barcodes: [{ code: generateEan13(bc2), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: priceTypeRetailId, amount: 200, currencyId }],
|
||||
})
|
||||
if (p1.status !== 201 || p2.status !== 201) throw new Error(`products ${suffix}: ${p1.status}/${p2.status}`)
|
||||
return {
|
||||
orgId: r.data.organizationId, email, password, token: sess.accessToken,
|
||||
storeId, productId: p1.data.id, product2Id: p2.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 inv01_setup({ ctx, step, report }: StepCtx) {
|
||||
ctx.ts = TS
|
||||
ctx.orgA = await bootstrapOrg('a', '+77011140001', 41, 42)
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
ctx.orgB = await bootstrapOrg('b', '+77011140002', 43, 44)
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
// qty1=50, qty2=80
|
||||
const e1 = await api.post('/api/inventory/enters', {
|
||||
date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId,
|
||||
lines: [
|
||||
{ productId: a.productId, quantity: 50, unitCost: 10 },
|
||||
{ productId: a.product2Id, quantity: 80, unitCost: 20 },
|
||||
],
|
||||
})
|
||||
const post = await api.post(`/api/inventory/enters/${e1.data.id}/post`, {})
|
||||
check(step, { kind: 'api', description: 'Enter posted (initial stock)', ok: post.status === 204, detail: `${post.status}` })
|
||||
const s1 = await getStock(a.token, a.storeId, a.productId)
|
||||
const s2 = await getStock(a.token, a.storeId, a.product2Id)
|
||||
check(step, { kind: 'api', description: 'Stock: p1=50, p2=80', ok: s1 === 50 && s2 === 80, detail: `p1=${s1} p2=${s2}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv02_create_draft_autopopulate({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const res = await api.post('/api/inventory/inventories', {
|
||||
date: new Date().toISOString(), storeId: a.storeId, notes: 'auto', lines: null,
|
||||
})
|
||||
check(step, { kind: 'api', description: 'POST inventories без Lines → 201', ok: res.status === 201, detail: `${res.status} ${asString(res.data).slice(0, 200)}` })
|
||||
if (res.status !== 201) return
|
||||
ctx.invIdAuto = res.data.id
|
||||
const got = await api.get(`/api/inventory/inventories/${ctx.invIdAuto}`)
|
||||
const lines = got.data?.lines ?? []
|
||||
const p1 = lines.find((l: { productId: string }) => l.productId === a.productId)
|
||||
const p2 = lines.find((l: { productId: string }) => l.productId === a.product2Id)
|
||||
check(step, {
|
||||
kind: 'api', description: 'Авто-Lines содержат оба товара с bookQty = текущий stock',
|
||||
ok: p1?.bookQty === 50 && p2?.bookQty === 80,
|
||||
detail: `p1.book=${p1?.bookQty} p2.book=${p2?.bookQty}`,
|
||||
})
|
||||
check(step, {
|
||||
kind: 'api', description: 'Auto: ActualQty=0, Diff=-bookQty',
|
||||
ok: p1?.actualQty === 0 && p1?.diff === -50,
|
||||
detail: `p1.actual=${p1?.actualQty} p1.diff=${p1?.diff}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv03_create_draft_explicit_lines({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
// p1: ActualQty=48 (диф=-2). p2: ActualQty=85 (диф=+5)
|
||||
const res = await api.post('/api/inventory/inventories', {
|
||||
date: new Date().toISOString(), storeId: a.storeId, notes: 'explicit',
|
||||
lines: [
|
||||
{ productId: a.productId, actualQty: 48 },
|
||||
{ productId: a.product2Id, actualQty: 85 },
|
||||
],
|
||||
})
|
||||
check(step, { kind: 'api', description: 'POST с явными Lines → 201', ok: res.status === 201, detail: `${res.status}` })
|
||||
if (res.status !== 201) return
|
||||
ctx.invIdA = res.data.id
|
||||
const got = await api.get(`/api/inventory/inventories/${ctx.invIdA}`)
|
||||
const p1 = got.data?.lines?.find((l: { productId: string }) => l.productId === a.productId)
|
||||
const p2 = got.data?.lines?.find((l: { productId: string }) => l.productId === a.product2Id)
|
||||
check(step, {
|
||||
kind: 'api', description: 'p1: book=50, actual=48, diff=-2',
|
||||
ok: p1?.bookQty === 50 && p1?.actualQty === 48 && p1?.diff === -2,
|
||||
detail: `book=${p1?.bookQty} actual=${p1?.actualQty} diff=${p1?.diff}`,
|
||||
})
|
||||
check(step, {
|
||||
kind: 'api', description: 'p2: book=80, actual=85, diff=+5',
|
||||
ok: p2?.bookQty === 80 && p2?.actualQty === 85 && p2?.diff === 5,
|
||||
detail: `book=${p2?.bookQty} actual=${p2?.actualQty} diff=${p2?.diff}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv04_update_actual_quantities({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.invIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
// Изменяем ActualQty
|
||||
const upd = await api.put(`/api/inventory/inventories/${ctx.invIdA}`, {
|
||||
date: new Date().toISOString(), storeId: a.storeId, notes: 'updated',
|
||||
lines: [
|
||||
{ productId: a.productId, actualQty: 49 },
|
||||
{ productId: a.product2Id, actualQty: 82 },
|
||||
],
|
||||
})
|
||||
check(step, {
|
||||
kind: 'api', description: 'PUT inventory → 204 (фикс EF8 на nav-collection)',
|
||||
ok: upd.status === 204,
|
||||
detail: `${upd.status} ${asString(upd.data).slice(0, 200)}`,
|
||||
})
|
||||
const got = await api.get(`/api/inventory/inventories/${ctx.invIdA}`)
|
||||
const p1 = got.data?.lines?.find((l: { productId: string }) => l.productId === a.productId)
|
||||
const p2 = got.data?.lines?.find((l: { productId: string }) => l.productId === a.product2Id)
|
||||
check(step, {
|
||||
kind: 'api', description: 'После PUT: p1.actual=49,diff=-1; p2.actual=82,diff=+2',
|
||||
ok: p1?.actualQty === 49 && p1?.diff === -1 && p2?.actualQty === 82 && p2?.diff === 2,
|
||||
detail: `p1.actual=${p1?.actualQty} diff=${p1?.diff}; p2.actual=${p2?.actualQty} diff=${p2?.diff}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv05_post_creates_adjustment_movements({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.invIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const s1Before = await getStock(a.token, a.storeId, a.productId)
|
||||
const s2Before = await getStock(a.token, a.storeId, a.product2Id)
|
||||
// Сейчас diff=-1 и +2; ждём stock=49 и 82
|
||||
const post = await api.post(`/api/inventory/inventories/${ctx.invIdA}/post`, {})
|
||||
check(step, { kind: 'api', description: 'POST /post → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` })
|
||||
const s1After = await getStock(a.token, a.storeId, a.productId)
|
||||
const s2After = await getStock(a.token, a.storeId, a.product2Id)
|
||||
check(step, {
|
||||
kind: 'api', description: 'p1: stock -=1 (был 50→49)',
|
||||
ok: s1After - s1Before === -1, detail: `${s1Before} → ${s1After}`,
|
||||
})
|
||||
check(step, {
|
||||
kind: 'api', description: 'p2: stock +=2 (был 80→82)',
|
||||
ok: s2After - s2Before === 2, detail: `${s2Before} → ${s2After}`,
|
||||
})
|
||||
const movs = await api.get(`/api/inventory/movements?take=200`)
|
||||
const ours = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.invIdA)
|
||||
const types = new Set(ours.map((m: { type: string }) => m.type))
|
||||
check(step, {
|
||||
kind: 'api', description: 'Создано 2 StockMovement type=InventoryAdjustment',
|
||||
ok: ours.length === 2 && types.has('InventoryAdjustment'),
|
||||
detail: `count=${ours.length} types=${[...types].join(',')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv06_unpost({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.invIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const s1Before = await getStock(a.token, a.storeId, a.productId)
|
||||
const s2Before = await getStock(a.token, a.storeId, a.product2Id)
|
||||
const unpost = await api.post(`/api/inventory/inventories/${ctx.invIdA}/unpost`, {})
|
||||
check(step, { kind: 'api', description: 'POST /unpost → 204', ok: unpost.status === 204, detail: `${unpost.status} ${asString(unpost.data).slice(0, 200)}` })
|
||||
const s1After = await getStock(a.token, a.storeId, a.productId)
|
||||
const s2After = await getStock(a.token, a.storeId, a.product2Id)
|
||||
check(step, {
|
||||
kind: 'api', description: 'p1: stock +1 обратно (49→50)',
|
||||
ok: s1After - s1Before === 1, detail: `${s1Before} → ${s1After}`,
|
||||
})
|
||||
check(step, {
|
||||
kind: 'api', description: 'p2: stock -2 обратно (82→80)',
|
||||
ok: s2After - s2Before === -2, detail: `${s2Before} → ${s2After}`,
|
||||
})
|
||||
const got = await api.get(`/api/inventory/inventories/${ctx.invIdA}`)
|
||||
check(step, {
|
||||
kind: 'api', description: 'Status=Draft (0)',
|
||||
ok: got.data?.status === 0,
|
||||
detail: `status=${got.data?.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv07_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.orgB || !ctx.invIdA) { step.status = 'skip'; return }
|
||||
const apiB = makeClient(ctx.orgB.token)
|
||||
const list = await apiB.get('/api/inventory/inventories?pageSize=200')
|
||||
const sees = (list.data?.items ?? []).some((x: { id: string }) => x.id === ctx.invIdA)
|
||||
check(step, {
|
||||
kind: 'api', description: 'Org B не видит inventory org A',
|
||||
ok: !sees, detail: `total=${list.data?.total} sees=${sees}`,
|
||||
})
|
||||
if (sees) report.bug({ step: 'inv07', severity: 'critical', title: 'P0: inventory org A виден из B', detail: `id=${ctx.invIdA}` })
|
||||
const get = await apiB.get(`/api/inventory/inventories/${ctx.invIdA}`)
|
||||
check(step, { kind: 'api', description: 'GET чужого id → 404', ok: get.status === 404, detail: `${get.status}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function inv08_csv_import_gap_check({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.invIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
// Пытаемся попасть в типичные CSV-import endpoints
|
||||
const candidates = [
|
||||
`/api/inventory/inventories/${ctx.invIdA}/import`,
|
||||
`/api/inventory/inventories/${ctx.invIdA}/csv`,
|
||||
`/api/inventory/inventories/${ctx.invIdA}/import-actual`,
|
||||
]
|
||||
const found: string[] = []
|
||||
for (const url of candidates) {
|
||||
const r = await api.post(url, {})
|
||||
if (r.status !== 404) found.push(`${url}=${r.status}`)
|
||||
}
|
||||
// Не fail step'a — фиксируем как информационный gap, чтобы не делать
|
||||
// отсутствие CSV-импорта блокером сценария.
|
||||
check(step, {
|
||||
kind: 'api', description: 'CSV-import endpoint существует ИЛИ отсутствует (см. gap)',
|
||||
ok: true, detail: found.length > 0 ? found.join(', ') : 'not found — см. logic gap',
|
||||
})
|
||||
if (found.length === 0) {
|
||||
report.gap(
|
||||
'CSV-импорт фактических количеств в Inventory не реализован. ' +
|
||||
'Все строки задаются через JSON-API POST/PUT inventories. ' +
|
||||
'Для оператора склада это неудобно: реальная инвентаризация — это сканер штрихкодов или Excel-таблица, ' +
|
||||
'а не ручной JSON. Желательно добавить POST /api/inventory/inventories/{id}/import-actual ' +
|
||||
'с multipart CSV { barcode|article, actualQty }.',
|
||||
)
|
||||
}
|
||||
}
|
||||
28
tests/e2e/scenarios/stage-inventory.yml
Normal file
28
tests/e2e/scenarios/stage-inventory.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: stage-inventory
|
||||
description: |
|
||||
Inventory (Инвентаризация) на test.admin.food-market.kz. Создание
|
||||
Draft (с автоподтягиванием bookQty из текущего Stock либо ручным
|
||||
списком). Update. Post (создание корректирующего StockMovement
|
||||
type=InventoryAdjustment на diff). Unpost. Multi-tenant.
|
||||
|
||||
preconditions:
|
||||
reset_db: false
|
||||
smoke_login_super_admin: false
|
||||
|
||||
steps:
|
||||
- id: inv01_setup
|
||||
title: 2 org. У org A — продукт1 (qty=50) + продукт2 (qty=80) через Enter
|
||||
- id: inv02_create_draft_autopopulate
|
||||
title: POST /api/inventory/inventories без Lines → автозаполнение всех товаров склада с book = текущий stock
|
||||
- id: inv03_create_draft_explicit_lines
|
||||
title: POST с явными Lines — book подгружается, diff = actual - book
|
||||
- id: inv04_update_actual_quantities
|
||||
title: PUT — поменять ActualQty по строкам, Diff пересчитан (фикс EF8)
|
||||
- id: inv05_post_creates_adjustment_movements
|
||||
title: POST /post → 204; StockMovement type=InventoryAdjustment с qty=diff; Stock приведён к фактическому
|
||||
- id: inv06_unpost
|
||||
title: POST /unpost → 204; Stock восстановлен; Status=Draft
|
||||
- id: inv07_multi_tenant_isolation
|
||||
title: Org B не видит inventory org A
|
||||
- id: inv08_csv_import_gap_check
|
||||
title: CSV-импорт фактического — endpoint существует? (logic gap если нет)
|
||||
Loading…
Reference in a new issue