16 шагов против настоящего стенда: регистрация→справочники→сотрудники/точки→ деньги→3 приёмки→оплаты→смена/продажи/возврат/Z→склад→отчёты→инварианты→откат. Сверяет конкретные числа и инварианты, 0×5xx/0×тостов/0×console.error. Режимы C (temp-орг) и B (Демо на реальном каталоге). Hi-res скриншоты каждого процесса + галерея. Включён в nightly-verify и quality-watchdog (скрипты в $HOME). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1320 lines
99 KiB
TypeScript
1320 lines
99 KiB
TypeScript
/**
|
||
* Шаги сценария 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
|
||
reportFrom?: string
|
||
reportTo?: string
|
||
}
|
||
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 }
|
||
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.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
|
||
}
|
||
// 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 — частично наличными из кассы
|
||
const partial = round2(s1.total * 0.4)
|
||
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' }); s1.paid = partial }
|
||
rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.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' }); 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}`)
|
||
|
||
// продажа больше остатка → отказ
|
||
const over = await makeSale(api, ctx, { productId: prods[0].id, qty: 999999, unitPrice: prods[0].price, method: 0, pointId: ctx.point1Id })
|
||
rec(step, 'api', 'Продажа больше остатка → отказ (409)', over.postStatus === 409, 409, over.postStatus)
|
||
if (over.id && over.postStatus !== 409) {
|
||
report.bug({ step: '09', severity: 'high', title: 'Продажа больше остатка прошла', detail: `post=${over.postStatus}` })
|
||
}
|
||
if (over.id) 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}`)
|
||
|
||
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
|
||
|
||
// продажа без смены → отказ (смена уже закрыта)
|
||
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
|
||
rec(step, 'api', 'Продажа без открытой смены → отказ', noShift.postStatus === 409, 409, noShift.postStatus)
|
||
if (noShift.id && noShift.postStatus !== 409) report.bug({ step: '09', severity: 'high', title: 'Продажа без смены прошла', detail: `post=${noShift.postStatus}` })
|
||
if (noShift.id) await api.delete(`/api/sales/retail/${noShift.id}`)
|
||
|
||
// 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)
|
||
}
|
||
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)
|
||
}
|
||
|
||
// продажа на НЕактивной точке → отказ
|
||
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}` })
|
||
}
|
||
|
||
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 }[]
|
||
// касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2
|
||
const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0)
|
||
const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0)
|
||
const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id)
|
||
rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance)
|
||
const bankAcc = accs.find((a) => a.accountId === ctx.bankId)
|
||
rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.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) по каждому счёту: баланс ≡ начальный + Σ проведённых операций
|
||
const bal = await api.get('/api/money/reports/balances')
|
||
const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[]
|
||
for (const a of accs) {
|
||
const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`)
|
||
const items = (txs.data?.items ?? []) as { kind: number; amount: number }[]
|
||
const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount)))
|
||
const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount)))
|
||
const expected = round2(Number(a.openingBalance) + inSum - outSum)
|
||
rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), 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]
|
||
|
||
// отмена проведения приёмки с уже проданным товаром → понятный отказ
|
||
const unpost = await api.post(`/api/purchases/supplies/${s1.id}/unpost`, {})
|
||
const refused = unpost.status >= 400
|
||
rec(step, 'api', 'Отмена приёмки с проданным товаром → отказ', refused, '4xx', unpost.status)
|
||
if (!refused) report.bug({ step: '13', severity: 'high', title: 'Отмена приёмки с проданным товаром прошла (остаток ушёл в минус?)', detail: `unpost=${unpost.status}` })
|
||
|
||
// отмена платежа → долг вернулся
|
||
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`)
|
||
}
|
||
|