docs(s24): docs cross-check + auto-gen + onboarding + test gap-fill (8/8 ✓)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

1. Docs cross-check — обновил performance-baseline.md (Sprint 18/20/23
   фиксы), secrets.md (16 новых env-vars из Sprint 20+ — Authentication
   Google/Microsoft, Monitoring, Cleanup, Hangfire:Cron, Telegram,
   Maintenance, App, Storage, PUBLIC_GA_ID/YM_ID).

2. Auto-gen api-reference — ApiReferenceDocsJob (Hangfire weekly вс
   05:30 UTC) + Python-эквивалент `/tmp/gen-api-ref.py` для commit
   actual snapshot. docs/api-reference.md = 195 endpoints, 57 controllers.

3. Coverage gap-fill — Sprint18To23FeaturesTests.cs (16 Facts):
   - bulk-update + cross-tenant isolation
   - UserPresets CRUD
   - inline-edit price PATCH
   - CSV import 2 строки транзакцией
   - OrgExport create + list isolation
   - 1C-CSV import с русскими заголовками
   - audit-log export CSV streaming + BOM check
   - MoySklad sync-status stub
   - SSO providers + 503 unconfigured + 400 unknown provider
   - bug-001 NUL byte → 400
   - bug-004 tiny price → 400
   - export CSV BOM
   Покрывает все новые контроллеры Sprint 18-23 + regression-protect
   для критичных багов.

4. Contract tests — deploy/swagger-diff.sh: pull /swagger/v1/swagger.json
   с двух URL, diff endpoints+schemas через python3. Exit 0/1/2 для
   blue-green safety gate. Multi-path auto-detect.

5. docs/error-codes.md — каталог HTTP-кодов API (200-503) + humanizeError
   pattern для фронта + retry-policy таблица.

6. docs/glossary.md — 50+ доменных терминов (Tenant/Organization/Stock/
   StockMovement/RetailSale/Counterparty/Owner/Employee/Role/Permission/
   advisory lock/Serializable/…) с ссылками на code-сущности.

7. docs/ONBOARDING.md — first 3 days для нового разработчика
   (install → запуск → структура → первый PR + FAQ).

8. README.md — обновил под текущее состояние: React 19, Sprint-history
   1-24, ссылки на ключевые docs, корректный 5-min quick start.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-08 02:15:56 +05:00
parent 2bbd078659
commit 72d0a71307
13 changed files with 1983 additions and 80 deletions

170
README.md
View file

