diff --git a/tests/e2e/reports/documents-edge-2026-05-23T07-32-53-747Z.md b/tests/e2e/reports/documents-edge-2026-05-23T07-32-53-747Z.md new file mode 100644 index 0000000..dd0bd92 --- /dev/null +++ b/tests/e2e/reports/documents-edge-2026-05-23T07-32-53-747Z.md @@ -0,0 +1,105 @@ +# E2E report: documents-edge + +Запущен: 2026-05-23T07:32:43.038Z +Длительность: 7.8с + +**Итог:** 10 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 10) + +## ✓ Step step01_bootstrap: SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT) + +Длительность: 4463мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Орг + админ созданы | ✓ org=f08ddf4a-8b1f-47e6-8c77-9a819330266c | +| api | Counterparty создан | ✓ | +| api | Product создан | ✓ fbcffe23-a038-4c55-b68e-255a8fb06ebf | +| api | Supply Draft создана | ✓ afe0920c-f338-4009-9e65-9df63c55f995 | + +## ✓ Step step02_post_supply_stock_10: Supply провести: stock=10, ReferencePrice=100, Cost=100 + +Длительность: 634мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Supply.Post → 200/204 | ✓ actual=204 | +| db | Stock.Quantity == 10 | ✓ qty=10 | + +## ✓ Step step03_oversell_blocked: RetailSale qty=15 (больше остатка 10), POST /post возвращает 409 + +Длительность: 812мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST RetailSale Draft (qty=15) | ✓ actual=201 {"id":"2d69366b-c3b3-49fb-a230-fc5e6bc49ce5","number":"ПР-2026-000001","date":"2026-05-23T07:32:51Z","status":0,"storeId | +| api | POST /post → 409 (oversell) | ✓ actual=409 {"error":"Недостаточно остатка для проведения чека.","lines":[{"productId":"fbcffe23-a038-4c55-b68e-255a8fb06ebf","produ | + +## ✓ Step step04_oversell_stock_unchanged: После заблокированного post stock остался 10, StockMovement не добавлен + +Длительность: 328мс + +| Тип | Проверка | Результат | +|---|---|---| +| db | Stock остался 10 после заблокированного post | ✓ qty=10 | +| db | Stock == Σ StockMovement (invariant) | ✓ sum=10 qty=10 | + +## ✓ Step step05_payment_mismatch_blocked: RetailSale с PaidCash+PaidCard не равной Total отвергается на post + +Длительность: 79мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Платёж ≠ Total → 4xx на post | ✓ actual=400 {"error":"Сумма оплаты 300.00 меньше итога 400.00. Доплатите или измените позиции чека.","field":"PaidCash"} | + +## ✓ Step step06_edit_posted_supply_blocked: PUT проведённой Supply (Posted) возвращает 409 + +Длительность: 114мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | PUT проведённой Supply → 409 | ✓ actual=409 {"error":"Только черновик может быть изменён. Сначала отмени проведение."} | + +## ✓ Step step07_delete_posted_supply_blocked: DELETE проведённой Supply возвращает 409 + +Длительность: 42мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | DELETE проведённой Supply → 409 | ✓ actual=409 {"error":"Нельзя удалить проведённый документ. Сначала отмени проведение."} | + +## ✓ Step step08_unpost_negative_blocked: После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус) + +Длительность: 195мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Sale qty=5 проведён | ✓ actual=204 | +| api | Unpost Supply при stock { + 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}` }) +} diff --git a/tests/e2e/scenarios/documents-edge.yml b/tests/e2e/scenarios/documents-edge.yml new file mode 100644 index 0000000..e509791 --- /dev/null +++ b/tests/e2e/scenarios/documents-edge.yml @@ -0,0 +1,32 @@ +name: documents-edge +description: | + Краевые случаи документов: защита от overselling, запреты на изменение + проведённых документов, валидация платежей, уникальность штрихкодов + per-tenant. Это сценарий регрессии для самой опасной зоны — потеря + денег/остатков при ошибках в RetailSale/Supply посту. + +preconditions: + reset_db: true + smoke_login_super_admin: true + +steps: + - id: step01_bootstrap + title: "SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT)" + - id: step02_post_supply_stock_10 + title: "Supply провести: stock=10, ReferencePrice=100, Cost=100" + - id: step03_oversell_blocked + title: "RetailSale qty=15 (больше остатка 10), POST /post возвращает 409" + - id: step04_oversell_stock_unchanged + title: "После заблокированного post stock остался 10, StockMovement не добавлен" + - id: step05_payment_mismatch_blocked + title: "RetailSale с PaidCash+PaidCard не равной Total отвергается на post" + - id: step06_edit_posted_supply_blocked + title: "PUT проведённой Supply (Posted) возвращает 409" + - id: step07_delete_posted_supply_blocked + title: "DELETE проведённой Supply возвращает 409" + - id: step08_unpost_negative_blocked + title: "После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус)" + - id: step09_barcode_unique_within_org + title: "Дубль штрихкода в одной орге, POST второго product отвергается" + - id: step10_barcode_per_tenant + title: "Тот же штрихкод в другой орге допустим (per-tenant unique)"