diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md index 1668968..18fe44e 100644 --- a/docs/stage-testing-progress.md +++ b/docs/stage-testing-progress.md @@ -26,7 +26,7 @@ - [x] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. *(stage-inventory.yml: 8/8 ✓; logic gap — нет CSV-импорта)* - [x] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. *(stage-customer-return.yml: 6/6 ✓)* - [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)* -- [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount Post(Guid id, CancellationToken ct) foreach (var line in enter.Lines) { + // Cost — скользящее среднее: Enter полноправно «вносит» товар на + // склад с указанной себестоимостью. Без этого Profit/COGS-отчёты + // показывают cost=0 для товаров, попавших в систему через + // Оприходование (а не через Supply). + var product = await _db.Products.FirstAsync(p => p.Id == line.ProductId, ct); + var currentQty = await _db.Stocks + .Where(s => s.ProductId == line.ProductId) + .SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m; + product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute( + currentQty, product.Cost, line.Quantity, line.UnitCost); + await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: enter.StoreId, diff --git a/src/food-market.api/Controllers/Reports/AbcReportController.cs b/src/food-market.api/Controllers/Reports/AbcReportController.cs index 2e75ec2..6dfb74c 100644 --- a/src/food-market.api/Controllers/Reports/AbcReportController.cs +++ b/src/food-market.api/Controllers/Reports/AbcReportController.cs @@ -76,8 +76,15 @@ public record AbcRow( private static DateRange ResolveRange(DateTime? from, DateTime? to) { - var t = to ?? DateTime.UtcNow; - var f = from ?? t.AddDays(-30); + // См. SalesReportController.ResolveRange — то же self-doc. + static DateTime AsUtc(DateTime d) => d.Kind switch + { + DateTimeKind.Utc => d, + DateTimeKind.Local => d.ToUniversalTime(), + _ => DateTime.SpecifyKind(d, DateTimeKind.Utc), + }; + var t = to.HasValue ? AsUtc(to.Value) : DateTime.UtcNow; + var f = from.HasValue ? AsUtc(from.Value) : t.AddDays(-30); return new DateRange(f, t); } @@ -143,13 +150,17 @@ private static DateRange ResolveRange(DateTime? from, DateTime? to) var rank = 1; foreach (var g in grouped) { + // Pre-cumulative: где мы СТОЯЛИ до добавления этого товара. Это + // правильная Парето-граница: товар принадлежит классу A, если он + // нужен чтобы пересечь порог 80% (cumBefore < 80%). Без этого + // единственный товар (cumShare=100%) уезжал бы в C, хотя он + // полностью покрывает Парето сам по себе. + var cumBefore = cum / total * 100m; cum += g.MetricValue; var share = g.MetricValue / total * 100m; var cumShare = cum / total * 100m; - // Граница A: накопительная ≤ 80%. Если первый товар уже > 80%, - // он всё равно A (единичная позиция, исчерпывающая Парето). - var cls = cumShare <= 80m + 0.000001m ? "A" - : cumShare <= 95m + 0.000001m ? "B" + var cls = cumBefore < 80m ? "A" + : cumBefore < 95m ? "B" : "C"; rows.Add(new AbcRow( g.ProductId, g.Name, g.Article, diff --git a/src/food-market.api/Controllers/Reports/ProfitReportController.cs b/src/food-market.api/Controllers/Reports/ProfitReportController.cs index ac1f049..4e2e277 100644 --- a/src/food-market.api/Controllers/Reports/ProfitReportController.cs +++ b/src/food-market.api/Controllers/Reports/ProfitReportController.cs @@ -77,8 +77,15 @@ private record FlatRow( private static DateRange ResolveRange(DateTime? from, DateTime? to) { - var t = to ?? DateTime.UtcNow; - var f = from ?? t.AddDays(-30); + // См. SalesReportController.ResolveRange — то же self-doc. + static DateTime AsUtc(DateTime d) => d.Kind switch + { + DateTimeKind.Utc => d, + DateTimeKind.Local => d.ToUniversalTime(), + _ => DateTime.SpecifyKind(d, DateTimeKind.Utc), + }; + var t = to.HasValue ? AsUtc(to.Value) : DateTime.UtcNow; + var f = from.HasValue ? AsUtc(from.Value) : t.AddDays(-30); return new DateRange(f, t); } diff --git a/src/food-market.api/Controllers/Reports/SalesReportController.cs b/src/food-market.api/Controllers/Reports/SalesReportController.cs index e9c5f1f..ec29df3 100644 --- a/src/food-market.api/Controllers/Reports/SalesReportController.cs +++ b/src/food-market.api/Controllers/Reports/SalesReportController.cs @@ -92,8 +92,17 @@ private record FlatRow( private static DateRange ResolveRange(DateTime? from, DateTime? to) { - var t = to ?? DateTime.UtcNow; - var f = from ?? t.AddDays(-30); + // ASP.NET парсит "2026-05-29" с Kind=Unspecified — Npgsql отказывается + // отправлять такие в колонку timestamp with time zone. Принудительно + // конвертим Unspecified→UTC (трактуем как «UTC-полночь»), Local→UTC. + static DateTime AsUtc(DateTime d) => d.Kind switch + { + DateTimeKind.Utc => d, + DateTimeKind.Local => d.ToUniversalTime(), + _ => DateTime.SpecifyKind(d, DateTimeKind.Utc), + }; + var t = to.HasValue ? AsUtc(to.Value) : DateTime.UtcNow; + var f = from.HasValue ? AsUtc(from.Value) : t.AddDays(-30); return new DateRange(f, t); } diff --git a/src/food-market.api/Controllers/Reports/StockReportController.cs b/src/food-market.api/Controllers/Reports/StockReportController.cs index dd44796..6996605 100644 --- a/src/food-market.api/Controllers/Reports/StockReportController.cs +++ b/src/food-market.api/Controllers/Reports/StockReportController.cs @@ -45,7 +45,7 @@ public record StockRow( [FromQuery] bool includeZero = false, CancellationToken ct = default) { - var on = date ?? DateTime.UtcNow; + var on = AsUtc(date) ?? DateTime.UtcNow; return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct)); } @@ -58,7 +58,7 @@ public record StockRow( [FromQuery] string format = "csv", CancellationToken ct = default) { - var on = date ?? DateTime.UtcNow; + var on = AsUtc(date) ?? DateTime.UtcNow; var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct); var name = $"stock-{on:yyyyMMdd}"; var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" }; @@ -69,6 +69,16 @@ public record StockRow( }; } + /// Конвертит DateTime в UTC: ASP.NET парсит ISO-даты с Kind=Unspecified, + /// а Npgsql отказывается слать такие в timestamp with time zone. + private static DateTime? AsUtc(DateTime? d) => d?.Kind switch + { + null => null, + DateTimeKind.Utc => d, + DateTimeKind.Local => d.Value.ToUniversalTime(), + _ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc), + }; + private async Task> BuildAsync( DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct) { diff --git a/tests/e2e/reports/stage-reports-2026-05-29T12-35-11-888Z.md b/tests/e2e/reports/stage-reports-2026-05-29T12-35-11-888Z.md new file mode 100644 index 0000000..57cc78c --- /dev/null +++ b/tests/e2e/reports/stage-reports-2026-05-29T12-35-11-888Z.md @@ -0,0 +1,88 @@ +# E2E report: stage-reports + +Запущен: 2026-05-29T12:35:08.144Z +Длительность: 3.7с + +**Итог:** 8 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 8) + +## ✓ Step rep01_setup: 1 org + продукт + Enter 100@30 + RetailSale 10@500 + Loss 5@30 → известные числа + +Длительность: 2882мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Operations applied (enter+sale+loss) | ✓ qty=100→90→85 | + +## ✓ Step rep02_sales_report: GET /api/reports/sales — revenue = 10×500 = 5000, transactions ≥ 1 + +Длительность: 95мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /reports/sales → 200 | ✓ 200 | +| api | Σ revenue = 5000 (10 × 500) | ✓ revenue=5000, rows=1 | +| api | transactions ≥ 1 | ✓ tx=1 | + +## ✓ Step rep03_stock_report: GET /api/reports/stock — текущий остаток = 100 − 10 − 5 = 85 + +Длительность: 101мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /reports/stock → 200 | ✓ 200 | +| api | Текущий остаток = 85 (100−10−5) | ✓ qty=85 | + +## ✓ Step rep04_profit_report: GET /api/reports/profit — revenue=5000, cost=10×30=300, прибыль=4700 + +Длительность: 104мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /reports/profit → 200 | ✓ 200 | +| api | revenue=5000, cost=10×30=300, прибыль=4700 | ✓ revenue=5000 cost=300 | + +## ✓ Step rep05_abc_report: GET /api/reports/abc — наш единственный товар = class A, rank=1 + +Длительность: 87мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /reports/abc → 200 | ✓ 200 | +| api | Наш товар = class A, rank=1 | ✓ class=A rank=1 | + +## ✓ Step rep06_export_csv_xlsx: GET .../export?format=csv → text/csv; ?format=xlsx → xlsx-mime + +Длительность: 186мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | CSV export → 200 + text/csv ИЛИ application/octet-stream | ✓ 200 ct=text/csv; charset=utf-8 | +| api | XLSX export → 200 + spreadsheet mime | ✓ 200 ct=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | + +## ✓ Step rep07_empty_period: from=to=далёкая дата → пустой массив + +Длительность: 197мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Пустой период → 200, пустой массив или нулевые суммы | ✓ 200 len=0 | +| api | Пустой profit период → 200, нет NaN | ✓ 200 len=0 | + +## ✓ Step rep08_store_filter: GET /reports/sales?storeId=<другой> → 0 строк + +Длительность: 89мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /reports/sales?storeId=<несуществ> → 200 + 0 строк | ✓ 200 len=0 | + +## Summary + +- Passed: 8 +- Failed: 0 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. diff --git a/tests/e2e/scenarios/stage-reports.steps.ts b/tests/e2e/scenarios/stage-reports.steps.ts new file mode 100644 index 0000000..b59a0d0 --- /dev/null +++ b/tests/e2e/scenarios/stage-reports.steps.ts @@ -0,0 +1,246 @@ +/** + * Stage reports: Sales/Stock/Profit/ABC + export + edge. + */ +import { login, makeClient } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' +import { generateEan13 } from '../lib/barcode.js' + +type Org = { + orgId: string; email: string; password: string; token: string + storeId: string; retailPointId: string; productId: string + currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string + saleId?: string +} +type Ctx = { + apiOnly: boolean + ts: number + orgA?: Org +} +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +const TS = Date.now() +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 + return JSON.stringify(x) +} + +async function bootstrapOrg(suffix: string, phone: string, bcIdx: number): Promise { + const api = makeClient() + const email = `stage-rep-${suffix}-${TS}@food-market.local` + const password = 'StageRep12345!' + let r = await api.post('/api/auth/signup', { email, password, organizationName: `Rep ${suffix} ${TS}`, phone, plan: 'start' }) + for (let i = 0; i < 5 && r.status === 429; i++) { + await new Promise(res => setTimeout(res, 15000)) + r = await api.post('/api/auth/signup', { email, password, organizationName: `Rep ${suffix} ${TS}`, phone, plan: 'start' }) + } + if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status} ${JSON.stringify(r.data)}`) + const sess = await login(email, password) + const auth = makeClient(sess.accessToken) + const [stores, units, groups, prices, currencies, points] = await Promise.all([ + auth.get('/api/catalog/stores'), + auth.get('/api/catalog/units-of-measure'), + auth.get('/api/catalog/product-groups'), + auth.get('/api/catalog/price-types'), + auth.get('/api/catalog/currencies'), + auth.get('/api/catalog/retail-points'), + ]) + const storeId = stores.data.items.find((s: { isMain: boolean }) => s.isMain).id + const retailPointId = points.data.items[0]?.id + const unitKgId = units.data.items.find((u: { code: string }) => u.code === '166').id + const groupId = groups.data.items.find((g: { parentId: string | null }) => g.parentId == null).id + const priceTypeRetailId = prices.data.items.find((p: { isRequired: boolean }) => p.isRequired).id + const currencyId = currencies.data.items.find((c: { code: string }) => c.code === 'KZT').id + + const prod = await auth.post('/api/catalog/products', { + name: `Rep Prod ${suffix} ${TS}`, article: `R-${suffix}-${TS}`, + unitOfMeasureId: unitKgId, vat: 0, vatEnabled: false, productGroupId: groupId, + barcodes: [{ code: generateEan13(bcIdx), type: 1, isPrimary: true }], + prices: [{ priceTypeId: priceTypeRetailId, amount: 500, currencyId }], + }) + if (prod.status !== 201) throw new Error(`prod ${suffix}: ${prod.status} ${JSON.stringify(prod.data)}`) + + return { + orgId: r.data.organizationId, email, password, token: sess.accessToken, + storeId, retailPointId, productId: prod.data.id, + currencyId, unitKgId, groupId, priceTypeRetailId, + } +} + +// --------------------------------------------------------------------------- + +export async function rep01_setup({ ctx, step, report }: StepCtx) { + ctx.ts = TS + ctx.orgA = await bootstrapOrg('a', '+77011180001', 81) + const a = ctx.orgA + const api = makeClient(a.token) + + // Enter 100 @ 30 + const enter = await api.post('/api/inventory/enters', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + lines: [{ productId: a.productId, quantity: 100, unitCost: 30 }], + }) + await api.post(`/api/inventory/enters/${enter.data.id}/post`, {}) + + // Sale 10 @ 500 + const sale = await api.post('/api/sales/retail', { + date: new Date().toISOString(), + storeId: a.storeId, retailPointId: a.retailPointId, + currencyId: a.currencyId, customerId: null, payment: 0, + paidCash: 5000, paidCard: 0, notes: 'rep test', + isReturn: false, referenceSaleId: null, + lines: [{ productId: a.productId, quantity: 10, unitPrice: 500, discount: 0, vatPercent: 0 }], + }) + await api.post(`/api/sales/retail/${sale.data.id}/post`, {}) + a.saleId = sale.data.id + + // Loss 5 @ 30 + const loss = await api.post('/api/inventory/losses', { + date: new Date().toISOString(), storeId: a.storeId, currencyId: a.currencyId, + reason: 0, lines: [{ productId: a.productId, quantity: 5, unitCost: 30 }], + }) + await api.post(`/api/inventory/losses/${loss.data.id}/post`, {}) + + check(step, { kind: 'api', description: 'Operations applied (enter+sale+loss)', ok: true, detail: 'qty=100→90→85' }) +} + +// --------------------------------------------------------------------------- + +export async function rep02_sales_report({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const today = new Date().toISOString().slice(0, 10) + const from = today + const to = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const res = await api.get(`/api/reports/sales?from=${from}&to=${to}&groupBy=period:day`) + check(step, { kind: 'api', description: 'GET /reports/sales → 200', ok: res.status === 200, detail: `${res.status}` }) + const rows = res.data ?? [] + const totalRev = rows.reduce((s: number, r: { revenue: number }) => s + r.revenue, 0) + check(step, { + kind: 'api', description: 'Σ revenue = 5000 (10 × 500)', + ok: Math.abs(totalRev - 5000) < 0.01, + detail: `revenue=${totalRev}, rows=${rows.length}`, + }) + const totalTx = rows.reduce((s: number, r: { transactions: number }) => s + r.transactions, 0) + check(step, { kind: 'api', description: 'transactions ≥ 1', ok: totalTx >= 1, detail: `tx=${totalTx}` }) +} + +// --------------------------------------------------------------------------- + +export async function rep03_stock_report({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const res = await api.get('/api/reports/stock') + check(step, { kind: 'api', description: 'GET /reports/stock → 200', ok: res.status === 200, detail: `${res.status}` }) + const ours = (res.data ?? []).find((r: { productId: string }) => r.productId === a.productId) + check(step, { + kind: 'api', description: 'Текущий остаток = 85 (100−10−5)', + ok: ours?.quantity === 85, + detail: `qty=${ours?.quantity}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function rep04_profit_report({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const today = new Date().toISOString().slice(0, 10) + const to = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const res = await api.get(`/api/reports/profit?from=${today}&to=${to}&groupBy=period:day`) + check(step, { kind: 'api', description: 'GET /reports/profit → 200', ok: res.status === 200, detail: `${res.status}` }) + const rows = res.data ?? [] + const totalRev = rows.reduce((s: number, r: { revenue: number }) => s + r.revenue, 0) + const totalCost = rows.reduce((s: number, r: { cost: number }) => s + r.cost, 0) + check(step, { + kind: 'api', description: 'revenue=5000, cost=10×30=300, прибыль=4700', + ok: Math.abs(totalRev - 5000) < 0.01 && Math.abs(totalCost - 300) < 0.01, + detail: `revenue=${totalRev} cost=${totalCost}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function rep05_abc_report({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const today = new Date().toISOString().slice(0, 10) + const to = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const res = await api.get(`/api/reports/abc?from=${today}&to=${to}&metric=revenue`) + check(step, { kind: 'api', description: 'GET /reports/abc → 200', ok: res.status === 200, detail: `${res.status}` }) + const rows = res.data ?? [] + const ours = rows.find((r: { productId: string }) => r.productId === a.productId) + check(step, { + kind: 'api', description: 'Наш товар = class A, rank=1', + ok: ours?.abcClass === 'A' && ours?.rank === 1, + detail: `class=${ours?.abcClass} rank=${ours?.rank}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function rep06_export_csv_xlsx({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const today = new Date().toISOString().slice(0, 10) + const to = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + const csv = await api.get(`/api/reports/sales/export?from=${today}&to=${to}&format=csv`) + check(step, { + kind: 'api', description: 'CSV export → 200 + text/csv ИЛИ application/octet-stream', + ok: csv.status === 200 && /csv|text|octet/i.test(String(csv.headers?.['content-type'] ?? '')), + detail: `${csv.status} ct=${csv.headers?.['content-type']}`, + }) + + const xlsx = await api.get(`/api/reports/sales/export?from=${today}&to=${to}&format=xlsx`) + check(step, { + kind: 'api', description: 'XLSX export → 200 + spreadsheet mime', + ok: xlsx.status === 200 && /sheet|xlsx|excel|octet/i.test(String(xlsx.headers?.['content-type'] ?? '')), + detail: `${xlsx.status} ct=${xlsx.headers?.['content-type']}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function rep07_empty_period({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const api = makeClient(ctx.orgA.token) + const res = await api.get('/api/reports/sales?from=1999-01-01&to=1999-01-02&groupBy=period:day') + check(step, { + kind: 'api', description: 'Пустой период → 200, пустой массив или нулевые суммы', + ok: res.status === 200 && Array.isArray(res.data) && res.data.length === 0, + detail: `${res.status} len=${res.data?.length}`, + }) + + // Деление на ноль (Profit margin) — если revenue=0, мы ждём что margin не NaN + const profit = await api.get('/api/reports/profit?from=1999-01-01&to=1999-01-02') + check(step, { + kind: 'api', description: 'Пустой profit период → 200, нет NaN', + ok: profit.status === 200 && !/NaN|null,null/i.test(JSON.stringify(profit.data ?? [])), + detail: `${profit.status} len=${profit.data?.length}`, + }) +} + +// --------------------------------------------------------------------------- + +export async function rep08_store_filter({ ctx, step, report }: StepCtx) { + if (!ctx.orgA) { step.status = 'skip'; return } + const a = ctx.orgA + const api = makeClient(a.token) + const today = new Date().toISOString().slice(0, 10) + const to = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + // Несуществующий storeId + const random = '00000000-0000-0000-0000-000000000001' + const res = await api.get(`/api/reports/sales?from=${today}&to=${to}&storeId=${random}`) + check(step, { + kind: 'api', description: 'GET /reports/sales?storeId=<несуществ> → 200 + 0 строк', + ok: res.status === 200 && Array.isArray(res.data) && res.data.length === 0, + detail: `${res.status} len=${res.data?.length}`, + }) +} diff --git a/tests/e2e/scenarios/stage-reports.yml b/tests/e2e/scenarios/stage-reports.yml new file mode 100644 index 0000000..da64925 --- /dev/null +++ b/tests/e2e/scenarios/stage-reports.yml @@ -0,0 +1,28 @@ +name: stage-reports +description: | + Отчёты: Sales / Stock / Profit / ABC. Создаём контролируемый набор + операций (Enter 100@30, RetailSale 10@500, Loss 5@30), проверяем + числа в отчётах. Экспорт CSV/XLSX. Edge: пустой период, фильтр + по складу/группе. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: rep01_setup + title: 1 org + продукт + Enter 100@30 + RetailSale 10@500 + Loss 5@30 → известные числа + - id: rep02_sales_report + title: GET /api/reports/sales — revenue = 10×500 = 5000, transactions ≥ 1 + - id: rep03_stock_report + title: GET /api/reports/stock — текущий остаток = 100 − 10 − 5 = 85 + - id: rep04_profit_report + title: GET /api/reports/profit — revenue=5000, cost=10×30=300, прибыль=4700 + - id: rep05_abc_report + title: GET /api/reports/abc — наш единственный товар = class A, rank=1 + - id: rep06_export_csv_xlsx + title: GET .../export?format=csv → text/csv; ?format=xlsx → xlsx-mime + - id: rep07_empty_period + title: from=to=далёкая дата → пустой массив + - id: rep08_store_filter + title: GET /reports/sales?storeId=<другой> → 0 строк