# Спринт 3 — отчёты и аналитика (P1)

Автономная работа. После каждого пункта: `dotnet build` (SDK 8.0.126),
unit + integration тесты этого пункта, коммит порцией, отметка `[x]` здесь,
коммит прогресса.

Multi-tenant: все запросы фильтруются по `OrganizationId` через query filter
`AppDbContext`. Каждый отчёт — отдельный e2e/integration на изоляцию orgA vs orgB.

## Чек-лист

1. [x] **P1-8 Отчёт «Продажи»** — `/api/reports/sales` с группировкой по период
   (день/неделя/месяц), товар, кассир, касса, способ оплаты; фильтры
   (от/до, магазин, группа товаров). Web `/reports/sales`: фильтр периода,
   табы по группировкам, экспорт CSV+XLSX.
   ✅ Реализация: проекция в плоский ряд на сервере + агрегация в C#
   (EF8 не переводит «distinct count» в group-проекции с nullable-ключами).
   `CsvHelper` + `ClosedXML`. Bonus: исправлен баг `RetailSalesController.Update`
   (DbUpdateConcurrency на свеже-созданном возврате). 5 интеграционных тестов.
2. [x] **P1-9 Отчёт «Остатки на дату»** — `/api/reports/stock` восстанавливает
   остатки на произвольную дату через журнал `StockMovement` (Σ движений до
   даты по продукту). Web `/reports/stock`: выбор даты, фильтр магазин/группа,
   экспорт. Edge: дата в будущем, дата раньше первой операции.
   ✅ Реконструкция через Σ `StockMovement.Quantity` где `OccurredAt ≤ date`.
   Стоимость — последний `UnitCost` движения до даты + fallback на `Product.Cost`.
   Edge'ы покрыты тестами: 5 интеграционных (today=current, before-first→empty,
   future=current, future-supply исключается на «сегодня», tenant-изоляция).
3. [x] **P1-10 Отчёт «Прибыль»** — `/api/reports/profit` = выручка − себестоимость
   по периодам/группам/товарам. Cost-snapshot уже есть в `RetailSaleLine`
   (через `UnitCost` movement'а). Защита от деления на ноль при нулевой выручке.
   ✅ Cost-snapshot — `Product.Cost` (скользящее среднее; точный FIFO потребует
   партий и вынесен из scope). Margin = profit/revenue·100, при revenue=0
   возвращаем 0. Возврат вычитает и выручку, и COGS симметрично. 3 интеграционных.
4. [x] **P1-11 Отчёт «ABC-анализ»** — топ товаров по выручке за период,
   классы A/B/C по Парето (A=80%, B=15%, C=5% накопительной выручки).
   Параметр метрики (выручка/прибыль/маржа). Web с визуализацией.
   ✅ `GET /api/reports/abc?metric=revenue|profit|margin`. Граница A —
   `cumulativeShare ≤ 80`, B — `≤ 95`, C — остальное. Товары с
   неположительной метрикой исключаются. Web: цветные плашки класса +
   полоса накопительной доли. 4 интеграционных теста.
5. [x] **P1-19 OpenAPI / Swagger** — `Swashbuckle.AspNetCore`,
   `/swagger/v1/swagger.json` в Development. Сгенерировать TS-клиент для
   food-market.web (`openapi-typescript`/`nswag`) и подключить для пары
   контроллеров как образец.
   ✅ SwaggerGen с Bearer security-scheme + стабильные operationId
   (Controller_VerbAction — verb включён против коллизии WipeAll/WipeAllAsync)
   + уникальные schemaId с namespace-префиксом. UI только в Development.
   `openapi-typescript` как devDependency + npm-script `gen:api`.
   `src/lib/api.generated.ts` + `apiClient.ts` (тонкая обёртка) — образец
   на Reports/Sales/ABC/Profit. `docs/openapi.md` — workflow генерации.

## Итог

**Все 5 пунктов выполнены.** Спринт 3 (отчёты и аналитика) завершён 2026-05-28.

Сводка:
- **P1-8 Sales** — `/api/reports/sales` 7 группировок + CSV/XLSX, 5 интеграционных.
- **P1-9 Stock** — реконструкция через журнал движений, 5 интеграционных (вкл. edges).
- **P1-10 Profit** — netto с защитой от деления на ноль, 3 интеграционных.
- **P1-11 ABC** — Парето 80/15/5 + 3 метрики + цветная UI-визуализация, 4 интеграционных.
- **P1-19 OpenAPI** — Swagger + TS-клиент через openapi-typescript,
  обёртка `apiClient.ts` для подсказки IDE.

**Сборка:** зелёная. **Тесты:** 24 unit + 49 integration (32 sprint1+2 + 17 sprint3) **зелёные**.
**Web:** `pnpm build` зелёный (4 новых report-страницы + boilerplate api.generated.ts).

### Архитектурное замечание
Все отчётные контроллеры идут паттерном «плоский pull + group в C#» — EF8 не
переводит `g.Select(...).Distinct().Count()` в SQL для group-проекций с
nullable join-ключами (cashier, retail-point). Объёмы отчётов (~десятки
тыс. строк/месяц) держатся в RAM спокойно; на крупных тенантах перейдём
на raw SQL/views (вне scope этого спринта).

### Bonus
Поймал и исправил баг `RetailSalesController.Update`: DbUpdateConcurrency
`expected 1 row, affected 0` воспроизводился на возвратах сразу после
`create-return`. Лечение — `ApplyLines` добавляет строки напрямую в DbSet
(а не через nav-collection), Update не делает `Include(Lines)`, старые
строки удаляются `ExecuteDelete`.

## Лог

- Каждый пункт: build + тесты + коммит порцией + отметка [x] + коммит прогресса.
- Все правки на `main` (origin Forgejo), без коммита `global.json`.
