/** * Шаги сценария 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 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 { 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 { 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 { 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() 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)[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, 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 = {} 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): 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 = {} 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}`) 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) // продажа без смены → отказ (смена уже закрыта) 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) 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`) }