fix(reports): 3 фикса по итогам stage-тестирования
Some checks are pending
Some checks are pending
1. **DateTime Kind=Unspecified → UTC** в ResolveRange / AsUtc. ASP.NET парсит 'from=2026-05-29' с Kind=Unspecified, Npgsql 8 отказывается слать такие в timestamp with time zone (500). Принудительно конвертим Unspecified→UTC (трактуем как полночь UTC), Local→ToUniversalTime. Применено к Sales/Profit/ABC/Stock. 2. **Enter.Post теперь пересчитывает Product.Cost** по той же формуле скользящего среднего что Supply.Post. Без этого товары, попавшие в систему через Оприходование (а не через Supply), имели Cost=0 — Profit/ABC-отчёты показывали cost=0 и неверную маржу. Воспроизведение: Enter 100@30 + RetailSale 10@500 → Profit-отчёт показывал revenue=5000, cost=0 (должно cost=300). 3. **ABC report: Парето-граница по cumBefore (а не cumAfter).** Единственный товар с cumShare=100% валился в класс C, хотя полностью покрывает Парето — должен быть A. Чиним: товар принадлежит классу A если он нужен чтобы пересечь порог 80% (cumBefore < 80%). Стандартный Парето-алгоритм. stage-reports (8 шагов): Sales/Stock/Profit/ABC + CSV/XLSX export + edge — все зелёные. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
475c5ca674
commit
97d5ae5eb0
|
|
@ -26,7 +26,7 @@
|
||||||
- [x] **6. Inventory (Инвентаризация)** — bookQty подгрузка, импорт CSV факта, корректирующий StockMovement на diff; multi-tenant. *(stage-inventory.yml: 8/8 ✓; logic gap — нет CSV-импорта)*
|
- [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] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. *(stage-customer-return.yml: 6/6 ✓)*
|
||||||
- [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
|
- [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
|
||||||
- [ ] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant.
|
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
|
||||||
- [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge.
|
- [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge.
|
||||||
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.
|
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.
|
||||||
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
|
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,17 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
|
|
||||||
foreach (var line in enter.Lines)
|
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(
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
ProductId: line.ProductId,
|
ProductId: line.ProductId,
|
||||||
StoreId: enter.StoreId,
|
StoreId: enter.StoreId,
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,15 @@ public record AbcRow(
|
||||||
|
|
||||||
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
||||||
{
|
{
|
||||||
var t = to ?? DateTime.UtcNow;
|
// См. SalesReportController.ResolveRange — то же self-doc.
|
||||||
var f = from ?? t.AddDays(-30);
|
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);
|
return new DateRange(f, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,13 +150,17 @@ private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
||||||
var rank = 1;
|
var rank = 1;
|
||||||
foreach (var g in grouped)
|
foreach (var g in grouped)
|
||||||
{
|
{
|
||||||
|
// Pre-cumulative: где мы СТОЯЛИ до добавления этого товара. Это
|
||||||
|
// правильная Парето-граница: товар принадлежит классу A, если он
|
||||||
|
// нужен чтобы пересечь порог 80% (cumBefore < 80%). Без этого
|
||||||
|
// единственный товар (cumShare=100%) уезжал бы в C, хотя он
|
||||||
|
// полностью покрывает Парето сам по себе.
|
||||||
|
var cumBefore = cum / total * 100m;
|
||||||
cum += g.MetricValue;
|
cum += g.MetricValue;
|
||||||
var share = g.MetricValue / total * 100m;
|
var share = g.MetricValue / total * 100m;
|
||||||
var cumShare = cum / total * 100m;
|
var cumShare = cum / total * 100m;
|
||||||
// Граница A: накопительная ≤ 80%. Если первый товар уже > 80%,
|
var cls = cumBefore < 80m ? "A"
|
||||||
// он всё равно A (единичная позиция, исчерпывающая Парето).
|
: cumBefore < 95m ? "B"
|
||||||
var cls = cumShare <= 80m + 0.000001m ? "A"
|
|
||||||
: cumShare <= 95m + 0.000001m ? "B"
|
|
||||||
: "C";
|
: "C";
|
||||||
rows.Add(new AbcRow(
|
rows.Add(new AbcRow(
|
||||||
g.ProductId, g.Name, g.Article,
|
g.ProductId, g.Name, g.Article,
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,15 @@ private record FlatRow(
|
||||||
|
|
||||||
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
||||||
{
|
{
|
||||||
var t = to ?? DateTime.UtcNow;
|
// См. SalesReportController.ResolveRange — то же self-doc.
|
||||||
var f = from ?? t.AddDays(-30);
|
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);
|
return new DateRange(f, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,17 @@ private record FlatRow(
|
||||||
|
|
||||||
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
private static DateRange ResolveRange(DateTime? from, DateTime? to)
|
||||||
{
|
{
|
||||||
var t = to ?? DateTime.UtcNow;
|
// ASP.NET парсит "2026-05-29" с Kind=Unspecified — Npgsql отказывается
|
||||||
var f = from ?? t.AddDays(-30);
|
// отправлять такие в колонку 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);
|
return new DateRange(f, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ public record StockRow(
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var on = date ?? DateTime.UtcNow;
|
var on = AsUtc(date) ?? DateTime.UtcNow;
|
||||||
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
|
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ public record StockRow(
|
||||||
[FromQuery] string format = "csv",
|
[FromQuery] string format = "csv",
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var on = date ?? DateTime.UtcNow;
|
var on = AsUtc(date) ?? DateTime.UtcNow;
|
||||||
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
|
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
|
||||||
var name = $"stock-{on:yyyyMMdd}";
|
var name = $"stock-{on:yyyyMMdd}";
|
||||||
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
|
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
|
||||||
|
|
@ -69,6 +69,16 @@ public record StockRow(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Конвертит DateTime в UTC: ASP.NET парсит ISO-даты с Kind=Unspecified,
|
||||||
|
/// а Npgsql отказывается слать такие в timestamp with time zone.</summary>
|
||||||
|
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<List<StockRow>> BuildAsync(
|
private async Task<List<StockRow>> BuildAsync(
|
||||||
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
|
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
88
tests/e2e/reports/stage-reports-2026-05-29T12-35-11-888Z.md
Normal file
88
tests/e2e/reports/stage-reports-2026-05-29T12-35-11-888Z.md
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
Нет.
|
||||||
246
tests/e2e/scenarios/stage-reports.steps.ts
Normal file
246
tests/e2e/scenarios/stage-reports.steps.ts
Normal file
|
|
@ -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<Org> {
|
||||||
|
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}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
28
tests/e2e/scenarios/stage-reports.yml
Normal file
28
tests/e2e/scenarios/stage-reports.yml
Normal file
|
|
@ -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 строк
|
||||||
Loading…
Reference in a new issue