/** * Step-handlers для documents-edge. Самые опасные edge-кейсы документов: * - oversell-protection (stock не уходит в минус), * - immutability проведённых документов, * - валидация платежа, * - уникальность штрихкодов per-tenant. * * Любая утечка/обход — критический баг. */ import { login, makeClient } from '../lib/api.js' import { countRows, 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 orgId?: string adminToken?: string unitId?: string groupId?: string currencyId?: string retailPriceTypeId?: string storeId?: string retailPointId?: string supplierId?: string productId?: string productBarcode?: string supplyId?: 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, 220) } catch { return String(x) } } 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 } // --------------------------------------------------------------------------- export async function step01_bootstrap({ ctx, step, report }: StepCtx) { const sa = await ensureSuperAdmin(ctx) const saApi = makeClient(sa) // 1. Создаём орг + админа. const orgRes = await saApi.post('/api/super-admin/organizations', { org: { name: `Edge Shop ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null, }, adminLastName: 'Edge', adminFirstName: 'Admin', adminEmail: `edge-${TS}@example.kz`, adminPosition: 'Директор', }) if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return } ctx.orgId = orgRes.data.organization.id const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword) ctx.adminToken = adminSess.accessToken check(step, { kind: 'api', description: 'Орг + админ созданы', ok: true, detail: `org=${ctx.orgId}` }) const api = makeClient(ctx.adminToken) // 2. Lookups. const units = await api.get('/api/catalog/units-of-measure') ctx.unitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id const grps = await api.get('/api/catalog/product-groups?pageSize=10') ctx.groupId = grps.data?.items?.[0]?.id if (!ctx.groupId) { const g = await api.post('/api/catalog/product-groups', { name: 'Group', parentId: null }) ctx.groupId = g.data?.id } const currencies = await api.get('/api/catalog/currencies?pageSize=200') ctx.currencyId = currencies.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id const priceTypes = await api.get('/api/catalog/price-types') ctx.retailPriceTypeId = priceTypes.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id // 3. Counterparty (поставщик). const cp = await api.post('/api/catalog/counterparties', { name: 'Edge Supplier', type: 1, bin: '012345678901', phone: '+77001234567', }) ctx.supplierId = cp.data?.id check(step, { kind: 'api', description: 'Counterparty создан', ok: cp.status === 201 }) // 4. Store + RetailPoint из bootstrap. 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 // 5. Product. ctx.productBarcode = generateEan13() const prod = await api.post('/api/catalog/products', { name: `Edge Product ${TS}`, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId, vat: 12, vatEnabled: true, barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }], prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 200 }], }) ctx.productId = prod.data?.id check(step, { kind: 'api', description: 'Product создан', ok: prod.status === 201, detail: prod.status === 201 ? ctx.productId : asString(prod.data) }) // 6. Supply Draft (10 шт по 100 KZT). const supplyRes = await api.post('/api/purchases/supplies', { storeId: ctx.storeId, supplierId: ctx.supplierId, currencyId: ctx.currencyId, date: new Date().toISOString(), lines: [{ productId: ctx.productId, quantity: 10, unitPrice: 100 }], }) ctx.supplyId = supplyRes.data?.id check(step, { kind: 'api', description: 'Supply Draft создана', ok: supplyRes.status === 201, detail: supplyRes.status === 201 ? ctx.supplyId : asString(supplyRes.data) }) } export async function step02_post_supply_stock_10({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.supplyId || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`) check(step, { kind: 'api', description: 'Supply.Post → 200/204', ok: post.status === 200 || post.status === 204, detail: `actual=${post.status}` }) // Проверяем что stock=10 const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`) const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId) const qty = Number(row?.quantity ?? 0) check(step, { kind: 'db', description: 'Stock.Quantity == 10', ok: qty === 10, detail: `qty=${qty}` }) if (qty !== 10) report.bug({ step: '02', severity: 'high', title: 'Stock после Supply.Post не равен 10', detail: `получено qty=${qty}` }) } export async function step03_oversell_blocked({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Создаём чек с qty=15 (превышает stock=10). 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: 15, unitPrice: 200, vatPercent: 12 }], paidCash: 3000, paidCard: 0, }) check(step, { kind: 'api', description: 'POST RetailSale Draft (qty=15)', ok: sale.status === 201, detail: `actual=${sale.status} ${asString(sale.data).slice(0, 120)}` }) if (sale.status !== 201) return const post = await api.post(`/api/sales/retail/${sale.data.id}/post`) const ok = post.status === 409 check(step, { kind: 'api', description: 'POST /post → 409 (oversell)', ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` }) if (!ok && post.status >= 200 && post.status < 300) { report.bug({ step: '03', severity: 'critical', title: 'OVERSELL: stock уходит в минус — продажа qty>остаток прошла', detail: `Post вернул ${post.status}. Нужен server-side guard: SUM(line.qty) <= stock.quantity per (product,store).` }) } } export async function step04_oversell_stock_unchanged({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`) const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId) const qty = Number(row?.quantity ?? 0) check(step, { kind: 'db', description: 'Stock остался 10 после заблокированного post', ok: qty === 10, detail: `qty=${qty}` }) // Также: invariant Stock == Σ Movement if (ctx.productId && ctx.storeId) { const movSum = psql( `SELECT COALESCE(SUM("Quantity"),0)::text FROM stock_movements WHERE "ProductId"='${ctx.productId}' AND "StoreId"='${ctx.storeId}'`, ).trim() const sumNum = Number(movSum.split('\n')[0] || '0') check(step, { kind: 'db', description: 'Stock == Σ StockMovement (invariant)', ok: sumNum === qty, detail: `sum=${sumNum} qty=${qty}` }) } } export async function step05_payment_mismatch_blocked({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return } 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: 2, unitPrice: 200, vatPercent: 12 }], // Total = 400, но оплачено только 300 — должен отвергаться на post. paidCash: 100, paidCard: 200, }) if (sale.status !== 201) { check(step, { kind: 'api', description: 'Draft создан', ok: false, detail: asString(sale.data) }) return } const post = await api.post(`/api/sales/retail/${sale.data.id}/post`) const ok = post.status >= 400 && post.status < 500 check(step, { kind: 'api', description: 'Платёж ≠ Total → 4xx на post', ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` }) if (!ok) report.bug({ step: '05', severity: 'high', title: 'Проведение чека с неверной суммой платежа разрешено', detail: `Сумма оплаты 300 при Total=400 — post вернул ${post.status}.` }) } export async function step06_edit_posted_supply_blocked({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Supply уже Posted в step02. const cur = await api.get(`/api/purchases/supplies/${ctx.supplyId}`) if (cur.status !== 200) { step.status = 'skip'; return } const put = await api.put(`/api/purchases/supplies/${ctx.supplyId}`, { ...cur.data, date: cur.data.date, lines: (cur.data.lines || []).map((l: { productId: string; quantity: number; unitPrice: number }) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice + 1 })), }) const ok = put.status === 409 check(step, { kind: 'api', description: 'PUT проведённой Supply → 409', ok, detail: `actual=${put.status} ${asString(put.data).slice(0, 100)}` }) if (!ok && put.status >= 200 && put.status < 300) report.bug({ step: '06', severity: 'high', title: 'Posted Supply можно изменить через PUT', detail: asString(put.data) }) } export async function step07_delete_posted_supply_blocked({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const del = await api.delete(`/api/purchases/supplies/${ctx.supplyId}`) const ok = del.status === 409 check(step, { kind: 'api', description: 'DELETE проведённой Supply → 409', ok, detail: `actual=${del.status} ${asString(del.data).slice(0, 100)}` }) if (!ok && del.status >= 200 && del.status < 300) report.bug({ step: '07', severity: 'high', title: 'Posted Supply можно удалить', detail: asString(del.data) }) } export async function step08_unpost_negative_blocked({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId || !ctx.supplyId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Сначала продаём qty=5 (валидно: stock=10 → станет 5). 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: 5, unitPrice: 200, vatPercent: 12 }], paidCash: 1000, paidCard: 0, }) if (sale.status !== 201) { step.status = 'skip'; step.notes.push(`Draft sale fail: ${asString(sale.data)}`); return } const postSale = await api.post(`/api/sales/retail/${sale.data.id}/post`) check(step, { kind: 'api', description: 'Sale qty=5 проведён', ok: postSale.status === 200 || postSale.status === 204, detail: `actual=${postSale.status}` }) if (postSale.status >= 400) return // Stock теперь 5. Unpost Supply (qty=10) уведёт stock в -5 → должен 409. const unpost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/unpost`) const ok = unpost.status === 409 check(step, { kind: 'api', description: 'Unpost Supply при stock= 200 && unpost.status < 300) { report.bug({ step: '08', severity: 'critical', title: 'Unpost Supply уводит Stock в минус (нет защиты)', detail: `После Sale qty=5 stock=5. Unpost Supply qty=10 ⇒ stock=-5. Got HTTP ${unpost.status}.` }) } else if (unpost.status >= 400 && unpost.status < 500 && unpost.status !== 409) { // Сервер тоже отверг, но другим кодом — это semantic gap. report.gap(`Unpost Supply при отрицательном остатке вернул ${unpost.status} вместо 409. Ожидался Conflict — единый код для бизнес-конфликтов.`) } } export async function step09_barcode_unique_within_org({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.productBarcode || !ctx.unitId || !ctx.groupId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const dup = await api.post('/api/catalog/products', { name: 'Дубль-штрихкод', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId, vat: 12, vatEnabled: true, barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }], prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 300 }], }) const ok = dup.status === 409 || dup.status === 400 check(step, { kind: 'api', description: 'POST product с тем же barcode → 4xx', ok, detail: `actual=${dup.status} ${asString(dup.data).slice(0, 120)}` }) if (!ok && dup.status >= 200 && dup.status < 300) report.bug({ step: '09', severity: 'high', title: 'Дубль штрихкода в одной орге разрешён', detail: `POST вернул ${dup.status}. Нарушает уникальный индекс per (OrgId, Barcode).` }) } export async function step10_barcode_per_tenant({ ctx, step, report }: StepCtx) { if (!ctx.productBarcode) { step.status = 'skip'; return } // Создаём другую орг и пытаемся использовать тот же штрихкод. const sa = await ensureSuperAdmin(ctx) const saApi = makeClient(sa) const orgRes = await saApi.post('/api/super-admin/organizations', { org: { name: `Edge Shop2 ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null, }, adminLastName: 'Edge2', adminFirstName: 'Admin', adminEmail: `edge2-${TS}@example.kz`, adminPosition: null, }) if (orgRes.status !== 200) { step.status = 'skip'; step.notes.push('Не удалось создать вторую орг'); return } const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword) const api2 = makeClient(adminSess.accessToken) // Lookups для новой орги const units = await api2.get('/api/catalog/units-of-measure') const u2 = units.data?.items?.[0]?.id ?? units.data?.[0]?.id const grps = await api2.get('/api/catalog/product-groups?pageSize=10') let g2 = grps.data?.items?.[0]?.id if (!g2) { const g = await api2.post('/api/catalog/product-groups', { name: 'G2', parentId: null }) g2 = g.data?.id } const cur = await api2.get('/api/catalog/currencies?pageSize=200') const c2 = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id const pt = await api2.get('/api/catalog/price-types') const r2 = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id const prod = await api2.post('/api/catalog/products', { name: 'Tenant-2 product (same barcode)', unitOfMeasureId: u2, productGroupId: g2, vat: 12, vatEnabled: true, barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }], prices: [{ priceTypeId: r2, currencyId: c2, amount: 250 }], }) check(step, { kind: 'api', description: 'POST product с тем же barcode в другой орге → 201', ok: prod.status === 201, detail: `actual=${prod.status} ${asString(prod.data).slice(0, 120)}` }) if (prod.status !== 201) report.bug({ step: '10', severity: 'medium', title: 'Per-tenant уникальность штрихкода не работает', detail: `Уникальный индекс должен быть на (OrganizationId, Code), сейчас отвергает межтенантное переиспользование. Got ${prod.status}.` }) // Подсчитаем общее число с этим штрихкодом const totalRows = countRows('product_barcodes', `"Code"='${ctx.productBarcode}'`) check(step, { kind: 'db', description: 'В product_barcodes 2 записи с этим Code (одна на орг)', ok: totalRows === 2, detail: `count=${totalRows}` }) }