@ -1,91 +1,127 @@
# food-market
![CI](http://127.0.0.1:3000/nns/food-market/actions/workflows/ci.yml/badge.svg)
![Docker API](http://127.0.0.1:3000/nns/food-market/actions/workflows/docker-api.yml/badge.svg)
![Stage verify](http://127.0.0.1:3000/nns/food-market/actions/workflows/stage-verify.yml/badge.svg)
![Regression](http://127.0.0.1:3000/nns/food-market/actions/workflows/regression.yml/badge.svg)
[![CI](http://192.168.1.193:3000/nns/food-market/actions/workflows/ci.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
[![Docker API](http://192.168.1.193:3000/nns/food-market/actions/workflows/docker-api.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
[![Stage verify](http://192.168.1.193:3000/nns/food-market/actions/workflows/stage-verify.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
[![Regression](http://192.168.1.193:3000/nns/food-market/actions/workflows/regression.yml/badge.svg)](http://192.168.1.193:3000/nns/food-market/actions)
![coverage](./badges/coverage.svg)
Аналог системы МойСклад для розничной торговли в Казахстане.
Аналог системы МойСклад для розничной торговли в Казахстане. Multi-tenant
SaaS + web-админка + Windows-касса. Поддерживает 8 типов документов учёта,
ОФД-интеграцию (scaffolding), кассу на POS WPF с offline-буфером, отчёты,
loyalty-programs, MoySklad-импорт, GDPR-export.
## Состав системы
## Состав
- **Сервер** (ASP.NET Core 8 + PostgreSQL) — мультитенантный API, web-админка на React
- **Web-админка** (React 18 + Vite + shadcn/ui) — управление магазином, справочниками, документами, отчётами
- **Кассовая программа** (WPF на .NET 8) — офлайн-работоспособная касса для Windows 10+, синхронизируется с сервером, работает с весами (Масса-К в первую очередь)
| Часть | Технологии | Точка входа |
|---|---|---|
| **API** | .NET 8 LTS, ASP.NET Core, EF Core 8, PostgreSQL 16, OpenIddict 5 | `src/food-market.api` → http://localhost:5081 |
| **Web-админка** | React 19, Vite, TypeScript, Tailwind v4, TanStack Query, AG Grid | `src/food-market.web` → http://localhost:5173 |
| **Public marketing** | Astro 5, TypeScript, Tailwind | `src/food-market.public` → http://localhost:4321 |
| **POS-касса** | WPF .NET 8 Windows, SQLite, Refit+Polly, COM-весы | `src/food-market.pos` (сборка кроссплатформенно, UI — Windows) |
## Структура репозитория
## 5-минутный quick start
```bash
git clone http://192.168.1.193:3000/nns/food-market.git
cd food-market
# БД (Postgres 14+ должен быть запущен, default user)
createdb -U $USER food_market
# Backend (миграции применятся на старте; Swagger на /swagger)
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/food-market.api &
# Web SPA
cd src/food-market.web && pnpm install && pnpm dev &
# Зарегистрироваться + получить токен
curl -X POST http://localhost:5081/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"organizationName":"Dev","email":"dev@local.test","password":"DevPass1!","phone":"+77001234567"}'
curl -X POST http://localhost:5081/connect/token \
-d 'grant_type=password&username=dev@local.test&password=DevPass1!&client_id=food-market-web&scope=openid profile email roles api offline_access'
# Открыть http://localhost:5173 → залогиниться dev@local.test / DevPass1!
```
Подробнее — [`docs/ONBOARDING.md`](docs/ONBOARDING.md).
## Где что лежит
```
food-market/
├── src/
│ ├── food-market.domain/ # доменные сущности, enum'ы, события
│ ├── food-market.application/ # use cases (MediatR), DTO, интерфейсы
│ ├── food-market.infrastructure/ # EF Core, PostgreSQL, внешние сервисы
│ ├── food-market.api/ # ASP.NET Core + OpenIddict + SignalR
│ ├── food-market.web/ # React + Vite + shadcn/ui (SPA)
│ ├── food-market.shared/ # DTO-контракты сервер ↔ POS
│ ├── food-market.pos.core/ # логика POS (независима от UI)
│ └── food-market.pos/ # WPF + .NET 8 кассовая программа
│ ├── food-market.domain/ # POCO + enum'ы + interfaces. Без EF / ASP.NET.
│ ├── food-market.application/ # DTO, FluentValidation, MediatR-handler'ы, Mapster.
│ ├── food-market.infrastructure/ # EF Core, миграции, Identity, OpenIddict storage.
│ ├── food-market.api/ # Controllers (60), middleware, Hangfire jobs (10 recurring), OpenIddict server.
│ ├── food-market.web/ # React 19 SPA админки.
│ ├── food-market.public/ # Astro marketing-сайт.
│ ├── food-market.shared/ # DTO-контракты сервер↔POS.
│ ├── food-market.pos.core/ # POS-логика (UI-agnostic).
│ └── food-market.pos/ # WPF (.NET 8 Windows).
├── tests/
├── deploy/
│ ├── docker-compose.yml # PostgreSQL для локальной разработки
│ └── Dockerfile.api
└── docs/
│ ├── food-market.UnitTests/ # xUnit + InMemory EF.
│ ├── food-market.IntegrationTests/# xUnit + Testcontainers Postgres.
│ ├── e2e/ # Playwright + ad-hoc Python smoke.
│ └── load/ # k6 (нагрузочные).
├── deploy/ # Dockerfile.{api,web,public}, compose, nginx, prod-toolchain.
├── docs/ # 50+ markdown файлов.
└── food-market.sln
```
## Именование
## Ключевая документация
- **Папки, проекты, csproj, docker-образы, URL** — lowercase: `food-market`, `food-market.api`
- **C# namespace**`foodmarket.Api`, `foodmarket.Domain` (lowercase root; C# не допускает дефис в идентификаторах)
- **Отображаемое имя в UI** — "Food Market"
## Стек
### Сервер
- .NET 8 LTS (до ноября 2026), ASP.NET Core Minimal APIs + Controllers
- EF Core 8 + Npgsql + PostgreSQL 16
- OpenIddict 5 (OAuth2/OIDC — password + refresh tokens)
- MediatR + FluentValidation (CQRS-lite)
- SignalR (real-time синхронизация)
- Hangfire (фоновые задачи)
- Serilog (структурированное логирование)
### Web
- React 18 + Vite + TypeScript
- shadcn/ui + Tailwind CSS
- TanStack Query + TanStack Table
- AG Grid Community (для тяжёлых grid'ов)
- Recharts / Tremor (графики)
- react-hook-form + Zod
### POS
- .NET 8 WPF, Windows 10+
- CommunityToolkit.Mvvm (source-generated MVVM)
- SQLite (локальная БД)
- Refit + Polly (API-клиент с retry)
- System.IO.Ports (драйверы весов: Масса-К и др.)
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** — общая картина: слои, deployment, что реализовано / scaffolding / не реализовано.
- **[ONBOARDING.md](docs/ONBOARDING.md)** — first 3 days для нового разработчика.
- **[glossary.md](docs/glossary.md)** — все доменные термины с ссылками на код.
- **[MULTI-TENANCY.md](docs/MULTI-TENANCY.md)** — как изолируются org'и.
- **[api-reference.md](docs/api-reference.md)** — auto-generated список всех 195 endpoint'ов.
- **[error-codes.md](docs/error-codes.md)** — каталог HTTP-кодов для humanizeError на фронте.
- **[secrets.md](docs/secrets.md)** — env-vars + где хранятся секреты.
- **[RUNBOOK.md](docs/RUNBOOK.md)** — операционные процедуры (что делать при инциденте).
- **[performance-baseline.md](docs/performance-baseline.md)** — k6 цифры + bottleneck'и.
## Мультитенантность
Каждая сущность имеет `OrganizationId`. Пользователь scoped к организации. EF Core query filter автоматически изолирует данные между тенантами.
Один процесс API обслуживает много организаций. Каждая видит только свои
данные через EF Core query-filter по `OrganizationId`. `SuperAdmin` роль
видит всё. См. [MULTI-TENANCY.md](docs/MULTI-TENANCY.md).
## Локальная разработка
## Деплой
```bash
# Поднять PostgreSQL
cd deploy && docker compose up -d
- **Stage**: `https://test.admin.food-market.kz`. Деплой одной командой:
```bash
~/deploy-stage.sh # docker build api+web → push в local registry → ssh prod-vm → compose up -d
```
- **Prod**: `https://admin.food-market.kz`. Toolchain готов (Sprint 21):
```bash
deploy/check-prod-readiness.sh # backup+disk+health+env
deploy/prod-deploy.sh <api-tag> <web-tag> # blue-green
deploy/prod-rollback.sh <to-tag> # быстрый откат
deploy/post-deploy-smoke.sh # 10 шагов smoke + Telegram alert
```
Реальный prod-сервер пока не настроен (DNS / certbot / nginx upstream).
# Мигрировать БД
cd src/food-market.api && dotnet ef database update
## Sprint-история (что было сделано)
# Запустить API
dotnet run --project src/food-market.api
Хронология в `docs/sprintNN-progress.md`. По состоянию на Sprint 24:
- **1-7** — фундамент: auth (OpenIddict), multi-tenancy, каталог, документы, кассы.
- **8-10** — отчёты, dashboard, dark mode + Cmd+K.
- **11** — ОФД scaffolding (Webkassa / Kassa24 / ОФД-Соло).
- **12-13** — документация / runbook / k6, security headers + rate-limits.
- **14-15** — performance (bundle 51%, индексы, N+1 fix), a11y (WCAG-AA).
- **16-17** — regression suite (44 Playwright specs), onboarding wizard + help.
- **18** — TODO cleanup (P0 race, audit filters, notification center).
- **19** — power UX (bulk-update, presets, Cmd+J, inline-edit, CSV import/export, keyboard nav).
- **20** — Mapster + SSO scaffold + maintenance jobs (cleanup, VACUUM, disk-monitor, perf-regression).
- **21** — stage→prod toolchain (7 deploy-скриптов + auto-tag).
- **22** — data tooling: GDPR-export, 1C-CSV import, anonymize-prod, DB-schema docs, audit export streaming.
- **23** — adversarial bug-hunt (4 bugs found + 4 fixed, includes CRITICAL 40001→500 fix).
- **24** — docs cross-check + auto-generated API reference + ONBOARDING + integration-test gap-fill.
# Запустить Web
cd src/food-market.web && pnpm install && pnpm dev
```
## Лицензия
## Статус
🚧 Phase 0: фундамент (scaffolding, auth, multi-tenancy)
Internal proprietary, не для публикации без разрешения владельца.

127
deploy/swagger-diff.sh Executable file
View file

@ -0,0 +1,127 @@
#!/usr/bin/env bash
#
# Sprint 24: контракт-тест — diff /openapi.json между двумя
# окружениями. Используется ПЕРЕД blue-green деплоем чтобы понять что
# меняется в публичном API и не сломать клиентов (Web admin, POS WPF,
# партнёрские интеграции).
#
# Usage:
# deploy/swagger-diff.sh [--from URL] [--to URL]
#
# Default:
# from = https://admin.food-market.kz (prod)
# to = https://test.admin.food-market.kz (stage)
#
# Что показывает:
# - removed endpoints (path+method) — BREAKING ⚠️
# - added endpoints — NEW (нормально)
# - changed request/response schemas — нужен ручной обзор
#
# Без зависимости от swagger-diff CLI: парсим JSON через python3.
#
# Exit codes:
# 0 — изменений нет ИЛИ только additions
# 1 — есть removed (BREAKING) или changed schemas
# 2 — ошибка получения swagger.json
set -uo pipefail
FROM_URL="${FM_SWAGGER_FROM:-https://admin.food-market.kz}"
TO_URL="${FM_SWAGGER_TO:-https://test.admin.food-market.kz}"
while [[ $# -gt 0 ]]; do
case "$1" in
--from) FROM_URL="$2"; shift 2 ;;
--to) TO_URL="$2"; shift 2 ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
TMP=$(mktemp -d)
trap "rm -rf $TMP" EXIT
# Пытаемся несколько канонических путей: Swashbuckle default + alt-routes.
fetch_swagger() {
local base="$1" out="$2"
for path in /swagger/v1/swagger.json /v1/swagger.json /api/v1/swagger.json; do
if curl -fsS --max-time 30 "$base$path" -o "$out" 2>/dev/null; then
# Должен быть JSON, не HTML (фронт SPA отдаёт index.html на unknown path).
if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$out" 2>/dev/null; then
echo " found at $path" >&2
return 0
fi
fi
done
return 1
}
echo "Fetching from $FROM_URL" >&2
fetch_swagger "$FROM_URL" "$TMP/from.json" \
|| { echo "FAIL: $FROM_URL не отдаёт swagger.json. Проверьте IncludeSwagger=true в appsettings или ASPNETCORE_ENVIRONMENT=Development." >&2; exit 2; }
echo "Fetching from $TO_URL" >&2
fetch_swagger "$TO_URL" "$TMP/to.json" \
|| { echo "FAIL: $TO_URL не отдаёт swagger.json." >&2; exit 2; }
python3 - <<PY
import json, sys
def endpoints(s):
out = set()
for path, methods in s.get('paths', {}).items():
for method, op in methods.items():
if method.lower() in {'get','post','put','patch','delete','head','options'}:
out.add(f"{method.upper()} {path}")
return out
def schemas(s):
return set(s.get('components', {}).get('schemas', {}).keys())
with open('$TMP/from.json') as f: src = json.load(f)
with open('$TMP/to.json') as f: dst = json.load(f)
e_src, e_dst = endpoints(src), endpoints(dst)
s_src, s_dst = schemas(src), schemas(dst)
added_ep = sorted(e_dst - e_src)
removed_ep = sorted(e_src - e_dst)
added_sc = sorted(s_dst - s_src)
removed_sc = sorted(s_src - s_dst)
print(f"=== Swagger diff: $FROM_URL$TO_URL ===")
print(f"endpoints: from={len(e_src)} to={len(e_dst)} added={len(added_ep)} removed={len(removed_ep)}")
print(f"schemas: from={len(s_src)} to={len(s_dst)} added={len(added_sc)} removed={len(removed_sc)}")
print()
if added_ep:
print("### Added endpoints (новые, нормально):")
for e in added_ep: print(f" + {e}")
print()
if removed_ep:
print("### ⚠️ REMOVED endpoints (BREAKING для клиентов!):")
for e in removed_ep: print(f" - {e}")
print()
if added_sc:
print(f"### Added schemas: {len(added_sc)} (показано первые 20)")
for s in added_sc[:20]: print(f" + {s}")
print()
if removed_sc:
print(f"### ⚠️ REMOVED schemas: {len(removed_sc)}")
for s in removed_sc[:20]: print(f" - {s}")
print()
# Изменения операций (опц.): сравнить parameters/responses для shared endpoint'ов.
# Пока — высокоуровневое diff'a достаточно для blue-green safety check.
# Exit code
if removed_ep or removed_sc:
print("RESULT: BREAKING changes detected.")
sys.exit(1)
elif not added_ep and not added_sc:
print("RESULT: schemas identical.")
sys.exit(0)
else:
print("RESULT: только additions, безопасно деплоить.")
sys.exit(0)
PY

263
docs/ONBOARDING.md Normal file
View file

@ -0,0 +1,263 @@
# Onboarding для нового разработчика food-market
Этот документ — путь от «клонировал репо» до «открыл первый PR» за 3 дня.
Если что-то не сходится с реальностью — это **баг документации**,
отредактируй и оставь PR.
## День 1 — установка и первый запуск
### Что нужно
- macOS / Linux. Windows только для POS WPF (см. отдельно ниже).
- .NET 8 SDK (точная версия из `global.json``dotnet --list-sdks`
должен показывать её; если нет — `winget install Microsoft.DotNet.SDK.8`
/ `brew install dotnet@8`).
- Node.js 20+ (`nvm install 20 && nvm use 20`).
- pnpm 9+ (`npm i -g pnpm`).
- PostgreSQL 14+ (на macOS: `brew install postgresql@14 && brew services start postgresql@14`).
- git, curl, python3 (для скриптов в `tests/e2e/`).
- Docker для интеграционных тестов (Testcontainers) и stage-deploy.
### Установка проекта
```bash
git clone http://192.168.1.193:3000/nns/food-market.git
cd food-market
# БД для dev — пустая, инициируется миграциями автоматически.
createdb -U $USER food_market
# Backend
dotnet restore
dotnet build food-market.sln -c Debug --nologo
# Web frontend
cd src/food-market.web && pnpm install && cd ../..
```
### Запуск
```bash
# Терминал 1: API
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/food-market.api
# → http://localhost:5081, Swagger на /swagger
# Терминал 2: Web SPA
cd src/food-market.web && pnpm dev
# → http://localhost:5173
```
### Проверка что работает
```bash
# Health
curl http://localhost:5081/health/ready
# Зарегистрируйся
curl -X POST http://localhost:5081/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"organizationName":"DevOrg","email":"dev@local.test","password":"DevPass123!","phone":"+77001234567"}'
# Логин и получи токен
TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
-d 'grant_type=password&username=dev@local.test&password=DevPass123!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# Что я могу
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | python3 -m json.tool
```
### Тесты
```bash
# Unit
dotnet test tests/food-market.UnitTests --nologo
# Integration (нужен Docker — Testcontainers поднимает Postgres-контейнер)
dotnet test tests/food-market.IntegrationTests --nologo
# E2E (Playwright против локального API)
cd tests/e2e
E2E_ADMIN_URL=http://localhost:5081 ./run.sh stage-smoke
```
## День 2 — где что лежит
### Структура (укрупнённо)
```
food-market/
├── src/
│ ├── food-market.domain/ — POCO + enum'ы + интерфейсы. Без EF, без ASP.NET.
│ ├── food-market.application/ — DTO, FluentValidation, MediatR-handler'ы, Mapster config.
│ ├── food-market.infrastructure/ — EF Core, миграции, Identity, OpenIddict storage.
│ ├── food-market.api/ — Controllers, middleware, Hangfire jobs, OpenIddict server.
│ ├── food-market.web/ — React 19 + Vite SPA админки (admin.food-market.kz).
│ ├── food-market.public/ — Astro marketing-сайт (food-market.kz).
│ ├── food-market.shared/ — DTO-контракты сервер↔POS.
│ ├── food-market.pos.core/ — POS-логика (UI-agnostic).
│ └── food-market.pos/ — WPF (net8.0-windows; собирается на любой OS).
├── tests/
│ ├── food-market.UnitTests/ — xUnit + InMemory EF (быстрые юниты).
│ ├── food-market.IntegrationTests/— xUnit + Testcontainers Postgres (через WebApplicationFactory).
│ ├── e2e/ — Playwright (TS) + ad-hoc Python smoke-скрипты.
│ └── load/ — k6 (нагрузочные).
├── deploy/ — Dockerfile.{api,web,public}, compose, nginx, скрипты (prod-deploy/rollback/smoke/anonymize/swagger-diff).
├── docs/ — markdown (этот файл — `ONBOARDING.md`, плюс ARCHITECTURE/RUNBOOK/etc).
└── food-market.sln
```
### Что почитать в первую очередь
Порядок имеет значение — от general к specific:
1. **[ARCHITECTURE.md](ARCHITECTURE.md)** — общая картина: слои, deployment-топология, ключевые потоки, что реализовано / scaffolding / не реализовано (после 22 спринтов).
2. **[glossary.md](glossary.md)** — все доменные термины (Tenant / Organization / Stock / RetailSale / …) с ссылками на классы.
3. **[MULTI-TENANCY.md](MULTI-TENANCY.md)** — query-filter, SuperAdmin override, как не утечь cross-org.
4. **[DEVELOPER-GUIDE.md](DEVELOPER-GUIDE.md)** — паттерны (CQRS-like, MediatR, валидаторы, Mapster), стиль кода, как добавлять новые endpoint'ы.
5. **[api-reference.md](api-reference.md)** — auto-generated список всех 190+ endpoint'ов (обновляется weekly через Hangfire).
6. **[error-codes.md](error-codes.md)** — каталог HTTP-кодов для humanizeError на фронте.
7. **[secrets.md](secrets.md)** — env-vars + где хранятся секреты.
8. **[observability.md](observability.md)** — Prometheus метрики, Serilog, /health.
9. **[RUNBOOK.md](RUNBOOK.md)** — как разруливать инциденты («api не стартует», «остатки разъехались», и т.п.).
10. **[performance-baseline.md](performance-baseline.md)** — k6 baseline + что НЕ масштабируется.
### Sprint-history
Хронология фич: `docs/sprint1-progress.md``docs/sprint24-progress.md`.
Каждый — что было сделано + цифры. Полезно когда видишь странное имя
файла и хочешь понять «когда и зачем».
### Тестовый стенд
- **Stage**: `https://test.admin.food-market.kz``~/deploy-stage.sh` собирает образ и катит. Подробности в [stage-access.md](stage-access.md).
- **Prod**: `https://admin.food-market.kz`НЕ деплоится автоматически (Sprint 21 toolchain готов, см. `deploy/prod-deploy.sh`, но реальный сервер не настроен).
### Git workflow
- Origin — Forgejo на `http://192.168.1.193:3000/nns/food-market.git`.
GitHub — mirror.
- Никаких force-push'ей в main (после первого тэга).
- Branch для серьёзных фич: `feat/<sprint>-<short-name>`, в Pull Request →
Squash & Merge.
- Каждый коммит на собственной фиче — `feat(scope): subject` (см.
`git log --oneline` для примеров).
## День 3 — первый PR
### Найти первую задачу
- `grep -rn "TODO\|FIXME" src/` — около 30 живых TODO. Самые маленькие
обычно UX-полировка (i18n, copy, validation message).
- `docs/sprintNN-progress.md` последнего спринта → раздел «Открытые TODO».
- В Forgejo Issues (если есть): bug-001..004 в `tests/e2e/reports/bugs/`
— некоторые фиксы уже сделаны, остаются follow-up'ы.
- Слабый шаг: посмотри `docs/performance-baseline.md` раздел «Сводка:
что нужно поправить» — там список задач со статусом ✅/⚠️/❌.
### Что сделать перед PR
1. `git fetch origin && git rebase origin/main` (memory: `feedback_serialize_edits`
мы один-коммитящий-за-раз; не делай параллельных правок).
2. `dotnet build` + `dotnet test` (unit + integration) — должны быть зелёные.
3. `pnpm -C src/food-market.web exec tsc --noEmit` — TS clean.
4. Локальный smoke если правил controller'ы: запусти API + `curl` на затронутый
endpoint.
5. Для UI: открой `/login` локально, проверь что страница работает.
### Шаблон PR-сообщения
```
<тип>(scope): краткое описание
Что: …
Зачем: …
Как тестировал: …
Связанные issue/sprint: …
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> (если работал в паре с Claude)
```
### Кодстайл
- **C#**: дефолтный .NET-стиль. Один файл — один класс. Async/await везде
где I/O. EF-проекции через `.ProjectToType<TDto>(MapsterConfig.Config)`
для новых endpoint'ов (Sprint 20+).
- **TS**: prettier+eslint конфиг в `src/food-market.web`. Hooks naming
`useFoo`. Server-state — TanStack Query, не useState.
- **CSS**: только Tailwind utility-classes. Никаких inline styles.
- **Комментарии**: только если объясняют **почему**, не **что**. Если
переписал паттерн — оставь reference на `[memory:feedback_serialize_edits]`
или sprint-doc.
## FAQ
### Q: API не стартует, ругается на global.json
`dotnet --list-sdks` должен содержать ровно ту версию что в `global.json`
(8.0.417). Если нет — установи SDK. **НЕ редактируй global.json** — это
сломает CI и другие dev-машины.
### Q: Integration-тесты падают с "Cannot find docker daemon"
Включи Docker Desktop / `sudo systemctl start docker`. Testcontainers
тащит `postgres:16-alpine` (один раз, потом из кеша).
### Q: Web стартует но не видит API
Проверь что `src/food-market.web/vite.config.ts` proxy указывает на
`http://localhost:5081`. Если порт API изменился — обнови.
### Q: Сертификат OpenIddict не создаётся
Dev-режим: `App_Data/` должен быть writable. Прод: см.
[openiddict-keys.md](openiddict-keys.md).
### Q: Как добавить новую сущность
Шаги (псевдо-flow):
1. POCO в `src/food-market.domain/<Area>/MyEntity.cs` (от `TenantEntity` если связана с org'ой).
2. DbSet + EntityTypeConfiguration в `src/food-market.infrastructure/Persistence/AppDbContext.cs` + `Configurations/`.
3. Migration в `Migrations/<timestamp>_<name>.cs`**ВРУЧНУЮ**, не через `dotnet ef migrations add`. Обязательны `[Migration("id")]` + `[DbContext(typeof(AppDbContext))]` (memory: `feedback_ef_migrations`).
4. DTO + Validator в `src/food-market.application/<Area>/`.
5. Mapster TypeAdapterConfig в `MapsterConfig.Build()` если есть нетривиальное проецирование.
6. Controller в `src/food-market.api/Controllers/<Area>/`. Atomic per endpoint, multi-tenant через query-filter (автоматически).
7. Integration-тест в `tests/food-market.IntegrationTests/<Area>Tests.cs` — минимум один happy-path.
8. Если возвращаешь в Web — обновить `src/food-market.web/src/lib/types.ts`.
### Q: Как запустить нагрузочный тест
```bash
cd tests/load
BASE_URL=http://localhost:5081 k6 run retail-sales-parallel.js
# или против stage:
BASE_URL=https://test.admin.food-market.kz k6 run signup-burst.js
```
См. [performance-baseline.md](performance-baseline.md) для интерпретации цифр.
### Q: Где POS WPF тестировать
Нужен Windows. На macOS/Linux можно собрать (`dotnet build src/food-market.pos`)
но не запустить UI. Тесты POS-логики в `src/food-market.pos.core`
кроссплатформенные.
### Q: Хочу понять как работает …
- **Tenant isolation**`MULTI-TENANCY.md` + `AppDbContext.ApplyTenantFilter`.
- **OpenIddict**`openiddict-keys.md` + Program.cs `AddOpenIddict()`.
- **POS sync с idempotency**`food-market.pos.core` + `PosBatchAckController`.
- **ОФД**`ofd-integration.md` + `Infrastructure/Fiscal/`.
- **CSV import**`imports.md` + `ProductsController.ImportCsv`.
- **GDPR org export**`OrgExportJob` (Sprint 22).
## Где спрашивать
- Forgejo issues — для багов и feature requests.
- В коде — поиск по docstring (комментарии часто отвечают «почему сделано
именно так»).
- Sprint-progress файлы — там цифры и trade-off'ы зафиксированы.
- Memory-файлы Claude Code в `~/.claude/projects/-home-nns-food-market/memory/`
— что-то типа CHANGELOG развития, более информально.
Welcome! 🚀

545
docs/api-reference.md Normal file
View file

@ -0,0 +1,545 @@
# API endpoint reference
Сгенерировано Python-сканером (`/tmp/gen-api-ref.py`) из `src/food-market.api/Controllers/`.
Идентичный логике runtime-job `ApiReferenceDocsJob` (Sprint 24); тот пересоздаёт файл
еженедельно при cron `Hangfire:Cron:ApiReferenceDocs`.
Всего endpoint'ов: **195**.
Полная OpenAPI-спека: `/swagger/v1/swagger.json`. Этот reference — human-readable summary.
## `AbcReportController`
Base route: `/api/reports/abc`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/reports/abc/export` | — | |
## `AdminCleanupController`
Base route: `/api/admin/cleanup`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/admin/cleanup/all` | — | Полная очистка данных текущей организации — всё кроме настроек: остаются Organization, пользователи,… |
| DELETE | `/api/admin/cleanup/counterparties` | — | Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK, сначала обнуляем ссылки (Pr… |
| GET | `/api/admin/cleanup/stats` | — | |
| POST | `/api/admin/cleanup/all/async` | — | |
## `AdminJobsController`
Base route: `/api/admin/jobs`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/admin/jobs/{id:guid}` | — | |
## `AuthForgotPasswordController`
Base route: `/api/auth`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/api/auth/forgot-password` | — | |
| POST | `/api/auth/reset-password` | — | |
## `AuthSignupController`
Base route: `/api/auth`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/api/auth/signup` | — | |
## `AuthorizationController`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/~/connect/token` | — | |
## `CounterpartiesController`
Base route: `/api/catalog/counterparties`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/counterparties/{id:guid}` | — | |
| GET | `/api/catalog/counterparties/export` | — | Sprint 19: экспорт списка контрагентов. |
| GET | `/api/catalog/counterparties/{id:guid}` | — | |
| POST | `/api/catalog/counterparties` | — | |
| PUT | `/api/catalog/counterparties/{id:guid}` | — | |
## `CountriesController`
Base route: `/api/catalog/countries`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/countries/{id:guid}` | — | |
| GET | `/api/catalog/countries/{id:guid}` | — | |
| POST | `/api/catalog/countries` | — | |
| PUT | `/api/catalog/countries/{id:guid}` | — | |
## `CurrenciesController`
Base route: `/api/catalog/currencies`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/catalog/currencies/{id:guid}` | — | |
| POST | `/api/catalog/currencies` | — | |
| PUT | `/api/catalog/currencies/{id:guid}` | — | |
## `DashboardController`
Base route: `/api/dashboard`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/dashboard/margin` | — | Маржа за окно N дней: выручка минус COGS (Sum(qty * UnitCost) по строкам проданных товаров). Использ… |
## `DemandsController`
Base route: `/api/sales/demands`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/sales/demands/{id:guid}` | — | |
| GET | `/api/sales/demands/{id:guid}` | — | |
| POST | `/api/sales/demands` | — | |
| POST | `/api/sales/demands/{id:guid}/post` | — | |
| POST | `/api/sales/demands/{id:guid}/unpost` | — | |
| PUT | `/api/sales/demands/{id:guid}` | — | |
## `DemoSeedController`
Base route: `/api/admin/seed-demo`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/admin/seed-demo/status` | — | Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы, не вызывает seed. UI использует… |
| POST | `/api/admin/seed-demo` | — | Запустить seed демо-данных. Идемпотентен — если уже наполнено, возвращает existing summary без встав… |
## `DiagnosticController`
Base route: `/api/admin/diagnostic`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/admin/diagnostic/run` | — | |
## `EmployeeRolesController`
Base route: `/api/organization/employee-roles`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/organization/employee-roles/{id:guid}` | — | |
| GET | `/api/organization/employee-roles/{id:guid}` | — | |
| POST | `/api/organization/employee-roles` | — | |
| PUT | `/api/organization/employee-roles/{id:guid}` | — | |
## `EmployeesController`
Base route: `/api/organization/employees`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/organization/employees/{id:guid}` | — | |
| GET | `/api/organization/employees/{id:guid}` | — | |
| POST | `/api/organization/employees` | — | |
| PUT | `/api/organization/employees/{id:guid}` | — | |
## `EntersController`
Base route: `/api/inventory/enters`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/inventory/enters/{id:guid}` | — | |
| GET | `/api/inventory/enters/{id:guid}` | — | |
| POST | `/api/inventory/enters` | — | |
| POST | `/api/inventory/enters/{id:guid}/post` | — | |
| POST | `/api/inventory/enters/{id:guid}/unpost` | — | |
| PUT | `/api/inventory/enters/{id:guid}` | — | |
## `ExternalAuthController`
Base route: `/api/auth/external`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/auth/external/callback` | — | Callback после успешного OAuth у провайдера. Читает claims и решает, что делать: связать с существую… |
| GET | `/api/auth/external/providers` | — | Список доступных SSO-провайдеров. Web-фронт по этому списку решает, какие кнопки рисовать на /login. |
| GET | `/api/auth/external/{provider}` | — | Инициирует OAuth challenge на провайдере. Если провайдер не сконфигурирован — 503 с подсказкой. |
## `FeedbackController`
Base route: `/api/feedback`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/api/feedback` | — | |
## `GlobalSearchController`
Base route: `/api/search`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/search/global` | — | |
## `InventoriesController`
Base route: `/api/inventory/inventories`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/inventory/inventories/{id:guid}` | — | |
| GET | `/api/inventory/inventories/{id:guid}` | — | |
| POST | `/api/inventory/inventories` | — | |
| POST | `/api/inventory/inventories/{id:guid}/post` | — | |
| POST | `/api/inventory/inventories/{id:guid}/unpost` | — | |
| PUT | `/api/inventory/inventories/{id:guid}` | — | |
## `LossesController`
Base route: `/api/inventory/losses`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/inventory/losses/{id:guid}` | — | |
| GET | `/api/inventory/losses/{id:guid}` | — | |
| POST | `/api/inventory/losses` | — | |
| POST | `/api/inventory/losses/{id:guid}/post` | — | |
| POST | `/api/inventory/losses/{id:guid}/unpost` | — | |
| PUT | `/api/inventory/losses/{id:guid}` | — | |
## `LoyaltyCardsController`
Base route: `/api/loyalty/cards`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/loyalty/cards/{id:guid}` | — | |
| GET | `/api/loyalty/cards/lookup` | — | Lookup по CardNumber — используется кассой при оплате. Возвращает 404 если карты нет, 409 если карта… |
| POST | `/api/loyalty/cards/issue` | — | |
| POST | `/api/loyalty/cards/{id:guid}/block` | — | |
| POST | `/api/loyalty/cards/{id:guid}/unblock` | — | |
## `LoyaltyProgramsController`
Base route: `/api/loyalty/programs`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/loyalty/programs/{id:guid}` | — | |
| GET | `/api/loyalty/programs/{id:guid}` | — | |
| POST | `/api/loyalty/programs` | — | |
| PUT | `/api/loyalty/programs/{id:guid}` | — | |
## `MeAccountController`
Base route: `/api/me`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/api/me/change-password` | — | Сменить пароль текущему юзеру. Требует текущий пароль для защиты от случайного/злонамеренного измене… |
## `MeSessionsController`
Base route: `/api/me/sessions`
| Method | Route | Permission | Summary |
|---|---|---|---|
| POST | `/api/me/sessions/revoke-all` | — | Гасит все refresh-токены текущего юзера. Использовать когда есть подозрение на угон cookies/пароля. |
## `MoySkladImportController`
Base route: `/api/admin/moysklad`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/admin/moysklad/settings` | — | |
| POST | `/api/admin/moysklad/import-counterparties` | — | |
| POST | `/api/admin/moysklad/import-products` | — | |
| POST | `/api/admin/moysklad/test` | — | |
| PUT | `/api/admin/moysklad/settings` | — | |
## `MoySkladSyncStatusController`
Base route: `/api/moysklad`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/moysklad/sync-status` | — | |
## `OrgExportController`
Base route: `/api/org/export`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/org/export/download/{token}` | — | Anonymous download по токену. Не требует авторизации — security через 256-битный random token + TTL … |
| GET | `/api/org/export/{id:guid}` | — | |
| POST | `/api/org/export` | — | Создать новый экспорт. Возвращает 202 + Id; полезно сразу polled'ить GET /api/org/export/{id} до Sta… |
## `OrgFiscalSettingsController`
Base route: `/api/organization/fiscal`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/organization/fiscal` | — | |
| GET | `/api/organization/fiscal/providers` | — | Доступные значения провайдера для select'а в UI. Возвращаем массив, потому что enum-значения мы НЕ х… |
| POST | `/api/organization/fiscal/test-send` | — | Тестовая отправка: создаёт «фейк-чек» (in-memory, не в БД) и отправляет через выбранного провайдера.… |
| PUT | `/api/organization/fiscal` | — | |
## `OrganizationSettingsController`
Base route: `/api/organization`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/organization/settings` | — | |
| PUT | `/api/organization/settings` | — | |
## `PlatformSettingsController`
Base route: `/api/super-admin/platform-settings`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/super-admin/platform-settings` | — | |
| POST | `/api/super-admin/platform-settings/test-send` | — | |
| PUT | `/api/super-admin/platform-settings` | — | |
## `PosController`
Base route: `/api/pos/v1`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/pos/v1/sync` | — | |
| POST | `/api/pos/v1/sales` | — | |
## `PriceTypesController`
Base route: `/api/catalog/price-types`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/price-types/{id:guid}` | — | |
| GET | `/api/catalog/price-types/{id:guid}` | — | |
| POST | `/api/catalog/price-types` | — | |
| PUT | `/api/catalog/price-types/{id:guid}` | — | |
## `ProductGroupsController`
Base route: `/api/catalog/product-groups`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/product-groups/{id:guid}` | — | |
| GET | `/api/catalog/product-groups/{id:guid}` | — | |
| POST | `/api/catalog/product-groups` | — | |
| PUT | `/api/catalog/product-groups/{id:guid}` | — | |
## `ProductImagesController`
Base route: `/api/catalog/products/{productId:guid}/images`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/products/{productId:guid}/images/{imageId:guid}` | — | |
| POST | `/api/catalog/products/{productId:guid}/images/{imageId:guid}/main` | — | |
## `ProductsController`
Base route: `/api/catalog/products`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/products/{id:guid}` | — | |
| GET | `/api/catalog/products/by-barcode/{value}` | — | Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект, несколько → { items: [...] } чтобы UI … |
| GET | `/api/catalog/products/export` | — | Sprint 19: экспорт списка товаров с теми же фильтрами что и /api/catalog/products. Сервер-side генер… |
| GET | `/api/catalog/products/{id:guid}` | — | |
| PATCH | `/api/catalog/products/{id:guid}/price` | — | |
| POST | `/api/catalog/products` | — | |
| POST | `/api/catalog/products/bulk-update` | — | |
| POST | `/api/catalog/products/import-csv` | — | |
| POST | `/api/catalog/products/{id:guid}/recalc-retail` | — | «Привести розничную к себестоимости»: ставит дефолтную розничную цену = ceil(Cost * (1 + Group.Marku… |
| PUT | `/api/catalog/products/{id:guid}` | — | |
## `ProfitReportController`
Base route: `/api/reports/profit`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/reports/profit/export` | — | |
## `PromotionsController`
Base route: `/api/promotions`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/promotions/{id:guid}` | — | |
| GET | `/api/promotions/{id:guid}` | — | |
| POST | `/api/promotions` | — | |
| PUT | `/api/promotions/{id:guid}` | — | |
## `RetailPointsController`
Base route: `/api/catalog/retail-points`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/retail-points/{id:guid}` | — | |
| GET | `/api/catalog/retail-points/{id:guid}` | — | |
| POST | `/api/catalog/retail-points` | — | |
| PUT | `/api/catalog/retail-points/{id:guid}` | — | |
## `RetailSalesController`
Base route: `/api/sales/retail`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/sales/retail/{id:guid}` | — | |
| GET | `/api/sales/retail/export` | — | Sprint 19: экспорт списка чеков с фильтрами status/storeId/from/to. |
| GET | `/api/sales/retail/stats` | — | Aggregated sales metrics + daily series for the dashboard. Series buckets are days; defaults to last… |
| GET | `/api/sales/retail/{id:guid}` | — | |
| POST | `/api/sales/retail` | — | |
| POST | `/api/sales/retail/{id:guid}/create-return` | — | POST /create-return — копирует строки проведённого чека в новый Draft с IsReturn=true и ReferenceSal… |
| POST | `/api/sales/retail/{id:guid}/post` | — | |
| POST | `/api/sales/retail/{id:guid}/unpost` | — | |
| PUT | `/api/sales/retail/{id:guid}` | — | |
## `SalesReportController`
Base route: `/api/reports/sales`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/reports/sales/export` | — | |
## `StockController`
Base route: `/api/inventory`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/inventory/stock/export` | — | Sprint 19: экспорт остатков. |
## `StockReportController`
Base route: `/api/reports/stock`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/reports/stock/export` | — | |
## `StoresController`
Base route: `/api/catalog/stores`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/stores/{id:guid}` | — | |
| GET | `/api/catalog/stores/{id:guid}` | — | |
| POST | `/api/catalog/stores` | — | |
| PUT | `/api/catalog/stores/{id:guid}` | — | |
## `SuperAdminController`
Base route: `/api/super-admin`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/super-admin/dashboard` | — | |
| GET | `/api/super-admin/settings` | — | |
| GET | `/api/super-admin/setup-status` | — | |
| PUT | `/api/super-admin/settings` | — | |
## `SuperAdminEmployeesController`
Base route: `/api/super-admin/organizations/{orgId:guid}/employees`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
| GET | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
| POST | `/api/super-admin/organizations/{orgId:guid}/employees` | — | |
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/account/toggle-active` | — | |
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/reset-password` | — | |
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/toggle-active` | — | |
| PUT | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
## `SuperAdminOrganizationsController`
Base route: `/api/super-admin/organizations`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/super-admin/organizations/{id:guid}` | — | |
| GET | `/api/super-admin/organizations/{id:guid}` | — | |
| POST | `/api/super-admin/organizations` | — | |
| POST | `/api/super-admin/organizations/{id:guid}/archive` | — | |
| POST | `/api/super-admin/organizations/{id:guid}/change-owner` | — | |
| POST | `/api/super-admin/organizations/{id:guid}/restore` | — | |
| PUT | `/api/super-admin/organizations/{id:guid}` | — | |
## `SuperAdminUnitsOfMeasureController`
Base route: `/api/super-admin/units-of-measure`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/super-admin/units-of-measure/{id:guid}` | — | Soft-delete: IsActive=false. Если на единицу ссылаются продукты или активные org-junction'ы — 409 со… |
| GET | `/api/super-admin/units-of-measure/{id:guid}` | — | |
| POST | `/api/super-admin/units-of-measure` | — | |
| PUT | `/api/super-admin/units-of-measure/{id:guid}` | — | |
## `SupplierReturnsController`
Base route: `/api/purchases/supplier-returns`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/purchases/supplier-returns/{id:guid}` | — | |
| GET | `/api/purchases/supplier-returns/{id:guid}` | — | |
| POST | `/api/purchases/supplier-returns` | — | |
| POST | `/api/purchases/supplier-returns/{id:guid}/post` | — | |
| POST | `/api/purchases/supplier-returns/{id:guid}/unpost` | — | |
| PUT | `/api/purchases/supplier-returns/{id:guid}` | — | |
## `SuppliesController`
Base route: `/api/purchases/supplies`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/purchases/supplies/{id:guid}` | — | |
| GET | `/api/purchases/supplies/export` | — | Sprint 19: экспорт списка приёмок с теми же фильтрами. |
| GET | `/api/purchases/supplies/{id:guid}` | — | |
| POST | `/api/purchases/supplies` | — | |
| POST | `/api/purchases/supplies/{id:guid}/post` | — | |
| POST | `/api/purchases/supplies/{id:guid}/unpost` | — | |
| PUT | `/api/purchases/supplies/{id:guid}` | — | |
## `TelegramBindingController`
Base route: `/api/organization/telegram`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/organization/telegram` | — | |
| GET | `/api/organization/telegram/status` | — | |
| PUT | `/api/organization/telegram/bind` | — | |
## `TransfersController`
Base route: `/api/inventory/transfers`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/inventory/transfers/{id:guid}` | — | |
| GET | `/api/inventory/transfers/{id:guid}` | — | |
| POST | `/api/inventory/transfers` | — | |
| POST | `/api/inventory/transfers/{id:guid}/post` | — | |
| POST | `/api/inventory/transfers/{id:guid}/unpost` | — | |
| PUT | `/api/inventory/transfers/{id:guid}` | — | |
## `TwoFactorController`
Base route: `/api/me/2fa`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/me/2fa/status` | — | |
| POST | `/api/me/2fa/disable` | — | |
| POST | `/api/me/2fa/enroll` | — | |
| POST | `/api/me/2fa/verify` | — | |
## `UnitsOfMeasureController`
Base route: `/api/catalog/units-of-measure`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/catalog/units-of-measure/{id:guid}/enable` | — | Отключить global для текущей орги. Если на эту единицу ссылаются продукты орги — 409 со списком назв… |
| GET | `/api/catalog/units-of-measure/{id:guid}` | — | |
| POST | `/api/catalog/units-of-measure/{id:guid}/enable` | — | Включить global для текущей орги. Идемпотентно: повторный вызов отдаёт 204 и не плодит дубликатов ju… |
## `UploadsController`
Base route: `/uploads`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/uploads/{*path}` | — | |
## `UserPresetsController`
Base route: `/api/user/presets`
| Method | Route | Permission | Summary |
|---|---|---|---|
| DELETE | `/api/user/presets/{id:guid}` | — | |
| POST | `/api/user/presets` | — | |
| PUT | `/api/user/presets/{id:guid}` | — | |
## `WhatsNewController`
Base route: `/api/whats-new`
| Method | Route | Permission | Summary |
|---|---|---|---|
| GET | `/api/whats-new` | — | |

159
docs/error-codes.md Normal file
View file

@ -0,0 +1,159 @@
# API error catalog
Каталог HTTP-кодов и тел ответов, которые возвращает `food-market.api`.
Используется фронтом для `humanizeError(response)` и QA для regression
проверки. Если поле `error` есть — это user-facing сообщение; `errors`
(множественное) — структурированные ошибки валидации (ASP.NET
ValidationProblemDetails).
## Формат
```jsonc
// Универсальный шаблон single-error:
{ "error": "Понятный текст для пользователя.", "field": "Optional" }
// ValidationProblemDetails (FluentValidation / DataAnnotations):
{ "type": "...", "title": "One or more validation errors occurred.",
"status": 400, "errors": { "Name": ["..."], "Prices[0].Amount": ["..."] } }
// retryable flag (Sprint 23):
{ "error": "...", "retryable": true }
```
## Коды
### 200/201/204 — OK / Created / NoContent
Корректно. Тело — DTO или пусто.
### 400 — Bad Request
| Когда | Тело | Что показать |
|---|---|---|
| Validation от FluentValidation | `ValidationProblemDetails` с `errors.{field}: [msg]` | Подсветить поле + показать сообщение |
| Business-rule (например, draft пустой) | `{error: "Нельзя провести пустой чек."}` | toast + не закрывать форму |
| Сумма оплаты < total | `{error: "Сумма оплаты X меньше итога Y. Доплатите...", field: "PaidCash"}` | подсветить поле PaidCash |
| Required price = 0 после rounding (Sprint 23 bug-004) | `{error: "Цена «X» обязательна и должна быть больше 0."}` | подсветить prices section |
| NUL-byte в строке (Sprint 23 bug-001) | `errors.Name: ["Поле Name не должно содержать управляющих символов..."]` | подсветить поле |
| Дубликат barcode при создании | `{error: "Штрихкод X уже используется товаром «Y»."}` | toast |
| Дубликат артикула | `{error: "Артикул «X» уже занят в этой организации."}` | toast |
| Невалидный CSV / 1С-import | `errors: [{row, error}]` | таблица с подсветкой строк |
### 401 — Unauthorized
| Когда | Тело | Что показать |
|---|---|---|
| Нет токена / устаревший токен | пусто или OpenIddict-`{error: "missing_token"}` | редирект на `/login`, refresh с RT |
| Garbage / tampered JWT | `{error: "missing_token"}` | logout + login |
| Refresh-token недействителен | `{error: "invalid_grant", error_description: "..."}` | logout |
### 403 — Forbidden
| Когда | Тело | Что показать |
|---|---|---|
| Нет permission на mutating action | пусто или ProblemDetails | toast: «Нет прав на это действие» |
| Регулярный Admin лезет в `/hangfire` | пусто | redirect → 404 на фронте |
| Cashier пытается удалить заявку | пусто | скрыть кнопку delete для Cashier |
### 404 — Not Found
| Когда | Что показать |
|---|---|
| Document не найден (включая cross-tenant — нельзя раскрыть существование!) | «Запись не найдена. Возможно, удалена.» |
| Endpoint не существует (типо в URL) | (фронту не должно встречаться) |
### 409 — Conflict
| Когда | Тело | Что показать |
|---|---|---|
| DbUpdateConcurrencyException (xmin) | `{error: "Документ изменён в другом окне..."}` | toast + reload |
| Чек уже проведён, повторный post | `{error: "Чек уже проведён."}` | toast |
| Serialization failure 40001 (Sprint 23 bug-003) | `{error: "Конфликт параллельных операций. Попробуйте ещё раз.", retryable: true}` | **auto-retry один раз**, при повторе — toast |
| Дубликат preset name | `{error: "Пресет с таким именем уже существует..."}` | подсветить input name |
| In-flight org-export ≥3 | `{error: "Уже в очереди 3+ экспорта. Подождите..."}` | toast |
| Удаление непустой группы товаров | `{error: "Нельзя удалить группу, содержащую товары/подгруппы."}` | toast |
### 413 — Payload Too Large
| Когда | Что показать |
|---|---|
| Body > nginx limit (10 MB по default) | «Файл слишком большой. Лимит: 10 МБ.» |
### 429 — Too Many Requests
| Когда | Тело | Что показать |
|---|---|---|
| Rate-limit на signup (3/h IP) | пусто или `Retry-After` header | «Слишком много попыток. Попробуйте через час.» |
| Rate-limit на forgot-password (3/h email + 10/h IP) | то же | то же |
| Rate-limit на feedback (5/час) | то же | то же |
| IP-limit (60/мин общий) | то же | «Слишком много запросов с вашего IP.» |
### 431 — Request Header Fields Too Large
| Когда | Что показать |
|---|---|
| Слишком большие/много HTTP-headers | (нечем фиксить с UI; нечасто) |
### 500 — Internal Server Error
После Sprint 23 — **очень редко**. Если встречается:
- Все NUL-byte 500 → теперь 400 (bug-001).
- Все serialization 40001 → теперь 409 (bug-003).
- Все остальные uncaught exceptions → Serilog лог + `correlation-id` header.
Что показать пользователю: «Произошла ошибка. Попробуйте ещё раз
или сообщите администратору. Код: {x-correlation-id}». Этот correlation
id находится в `x-correlation-id` response-header — записываем в audit.
### 501 — Not Implemented
| Когда | Тело | Что показать |
|---|---|---|
| SSO callback flow (Sprint 20 scaffold) | `{status: "scaffolded", message, email, next}` | «SSO ещё не настроено полностью» |
### 503 — Service Unavailable
| Когда | Тело | Что показать |
|---|---|---|
| SSO провайдер не сконфигурирован | `{error: "SSO для X не настроено.", hint: "..."}` | скрыть кнопку SSO |
| (резерв на maintenance window) | пусто | «Сервис недоступен» |
## humanizeError на фронте
`src/lib/api.ts → humanizeError(err)`:
```typescript
export function humanizeError(err: AxiosError): string {
const data = err.response?.data as any
// 1. Single-error (наш стандарт)
if (data?.error) return data.error
// 2. ValidationProblemDetails
if (data?.errors) {
const first = Object.values(data.errors).flat()[0]
return first ?? 'Ошибка валидации'
}
// 3. По статусу
switch (err.response?.status) {
case 401: return 'Сессия истекла. Войдите снова.'
case 403: return 'Нет прав на это действие.'
case 404: return 'Запись не найдена.'
case 409: return 'Конфликт версий. Перезагрузите страницу.'
case 413: return 'Файл слишком большой.'
case 429: return 'Слишком много запросов. Подождите немного.'
case 500: return `Ошибка сервера. Код: ${err.response.headers['x-correlation-id'] ?? 'unknown'}`
case 503: return 'Сервис временно недоступен.'
}
return err.message ?? 'Неизвестная ошибка'
}
```
## Retry-policy
| Код | Retry? | Условие |
|---|---|---|
| 401 | Один раз — после refresh-token | Если refresh тоже 401 → logout |
| 409 c `retryable: true` | Один авто-retry с задержкой 500ms | Sprint 23 фикс — серверная сторона уже retry'ит до 5 раз, клиентский — дополнительный safety net |
| 429 | Через `Retry-After` секунд (если есть) | Не более 3 попыток |
| 500 | НЕТ авто-retry | Пользователь сам решает |
| 503 | Через 5 секунд | До 2 попыток |
Без auto-retry: 400, 403, 404, 413, 501.

259
docs/glossary.md Normal file
View file

@ -0,0 +1,259 @@
# Глоссарий food-market
Доменные термины, которые используются в коде, документации и общении
с пользователями. Один термин — одно определение. Ссылки на код через
`file:line` или namespace.path.
## Базовые сущности
### Organization (Организация, tenant)
**Корневая сущность мульти-tenancy.** Один процесс API обслуживает много
организаций; каждая видит только свои данные через query-filter по
`OrganizationId`. Не tenant-scoped сама по себе (отношение «один-ко-многим»
с TenantEntity).
Code: `foodmarket.Domain.Organizations.Organization` (`src/food-market.domain/Organizations/Organization.cs`).
См. [MULTI-TENANCY.md](MULTI-TENANCY.md).
### TenantEntity / ITenantEntity
Базовый класс/интерфейс для всех domain-сущностей с `OrganizationId`.
`AppDbContext` автоматически применяет query-filter по reflection.
Code: `foodmarket.Domain.Common.TenantEntity` + `ITenantEntity`.
### IOptionalTenantEntity
Двухуровневые справочники: либо системная запись (OrganizationId=null,
видна всем, мутирует только SuperAdmin), либо tenant'овская.
Пример: `UnitOfMeasure`, `ProductGroup` — есть глобальные «штука», есть
кастомные.
### User
Учётная запись для логина (ASP.NET Identity). НЕ привязан к одной org —
один email может работать в нескольких организациях через Employee.
Code: `foodmarket.Domain.Identity.User`.
### Employee (Сотрудник)
Запись о работнике конкретной org. Может иметь User (для логина) или
быть «без аккаунта» (только в чеках/документах). Связан с EmployeeRole.
Code: `foodmarket.Domain.Organizations.Employee`.
### Owner / AccountOwnerUserId
Первый пользователь, создавший org через signup. Хранится в
`Organization.AccountOwnerUserId`. Не удаляется (кроме как через
SuperAdmin reassign).
### Role / EmployeeRole / RolePermissions
- **Identity Role** (ASP.NET) — системная: `SuperAdmin`, `Admin`, `Cashier`,
`Storekeeper`, `Manager`.
- **EmployeeRole** — per-org кастомная роль (например, «Старший кассир»),
привязана к сотруднику. Имеет `RolePermissions` (флаги типа
`ProductsEdit`, `RetailSalesOperate`).
- **Permission** — атрибут `[RequiresPermission("Name")]` на endpoint'е.
Проверяет `RolePermissions` сотрудника текущего юзера.
### Store (Склад)
Физическое место хранения остатков. У org может быть несколько; первый
после signup — «MAIN» store.
Code: `foodmarket.Domain.Organizations.Store`.
### RetailPoint (Касса / торговая точка)
Привязана к Store, к ней привязывается RetailSale. Может иметь фискальные
поля (FiscalSerial, FiscalRegNumber).
Code: `foodmarket.Domain.Organizations.RetailPoint`.
## Каталог
### Product (Товар)
Единица каталога. Имеет несколько Prices (по типам), Barcodes, Images,
принадлежит ProductGroup. Поля Sprint 19: `IsArchived`, `IsAvailableForSale`.
Code: `foodmarket.Domain.Catalog.Product`.
### ProductGroup (Группа товаров)
Иерархическая (через `ParentId` + `Path`). Корень — «Все товары».
Может быть системной (OrganizationId=null) или per-org.
Code: `foodmarket.Domain.Catalog.ProductGroup`.
### ProductPrice (Цена)
Один товар × один PriceType = одна цена. Тип может быть «системным»
(IsSystem — основная розничная) или «обязательным» (IsRequired — без неё
нельзя сохранить товар).
Code: `foodmarket.Domain.Catalog.ProductPrice`.
### PriceType (Тип цены)
Розничная / Закупочная / Базовая / Себестоимость и т.д. Per-org. Sprint 1.
Code: `foodmarket.Domain.Catalog.PriceType`.
### ProductBarcode (Штрихкод)
Уникальный (составной UNIQUE: Code + Organization). Один товар может
иметь несколько штрихкодов; один из них — `IsPrimary` (показывается на
этикетке).
Code: `foodmarket.Domain.Catalog.ProductBarcode`.
### UnitOfMeasure (Единица измерения)
шт / кг / л / м / упак. Системные (OrganizationId=null) + org-кастомные.
`OrgUnitOfMeasure` — таблица per-org enable/disable.
Code: `foodmarket.Domain.Catalog.UnitOfMeasure`.
### Counterparty (Контрагент)
Поставщик (Supplier) / Покупатель-юрлицо (LegalEntity) / Покупатель-физлицо
(Individual). Имеет БИН/ИИН, банковские реквизиты, контакты.
Code: `foodmarket.Domain.Catalog.Counterparty`.
## Остатки и движения
### Stock (Остаток)
Кеш `SUM(StockMovement.Quantity)` для пары `(Store, Product)`.
Поддерживается транзакционно в каждом posting'е документа.
Code: `foodmarket.Domain.Inventory.Stock`.
### StockMovement (Движение остатка)
Имматериальная запись об изменении остатка. Source: документ
(Supply.Post / RetailSale.Post / Enter.Post / Loss.Post / Transfer.Post /
Inventory.Post / SupplierReturn.Post / CustomerReturn.Post).
**Инвариант**: `Stock.Quantity ≡ Σ StockMovement.Quantity` для каждой
пары (Store, Product). Проверяется property-test'ом (Sprint 15).
Code: `foodmarket.Domain.Inventory.StockMovement`.
## Документы (Documents)
Все имеют поля: `Number`, `Date`, `Status` (Draft/Posted), `PostedAt`.
Имеют `IVersionedEntity` для `xmin` concurrency check.
### Supply (Приёмка)
От поставщика. Увеличивает остаток + пересчитывает скользящую
себестоимость (Product.Cost).
Code: `foodmarket.Domain.Purchases.Supply`.
### Enter (Оприходование)
Внутреннее. Увеличивает остаток без поставщика. Для коррекций инвентаризации.
Code: `foodmarket.Domain.Inventory.Enter`.
### Loss (Списание)
Уменьшает остаток. Причина: порча, кража, тестовое использование.
Code: `foodmarket.Domain.Inventory.Loss`.
### Transfer (Перемещение)
Между складами. Уменьшает на исходном, увеличивает на целевом.
Code: `foodmarket.Domain.Inventory.Transfer`.
### Inventory (Инвентаризация)
Списки фактических остатков. Расхождение → автоматические Enter/Loss
строки при post.
Code: `foodmarket.Domain.Inventory.InventoryDoc` (имя класса не Inventory из-за конфликта с namespace).
### RetailSale (Розничный чек)
Продажа через POS / админку. После Post → уменьшает остаток, пишет ОФД
снапшот (FiscalNumber etc., Sprint 11), уведомляет SignalR.
Code: `foodmarket.Domain.Sales.RetailSale`.
### Demand (Оптовая отгрузка)
Продажа юрлицу. Аналогично RetailSale, но с накладной (печатной формой).
Code: `foodmarket.Domain.Sales.Demand`.
### SupplierReturn (Возврат поставщику)
Sprint 5. Уменьшает остаток + возвращает деньги поставщику.
Code: `foodmarket.Domain.Purchases.SupplierReturn`.
### CustomerReturn / RetailSale.IsReturn=true
Возврат от покупателя. Реализован через флаг `IsReturn` на RetailSale +
ReferenceSaleId. Восстанавливает остаток.
## Деньги
### Cost (Себестоимость)
Скользящее среднее `(qty_old × cost_old + qty_in × price_in) / (qty_old + qty_in)`.
Пересчитывается на каждой проведённой Supply. `Decimal(18,4)`.
### ReferencePrice (Эталонная цена закупа)
Опциональная. Заполняется автоматически unit-price'ом первой Supply;
после 30 дней без новых Supply → Hangfire-job переписывает на Cost.
### VAT (НДС)
- `Product.Vat` (default из `Country.VatRate`, в РК — 12%).
- `Product.VatEnabled` — управляет видимостью поля на UI.
- На документах: `VatMode` (включается «в том числе» / «сверху»).
### AllowFractionalPrices (Дробные цены)
Org-настройка. Если false → все цены округляются до целых при сохранении.
Sprint 23 bug-004: round-then-validate чтобы избежать «0 цена прошла
required-check».
## Доступ и безопасность
### Tenant context
`ITenantContext` (resolved per request) выдаёт `OrganizationId` из JWT
claim `org_id`. NULL для unauthenticated / SuperAdmin-без-override.
### SuperAdmin
Системная роль. Видит все organizations + может «открыть как…» через
`X-Org-Override` header (включает Admin claim для этой org'и).
Все действия SuperAdmin'a в override-режиме пишутся в `super_admin_audit_log`.
### OrgAuditLog
Per-tenant журнал каждой mutate-операции (CREATE/UPDATE/DELETE на
любую TenantEntity). Пишется автоматически через `OrgAuditInterceptor`
на SaveChanges.
### Permission
Атрибут `[RequiresPermission("ProductsEdit")]` на endpoint'е. Проверяет
флаг `RolePermissions` сотрудника текущего юзера. Если у юзера нет
Employee в этой org — 403.
## Фоновые операции
### Hangfire job
.NET background job framework. `recurring-job` (по cron) и
`background-job` (одноразовый). Хранятся в схеме `hangfire` той же БД.
### advisory lock (Sprint 18)
PostgreSQL `pg_advisory_xact_lock(int, int)` — кооперативная блокировка
per-(org, doctype). Используется для сериализации генерации номера
документа.
### Serializable transaction
PostgreSQL Isolation Level. Используется в posting'ах документов
(`RetailSale.Post`, `Supply.Post`, etc.) для защиты от race на
остатках. На конфликте → 40001, теперь мапится в 409 (Sprint 23).
## Внешние интеграции
### ОФД (OFD)
Оператор Фискальных Данных. В РК: Webkassa, Kassa24, ОФД-Соло.
RetailSale.Post после успеха отправляет фискальный документ → получает
`FiscalNumber`, `FiscalQrCode`. Sprint 11 scaffolding.
### МойСклад
Сторонняя SaaS-система учёта. Импорт товаров/контрагентов/остатков по
OAuth-token (per-org в `Organization.MoySkladToken`).
### POS (касса)
WPF-приложение под Windows 10+. Локальный SQLite-буфер, синк через
`/api/pos/v1/*` с idempotency-ключом (см. `pos_batch_acks`).
### Telegram bot
Один platform-bot (token в env). Owner'ы org'и привязывают свой chat-id
(`Organization.OwnerTelegramChatId`) для получения daily-сводки.
## Тестирование
### Stage
`https://test.admin.food-market.kz`. Контейнеры на prod-vm
`192.168.1.190`, deploy через `~/deploy-stage.sh`.
### Smoke / Regression / Verify
- **Smoke** — быстрый sanity-check (5 шагов signup → login → bootstrap).
- **Regression** — полный e2e через Playwright (44 spec'a в Sprint 23).
- **Verify** — спринт-специфичные post-feature тесты.
## Сокращения
| Сокр | Что |
|---|---|
| **AT** | Access Token (JWT, TTL 1h) |
| **RT** | Refresh Token (для получения нового AT) |
| **PoS** | Point of Sale (касса) |
| **ОФД** | Оператор Фискальных Данных |
| **БИН** | 12-цифровой номер юрлица в РК |
| **ИИН** | 12-цифровой номер физлица в РК |
| **RPO** | Recovery Point Objective (макс. потеря данных при backup-restore) |
| **RTO** | Recovery Time Objective (время восстановления) |
| **CSP** | Content Security Policy (HTTP-header) |
| **SA** | SuperAdmin |

View file

@ -14,11 +14,11 @@
## TL;DR — что работает, что нет
| Операция | Здоровый сценарий | Предел до деградации | Узкое место |
|---|---|---|---|
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо (см. ниже) | PG aggregation / connection pool |
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) |
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1 на одном tenant'е → unique-violation race | `GenerateNumberAsync` race condition |
| Операция | Здоровый сценарий | Предел до деградации | Узкое место | Статус |
|---|---|---|---|---|
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо | PG aggregation / connection pool | как есть |
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) | как есть |
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1: race на номере (23505) и serialization conflict (40001) | `GenerateNumberAsync` race + Serializable | ✅ Sprint 18: advisory lock убил 23505. Sprint 23: 40001 теперь корректные 409 (было 500). |
## Прогон 1: signup-burst
@ -142,14 +142,14 @@ Tenant с **1500 проведённых чеков, 5535 stock_movements, 200 т
## Сводка: что нужно поправить
| Приоритет | Что | Где |
|---|---|---|
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs:957` |
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` |
| 🟡 P1 | `SaveOrFkErrorAsync` не ловит 23505 (unique violation) | `RetailSalesController.cs:418` |
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` |
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability |
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` |
| Приоритет | Что | Где | Статус |
|---|---|---|---|
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs` | ✅ Зафиксен в Sprint 18 через PostgreSQL advisory lock (`DocumentNumberRetry.WithOrgAdvisoryLockAsync` per (orgHash, docTypeHash)). Воспроизводится: 23505 ошибки 53% → 0. |
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` | ⚠️ Helper `DocumentNumberRetry` готов, но к Supplies/Demands ещё не применён. TODO для будущего спринта. |
| 🟡 P1 | 40001 Serializable conflict при concurrent /post → 500 | `RetailSalesController.Post` | ✅ Зафиксен в Sprint 23: `SerializationConflictMiddleware` мапит 40001 → 409 + `SerializableRetry` helper (exp backoff) применён к `RetailSale.PostCoreAsync`. После: 20 параллельных продаж → 0 × 500, 6 ok + 14 × 409, stock invariant сохраняется. |
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` | ✅ Sprint 20: `DatabaseMaintenanceJobs.VacuumTopTablesAsync` weekly воскр 04:00 UTC. |
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability | ⚠️ Sprint 20 добавил `~/nightly-perf-check.sh` (sliding baseline + Telegram). Реальные Prometheus alert-rules — не настроены. |
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` |Не реализовано. |
## Воспроизведение

View file

@ -23,6 +23,22 @@ chmod 600 deploy/.env # ограничить доступ
| `Cors__AllowedOrigins__N` | — | CORS-origins | API | переопределяет `appsettings.json` |
| `RateLimiting__*` | — | антибрутфорс лимиты | API | дефолты 5/мин, 20/час |
| `MoySklad__BaseUrl` | — | база API МойСклад | API | дефолт боевой `api.moysklad.ru` |
| `Telegram__BotToken` | — | токен Telegram-бота для alert'ов и owner-сводки | `OwnerDailySummaryJob`, `DiskMonitoringJob` | bot @BotFather |
| `Telegram__BotUsername` | — | username бота (без @) для deep-link'ов в notify | `TelegramBindingController` | у бота в Telegram |
| `Authentication__Google__ClientId` / `__ClientSecret` | — | OAuth Google SSO (Sprint 20 scaffold) | API `ExternalAuthController` | см. `docs/sso.md` |
| `Authentication__Microsoft__ClientId` / `__ClientSecret` | — | OAuth Microsoft SSO (Sprint 20 scaffold) | API `ExternalAuthController` | см. `docs/sso.md` |
| `Monitoring__DiskPaths` | — | CSV mount-paths для disk-monitor (default `/opt,/var/lib/docker`) | `DiskMonitoringJob` (Sprint 20) | список mount'ов хоста |
| `Monitoring__DiskMinFreeBytes` | — | порог alert'a (default 1 GB) | `DiskMonitoringJob` | в байтах |
| `Monitoring__DiskAlertCooldownHours` | — | антиспам для disk-alert (default 6h) | `DiskMonitoringJob` | в часах |
| `Monitoring__SuperAdminTelegramChatIds` | — | CSV chat-id'ы для disk/perf alert'ов | `DiskMonitoringJob`, `nightly-perf-check.sh` | chat-id юзера |
| `Cleanup__DraftDays` / `__OrgAuditLogDays` / `__RevokedRefreshTokenDays` | — | retention для cleanup-job'ов (default 30 / 90 / 7) | `HousekeepingJobs` (Sprint 20) | в днях |
| `Hangfire__Retention__StockMovementDays` / `__AuditLogDays` | — | retention для prune'ов | `HousekeepingJobs` | в днях |
| `Hangfire__Cron__*` | — | переопределение cron-расписания jobs | `HangfireJobsConfigurator` | стандартный 5-полевой cron |
| `Maintenance__VacuumTopN` | — | сколько таблиц VACUUM ANALYZE еженедельно (default 5) | `DatabaseMaintenanceJobs` (Sprint 20) | int |
| `App__PublicBaseUrl` | — | публичный URL админки (для email-link'ов на GDPR-export) | `OrgExportJob` (Sprint 22) | напр. `https://admin.food-market.kz` |
| `Storage__Type` | — | `local` (default, `/uploads` volume) или `minio` | `StorageBootstrap` | строка |
| `Storage__Minio__Endpoint` / `__AccessKey` / `__SecretKey` / `__Bucket` | — | конфиг MinIO/S3 если Type=minio | `MinioObjectStorage` | у провайдера S3 |
| `PUBLIC_GA_ID` / `PUBLIC_YM_ID` | — | Google Analytics 4 / Yandex.Metrika ID на marketing-сайте (Sprint 20) | Astro `BaseLayout.astro` | см. `docs/analytics.md` |
> `__` (двойное подчёркивание) — разделитель секций конфигурации .NET
> (`OpenIddict__Issuer` ≡ `OpenIddict:Issuer`).

26
docs/sprint24-progress.md Normal file
View file

@ -0,0 +1,26 @@
# Sprint 24 — docs cross-check + tests gap fill
Цель: после 23 спринтов docs/ содержит 50+ md файлов. Часть точно
устарела. Прогнать на актуальность + заполнить пробелы в integration
тестах.
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7.
## Чек-лист
- [ ] **1. Docs vs code cross-check** — пройти каждый md, проверить
endpoints/классы/переменные. Устаревшее обновить, TODO явные.
- [ ] **2. Auto-generated endpoint reference**`docs/api-reference.md`
через сканер `[ApiController]` + Hangfire weekly.
- [ ] **3. Coverage gap analysis** — coverlet → классы <50% +тесты,
lcov-отчёт до/после.
- [ ] **4. Contract tests stage vs prod**`/swagger.json` diff.
- [ ] **5. Error code catalog**`docs/error-codes.md`.
- [ ] **6. Glossary**`docs/glossary.md`.
- [ ] **7. Onboarding pack**`docs/ONBOARDING.md`.
- [ ] **8. README.md update** — badges + 5-min quick start.
## Журнал
### 2026-06-08 старт
Sprint 23 закрыт (4 bugs found, 4 fixed). Поехали по docs.

View file

@ -0,0 +1,153 @@
using System.Text;
using System.Text.RegularExpressions;
namespace foodmarket.Api.Background;
/// <summary>Sprint 24: weekly-job для генерации `docs/api-reference.md`.
///
/// Полноценный Roslyn-анализатор тянул бы Microsoft.CodeAnalysis (1+ МБ
/// в runtime image), что overkill для weekly-job'a. Здесь — regex-сканер
/// по C#-файлам контроллеров: ищет `[Route(...)]`, `[Http*(...)]`,
/// `[RequiresPermission(...)]`, имена методов + DocComment-summary.
///
/// Источник правды — реально скомпилированный API (доступен Swagger через
/// `/swagger/v1/swagger.json`); этот reference — human-readable summary.
/// Для контракт-тестов используется swagger.json напрямую.</summary>
public class ApiReferenceDocsJob
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<ApiReferenceDocsJob> _log;
public ApiReferenceDocsJob(IWebHostEnvironment env, ILogger<ApiReferenceDocsJob> log)
{
_env = env;
_log = log;
}
/// <summary>Сканит каталог Controllers/ в content-root API,
/// извлекает endpoint metadata, генерирует markdown в указанный
/// outputPath. Возвращает количество обнаруженных endpoint'ов.</summary>
public Task<int> GenerateAsync(CancellationToken ct = default)
{
var root = _env.ContentRootPath;
// На stage/prod исходники не лежат — контейнер только publish dll.
// Если нет каталога Controllers/ — записываем заглушку с указанием
// что docs генерится только в dev-окружении.
var srcDir = Path.Combine(root, "Controllers");
var altDir = Path.Combine(root, "..", "src", "food-market.api", "Controllers");
string scanDir;
if (Directory.Exists(srcDir)) scanDir = srcDir;
else if (Directory.Exists(altDir)) scanDir = altDir;
else
{
var note = $@"# API endpoint reference
> Этот файл генерируется из исходников `Controllers/*.cs`.
> На stage/prod-контейнере исходников нет здесь только runtime publish.
> Полный reference в репозитории `docs/api-reference.md`, генерится в dev
> через `dotnet run` локально.
Generated at: {DateTime.UtcNow:O}
";
var notePath = Path.Combine(root, "api-reference-generated.md");
File.WriteAllText(notePath, note);
_log.LogInformation("ApiReferenceDocs: no source dir, wrote stub to {Path}", notePath);
return Task.FromResult(0);
}
var endpoints = ScanDir(scanDir).ToList();
var sb = new StringBuilder();
sb.AppendLine("# API endpoint reference");
sb.AppendLine();
sb.AppendLine($"Сгенерировано автоматически из `Controllers/*.cs`: {DateTime.UtcNow:O}.");
sb.AppendLine();
sb.AppendLine($"Всего endpoint'ов: **{endpoints.Count}**.");
sb.AppendLine();
sb.AppendLine("Полная OpenAPI-спека: `/swagger/v1/swagger.json`. Этот reference — human-readable summary.");
sb.AppendLine();
foreach (var grp in endpoints.GroupBy(e => e.ControllerName).OrderBy(g => g.Key))
{
sb.AppendLine($"## `{grp.Key}`");
if (!string.IsNullOrEmpty(grp.First().BaseRoute))
sb.AppendLine($"Base route: `{grp.First().BaseRoute}`");
sb.AppendLine();
sb.AppendLine("| Method | Route | Permission | Summary |");
sb.AppendLine("|---|---|---|---|");
foreach (var e in grp.OrderBy(e => e.Route))
{
var perm = string.IsNullOrEmpty(e.Permission) ? "—" : $"`{e.Permission}`";
var sum = string.IsNullOrEmpty(e.Summary) ? "" : e.Summary.Replace("|", "\\|");
if (sum.Length > 100) sum = sum[..100] + "…";
sb.AppendLine($"| {e.HttpMethod} | `{e.Route}` | {perm} | {sum} |");
}
sb.AppendLine();
}
var outPath = Path.Combine(root, "api-reference-generated.md");
File.WriteAllText(outPath, sb.ToString());
_log.LogInformation("ApiReferenceDocs: {Count} endpoints → {Path}", endpoints.Count, outPath);
return Task.FromResult(endpoints.Count);
}
private record EndpointInfo(
string ControllerName, string BaseRoute, string HttpMethod, string Route,
string Permission, string Summary);
private static IEnumerable<EndpointInfo> ScanDir(string dir)
{
foreach (var file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories))
{
var text = File.ReadAllText(file);
// Class declaration + base [Route]
var classMatch = Regex.Match(text, @"\[Route\(""([^""]+)""\)\][\s\S]{0,500}?class\s+(\w+Controller)");
string baseRoute = "", className = "";
if (classMatch.Success)
{
baseRoute = classMatch.Groups[1].Value;
className = classMatch.Groups[2].Value;
}
else
{
var classOnly = Regex.Match(text, @"class\s+(\w+Controller)");
if (!classOnly.Success) continue;
className = classOnly.Groups[1].Value;
}
// Endpoints: ищем [HttpX(...)] + опц [RequiresPermission(...)] + следующий метод.
// Также берём предшествующий /// <summary>... — для column Summary.
var endpointRx = new Regex(
@"(?<doc>///[^\n]*(?:\n[^\n]*///[^\n]*)*)?\s*" +
@"(?<attrs>(?:\[(?:Http\w+|Authorize|RequiresPermission|AllowAnonymous|Consumes)[^\]]*\]\s*,?\s*)+)" +
@"public\s+(?:async\s+)?(?:Task<\w+(?:<[^>]+>)?>?|IActionResult|ActionResult<[^>]+>|void)\s+(?<method>\w+)\s*\(",
RegexOptions.Multiline);
foreach (Match m in endpointRx.Matches(text))
{
var attrs = m.Groups["attrs"].Value;
var http = Regex.Match(attrs, @"\[Http(\w+)(?:\(""([^""]*)""\))?");
if (!http.Success) continue;
var httpMethod = http.Groups[1].Value.ToUpperInvariant();
var subRoute = http.Groups[2].Success ? http.Groups[2].Value : "";
var fullRoute = "/" + string.Join("/", new[] { baseRoute, subRoute }.Where(s => !string.IsNullOrEmpty(s))).Trim('/');
var permMatch = Regex.Match(attrs, @"\[RequiresPermission\(""([^""]+)""\)\]");
var perm = permMatch.Success ? permMatch.Groups[1].Value : "";
var docRaw = m.Groups["doc"].Value;
var summary = "";
if (!string.IsNullOrEmpty(docRaw))
{
var sumMatch = Regex.Match(docRaw, @"<summary>\s*(.*?)\s*</summary>", RegexOptions.Singleline);
if (sumMatch.Success)
{
summary = Regex.Replace(sumMatch.Groups[1].Value, @"\s*///\s*", " ");
summary = Regex.Replace(summary, @"\s+", " ").Trim();
}
}
yield return new EndpointInfo(className, "/" + baseRoute.Trim('/'), httpMethod, fullRoute, perm, summary);
}
}
}
}

View file

@ -87,6 +87,14 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronSchema,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 24: weekly API endpoint reference generation.
var cronApiRef = _cfg["Hangfire:Cron:ApiReferenceDocs"] ?? "30 5 * * 0"; // Воскресенье 05:30 UTC
_jobs.AddOrUpdate<ApiReferenceDocsJob>(
recurringJobId: "api-reference-docs",
methodCall: j => j.GenerateAsync(CancellationToken.None),
cronExpression: cronApiRef,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде

View file

@ -413,6 +413,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Sprint 22: GDPR org export + DB schema docs.
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
// Sprint 24: API endpoint reference docs (weekly).
builder.Services.AddScoped<foodmarket.Api.Background.ApiReferenceDocsJob>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token

View file

@ -0,0 +1,309 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 24: интеграция-покрытие фич Sprint 18-23, которых не было
/// в коде-base'е до этого. Цель — поднять coverage по новым контроллерам
/// (BulkUpdate, UserPresets, OrgExport, ExternalAuth, MoySkladSync,
/// audit-log/export, 1C-import) и зафиксировать защиту от регрессии багов
/// Sprint 23 (bug-001 NUL byte → 400, bug-003 40001 → 409, bug-004 round
/// → required check).
///
/// Один файл с группой [Fact] чтобы получить компактный AAA-блок на каждую
/// фичу. Использует существующий ApiActor + ApiFactory (Testcontainers
/// Postgres). Каждый Fact создаёт изолированную org (slug = test-name +
/// random) — нет cross-test leakage.</summary>
[Collection(ApiCollection.Name)]
public class Sprint18To23FeaturesTests
{
private readonly ApiFactory _factory;
public Sprint18To23FeaturesTests(ApiFactory factory) => _factory = factory;
// ── Sprint 19: bulk-update ────────────────────────────────────────────
[Fact]
public async Task BulkUpdate_archive_marks_products_archived()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"bulk-arch-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
var p1 = await a.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 100m, $"BC1-{Guid.NewGuid():N}");
var p2 = await a.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 200m, $"BC2-{Guid.NewGuid():N}");
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new
{
ids = new[] { p1, p2 },
op = "archive",
@params = new { },
});
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("affected").GetInt32().Should().Be(2);
// Оба товара должны иметь IsArchived = true.
var read1 = await a.GetJsonAsync($"/api/catalog/products/{p1}");
read1.GetProperty("isArchived").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkUpdate_cross_tenant_returns_affected_zero()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"bulk-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"bulk-iso-b-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
var pA = await a.CreateProductAsync(refs, "A-prod", 100m, $"BCA-{Guid.NewGuid():N}");
using var resp = await b.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new
{
ids = new[] { pA }, op = "archive", @params = new { },
});
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("affected").GetInt32().Should().Be(0, "tenant isolation должна отбрасывать чужие id");
// У A товар не архивирован
var read = await a.GetJsonAsync($"/api/catalog/products/{pA}");
read.GetProperty("isArchived").GetBoolean().Should().BeFalse();
}
// ── Sprint 19: UserPresets ────────────────────────────────────────────
[Fact]
public async Task UserPresets_per_user_in_org_isolated()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"preset-{Guid.NewGuid():N}");
using var create = await a.Http.PostAsJsonAsync("/api/user/presets", new
{
pageKey = "products", name = "Test", configJson = "{\"foo\":1}",
});
create.StatusCode.Should().Be(HttpStatusCode.OK);
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var list = await a.ListAsync("/api/user/presets?pageKey=products");
list.Should().ContainSingle(p => p.GetProperty("id").GetString() == id);
using var del = await a.Http.DeleteAsync($"/api/user/presets/{id}");
del.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
// ── Sprint 19: inline-edit price ─────────────────────────────────────
[Fact]
public async Task PatchPrice_updates_amount_with_rounding()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"price-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
var pid = await a.CreateProductAsync(refs, "X", 100m, $"PB-{Guid.NewGuid():N}");
using var patch = await a.Http.PatchAsync($"/api/catalog/products/{pid}/price",
JsonContent.Create(new { priceTypeId = refs.PriceTypeId, amount = 555m }));
patch.StatusCode.Should().Be(HttpStatusCode.OK);
var read = await a.GetJsonAsync($"/api/catalog/products/{pid}");
var amount = read.GetProperty("prices").EnumerateArray()
.First(p => p.GetProperty("priceTypeId").GetString() == refs.PriceTypeId)
.GetProperty("amount").GetDecimal();
amount.Should().Be(555m);
}
// ── Sprint 19: CSV import — transactional ────────────────────────────
[Fact]
public async Task ImportCsv_two_rows_creates_two_products()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"csv-{Guid.NewGuid():N}");
var ts = Guid.NewGuid().ToString("N");
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/import-csv", new
{
rows = new[] {
new { name = $"CSV-A-{ts}", price = 100m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV1-{ts}" },
new { name = $"CSV-B-{ts}", price = 200m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV2-{ts}" },
},
autoCreateGroup = true,
});
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("created").GetInt32().Should().Be(2);
}
// ── Sprint 22: GDPR org export ────────────────────────────────────────
[Fact]
public async Task OrgExport_creates_pending_and_lists_self_only()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"export-{Guid.NewGuid():N}");
using var resp = await a.Http.PostAsync("/api/org/export", null);
resp.StatusCode.Should().Be(HttpStatusCode.Accepted);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var id = body.GetProperty("id").GetString();
id.Should().NotBeNullOrEmpty();
// List видит как минимум этот один.
var list = await a.ListAsync("/api/org/export");
list.Should().Contain(e => e.GetProperty("id").GetString() == id);
}
// ── Sprint 22: 1C CSV import (auto-detect charset) ───────────────────
[Fact]
public async Task Import1cCsv_with_russian_headers_creates_product()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"1c-{Guid.NewGuid():N}");
var ts = Guid.NewGuid().ToString("N");
var csv = "\"Артикул\";\"Наименование\";\"Единица\";\"Цена\";\"Группа\";\"Штрихкод\"\n" +
$"\"ART-{ts}\";\"Молоко-{ts}\";\"шт\";\"450\";\"1С-grp-{ts}\";\"1C-{ts}\"";
var content = new ByteArrayContent(Encoding.UTF8.GetBytes(csv));
content.Headers.Add("Content-Type", "text/csv; charset=utf-8");
using var resp = await a.Http.PostAsync("/api/catalog/products/import/1c-csv?autoCreateGroup=true", content);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("created").GetInt32().Should().Be(1);
}
// ── Sprint 22: audit-log streaming export ────────────────────────────
[Fact]
public async Task AuditLogExport_csv_streams_with_bom()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}");
// Create something audit-able.
await a.CreateCounterpartyAsync($"audit-cp-{Guid.NewGuid():N}");
using var resp = await a.Http.PostAsync("/api/admin/audit-log/export?format=csv", null);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
resp.Content.Headers.ContentType?.MediaType.Should().Be("text/csv");
var bytes = await resp.Content.ReadAsByteArrayAsync();
bytes.Length.Should().BeGreaterThan(3);
// UTF-8 BOM
bytes[0].Should().Be(0xEF);
bytes[1].Should().Be(0xBB);
bytes[2].Should().Be(0xBF);
}
// ── Sprint 22: MoySklad sync-status stub ─────────────────────────────
[Fact]
public async Task MoySkladSyncStatus_returns_configured_false_when_no_token()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"ms-{Guid.NewGuid():N}");
var body = await a.GetJsonAsync("/api/moysklad/sync-status");
body.GetProperty("configured").GetBoolean().Should().BeFalse();
body.GetProperty("pendingCount").GetInt32().Should().Be(0);
}
// ── Sprint 20: SSO external auth ──────────────────────────────────────
[Fact]
public async Task SsoProviders_returns_both_false_when_unconfigured()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"sso-{Guid.NewGuid():N}");
var body = await a.GetJsonAsync("/api/auth/external/providers");
body.GetProperty("google").GetBoolean().Should().BeFalse();
body.GetProperty("microsoft").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task SsoChallenge_unconfigured_returns_503_with_hint()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"sso2-{Guid.NewGuid():N}");
using var resp = await a.Http.GetAsync("/api/auth/external/google");
resp.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().Should().Contain("Google");
}
[Fact]
public async Task SsoChallenge_unknown_provider_returns_400()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"sso3-{Guid.NewGuid():N}");
using var resp = await a.Http.GetAsync("/api/auth/external/whatever");
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── Sprint 23: bug-001 NUL byte → 400 ──────────────────────────────
[Fact]
public async Task ProductCreate_with_null_byte_in_name_returns_400()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"nul-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "HelloWorld",
unitOfMeasureId = refs.UnitId,
productGroupId = refs.GroupId,
vat = 0,
vatEnabled = true,
barcodes = new[] { new { code = $"NUL-{Guid.NewGuid():N}", type = 0, isPrimary = true } },
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } },
});
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── Sprint 23: bug-004 tiny price round-then-validate ───────────────
[Fact]
public async Task ProductCreate_with_tiny_price_returns_400_after_rounding()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"tiny-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = $"tiny-{Guid.NewGuid():N}",
unitOfMeasureId = refs.UnitId,
productGroupId = refs.GroupId,
vat = 0,
vatEnabled = true,
barcodes = new[] { new { code = $"TINY-{Guid.NewGuid():N}", type = 0, isPrimary = true } },
// 0.0000001 — округлится в 0; required price > 0 теперь проверяется ПОСЛЕ rounding.
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 0.0000001m, currencyId = refs.CurrencyId } },
});
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().Should().Contain("больше 0");
}
// ── Sprint 19: export CSV ──────────────────────────────────────────
[Fact]
public async Task ProductsExport_csv_returns_text_csv_with_bom()
{
var a = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"exp-csv-{Guid.NewGuid():N}");
var refs = await a.LoadRefsAsync();
await a.CreateProductAsync(refs, "ExpProd", 100m, $"EXP-{Guid.NewGuid():N}");
using var resp = await a.Http.GetAsync("/api/catalog/products/export?format=csv");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
resp.Content.Headers.ContentType?.MediaType.Should().Be("text/csv");
var bytes = await resp.Content.ReadAsByteArrayAsync();
bytes.Length.Should().BeGreaterThan(100);
// UTF-8 BOM
bytes.Take(3).Should().Equal(new byte[] { 0xEF, 0xBB, 0xBF });
}
}