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>
This commit is contained in:
nns 2026-05-26 11:18:54 +05:00
parent ad25e12ce4
commit 50ae8bd18b
2 changed files with 245 additions and 0 deletions

View file

@ -0,0 +1,222 @@
/**
* 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 нет.')
}

View file

@ -0,0 +1,23 @@
name: reports-stats
description: |
Раздел «Отчёты/прибыльность» ТЗ 2.12. Полноценные отчёты (профит по
себестоимости, ABC, остатки на дату, экспорт) пока НЕ реализованы — есть
только дашбордный агрегат GET /api/sales/retail/stats. Проверяем его
корректность и тенант-изоляцию, а нереализованное фиксируем как Logic gaps
(не баги), чтобы не выдавать отсутствие фичи за регресс.
preconditions:
reset_db: true
smoke_login_super_admin: true
steps:
- id: step01_bootstrap
title: "Орг A + товар + приёмка (остаток под продажи)"
- id: step02_stats_reflect_posted_sales
title: "stats: RevenueToday/Transactions/AvgTicket = сумме проведённых чеков, серия непрерывна"
- id: step03_draft_sale_excluded
title: "Черновик чека (не проведён) не попадает в stats"
- id: step04_stats_tenant_isolated
title: "stats орг A не видит продажи орг B и наоборот"
- id: step05_days_param_and_gaps
title: "Параметр days меняет длину серии; профит/ABC-отчёты отсутствуют (gap)"