diff --git a/README.md b/README.md index ab70ede..93ea780 100644 --- a/README.md +++ b/README.md @@ -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 # blue-green + deploy/prod-rollback.sh # быстрый откат + 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, не для публикации без разрешения владельца. diff --git a/deploy/swagger-diff.sh b/deploy/swagger-diff.sh new file mode 100755 index 0000000..c4ab2ba --- /dev/null +++ b/deploy/swagger-diff.sh @@ -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 - <-`, в 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 (если работал в паре с Claude) +``` + +### Кодстайл + +- **C#**: дефолтный .NET-стиль. Один файл — один класс. Async/await везде + где I/O. EF-проекции через `.ProjectToType(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//MyEntity.cs` (от `TenantEntity` если связана с org'ой). +2. DbSet + EntityTypeConfiguration в `src/food-market.infrastructure/Persistence/AppDbContext.cs` + `Configurations/`. +3. Migration в `Migrations/_.cs` — **ВРУЧНУЮ**, не через `dotnet ef migrations add`. Обязательны `[Migration("id")]` + `[DbContext(typeof(AppDbContext))]` (memory: `feedback_ef_migrations`). +4. DTO + Validator в `src/food-market.application//`. +5. Mapster TypeAdapterConfig в `MapsterConfig.Build()` если есть нетривиальное проецирование. +6. Controller в `src/food-market.api/Controllers//`. Atomic per endpoint, multi-tenant через query-filter (автоматически). +7. Integration-тест в `tests/food-market.IntegrationTests/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! 🚀 diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..cc0282f --- /dev/null +++ b/docs/api-reference.md @@ -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` | — | | diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 0000000..cb5d116 --- /dev/null +++ b/docs/error-codes.md @@ -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. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..bed8acf --- /dev/null +++ b/docs/glossary.md @@ -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 | diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index e9a01e6..13c7320 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -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` | ❌ Не реализовано. | ## Воспроизведение diff --git a/docs/secrets.md b/docs/secrets.md index 890634c..2ab1f38 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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`). diff --git a/docs/sprint24-progress.md b/docs/sprint24-progress.md new file mode 100644 index 0000000..e17e9b4 --- /dev/null +++ b/docs/sprint24-progress.md @@ -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. diff --git a/src/food-market.api/Background/ApiReferenceDocsJob.cs b/src/food-market.api/Background/ApiReferenceDocsJob.cs new file mode 100644 index 0000000..d9b20a9 --- /dev/null +++ b/src/food-market.api/Background/ApiReferenceDocsJob.cs @@ -0,0 +1,153 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace foodmarket.Api.Background; + +/// 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 напрямую. +public class ApiReferenceDocsJob +{ + private readonly IWebHostEnvironment _env; + private readonly ILogger _log; + + public ApiReferenceDocsJob(IWebHostEnvironment env, ILogger log) + { + _env = env; + _log = log; + } + + /// Сканит каталог Controllers/ в content-root API, + /// извлекает endpoint metadata, генерирует markdown в указанный + /// outputPath. Возвращает количество обнаруженных endpoint'ов. + public Task 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 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(...)] + следующий метод. + // Также берём предшествующий /// ... — для column Summary. + var endpointRx = new Regex( + @"(?///[^\n]*(?:\n[^\n]*///[^\n]*)*)?\s*" + + @"(?(?:\[(?:Http\w+|Authorize|RequiresPermission|AllowAnonymous|Consumes)[^\]]*\]\s*,?\s*)+)" + + @"public\s+(?:async\s+)?(?:Task<\w+(?:<[^>]+>)?>?|IActionResult|ActionResult<[^>]+>|void)\s+(?\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, @"\s*(.*?)\s*", 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); + } + } + } +} diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index a770f40..3a2ab6b 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -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( + 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-ы конфигурируются — на тестовом стенде diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index c2dfb0b..dc51d7f 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -413,6 +413,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Sprint 22: GDPR org export + DB schema docs. builder.Services.AddScoped(); builder.Services.AddScoped(); + // Sprint 24: API endpoint reference docs (weekly). + builder.Services.AddScoped(); builder.Services.AddScoped(); // Telegram-бот владельца. Token + username берём из конфига; если token diff --git a/tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs b/tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs new file mode 100644 index 0000000..ae740d3 --- /dev/null +++ b/tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs @@ -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; + +/// 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. +[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(); + 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(); + 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()).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(); + 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(); + 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(); + 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(); + 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(); + 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 }); + } +}