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>
223 lines
12 KiB
TypeScript
223 lines
12 KiB
TypeScript
/**
|
||
* 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 нет.')
|
||
}
|