From 37cd9aa94bdd0dabc4cbb0fcb4636f42170eae57 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 8 May 2026 11:01:56 +0500 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20supply/sale=20+=20EAN-13=20+=20bug-hunt=20+=20fu?= =?UTF-8?q?ll-pass=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контракты до фикса не совпадали с реальными: - Product: unitId/groupId/retailPrice → unitOfMeasureId/productGroupId/prices[], плюс обязательный barcodes[] (генерим валидный EAN-13). - Supply: counterpartyId/docDate/lines.price → supplierId/date/lines.unitPrice, плюс обязательный currencyId. - RetailSale: путь /api/sales/retail-sales 404 → /api/sales/retail; payload обновлён под RetailSaleInput (storeId, currencyId, payment, paidCash и т.п.). Шаги 9-12 теперь полностью проходят (не skip). Добавлены deep-bug-hunt'ы: - Supply без supplierId / с пустым lines[] - двойной post Supply / RetailSale → 409 - stock_movements vs Stocks.Quantity консистентность - RetailPoint с несуществующим storeId - продажа qty>остатка (выявил блокирующий баг — продаёт) - discount на line, отрицательные qty/price - stock_movements.Type = RetailSale (2) Отчёт: tests/e2e/reports/full-cycle-2026-05-08-full-pass.md Финальный счёт 10 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ — две ✗ это РЕАЛЬНЫЕ баги: [HIGH] step11 oversell проходит /post (нужна валидация qty≤stock) [MEDIUM] step08 Supply без supplierId → 500 вместо 400 --- tests/e2e/lib/barcode.ts | 28 + .../full-cycle-2026-05-08-full-pass.md | 165 ++++++ tests/e2e/scenarios/full-cycle.steps.ts | 491 +++++++++++++++--- 3 files changed, 614 insertions(+), 70 deletions(-) create mode 100644 tests/e2e/lib/barcode.ts create mode 100644 tests/e2e/reports/full-cycle-2026-05-08-full-pass.md diff --git a/tests/e2e/lib/barcode.ts b/tests/e2e/lib/barcode.ts new file mode 100644 index 0000000..1f0e64d --- /dev/null +++ b/tests/e2e/lib/barcode.ts @@ -0,0 +1,28 @@ +/** + * EAN-13 generator для e2e: префикс "20" (внутренний магазинный, как и + * в src/food-market.web/src/lib/barcode.ts), 10 цифр payload + checksum. + * Использует индекс шага вместо рандома, чтобы прогон был детерминированным + * и легче отлавливать дубликаты в отчёте. + */ + +function ean13Checksum(twelve: string): number { + let sum = 0 + for (let i = 0; i < 12; i++) { + const d = twelve.charCodeAt(i) - 48 + sum += i % 2 === 0 ? d : d * 3 + } + return (10 - (sum % 10)) % 10 +} + +/** + * Сгенерировать валидный EAN-13. Префикс "20" — резерв для внутренних + * штрихкодов магазина (in-store). Тело: timestamp.slice(-7) + index в + * 3-значной форме, итого 10 цифр payload. Получаем 13-значный код с + * контрольной суммой. + */ +export function generateEan13(index: number): string { + const ts = Date.now().toString().slice(-7) + const idx = String(index).padStart(3, '0') + const body = '20' + ts + idx + return body + ean13Checksum(body).toString() +} diff --git a/tests/e2e/reports/full-cycle-2026-05-08-full-pass.md b/tests/e2e/reports/full-cycle-2026-05-08-full-pass.md new file mode 100644 index 0000000..49e5967 --- /dev/null +++ b/tests/e2e/reports/full-cycle-2026-05-08-full-pass.md @@ -0,0 +1,165 @@ +# E2E report: full-cycle + +Запущен: 2026-05-08T05:33:45.050Z +Длительность: 9.7с + +**Итог:** 10 ✓ / 2 ✗ / 0 ⚠ / 0 ◯ (всего 12) + +## ✓ Step step01_create_organization: SuperAdmin создаёт «Test Shop {timestamp}» (KZ, KZT, ФЛК телефона) + +Длительность: 1316мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/super-admin/organizations → 200 | ✓ org=Test Shop 1778218425049 | +| api | GET /api/super-admin/organizations включает созданную org | ✓ | +| api | Невалидный phone отвергается | ✓ 400 | + +## ✓ Step step02_create_first_admin: SuperAdmin создаёт первого Admin сотрудника организации (Employee + AppUser) + +Длительность: 619мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Temp password возвращён CreateOrgResult | ✓ len=12 | +| db | employees содержит ровно 1 запись для новой org | ✓ count=1 | +| db | AspNetUserRoles содержит role=Admin для нового user | ✓ Admin | + +## ✓ Step step03_login_as_admin: Логин под admin (не SuperAdmin override) — JWT с org_id и role=Admin + +Длительность: 540мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /connect/token password-grant выдал токен | ✓ | +| api | /api/me содержит role=Admin | ✓ Admin | +| api | /api/me содержит правильный orgId | ✓ 13ed4954-afeb-47fe-8d4a-7070758c7158 | + +## ✓ Step step04_create_storekeeper_and_cashier: Admin создаёт Storekeeper и Cashier через /settings/employees + +Длительность: 1318мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | employee-roles list | ✓ 200, total=3 | +| api | Системная роль «Кладовщик» существует | ✓ | +| api | Системная роль «Кассир» существует | ✓ | +| api | POST /api/organization/employees (Кладовщик) | ✓ 200 | +| api | POST /api/organization/employees (Кассир) | ✓ 200 | +| db | employees total = 3 (admin + keeper + cashier) | ✓ count=3 | +| api | Невалидный email отвергается при createAccount | ✓ 400 | + +## ✓ Step step05_login_as_cashier: Логин под Cashier — role-guard проверяется (sidebar/role guard) + +Длительность: 671мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | /api/me содержит роль соответствующую системной Cashier | ✓ Cashier | +| api | Cashier → GET /api/organization/employees → 403 | ✓ 403 | +| api | Cashier → GET /api/sales/retail — доступен | ✓ 200 | + +## ✓ Step step06_create_counterparty: Admin создаёт «ТОО Тест Поставщик» (БИН + телефон) + +Длительность: 182мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | POST /api/catalog/counterparties | ✓ 201 | + +## ✓ Step step07_ensure_main_store: Проверить что есть main store (из bootstrap), иначе создать + +Длительность: 84мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /api/catalog/stores | ✓ 200 | +| db | Main store существует (от bootstrap) | ✓ Основной склад | + +## ✗ Step step08_create_supply: Admin создаёт Supply Draft (3-5 товаров) и проводит (Posted) + +Длительность: 2005мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Создано 3 product (валидный barcode + price + group) | ✓ e2e Product 1 1778218425049, e2e Product 2 1778218425049, e2e Product 3 1778218425049 | +| api | Supply без supplierId → 400/409 | ✗ 500 | +| api | Supply с пустым lines[] → 400 | ✓ 400 | +| api | POST /api/purchases/supplies (Draft) | ✓ 201 | +| api | POST /api/purchases/supplies/{id}/post (Draft → Posted) | ✓ 204 | +| api | Повторный post Supply → 409 (idempotency) | ✓ 409 {"error":"Документ уже проведён."} | +| db | stock_movements содержат запись на каждую строку Supply | ✓ count=3, expected=3 | + +## ✓ Step step09_check_stock_after_supply: GET /api/inventory/stock — quantity увеличился на supplied amount + +Длительность: 878мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | stock(e2e Product 1 1778218425049) +10 (было 0, стало 10) | ✓ delta=10, expected=10 | +| api | stock(e2e Product 2 1778218425049) +15 (было 0, стало 15) | ✓ delta=15, expected=15 | +| api | stock(e2e Product 3 1778218425049) +20 (было 0, стало 20) | ✓ delta=20, expected=20 | +| api | GET /api/inventory/stock без storeId возвращает строки на каждый склад | ✓ rows=1, stores=07d23bb1 | +| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 1 1778218425049… | ✓ sum_movements=10 stocks.Quantity=10 | +| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 2 1778218425049… | ✓ sum_movements=15 stocks.Quantity=15 | +| db | stocks.Quantity == SUM(stock_movements.Quantity) для e2e Product 3 1778218425049… | ✓ sum_movements=20 stocks.Quantity=20 | + +## ✓ Step step10_ensure_retail_point: Проверить или создать розничную точку (кассу) + +Длительность: 168мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | RetailPoint существует | ✓ Касса 1 | +| api | RetailPoint с несуществующим storeId → 400/404 | ✓ 400 | + +## ✗ Step step11_create_retail_sale: Admin создаёт RetailSale, 2 позиции из приёмки, cash, Post + +Длительность: 1035мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Продажа qty>остатка → /post должен 4xx | ✗ 204 | +| api | Продажа с отрицательным qty/price → 400 | ✓ 400 | +| api | discount=10 на line(price=100,qty=1) → lineTotal=90 | ✓ lineTotal=90 | +| api | POST /api/sales/retail (Draft) | ✓ 201 | +| api | POST /retail/{id}/post | ✓ 204 | +| api | Повторный post RetailSale → 409 | ✓ 409 | + +## ✓ Step step12_check_stock_after_sale: GET /api/inventory/stock — quantity уменьшился на sold amount + +Длительность: 889мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | stock product=f94d281a… −2 (было 10, стало 8) | ✓ delta=2, expected=2 | +| api | stock product=6e22649a… −2 (было 15, стало 13) | ✓ delta=2, expected=2 | +| db | stock_movements запись на sale-line f94d281a… | ✓ count=1, sum=-2 (expected sum=-2) | +| db | stock_movements запись на sale-line 6e22649a… | ✓ count=1, sum=-2 (expected sum=-2) | +| db | stock_movements.Type = RetailSale (2) для sale документа | ✓ types=2 | + +## Summary + +- Passed: 10 +- Failed: 2 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +### HIGH + +- **[11] Розничная продажа провёл количество больше остатка** + - qty=99999 при остатке 10-15 — /post вернул 204 вместо 409/400. + - Fix: RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available. + +### MEDIUM + +- **[08] Supply без supplierId → 500 вместо 400** + - Сервер бросил исключение (500) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения. + - Fix: SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием. + +## Logic gaps + +- Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу. +- RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше. diff --git a/tests/e2e/scenarios/full-cycle.steps.ts b/tests/e2e/scenarios/full-cycle.steps.ts index 0fe74c8..380938e 100644 --- a/tests/e2e/scenarios/full-cycle.steps.ts +++ b/tests/e2e/scenarios/full-cycle.steps.ts @@ -11,6 +11,7 @@ import { login, makeClient, ADMIN_BASE } from '../lib/api.js' import type { CheckResult, Step, Report } from '../lib/report.js' import { Report as _R } from '../lib/report.js' // type-only ниже import { countRows, psql } from '../lib/db.js' +import { generateEan13 } from '../lib/barcode.js' type Ctx = { apiOnly: boolean @@ -26,10 +27,13 @@ type Ctx = { counterpartyId?: string storeId?: string retailPointId?: string + productGroupId?: string + retailPriceTypeId?: string + currencyId?: string supplyId?: string supplyLines?: { productId: string; productName: string; quantity: number; price: number }[] retailSaleId?: string - saleLines?: { productId: string; quantity: number }[] + saleLines?: { productId: string; quantity: number; unitPrice: number }[] stockBefore?: Record stockAfterSupply?: Record } @@ -354,9 +358,9 @@ export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) { } } - const salesRes = await apiCashier.get('/api/sales/retail-sales?pageSize=10') + const salesRes = await apiCashier.get('/api/sales/retail?pageSize=10') check(step, { - kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен', + kind: 'api', description: 'Cashier → GET /api/sales/retail — доступен', ok: salesRes.status === 200 || salesRes.status === 404, detail: `${salesRes.status}`, }) @@ -449,75 +453,161 @@ export async function step08_create_supply({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) - // Берём 3 произвольных products. Реестр products в БД tenant-scoped - // (ITenantEntity), поэтому после создания новой org `GET /api/catalog/ - // products` возвращает 0 — старые products принадлежат другому tenant'у. - // Это logic-gap описанный в отчёте; для прогона сценария создаём 3 - // products в новой org прямо сейчас. - const products = await api.get('/api/catalog/products?pageSize=5') - let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[] - if (items.length < 3) { - report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.') - // Получим первую unit-of-measure (системную или из org). - const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10') - const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined - if (!unit) { + // 1) Подготовим справочники: unit, product group, retail price type, currency. + const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10') + const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined + if (!unit) { + report.bug({ + step: '08', severity: 'critical', + title: 'Для нового tenant нет ни одной единицы измерения', + detail: 'GET /api/catalog/units-of-measure?pageSize=10 → пустой items', + fix: 'Phase5c должен auto-enable все active globals в org_units_of_measure при создании org.', + }) + return + } + + const groupsRes = await api.get('/api/catalog/product-groups?pageSize=200') + let groupId = (groupsRes.data?.items ?? [])[0]?.id as string | undefined + if (!groupId) { + const cg = await api.post('/api/catalog/product-groups', { + name: 'e2e Группа', parentId: null, sortOrder: 0, + }) + if (cg.status >= 400) { report.bug({ step: '08', severity: 'high', - title: 'Нет ни одной единицы измерения для нового tenant', - detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.', + title: 'Не удалось создать ProductGroup для нового tenant', + detail: `${cg.status} ${asString(cg.data).slice(0, 200)}`, + fix: 'Bootstrap должен сидить дефолтную группу «Продукты питания» при создании org.', }) return } - const created: { id: string; name: string }[] = [] - for (let i = 0; i < 3; i++) { - const cr = await api.post('/api/catalog/products', { - name: `e2e Product ${i + 1} ${TIMESTAMP}`, - article: `E2E-${TIMESTAMP}-${i + 1}`, - barcode: null, - unitId: unit.id, - groupId: null, - retailPrice: 100 + i * 50, - purchasePrice: 70 + i * 30, - isActive: true, - }) - if (cr.status >= 400) { - report.bug({ - step: '08', severity: 'high', - title: `Не удалось создать product №${i + 1}`, - detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`, - }) - return - } - created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name }) - } - items = created.map((p) => ({ id: p.id, name: p.name })) - check(step, { - kind: 'api', description: 'Auto-created 3 products для нового tenant', - ok: true, detail: items.map((i) => i.name).join(', '), - }) + groupId = cg.data?.id + report.gap('Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу.') } - const lines = items.slice(0, 3).map((p, i) => ({ - productId: p.id, - productName: p.name, - quantity: 10 + i * 5, - price: 100 + i * 50, + ctx.productGroupId = groupId! + + const ptRes = await api.get('/api/catalog/price-types?pageSize=200') + const retailPt = ((ptRes.data?.items ?? []) as { id: string; isRetail?: boolean; name: string }[]) + .find((p) => p.isRetail) ?? (ptRes.data?.items ?? [])[0] + if (!retailPt) { + report.bug({ + step: '08', severity: 'critical', + title: 'Нет ни одного PriceType для нового tenant', + detail: '', + }) + return + } + ctx.retailPriceTypeId = retailPt.id + + const curRes = await api.get('/api/catalog/currencies?pageSize=50') + const kzt = ((curRes.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT') + if (!kzt) { + report.bug({ step: '08', severity: 'high', title: 'Нет валюты KZT в справочнике', detail: '' }) + return + } + ctx.currencyId = kzt.id + + // 2) Создаём 3 product'а с валидным ProductInput. + const created: { id: string; name: string; price: number }[] = [] + for (let i = 0; i < 3; i++) { + const retailPrice = 100 + i * 50 + const cr = await api.post('/api/catalog/products', { + name: `e2e Product ${i + 1} ${TIMESTAMP}`, + article: `E2E-${TIMESTAMP}-${i + 1}`, + description: null, + unitOfMeasureId: unit.id, + vat: 12, + vatEnabled: true, + productGroupId: ctx.productGroupId, + defaultSupplierId: null, + countryOfOriginId: null, + isService: false, + packaging: 0, + isMarked: false, + minStock: null, maxStock: null, + referencePrice: retailPrice * 0.7, + purchaseCurrencyId: ctx.currencyId, + imageUrl: null, + prices: [ + { priceTypeId: ctx.retailPriceTypeId, amount: retailPrice, currencyId: ctx.currencyId }, + ], + barcodes: [ + { code: generateEan13(i + 1), type: 0, isPrimary: true }, + ], + }) + if (cr.status >= 400) { + report.bug({ + step: '08', severity: 'high', + title: `Не удалось создать product №${i + 1}`, + detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`, + }) + return + } + created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name, price: retailPrice }) + } + check(step, { + kind: 'api', description: 'Создано 3 product (валидный barcode + price + group)', + ok: created.length === 3, detail: created.map((p) => p.name).join(', '), + }) + + const lines = created.map((p, i) => ({ + productId: p.id, productName: p.name, quantity: 10 + i * 5, price: 70 + i * 30, })) ctx.supplyLines = lines - // Сохраняем stock-snapshot ДО приёмки. + // Stock snapshot до приёмки (должен быть 0 на новой орге). const stockBefore: Record = {} for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId) ctx.stockBefore = stockBefore - const draft = await api.post('/api/purchases/supplies', { - counterpartyId: ctx.counterpartyId, + // 3) Bug-hunt: попытаться создать Supply без supplierId / без lines. + const noSupplier = await api.post('/api/purchases/supplies', { + date: new Date().toISOString(), storeId: ctx.storeId, - docDate: new Date().toISOString(), - description: 'e2e draft', - lines: lines.map((l) => ({ - productId: l.productId, quantity: l.quantity, price: l.price, - })), + currencyId: ctx.currencyId, + notes: 'no supplier', + lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })), + }) + check(step, { + kind: 'api', description: 'Supply без supplierId → 400/409', + ok: noSupplier.status >= 400 && noSupplier.status < 500, + detail: `${noSupplier.status} ${asString(noSupplier.data).slice(0, 100)}`, + }) + if (noSupplier.status === 200 || noSupplier.status === 201) { + report.bug({ + step: '08', severity: 'high', + title: 'Supply создаётся без supplierId', + detail: 'POST /api/purchases/supplies без поля supplierId прошёл валидацию и вернул 200', + fix: 'SupplyInput.SupplierId должен быть [Required] и проверяться в Create.', + }) + } else if (noSupplier.status >= 500) { + report.bug({ + step: '08', severity: 'medium', + title: 'Supply без supplierId → 500 вместо 400', + detail: `Сервер бросил исключение (${noSupplier.status}) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения.`, + fix: 'SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием.', + }) + } + + const noLines = await api.post('/api/purchases/supplies', { + date: new Date().toISOString(), + supplierId: ctx.counterpartyId, storeId: ctx.storeId, currencyId: ctx.currencyId, + notes: 'no lines', lines: [], + }) + check(step, { + kind: 'api', description: 'Supply с пустым lines[] → 400', + ok: noLines.status >= 400 && noLines.status < 500, + detail: `${noLines.status}`, + }) + + // 4) Создаём настоящий draft Supply. + const draft = await api.post('/api/purchases/supplies', { + date: new Date().toISOString(), + supplierId: ctx.counterpartyId, + storeId: ctx.storeId, + currencyId: ctx.currencyId, + notes: 'e2e draft', + lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })), }) check(step, { kind: 'api', description: 'POST /api/purchases/supplies (Draft)', @@ -548,6 +638,48 @@ export async function step08_create_supply({ ctx, step, report }: StepCtx) { ok: post.status === 200 || post.status === 204, detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, }) + if (post.status >= 400) { + report.bug({ + step: '08', severity: 'critical', + title: 'Posted-переход Supply падает', + detail: asString(post.data), + }) + return + } + + // 5) Bug-hunt: повторный post → 409 (idempotency contract). + const dblPost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {}) + check(step, { + kind: 'api', description: 'Повторный post Supply → 409 (idempotency)', + ok: dblPost.status === 409, + detail: `${dblPost.status} ${asString(dblPost.data).slice(0, 100)}`, + }) + if (dblPost.status !== 409) { + report.bug({ + step: '08', severity: 'medium', + title: 'Повторный post Supply возвращает не 409', + detail: `Получили ${dblPost.status}; ожидаем 409 чтобы UI мог показать «Уже проведено».`, + }) + } + + // 6) Bug-hunt: stock_movements должны содержать одну запись на каждую строку. + const orgId = ctx.organization!.id + const movsRows = psql(`SELECT count(*) FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.supplyId}'`) + .trim() + const movs = parseInt(movsRows, 10) || 0 + check(step, { + kind: 'db', description: 'stock_movements содержат запись на каждую строку Supply', + ok: movs === lines.length, + detail: `count=${movs}, expected=${lines.length}`, + }) + if (movs !== lines.length) { + report.bug({ + step: '08', severity: 'high', + title: 'stock_movements не записаны после Posted Supply', + detail: `expected ${lines.length}, got ${movs}`, + fix: 'Проверить SupplyController.Post: ApplyMovementAsync вызывается для каждой строки.', + }) + } } // --------------------------------------------------------------------------- @@ -580,6 +712,41 @@ export async function step09_check_stock_after_supply({ ctx, step, report }: Ste }) } } + + // GET /stock без storeId — должен агрегировать или возвращать строку на склад. + const aggRes = await api.get(`/api/inventory/stock?productId=${ctx.supplyLines[0].productId}&pageSize=200`) + const aggItems = (aggRes.data?.items ?? []) as { storeId: string; quantity: number }[] + check(step, { + kind: 'api', + description: 'GET /api/inventory/stock без storeId возвращает строки на каждый склад', + ok: aggItems.length >= 1, + detail: `rows=${aggItems.length}, stores=${aggItems.map((i) => i.storeId.slice(0, 8)).join(',')}`, + }) + const hasTotal = 'totalQuantity' in (aggRes.data ?? {}) + if (!hasTotal && aggItems.length > 1) { + report.gap('Stock endpoint не агрегирует totalQuantity по нескольким складам — UI должен суммировать сам. Если в орге будет 5+ складов, это будет неудобно для дашборда «общие остатки товара».') + } + + // Согласованность Stocks.Quantity с суммой stock_movements.Quantity. + const orgId = ctx.organization!.id + for (const ln of ctx.supplyLines) { + const sumStr = psql(`SELECT COALESCE(SUM("Quantity"), 0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "ProductId"='${ln.productId}' AND "StoreId"='${ctx.storeId}'`).trim() + const sum = parseFloat(sumStr) || 0 + const stockNow = after[ln.productId] ?? 0 + check(step, { + kind: 'db', + description: `stocks.Quantity == SUM(stock_movements.Quantity) для ${ln.productName.slice(0, 30)}…`, + ok: Math.abs(sum - stockNow) < 0.0001, + detail: `sum_movements=${sum} stocks.Quantity=${stockNow}`, + }) + if (Math.abs(sum - stockNow) >= 0.0001) { + report.bug({ + step: '09', severity: 'high', + title: 'Stocks.Quantity рассинхронизированы с SUM(stock_movements.Quantity)', + detail: `product=${ln.productName} sum=${sum} stocks=${stockNow}`, + }) + } + } } // --------------------------------------------------------------------------- @@ -588,7 +755,7 @@ export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) if (!ctx.adminToken) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const list = await api.get('/api/catalog/retail-points?pageSize=200') - const items = (list.data?.items ?? []) as { id: string; name: string }[] + const items = (list.data?.items ?? []) as { id: string; name: string; storeId?: string }[] if (items.length === 0) { const create = await api.post('/api/catalog/retail-points', { name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true, @@ -608,27 +775,168 @@ export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) title: 'Не получилось гарантировать наличие розничной точки', detail: '', }) + return + } + + // Bug-hunt: касса должна быть привязана к существующему складу. + const fakeStore = '00000000-0000-0000-0000-000000000001' + const badStore = await api.post('/api/catalog/retail-points', { + name: `e2e-bad-${TIMESTAMP}`, code: `BS-${TIMESTAMP}`, storeId: fakeStore, isActive: true, + }) + check(step, { + kind: 'api', description: 'RetailPoint с несуществующим storeId → 400/404', + ok: badStore.status >= 400 && badStore.status < 500, + detail: `${badStore.status}`, + }) + if (badStore.status === 200 || badStore.status === 201) { + report.bug({ + step: '10', severity: 'medium', + title: 'RetailPoint создаётся с несуществующим storeId', + detail: 'POST /api/catalog/retail-points c storeId=00000000-0000-0000-0000-000000000001 прошёл валидацию.', + fix: 'Добавить проверку EXISTS Store в RetailPointsController.Create.', + }) } } // --------------------------------------------------------------------------- export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) { - if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return } + if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines || !ctx.storeId || !ctx.currencyId) { + step.status = 'skip'; return + } const api = makeClient(ctx.adminToken) + // Берём 2 первых product, продаём по 2 шт каждого. Цена = supply.price*2 (наценка). const lines = ctx.supplyLines.slice(0, 2).map((l) => ({ - productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт. + productId: l.productId, + quantity: 2, + unitPrice: l.price * 2, + discount: 0, + vatPercent: 12, })) - ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity })) + ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })) - const draft = await api.post('/api/sales/retail-sales', { + // Bug-hunt: пустой чек → 400 на /post (после Draft); тут проверяем create с пустыми lines. + const empty = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: ctx.storeId, retailPointId: ctx.retailPointId, - docDate: new Date().toISOString(), - description: 'e2e sale', + customerId: null, + currencyId: ctx.currencyId, + payment: 0, paidCash: 0, paidCard: 0, + notes: 'empty', lines: [], + }) + if (empty.status === 200 || empty.status === 201) { + report.gap('RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше.') + } + + // Bug-hunt: продажа qty больше остатка должна давать предупреждение или ошибку. + const overSell = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: ctx.storeId, + retailPointId: ctx.retailPointId, + customerId: null, + currencyId: ctx.currencyId, + payment: 0, paidCash: 999999, paidCard: 0, + notes: 'over-sell', + lines: [{ productId: ctx.supplyLines[0].productId, quantity: 99999, unitPrice: 100, discount: 0, vatPercent: 0 }], + }) + let overSellId: string | undefined + if (overSell.status === 200 || overSell.status === 201) { + overSellId = overSell.data?.id ?? overSell.data?.saleId + const overPost = await api.post(`/api/sales/retail/${overSellId}/post`, {}) + check(step, { + kind: 'api', description: 'Продажа qty>остатка → /post должен 4xx', + ok: overPost.status >= 400 && overPost.status < 500, + detail: `${overPost.status}`, + }) + if (overPost.status === 200 || overPost.status === 204) { + report.bug({ + step: '11', severity: 'high', + title: 'Розничная продажа провёл количество больше остатка', + detail: `qty=99999 при остатке 10-15 — /post вернул ${overPost.status} вместо 409/400.`, + fix: 'RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available.', + }) + // Откатим, чтобы не сбить остатки для step12. + await api.post(`/api/sales/retail/${overSellId}/unpost`, {}) + } + // Удалим draft чтобы не мусорил. + await api.delete(`/api/sales/retail/${overSellId}`) + } else { + check(step, { + kind: 'api', description: 'POST /api/sales/retail с qty>>остатка отклонено', + ok: overSell.status >= 400 && overSell.status < 500, + detail: `${overSell.status}`, + }) + } + + // Bug-hunt: отрицательная цена/количество должны быть отклонены. + const negative = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: ctx.storeId, + retailPointId: ctx.retailPointId, + customerId: null, + currencyId: ctx.currencyId, + payment: 0, paidCash: 0, paidCard: 0, + notes: 'negative', + lines: [{ productId: ctx.supplyLines[0].productId, quantity: -1, unitPrice: -100, discount: 0, vatPercent: 0 }], + }) + check(step, { + kind: 'api', description: 'Продажа с отрицательным qty/price → 400', + ok: negative.status === 400, + detail: `${negative.status}`, + }) + if (negative.status === 200 || negative.status === 201) { + report.bug({ + step: '11', severity: 'high', + title: 'RetailSale принимает отрицательные quantity / unitPrice', + detail: 'POST /api/sales/retail с qty=-1, unitPrice=-100 → 200. [Range(0,..)] не валидирует отрицательное?', + fix: 'Проверить RetailSaleLineInput атрибуты Range и [ApiController] валидацию.', + }) + } + + // Bug-hunt: продажа с discount на позиции (lineTotal должен учитывать). + const withDiscount = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: ctx.storeId, + retailPointId: ctx.retailPointId, + customerId: null, + currencyId: ctx.currencyId, + payment: 0, paidCash: 90, paidCard: 0, + notes: 'discount-test', + lines: [{ productId: ctx.supplyLines[0].productId, quantity: 1, unitPrice: 100, discount: 10, vatPercent: 0 }], + }) + if (withDiscount.status === 200 || withDiscount.status === 201) { + const lt = withDiscount.data?.lines?.[0]?.lineTotal as number | undefined + check(step, { + kind: 'api', description: 'discount=10 на line(price=100,qty=1) → lineTotal=90', + ok: lt === 90, + detail: `lineTotal=${lt}`, + }) + if (lt !== 90) { + report.bug({ + step: '11', severity: 'medium', + title: 'Discount на позиции не применяется к lineTotal', + detail: `Передали unitPrice=100, qty=1, discount=10; ожидаем lineTotal=90 — получили ${lt}.`, + }) + } + if (withDiscount.data?.id) await api.delete(`/api/sales/retail/${withDiscount.data.id}`) + } + + // Реальная продажа (positive). + const draft = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: ctx.storeId, + retailPointId: ctx.retailPointId, + customerId: null, + currencyId: ctx.currencyId, + payment: 0, + paidCash: lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0), + paidCard: 0, + notes: 'e2e sale', lines, }) check(step, { - kind: 'api', description: 'POST /api/sales/retail-sales (Draft)', + kind: 'api', description: 'POST /api/sales/retail (Draft)', ok: draft.status === 200 || draft.status === 201, detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`, }) @@ -644,25 +952,36 @@ export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) if (!ctx.retailSaleId) { report.bug({ step: '11', severity: 'high', - title: 'POST /retail-sales не возвращает id', + title: 'POST /retail не возвращает id', detail: asString(draft.data).slice(0, 200), }) return } - const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {}) + const post = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {}) check(step, { - kind: 'api', description: 'POST /retail-sales/{id}/post', + kind: 'api', description: 'POST /retail/{id}/post', ok: post.status === 200 || post.status === 204, detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, }) + + // Bug-hunt: повторный post проведённого → 409. + const dblPost = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {}) + check(step, { + kind: 'api', description: 'Повторный post RetailSale → 409', + ok: dblPost.status === 409, + detail: `${dblPost.status}`, + }) } // --------------------------------------------------------------------------- export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) { - if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return } + if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply || !ctx.retailSaleId) { + step.status = 'skip'; return + } const api = makeClient(ctx.adminToken) + const orgId = ctx.organization!.id for (const ln of ctx.saleLines) { const before = ctx.stockAfterSupply[ln.productId] ?? 0 @@ -682,6 +1001,38 @@ export async function step12_check_stock_after_sale({ ctx, step, report }: StepC }) } } + + // stock_movements: записи на каждую sale-строку с типом RetailSale и qty<0. + for (const ln of ctx.saleLines) { + const row = psql( + `SELECT count(*), COALESCE(SUM("Quantity"),0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}' AND "ProductId"='${ln.productId}'` + ).trim().split('|') + const cnt = parseInt(row[0], 10) || 0 + const sumQty = parseFloat(row[1]) || 0 + check(step, { + kind: 'db', + description: `stock_movements запись на sale-line ${ln.productId.slice(0, 8)}…`, + ok: cnt === 1 && Math.abs(sumQty + ln.quantity) < 0.0001, + detail: `count=${cnt}, sum=${sumQty} (expected sum=${-ln.quantity})`, + }) + if (cnt !== 1 || Math.abs(sumQty + ln.quantity) >= 0.0001) { + report.bug({ + step: '12', severity: 'high', + title: 'stock_movements не записаны после Posted RetailSale', + detail: `productId=${ln.productId} count=${cnt} sumQty=${sumQty} (ожидаем 1 запись с qty=${-ln.quantity})`, + }) + } + } + + // Тип движения должен быть RetailSale (enum=2 в Domain.Inventory.MovementType). + const typesRaw = psql( + `SELECT DISTINCT "Type"::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}'` + ).trim() + check(step, { + kind: 'db', description: 'stock_movements.Type = RetailSale (2) для sale документа', + ok: typesRaw === '2' || typesRaw.toLowerCase() === 'retailsale', + detail: `types=${typesRaw}`, + }) } // ---------------------------------------------------------------------------