test(stage): пункт 9 — Demand 8/8 ✓ (Cash + Credit + post + multi-tenant)
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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-29 17:23:03 +05:00
parent 74e14ebeb5
commit 475c5ca674
4 changed files with 357 additions and 1 deletions

View file

@ -25,7 +25,7 @@
- [x] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant. *(stage-transfer.yml: 7/7 ✓)* - [x] **5. Transfer (Перемещение)** — два склада, From!=To, atomic post, Unpost без orphan-движений; multi-tenant. *(stage-transfer.yml: 7/7 ✓)*
- [x] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. *(stage-inventory.yml: 8/8 ✓; logic gap — нет CSV-импорта)* - [x] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. *(stage-inventory.yml: 8/8 ✓; logic gap — нет CSV-импорта)*
- [x] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. *(stage-customer-return.yml: 6/6 ✓)* - [x] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. *(stage-customer-return.yml: 6/6 ✓)*
- [ ] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. - [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
- [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. - [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant.
- [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. - [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge.
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. - [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.

View file

@ -0,0 +1,92 @@
# E2E report: stage-demand
Запущен: 2026-05-29T12:22:49.971Z
Длительность: 8.0с
**Итог:** 8 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 8)
## ✓ Step dm01_setup: 2 org. У orgA — продукт qty=100, customer-юрлицо
Длительность: 5343мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Initial Stock = 100 | ✓ 100 |
## ✓ Step dm02_create_draft_full_paid: POST demand с Payment=Cash, PaidAmount=Total → 201
Длительность: 569мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST demand → 201 | ✓ 201 {"id":"8fe3b963-f4c4-4073-8f50-75cd06f684bd","number":"ОТГ-2026-000001","date":"2026-05-29T12:22:55.315Z","status":0,"customerId":"0b98adc7-0367-41cc-a18d-7a5f6d4f6534","customerName":"ТОО Покупат |
| api | Total = 2000 | ✓ total=2000 |
| api | PaidAmount = 2000 | ✓ paid=2000 |
## ✓ Step dm03_update_draft: PUT — изменить строки (фикс EF8 уже применён к demands в TD-6) → 204
Длительность: 310мс
| Тип | Проверка | Результат |
|---|---|---|
| api | PUT demand → 204 | ✓ 204 |
| api | После PUT: total=3000, paid=3000 | ✓ total=3000 paid=3000 |
## ✓ Step dm04_post_full_paid: POST /post → 204; Stock -= qty; StockMovement type=WholesaleSale
Длительность: 428мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST /post → 204 | ✓ 204 |
| api | Stock -= 6 | ✓ 100 → 94 |
| api | StockMovement type=WholesaleSale | ✓ count=1 type=WholesaleSale |
## ✓ Step dm05_credit_partial_payment: POST demand с Payment=Credit + PaidAmount=0 (дебиторка) → 201, post → 204; долг = Total
Длительность: 297мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Credit draft → 201 | ✓ 201 {"id":"efaafaa6-7205-464d-9bbf-1ffc2fa87da6","number":"ОТГ-2026-000002","date":"2026-05-29T12:22:56.622Z","status":0,"customerId":"0b98adc7-0367-41cc-a18d-7a5f6d4f6534","customerName":"ТОО Покупат |
| api | Credit post → 204 | ✓ 204 |
| api | После Post: payment=Credit(3), total=1600, paid=0 (debt=1600) | ✓ payment=3 total=1600 paid=0 |
## ✓ Step dm06_post_more_than_stock: Demand с qty > stock → 409 при post
Длительность: 282мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Post qty > stock → 409 | ✓ 409 {"error":"Недостаточно остатка для проведения отгрузки.","lines":[{"productId":"7da822c6-6da0-49e7-bfac-417884b151e5","productName":"DM Prod a 1780057369970","requested":190,"available":90}]} |
## ✓ Step dm07_unpost: POST /unpost → 204; Stock восстановлен
Длительность: 388мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST /unpost → 204 | ✓ 204 |
| api | Stock += 6 обратно | ✓ 90 → 96 |
| api | Status = Draft (0) | ✓ status=0 |
## ✓ Step dm08_multi_tenant_isolation: Org B не видит demand org A
Длительность: 333мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Org B не видит demand org A | ✓ total=0 sees=false |
| api | GET чужого → 404 | ✓ 404 |
| api | POST /post чужого → 404 | ✓ 404 |
## Summary
- Passed: 8
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.

View file

@ -0,0 +1,236 @@
/**
* Stage Demand: оптовая отгрузка. 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; customerId: string
currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string
}
type Ctx = {
apiOnly: boolean
ts: number
orgA?: Org
orgB?: Org
demandIdA?: string
creditDemandIdA?: 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): Promise<Org> {
const api = makeClient()
const email = `stage-dm-${suffix}-${TS}@food-market.local`
const password = 'StageDm12345!'
let r = await api.post('/api/auth/signup', { email, password, organizationName: `DM ${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: `DM ${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 customer = await auth.post('/api/catalog/counterparties', {
name: `ТОО Покупатель ${suffix} ${TS}`, legalName: `ТОО Покупатель ${suffix} ${TS}`, type: 1,
bin: `12345699999${bcIdx % 10}`, iin: null, taxNumber: null,
countryId: null, address: null, phone: '+77011112233', email: null,
bankName: null, bankAccount: null, bik: null, contactPerson: null, notes: null,
})
if (customer.status !== 201) throw new Error(`customer ${suffix}: ${customer.status} ${JSON.stringify(customer.data)}`)
const prod = await auth.post('/api/catalog/products', {
name: `DM Prod ${suffix} ${TS}`, article: `DM-${suffix}-${TS}`,
unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId,
barcodes: [{ code: generateEan13(bcIdx), type: 1, isPrimary: true }],
prices: [{ priceTypeId: priceTypeRetailId, amount: 500, currencyId }],
})
if (prod.status !== 201) throw new Error(`prod ${suffix}: ${prod.status} ${JSON.stringify(prod.data)}`)
// Initial stock = 100 через Enter
const enter = await auth.post('/api/inventory/enters', {
date: new Date().toISOString(), storeId, currencyId,
lines: [{ productId: prod.data.id, quantity: 100, unitCost: 300 }],
})
await auth.post(`/api/inventory/enters/${enter.data.id}/post`, {})
return {
orgId: r.data.organizationId, email, password, token: sess.accessToken,
storeId, productId: prod.data.id, customerId: customer.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 dm01_setup({ ctx, step, report }: StepCtx) {
ctx.ts = TS
ctx.orgA = await bootstrapOrg('a', '+77011170001', 71)
await new Promise(r => setTimeout(r, 1500))
ctx.orgB = await bootstrapOrg('b', '+77011170002', 72)
const stock = await getStock(ctx.orgA.token, ctx.orgA.storeId, ctx.orgA.productId)
check(step, { kind: 'api', description: 'Initial Stock = 100', ok: stock === 100, detail: `${stock}` })
}
// ---------------------------------------------------------------------------
export async function dm02_create_draft_full_paid({ ctx, step, report }: StepCtx) {
if (!ctx.orgA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
// PaymentMethod.Cash=0, qty=5 × unitPrice=400 = 2000
const res = await api.post('/api/sales/demands', {
date: new Date().toISOString(),
customerId: a.customerId, storeId: a.storeId, currencyId: a.currencyId,
payment: 0, paidAmount: 2000, notes: 'оптовая Cash',
lines: [{ productId: a.productId, quantity: 5, unitPrice: 400, discount: 0, vatPercent: 0 }],
})
check(step, { kind: 'api', description: 'POST demand → 201', ok: res.status === 201, detail: `${res.status} ${asString(res.data).slice(0, 200)}` })
if (res.status !== 201) return
ctx.demandIdA = res.data.id
check(step, { kind: 'api', description: 'Total = 2000', ok: res.data.total === 2000, detail: `total=${res.data.total}` })
check(step, { kind: 'api', description: 'PaidAmount = 2000', ok: res.data.paidAmount === 2000, detail: `paid=${res.data.paidAmount}` })
}
// ---------------------------------------------------------------------------
export async function dm03_update_draft({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.demandIdA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
const upd = await api.put(`/api/sales/demands/${ctx.demandIdA}`, {
date: new Date().toISOString(),
customerId: a.customerId, storeId: a.storeId, currencyId: a.currencyId,
payment: 0, paidAmount: 3000, notes: 'updated',
lines: [{ productId: a.productId, quantity: 6, unitPrice: 500, discount: 0, vatPercent: 0 }],
})
check(step, { kind: 'api', description: 'PUT demand → 204', ok: upd.status === 204, detail: `${upd.status} ${asString(upd.data).slice(0, 200)}` })
const got = await api.get(`/api/sales/demands/${ctx.demandIdA}`)
check(step, { kind: 'api', description: 'После PUT: total=3000, paid=3000', ok: got.data?.total === 3000 && got.data?.paidAmount === 3000, detail: `total=${got.data?.total} paid=${got.data?.paidAmount}` })
}
// ---------------------------------------------------------------------------
export async function dm04_post_full_paid({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.demandIdA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
const before = await getStock(a.token, a.storeId, a.productId)
const post = await api.post(`/api/sales/demands/${ctx.demandIdA}/post`, {})
check(step, { kind: 'api', description: 'POST /post → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` })
const after = await getStock(a.token, a.storeId, a.productId)
check(step, { kind: 'api', description: 'Stock -= 6', ok: before - after === 6, detail: `${before}${after}` })
const movs = await api.get('/api/inventory/movements?take=200')
const ours = (movs.data?.items ?? []).filter((m: { documentId: string }) => m.documentId === ctx.demandIdA)
check(step, {
kind: 'api', description: 'StockMovement type=WholesaleSale',
ok: ours.length >= 1 && ours[0].type === 'WholesaleSale',
detail: `count=${ours.length} type=${ours[0]?.type}`,
})
}
// ---------------------------------------------------------------------------
export async function dm05_credit_partial_payment({ ctx, step, report }: StepCtx) {
if (!ctx.orgA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
// Payment=Credit (3), qty=4 × 400 = 1600, paid=0 → дебиторка 1600
const create = await api.post('/api/sales/demands', {
date: new Date().toISOString(),
customerId: a.customerId, storeId: a.storeId, currencyId: a.currencyId,
payment: 3, paidAmount: 0, notes: 'кредит',
lines: [{ productId: a.productId, quantity: 4, unitPrice: 400, discount: 0, vatPercent: 0 }],
})
check(step, { kind: 'api', description: 'Credit draft → 201', ok: create.status === 201, detail: `${create.status} ${asString(create.data).slice(0, 200)}` })
if (create.status !== 201) return
ctx.creditDemandIdA = create.data.id
const post = await api.post(`/api/sales/demands/${ctx.creditDemandIdA}/post`, {})
check(step, { kind: 'api', description: 'Credit post → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` })
const got = await api.get(`/api/sales/demands/${ctx.creditDemandIdA}`)
check(step, {
kind: 'api', description: 'После Post: payment=Credit(3), total=1600, paid=0 (debt=1600)',
ok: got.data?.payment === 3 && got.data?.total === 1600 && got.data?.paidAmount === 0,
detail: `payment=${got.data?.payment} total=${got.data?.total} paid=${got.data?.paidAmount}`,
})
}
// ---------------------------------------------------------------------------
export async function dm06_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.storeId, a.productId)
const create = await api.post('/api/sales/demands', {
date: new Date().toISOString(),
customerId: a.customerId, storeId: a.storeId, currencyId: a.currencyId,
payment: 0, paidAmount: 0, notes: 'over-stock',
lines: [{ productId: a.productId, quantity: avail + 100, unitPrice: 400, discount: 0, vatPercent: 0 }],
})
if (create.status !== 201) { step.notes.push(`create unexpected: ${create.status}`); return }
const post = await api.post(`/api/sales/demands/${create.data.id}/post`, {})
check(step, {
kind: 'api', description: 'Post qty > stock → 409',
ok: post.status === 409 && /остат|stock|больше|недостаточ|нал/i.test(asString(post.data)),
detail: `${post.status} ${asString(post.data).slice(0, 200)}`,
})
}
// ---------------------------------------------------------------------------
export async function dm07_unpost({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.demandIdA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
const before = await getStock(a.token, a.storeId, a.productId)
const unpost = await api.post(`/api/sales/demands/${ctx.demandIdA}/unpost`, {})
check(step, { kind: 'api', description: 'POST /unpost → 204', ok: unpost.status === 204, detail: `${unpost.status} ${asString(unpost.data).slice(0, 200)}` })
const after = await getStock(a.token, a.storeId, a.productId)
check(step, { kind: 'api', description: 'Stock += 6 обратно', ok: after - before === 6, detail: `${before}${after}` })
const got = await api.get(`/api/sales/demands/${ctx.demandIdA}`)
check(step, { kind: 'api', description: 'Status = Draft (0)', ok: got.data?.status === 0, detail: `status=${got.data?.status}` })
}
// ---------------------------------------------------------------------------
export async function dm08_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.orgB || !ctx.demandIdA) { step.status = 'skip'; return }
const apiB = makeClient(ctx.orgB.token)
const list = await apiB.get('/api/sales/demands?pageSize=200')
const sees = (list.data?.items ?? []).some((x: { id: string }) => x.id === ctx.demandIdA)
check(step, { kind: 'api', description: 'Org B не видит demand org A', ok: !sees, detail: `total=${list.data?.total} sees=${sees}` })
if (sees) report.bug({ step: 'dm08', severity: 'critical', title: 'P0: demand org A виден из B', detail: `id=${ctx.demandIdA}` })
const get = await apiB.get(`/api/sales/demands/${ctx.demandIdA}`)
check(step, { kind: 'api', description: 'GET чужого → 404', ok: get.status === 404, detail: `${get.status}` })
const post = await apiB.post(`/api/sales/demands/${ctx.demandIdA}/post`, {})
check(step, { kind: 'api', description: 'POST /post чужого → 404', ok: post.status === 404, detail: `${post.status}` })
}

View file

@ -0,0 +1,28 @@
name: stage-demand
description: |
Demand (оптовая отгрузка юрлицу) на test.admin.food-market.kz.
Создание Draft с DemandPayment (Cash/Card/BankTransfer/Credit),
PaidAmount < Total для Credit (дебиторка). Post → Stock минус с
MovementType.WholesaleSale. Multi-tenant. Edge: post больше остатка.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: dm01_setup
title: 2 org. У orgA — продукт qty=100, customer-юрлицо
- id: dm02_create_draft_full_paid
title: POST demand с Payment=Cash, PaidAmount=Total → 201
- id: dm03_update_draft
title: PUT — изменить строки (фикс EF8 уже применён к demands в TD-6) → 204
- id: dm04_post_full_paid
title: POST /post → 204; Stock -= qty; StockMovement type=WholesaleSale
- id: dm05_credit_partial_payment
title: POST demand с Payment=Credit + PaidAmount=0 (дебиторка) → 201, post → 204; долг = Total
- id: dm06_post_more_than_stock
title: Demand с qty > stock → 409 при post
- id: dm07_unpost
title: POST /unpost → 204; Stock восстановлен
- id: dm08_multi_tenant_isolation
title: Org B не видит demand org A