food-market/tests/e2e/scenarios/reports-stats.steps.ts
nns 50ae8bd18b test(e2e): scenario reports-stats — дашбордная выручка + tenant-изоляция
5 шагов: stats считает только Posted-чеки (черновик исключён), агрегаты
RevenueToday/ThisMonth/AvgTicket и непрерывная серия по дням верны, параметр
days меняет длину серии, данные строго tenant-scoped (орг A ≠ орг B).
Профит по себестоимости, ABC и экспорт (ТЗ 2.12) зафиксированы как Logic
gaps — не реализованы (нет Cost-снимка в RetailSaleLine, нет ReportsController).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:18:54 +05:00

223 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Step-handlers для reports-stats.
*
* Источник правды по выручке — GET /api/sales/retail/stats. Проверяем что
* агрегаты считаются только по Posted-чекам, серия по дням непрерывна и
* длиной ровно `days`, и что данные строго tenant-scoped (орг A ≠ орг B).
* Профит по себестоимости и ABC-анализ пока не реализованы — фиксируем как
* Logic gaps.
*/
import { login, makeClient } from '../lib/api.js'
import { generateEan13 } from '../lib/barcode.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import type { AxiosInstance } from 'axios'
const TS = Date.now()
interface OrgBundle {
orgId: string
token: string
productId: string
storeId: string
retailPointId: string
currencyId: string
}
interface Ctx {
apiOnly: boolean
superAdminToken?: string
a?: OrgBundle
b?: OrgBundle
revenueA?: number
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
try { return JSON.stringify(x).slice(0, 200) } catch { return String(x) }
}
const approx = (a: number, b: number) => Math.abs(a - b) < 0.01
async function ensureSuperAdmin(ctx: Ctx): Promise<string> {
if (!ctx.superAdminToken) {
const sa = await login('admin@food-market.local', 'Admin12345!')
ctx.superAdminToken = sa.accessToken
}
return ctx.superAdminToken
}
// Полный bootstrap отдельной организации: товар + склад + точка + поставщик +
// приёмка на 100 ед (чтобы было что продавать).
async function bootstrapOrg(saToken: string, label: string): Promise<OrgBundle | undefined> {
const orgRes = await makeClient(saToken).post('/api/super-admin/organizations', {
org: {
name: `Reports ${label} ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: label, adminFirstName: 'Admin',
adminEmail: `reports-${label.toLowerCase()}-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) return undefined
const sess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
const api = makeClient(sess.accessToken)
const units = await api.get('/api/catalog/units-of-measure')
const unitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
let groupId = grps.data?.items?.[0]?.id
if (!groupId) groupId = (await api.post('/api/catalog/product-groups', { name: 'G', parentId: null })).data?.id
const cur = await api.get('/api/catalog/currencies?pageSize=200')
const currencyId = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const pt = await api.get('/api/catalog/price-types')
const retailPriceTypeId = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
const storeId = (await api.get('/api/catalog/stores?pageSize=10')).data?.items?.[0]?.id
const retailPointId = (await api.get('/api/catalog/retail-points?pageSize=10')).data?.items?.[0]?.id
const supplierId = (await api.post('/api/catalog/counterparties', {
name: `Sup ${label} ${TS}`, type: 1, bin: '987654321012', phone: '+77001112233',
})).data?.id
const productId = (await api.post('/api/catalog/products', {
name: `Prod ${label} ${TS}`, unitOfMeasureId: unitId, productGroupId: groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: retailPriceTypeId, currencyId, amount: 200 }],
})).data?.id
// Приёмка 100 ед, проводим.
const sup = await api.post('/api/purchases/supplies', {
storeId, supplierId, currencyId, date: new Date().toISOString(),
lines: [{ productId, quantity: 100, unitPrice: 100 }],
})
if (sup.status === 201) await api.post(`/api/purchases/supplies/${sup.data.id}/post`)
return { orgId: orgRes.data.organization.id, token: sess.accessToken, productId, storeId, retailPointId, currencyId }
}
// Проводит чек и возвращает его Total (из ответа Create).
async function postSale(b: OrgBundle, quantity: number, unitPrice: number): Promise<number | undefined> {
const api = makeClient(b.token)
const sale = await api.post('/api/sales/retail', {
storeId: b.storeId, retailPointId: b.retailPointId, currencyId: b.currencyId,
date: new Date().toISOString(),
lines: [{ productId: b.productId, quantity, unitPrice, vatPercent: 12 }],
paidCash: quantity * unitPrice * 2, paidCard: 0,
})
if (sale.status !== 201) return undefined
const total = Number(sale.data.total)
const p = await api.post(`/api/sales/retail/${sale.data.id}/post`)
return p.status >= 400 ? undefined : total
}
async function getStats(token: string, days?: number) {
const api: AxiosInstance = makeClient(token)
return api.get(`/api/sales/retail/stats${days ? `?days=${days}` : ''}`)
}
// ---------------------------------------------------------------------------
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
const sa = await ensureSuperAdmin(ctx)
ctx.a = await bootstrapOrg(sa, 'A')
check(step, { kind: 'api', description: 'Орг A с товаром и остатком готова',
ok: !!ctx.a?.productId, detail: ctx.a ? asString(ctx.a.orgId) : 'bootstrap failed' })
if (!ctx.a) report.bug({ step: '01', severity: 'critical', title: 'Bootstrap орг A не удался', detail: '' })
}
export async function step02_stats_reflect_posted_sales({ ctx, step, report }: StepCtx) {
if (!ctx.a) { step.status = 'skip'; return }
const totals: number[] = []
for (const [q, p] of [[3, 200], [2, 250], [1, 400]] as const) {
const t = await postSale(ctx.a, q, p)
if (t !== undefined) totals.push(t)
}
check(step, { kind: 'api', description: 'Проведено 3 чека', ok: totals.length === 3, detail: `totals=${totals.join(',')}` })
const expected = totals.reduce((a, b) => a + b, 0)
ctx.revenueA = expected
const r = await getStats(ctx.a.token)
check(step, { kind: 'api', description: 'GET stats → 200', ok: r.status === 200, detail: `status=${r.status}` })
if (r.status !== 200) return
const d = r.data
check(step, { kind: 'api', description: 'RevenueToday == Σ проведённых чеков',
ok: approx(Number(d.revenueToday), expected), detail: `today=${d.revenueToday} expected=${expected}` })
check(step, { kind: 'api', description: 'TransactionsToday == 3',
ok: d.transactionsToday === 3, detail: `tx=${d.transactionsToday}` })
check(step, { kind: 'api', description: 'RevenueThisMonth == RevenueToday (все чеки сегодня)',
ok: approx(Number(d.revenueThisMonth), expected), detail: `month=${d.revenueThisMonth}` })
check(step, { kind: 'api', description: 'AvgTicketThisMonth == Revenue/Transactions',
ok: approx(Number(d.avgTicketThisMonth), expected / 3), detail: `avg=${d.avgTicketThisMonth} expected=${(expected / 3).toFixed(2)}` })
// Серия по дням: непрерывна, длина = 30 (default), последний бакет = сегодня.
const series = d.series ?? []
check(step, { kind: 'api', description: 'Серия длиной 30 дней (default)',
ok: series.length === 30, detail: `len=${series.length}` })
const last = series[series.length - 1]
check(step, { kind: 'api', description: 'Последний бакет серии (сегодня) == RevenueToday',
ok: !!last && approx(Number(last.revenue), expected) && last.transactions === 3,
detail: `last={rev:${last?.revenue},tx:${last?.transactions}}` })
}
export async function step03_draft_sale_excluded({ ctx, step }: StepCtx) {
if (!ctx.a) { step.status = 'skip'; return }
const api = makeClient(ctx.a.token)
// Черновик: создаём, но НЕ проводим.
const draft = await api.post('/api/sales/retail', {
storeId: ctx.a.storeId, retailPointId: ctx.a.retailPointId, currencyId: ctx.a.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.a.productId, quantity: 5, unitPrice: 999, vatPercent: 12 }],
paidCash: 0, paidCard: 0,
})
check(step, { kind: 'api', description: 'Черновик чека создан (Status=Draft)',
ok: draft.status === 201, detail: `status=${draft.status}` })
const r = await getStats(ctx.a.token)
check(step, { kind: 'api', description: 'RevenueToday не изменился (черновик исключён)',
ok: approx(Number(r.data.revenueToday), ctx.revenueA ?? -1), detail: `today=${r.data.revenueToday} expected=${ctx.revenueA}` })
check(step, { kind: 'api', description: 'TransactionsToday остался 3',
ok: r.data.transactionsToday === 3, detail: `tx=${r.data.transactionsToday}` })
}
export async function step04_stats_tenant_isolated({ ctx, step, report }: StepCtx) {
if (!ctx.a) { step.status = 'skip'; return }
const sa = await ensureSuperAdmin(ctx)
ctx.b = await bootstrapOrg(sa, 'B')
check(step, { kind: 'api', description: 'Орг B готова', ok: !!ctx.b?.productId })
if (!ctx.b) return
const bTotal = await postSale(ctx.b, 4, 150)
check(step, { kind: 'api', description: 'В орг B проведён 1 чек', ok: bTotal !== undefined, detail: `total=${bTotal}` })
// Орг A — без изменений.
const ra = await getStats(ctx.a.token)
check(step, { kind: 'api', description: 'stats орг A не включает продажу орг B',
ok: approx(Number(ra.data.revenueToday), ctx.revenueA ?? -1) && ra.data.transactionsToday === 3,
detail: `A.today=${ra.data.revenueToday} A.tx=${ra.data.transactionsToday}` })
if (!approx(Number(ra.data.revenueToday), ctx.revenueA ?? -1) || ra.data.transactionsToday !== 3) {
report.bug({ step: '04', severity: 'critical', title: 'Утечка выручки между тенантами в /stats',
detail: `Орг A видит чужие продажи: today=${ra.data.revenueToday}, tx=${ra.data.transactionsToday}` })
}
// Орг B — видит только свой чек.
const rb = await getStats(ctx.b.token)
check(step, { kind: 'api', description: 'stats орг B == только её 1 чек',
ok: approx(Number(rb.data.revenueToday), Number(bTotal)) && rb.data.transactionsToday === 1,
detail: `B.today=${rb.data.revenueToday} B.tx=${rb.data.transactionsToday}` })
}
export async function step05_days_param_and_gaps({ ctx, step, report }: StepCtx) {
if (!ctx.a) { step.status = 'skip'; return }
const r7 = await getStats(ctx.a.token, 7)
check(step, { kind: 'api', description: 'days=7 → серия длиной 7',
ok: (r7.data.series?.length ?? 0) === 7, detail: `len=${r7.data.series?.length}` })
// Профит/ABC/остатки-на-дату/экспорт (ТЗ 2.12) — не реализованы. Проверяем,
// что отдельного /api/reports нет, и фиксируем как gap, а не баг.
const api = makeClient(ctx.a.token)
const profit = await api.get('/api/reports/profit')
const sales = await api.get('/api/reports/sales')
check(step, { kind: 'api', description: 'Отчётов /api/reports/* нет (ожидаемо 404)',
ok: profit.status === 404 && sales.status === 404, detail: `profit=${profit.status} sales=${sales.status}` })
report.gap('ТЗ 2.12: отчёт «прибыль» (выручка себестоимость по Cost-снимку RetailSaleLine) не реализован — RetailSaleLine не хранит снимок себестоимости, /stats отдаёт только валовую выручку.')
report.gap('ТЗ 2.12: ABC-анализ, «остатки на дату» (SUM Movement до даты) и экспорт CSV/XLSX отсутствуют — отдельного ReportsController нет.')
}