diff --git a/tests/e2e/scenarios/reports-stats.steps.ts b/tests/e2e/scenarios/reports-stats.steps.ts new file mode 100644 index 0000000..e51ea5a --- /dev/null +++ b/tests/e2e/scenarios/reports-stats.steps.ts @@ -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 { + 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 { + 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 { + 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 нет.') +} diff --git a/tests/e2e/scenarios/reports-stats.yml b/tests/e2e/scenarios/reports-stats.yml new file mode 100644 index 0000000..faf141b --- /dev/null +++ b/tests/e2e/scenarios/reports-stats.yml @@ -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)"