- auth-edge (10 шагов): refresh rotation/redemption, подделка JWT, деактивированный user, архивная орг, повторный/orphan signup. - catalog-edge (12 шагов): валидация товара, дубль артикула, удаление групп/единиц/системных типов цен с зависимостями, FK-guard контрагента. - stock-invariant-deep (10 шагов): инвариант Stock == SUM(StockMovement) через post/unpost/repost и конкурентные продажи. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
/**
|
||
* Step-handlers для stock-invariant-deep.
|
||
*
|
||
* Главный инвариант розничного учёта: для любого (ProductId, StoreId) сумма
|
||
* StockMovement.Quantity точно равна materialized Stock.Quantity. Любое
|
||
* расхождение = bug в Supply.Post / RetailSale.Post / Unpost — потеря товара
|
||
* или ложные минусовые/завышенные остатки.
|
||
*
|
||
* Дополнительно проверяем что Serializable-транзакция RetailSale.Post
|
||
* отбивает гонку: два кассира одновременно проводят чеки на один и тот же
|
||
* остаток — должен пройти ровно один.
|
||
*/
|
||
import { login, makeClient } from '../lib/api.js'
|
||
import { psql } from '../lib/db.js'
|
||
import { generateEan13 } from '../lib/barcode.js'
|
||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||
|
||
const TS = Date.now()
|
||
|
||
interface Ctx {
|
||
apiOnly: boolean
|
||
superAdminToken?: string
|
||
adminToken?: string
|
||
productId?: string
|
||
storeId?: string
|
||
retailPointId?: string
|
||
supplierId?: string
|
||
currencyId?: string
|
||
retailPriceTypeId?: string
|
||
supplyA?: string
|
||
supplyB?: string
|
||
saleA?: string
|
||
saleB?: string
|
||
}
|
||
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) }
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function dbQty(productId: string, storeId: string): number {
|
||
const out = psql(`SELECT "Quantity" FROM stocks
|
||
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
|
||
return Number((out.split('\n')[0] || '0')) || 0
|
||
}
|
||
|
||
function dbMovementSum(productId: string, storeId: string): number {
|
||
const out = psql(`SELECT COALESCE(SUM("Quantity"),0)::text FROM stock_movements
|
||
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
|
||
return Number((out.split('\n')[0] || '0')) || 0
|
||
}
|
||
|
||
function dbMovementCount(productId: string, storeId: string): number {
|
||
const out = psql(`SELECT count(*)::text FROM stock_movements
|
||
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
|
||
return Number((out.split('\n')[0] || '0')) || 0
|
||
}
|
||
|
||
function assertInvariant(step: Step, productId: string, storeId: string, expectedQty: number) {
|
||
const stock = dbQty(productId, storeId)
|
||
const sum = dbMovementSum(productId, storeId)
|
||
check(step, { kind: 'db', description: `Stock.Quantity == ${expectedQty}`,
|
||
ok: stock === expectedQty, detail: `actual=${stock}` })
|
||
check(step, { kind: 'db', description: 'Stock.Quantity == Σ StockMovement (invariant)',
|
||
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step01_bootstrap({ ctx, step, report }: StepCtx) {
|
||
const sa = await ensureSuperAdmin(ctx)
|
||
const orgRes = await makeClient(sa).post('/api/super-admin/organizations', {
|
||
org: {
|
||
name: `Stock Inv ${TS}`, countryCode: 'KZ',
|
||
bin: null, address: null, phone: null, email: null,
|
||
defaultCurrencyId: null, accountOwnerUserId: null,
|
||
},
|
||
adminLastName: 'Stock', adminFirstName: 'Admin',
|
||
adminEmail: `stock-inv-${TS}@example.kz`, adminPosition: null,
|
||
})
|
||
if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical',
|
||
title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return }
|
||
const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
|
||
ctx.adminToken = adminSess.accessToken
|
||
const api = makeClient(ctx.adminToken)
|
||
|
||
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) {
|
||
const g = await api.post('/api/catalog/product-groups', { name: 'G', parentId: null })
|
||
groupId = g.data?.id
|
||
}
|
||
const cur = await api.get('/api/catalog/currencies?pageSize=200')
|
||
ctx.currencyId = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
|
||
const pt = await api.get('/api/catalog/price-types')
|
||
ctx.retailPriceTypeId = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
|
||
const stores = await api.get('/api/catalog/stores?pageSize=10')
|
||
ctx.storeId = stores.data?.items?.[0]?.id
|
||
const rps = await api.get('/api/catalog/retail-points?pageSize=10')
|
||
ctx.retailPointId = rps.data?.items?.[0]?.id
|
||
|
||
const cp = await api.post('/api/catalog/counterparties', {
|
||
name: `Inv Supplier ${TS}`, type: 1, bin: '987654321012', phone: '+77001112233',
|
||
})
|
||
ctx.supplierId = cp.data?.id
|
||
|
||
const prod = await api.post('/api/catalog/products', {
|
||
name: `Inv Product ${TS}`, unitOfMeasureId: unitId, productGroupId: groupId,
|
||
vat: 12, vatEnabled: true,
|
||
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 200 }],
|
||
})
|
||
ctx.productId = prod.data?.id
|
||
check(step, { kind: 'api', description: 'Bootstrap product создан',
|
||
ok: prod.status === 201, detail: ctx.productId ?? asString(prod.data) })
|
||
|
||
// Стартовый стейт.
|
||
assertInvariant(step, ctx.productId!, ctx.storeId!, 0)
|
||
}
|
||
|
||
async function postSupply(ctx: Ctx, quantity: number, unitPrice: number): Promise<string | undefined> {
|
||
const api = makeClient(ctx.adminToken!)
|
||
const sup = await api.post('/api/purchases/supplies', {
|
||
storeId: ctx.storeId, supplierId: ctx.supplierId, currencyId: ctx.currencyId,
|
||
date: new Date().toISOString(),
|
||
lines: [{ productId: ctx.productId, quantity, unitPrice }],
|
||
})
|
||
if (sup.status !== 201) return undefined
|
||
const p = await api.post(`/api/purchases/supplies/${sup.data.id}/post`)
|
||
if (p.status >= 400) return undefined
|
||
return sup.data.id
|
||
}
|
||
|
||
async function postSale(ctx: Ctx, quantity: number, unitPrice: number): Promise<string | undefined> {
|
||
const api = makeClient(ctx.adminToken!)
|
||
const sale = await api.post('/api/sales/retail', {
|
||
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
|
||
date: new Date().toISOString(),
|
||
lines: [{ productId: ctx.productId, quantity, unitPrice, vatPercent: 12 }],
|
||
paidCash: quantity * unitPrice, paidCard: 0,
|
||
})
|
||
if (sale.status !== 201) return undefined
|
||
const p = await api.post(`/api/sales/retail/${sale.data.id}/post`)
|
||
if (p.status >= 400) return undefined
|
||
return sale.data.id
|
||
}
|
||
|
||
export async function step02_supply_a_qty_20({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
ctx.supplyA = await postSupply(ctx, 20, 100)
|
||
check(step, { kind: 'api', description: 'Supply A qty=20 проведена', ok: !!ctx.supplyA })
|
||
assertInvariant(step, ctx.productId, ctx.storeId!, 20)
|
||
}
|
||
|
||
export async function step03_sale_a_qty_5({ ctx, step }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
ctx.saleA = await postSale(ctx, 5, 200)
|
||
check(step, { kind: 'api', description: 'Sale A qty=5 проведена', ok: !!ctx.saleA })
|
||
assertInvariant(step, ctx.productId, ctx.storeId!, 15)
|
||
}
|
||
|
||
export async function step04_supply_b_qty_10({ ctx, step }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
ctx.supplyB = await postSupply(ctx, 10, 120)
|
||
check(step, { kind: 'api', description: 'Supply B qty=10 проведена', ok: !!ctx.supplyB })
|
||
assertInvariant(step, ctx.productId, ctx.storeId!, 25)
|
||
}
|
||
|
||
export async function step05_sale_b_qty_8({ ctx, step }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
ctx.saleB = await postSale(ctx, 8, 200)
|
||
check(step, { kind: 'api', description: 'Sale B qty=8 проведена', ok: !!ctx.saleB })
|
||
assertInvariant(step, ctx.productId, ctx.storeId!, 17)
|
||
}
|
||
|
||
export async function step06_unpost_sale_a({ ctx, step }: StepCtx) {
|
||
if (!ctx.saleA) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken!)
|
||
const r = await api.post(`/api/sales/retail/${ctx.saleA}/unpost`)
|
||
check(step, { kind: 'api', description: 'Unpost Sale A → 200/204',
|
||
ok: r.status === 200 || r.status === 204, detail: `status=${r.status}` })
|
||
assertInvariant(step, ctx.productId!, ctx.storeId!, 22)
|
||
}
|
||
|
||
export async function step07_repost_sale_a({ ctx, step }: StepCtx) {
|
||
if (!ctx.saleA) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken!)
|
||
const r = await api.post(`/api/sales/retail/${ctx.saleA}/post`)
|
||
check(step, { kind: 'api', description: 'Re-post Sale A → 200/204',
|
||
ok: r.status === 200 || r.status === 204, detail: `status=${r.status}` })
|
||
assertInvariant(step, ctx.productId!, ctx.storeId!, 17)
|
||
}
|
||
|
||
export async function step08_movement_count_correct({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
const cnt = dbMovementCount(ctx.productId, ctx.storeId!)
|
||
// 2 supply + 2 sale + 1 reverse(unpost-sale-A) + 1 re-post sale-A = 6 строк.
|
||
// Реализация может писать дополнительные movement'ы (например, при unpost
|
||
// sale-A пишется reversal с положительным qty; при re-post — обычный negative).
|
||
// Минимум 4 (по одному на каждый posted документ) и не больше 8.
|
||
check(step, { kind: 'db', description: 'StockMovement содержит как минимум 4 строки',
|
||
ok: cnt >= 4, detail: `count=${cnt}` })
|
||
check(step, { kind: 'db', description: 'StockMovement не более 8 строк (нет лишних дублей)',
|
||
ok: cnt <= 8, detail: `count=${cnt}` })
|
||
if (cnt > 8) report.gap(`StockMovement аномально большой: ${cnt} строк после 4 documents + 1 unpost + 1 re-post.`)
|
||
}
|
||
|
||
export async function step09_concurrent_sales_serialized({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
// Сейчас остаток 17. Создаём два Draft чека по qty=10 каждый, проводим
|
||
// ОДНОВРЕМЕННО — один должен 200, второй 409 (Serializable).
|
||
const api = makeClient(ctx.adminToken!)
|
||
const mk = async () => {
|
||
const sale = await api.post('/api/sales/retail', {
|
||
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
|
||
date: new Date().toISOString(),
|
||
lines: [{ productId: ctx.productId, quantity: 10, unitPrice: 200, vatPercent: 12 }],
|
||
paidCash: 2000, paidCard: 0,
|
||
})
|
||
return sale.data?.id as string
|
||
}
|
||
const a = await mk()
|
||
const b = await mk()
|
||
check(step, { kind: 'api', description: 'Два Draft созданы', ok: !!a && !!b })
|
||
|
||
const [r1, r2] = await Promise.all([
|
||
api.post(`/api/sales/retail/${a}/post`),
|
||
api.post(`/api/sales/retail/${b}/post`),
|
||
])
|
||
const statuses = [r1.status, r2.status].sort()
|
||
// Ожидаем: один 200/204, второй 409 (или 500 если Serializable retry не настроен).
|
||
const success = statuses.filter(s => s >= 200 && s < 300).length
|
||
const fail = statuses.filter(s => s >= 400).length
|
||
check(step, { kind: 'api', description: 'Ровно один post 2xx, второй 4xx (5xx)',
|
||
ok: success === 1 && fail === 1, detail: `statuses=${statuses.join(',')}` })
|
||
|
||
// Stock не должен уйти в минус ни в каком случае.
|
||
const stock = dbQty(ctx.productId, ctx.storeId!)
|
||
check(step, { kind: 'db', description: 'Stock >= 0 (не минус из-за гонки)',
|
||
ok: stock >= 0, detail: `stock=${stock}` })
|
||
if (stock < 0) report.bug({ step: '09', severity: 'critical',
|
||
title: 'Конкурентный RetailSale.Post привёл к отрицательному Stock',
|
||
detail: `stock=${stock}. Serializable-транзакция не сработала или dropped.` })
|
||
if (success === 2) report.bug({ step: '09', severity: 'critical',
|
||
title: 'Оба POST /post прошли при остатке < ΣQty',
|
||
detail: 'Гонка не отбита — потеря товара.' })
|
||
// Инвариант после гонки тоже сохраняется.
|
||
const sum = dbMovementSum(ctx.productId, ctx.storeId!)
|
||
check(step, { kind: 'db', description: 'Stock == Σ Movement после гонки',
|
||
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
|
||
}
|
||
|
||
export async function step10_final_invariant({ ctx, step }: StepCtx) {
|
||
if (!ctx.productId) { step.status = 'skip'; return }
|
||
const stock = dbQty(ctx.productId, ctx.storeId!)
|
||
const sum = dbMovementSum(ctx.productId, ctx.storeId!)
|
||
check(step, { kind: 'db', description: 'Финальный invariant Stock == Σ Movement',
|
||
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
|
||
}
|