food-market/tests/e2e/scenarios/full-cycle.steps.ts
nns 37cd9aa94b test(e2e): починка контрактов supply/sale + EAN-13 + bug-hunt + full-pass отчёт
Контракты до фикса не совпадали с реальными:
- Product: unitId/groupId/retailPrice → unitOfMeasureId/productGroupId/prices[],
  плюс обязательный barcodes[] (генерим валидный EAN-13).
- Supply: counterpartyId/docDate/lines.price → supplierId/date/lines.unitPrice,
  плюс обязательный currencyId.
- RetailSale: путь /api/sales/retail-sales 404 → /api/sales/retail; payload
  обновлён под RetailSaleInput (storeId, currencyId, payment, paidCash и т.п.).

Шаги 9-12 теперь полностью проходят (не skip). Добавлены deep-bug-hunt'ы:
- Supply без supplierId / с пустым lines[]
- двойной post Supply / RetailSale → 409
- stock_movements vs Stocks.Quantity консистентность
- RetailPoint с несуществующим storeId
- продажа qty>остатка (выявил блокирующий баг — продаёт)
- discount на line, отрицательные qty/price
- stock_movements.Type = RetailSale (2)

Отчёт: tests/e2e/reports/full-cycle-2026-05-08-full-pass.md
Финальный счёт 10 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ — две ✗ это РЕАЛЬНЫЕ баги:
[HIGH] step11 oversell проходит /post (нужна валидация qty≤stock)
[MEDIUM] step08 Supply без supplierId → 500 вместо 400
2026-05-08 11:01:56 +05:00

