food-market/tests/e2e/scenarios/full-chain.steps.ts
nns 4181bfe8ad
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
feat(pos): Z-отчёт по товарам на печать + вопрос «размен?» при автооткрытии (п.1,2)
П.2 (POS): закрытие смены печатает Z-отчёт с разбивкой по товарам (продано/возврат)
через ESC/POS; кнопка «Печать Z-отчёта (по товарам)». ShiftReportDto += byProduct/overLimit24h.
П.1 (POS): первая продажа без открытой смены (онлайн) — диалог «Открыть смену. Размен?
(можно пропустить)» → автооткрытие и переход к оплате; офлайн — как было.
П.10: full-chain обновлён — продажа без смены теперь автооткрытие (не 409) + проверка
byProduct в Z-отчёте; прогон на стенде 16 ✓ / 0 ✗.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 07:38:07 +05:00

1448 lines
112 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* Шаги сценария full-chain. Каждый шаг сверяет конкретные числа (ожидаемое
* считает сам тест) и инварианты против настоящего стенда. См. full-chain.yml.
*
* Запуск: E2E_ADMIN_URL=https://test.admin.food-market.kz tests/e2e/run.sh full-chain
* режим B: FULLCHAIN_TARGET=demo ... (вход demo-*, реальный каталог).
*/
import type { CheckResult, Step, Report } from '../lib/report.js'
import {
BASE, IS_DEMO, makeClient, signupOrg, demoLogin, loginCreds, refreshToken,
me as apiMe, round2, approxEq, sum, sleep, ean13, isValidEan13, type Session,
} from '../lib/fc.js'
import { Ui } from '../lib/fc-ui.js'
import type { AxiosInstance } from 'axios'
interface Ctx {
apiOnly: boolean
ui?: Ui
owner?: Session
ownerApi?: AxiosInstance
tag: string
storeId?: string
currencyId?: string
retailPtId?: string
groupId?: string
products: Prod[]
supplierId?: string
placeholderId?: string
emp: Record<string, EmpRec>
point1Id?: string
point2Id?: string
cash1Id?: string
acq1Id?: string
cash2Id?: string
acq2Id?: string
bankId?: string
cashOpening: number
bankOpening: number
acqOpening: number
initialDebt: number
supplies: SupplyRec[]
pay: PayRec[]
shift?: ShiftRec
closedShifts: ShiftContrib[]
reportFrom?: string
reportTo?: string
}
/** Вклад закрытой смены в счета (для НЕЗАВИСИМого расчёта остатка в тесте):
* касса += actual − opening; эквайринг += salesCard − returnsCard. */
interface ShiftContrib { cashAcctId: string; actual: number; opening: number; acqAcctId?: string; salesCard: number; returnsCard: number }
interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean }
interface EmpRec { id: string; email: string; password: string; role: string; token?: string }
interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number }
interface PayRec { txId: string; supplyIdx: number; amount: number; account: string; accountId: string }
interface ShiftRec {
id: string; pointId: string; cashId: string; openingFloat: number
salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number
expected: number; actual: number
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
const TS = Date.now()
const RUN_ID = process.env.FULLCHAIN_RUN_ID ?? new Date().toISOString().replace(/[:.]/g, '-')
// Режим B (демо) — снимаем всё; режим C — только при падении шага.
const SHOTS = IS_DEMO || process.env.FULLCHAIN_SHOTS === 'always'
// ── запись проверки «ожидаемое/фактическое» ─────────────────────────────────
function rec(step: Step, kind: CheckResult['kind'], desc: string, ok: boolean, expected?: unknown, actual?: unknown) {
let detail = ''
if (expected !== undefined || actual !== undefined) detail = `ожид=${fmt(expected)} факт=${fmt(actual)}`
step.checks.push({ kind, description: desc, ok, detail })
}
function fmt(x: unknown): string {
if (x == null) return '—'
if (typeof x === 'number') return String(round2(x))
if (typeof x === 'string') return x.length > 60 ? x.slice(0, 60) + '…' : x
return JSON.stringify(x).slice(0, 80)
}
function initCtx(ctx: Ctx) {
ctx.products ??= []
ctx.emp ??= {}
ctx.supplies ??= []
ctx.pay ??= []
ctx.closedShifts ??= []
ctx.cashOpening ??= 0
ctx.bankOpening ??= 0
ctx.acqOpening ??= 0
ctx.initialDebt ??= 0
ctx.tag ??= IS_DEMO ? `(e2e) ${TS}` : `fc${TS}`
}
async function ui(ctx: Ctx): Promise<Ui | undefined> {
if (ctx.apiOnly) return undefined
if (!ctx.ui) ctx.ui = await Ui.launch(RUN_ID)
return ctx.ui
}
/** Навигация на экран + запись скрина процесса (ожид. до/после/ошибка) и
* UI-проверки «0 ошибок». В режиме B снимаем всегда; в C — только force
* (экран ошибки при реальном ✗) либо пост-фактум для упавших шагов. */
async function capture(ctx: Ctx, step: Step, path: string, name: string, caption: string, opts?: { fullPage?: boolean; force?: boolean; check?: boolean }) {
const u = await ui(ctx); if (!u) return
u.resetErrors()
await u.goto(path)
await u.collectToasts()
if (SHOTS || opts?.force) {
const sh = await u.shot(step.id, name, caption, { fullPage: opts?.fullPage })
;(step.screenshots ??= []).push({ rel: sh.rel, caption })
}
if (opts?.check !== false) {
const errs = u.errorSummary()
rec(step, 'ui', `Экран «${caption}»: 0 console/5xx/тостов`, errs === '', '0 ошибок', errs || '0 ошибок')
}
}
/** Мобильный снимок (телефонные экраны: инвентаризация, фото-приёмка). */
async function captureMobile(ctx: Ctx, step: Step, path: string, name: string, caption: string) {
const u = await ui(ctx); if (!u) return
if (!(SHOTS)) return
const sh = await u.shotMobile(step.id, name, caption, path)
;(step.screenshots ??= []).push({ rel: sh.rel, caption: caption + ' (моб.)' })
}
/** В режиме C: если шаг упал — сохранить один скрин текущего экрана. */
async function maybeFailShot(ctx: Ctx, step: Step) {
if (SHOTS) return
const u = ctx.ui; if (!u) return
if (!step.checks.some((c) => !c.ok)) return
const sh = await u.shot(step.id, `${step.id}-fail`, `Состояние на падении шага ${step.id}`)
;(step.screenshots ??= []).push({ rel: sh.rel, caption: `Состояние на падении ${step.id}` })
}
// helper: inventory stock for product (store-scoped)
async function stockOf(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
const r = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
if (r.status !== 200) return 0
const items = (r.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[]
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
return row ? Number(row.quantity) : 0
}
// НЕЗАВИСИМЫЙ расчёт остатка счёта (считает сам тест из своих данных, не из
// money_transactions API): начальный − расходы теста + вклад закрытых смен
// (касса += факт − размен; эквайринг += продажи картой − возвраты картой).
function indepBalance(ctx: Ctx, accountId: string | undefined, opening: number): number {
if (!accountId) return opening
const payOut = ctx.pay.filter((p) => p.accountId === accountId).reduce((a, p) => a + p.amount, 0)
const shiftCash = ctx.closedShifts.filter((s) => s.cashAcctId === accountId).reduce((a, s) => a + (s.actual - s.opening), 0)
const shiftCard = ctx.closedShifts.filter((s) => s.acqAcctId === accountId).reduce((a, s) => a + (s.salesCard - s.returnsCard), 0)
return round2(opening - payOut + shiftCash + shiftCard)
}
// helper: sum of movements for a product+store (инвариант)
async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise<number> {
const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`)
if (r.status !== 200) return NaN
const items = (r.data?.items ?? []) as { quantity: number }[]
return round2(sum(items.map((i) => Number(i.quantity))))
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 1 — регистрация/вход + простой режим
// ═══════════════════════════════════════════════════════════════════════════
export async function step01_register_and_login({ ctx, step, report }: StepCtx) {
initCtx(ctx)
let sess: Session
if (IS_DEMO) {
sess = await demoLogin('admin')
rec(step, 'api', 'Вход demo-admin (режим B)', !!sess.token, 'token', sess.token ? 'token' : 'нет')
} else {
sess = await signupOrg()
rec(step, 'api', 'Регистрация организации e2e-fullchain + вход владельцем', !!sess.token, 'token', sess.token ? 'token' : 'нет')
}
ctx.owner = sess
ctx.ownerApi = makeClient(sess.token)
const m = await apiMe(sess.token)
rec(step, 'api', '/api/me: роль Admin', m.roles.includes('Admin'), 'Admin', m.roles.join(','))
// Простой режим по умолчанию (для temp-орг). Демо-магазин тоже простой.
rec(step, 'api', 'Простой режим включён (advancedMode=false)', m.advancedMode === false, false, m.advancedMode)
// Скрытые модули недоступны: demands/transfers/promotions/loyalty/abc → 403 advanced-mode-required.
const api = ctx.ownerApi
for (const [name, path] of [
['приёмка оптовых отгрузок (demands)', '/api/sales/demands?pageSize=1'],
['перемещения (transfers)', '/api/inventory/transfers?pageSize=1'],
['акции (promotions)', '/api/promotions?pageSize=1'],
['ABC-отчёт', '/api/reports/abc?from=2026-01-01&to=2026-12-31'],
] as const) {
const r = await api.get(path)
const gated = r.status === 403 && String(r.data?.type ?? '').includes('advanced-mode-required')
rec(step, 'api', `Скрытый модуль недоступен: ${name}`, gated, '403 advanced-mode-required', `${r.status} ${r.data?.type ?? ''}`)
if (!gated && r.status < 400) {
report.bug({ step: '01', severity: 'high', title: `Скрытый модуль доступен в простом режиме: ${name}`, detail: `${path} → ${r.status}` })
}
}
// UI: вход владельца в кабинет (без пароля на экране — токеном в localStorage).
const u = await ui(ctx)
if (u) {
u.resetErrors()
await u.attach(sess.token, '/dashboard')
await u.collectToasts()
if (SHOTS) {
const sh = await u.shot(step.id, '01-vhod-dashboard', 'Вход владельца → дашборд организации')
;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Вход владельца → дашборд' })
}
const errs = u.errorSummary()
rec(step, 'ui', 'Кабинет владельца открылся без ошибок (0 console/5xx/тостов)', errs === '', '0 ошибок', errs || '0 ошибок')
}
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 2 — справочники: группа, 5 товаров, поставщик
// ═══════════════════════════════════════════════════════════════════════════
export async function step02_catalog_and_supplier({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api) { step.status = 'skip'; return }
// currency KZT, retail price type
const cur = await api.get('/api/catalog/currencies?pageSize=50')
ctx.currencyId = ((cur.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT')?.id
rec(step, 'api', 'Валюта KZT в справочнике', !!ctx.currencyId, 'KZT', ctx.currencyId ? 'есть' : 'нет')
const pt = await api.get('/api/catalog/price-types?pageSize=50')
const retailPt = ((pt.data?.items ?? []) as { id: string; isRetail?: boolean }[]).find((p) => p.isRetail) ?? (pt.data?.items ?? [])[0]
const retailPtId = retailPt?.id
// store (bootstrap main)
const stores = await api.get('/api/catalog/stores?pageSize=50')
const mainStore = ((stores.data?.items ?? []) as { id: string; isMain: boolean }[]).find((s) => s.isMain) ?? (stores.data?.items ?? [])[0]
ctx.storeId = mainStore?.id
rec(step, 'api', 'Магазин (склад) существует', !!ctx.storeId, '≥1', ctx.storeId ? '1' : '0')
// units — берём до 5 различных; «весовой» = тот где в названии кг/килограмм, иначе первый
const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=50')
const units = ((unitsRes.data?.items ?? []) as { id: string; name: string; symbol?: string }[])
rec(step, 'api', 'Единицы измерения доступны (≥5 различных)', units.length >= 5, '≥5', units.length)
const pick = units.slice(0, 5)
const kgIdx = pick.findIndex((u) => /кг|килограм|kg/i.test(u.name + ' ' + (u.symbol ?? '')))
if (IS_DEMO) {
// Режим B: берём НАСТОЯЩИЕ товары каталога «Демо-магазина» (со штрихкодом и
// ценой), ничего не создаём. Владелец потом откроет и увидит знакомые позиции.
const list = await api.get('/api/catalog/products?pageSize=80&sortBy=systemPrice&sortDir=desc')
const items = (list.data?.items ?? []) as any[]
const priceOf = (p: any) => Math.max(0, ...(p.prices ?? []).map((pr: any) => Number(pr.amount ?? pr.price ?? 0)))
const usable = items.filter((p) => (p.barcodes?.length ?? 0) > 0
&& String(p.barcodes[0].code).replace(/\D/g, '').length >= 6
&& priceOf(p) > 0)
const seenBc = new Set<string>()
for (const p of usable) {
if (ctx.products.length >= 12) break
const barcode = String(p.barcodes[0].code)
if (seenBc.has(barcode)) continue // дедуп: в демо-каталоге есть дубли товаров с одним ШК
seenBc.add(barcode)
const price = round2(priceOf(p))
ctx.products.push({
id: p.id, name: p.name, price, cost: round2(price * 0.6),
unitId: p.unitOfMeasureId, unitName: p.unitName ?? '',
barcode, weighted: /кг|килограм|kg/i.test(p.unitName ?? ''),
})
}
rec(step, 'api', 'Выбрано реальных товаров каталога (≥5, штрихкод+цена)', ctx.products.length >= 5, '≥5', ctx.products.length)
rec(step, 'api', 'Среди выбранных есть весовой (информативно)', true, '≥0', ctx.products.filter((p) => p.weighted).length)
if (ctx.products.length < 5) report.bug({ step: '02', severity: 'high', title: 'В демо-каталоге не нашлось 5 товаров со штрихкодом и ценой', detail: `usable=${usable.length}` })
// поставщик (реалистичный, с меткой e2e)
const countriesD = await api.get('/api/catalog/countries?pageSize=200')
const kzD = ((countriesD.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZ')
const supD = await api.post('/api/catalog/counterparties', {
name: `[e2e] ТОО Алматы Трейд ${TS}`, legalName: 'ТОО Алматы Трейд', type: 0,
bin: '050140004321', iin: null, taxNumber: null, countryId: kzD?.id ?? null,
address: 'Алматы, пр. Райымбека 200', phone: '+77272501020', email: 'opt@almaty-trade.kz',
bankName: 'Halyk Bank', bankAccount: 'KZ22009000012345', bik: null, contactPerson: 'Асан', notes: null,
})
ctx.supplierId = supD.data?.id
rec(step, 'api', 'Поставщик [e2e] создан', supD.status < 400 && !!ctx.supplierId, '200+id', `${supD.status}`)
await capture(ctx, step, '/catalog/products', '02-tovary-realnye', 'Каталог «Демо-магазина»: реальные товары', { fullPage: true })
await capture(ctx, step, '/catalog/counterparties', '02-postavshchik', 'Поставщик [e2e]')
await maybeFailShot(ctx, step)
return
}
// группа товаров
const grp = await api.post('/api/catalog/product-groups', { name: 'e2e Группа', parentId: null, sortOrder: 0 })
ctx.groupId = grp.data?.id
rec(step, 'api', 'Группа товаров создана', grp.status < 400 && !!ctx.groupId, '200+id', `${grp.status}`)
// 5 товаров: разные ед.изм., 1 весовой, цены + штрихкоды
for (let i = 0; i < 5; i++) {
const unit = pick[i] ?? pick[0]
const weighted = i === (kgIdx >= 0 ? kgIdx : 0)
const price = 500 + i * 200
const cost = round2(price * 0.6)
const barcode = ean13(i)
const r = await api.post('/api/catalog/products', {
name: `${IS_DEMO ? '[e2e] ' : ''}Товар ${i + 1} ${ctx.tag}`,
article: `FC-${TS}-${i + 1}`,
description: null, unitOfMeasureId: unit.id, vat: 12, vatEnabled: true,
productGroupId: ctx.groupId, defaultSupplierId: null, countryOfOriginId: null,
isService: false, packaging: 0, isMarked: false, minStock: null, maxStock: null,
referencePrice: cost, purchaseCurrencyId: ctx.currencyId, imageUrl: null,
prices: retailPtId ? [{ priceTypeId: retailPtId, amount: price, currencyId: ctx.currencyId }] : [],
barcodes: [{ code: barcode, type: 0, isPrimary: true }],
})
if (r.status >= 400) {
report.bug({ step: '02', severity: 'critical', title: `Товар №${i + 1} не создан`, detail: `${r.status} ${JSON.stringify(r.data).slice(0, 160)}` })
rec(step, 'api', `Товар №${i + 1} создан`, false, '200', `${r.status}`)
continue
}
ctx.products.push({ id: r.data.id ?? r.data.productId, name: r.data.name, price, cost, unitId: unit.id, unitName: unit.name, barcode, weighted })
}
rec(step, 'api', 'Создано 5 товаров (разные ед.изм.)', ctx.products.length === 5, 5, ctx.products.length)
rec(step, 'api', 'Один товар весовой (дробное кол-во)', ctx.products.some((p) => p.weighted), '1', ctx.products.filter((p) => p.weighted).length)
// проверим что штрихкод реально сохранён и ищется
if (ctx.products[0]) {
const byBc = await api.get(`/api/purchases/invoices/product-by-barcode?barcode=${ctx.products[0].barcode}`)
rec(step, 'api', 'Товар находится по штрихкоду', byBc.status === 200, '200', `${byBc.status}`)
}
// поставщик (bin + телефон)
const countries = await api.get('/api/catalog/countries?pageSize=200')
const kz = ((countries.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZ')
const sup = await api.post('/api/catalog/counterparties', {
name: `${IS_DEMO ? '[e2e] ' : ''}ТОО Поставщик ${ctx.tag}`, legalName: 'ТОО Поставщик', type: 0,
bin: '987654321098', iin: null, taxNumber: null, countryId: kz?.id ?? null,
address: 'Алматы', phone: '+77003332211', email: 'supplier@example.kz',
bankName: null, bankAccount: null, bik: null, contactPerson: 'Иванов', notes: null,
})
ctx.supplierId = sup.data?.id
rec(step, 'api', 'Поставщик создан (БИН+телефон)', sup.status < 400 && !!ctx.supplierId, '200+id', `${sup.status}`)
// скриншоты результата: каталог товаров + карточка товара (цена/штрихкод) + поставщик
await capture(ctx, step, '/catalog/products', '02-tovary-spisok', 'Каталог: 5 товаров с ценами и штрихкодами', { fullPage: true })
if (ctx.products[0]) await capture(ctx, step, `/catalog/products/${ctx.products[0].id}`, '02-tovar-kar_tochka', 'Карточка товара: цена продажи и штрихкод')
await capture(ctx, step, '/catalog/counterparties', '02-postavshchik', 'Справочник контрагентов: поставщик (БИН, телефон)')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 2а — сотрудники + точки продаж + права
// ═══════════════════════════════════════════════════════════════════════════
export async function step02a_employees_and_points({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId) { step.status = 'skip'; return }
// штрихкоды товаров — валидный EAN-13 (баг iOS: генератор давал неверную
// контрольную цифру у 12-значного тела). Проверяем 13-значные коды.
const ean13s = ctx.products.filter((p) => /^\d{13}$/.test(p.barcode))
if (ean13s.length > 0) {
const bad = ean13s.filter((p) => !isValidEan13(p.barcode))
rec(step, 'api', 'Штрихкоды EAN-13 с валидной контрольной цифрой', bad.length === 0, '0 неверных', `${bad.length}/${ean13s.length}`)
if (bad.length > 0) report.bug({ step: '02a', severity: 'high', title: 'Неверная контрольная цифра EAN-13', detail: bad.map((p) => p.barcode).join(',') })
}
// системные роли
const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200')
const roles = (rolesRes.data?.items ?? []) as { id: string; name: string }[]
const roleId = (n: string) => roles.find((r) => r.name === n)?.id
rec(step, 'api', 'Системные роли Кассир/Кладовщик есть', !!roleId('Кассир') && !!roleId('Кладовщик'), 'есть', roles.map((r) => r.name).join(','))
// 2 точки продаж (на будущие кассы привяжем в шаге 3)
for (const [key, name] of [['point1Id', `Касса-1 ${ctx.tag}`], ['point2Id', `Касса-2 ${ctx.tag}`]] as const) {
const r = await api.post('/api/catalog/retail-points', { name, code: `${key === 'point1Id' ? 'P1' : 'P2'}-${TS}`, storeId: ctx.storeId, isActive: true })
;(ctx as Record<string, unknown>)[key] = r.data?.id
rec(step, 'api', `Точка продаж создана: ${name}`, r.status < 400 && !!r.data?.id, '200+id', `${r.status}`)
}
// редактирование точки + деактивация/активация
if (ctx.point2Id) {
const upd = await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: false })
rec(step, 'api', 'Точка 2: переименование + деактивация', upd.status < 400, '200', `${upd.status}`)
const act = await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: true })
rec(step, 'api', 'Точка 2: повторная активация', act.status < 400, '200', `${act.status}`)
}
// сотрудники: кассир, кладовщик, админ(e2e)
for (const [key, role, ln, fn, pos] of [
['cashier', 'Кассир', 'Кассиров', 'Пётр', 'Кассир'],
['keeper', 'Кладовщик', 'Кладовщиков', 'Иван', 'Кладовщик'],
['admin2', 'Администратор', 'Админов', 'Анна', 'Администратор'],
] as const) {
const rid = roleId(role) ?? roleId('Кассир')
const email = `fc-${key}-${TS}@food-market.local`
const points = key === 'cashier' && ctx.point1Id ? [ctx.point1Id] : []
const r = await api.post('/api/organization/employees', {
lastName: `${ln}${IS_DEMO ? ' e2e' : ''}`, firstName: fn, middleName: null, position: pos,
email, phone: '+77002223344', salary: null, taxNumber: null, description: null, imageUrl: null,
roleId: rid, isActive: true, retailPointIds: points, createAccount: true,
})
if (r.status < 400 && r.data?.generatedPassword) {
ctx.emp[key] = { id: r.data.employee?.id ?? r.data.id, email, password: r.data.generatedPassword, role }
rec(step, 'api', `Сотрудник создан: ${pos}`, true, '200+пароль', 'ok')
} else {
rec(step, 'api', `Сотрудник создан: ${pos}`, false, '200', `${r.status} ${JSON.stringify(r.data).slice(0, 120)}`)
}
}
// права API: кассир НЕ видит деньги и настройки; кладовщик НЕ видит деньги
if (ctx.emp.cashier) {
const cashLogin = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password)
ctx.emp.cashier.token = cashLogin.token // чистый токен до любых неудачных попыток (переиспользуем в 9а)
const c = makeClient(cashLogin.token)
const money = await c.get('/api/money/accounts?pageSize=1')
rec(step, 'api', 'Кассир → деньги (API) 403', money.status === 403, 403, money.status)
const txs = await c.get('/api/money/transactions?pageSize=1')
rec(step, 'api', 'Кассир → платежи (API) 403', txs.status === 403, 403, txs.status)
const emps = await c.get('/api/organization/employees?pageSize=1')
rec(step, 'api', 'Кассир → сотрудники/настройки (API) 403', emps.status === 403, 403, emps.status)
const sales = await c.get('/api/sales/retail?pageSize=1')
rec(step, 'api', 'Кассир → продажи доступны', sales.status === 200, 200, sales.status)
// но свою смену/кассу кассир видит (CashShiftOperate по ИЛИ-семантике)
const shifts = await c.get('/api/money/shifts?mine=true&pageSize=1')
rec(step, 'api', 'Кассир → свои смены доступны (касса)', shifts.status === 200, 200, shifts.status)
if (money.status !== 403 || emps.status !== 403) {
report.bug({ step: '02a', severity: 'critical', title: 'Кассир видит деньги/настройки', detail: `money=${money.status} emps=${emps.status}` })
}
// Права в UI: в сайдбаре кассира нет «Деньги»/«Настройки», прямой URL не даёт данных.
const u = await ui(ctx)
if (u) {
const hrefs = await u.sidebarHrefs(cashLogin.token!)
// Кассир НЕ должен видеть модуль «Деньги» (счета/платежи/долги/общий отчёт),
// но СВОЮ кассу (/money/shifts) видит — это его смены.
const moneyModule = ['/money/accounts', '/money/transactions', '/money/supplier-debts', '/money/report', '/money/expense-items']
const noMoneyModule = !hrefs.some((h) => moneyModule.some((x) => h.startsWith(x)))
const noSettings = !hrefs.some((h) => h.startsWith('/settings'))
const hasShifts = hrefs.some((h) => h.startsWith('/money/shifts'))
rec(step, 'ui', 'Кассир: в меню нет модуля «Деньги» (счета/платежи/долги)', noMoneyModule, 'нет', hrefs.filter((h) => moneyModule.some((x) => h.startsWith(x))).join(',') || 'нет')
rec(step, 'ui', 'Кассир: в меню есть своя касса (/money/shifts)', hasShifts, 'есть', hasShifts ? 'есть' : 'нет')
rec(step, 'ui', 'Кассир: в меню нет «Настройки»', noSettings, 'нет /settings', hrefs.filter((h) => h.startsWith('/settings')).join(',') || 'нет')
if (!noMoneyModule || !noSettings) report.bug({ step: '02a', severity: 'high', title: 'В меню кассира видны запретные разделы', detail: hrefs.join(',') })
if (SHOTS) {
const sh = await u.shotAs(step.id, cashLogin.token!, '/dashboard', '02a-kabinet-kassira', 'Кабинет кассира: без «Деньги»/«Настроек»')
;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Кабинет кассира (ограниченное меню)' })
const sh2 = await u.shotAs(step.id, cashLogin.token!, '/money/accounts', '02a-kassir-pryamoy-url', 'Кассир по прямому URL /money/accounts — нет данных')
;(step.screenshots ??= []).push({ rel: sh2.rel, caption: 'Кассир: прямой URL /money/accounts не открывает данные' })
}
}
// Смена пароля (самообслуживание): новый пароль работает, старый — нет.
const oldPass = ctx.emp.cashier.password
const newPass = 'NewCashier123!'
const cp = await c.post('/api/me/change-password', { currentPassword: oldPass, newPassword: newPass })
rec(step, 'api', 'Кассир сменил пароль', cp.status < 400, '204', `${cp.status}`)
if (cp.status < 400) {
const newOk = await loginCreds(ctx.emp.cashier.email, newPass)
const oldOk = await loginCreds(ctx.emp.cashier.email, oldPass)
rec(step, 'api', 'Вход по новому паролю — успех', newOk.ok, 'успех', newOk.status)
rec(step, 'api', 'Вход по старому паролю — отказ', !oldOk.ok, 'отказ', oldOk.status)
ctx.emp.cashier.password = newPass
}
}
if (ctx.emp.keeper) {
const k = makeClient((await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password)).token)
const money = await k.get('/api/money/accounts?pageSize=1')
rec(step, 'api', 'Кладовщик → деньги 403', money.status === 403, 403, money.status)
const supplies = await k.get('/api/purchases/supplies?pageSize=1')
rec(step, 'api', 'Кладовщик → приёмки доступны', supplies.status === 200, 200, supplies.status)
if (money.status !== 403) report.bug({ step: '02a', severity: 'critical', title: 'Кладовщик видит деньги', detail: `money=${money.status}` })
}
// правка сотрудника (ФИО/телефон), смена пароля, блокировка → не входит
if (ctx.emp.cashier) {
const rolesRes2 = roles
const rid = rolesRes2.find((r) => r.name === 'Кассир')?.id
// редактирование
const edit = await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, {
lastName: 'Кассиров-изм', firstName: 'Пётр', middleName: 'Петрович', position: 'Старший кассир',
email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null,
description: null, imageUrl: null, roleId: rid, isActive: true, retailPointIds: ctx.point1Id ? [ctx.point1Id] : [],
})
rec(step, 'api', 'Правка сотрудника (ФИО/тел/должность)', edit.status < 400, '200', `${edit.status}`)
// до блокировки — получим активный refresh-token (проверим, что после
// блокировки обновление по нему тоже не сработает).
const activeSess = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password)
// блокировка (увольнение): isActive=false
const block = await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, {
lastName: 'Кассиров-изм', firstName: 'Пётр', middleName: 'Петрович', position: 'Старший кассир',
email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null,
description: null, imageUrl: null, roleId: rid, isActive: false, retailPointIds: [],
})
rec(step, 'api', 'Блокировка (увольнение) сотрудника', block.status < 400, '200', `${block.status}`)
// заблокированный не входит: ни token, ни refresh
const relog = await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password)
rec(step, 'api', 'Заблокированный кассир не получает токен', !relog.ok, 'отказ', relog.status)
if (relog.ok) report.bug({ step: '02a', severity: 'critical', title: 'Заблокированный сотрудник всё ещё входит', detail: `token выдан (${relog.status})` })
if (activeSess.refresh) {
const reref = await refreshToken(activeSess.refresh)
rec(step, 'api', 'Заблокированный кассир: обновление токена (refresh) → отказ', !reref.ok, 'отказ', reref.status)
if (reref.ok) report.bug({ step: '02a', severity: 'critical', title: 'Заблокированный сотрудник обновляет токен по refresh', detail: `refresh ok (${reref.status})` })
}
// восстановим для дальнейших шагов смены (разблокируем + привяжем к точке 1)
await api.put(`/api/organization/employees/${ctx.emp.cashier.id}`, {
lastName: 'Кассиров', firstName: 'Пётр', middleName: null, position: 'Кассир',
email: ctx.emp.cashier.email, phone: '+77009998877', salary: null, taxNumber: null,
description: null, imageUrl: null, roleId: rid, isActive: true, retailPointIds: ctx.point1Id ? [ctx.point1Id] : [],
})
}
await capture(ctx, step, '/settings/employees', '02a-sotrudniki', 'Сотрудники: кассир, кладовщик, администратор (роли)', { fullPage: true })
await capture(ctx, step, '/catalog/retail-points', '02a-tochki-prodazh', 'Точки продаж (2 кассы)')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 3 — деньги: касса, банк, эквайринг + начальные остатки
// ═══════════════════════════════════════════════════════════════════════════
export async function step03_money_accounts({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId || !ctx.currencyId) { step.status = 'skip'; return }
const today = new Date().toISOString()
ctx.cashOpening = 50_000; ctx.bankOpening = 200_000; ctx.acqOpening = 0
const mk = async (name: string, kind: number, extra: Record<string, unknown>, opening: number) => {
const r = await api.post('/api/money/accounts', {
Name: name, Kind: kind, StoreId: null, CurrencyId: ctx.currencyId,
OpeningBalance: opening, OpeningDate: today, IsActive: true, IsDefault: false, ...extra,
})
return r
}
// касса 1 (привязана к магазину — обязательно для Cash)
const c1 = await mk(`Касса ${ctx.tag}`, 1, { StoreId: ctx.storeId }, ctx.cashOpening)
ctx.cash1Id = c1.data?.id
rec(step, 'api', 'Касса магазина создана (Cash, нач.остаток 50000)', c1.status < 400 && !!ctx.cash1Id, '200+id', `${c1.status}`)
// банковский счёт (с номером)
const bank = await mk(`Банк ${ctx.tag}`, 3, { BankName: 'Kaspi', AccountNumber: 'KZ123456789012345678' }, ctx.bankOpening)
ctx.bankId = bank.data?.id
rec(step, 'api', 'Банковский счёт создан (Bank, номер, нач.остаток 200000)', bank.status < 400 && !!ctx.bankId, '200+id', `${bank.status}`)
// эквайринг
const acq = await mk(`Эквайринг ${ctx.tag}`, 4, {}, ctx.acqOpening)
ctx.acq1Id = acq.data?.id
rec(step, 'api', 'Счёт эквайринга создан (Acquiring)', acq.status < 400 && !!ctx.acq1Id, '200+id', `${acq.status}`)
// касса 2 + эквайринг 2 для второй точки (шаг 2а: у каждой точки своя касса/эквайринг)
const c2 = await mk(`Касса-2 ${ctx.tag}`, 1, { StoreId: ctx.storeId }, 3_000)
ctx.cash2Id = c2.data?.id
const a2 = await mk(`Эквайринг-2 ${ctx.tag}`, 4, {}, 0)
ctx.acq2Id = a2.data?.id
rec(step, 'api', 'Для 2-й точки созданы своя касса и эквайринг', !!ctx.cash2Id && !!ctx.acq2Id, '2 счёта', `${c2.status}/${a2.status}`)
// проверка начального остатка в отчёте balances
const bal = await api.get('/api/money/reports/balances')
const rows = (bal.data?.accounts ?? []) as { accountId: string; currentBalance: number; openingBalance: number }[]
const cashRow = rows.find((r) => r.accountId === ctx.cash1Id)
rec(step, 'api', 'Отчёт «Деньги»: касса currentBalance = начальный остаток', !!cashRow && approxEq(cashRow.currentBalance, ctx.cashOpening), ctx.cashOpening, cashRow?.currentBalance)
const bankRow = rows.find((r) => r.accountId === ctx.bankId)
rec(step, 'api', 'Отчёт «Деньги»: банк currentBalance = начальный остаток', !!bankRow && approxEq(bankRow.currentBalance, ctx.bankOpening), ctx.bankOpening, bankRow?.currentBalance)
await capture(ctx, step, '/money/accounts', '03-scheta', 'Счета: касса, банк (номер), эквайринг — начальные остатки')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 4 — начальный долг поставщику
// ═══════════════════════════════════════════════════════════════════════════
export async function step04_initial_supplier_debt({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.supplierId) { step.status = 'skip'; return }
ctx.initialDebt = 100_000
const adj = await api.post('/api/money/adjustments', {
Date: new Date().toISOString(), CounterpartyId: ctx.supplierId,
Direction: 1 /* WeOwe */, Amount: ctx.initialDebt, Notes: 'Начальный долг (e2e)',
})
rec(step, 'api', 'Корректировка взаиморасчётов создана (WeOwe 100000)', adj.status < 400 && !!adj.data?.id, '200+id', `${adj.status}`)
if (adj.data?.id) {
const post = await api.post(`/api/money/adjustments/${adj.data.id}/post`, {})
rec(step, 'api', 'Корректировка проведена', post.status < 400, '204', `${post.status}`)
}
// долг поставщика = начальный долг
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false')
const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)
rec(step, 'api', 'Долг поставщика = 100000 (только начальный)', !!row && approxEq(row.debt, ctx.initialDebt), ctx.initialDebt, row?.debt)
await capture(ctx, step, '/money/supplier-debts', '04-dolg-nachalnyy', 'Долги поставщикам: начальный долг 100 000 ₸')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 5 — приёмка №1 вручную (кладовщик)
// ═══════════════════════════════════════════════════════════════════════════
export async function step05_supply1_manual({ ctx, step, report }: StepCtx) {
if (!ctx.storeId || !ctx.currencyId || !ctx.supplierId || ctx.products.length < 5) { step.status = 'skip'; return }
// кладовщик проводит приёмку (если есть e2e-кладовщик — его токеном; иначе владельцем)
const keeperLogin = ctx.emp.keeper ? await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password) : null
const api = keeperLogin?.token ? makeClient(keeperLogin.token) : ctx.ownerApi!
rec(step, 'api', 'Кладовщик авторизован для приёмки', !!api, 'token', keeperLogin?.token ? 'keeper' : 'owner')
// остатки до
const before: Record<string, number> = {}
for (const p of ctx.products) before[p.id] = await stockOf(ctx.ownerApi!, ctx.storeId, p.id)
// строки: весовой товар — дробное количество
const lines = ctx.products.map((p, i) => ({ productId: p.id, quantity: p.weighted ? 12.5 : 20 + i * 5, unitPrice: p.cost }))
const total = round2(sum(lines.map((l) => l.quantity * l.unitPrice)))
const draft = await api.post('/api/purchases/supplies', {
date: new Date().toISOString(), supplierId: ctx.supplierId, storeId: ctx.storeId,
currencyId: ctx.currencyId, notes: `Приёмка №1 ${ctx.tag}`,
lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })),
})
const supplyId = draft.data?.id ?? draft.data?.supplyId
rec(step, 'api', 'Приёмка №1 создана (черновик)', draft.status < 400 && !!supplyId, '200+id', `${draft.status}`)
if (!supplyId) { report.bug({ step: '05', severity: 'critical', title: 'Приёмка №1 не создана', detail: JSON.stringify(draft.data).slice(0, 160) }); return }
const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {})
rec(step, 'api', 'Приёмка №1 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`)
if (post.status >= 400) { report.bug({ step: '05', severity: 'critical', title: 'Приёмка №1 не проводится', detail: JSON.stringify(post.data).slice(0, 160) }); return }
ctx.supplies.push({ id: supplyId, total, supplierId: ctx.supplierId, lines, paid: 0 })
// остатки выросли ровно на количество
let allOk = true
for (const l of lines) {
const now = await stockOf(ctx.ownerApi!, ctx.storeId, l.productId)
const delta = round2(now - (before[l.productId] ?? 0))
const ok = approxEq(delta, l.quantity)
if (!ok) allOk = false
rec(step, 'api', `Остаток ${l.productId.slice(0, 8)}… +${l.quantity}`, ok, l.quantity, delta)
}
if (!allOk) report.bug({ step: '05', severity: 'high', title: 'Остатки не выросли на количество приёмки', detail: `supply=${supplyId}` })
// себестоимость: stock report Cost == unitCost приёмки для одного товара
const stockRep = await ctx.ownerApi!.get(`/api/reports/stock?storeId=${ctx.storeId}&includeZero=false`)
const srows = (stockRep.data ?? []) as { productId?: string; cost: number }[]
const p0 = ctx.products[0]
const srow = srows.find((r) => r.productId === p0.id)
rec(step, 'api', 'Себестоимость товара = цене приёмки', !!srow && approxEq(Number(srow.cost), p0.cost), p0.cost, srow?.cost)
// UI: список приёмок (до — форма новой приёмки) и карточка проведённой приёмки
await capture(ctx, step, '/purchases/supplies/new', '05-priemka1-forma', 'Приёмка №1: форма создания (до проведения)')
await capture(ctx, step, '/purchases/supplies', '05-priemka1-spisok', 'Список приёмок')
await capture(ctx, step, `/purchases/supplies/${supplyId}`, '05-priemka1-provedena', 'Приёмка №1: проведена, строки и себестоимость', { fullPage: true })
await maybeFailShot(ctx, step)
}
// multipart upload через глобальный fetch (TLS off на стенде self-signed)
async function uploadFile(token: string, path: string, filename: string, bytes: Buffer, contentType: string, field = 'files', extra?: Record<string, string>): Promise<{ status: number; body: any }> {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const fd = new FormData()
fd.append(field, new Blob([bytes], { type: contentType }), filename)
for (const [k, v] of Object.entries(extra ?? {})) fd.append(k, v)
const r = await fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: fd })
let body: any = null
try { body = await r.json() } catch { body = null }
return { status: r.status, body }
}
async function waitInvoiceParsed(api: AxiosInstance, id: string, timeoutMs: number): Promise<{ status: string; lines: any[]; supplierName?: string }> {
const t0 = Date.now()
let last = 'pending'
while (Date.now() - t0 < timeoutMs) {
const r = await api.get(`/api/purchases/invoices/${id}`)
if (r.status === 200) {
last = r.data.status
if (last !== 'pending' && last !== 'parsing') {
return { status: last, lines: r.data.lines ?? [], supplierName: r.data.supplierName }
}
}
await sleep(3000)
}
return { status: last, lines: [] }
}
/** Продажа: создать draft → прочитать Total → выставить точную оплату → провести. */
async function makeSale(api: AxiosInstance, ctx: Ctx, opts: { productId: string; qty: number; unitPrice: number; method: 0 | 1 | 99; pointId: string }): Promise<{ id?: string; total: number; paidCash: number; paidCard: number; postStatus: number; postBody: any }> {
const base = {
date: new Date().toISOString(), storeId: ctx.storeId, retailPointId: opts.pointId,
customerId: null, currencyId: ctx.currencyId,
lines: [{ productId: opts.productId, quantity: opts.qty, unitPrice: opts.unitPrice, discount: 0, vatPercent: 12 }],
}
const draft = await api.post('/api/sales/retail', { ...base, payment: opts.method, paidCash: 0, paidCard: 0, notes: 'e2e' })
const id = draft.data?.id ?? draft.data?.saleId
if (!id) return { id: undefined, total: 0, paidCash: 0, paidCard: 0, postStatus: draft.status, postBody: draft.data }
const got = await api.get(`/api/sales/retail/${id}`)
const total = round2(Number(got.data?.total ?? 0))
let paidCash = 0, paidCard = 0
if (opts.method === 0) paidCash = total
else if (opts.method === 1) paidCard = total
else { paidCash = Math.floor(total / 2); paidCard = round2(total - paidCash) }
await api.put(`/api/sales/retail/${id}`, { ...base, payment: opts.method, paidCash, paidCard, notes: 'e2e' })
const post = await api.post(`/api/sales/retail/${id}/post`, {})
return { id, total, paidCash, paidCard, postStatus: post.status, postBody: post.data }
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 6 — приёмка №2 из табличной накладной (поставщик не указан → placeholder)
// ═══════════════════════════════════════════════════════════════════════════
export async function step06_supply2_excel({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId || !ctx.currencyId || ctx.products.length < 5) { step.status = 'skip'; return }
const picks = ctx.products.slice(0, 3)
const qty = [7, 4, 9]
// CSV-накладная «по шаблону»: Штрихкод; Наименование; Количество; Цена
const rows = ['Штрихкод;Наименование;Количество;Цена']
picks.forEach((p, i) => rows.push(`${p.barcode};${p.name};${qty[i]};${p.cost}`))
const csv = '' + rows.join('\r\n')
const before: Record<string, number> = {}
for (const p of picks) before[p.id] = await stockOf(api, ctx.storeId, p.id)
const up = await uploadFile(ctx.owner!.token, '/api/purchases/invoices/upload', `nakladnaya-${TS}.csv`, Buffer.from(csv, 'utf8'), 'text/csv')
const invId = (up.body?.ids ?? [])[0]
rec(step, 'api', 'Табличная накладная загружена', up.status < 400 && !!invId, '200+id', `${up.status}`)
if (!invId) { report.bug({ step: '06', severity: 'high', title: 'Загрузка табличной накладной не вернула id', detail: JSON.stringify(up.body).slice(0, 160) }); return }
const parsed = await waitInvoiceParsed(api, invId, 60_000)
rec(step, 'api', 'Накладная разобрана (не pending/parsing)', parsed.status === 'needs_review', 'needs_review', parsed.status)
// ручной матч по штрихкоду (на случай если авто-матч не сработал)
const lines = (parsed.lines ?? []).map((l: any) => {
if (!l.productId) {
const p = picks.find((x) => x.barcode === (l.barcode ?? '').toString() || x.barcode === (l.supplierCode ?? '').toString())
if (p) { l.productId = p.id; l.matchedBy = 'manual' }
}
return l
})
await api.put(`/api/purchases/invoices/${invId}/lines`, { lines, supplierName: null, invoiceNumber: `TN-${TS}`, invoiceDate: new Date().toISOString(), totalSum: null })
const apply = await api.post(`/api/purchases/invoices/${invId}/apply`, { supplierId: null, storeId: ctx.storeId, currencyId: ctx.currencyId, notes: null })
const supplyId = apply.data?.supplyId
rec(step, 'api', 'Накладная перенесена в приёмку (apply)', apply.status < 400 && !!supplyId, '200+supplyId', `${apply.status}`)
if (!supplyId) { report.bug({ step: '06', severity: 'high', title: 'apply табличной накладной не создал приёмку', detail: JSON.stringify(apply.data).slice(0, 160) }); return }
// поставщик = «Поставщик не указан»
const sdet = await api.get(`/api/purchases/supplies/${supplyId}`)
const supName: string = sdet.data?.supplierName ?? ''
ctx.placeholderId = sdet.data?.supplierId
rec(step, 'api', 'Поставщик приёмки №2 = «Поставщик не указан»', /не указан/i.test(supName), 'Поставщик не указан', supName)
const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {})
rec(step, 'api', 'Приёмка №2 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`)
const total = round2(sum(picks.map((p, i) => qty[i] * p.cost)))
ctx.supplies.push({ id: supplyId, total: round2(Number(sdet.data?.total ?? total)), supplierId: ctx.placeholderId!, lines: picks.map((p, i) => ({ productId: p.id, quantity: qty[i], unitPrice: p.cost })), paid: 0 })
let ok = true
for (let i = 0; i < picks.length; i++) {
const now = await stockOf(api, ctx.storeId, picks[i].id)
const delta = round2(now - (before[picks[i].id] ?? 0))
if (!approxEq(delta, qty[i])) ok = false
rec(step, 'api', `Остаток ${picks[i].name.slice(0, 18)} +${qty[i]}`, approxEq(delta, qty[i]), qty[i], delta)
}
if (!ok) report.bug({ step: '06', severity: 'high', title: 'Остатки после приёмки №2 не совпали', detail: `supply=${supplyId}` })
await capture(ctx, step, '/purchases/invoices', '06-nakladnye-spisok', 'Загруженные накладные (табличная разобрана)')
await capture(ctx, step, `/purchases/supplies/${supplyId}`, '06-priemka2-provedena', 'Приёмка №2: «Поставщик не указан», проведена', { fullPage: true })
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 7 — приёмка №3 по фото-накладной (OCR) → ручное сопоставление
// ═══════════════════════════════════════════════════════════════════════════
export async function step07_supply3_photo({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId || !ctx.currencyId || !ctx.supplierId || ctx.products.length < 5) { step.status = 'skip'; return }
const { readFileSync, existsSync } = await import('node:fs')
const { resolve } = await import('node:path')
const fixture = resolve(import.meta.dirname, '..', '..', 'fixtures', 'invoice-sample.jpg')
if (!existsSync(fixture)) {
rec(step, 'api', 'Фикстура фото-накладной есть', false, 'invoice-sample.jpg', 'нет файла')
report.gap('tests/fixtures/invoice-sample.jpg отсутствует — сгенерируй scripts/make-invoice-fixture.ts')
return
}
const bytes = readFileSync(fixture)
// OCR идёт через Claude CLI (ssh на dev-vm) — внешняя зависимость, иногда
// транзиентно падает (status=failed). Ретраим загрузку до 3 раз.
let invId: string | undefined
let parsed: { status: string; lines: any[]; supplierName?: string } = { status: 'none', lines: [] }
for (let attempt = 1; attempt <= 3; attempt++) {
const up = await uploadFile(ctx.owner!.token, '/api/purchases/invoices/upload-photos', '01.jpg', bytes, 'image/jpeg', 'files')
invId = up.body?.id
if (!invId) { await sleep(2000); continue }
parsed = await waitInvoiceParsed(api, invId, 150_000)
if (parsed.status === 'needs_review') break
// неудачная попытка: удалим загрузку и попробуем снова
await api.delete(`/api/purchases/invoices/${invId}`)
invId = undefined
if (attempt < 3) await sleep(3000)
}
rec(step, 'api', 'Фото-накладная загружена', !!invId, '200+id', invId ? 'id' : 'нет')
rec(step, 'api', 'Фото-накладная распознана (OCR, needs_review; до 3 попыток)', parsed.status === 'needs_review', 'needs_review', parsed.status)
if (!invId || parsed.status !== 'needs_review') {
report.bug({ step: '07', severity: 'high', title: 'OCR фото-накладной не дал needs_review за 3 попытки', detail: `status=${parsed.status}` })
return
}
rec(step, 'api', 'OCR извлёк хотя бы одну строку', (parsed.lines?.length ?? 0) >= 1, '≥1', parsed.lines?.length ?? 0)
// ручное сопоставление: каждую распознанную строку — к нашему товару по индексу
const useLines = (parsed.lines ?? []).slice(0, Math.min(3, parsed.lines.length)).map((l: any, i: number) => ({
...l, productId: ctx.products[i].id, matchedBy: 'manual', quantity: l.quantity > 0 ? l.quantity : 3,
}))
await api.put(`/api/purchases/invoices/${invId}/lines`, { lines: useLines, supplierName: `ТОО Поставщик ${ctx.tag}`, invoiceNumber: `PHOTO-${TS}`, invoiceDate: new Date().toISOString(), totalSum: null })
const apply = await api.post(`/api/purchases/invoices/${invId}/apply`, { supplierId: ctx.supplierId, storeId: ctx.storeId, currencyId: ctx.currencyId, notes: null })
const supplyId = apply.data?.supplyId
rec(step, 'api', 'Фото-накладная перенесена в приёмку', apply.status < 400 && !!supplyId, '200', `${apply.status}`)
if (supplyId) {
const post = await api.post(`/api/purchases/supplies/${supplyId}/post`, {})
rec(step, 'api', 'Приёмка №3 проведена', post.status === 200 || post.status === 204, '204', `${post.status}`)
const sdet = await api.get(`/api/purchases/supplies/${supplyId}`)
ctx.supplies.push({ id: supplyId, total: round2(Number(sdet.data?.total ?? 0)), supplierId: ctx.supplierId, lines: useLines.map((l: any) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price ?? 0 })), paid: 0 })
}
// снимки удалены с диска после apply
const photo = await api.get(`/api/purchases/invoices/${invId}/photo/1`)
rec(step, 'api', 'Снимки удалены с диска после apply (photo → 404)', photo.status === 404, 404, photo.status)
if (photo.status !== 404) report.bug({ step: '07', severity: 'medium', title: 'Снимки фото-накладной не удалены после apply', detail: `GET photo/1 → ${photo.status}` })
await capture(ctx, step, '/purchases/invoices', '07-foto-nakladnaya-razobrana', 'Фото-накладная: распознана (OCR) и сопоставлена вручную')
if (supplyId) await capture(ctx, step, `/purchases/supplies/${supplyId}`, '07-priemka3-provedena', 'Приёмка №3 (по фото): проведена')
await captureMobile(ctx, step, '/purchases/invoices', '07-mob-foto-priemka', 'Фото-приёмка с телефона')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 8 — оплаты поставщикам
// ═══════════════════════════════════════════════════════════════════════════
export async function step08_supplier_payments({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || ctx.supplies.length < 2 || !ctx.cash1Id || !ctx.bankId) { step.status = 'skip'; return }
const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик)
const s2 = ctx.supplies[1] // приёмка №2 (placeholder)
// №1 — частично наличными из кассы. Сумма НЕ должна превышать остаток кассы
// (нач. 50000, пока без движений смены): берём ≤ 30000.
const partial = round2(Math.min(s1.total * 0.4, 30_000))
const p1 = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial,
Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }],
})
let ok1 = p1.status < 400 && !!p1.data?.id
if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash', accountId: ctx.cash1Id! }); s1.paid = partial }
rec(step, 'api', 'Оплата №1 частично наличными из кассы (≤ остатка)', ok1, '204', `${p1.status}`)
// Отказ при оплате больше остатка кассы (нет настройки «минус»).
const over = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id,
CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: round2(ctx.cashOpening + 100_000),
Purpose: 'Переплата (тест отказа)', Links: [{ SupplyId: s1.id, LinkedSum: 0 }], ExpenseItemId: null,
})
if (over.data?.id) {
const overPost = await api.post(`/api/money/transactions/${over.data.id}/post`, {})
rec(step, 'api', 'Оплата больше остатка кассы → отказ (409 insufficient-funds)', overPost.status === 409, 409, overPost.status)
if (overPost.status !== 409) { await api.post(`/api/money/transactions/${over.data.id}/unpost`, {}); report.bug({ step: '08', severity: 'critical', title: 'Расход больше остатка кассы прошёл (касса в минус)', detail: `post=${overPost.status}` }) }
await api.delete(`/api/money/transactions/${over.data.id}`)
} else {
rec(step, 'api', 'Оплата больше остатка кассы → отказ', over.status === 409, 409, over.status)
}
// №2 — полностью с банковского счёта
const p2 = await api.post('/api/money/transactions', {
Date: new Date().toISOString(), Kind: 2 /* PaymentOut */, AccountId: ctx.bankId,
CounterpartyId: s2.supplierId, CurrencyId: ctx.currencyId, Amount: s2.total,
Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }],
})
let ok2 = p2.status < 400 && !!p2.data?.id
if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank', accountId: ctx.bankId! }); s2.paid = s2.total }
rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`)
// статусы оплаты (PayedSum)
const d1 = await api.get(`/api/purchases/supplies/${s1.id}`)
const paid1 = round2(Number(d1.data?.payedSum ?? d1.data?.paidSum ?? d1.data?.payed ?? 0))
rec(step, 'api', 'Приёмка №1: оплачено = частичной сумме', approxEq(paid1, partial), partial, paid1)
const d2 = await api.get(`/api/purchases/supplies/${s2.id}`)
const paid2 = round2(Number(d2.data?.payedSum ?? d2.data?.paidSum ?? 0))
rec(step, 'api', 'Приёмка №2: оплачено полностью', approxEq(paid2, s2.total), s2.total, paid2)
// долг поставщика = начальный + Σприёмки(его) − Σоплат(его)
const mySupplies = ctx.supplies.filter((s) => s.supplierId === ctx.supplierId)
const myPaid = ctx.pay.filter((p) => ctx.supplies[p.supplyIdx].supplierId === ctx.supplierId).reduce((a, p) => a + p.amount, 0)
const expectedDebt = round2(ctx.initialDebt + sum(mySupplies.map((s) => s.total)) - myPaid)
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false')
const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)
rec(step, 'api', 'Долг реального поставщика = начальный + приёмки − оплаты', !!row && approxEq(row.debt, expectedDebt), expectedDebt, row?.debt)
if (row && !approxEq(row.debt, expectedDebt)) {
report.bug({ step: '08', severity: 'high', title: 'Долг поставщика не сходится с формулой', detail: `ожид=${expectedDebt} факт=${row.debt}` })
}
await capture(ctx, step, '/money/transactions', '08-platezhi', 'Платежи поставщикам: нал из кассы + с банка', { fullPage: true })
await capture(ctx, step, '/money/supplier-debts', '08-dolg-posle-oplat', 'Долги поставщикам после оплат (= формуле)')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 9 — смена кассира: размен, продажи, oversell, возврат, внесение/выплата, закрытие
// ═══════════════════════════════════════════════════════════════════════════
export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.point1Id || !ctx.cash1Id || !ctx.storeId) { step.status = 'skip'; return }
const openingFloat = 5000
// размен — открыть смену
const open = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: openingFloat })
const shiftId = open.data?.id
rec(step, 'api', 'Смена открыта (размен 5000)', open.status < 400 && !!shiftId, '200+id', `${open.status}`)
if (!shiftId) { report.bug({ step: '09', severity: 'critical', title: 'Смена не открывается', detail: JSON.stringify(open.data).slice(0, 160) }); return }
// товары с остатком для продаж (из приёмок)
const prods = ctx.products.slice(0, 3)
// 3 продажи: нал / карта / смешанная
const sale1 = await makeSale(api, ctx, { productId: prods[0].id, qty: 2, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id })
rec(step, 'api', 'Продажа 1 (наличные) проведена', sale1.postStatus === 200 || sale1.postStatus === 204, '204', `${sale1.postStatus}`)
const sale2 = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 1, pointId: ctx.point1Id })
rec(step, 'api', 'Продажа 2 (карта) проведена', sale2.postStatus === 200 || sale2.postStatus === 204, '204', `${sale2.postStatus}`)
const sale3 = await makeSale(api, ctx, { productId: prods[2].id, qty: 1, unitPrice: prods[2].price, method: 99, pointId: ctx.point1Id })
rec(step, 'api', 'Продажа 3 (смешанная) проведена', sale3.postStatus === 200 || sale3.postStatus === 204, '204', `${sale3.postStatus}`)
// продажа больше остатка → ПРОВЕДЕНО (минус разрешён, решение владельца 08.10)
// + появляется предупреждение об отрицательном остатке.
const over = await makeSale(api, ctx, { productId: prods[0].id, qty: 999999, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id })
rec(step, 'api', 'Продажа больше остатка → проведено (минус разрешён)', over.postStatus === 200 || over.postStatus === 204, '204', over.postStatus)
if (over.postStatus !== 200 && over.postStatus !== 204) {
report.bug({ step: '09', severity: 'high', title: 'Продажа в минус заблокирована (минус должен быть разрешён)', detail: `post=${over.postStatus}` })
}
const negCnt = await api.get(`/api/negative-stock/count?storeId=${ctx.storeId}`)
const negTotal = negCnt.data?.total ?? 0
rec(step, 'api', 'После продажи в минус появилось предупреждение', negTotal >= 1, '>=1', negTotal)
if (negTotal < 1) report.bug({ step: '09', severity: 'high', title: 'Продажа в минус не создала предупреждение', detail: `count=${negTotal}` })
// Откат: снимаем с проведения и удаляем — остаток восстановится, предупреждение закроется.
if (over.id) {
await api.post(`/api/sales/retail/${over.id}/unpost`, {})
await api.delete(`/api/sales/retail/${over.id}`)
}
// возврат от покупателя по чеку (частичный, наличными)
let returnCash = 0
if (sale1.id) {
const cr = await api.post(`/api/sales/retail/${sale1.id}/create-return`, {})
const retId = cr.data?.id ?? cr.data?.saleId
if (retId) {
// вернуть 1 шт из 2, наличными
const refund = round2(prods[0].price)
await api.put(`/api/sales/retail/${retId}`, {
date: new Date().toISOString(), storeId: ctx.storeId, retailPointId: ctx.point1Id, customerId: null,
currencyId: ctx.currencyId, payment: 0, paidCash: refund, paidCard: 0, notes: 'возврат',
isReturn: true, referenceSaleId: sale1.id,
lines: [{ productId: prods[0].id, quantity: 1, unitPrice: prods[0].price, discount: 0, vatPercent: 12 }],
})
const rp = await api.post(`/api/sales/retail/${retId}/post`, {})
returnCash = refund
rec(step, 'api', 'Возврат по чеку проведён (1 шт, наличные)', rp.status === 200 || rp.status === 204, '204', `${rp.status}`)
} else {
rec(step, 'api', 'Возврат по чеку создан', false, 'id', `${cr.status}`)
}
}
// внесение и выплата
const drawerIn = 10000, drawerOut = 3000
const di = await api.post(`/api/money/shifts/${shiftId}/drawer`, { Direction: 1, Amount: drawerIn, Reason: 'Внесение разменом' })
const dout = await api.post(`/api/money/shifts/${shiftId}/drawer`, { Direction: 2, Amount: drawerOut, Reason: 'Выплата на хознужды' })
rec(step, 'api', 'Внесение и выплата зафиксированы', di.status < 400 && dout.status < 400, '2×200', `${di.status}/${dout.status}`)
// ожидаемая касса
const salesCash = round2((sale1.paidCash || 0) + (sale3.paidCash || 0))
const salesCard = round2((sale2.paidCard || 0) + (sale3.paidCard || 0))
const expected = round2(openingFloat + salesCash - returnCash + drawerIn - drawerOut)
const discrepancy = 500
const actualCash = round2(expected + discrepancy)
const close = await api.post(`/api/money/shifts/${shiftId}/close`, { ActualCash: actualCash, Notes: 'Закрытие e2e с расхождением' })
rec(step, 'api', 'Смена закрыта', close.status < 400, '200', `${close.status}`)
rec(step, 'api', 'Закрытие: «должно быть» (expectedCash) верно', approxEq(Number(close.data?.expectedCash), expected), expected, close.data?.expectedCash)
rec(step, 'api', 'Закрытие: разница (факт − должно) = расхождению', approxEq(Number(close.data?.difference), discrepancy), discrepancy, close.data?.difference)
if (close.status < 400 && !approxEq(Number(close.data?.expectedCash), expected)) {
report.bug({ step: '09', severity: 'high', title: 'Z-отчёт: expectedCash не сходится', detail: `ожид=${expected} факт=${close.data?.expectedCash}` })
}
// Z-отчёт
const z = await api.get(`/api/money/shifts/${shiftId}/report`)
const zOk = z.status === 200 && approxEq(Number(z.data?.sales?.cash), salesCash) && approxEq(Number(z.data?.expectedCash), expected)
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
// Паритет п.2: Z-отчёт содержит разбивку по товарам.
const zByProd = Array.isArray(z.data?.byProduct) && z.data.byProduct.length > 0
rec(step, 'api', 'Z-отчёт: разбивка по товарам (byProduct)', zByProd, '>0', (z.data?.byProduct?.length ?? 0))
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
// Движения смены попали в остаток кассы: касса выросла на (факт − размен).
const balAfter = await api.get('/api/money/reports/balances')
const cashBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.cash1Id)?.currentBalance ?? 0
const expCashAfter = round2(ctx.cashOpening - sum(ctx.pay.filter((p) => p.accountId === ctx.cash1Id).map((p) => p.amount)) + (actualCash - openingFloat))
rec(step, 'api', 'Деньги смены в остатке кассы (касса += факт − размен)', approxEq(cashBal, expCashAfter), expCashAfter, cashBal)
if (!approxEq(cashBal, expCashAfter)) report.bug({ step: '09', severity: 'critical', title: 'Движения смены не попали в остаток кассы', detail: `ожид=${expCashAfter} факт=${cashBal}` })
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
// Паритет п.1: продажа без открытой смены → АВТООТКРЫТИЕ новой смены (как МойСклад),
// раньше был отказ 409. Чек проводится; подчищаем автооткрытую смену и чек.
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
const autoOpened = noShift.postStatus === 200 || noShift.postStatus === 204
rec(step, 'api', 'Продажа без смены → автооткрытие смены', autoOpened, '204', noShift.postStatus)
if (noShift.id && !autoOpened) report.bug({ step: '09', severity: 'high', title: 'Продажа без смены не автооткрыла смену', detail: `post=${noShift.postStatus}` })
// Закрыть автооткрытую смену, чтобы не мешала последующим шагам.
const cur = await api.get(`/api/money/shifts/current?retailPointId=${ctx.point1Id}`)
const autoShiftId = cur.data?.open ? cur.data?.shift?.id : null
if (autoShiftId) await api.post(`/api/money/shifts/${autoShiftId}/close`, { ActualCash: 0, Notes: 'Автооткрытая — закрыта в e2e' })
// UI: экран смен + Z-отчёт закрытой смены + чеки продаж
await capture(ctx, step, '/money/shifts', '09-smeny-spisok', 'Смены кассы: открытие/закрытие, размен')
await capture(ctx, step, `/money/shifts`, '09-smena-zakryta-z', 'Закрытая смена: должно быть / факт / разница (Z-отчёт)', { fullPage: true })
await capture(ctx, step, '/sales/retail', '09-cheki-prodazhi', 'Чеки продаж (наличные/карта/смешанная) и возврат', { fullPage: true })
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 9а — привязка кассира к точкам; параллельные смены; неактивная точка
// ═══════════════════════════════════════════════════════════════════════════
export async function step09a_points_binding({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.point1Id || !ctx.point2Id || !ctx.cash1Id || !ctx.cash2Id) { step.status = 'skip'; return }
// привязка кассира к точкам: кассир закреплён за точкой 1
if (ctx.emp.cashier) {
// переиспользуем токен, выданный в 2а (stateless JWT, валиден весь прогон) —
// чтобы не упереться в возможный lockout после неудачных попыток входа в 2а.
const cashToken = ctx.emp.cashier.token ?? (await loginCreds(ctx.emp.cashier.email, ctx.emp.cashier.password)).token
rec(step, 'api', 'Кассир авторизован для проверки привязки', !!cashToken, 'token', cashToken ? 'ok' : 'нет')
if (cashToken) {
const capi = makeClient(cashToken)
const onP2 = await capi.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 0 })
rec(step, 'api', 'Кассир открывает смену на НЕзакреплённой точке 2 → отказ', onP2.status >= 400, '4xx', onP2.status)
if (onP2.status < 400) {
report.bug({ step: '09a', severity: 'high', title: 'Кассир открыл смену на непривязанной точке', detail: `status=${onP2.status}` })
if (onP2.data?.id) await capi.post(`/api/money/shifts/${onP2.data.id}/close`, { ActualCash: 0, Notes: 'cleanup' })
}
const onP1 = await capi.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 0 })
rec(step, 'api', 'Кассир открывает смену на закреплённой точке 1 → успех', onP1.status < 400 && !!onP1.data?.id, '200+id', onP1.status)
if (onP1.data?.id) await capi.post(`/api/money/shifts/${onP1.data.id}/close`, { ActualCash: 0, Notes: 'binding-test' })
}
}
// две смены на ОДНОЙ точке одновременно → 409
const o1 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 1000 })
const shift1 = o1.data?.id
const o1dup = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point1Id, CashAccountId: ctx.cash1Id, AcquiringAccountId: ctx.acq1Id, OpeningFloat: 1000 })
rec(step, 'api', 'Вторая смена на той же точке → 409', o1dup.status === 409, 409, o1dup.status)
// параллельная смена на точке 2 (своя касса) → открывается
const o2 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 2000 })
const shift2 = o2.data?.id
rec(step, 'api', 'Параллельная смена на точке 2 открыта', o2.status < 400 && !!shift2, '200+id', `${o2.status}`)
// продажа на точке 1 и точке 2 — деньги раздельны
const p = ctx.products[0]
const sp1 = await makeSale(api, ctx, { productId: p.id, qty: 1, unitPrice: p.price, method: 0, pointId: ctx.point1Id })
const p2prod = ctx.products[1]
const sp2 = await makeSale(api, ctx, { productId: p2prod.id, qty: 1, unitPrice: p2prod.price, method: 0, pointId: ctx.point2Id })
rec(step, 'api', 'Продажи прошли на обеих точках', (sp1.postStatus === 204 || sp1.postStatus === 200) && (sp2.postStatus === 204 || sp2.postStatus === 200), '2×204', `${sp1.postStatus}/${sp2.postStatus}`)
// закрываем обе — expectedCash у каждой = свой размен + своя касса
if (shift1) {
const exp1 = round2(1000 + sp1.paidCash)
const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' })
rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: exp1, opening: 1000, acqAcctId: ctx.acq1Id, salesCard: 0, returnsCard: 0 })
}
if (shift2) {
const exp2 = round2(2000 + sp2.paidCash)
const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' })
rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash)
ctx.closedShifts.push({ cashAcctId: ctx.cash2Id!, actual: exp2, opening: 2000, acqAcctId: ctx.acq2Id, salesCard: 0, returnsCard: 0 })
}
// продажа на НЕактивной точке → отказ
await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: false })
const o3 = await api.post('/api/money/shifts/open', { RetailPointId: ctx.point2Id, CashAccountId: ctx.cash2Id, AcquiringAccountId: ctx.acq2Id, OpeningFloat: 0 })
const inactiveRefused = o3.status >= 400
rec(step, 'api', 'Смена/продажа на неактивной точке → отказ', inactiveRefused, '4xx', o3.status)
if (!inactiveRefused) {
report.gap('Открытие смены на НЕактивной точке продаж не отклоняется сервером — продажа на деактивированной точке возможна.')
if (o3.data?.id) await api.post(`/api/money/shifts/${o3.data.id}/close`, { ActualCash: 0, Notes: 'cleanup' })
}
await capture(ctx, step, '/money/shifts', '09a-parallelnye-smeny', 'Параллельные смены на точках 1 и 2 (деньги раздельны)', { fullPage: true })
// вернём точку 2 активной
await api.put(`/api/catalog/retail-points/${ctx.point2Id}`, { name: `Касса-2изм ${ctx.tag}`, code: `P2-${TS}`, storeId: ctx.storeId, isActive: true })
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 10 — склад: оприходование, списание, инвентаризация CSV (часть товаров)
// ═══════════════════════════════════════════════════════════════════════════
export async function step10_warehouse_ops({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId || !ctx.currencyId || ctx.products.length < 5) { step.status = 'skip'; return }
const now = () => new Date().toISOString()
// Bug (iOS #1): у кладовщика должны быть права на оприходование/списание/
// инвентаризацию (веб их показывает — API должен совпадать). Проверяем, что
// НЕ 403 (создаём черновики и удаляем, не трогая остатки).
if (ctx.emp.keeper) {
const kl = await loginCreds(ctx.emp.keeper.email, ctx.emp.keeper.password)
if (kl.token) {
const kapi = makeClient(kl.token)
const kE = await kapi.post('/api/inventory/enters', { date: now(), storeId: ctx.storeId, currencyId: ctx.currencyId, notes: 'keeper-perm', lines: [{ productId: ctx.products[0].id, quantity: 1, unitCost: ctx.products[0].cost }] })
rec(step, 'api', 'Кладовщик → оприходование доступно (не 403)', kE.status !== 403 && kE.status < 400, '<400', kE.status)
if (kE.data?.id) await kapi.delete(`/api/inventory/enters/${kE.data.id}`)
const kL = await kapi.post('/api/inventory/losses', { date: now(), storeId: ctx.storeId, currencyId: ctx.currencyId, reason: 0, notes: 'keeper-perm', lines: [{ productId: ctx.products[0].id, quantity: 1, unitCost: ctx.products[0].cost }] })
rec(step, 'api', 'Кладовщик → списание доступно (не 403)', kL.status !== 403 && kL.status < 400, '<400', kL.status)
if (kL.data?.id) await kapi.delete(`/api/inventory/losses/${kL.data.id}`)
const kI = await kapi.get('/api/inventory/inventories?pageSize=1')
rec(step, 'api', 'Кладовщик → инвентаризация доступна (не 403)', kI.status === 200, 200, kI.status)
if (kE.status === 403 || kL.status === 403) report.bug({ step: '10', severity: 'high', title: 'Кладовщик без прав на склад (API 403, а веб показывает)', detail: `enter=${kE.status} loss=${kL.status}` })
}
}
// Bug (iOS #2): инвентаризация из веба/телефона создаётся с пустыми строками
// (сервер подтягивает весь склад) — проведение НЕ должно обнулять непосчитанное.
// В demo каталог огромный (авто-подтяжка всех товаров тяжела) → только режим C.
if (!IS_DEMO) {
const counted = ctx.products[2], uncounted2 = ctx.products[3]
const uBefore = await stockOf(api, ctx.storeId, uncounted2.id)
const bookC = await stockOf(api, ctx.storeId, counted.id)
const invE = await api.post('/api/inventory/inventories', { date: now(), storeId: ctx.storeId, notes: 'пустая (iOS repro)', lines: [] })
const invEId = invE.data?.id
if (invEId) {
await api.post(`/api/inventory/inventories/${invEId}/add-line`, { productId: counted.id, actualQty: round2(bookC - 1) })
const postE = await api.post(`/api/inventory/inventories/${invEId}/post`, {})
rec(step, 'api', 'Пустая инвентаризация проведена (одно расхождение)', postE.status === 200 || postE.status === 204, '204', postE.status)
const uAfter = await stockOf(api, ctx.storeId, uncounted2.id)
rec(step, 'api', 'Пустая инвентаризация: непосчитанный НЕ обнулён', approxEq(uAfter, uBefore), uBefore, uAfter)
const cAfter = await stockOf(api, ctx.storeId, counted.id)
rec(step, 'api', 'Пустая инвентаризация: посчитанный применён (недостача 1)', approxEq(cAfter, round2(bookC - 1)), round2(bookC - 1), cAfter)
if (!approxEq(uAfter, uBefore)) report.bug({ step: '10', severity: 'critical', title: 'Пустая инвентаризация обнулила непосчитанные товары', detail: `было ${uBefore} стало ${uAfter}` })
}
}
const pEnter = ctx.products[3], pLoss = ctx.products[4]
// оприходование излишка
const beforeEnter = await stockOf(api, ctx.storeId, pEnter.id)
const enter = await api.post('/api/inventory/enters', { date: new Date().toISOString(), storeId: ctx.storeId, currencyId: ctx.currencyId, notes: 'Излишек', lines: [{ productId: pEnter.id, quantity: 8, unitCost: pEnter.cost }] })
const enterId = enter.data?.id
if (enterId) await api.post(`/api/inventory/enters/${enterId}/post`, {})
const afterEnter = await stockOf(api, ctx.storeId, pEnter.id)
rec(step, 'api', 'Оприходование излишка (+8)', approxEq(afterEnter - beforeEnter, 8), 8, round2(afterEnter - beforeEnter))
// списание брака
const beforeLoss = await stockOf(api, ctx.storeId, pLoss.id)
const loss = await api.post('/api/inventory/losses', { date: new Date().toISOString(), storeId: ctx.storeId, currencyId: ctx.currencyId, reason: 0, notes: 'Брак', lines: [{ productId: pLoss.id, quantity: 2, unitCost: pLoss.cost }] })
const lossId = loss.data?.id
if (lossId) await api.post(`/api/inventory/losses/${lossId}/post`, {})
const afterLoss = await stockOf(api, ctx.storeId, pLoss.id)
rec(step, 'api', 'Списание брака (−2)', approxEq(beforeLoss - afterLoss, 2), 2, round2(beforeLoss - afterLoss))
// инвентаризация по CSV (только 2 товара) — у одного расхождение
const invProds = [ctx.products[0], ctx.products[1]]
const uncounted = ctx.products[2]
const uncountedBefore = await stockOf(api, ctx.storeId, uncounted.id)
const book0 = await stockOf(api, ctx.storeId, invProds[0].id)
const book1 = await stockOf(api, ctx.storeId, invProds[1].id)
const actual0 = round2(book0 - 1) // недостача 1
const actual1 = round2(book1) // без расхождения
const csv = '' + ['Штрихкод;Количество', `${invProds[0].barcode};${actual0}`, `${invProds[1].barcode};${actual1}`].join('\r\n')
const imp = await uploadFile(ctx.owner!.token, '/api/inventory/inventories/import', `inv-${TS}.csv`, Buffer.from(csv, 'utf8'), 'text/csv', 'file')
const found = (imp.body?.found ?? []) as { id: string; qty: number }[]
rec(step, 'api', 'Импорт инвентаризации нашёл 2 товара', found.length === 2, 2, found.length)
const inv = await api.post('/api/inventory/inventories', {
date: new Date().toISOString(), storeId: ctx.storeId, notes: 'Инвентаризация (часть)',
lines: invProds.map((p, i) => ({ productId: p.id, actualQty: i === 0 ? actual0 : actual1 })),
})
const invId = inv.data?.id
rec(step, 'api', 'Инвентаризация (частичная) создана', inv.status < 400 && !!invId, '200+id', `${inv.status}`)
if (invId) {
const post = await api.post(`/api/inventory/inventories/${invId}/post`, {})
rec(step, 'api', 'Инвентаризация проведена', post.status === 200 || post.status === 204, '204', `${post.status}`)
// расхождение применилось: товар 0 стал actual0
const now0 = await stockOf(api, ctx.storeId, invProds[0].id)
rec(step, 'api', 'Расхождение применено (товар с недостачей → факт)', approxEq(now0, actual0), actual0, now0)
// непосчитанный товар НЕ обнулён
const uncountedNow = await stockOf(api, ctx.storeId, uncounted.id)
rec(step, 'api', 'Непосчитанный товар не обнулён', approxEq(uncountedNow, uncountedBefore), uncountedBefore, uncountedNow)
if (!approxEq(uncountedNow, uncountedBefore)) report.bug({ step: '10', severity: 'critical', title: 'Инвентаризация обнулила непосчитанный товар', detail: `было ${uncountedBefore} стало ${uncountedNow}` })
}
// ── Новая модель МойСклада: завершить (без движений) → создать оприходование
// «на основании» → провести его (вот тогда меняются остатки) ──────────────
const nmProd = invProds[0]
const nmBefore = await stockOf(api, ctx.storeId, nmProd.id)
const nmInv = await api.post('/api/inventory/inventories', {
date: new Date().toISOString(), storeId: ctx.storeId, notes: 'Новая модель: излишек',
lines: [{ productId: nmProd.id, actualQty: round2(nmBefore + 3) }],
})
const nmId = nmInv.data?.id
if (nmId) {
const compl = await api.post(`/api/inventory/inventories/${nmId}/complete`, {})
rec(step, 'api', 'Инвентаризация завершена (Completed)', compl.status === 204, '204', `${compl.status}`)
const midStock = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'После завершения остаток НЕ изменился', approxEq(midStock, nmBefore), nmBefore, midStock)
const ce = await api.post(`/api/inventory/inventories/${nmId}/create-enter`, {})
const nmEnterId = ce.data?.enterId as string | undefined
rec(step, 'api', 'Создано оприходование излишков', ce.status === 200 && !!nmEnterId, '200+enterId', `${ce.status}`)
const invGet = await api.get(`/api/inventory/inventories/${nmId}`)
const relOk = ((invGet.data?.related ?? []) as { kind: string }[]).some((r) => r.kind === 'enter')
rec(step, 'api', 'В инвентаризации виден связанный документ', relOk, true, relOk)
if (nmEnterId) {
const pe = await api.post(`/api/inventory/enters/${nmEnterId}/post`, {})
rec(step, 'api', 'Оприходование проведено', pe.status === 204 || pe.status === 200, '204', `${pe.status}`)
const nmAfter = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'Излишек применён оприходованием (+3)', approxEq(nmAfter - nmBefore, 3), 3, round2(nmAfter - nmBefore))
const enterGet = await api.get(`/api/inventory/enters/${nmEnterId}`)
rec(step, 'api', 'Оприходование «на основании инвентаризации №…»', !!enterGet.data?.inventoryNumber, 'инв.№', enterGet.data?.inventoryNumber ?? '—')
// Отмена результатов: unpost при проведённом связанном → 409 (не удаляет);
// cancel-results снимает проведение, документ остаётся черновиком, остаток возвращается.
const badUnpost = await api.post(`/api/inventory/inventories/${nmId}/unpost`, {})
rec(step, 'api', 'unpost при проведённом связанном → 409 (не удаляет)', badUnpost.status === 409, 409, badUnpost.status)
if (badUnpost.status !== 409) report.bug({ step: '10', severity: 'critical', title: 'unpost снял/удалил проведённый документ без cancel-results', detail: `status=${badUnpost.status}` })
const cancel = await api.post(`/api/inventory/inventories/${nmId}/cancel-results`, {})
rec(step, 'api', 'Отмена результатов (cancel-results) → 200', cancel.status === 200, 200, cancel.status)
const afterCancel = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'После отмены результатов остаток вернулся', approxEq(afterCancel, nmBefore), nmBefore, afterCancel)
const enterAfter = await api.get(`/api/inventory/enters/${nmEnterId}`)
const enterDraft = enterAfter.data?.status === 0 || `${enterAfter.data?.status}`.toLowerCase() === 'draft'
rec(step, 'api', 'Оприходование НЕ удалено, стало черновиком', enterAfter.status === 200 && enterDraft, 'draft', `${enterAfter.status}/${enterAfter.data?.status}`)
if (enterAfter.status === 404) report.bug({ step: '10', severity: 'critical', title: 'cancel-results удалил проведённый документ (потеря истории)', detail: 'enter 404' })
const invRel2 = await api.get(`/api/inventory/inventories/${nmId}`)
const stillLinked = ((invRel2.data?.related ?? []) as { kind: string; posted: boolean }[]).some((r) => r.kind === 'enter' && !r.posted)
rec(step, 'api', 'Связанный документ остался (черновик)', stillLinked, true, stillLinked)
// Повторное проведение → остаток снова = факт (+3).
const rePost = await api.post(`/api/inventory/enters/${nmEnterId}/post`, {})
rec(step, 'api', 'Повторное проведение оприходования', rePost.status === 204 || rePost.status === 200, '204', `${rePost.status}`)
const reAfter = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'После повторного проведения остаток снова = факт (+3)', approxEq(reAfter - nmBefore, 3), 3, round2(reAfter - nmBefore))
}
}
await capture(ctx, step, '/inventory/enters', '10-oprihodovanie', 'Оприходование излишка (+8)')
await capture(ctx, step, '/inventory/losses', '10-spisanie-braka', 'Списание брака (−2)')
await capture(ctx, step, '/inventory/inventories', '10-inventarizaciya', 'Инвентаризация (часть товаров): расхождения', { fullPage: true })
await capture(ctx, step, '/inventory/stock', '10-ostatki-sklad', 'Остатки после складских операций', { fullPage: true })
await captureMobile(ctx, step, '/inventory/mobile', '10-mob-inventarizaciya', 'Инвентаризация с телефона (сканер)')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 11 — отчёты
// ═══════════════════════════════════════════════════════════════════════════
export async function step11_reports({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId) { step.status = 'skip'; return }
const from = new Date(Date.now() - 36 * 3600_000).toISOString()
const to = new Date(Date.now() + 24 * 3600_000).toISOString()
ctx.reportFrom = from; ctx.reportTo = to
// продажи за день: выручка = сумма чеков − возвраты (сам отчёт нетит возвраты)
const salesRep = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=period:day`)
const revenue = round2(sum(((salesRep.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue))))
rec(step, 'api', 'Отчёт продаж за день доступен, выручка ≥ 0', salesRep.status === 200 && revenue >= 0, '≥0', revenue)
// прибыль = выручка − себестоимость
const profitRep = await api.get(`/api/reports/profit?from=${from}&to=${to}&groupBy=period:day`)
const prows = (profitRep.data ?? []) as { revenue: number; cost: number; profit: number }[]
const profOk = profitRep.status === 200 && prows.every((r) => approxEq(Number(r.profit), round2(Number(r.revenue) - Number(r.cost))))
rec(step, 'api', 'Прибыль = выручка − себестоимость (по каждой строке)', profOk, 'revenue−cost', profitRep.status)
// остатки на дату
const stockRep = await api.get(`/api/reports/stock?storeId=${ctx.storeId}&includeZero=false`)
rec(step, 'api', 'Отчёт остатков на дату доступен', stockRep.status === 200 && Array.isArray(stockRep.data), 'список', `${stockRep.status}`)
// отчёт «Деньги»: остаток каждого счёта = начальный + движения
const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[]
// остаток счёта = начальный + платежи + ДВИЖЕНИЯ СМЕН (независимый расчёт).
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
const expCash = indepBalance(ctx, ctx.cash1Id, ctx.cashOpening)
rec(step, 'api', 'Деньги: касса = начальный − оплаты + движения смен', !!cashAcc && approxEq(cashAcc.currentBalance, expCash), expCash, cashAcc?.currentBalance)
const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
const expBank = indepBalance(ctx, ctx.bankId, ctx.bankOpening)
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, expBank), expBank, bankAcc?.currentBalance)
const acqAcc = accs.find((a) => a.accountId === ctx.acq1Id)
const expAcq = indepBalance(ctx, ctx.acq1Id, ctx.acqOpening)
rec(step, 'api', 'Деньги: эквайринг = продажи картой − возвраты картой', !!acqAcc && approxEq(acqAcc.currentBalance, expAcq), expAcq, acqAcc?.currentBalance)
// долги поставщикам
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true')
rec(step, 'api', 'Отчёт долгов поставщикам доступен', debts.status === 200, 200, debts.status)
// по точкам и по кассирам: выручка = сумме их чеков − возвраты.
// Проверяем, что сумма по группам = общей выручке за день (нетто возвратов).
const byPoint = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=register`)
const pointRev = round2(sum(((byPoint.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue))))
rec(step, 'api', 'Выручка по точкам в сумме = общей выручке дня', byPoint.status === 200 && approxEq(pointRev, revenue), revenue, pointRev)
const byCashier = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=cashier`)
const cashierRev = round2(sum(((byCashier.data ?? []) as { revenue: number }[]).map((r) => Number(r.revenue))))
rec(step, 'api', 'Выручка по кассирам в сумме = общей выручке дня', byCashier.status === 200 && approxEq(cashierRev, revenue), revenue, cashierRev)
if (byPoint.status === 200 && !approxEq(pointRev, revenue)) report.bug({ step: '11', severity: 'medium', title: 'Выручка по точкам ≠ общей выручке', detail: `точки=${pointRev} всего=${revenue}` })
// UI: отчёты с числами (продажи, прибыль, остатки, деньги, долги)
await capture(ctx, step, '/reports/sales', '11-otchet-prodazhi', 'Отчёт: продажи за день (выручка)', { fullPage: true })
await capture(ctx, step, '/reports/profit', '11-otchet-pribyl', 'Отчёт: прибыль (выручка − себестоимость)', { fullPage: true })
await capture(ctx, step, '/reports/stock', '11-otchet-ostatki', 'Отчёт: остатки на дату', { fullPage: true })
await capture(ctx, step, '/money/report', '11-otchet-dengi', 'Отчёт «Деньги»: остатки по счетам', { fullPage: true })
await capture(ctx, step, '/money/supplier-debts', '11-otchet-dolgi', 'Отчёт: долги поставщикам')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 12 — инварианты
// ═══════════════════════════════════════════════════════════════════════════
export async function step12_invariants({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || !ctx.storeId) { step.status = 'skip'; return }
// 1) по каждому товару: остаток ≡ Σ движений
let stockOk = true
for (const p of ctx.products) {
const stock = await stockOf(api, ctx.storeId, p.id)
const mv = await movementsSum(api, ctx.storeId, p.id)
const ok = !Number.isNaN(mv) && approxEq(stock, mv)
if (!ok) stockOk = false
rec(step, 'api', `Инвариант остаток≡Σдвижений: ${p.name.slice(0, 16)}`, ok, mv, stock)
}
if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' })
// 2) по каждому счёту: остаток ≡ НЕЗАВИСИМО посчитанной в тесте сумме
// (начальный + платежи + движения смен), а НЕ сумме из того же API.
const bal = await api.get('/api/money/reports/balances')
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
const known: [string | undefined, number, string][] = [
[ctx.cash1Id, ctx.cashOpening, 'Касса'],
[ctx.bankId, ctx.bankOpening, 'Банк'],
[ctx.acq1Id, ctx.acqOpening, 'Эквайринг'],
[ctx.cash2Id, 3000, 'Касса-2'],
]
for (const [acctId, opening, label] of known) {
if (!acctId) continue
const a = accs.find((x) => x.accountId === acctId)
const expected = indepBalance(ctx, acctId, opening)
rec(step, 'api', `Инвариант ${label} ≡ независимому расчёту (нач+платежи+смены)`, !!a && approxEq(Number(a.currentBalance), expected), expected, a?.currentBalance)
if (a && !approxEq(Number(a.currentBalance), expected)) report.bug({ step: '12', severity: 'critical', title: `Остаток счёта ${label} ≠ независимой формуле`, detail: `ожид=${expected} факт=${a.currentBalance}` })
}
// 3) долг поставщика ≡ формуле (пересчёт независимо)
const mySupplies = ctx.supplies.filter((s) => s.supplierId === ctx.supplierId)
const myPaid = ctx.pay.filter((p) => ctx.supplies[p.supplyIdx].supplierId === ctx.supplierId).reduce((a, p) => a + p.amount, 0)
const expectedDebt = round2(ctx.initialDebt + sum(mySupplies.map((s) => s.total)) - myPaid)
const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false')
const row = ((debts.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)
rec(step, 'api', 'Инвариант долг≡формуле', !!row && approxEq(row.debt, expectedDebt), expectedDebt, row?.debt)
// 4) изоляция организаций
if (!IS_DEMO) {
// demo-admin не видит наш товар; мы не видим demo-данные
try {
const demo = await demoLogin('admin')
const demoApi = makeClient(demo.token)
const ourProd = ctx.products[0].id
const seen = await demoApi.get(`/api/catalog/products/${ourProd}`)
rec(step, 'api', 'Изоляция: demo-admin НЕ видит товар e2e-орг', seen.status === 404 || seen.status === 403, '404/403', seen.status)
if (seen.status === 200) report.bug({ step: '12', severity: 'critical', title: 'Нарушение изоляции: чужая орг видит товар', detail: `demo увидел ${ourProd}` })
} catch (e) {
rec(step, 'api', 'Изоляция: вход demo-admin', false, 'token', (e as Error).message.slice(0, 60))
}
} else {
rec(step, 'api', 'Изоляция: режим B (демо) — проверка через temp-орг в режиме C', true, 'n/a', 'n/a')
}
await capture(ctx, step, '/inventory/movements', '12-dvizheniya-sklada', 'Движения склада (остаток ≡ Σ движений)', { fullPage: true })
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 13 — отмена и откат
// ═══════════════════════════════════════════════════════════════════════════
export async function step13_cancel_rollback({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
if (!api || ctx.supplies.length < 1) { step.status = 'skip'; return }
const s1 = ctx.supplies[0]
// отмена проведения приёмки с уже проданным товаром → ПРОВЕДЕНО (минус разрешён),
// остаток может уйти в минус, появляется предупреждение. Затем вернём обратно (re-post).
const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {})
const ok = unpost.status === 200 || unpost.status === 204
rec(step, 'api', 'Отмена приёмки с проданным товаром → проведено (минус разрешён)', ok, '204', unpost.status)
if (!ok) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки заблокирована (минус должен быть разрешён)', detail: `unpost=${unpost.status}` })
// Возвращаем приёмку обратно, чтобы не ломать остальные проверки отката.
if (ok) await api.post(`/api/purchases/supplies/${s1.id}/post`, {})
// отмена платежа → долг вернулся
const pay1 = ctx.pay.find((p) => p.supplyIdx === 0)
if (pay1) {
const debtsBefore = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false')
const before = ((debtsBefore.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)?.debt ?? 0
const un = await api.post(`/api/money/transactions/${pay1.txId}/unpost`, {})
rec(step, 'api', 'Отмена платежа №1', un.status < 400, '204', `${un.status}`)
const debtsAfter = await api.get('/api/money/reports/supplier-debts?withDebtOnly=false')
const after = ((debtsAfter.data?.rows ?? []) as { supplierId: string; debt: number }[]).find((r) => r.supplierId === ctx.supplierId)?.debt ?? 0
const expected = round2(before + pay1.amount)
rec(step, 'api', 'Долг вернулся на сумму платежа', approxEq(after, expected), expected, after)
if (!approxEq(after, expected)) report.bug({ step: '13', severity: 'high', title: 'После отмены платежа долг не вернулся', detail: `ожид=${expected} факт=${after}` })
// вернём оплату, чтобы данные остались консистентными
await api.post(`/api/money/transactions/${pay1.txId}/post`, {})
}
await capture(ctx, step, '/money/supplier-debts', '13-otkat-dolg-vernulsya', 'Отмена платежа → долг вернулся')
await maybeFailShot(ctx, step)
}
// ═══════════════════════════════════════════════════════════════════════════
// Шаг 14 — финал: скриншоты + деактивация e2e-сущностей (режим B)
// ═══════════════════════════════════════════════════════════════════════════
export async function step14_finalize({ ctx, step, report }: StepCtx) {
const api = ctx.ownerApi!
const u = await ui(ctx)
if (u) {
u.resetErrors()
await u.goto('/dashboard')
await u.collectToasts()
if (SHOTS) {
const sh = await u.shot(step.id, '14-itog-dashboard', 'Итоговый дашборд магазина после полного дня')
;(step.screenshots ??= []).push({ rel: sh.rel, caption: 'Итоговый дашборд' })
}
rec(step, 'ui', 'Финальный дашборд без ошибок', u.errorSummary() === '', '0 ошибок', u.errorSummary() || '0 ошибок')
// HTML-галерея всех скриншотов процессов
if (u.shots.length > 0) {
const gallery = u.writeGallery(RUN_ID, { target: IS_DEMO ? 'Демо-магазин (B)' : 'temp-орг (C)', when: new Date().toISOString() })
report.ux(`Галерея скриншотов: ${gallery}`)
rec(step, 'ui', `Галерея index.html собрана (${u.shots.length} кадров)`, true, 'index.html', `${u.shots.length}`)
}
await u.close()
}
// В режиме B (демо) — деактивируем e2e-сотрудников и точки (НЕ удаляем).
if (IS_DEMO && api) {
let deact = 0
for (const key of Object.keys(ctx.emp)) {
const e = ctx.emp[key]
const roles = await api.get('/api/organization/employee-roles?pageSize=200')
const rid = ((roles.data?.items ?? []) as { id: string; name: string }[]).find((r) => r.name === e.role)?.id
const r = await api.put(`/api/organization/employees/${e.id}`, {
lastName: 'e2e', firstName: 'e2e', middleName: null, position: 'e2e', email: e.email, phone: null,
salary: null, taxNumber: null, description: null, imageUrl: null, roleId: rid, isActive: false, retailPointIds: [],
})
if (r.status < 400) deact++
}
for (const pid of [ctx.point1Id, ctx.point2Id]) {
if (!pid) continue
await api.put(`/api/catalog/retail-points/${pid}`, { name: `e2e (деакт.)`, code: `X-${TS}`, storeId: ctx.storeId, isActive: false })
}
rec(step, 'api', 'Режим B: e2e-сотрудники деактивированы (не удалены)', true, `${Object.keys(ctx.emp).length}`, deact)
} else {
rec(step, 'api', 'Режим C: temp-орг чистится ночной уборкой (>3 дней)', true, 'cleanup', 'ночь')
}
report.ux(`Скриншоты ключевых экранов: tests/e2e/reports/full-chain/*.png`)
}