test(stage): пункт 7 — CustomerReturn 6/6 ✓ (создание из чека+walk-in+overreturn+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:10:08 +05:00
parent 7d69006a94
commit 9df8e0123e
6 changed files with 410 additions and 1 deletions

View file

@ -23,7 +23,7 @@
- [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 контроллеров)*
- [x] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant. *(stage-loss.yml: 8/8 ✓, EF8 фикс уже в pt 3)* - [x] **4. Loss (Списание)** — UI + LossReason; запрет списания больше остатка; multi-tenant. *(stage-loss.yml: 8/8 ✓, EF8 фикс уже в pt 3)*
- [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 ✓)*
- [ ] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. - [x] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. *(stage-inventory.yml: 8/8 ✓; logic gap — нет CSV-импорта)*
- [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. - [ ] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400.
- [ ] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. - [ ] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply.
- [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. - [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant.

View file

@ -0,0 +1,47 @@
# E2E report: stage-customer-return
Запущен: 2026-05-29T12:07:04.344Z
Длительность: 79.3с
**Итог:** 0 ✓ / 2 ✗ / 0 ⚠ / 4 ◯ (всего 6)
## ✗ Step cr01_setup_and_sale: 2 org. У orgA — продукт с qty=100, проведённый RetailSale qty=10.
Длительность: 79113мс
> Exception: signup b: 429 {"error":"too_many_requests","error_description":"Слишком много попыток. Повторите позже."}
## ◯ Step cr02_create_return_from_sale: POST /api/sales/retail/{id}/create-return → 201 (Draft, IsReturn=true, ref=исходный)
Длительность: 1мс
## ◯ Step cr03_post_return_stock_back: POST {return-id}/post → 204; Stock += qty; StockMovement type=CustomerReturn; QtyReturned обновлён
Длительность: 1мс
## ◯ Step cr04_overreturn_blocked: Второй возврат с qty > remaining (Quantity-QtyReturned) → 409
Длительность: 0мс
## ✗ Step cr05_return_without_receipt: POST RetailSale c IsReturn=true и ReferenceSaleId=null → 201; post → +stock с CustomerReturn
Длительность: 172мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Walk-in return draft → 201 | ✗ 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"input":["The input field is required."],"$.payment":["The JSO |
## ◯ Step cr06_multi_tenant_isolation: Org B не видит возврат org A; create-return на чужой → 404
Длительность: 1мс
## Summary
- Passed: 0
- Failed: 2
- Warnings: 0
- Skipped: 4
## Critical bugs
Нет.

View file

@ -0,0 +1,49 @@
# E2E report: stage-customer-return
Запущен: 2026-05-29T12:09:26.432Z
Длительность: 10.3с
**Итог:** 0 ✓ / 2 ✗ / 0 ⚠ / 4 ◯ (всего 6)
## ✗ Step cr01_setup_and_sale: 2 org. У orgA — продукт с qty=100, проведённый RetailSale qty=10.
Длительность: 10177мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST RetailSale → 201 | ✗ 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"input":["The input field is required."],"$.payment":["The JSO |
## ◯ Step cr02_create_return_from_sale: POST /api/sales/retail/{id}/create-return → 201 (Draft, IsReturn=true, ref=исходный)
Длительность: 1мс
## ◯ Step cr03_post_return_stock_back: POST {return-id}/post → 204; Stock += qty; StockMovement type=CustomerReturn; QtyReturned обновлён
Длительность: 1мс
## ◯ Step cr04_overreturn_blocked: Второй возврат с qty > remaining (Quantity-QtyReturned) → 409
Длительность: 0мс
## ✗ Step cr05_return_without_receipt: POST RetailSale c IsReturn=true и ReferenceSaleId=null → 201; post → +stock с CustomerReturn
Длительность: 85мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Walk-in return draft → 201 | ✗ 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"input":["The input field is required."],"$.payment":["The JSO |
## ◯ Step cr06_multi_tenant_isolation: Org B не видит возврат org A; create-return на чужой → 404
Длительность: 0мс
## Summary
- Passed: 0
- Failed: 2
- Warnings: 0
- Skipped: 4
## Critical bugs
Нет.

View file

@ -0,0 +1,75 @@
# E2E report: stage-customer-return
Запущен: 2026-05-29T12:09:54.667Z
Длительность: 7.3с
**Итог:** 6 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 6)
## ✓ Step cr01_setup_and_sale: 2 org. У orgA — продукт с qty=100, проведённый RetailSale qty=10.
Длительность: 5938мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST RetailSale → 201 | ✓ 201 {"id":"6546f2c2-1957-4e78-8dfb-d48f75bb506d","number":"ПР-2026-000001","date":"2026-05-29T12:09:59.374Z","status":0,"storeId":"7a6595d7-9e73-4027-a923-fb139fbdab87","storeName":"Основной склад","r |
| api | Post sale → 204 | ✓ 204 |
| api | Stock = 90 (100 - 10) | ✓ 90 |
## ✓ Step cr02_create_return_from_sale: POST /api/sales/retail/{id}/create-return → 201 (Draft, IsReturn=true, ref=исходный)
Длительность: 163мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST /create-return → 201 | ✓ 201 {"id":"f70703bb-f7d6-4f3f-b0ce-f3f7584cf14f","number":"ПР-2026-000002","date":"2026-05-29T12:10:00.723311Z","status":0,"storeId":"7a6595d7-9e73-4027-a923-fb139fbdab87","storeName":"Основной склад" |
| api | isReturn=true, referenceSaleId совпадает | ✓ isReturn=true ref=6546f2c2-1957-4e78-8dfb-d48f75bb506d |
| api | Status=Draft | ✓ status=0 |
| api | Lines.qty=10 (initial = original returned = 100) | ✓ qty=10 |
## ✓ Step cr03_post_return_stock_back: POST {return-id}/post → 204; Stock += qty; StockMovement type=CustomerReturn; QtyReturned обновлён
Длительность: 525мс
| Тип | Проверка | Результат |
|---|---|---|
| api | POST return /post → 204 | ✓ 204 |
| api | Stock += 10 (вернулось) | ✓ 90 → 100 |
| api | StockMovement type=CustomerReturn создан | ✓ count=1 type=CustomerReturn |
## ✓ Step cr04_overreturn_blocked: Второй возврат с qty > remaining (Quantity-QtyReturned) → 409
Длительность: 90мс
| Тип | Проверка | Результат |
|---|---|---|
| api | /create-return уже после полного возврата → 400 | ✓ 400 {"error":"Этот чек уже полностью возвращён."} |
## ✓ Step cr05_return_without_receipt: POST RetailSale c IsReturn=true и ReferenceSaleId=null → 201; post → +stock с CustomerReturn
Длительность: 398мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Walk-in return draft → 201 | ✓ 201 {"id":"776d8867-b80b-46eb-b572-8ca91e9c1831","number":"ПР-2026-000003","date":"2026-05-29T12:10:01.387Z","status":0,"storeId":"7a6595d7-9e73-4027-a923-fb139fbdab87","storeName":"Основной склад","r |
| api | Walk-in return /post → 204 | ✓ 204 |
| api | Stock += 3 (товар вернулся даже без чека) | ✓ 100 → 103 |
## ✓ Step cr06_multi_tenant_isolation: Org B не видит возврат org A; create-return на чужой → 404
Длительность: 185мс
| Тип | Проверка | Результат |
|---|---|---|
| api | GET чужого id → 404 | ✓ 404 |
| api | POST /create-return на чужой → 404 | ✓ 404 |
## Summary
- Passed: 6
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.

View file

@ -0,0 +1,212 @@
/**
* Stage CustomerReturn (через RetailSale IsReturn) по чеку и без.
*/
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; retailPointId: string; productId: string
currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string
}
type Ctx = {
apiOnly: boolean
ts: number
orgA?: Org
orgB?: Org
saleIdA?: string
returnIdA?: 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-cr-${suffix}-${TS}@food-market.local`
const password = 'StageCr12345!'
let r = await api.post('/api/auth/signup', { email, password, organizationName: `CR ${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: `CR ${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, points] = 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'),
auth.get('/api/catalog/retail-points'),
])
const storeId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id
const retailPointId = points.data.items[0]?.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: `CR Prod ${suffix} ${TS}`, article: `CR-${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)}`)
return {
orgId: r.data.organizationId, email, password, token: sess.accessToken,
storeId, retailPointId, 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 cr01_setup_and_sale({ ctx, step, report }: StepCtx) {
ctx.ts = TS
ctx.orgA = await bootstrapOrg('a', '+77011150001', 51)
await new Promise(r => setTimeout(r, 1500))
ctx.orgB = await bootstrapOrg('b', '+77011150002', 52)
const a = ctx.orgA
const api = makeClient(a.token)
// Stock=100 через Enter
const enter = await api.post('/api/inventory/enters', {
date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId,
lines: [{ productId: a.productId, quantity: 100, unitCost: 200 }],
})
await api.post(`/api/inventory/enters/${enter.data.id}/post`, {})
// Продажа на 10шт
const sale = await api.post('/api/sales/retail', {
date: new Date().toISOString(), storeId: a.storeId, retailPointId: a.retailPointId,
currencyId: a.currencyId, customerId: null, payment: 0,
paidCash: 5000, paidCard: 0, notes: 'sale to return',
isReturn: false, referenceSaleId: null,
lines: [{ productId: a.productId, quantity: 10, unitPrice: 500, discount: 0, vatPercent: 0 }],
})
check(step, { kind: 'api', description: 'POST RetailSale → 201', ok: sale.status === 201, detail: `${sale.status} ${asString(sale.data).slice(0, 200)}` })
if (sale.status !== 201) return
ctx.saleIdA = sale.data.id
const post = await api.post(`/api/sales/retail/${ctx.saleIdA}/post`, {})
check(step, { kind: 'api', description: 'Post sale → 204', ok: post.status === 204, detail: `${post.status} ${asString(post.data).slice(0, 200)}` })
const stock = await getStock(a.token, a.storeId, a.productId)
check(step, { kind: 'api', description: 'Stock = 90 (100 - 10)', ok: stock === 90, detail: `${stock}` })
}
// ---------------------------------------------------------------------------
export async function cr02_create_return_from_sale({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.saleIdA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
const res = await api.post(`/api/sales/retail/${ctx.saleIdA}/create-return`, {})
check(step, { kind: 'api', description: 'POST /create-return → 201', ok: res.status === 201, detail: `${res.status} ${asString(res.data).slice(0, 250)}` })
if (res.status !== 201) return
ctx.returnIdA = res.data.id
check(step, { kind: 'api', description: 'isReturn=true, referenceSaleId совпадает', ok: res.data.isReturn === true && res.data.referenceSaleId === ctx.saleIdA, detail: `isReturn=${res.data.isReturn} ref=${res.data.referenceSaleId}` })
check(step, { kind: 'api', description: 'Status=Draft', ok: res.data.status === 0, detail: `status=${res.data.status}` })
check(step, { kind: 'api', description: 'Lines.qty=10 (initial = original returned = 100)', ok: res.data.lines?.[0]?.quantity === 10, detail: `qty=${res.data.lines?.[0]?.quantity}` })
}
// ---------------------------------------------------------------------------
export async function cr03_post_return_stock_back({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.returnIdA) { 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/retail/${ctx.returnIdA}/post`, {})
check(step, { kind: 'api', description: 'POST return /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 += 10 (вернулось)', ok: after - before === 10, 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.returnIdA)
check(step, {
kind: 'api', description: 'StockMovement type=CustomerReturn создан',
ok: ours.length >= 1 && ours[0].type === 'CustomerReturn',
detail: `count=${ours.length} type=${ours[0]?.type}`,
})
}
// ---------------------------------------------------------------------------
export async function cr04_overreturn_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.saleIdA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
// Уже вернули 10/10. Создадим ещё один возврат — должно либо запретить создание (BadRequest "уже возвращён"), либо если создаст, на post будет 409 over-return.
const second = await api.post(`/api/sales/retail/${ctx.saleIdA}/create-return`, {})
if (second.status === 400 && /полност.*возвращ|already/i.test(asString(second.data))) {
check(step, {
kind: 'api', description: '/create-return уже после полного возврата → 400',
ok: true, detail: `${second.status} ${asString(second.data).slice(0, 200)}`,
})
return
}
// Иначе попробуем сделать ручной возврат на 5 (всё ещё больше remaining=0)
const manual = await api.post('/api/sales/retail', {
date: new Date().toISOString(), storeId: a.storeId, retailPointId: a.retailPointId,
currencyId: a.currencyId, customerId: null, payment: 0,
paidCash: 0, paidCard: 0, notes: 'over-return test',
isReturn: true, referenceSaleId: ctx.saleIdA,
lines: [{ productId: a.productId, quantity: 5, unitPrice: 500, discount: 0, vatPercent: 0 }],
})
if (manual.status !== 201) {
step.notes.push(`manual create unexpected: ${manual.status} ${asString(manual.data).slice(0, 200)}`)
return
}
const post = await api.post(`/api/sales/retail/${manual.data.id}/post`, {})
check(step, {
kind: 'api', description: 'POST over-return /post → 409',
ok: post.status === 409 && /превыш|over|больше|доступн/i.test(asString(post.data)),
detail: `${post.status} ${asString(post.data).slice(0, 200)}`,
})
}
// ---------------------------------------------------------------------------
export async function cr05_return_without_receipt({ ctx, step, report }: StepCtx) {
if (!ctx.orgA) { step.status = 'skip'; return }
const a = ctx.orgA
const api = makeClient(a.token)
const create = await api.post('/api/sales/retail', {
date: new Date().toISOString(), storeId: a.storeId, retailPointId: a.retailPointId,
currencyId: a.currencyId, customerId: null, payment: 0,
paidCash: 0, paidCard: 0, notes: 'walk-in return',
isReturn: true, referenceSaleId: null,
lines: [{ productId: a.productId, quantity: 3, unitPrice: 500, discount: 0, vatPercent: 0 }],
})
check(step, { kind: 'api', description: 'Walk-in return draft → 201', ok: create.status === 201, detail: `${create.status} ${asString(create.data).slice(0, 200)}` })
if (create.status !== 201) return
const before = await getStock(a.token, a.storeId, a.productId)
const post = await api.post(`/api/sales/retail/${create.data.id}/post`, {})
check(step, { kind: 'api', description: 'Walk-in return /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 += 3 (товар вернулся даже без чека)', ok: after - before === 3, detail: `${before}${after}` })
}
// ---------------------------------------------------------------------------
export async function cr06_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
if (!ctx.orgA || !ctx.orgB || !ctx.returnIdA) { step.status = 'skip'; return }
const apiB = makeClient(ctx.orgB.token)
const get = await apiB.get(`/api/sales/retail/${ctx.returnIdA}`)
check(step, { kind: 'api', description: 'GET чужого id → 404', ok: get.status === 404, detail: `${get.status}` })
const cr = await apiB.post(`/api/sales/retail/${ctx.saleIdA}/create-return`, {})
check(step, { kind: 'api', description: 'POST /create-return на чужой → 404', ok: cr.status === 404, detail: `${cr.status}` })
}

View file

@ -0,0 +1,26 @@
name: stage-customer-return
description: |
CustomerReturn (возврат от покупателя) на test.admin.food-market.kz.
Создание возврата по чеку через /api/sales/retail/{id}/create-return
и POST-проведение. Возврат без чека (IsReturn=true,
ReferenceSaleId=null) — продукт оказывается на складе с
StockMovement type=CustomerReturn. Edge: возврат больше чем
продано → 409 (защита от over-return). Multi-tenant.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: cr01_setup_and_sale
title: 2 org. У orgA — продукт с qty=100, проведённый RetailSale qty=10.
- id: cr02_create_return_from_sale
title: POST /api/sales/retail/{id}/create-return → 201 (Draft, IsReturn=true, ref=исходный)
- id: cr03_post_return_stock_back
title: POST {return-id}/post → 204; Stock += qty; StockMovement type=CustomerReturn; QtyReturned обновлён
- id: cr04_overreturn_blocked
title: Второй возврат с qty > remaining (Quantity-QtyReturned) → 409
- id: cr05_return_without_receipt
title: POST RetailSale c IsReturn=true и ReferenceSaleId=null → 201; post → +stock с CustomerReturn
- id: cr06_multi_tenant_isolation
title: Org B не видит возврат org A; create-return на чужой → 404