food-market/tests/e2e/scenarios/full-cycle.steps.ts
nns ee127b2785
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 55s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m17s
Docker API / Deploy API on stage (push) Successful in 18s
fix(migrations): добавить [Migration] атрибут для Phase5c — без него Migrate() не находит миграцию
stage api зашёл в crash-loop после деплоя phase5c: DevDataSeeder упал
с «column IsActive does not exist», потому что миграция Phase5c не
была подхвачена db.Database.Migrate(). EF Core ищет миграции по
[MigrationAttribute] на классе (или Designer-файле, который этот
атрибут содержит). Без него миграция в сборке есть, но не известна
runtime-механизму.

Также чиню e2e: URL единиц был /api/catalog/units (404), правильный —
/api/catalog/units-of-measure.
2026-05-08 01:29:51 +05:00

697 lines
29 KiB
TypeScript
Raw 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'
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
supplyId?: string
supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
retailSaleId?: string
saleLines?: { productId: string; quantity: 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-sales?pageSize=10')
check(step, {
kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен',
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)
// Берём 3 произвольных products. Реестр products в БД tenant-scoped
// (ITenantEntity), поэтому после создания новой org `GET /api/catalog/
// products` возвращает 0 — старые products принадлежат другому tenant'у.
// Это logic-gap описанный в отчёте; для прогона сценария создаём 3
// products в новой org прямо сейчас.
const products = await api.get('/api/catalog/products?pageSize=5')
let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[]
if (items.length < 3) {
report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.')
// Получим первую unit-of-measure (системную или из org).
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: 'high',
title: 'Нет ни одной единицы измерения для нового tenant',
detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.',
})
return
}
const created: { id: string; name: string }[] = []
for (let i = 0; i < 3; i++) {
const cr = await api.post('/api/catalog/products', {
name: `e2e Product ${i + 1} ${TIMESTAMP}`,
article: `E2E-${TIMESTAMP}-${i + 1}`,
barcode: null,
unitId: unit.id,
groupId: null,
retailPrice: 100 + i * 50,
purchasePrice: 70 + i * 30,
isActive: 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 })
}
items = created.map((p) => ({ id: p.id, name: p.name }))
check(step, {
kind: 'api', description: 'Auto-created 3 products для нового tenant',
ok: true, detail: items.map((i) => i.name).join(', '),
})
}
const lines = items.slice(0, 3).map((p, i) => ({
productId: p.id,
productName: p.name,
quantity: 10 + i * 5,
price: 100 + i * 50,
}))
ctx.supplyLines = lines
// Сохраняем stock-snapshot ДО приёмки.
const stockBefore: Record<string, number> = {}
for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
ctx.stockBefore = stockBefore
const draft = await api.post('/api/purchases/supplies', {
counterpartyId: ctx.counterpartyId,
storeId: ctx.storeId,
docDate: new Date().toISOString(),
description: 'e2e draft',
lines: lines.map((l) => ({
productId: l.productId, quantity: l.quantity, price: 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) : ''}`,
})
}
// ---------------------------------------------------------------------------
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',
})
}
}
}
// ---------------------------------------------------------------------------
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 }[]
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: '',
})
}
}
// ---------------------------------------------------------------------------
export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт.
}))
ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity }))
const draft = await api.post('/api/sales/retail-sales', {
retailPointId: ctx.retailPointId,
docDate: new Date().toISOString(),
description: 'e2e sale',
lines,
})
check(step, {
kind: 'api', description: 'POST /api/sales/retail-sales (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-sales не возвращает id',
detail: asString(draft.data).slice(0, 200),
})
return
}
const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {})
check(step, {
kind: 'api', description: 'POST /retail-sales/{id}/post',
ok: post.status === 200 || post.status === 204,
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
})
}
// ---------------------------------------------------------------------------
export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
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}`,
})
}
}
}
// ---------------------------------------------------------------------------
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
}