1048 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Step-handlers для full-cycle сценария. Каждая функция получает
* { ctx, step, report }
* и обновляет step.checks и report.bugs / report.uxNotes / report.gap.
*
* ctx — общий накопитель ID'ов созданных сущностей и активных сессий
* между steps. Структура шире чем строго необходима — для удобства
* логирования в отчёте.
*/
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import { Report as _R } from '../lib/report.js' // type-only ниже
import { countRows, psql } from '../lib/db.js'
import { generateEan13 } from '../lib/barcode.js'
type Ctx = {
apiOnly: boolean
superAdminToken?: string
organization?: { id: string; name: string }
adminEmail?: string
adminTempPassword?: string
adminToken?: string
storekeeperEmail?: string
cashierEmail?: string
cashierTempPassword?: string
storekeeperTempPassword?: string
counterpartyId?: string
storeId?: string
retailPointId?: string
productGroupId?: string
retailPriceTypeId?: string
currencyId?: string
supplyId?: string
supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
retailSaleId?: string
saleLines?: { productId: string; quantity: number; unitPrice: number }[]
stockBefore?: Record<string, number>
stockAfterSupply?: Record<string, number>
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
const TIMESTAMP = 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 ensureSuperAdminToken(ctx: Ctx): Promise<string> {
if (!ctx.superAdminToken) {
const sa = await login('admin@food-market.local', 'Admin12345!')
ctx.superAdminToken = sa.accessToken
}
return ctx.superAdminToken
}
// ---------------------------------------------------------------------------
export async function step01_create_organization({ ctx, step, report }: StepCtx) {
const token = await ensureSuperAdminToken(ctx)
const api = makeClient(token)
const orgName = `Test Shop ${TIMESTAMP}`
const orgEmail = `e2e-${TIMESTAMP}@example.kz`
const res = await api.post('/api/super-admin/organizations', {
org: {
name: orgName,
countryCode: 'KZ',
bin: '123456789012',
address: 'Алматы, ул. Тестовая 1',
phone: '+77001234567',
email: orgEmail,
defaultCurrencyId: null,
accountOwnerUserId: null,
},
adminLastName: 'Тестов',
adminFirstName: 'Админ',
adminEmail: `admin-${TIMESTAMP}@example.kz`,
adminPosition: 'Директор',
})
check(step, {
kind: 'api',
description: `POST /api/super-admin/organizations → 200`,
ok: res.status === 200,
detail: res.status === 200 ? `org=${res.data?.organization?.name}` : asString(res.data),
})
if (res.status !== 200) {
report.bug({
step: '01',
severity: 'critical',
title: 'Не удаётся создать организацию через API SuperAdmin',
detail: `POST /api/super-admin/organizations вернул ${res.status} ${asString(res.data)}`,
fix: 'Проверь что таблицы tenant-bootstrap (employee_roles системные) сохранены при reset_db.',
})
return
}
ctx.organization = { id: res.data.organization.id, name: res.data.organization.name }
ctx.adminEmail = res.data.adminEmail
ctx.adminTempPassword = res.data.adminTempPassword
// List ↦ должен возвращать новую org
const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200')
const found = list.data?.items?.some((o: { id: string }) => o.id === ctx.organization!.id)
check(step, {
kind: 'api',
description: 'GET /api/super-admin/organizations включает созданную org',
ok: !!found,
detail: found ? '' : `total=${list.data?.total}`,
})
// ФЛК телефона: проверим что орг с невалидным KZ-телефоном (например +71234567890)
// отвергается сервером. Делаем pure-API smoke без UI — это проверка backend-validation.
const bad = await api.post('/api/super-admin/organizations', {
org: {
name: `Bad Phone ${TIMESTAMP}`,
countryCode: 'KZ', bin: null, address: null, phone: 'abc', email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'X', adminFirstName: 'Y',
adminEmail: `bad-${TIMESTAMP}@example.kz`, adminPosition: null,
})
// Здесь backend может НЕ валидировать phone (это поле опциональное и без regex'а
// на уровне домена). Поэтому статус 200 — это logic gap, отметим:
if (bad.status === 200) {
report.gap('SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения).')
} else {
check(step, {
kind: 'api',
description: 'Невалидный phone отвергается',
ok: bad.status >= 400 && bad.status < 500,
detail: `${bad.status}`,
})
}
}
// ---------------------------------------------------------------------------
export async function step02_create_first_admin({ ctx, step, report }: StepCtx) {
// SuperAdmin при создании org уже создаёт первого Admin'a (см. step01).
// Проверяем что temp password выдан и в БД появился Employee + Identity-Admin.
if (!ctx.organization || !ctx.adminEmail) {
step.status = 'skip'; step.notes.push('Нет organization из step 01 — пропускаем.')
return
}
check(step, {
kind: 'api',
description: 'Temp password возвращён CreateOrgResult',
ok: !!ctx.adminTempPassword && ctx.adminTempPassword.length >= 8,
detail: ctx.adminTempPassword ? `len=${ctx.adminTempPassword.length}` : 'empty',
})
const employeesCount = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
check(step, {
kind: 'db',
description: `employees содержит ровно 1 запись для новой org`,
ok: employeesCount === 1,
detail: `count=${employeesCount}`,
})
// Проверим что AspNetUserRoles содержит role=Admin для нового user-id.
const userIdRows = psql(
`SELECT u."Id" FROM users u WHERE u."Email" = '${ctx.adminEmail}';`).trim()
if (!userIdRows) {
check(step, { kind: 'db', description: 'AppUser существует', ok: false, detail: 'не найден' })
return
}
const userId = userIdRows
const roleRows = psql(
`SELECT r."Name" FROM "AspNetUserRoles" ur
JOIN roles r ON r."Id" = ur."RoleId"
WHERE ur."UserId" = '${userId}';`).trim().split('\n')
const hasAdmin = roleRows.includes('Admin')
check(step, {
kind: 'db',
description: 'AspNetUserRoles содержит role=Admin для нового user',
ok: hasAdmin,
detail: roleRows.join(','),
})
if (!hasAdmin) {
report.bug({
step: '02',
severity: 'high',
title: 'Identity-роль Admin не присвоена при создании организации',
detail: 'CreateOrg в SuperAdminOrganizationsController должен вызывать UserManager.AddToRoleAsync(user, "Admin")',
})
}
}
// ---------------------------------------------------------------------------
export async function step03_login_as_admin({ ctx, step, report }: StepCtx) {
if (!ctx.adminEmail || !ctx.adminTempPassword) {
step.status = 'skip'; step.notes.push('Нет creds admin'); return
}
try {
const sess = await login(ctx.adminEmail, ctx.adminTempPassword)
ctx.adminToken = sess.accessToken
check(step, {
kind: 'api',
description: '/connect/token password-grant выдал токен',
ok: !!sess.accessToken,
})
check(step, {
kind: 'api',
description: '/api/me содержит role=Admin',
ok: sess.roles.includes('Admin'),
detail: sess.roles.join(','),
})
check(step, {
kind: 'api',
description: '/api/me содержит правильный orgId',
ok: sess.orgId === ctx.organization?.id,
detail: sess.orgId ?? 'null',
})
} catch (e) {
check(step, { kind: 'api', description: 'login admin', ok: false, detail: (e as Error).message })
report.bug({
step: '03',
severity: 'critical',
title: 'Свежесозданный Admin не может залогиниться с temp password',
detail: (e as Error).message,
})
}
}
// ---------------------------------------------------------------------------
export async function step04_create_storekeeper_and_cashier({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.organization) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Получаем list ролей; нужны Storekeeper (системная) и Кассир (системная).
const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200')
check(step, {
kind: 'api', description: 'employee-roles list', ok: rolesRes.status === 200,
detail: `${rolesRes.status}, total=${rolesRes.data?.total}`,
})
const roles = (rolesRes.data?.items ?? []) as { id: string; name: string; isSystem: boolean }[]
const keeperRole = roles.find((r) => r.name === 'Кладовщик')
const cashierRole = roles.find((r) => r.name === 'Кассир')
check(step, {
kind: 'api', description: 'Системная роль «Кладовщик» существует', ok: !!keeperRole,
})
check(step, {
kind: 'api', description: 'Системная роль «Кассир» существует', ok: !!cashierRole,
})
if (!keeperRole || !cashierRole) {
report.bug({
step: '04', severity: 'high',
title: 'Не сидируются системные роли при создании org',
detail: `roles list = ${roles.map((r) => r.name).join(', ')}`,
fix: 'DevDataSeeder.SeedEmployeeRolesAsync должен вызываться после CreateOrg.',
})
return
}
ctx.storekeeperEmail = `keeper-${TIMESTAMP}@example.kz`
ctx.cashierEmail = `cashier-${TIMESTAMP}@example.kz`
for (const [emailField, roleId, lastName, firstName, position] of [
[ctx.storekeeperEmail, keeperRole.id, 'Кладовщиков', 'Иван', 'Кладовщик'],
[ctx.cashierEmail, cashierRole.id, 'Кассиров', 'Пётр', 'Кассир'],
] as const) {
const res = await api.post('/api/organization/employees', {
lastName, firstName, middleName: null, position,
email: emailField, phone: '+77002223344',
salary: null, taxNumber: null, description: null, imageUrl: null,
roleId, isActive: true, retailPointIds: [], createAccount: true,
})
check(step, {
kind: 'api', description: `POST /api/organization/employees (${position})`,
ok: res.status === 200, detail: `${res.status} ${res.status !== 200 ? asString(res.data) : ''}`,
})
if (res.status === 200) {
const tempPwd = res.data?.generatedPassword as string | undefined
if (position === 'Кладовщик') ctx.storekeeperTempPassword = tempPwd
else ctx.cashierTempPassword = tempPwd
}
}
const total = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
check(step, {
kind: 'db', description: 'employees total = 3 (admin + keeper + cashier)',
ok: total === 3, detail: `count=${total}`,
})
// Валидация: невалидный email должен быть отвергнут.
const bad = await api.post('/api/organization/employees', {
lastName: 'Bad', firstName: 'Email', middleName: null, position: null,
email: 'not-an-email', phone: null, salary: null, taxNumber: null,
description: null, imageUrl: null, roleId: cashierRole.id, isActive: true,
retailPointIds: [], createAccount: true,
})
if (bad.status === 200) {
report.gap('POST /api/organization/employees принимает невалидный email при createAccount=true (Identity создаёт юзера с UserName=not-an-email).')
} else {
check(step, {
kind: 'api', description: 'Невалидный email отвергается при createAccount',
ok: bad.status >= 400, detail: `${bad.status}`,
})
}
}
// ---------------------------------------------------------------------------
export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) {
if (!ctx.cashierEmail || !ctx.cashierTempPassword) { step.status = 'skip'; return }
let cashier
try {
cashier = await login(ctx.cashierEmail, ctx.cashierTempPassword)
} catch (e) {
check(step, { kind: 'api', description: 'login Cashier', ok: false, detail: (e as Error).message })
report.bug({
step: '05', severity: 'critical',
title: 'Cashier не может залогиниться с выданным паролем',
detail: (e as Error).message,
})
return
}
check(step, {
kind: 'api', description: '/api/me содержит роль соответствующую системной Cashier',
ok: cashier.roles.length > 0, // Identity-роль может отсутствовать (используются org-роли),
detail: cashier.roles.join(',') || 'no Identity roles',
})
// Identity-роль для Cashier — "Cashier"? Проверим. Если она НЕ присвоена,
// это значит role-guard на сервере основан только на Identity-роли Admin
// (см. Authorize(Roles="Admin")). Cashier тогда всегда 403 на admin-ресурсах,
// но и на /sales/retail может не пройти если там стоит [Authorize(Roles="Cashier")].
if (!cashier.roles.includes('Cashier') && !cashier.roles.includes('Admin')) {
report.gap('Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions.')
}
const apiCashier = makeClient(cashier.accessToken)
// /settings/employees должен быть 403 для Cashier.
const empRes = await apiCashier.get('/api/organization/employees')
check(step, {
kind: 'api', description: 'Cashier → GET /api/organization/employees → 403',
ok: empRes.status === 403,
detail: `${empRes.status}`,
})
if (empRes.status !== 403) {
if (empRes.status === 200) {
report.bug({
step: '05', severity: 'critical',
title: 'Cashier ВИДИТ список сотрудников через API',
detail: 'GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin)',
fix: 'Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin")',
})
}
}
const salesRes = await apiCashier.get('/api/sales/retail?pageSize=10')
check(step, {
kind: 'api', description: 'Cashier → GET /api/sales/retail — доступен',
ok: salesRes.status === 200 || salesRes.status === 404,
detail: `${salesRes.status}`,
})
}
// ---------------------------------------------------------------------------
export async function step06_create_counterparty({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Получим страну KZ
const countries = await api.get('/api/catalog/countries?pageSize=200')
const kz = (countries.data?.items as { id: string; code: string }[] | undefined)
?.find((c) => c.code === 'KZ')
if (!kz) {
report.bug({ step: '06', severity: 'high', title: 'Страна KZ отсутствует в справочнике', detail: '' })
}
const res = await api.post('/api/catalog/counterparties', {
name: 'ТОО Тест Поставщик',
legalName: 'Товарищество с ограниченной ответственностью «Тест Поставщик»',
type: 0, // LegalEntity
bin: '987654321098', iin: null, taxNumber: null,
countryId: kz?.id ?? null,
address: 'Алматы, ул. Поставщиков 1',
phone: '+77003332211', email: 'supplier@example.kz',
bankName: null, bankAccount: null, bik: null,
contactPerson: 'Иванов И.И.', notes: null,
})
check(step, {
kind: 'api', description: 'POST /api/catalog/counterparties',
ok: res.status === 200 || res.status === 201,
detail: `${res.status} ${res.status >= 400 ? asString(res.data) : ''}`,
})
if (res.status === 200 || res.status === 201) {
ctx.counterpartyId = res.data?.id
} else {
report.bug({
step: '06', severity: 'critical',
title: 'Создание контрагента падает',
detail: `${res.status} ${asString(res.data)}`,
})
}
}
// ---------------------------------------------------------------------------
export async function step07_ensure_main_store({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const list = await api.get('/api/catalog/stores?pageSize=200')
check(step, {
kind: 'api', description: 'GET /api/catalog/stores',
ok: list.status === 200, detail: `${list.status}`,
})
const stores = (list.data?.items ?? []) as { id: string; name: string; isMain: boolean }[]
let main = stores.find((s) => s.isMain) ?? stores[0]
if (!main) {
const create = await api.post('/api/catalog/stores', {
name: 'Main', code: 'MAIN', address: null, phone: null,
managerName: null, isMain: true, isActive: true,
})
check(step, {
kind: 'api', description: 'Main store создан вручную',
ok: create.status === 200 || create.status === 201,
detail: `${create.status}`,
})
if (create.status >= 400) {
report.bug({
step: '07', severity: 'high',
title: 'Main store не сидируется автоматически и не создаётся вручную',
detail: asString(create.data),
})
return
}
main = create.data
} else {
check(step, {
kind: 'db', description: 'Main store существует (от bootstrap)',
ok: true, detail: main.name,
})
}
ctx.storeId = main!.id
}
// ---------------------------------------------------------------------------
export async function step08_create_supply({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// 1) Подготовим справочники: unit, product group, retail price type, currency.
const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10')
const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined
if (!unit) {
report.bug({
step: '08', severity: 'critical',
title: 'Для нового tenant нет ни одной единицы измерения',
detail: 'GET /api/catalog/units-of-measure?pageSize=10 → пустой items',
fix: 'Phase5c должен auto-enable все active globals в org_units_of_measure при создании org.',
})
return
}
const groupsRes = await api.get('/api/catalog/product-groups?pageSize=200')
let groupId = (groupsRes.data?.items ?? [])[0]?.id as string | undefined
if (!groupId) {
const cg = await api.post('/api/catalog/product-groups', {
name: 'e2e Группа', parentId: null, sortOrder: 0,
})
if (cg.status >= 400) {
report.bug({
step: '08', severity: 'high',
title: 'Не удалось создать ProductGroup для нового tenant',
detail: `${cg.status} ${asString(cg.data).slice(0, 200)}`,
fix: 'Bootstrap должен сидить дефолтную группу «Продукты питания» при создании org.',
})
return
}
groupId = cg.data?.id
report.gap('Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу.')
}
ctx.productGroupId = groupId!
const ptRes = await api.get('/api/catalog/price-types?pageSize=200')
const retailPt = ((ptRes.data?.items ?? []) as { id: string; isRetail?: boolean; name: string }[])
.find((p) => p.isRetail) ?? (ptRes.data?.items ?? [])[0]
if (!retailPt) {
report.bug({
step: '08', severity: 'critical',
title: 'Нет ни одного PriceType для нового tenant',
detail: '',
})
return
}
ctx.retailPriceTypeId = retailPt.id
const curRes = await api.get('/api/catalog/currencies?pageSize=50')
const kzt = ((curRes.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT')
if (!kzt) {
report.bug({ step: '08', severity: 'high', title: 'Нет валюты KZT в справочнике', detail: '' })
return
}
ctx.currencyId = kzt.id
// 2) Создаём 3 product'а с валидным ProductInput.
const created: { id: string; name: string; price: number }[] = []
for (let i = 0; i < 3; i++) {
const retailPrice = 100 + i * 50
const cr = await api.post('/api/catalog/products', {
name: `e2e Product ${i + 1} ${TIMESTAMP}`,
article: `E2E-${TIMESTAMP}-${i + 1}`,
description: null,
unitOfMeasureId: unit.id,
vat: 12,
vatEnabled: true,
productGroupId: ctx.productGroupId,
defaultSupplierId: null,
countryOfOriginId: null,
isService: false,
packaging: 0,
isMarked: false,
minStock: null, maxStock: null,
referencePrice: retailPrice * 0.7,
purchaseCurrencyId: ctx.currencyId,
imageUrl: null,
prices: [
{ priceTypeId: ctx.retailPriceTypeId, amount: retailPrice, currencyId: ctx.currencyId },
],
barcodes: [
{ code: generateEan13(i + 1), type: 0, isPrimary: true },
],
})
if (cr.status >= 400) {
report.bug({
step: '08', severity: 'high',
title: `Не удалось создать product №${i + 1}`,
detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`,
})
return
}
created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name, price: retailPrice })
}
check(step, {
kind: 'api', description: 'Создано 3 product (валидный barcode + price + group)',
ok: created.length === 3, detail: created.map((p) => p.name).join(', '),
})
const lines = created.map((p, i) => ({
productId: p.id, productName: p.name, quantity: 10 + i * 5, price: 70 + i * 30,
}))
ctx.supplyLines = lines
// Stock snapshot до приёмки (должен быть 0 на новой орге).
const stockBefore: Record<string, number> = {}
for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
ctx.stockBefore = stockBefore
// 3) Bug-hunt: попытаться создать Supply без supplierId / без lines.
const noSupplier = await api.post('/api/purchases/supplies', {
date: new Date().toISOString(),
storeId: ctx.storeId,
currencyId: ctx.currencyId,
notes: 'no supplier',
lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
})
check(step, {
kind: 'api', description: 'Supply без supplierId → 400/409',
ok: noSupplier.status >= 400 && noSupplier.status < 500,
detail: `${noSupplier.status} ${asString(noSupplier.data).slice(0, 100)}`,
})
if (noSupplier.status === 200 || noSupplier.status === 201) {
report.bug({
step: '08', severity: 'high',
title: 'Supply создаётся без supplierId',
detail: 'POST /api/purchases/supplies без поля supplierId прошёл валидацию и вернул 200',
fix: 'SupplyInput.SupplierId должен быть [Required] и проверяться в Create.',
})
} else if (noSupplier.status >= 500) {
report.bug({
step: '08', severity: 'medium',
title: 'Supply без supplierId → 500 вместо 400',
detail: `Сервер бросил исключение (${noSupplier.status}) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения.`,
fix: 'SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием.',
})
}
const noLines = await api.post('/api/purchases/supplies', {
date: new Date().toISOString(),
supplierId: ctx.counterpartyId, storeId: ctx.storeId, currencyId: ctx.currencyId,
notes: 'no lines', lines: [],
})
check(step, {
kind: 'api', description: 'Supply с пустым lines[] → 400',
ok: noLines.status >= 400 && noLines.status < 500,
detail: `${noLines.status}`,
})
// 4) Создаём настоящий draft Supply.
const draft = await api.post('/api/purchases/supplies', {
date: new Date().toISOString(),
supplierId: ctx.counterpartyId,
storeId: ctx.storeId,
currencyId: ctx.currencyId,
notes: 'e2e draft',
lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
})
check(step, {
kind: 'api', description: 'POST /api/purchases/supplies (Draft)',
ok: draft.status === 200 || draft.status === 201,
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
})
if (draft.status >= 400) {
report.bug({
step: '08', severity: 'critical',
title: 'Не удаётся создать Draft Supply',
detail: asString(draft.data),
})
return
}
ctx.supplyId = draft.data?.id ?? draft.data?.supplyId
if (!ctx.supplyId) {
report.bug({
step: '08', severity: 'high',
title: 'POST /supplies не возвращает id созданного документа',
detail: asString(draft.data).slice(0, 200),
})
return
}
const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
check(step, {
kind: 'api', description: 'POST /api/purchases/supplies/{id}/post (Draft → Posted)',
ok: post.status === 200 || post.status === 204,
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
})
if (post.status >= 400) {
report.bug({
step: '08', severity: 'critical',
title: 'Posted-переход Supply падает',
detail: asString(post.data),
})
return
}
// 5) Bug-hunt: повторный post → 409 (idempotency contract).
const dblPost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
check(step, {
kind: 'api', description: 'Повторный post Supply → 409 (idempotency)',
ok: dblPost.status === 409,
detail: `${dblPost.status} ${asString(dblPost.data).slice(0, 100)}`,
})
if (dblPost.status !== 409) {
report.bug({
step: '08', severity: 'medium',
title: 'Повторный post Supply возвращает не 409',
detail: `Получили ${dblPost.status}; ожидаем 409 чтобы UI мог показать «Уже проведено».`,
})
}
// 6) Bug-hunt: stock_movements должны содержать одну запись на каждую строку.
const orgId = ctx.organization!.id
const movsRows = psql(`SELECT count(*) FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.supplyId}'`)
.trim()
const movs = parseInt(movsRows, 10) || 0
check(step, {
kind: 'db', description: 'stock_movements содержат запись на каждую строку Supply',
ok: movs === lines.length,
detail: `count=${movs}, expected=${lines.length}`,
})
if (movs !== lines.length) {
report.bug({
step: '08', severity: 'high',
title: 'stock_movements не записаны после Posted Supply',
detail: `expected ${lines.length}, got ${movs}`,
fix: 'Проверить SupplyController.Post: ApplyMovementAsync вызывается для каждой строки.',
})
}
}
// ---------------------------------------------------------------------------
export async function step09_check_stock_after_supply({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyLines || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const after: Record<string, number> = {}
for (const ln of ctx.supplyLines) {
after[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
}
ctx.stockAfterSupply = after
for (const ln of ctx.supplyLines) {
const before = ctx.stockBefore![ln.productId] ?? 0
const now = after[ln.productId] ?? 0
const delta = now - before
check(step, {
kind: 'api',
description: `stock(${ln.productName}) +${ln.quantity} (было ${before}, стало ${now})`,
ok: delta === ln.quantity,
detail: `delta=${delta}, expected=${ln.quantity}`,
})
if (delta !== ln.quantity) {
report.bug({
step: '09', severity: 'high',
title: 'Остатки не увеличились после Posted Supply',
detail: `product=${ln.productName} expected delta=${ln.quantity} actual=${delta}`,
fix: 'Проверь что SupplyPostHandler / hostedService применяет stock_movements при /post',
})
}
}
// GET /stock без storeId — должен агрегировать или возвращать строку на склад.
const aggRes = await api.get(`/api/inventory/stock?productId=${ctx.supplyLines[0].productId}&pageSize=200`)
const aggItems = (aggRes.data?.items ?? []) as { storeId: string; quantity: number }[]
check(step, {
kind: 'api',
description: 'GET /api/inventory/stock без storeId возвращает строки на каждый склад',
ok: aggItems.length >= 1,
detail: `rows=${aggItems.length}, stores=${aggItems.map((i) => i.storeId.slice(0, 8)).join(',')}`,
})
const hasTotal = 'totalQuantity' in (aggRes.data ?? {})
if (!hasTotal && aggItems.length > 1) {
report.gap('Stock endpoint не агрегирует totalQuantity по нескольким складам — UI должен суммировать сам. Если в орге будет 5+ складов, это будет неудобно для дашборда «общие остатки товара».')
}
// Согласованность Stocks.Quantity с суммой stock_movements.Quantity.
const orgId = ctx.organization!.id
for (const ln of ctx.supplyLines) {
const sumStr = psql(`SELECT COALESCE(SUM("Quantity"), 0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "ProductId"='${ln.productId}' AND "StoreId"='${ctx.storeId}'`).trim()
const sum = parseFloat(sumStr) || 0
const stockNow = after[ln.productId] ?? 0
check(step, {
kind: 'db',
description: `stocks.Quantity == SUM(stock_movements.Quantity) для ${ln.productName.slice(0, 30)}`,
ok: Math.abs(sum - stockNow) < 0.0001,
detail: `sum_movements=${sum} stocks.Quantity=${stockNow}`,
})
if (Math.abs(sum - stockNow) >= 0.0001) {
report.bug({
step: '09', severity: 'high',
title: 'Stocks.Quantity рассинхронизированы с SUM(stock_movements.Quantity)',
detail: `product=${ln.productName} sum=${sum} stocks=${stockNow}`,
})
}
}
}
// ---------------------------------------------------------------------------
export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const list = await api.get('/api/catalog/retail-points?pageSize=200')
const items = (list.data?.items ?? []) as { id: string; name: string; storeId?: string }[]
if (items.length === 0) {
const create = await api.post('/api/catalog/retail-points', {
name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true,
})
check(step, {
kind: 'api', description: 'Создана касса',
ok: create.status === 200 || create.status === 201, detail: `${create.status}`,
})
ctx.retailPointId = create.data?.id
} else {
ctx.retailPointId = items[0].id
check(step, { kind: 'api', description: 'RetailPoint существует', ok: true, detail: items[0].name })
}
if (!ctx.retailPointId) {
report.bug({
step: '10', severity: 'high',
title: 'Не получилось гарантировать наличие розничной точки',
detail: '',
})
return
}
// Bug-hunt: касса должна быть привязана к существующему складу.
const fakeStore = '00000000-0000-0000-0000-000000000001'
const badStore = await api.post('/api/catalog/retail-points', {
name: `e2e-bad-${TIMESTAMP}`, code: `BS-${TIMESTAMP}`, storeId: fakeStore, isActive: true,
})
check(step, {
kind: 'api', description: 'RetailPoint с несуществующим storeId → 400/404',
ok: badStore.status >= 400 && badStore.status < 500,
detail: `${badStore.status}`,
})
if (badStore.status === 200 || badStore.status === 201) {
report.bug({
step: '10', severity: 'medium',
title: 'RetailPoint создаётся с несуществующим storeId',
detail: 'POST /api/catalog/retail-points c storeId=00000000-0000-0000-0000-000000000001 прошёл валидацию.',
fix: 'Добавить проверку EXISTS Store в RetailPointsController.Create.',
})
}
}
// ---------------------------------------------------------------------------
export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines || !ctx.storeId || !ctx.currencyId) {
step.status = 'skip'; return
}
const api = makeClient(ctx.adminToken)
// Берём 2 первых product, продаём по 2 шт каждого. Цена = supply.price*2 (наценка).
const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
productId: l.productId,
quantity: 2,
unitPrice: l.price * 2,
discount: 0,
vatPercent: 12,
}))
ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice }))
// Bug-hunt: пустой чек → 400 на /post (после Draft); тут проверяем create с пустыми lines.
const empty = await api.post('/api/sales/retail', {
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, paidCash: 0, paidCard: 0,
notes: 'empty', lines: [],
})
if (empty.status === 200 || empty.status === 201) {
report.gap('RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше.')
}
// Bug-hunt: продажа qty больше остатка должна давать предупреждение или ошибку.
const overSell = await api.post('/api/sales/retail', {
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, paidCash: 999999, paidCard: 0,
notes: 'over-sell',
lines: [{ productId: ctx.supplyLines[0].productId, quantity: 99999, unitPrice: 100, discount: 0, vatPercent: 0 }],
})
let overSellId: string | undefined
if (overSell.status === 200 || overSell.status === 201) {
overSellId = overSell.data?.id ?? overSell.data?.saleId
const overPost = await api.post(`/api/sales/retail/${overSellId}/post`, {})
check(step, {
kind: 'api', description: 'Продажа qty>остатка → /post должен 4xx',
ok: overPost.status >= 400 && overPost.status < 500,
detail: `${overPost.status}`,
})
if (overPost.status === 200 || overPost.status === 204) {
report.bug({
step: '11', severity: 'high',
title: 'Розничная продажа провёл количество больше остатка',
detail: `qty=99999 при остатке 10-15 — /post вернул ${overPost.status} вместо 409/400.`,
fix: 'RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available.',
})
// Откатим, чтобы не сбить остатки для step12.
await api.post(`/api/sales/retail/${overSellId}/unpost`, {})
}
// Удалим draft чтобы не мусорил.
await api.delete(`/api/sales/retail/${overSellId}`)
} else {
check(step, {
kind: 'api', description: 'POST /api/sales/retail с qty>>остатка отклонено',
ok: overSell.status >= 400 && overSell.status < 500,
detail: `${overSell.status}`,
})
}
// Bug-hunt: отрицательная цена/количество должны быть отклонены.
const negative = await api.post('/api/sales/retail', {
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, paidCash: 0, paidCard: 0,
notes: 'negative',
lines: [{ productId: ctx.supplyLines[0].productId, quantity: -1, unitPrice: -100, discount: 0, vatPercent: 0 }],
})
check(step, {
kind: 'api', description: 'Продажа с отрицательным qty/price → 400',
ok: negative.status === 400,
detail: `${negative.status}`,
})
if (negative.status === 200 || negative.status === 201) {
report.bug({
step: '11', severity: 'high',
title: 'RetailSale принимает отрицательные quantity / unitPrice',
detail: 'POST /api/sales/retail с qty=-1, unitPrice=-100 → 200. [Range(0,..)] не валидирует отрицательное?',
fix: 'Проверить RetailSaleLineInput атрибуты Range и [ApiController] валидацию.',
})
}
// Bug-hunt: продажа с discount на позиции (lineTotal должен учитывать).
const withDiscount = await api.post('/api/sales/retail', {
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, paidCash: 90, paidCard: 0,
notes: 'discount-test',
lines: [{ productId: ctx.supplyLines[0].productId, quantity: 1, unitPrice: 100, discount: 10, vatPercent: 0 }],
})
if (withDiscount.status === 200 || withDiscount.status === 201) {
const lt = withDiscount.data?.lines?.[0]?.lineTotal as number | undefined
check(step, {
kind: 'api', description: 'discount=10 на line(price=100,qty=1) → lineTotal=90',
ok: lt === 90,
detail: `lineTotal=${lt}`,
})
if (lt !== 90) {
report.bug({
step: '11', severity: 'medium',
title: 'Discount на позиции не применяется к lineTotal',
detail: `Передали unitPrice=100, qty=1, discount=10; ожидаем lineTotal=90 — получили ${lt}.`,
})
}
if (withDiscount.data?.id) await api.delete(`/api/sales/retail/${withDiscount.data.id}`)
}
// Реальная продажа (positive).
const draft = await api.post('/api/sales/retail', {
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0,
paidCash: lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
paidCard: 0,
notes: 'e2e sale',
lines,
})
check(step, {
kind: 'api', description: 'POST /api/sales/retail (Draft)',
ok: draft.status === 200 || draft.status === 201,
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
})
if (draft.status >= 400) {
report.bug({
step: '11', severity: 'critical',
title: 'Не удаётся создать Draft RetailSale',
detail: asString(draft.data),
})
return
}
ctx.retailSaleId = draft.data?.id ?? draft.data?.saleId
if (!ctx.retailSaleId) {
report.bug({
step: '11', severity: 'high',
title: 'POST /retail не возвращает id',
detail: asString(draft.data).slice(0, 200),
})
return
}
const post = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
check(step, {
kind: 'api', description: 'POST /retail/{id}/post',
ok: post.status === 200 || post.status === 204,
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
})
// Bug-hunt: повторный post проведённого → 409.
const dblPost = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
check(step, {
kind: 'api', description: 'Повторный post RetailSale → 409',
ok: dblPost.status === 409,
detail: `${dblPost.status}`,
})
}
// ---------------------------------------------------------------------------
export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply || !ctx.retailSaleId) {
step.status = 'skip'; return
}
const api = makeClient(ctx.adminToken)
const orgId = ctx.organization!.id
for (const ln of ctx.saleLines) {
const before = ctx.stockAfterSupply[ln.productId] ?? 0
const now = await stockOf(api, ctx.storeId, ln.productId)
const delta = before - now
check(step, {
kind: 'api',
description: `stock product=${ln.productId.slice(0, 8)}${ln.quantity} (было ${before}, стало ${now})`,
ok: delta === ln.quantity,
detail: `delta=${delta}, expected=${ln.quantity}`,
})
if (delta !== ln.quantity) {
report.bug({
step: '12', severity: 'high',
title: 'Остатки не уменьшились после Posted RetailSale',
detail: `expected delta=${ln.quantity} actual=${delta}`,
})
}
}
// stock_movements: записи на каждую sale-строку с типом RetailSale и qty<0.
for (const ln of ctx.saleLines) {
const row = psql(
`SELECT count(*), COALESCE(SUM("Quantity"),0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}' AND "ProductId"='${ln.productId}'`
).trim().split('|')
const cnt = parseInt(row[0], 10) || 0
const sumQty = parseFloat(row[1]) || 0
check(step, {
kind: 'db',
description: `stock_movements запись на sale-line ${ln.productId.slice(0, 8)}`,
ok: cnt === 1 && Math.abs(sumQty + ln.quantity) < 0.0001,
detail: `count=${cnt}, sum=${sumQty} (expected sum=${-ln.quantity})`,
})
if (cnt !== 1 || Math.abs(sumQty + ln.quantity) >= 0.0001) {
report.bug({
step: '12', severity: 'high',
title: 'stock_movements не записаны после Posted RetailSale',
detail: `productId=${ln.productId} count=${cnt} sumQty=${sumQty} (ожидаем 1 запись с qty=${-ln.quantity})`,
})
}
}
// Тип движения должен быть RetailSale (enum=2 в Domain.Inventory.MovementType).
const typesRaw = psql(
`SELECT DISTINCT "Type"::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}'`
).trim()
check(step, {
kind: 'db', description: 'stock_movements.Type = RetailSale (2) для sale документа',
ok: typesRaw === '2' || typesRaw.toLowerCase() === 'retailsale',
detail: `types=${typesRaw}`,
})
}
// ---------------------------------------------------------------------------
async function stockOf(api: ReturnType<typeof makeClient>, storeId: string, productId: string): Promise<number> {
const res = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
if (res.status !== 200) return 0
const items = (res.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[]
// Fallback: если нет фильтра по storeId на endpoint'е, фильтруем сами.
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
return row?.quantity ?? 0
}