docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI

Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.

Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
  root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
  stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
  с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
  disaster-recovery на новый сервер, 6 описанных инцидентов
  (включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
  полные паттерны (controller с permission + tenant-сущность с
  RowVersion + 5 шагов миграции), валидация, structured-логирование,
  «НЕ делать» список.

k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
  sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
  * signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
  * retail-sale sequential — 17/sec, p95 71ms;
  * retail-sale @ VU>1 — 53% failure из-за race в
    GenerateNumberAsync (unique-violation 23505 не ловится в
    SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
  * reports на 1500 чеков — p95 50-114ms до VU=5.

CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
  API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
  5 этапов: health, signup, token, multi-tenant изоляция (B → 404
  на product A, B → пустой список), полный документ-цикл
  (supplier+supply.post → stock=100 → sale.post → stock=99).
  Локальный прогон против stage — все этапы зелёные.

Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-07 03:19:25 +05:00
parent 0d3ef81f72
commit 97e26a65d5
12 changed files with 2425 additions and 0 deletions

View file

@ -0,0 +1,70 @@
name: Stage verify
# Запускается ПОСЛЕ успешного docker-api или docker-web — они уже
# собирают и деплоят на stage. Эта работа делает быстрый smoke
# (~30с): auth, multi-tenant изоляция, один полный документ-цикл
# (signup → seed → supply.post → retail-sale.post → проверка остатка).
#
# Если падает — пинг в Telegram. По дефолту в notify.yml уже есть
# perfailure нотификация для CI/Docker — этот workflow добавляет к ним.
on:
workflow_run:
workflows: ["Docker API", "Docker Web"]
types: [completed]
workflow_dispatch:
# Не запускаемся, если триггерный workflow упал — нет смысла верифировать
# то что не задеплоилось.
jobs:
smoke:
name: Smoke против stage
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
runs-on: [self-hosted, linux]
env:
BASE_URL: https://test.admin.food-market.kz
steps:
- uses: actions/checkout@v4
- name: Wait for health/ready
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"Healthy"'; then
echo "Stage ready"
exit 0
fi
echo "[$i/10] not ready yet, sleeping..."
sleep 3
done
echo "Stage NOT ready after 30s" >&2
exit 1
- name: Run smoke suite
env:
BASE_URL: ${{ env.BASE_URL }}
run: bash tests/stage-smoke.sh
- name: Notify Telegram on success
if: success() && github.event_name == 'workflow_run'
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage verify OK — ${SHA:0:7}" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage verify FAILED — ${SHA:0:7} — $RUN_URL" \
> /dev/null

397
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,397 @@
# food-market — архитектура
Документ для разработчика, который пришёл в проект первый раз. Описывает
слои, модули, ключевые потоки и почему некоторые вещи сделаны именно так.
Старая короткая версия — `docs/architecture.md` (lowercase). Этот файл
заменяет её и расширяет.
## TL;DR
- **Что**: multi-tenant SaaS-аналог МойСклад для розничных магазинов РК.
- **Backend**: .NET 8 LTS, ASP.NET Core, EF Core 8, PostgreSQL 14+ (dev) / 16 (prod).
- **Auth**: OpenIddict 5 (password + refresh) поверх ASP.NET Identity.
- **Web**: React 19 + Vite + TS, Tailwind v4, shadcn/ui, TanStack Query, AG Grid.
- **POS**: WPF на .NET 8 Windows, оффлайн-буфер в SQLite, синк через `/api/pos/v1`.
## Топология deployment
```
┌─────────────────────────────────────────────────────────────────┐
│ Internet / LAN магазина │
└───────────┬───────────────────────┬─────────────────────────────┘
│ HTTPS │ HTTPS (Bearer) + офлайн-буфер
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ food-market.web │ │ food-market.pos (WPF) │
│ React SPA │ │ .NET 8, Windows 10+ │
│ admin.fm.kz │ │ локальная SQLite │
└─────────┬──────────┘ └──────────┬───────────────┘
│ │
│ /api/* │ /api/pos/v1/*
│ /hubs/notifications │
└─────────────┬────────────┘
┌───────────────────────────────────────────┐
│ food-market.api │
│ ASP.NET Core + OpenIddict + SignalR │
│ - tenant query filters per request │
│ - Hangfire scheduler + recurring jobs │
│ - /metrics (Prometheus) /health/{live,ready}│
└────┬──────────┬───────────┬───────────┬───┘
▼ ▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│Postgres│ │ Hangfire│ │ MinIO │ │ Logs │
│ 16 │ │ (jobs) │ │ (S3, opt)│ │ Serilog │
└────────┘ └─────────┘ └──────────┘ └──────────┘
локальный FS (/uploads volume)
— если MinIO не настроен
```
Stage и prod крутятся через `deploy/docker-compose.yml` на dev-vm
(`192.168.1.190`). Локальный dev: API на `:5081`, Postgres из
brew (`postgres@14` на `:5432`), web через `pnpm dev` на `:5173`.
## Структура солюшна
```
food-market/
├── src/
│ ├── food-market.domain/ ← POCO, enum, доменные интерфейсы
│ ├── food-market.application/ ← MediatR-handlers, DTO, абстракции
│ ├── food-market.infrastructure/ ← EF Core, Identity, OpenIddict EF, внешние API
│ ├── food-market.api/ ← ASP.NET Core host: controllers, middleware, DI
│ ├── food-market.web/ ← React SPA
│ ├── food-market.shared/ ← DTO-контракты api ↔ pos
│ ├── food-market.public/ ← Astro static (маркетинг food-market.kz)
│ ├── food-market.pos.core/ ← логика POS (без UI)
│ └── food-market.pos/ ← WPF UI (net8.0-windows)
├── tests/
│ ├── food-market.UnitTests/ ← xUnit + InMemoryDB
│ ├── food-market.IntegrationTests/← xUnit + Testcontainers Postgres
│ ├── e2e/ ← Playwright (TS), бьёт по test.admin.food-market.kz
│ └── load/ ← k6 (Sprint 12)
├── deploy/ ← docker-compose, Dockerfile.*, systemd-юниты
└── docs/ ← вы здесь
```
### Слои (Clean Architecture)
| Слой | Зависит от | Что лежит |
|-------------------|---------------------------|----------------------------------------------------------------------------------------|
| **domain** | ничего | POCO-сущности, enum'ы, доменные интерфейсы (`ITenantEntity`, `IVersionedEntity`). |
| **application** | domain + shared | MediatR `IRequest`/`IRequestHandler`, DTO, абстракции (`IFiscalProvider`, `IEmailSender`, `IStockService`, `ITenantContext`), `FluentValidation` валидаторы. |
| **infrastructure**| application + domain | `AppDbContext`, Identity-таблицы, OpenIddict EF store, реализации абстракций, HTTP-клиенты к внешним API (Webkassa, MoySklad, MailKit, Telegram). |
| **api** | всё перечисленное выше | ASP.NET Core host: контроллеры, middleware, DI-проводка, фоновые джобы (Hangfire), Realtime hub'ы (SignalR), сидеры. |
Правило одностороннего направления зависимостей: домен не знает про EF и
ASP.NET, application — про конкретные провайдеры. Это позволило прикрутить
ОФД (Sprint 11) одним интерфейсом + четырьмя реализациями, без правок
контроллеров кроме одной точки вызова.
## Модули backend
### Domain (`src/food-market.domain/`)
- `Common/Entity.cs` — базовая `Entity` с `Id/CreatedAt/UpdatedAt`.
- `Common/TenantEntity.cs``ITenantEntity` (обязательный `OrganizationId`),
`TenantEntity` (база), `IOptionalTenantEntity` (системные справочники с
`OrganizationId?`).
- `Common/IVersionedEntity.cs` — оптимистичная блокировка через PG `xmin`
(`Xmin` поле).
- Бизнес-сущности по поддоменам: `Catalog/` (Product, Counterparty,
ProductGroup, …), `Inventory/` (Stock, StockMovement, Loss, Transfer,
Inventory), `Purchases/` (Supply, Enter, SupplierReturn),
`Sales/` (RetailSale, RetailSaleLine, Demand, LoyaltyCard,
LoyaltyProgram, Promotion), `Organizations/` (Organization, Employee,
EmployeeRole, OrgAuditLog, SuperAdminAuditLog), `Platform/`
(PlatformSettings — singleton SMTP-конфиг).
### Application (`src/food-market.application/`)
- **CQRS на MediatR** — пока partial: образцы в `Purchases/Commands/CreateSupplyCommand.cs`,
`Sales/Commands/PostRetailSaleCommand.cs`, `Sales/Queries/GetSalesReportQuery.cs`.
Большинство контроллеров пока «толстые» (исторически до TD-1).
- **Абстракции**:
- `Common/Tenancy/ITenantContext``OrganizationId`, `IsSuperAdmin`,
`IsTenantOverride`, `UserId`.
- `Common/Email/IEmailSender` — отправка через текущий SMTP-конфиг.
- `Common/Fiscal/IFiscalProvider` + `IFiscalProviderFactory` (Sprint 11).
- `Inventory/IStockService` — единая точка списания/начисления остатка
(любая операция, меняющая склад, идёт через `ApplyMovementAsync`).
- **FluentValidation** валидаторы рядом с DTO; глобально подключаются
через `AddValidatorsFromAssemblyContaining<Program>()`.
### Infrastructure (`src/food-market.infrastructure/`)
- `Persistence/AppDbContext.cs` — единый DbContext (тенанта + Identity +
OpenIddict EF store). Query-filter применяется через reflection ко всем
`ITenantEntity` (см. [MULTI-TENANCY.md](MULTI-TENANCY.md)).
- `Persistence/Configurations/*.cs` — EF Core fluent configs по поддоменам.
- `Persistence/Migrations/` — миграции пишутся вручную (см. CLAUDE.md /
memory `feedback_ef_migrations`), снапшот не синхронизируется
с моделью (используется только `dotnet ef migrations add`, который
не вызывается в этом проекте).
- `Persistence/OrgAuditInterceptor.cs` — EF `ISaveChangesInterceptor`,
пишет каждую `Add/Update/Delete` в `org_audit_log` (JSONB diff).
- `Identity/` — кастомные `User`, `Role` для ASP.NET Identity.
- `Email/MailKitEmailSender.cs` — SMTP через MailKit, конфиг из
`PlatformSettings` (читается на каждой отправке через scope).
- `Fiscal/``IFiscalProvider` реализации: Mock + Webkassa (полный) +
Kassa24/OfdSolo (skeleton). См. [ofd-integration.md](ofd-integration.md).
- `Inventory/StockService.cs` — единственное место, где двигаются остатки.
Бизнес-инвариант: stock = SUM(stock_movements) per (productId, storeId).
- `Integrations/MoySklad/` — HTTP-клиент + конвертер для импорта каталога.
### Api (`src/food-market.api/`)
- `Program.cs` — composition root (~570 строк, поделён логическими
блоками; см. секцию «Composition root» ниже).
- `Controllers/` — REST-API. Структура совпадает с маршрутами:
- `Auth/``/api/auth/*` (signup, forgot-password, 2FA).
- `Catalog/``/api/catalog/{products,counterparties,…}`.
- `Purchases/``/api/purchases/{supplies,supplier-returns}`.
- `Sales/``/api/sales/{retail,demands}`.
- `Inventory/``/api/inventory/{stock,enters,losses,transfers,inventories}`.
- `Reports/``/api/reports/{sales,stock,profit,abc}`.
- `Dashboard/``/api/dashboard/{top-products,low-stock,recent-sales,margin}`.
- `Loyalty/`, `Promotions/` — Sprint 9.
- `Organizations/` — настройки орги, сотрудники, роли, ОФД.
- `Pos/``/api/pos/v1/*` для WPF POS (sync, idempotency).
- `SuperAdmin/``/api/super-admin/*` (управление платформой).
- `Admin/``/api/admin/*` (per-org admin tools: cleanup, demo-seed,
moysklad-import, audit-log просмотр).
- `Search/` — глобальный `/api/search/global` (Cmd+K).
- `Telegram/` — bind owner-chat, статус.
- `Uploads/` — multipart upload изображений.
- `Infrastructure/`:
- `Tenancy/HttpContextTenantContext.cs` — реализация `ITenantContext`
через `IHttpContextAccessor` + AsyncLocal-override для background tasks.
- `Tenancy/SuperAdminOverrideClaimsTransformer.cs` — добавляет
`Admin/Cashier/Storekeeper` роли SuperAdmin'у с активным
`X-Org-Override`, чтобы `[Authorize(Roles="Admin")]` не отшил его.
- `Tenancy/ReadonlyOverrideMiddleware.cs` — в режиме override без
`X-Org-Override-Reason` блочит любую мутацию (читать всё, писать
ничего; писать — только в edit-mode с reason).
- `Tenancy/SuperAdminEditAuditFilter.cs` — глобальный action-filter,
при mutate-в-override пишет в `super_admin_audit_log`.
- `Authorization/RequiresPermissionAttribute.cs` + `PermissionAuthorizationPolicyProvider`
+ `PermissionAuthorizationHandler` — permission-based авторизация.
`[RequiresPermission("ProductsEdit")]` → policy `perm:ProductsEdit`
`RolePermissions.ProductsEdit` булева на `EmployeeRole`.
- `Validation/ValidationFilter.cs` — FluentValidation → 400
ProblemDetails (RFC 7807).
- `RateLimiting/AuthRateLimiterExtensions.cs` — 5/мин + 20/час на
`/connect/token`, `/api/auth/signup` по IP+username.
- `Observability/LogEnrichmentMiddleware.cs` — кладёт
`CorrelationId/OrgId/UserId` в Serilog `LogContext`, каждая запись
в журнале получает эти лейблы.
- `Observability/DbMetricsInterceptor.cs` — EF интерсептор, Prometheus
`food_market_db_query_duration_seconds`.
- `Observability/AppMetrics.cs` — статические Counter'ы (Posted/Unposted
per docType, FiscalRegistered, …).
- `Health/DatabaseReadyHealthCheck.cs``SELECT 1` + проверка
`__EFMigrationsHistory`.
- `Security/OpenIddictKeyConfigurator.cs` — в dev — persistent RSA в
`App_Data/oidc-keys/*`; в stage/prod — X509 PFX из конфига
(см. [openiddict-keys.md](openiddict-keys.md)).
- `Realtime/NotificationsHub.cs` + `NotificationsPublisher.cs`
SignalR-хаб `/hubs/notifications`, группы per-org. События:
`SalePosted`, `LowStock`, `ImportProgress`.
- `Background/`:
- `HangfireJobsConfigurator` — регистрирует recurring jobs при старте:
`prune-stock-movements` (03:30), `prune-audit-log` (03:45),
`weekly-summary` (пн 07:00), `low-stock-alert` (08:00),
`telegram-owner-daily-summary` (06:00).
- `HousekeepingJobs` — pg-cleanup'ы.
- `EmailNotificationJobs` — weekly-summary + low-stock email.
- `OwnerDailySummaryJob` — Telegram-сводка владельцу.
- `ReferencePriceRefreshJob` — пересчёт `Product.ReferencePrice`
каждые 30 дней без приёмок.
- `Seed/`:
- `SystemReferenceSeeder` — справочники (страны, валюты, единицы).
- `OpenIddictClientSeeder` — регистрирует client `food-market-web`.
- `DevDataSeeder` — dev-only admin user (SuperAdmin).
- `DemoTenantSeeder` / `YearDemoSeeder` — заполняют tenant
демо-данными (Sprint 5 / Sprint 10).
### Web (`src/food-market.web/`)
- Vite + React 19 + TS 6, Tailwind v4. Маршрутизация — React Router 6.
- `src/lib/api.ts` — axios instance с auto-refresh токена.
- `src/lib/auth.ts` — login/logout, store токена в `localStorage`.
- `src/components/` — общие виджеты (Field, Button, Skeleton,
CommandPalette, DashboardWidgets).
- `src/pages/` — страницы (один файл per route).
- TanStack Query — кеширование API-вызовов, инвалидация по SignalR.
- AG Grid Community — большие списки (товары, контрагенты, отчёты).
### POS (`src/food-market.pos*/`)
- `pos.core/` — логика без UI: оффлайн-буфер, sync, расчёт чека.
- `pos/` — WPF UI, CommunityToolkit.Mvvm, SQLite, Refit + Polly,
System.IO.Ports для весов CAS.
- Sync: батчем по 50 чеков через `POST /api/pos/v1/batch` с
`Idempotency-Key`. Сервер дедупит через `pos_batch_acks` уникальный
индекс (`OrganizationId, IdempotencyKey`).
## Composition root (`Program.cs`)
Логические блоки в порядке регистрации:
1. **Serilog** bootstrap (до builder).
2. **CORS** (`Cors:AllowedOrigins` из конфига).
3. **HttpContextAccessor** + `ITenantContext` + `IClaimsTransformation`
(SuperAdmin override роли).
4. **EF Core**: `AppDbContext` (Npgsql, OpenIddict, два interceptor'а).
5. **Identity** + **OpenIddict** server (password + refresh, rolling
refresh, leeway = 0).
6. **Authentication/Authorization** policies (`AdminAccess`, `perm:*`).
7. **Rate-limiter** (`/connect/token`, `/api/auth/signup`).
8. **HealthChecks** (`database` тег `ready`).
9. **IEmailSender** (Singleton + scope для DbContext).
10. **IFiscalProvider** + 3 HttpClient + `IFiscalProviderFactory`.
11. **MediatR** (assembly scan), **FluentValidation**.
12. **MoySklad HttpClient** + import service.
13. **Hangfire** server + storage (PG), `HangfireJobsConfigurator` хостед.
14. **SignalR** + `INotificationsPublisher`.
15. **Telegram-бот** HttpClient (если token задан).
16. **Сидеры**: `OpenIddictClientSeeder`, `SystemReferenceSeeder`,
`DevDataSeeder` хостед; `DemoTenantSeeder`/`YearDemoSeeder` scoped.
17. `Build()` → middleware pipeline (Serilog→CORS→HttpMetrics→
RateLimiter→hubs-token-fix→AuthN→AuthZ→LogEnrichment→
ReadonlyOverride→StaticFiles[/uploads]→Swagger→MapControllers→
MapHub→MapMetrics→HangfireDashboard→HealthChecks).
18. На старте: `db.Database.Migrate()` (идемпотентно).
19. `app.Run()`.
## Поток: signup → bootstrap → первая продажа
```
1. POST /api/auth/signup { email, password, organizationName, phone }
─→ создание Organization (Entity, не tenant-scoped)
─→ создание AppUser + добавление в роль "Admin"
─→ создание Employee с AdminRole и всеми permission'ами
─→ создание главного Store (isMain=true) + RetailPoint
─→ создание PriceType "Розничная" (isRetail=true, isSystem=true)
─→ копирование системных UnitOfMeasure (OrganizationId=null) на org
2. POST /connect/token { grant_type=password, username, password }
─→ OpenIddict проверяет, выдаёт access_token + refresh_token
─→ access_token содержит claim org_id и role
3. GET /api/me (web bootstrap)
─→ возвращает { sub, email, roles, orgId, hasLiveOrg, hasActiveEmployee }
─→ фронт роутит на /dashboard или /no-organization (orphan-fallback)
4. POST /api/catalog/products { name, prices, barcodes, ... }
─→ ValidationFilter (FluentValidation)
─→ controller → _db.Products.Add(...) (OrganizationId stamped в SaveChanges)
─→ возвращает Product DTO
5. POST /api/purchases/supplies + POST /{id}/post
─→ post идёт под Serializable tx
─→ для каждой строки StockService.ApplyMovementAsync(+qty, MovementType.Supply)
─→ Stock row для (productId, storeId) либо создаётся, либо обновляется
─→ commit, AppMetrics.IncrementPosted("supply")
─→ SignalR: NotificationsHub → группа org → событие SupplyPosted
6. POST /api/sales/retail + POST /{id}/post
─→ Serializable tx, проверка остатка ≥ 0 для каждой строки
─→ StockService.ApplyMovementAsync(-qty, MovementType.RetailSale)
─→ commit, AppMetrics.IncrementPosted("retail-sale")
─→ best-effort TryFiscalizeAsync (Sprint 11) — отдельно, после commit
─→ SignalR: SalePosted (dashboard виджеты инвалидируют queries)
```
## База данных
Postgres 14+ для dev (brew systemwide), Postgres 16 в Docker для
stage/prod. Названия таблиц snake_case через явный `ToTable("…")`.
### Ключевые таблицы
| Таблица | Назначение |
|---|---|
| `organizations` | Корневой tenant. Не tenant-scoped. |
| `users`, `roles`, `user_roles` | ASP.NET Identity. |
| `employees`, `employee_roles`, `role_permissions` | Сотрудники tenant'а + кастомные роли с булевыми флагами прав. |
| `products`, `product_prices`, `product_barcodes`, `product_images`, `product_groups` | Каталог товаров. |
| `counterparties` | Поставщики + покупатели (тип=Supplier/Individual/Legal). |
| `stores`, `retail_points`, `units_of_measure`, `currencies`, `price_types`, `countries` | Справочники. |
| `stocks`, `stock_movements` | Остатки + история движений. `stocks` — кеш `SUM(stock_movements)`. |
| `supplies`, `supply_lines`, `enters`, `enter_lines`, `supplier_returns`, `supplier_return_lines` | Приходные документы. |
| `losses`, `loss_lines`, `transfers`, `transfer_lines`, `inventory_docs`, `inventory_lines` | Внутренний учёт. |
| `retail_sales`, `retail_sale_lines` | Чеки розницы + строки чека. Sprint 11: ОФД-снапшоты на `retail_sales` (FiscalNumber, FiscalQrCode, …). |
| `demands`, `demand_lines` | Опт-отгрузки. |
| `loyalty_programs`, `loyalty_cards`, `promotions` | Sprint 9. |
| `pos_batch_acks` | Идемпотентность POS-синка (UNIQUE OrganizationId, IdempotencyKey). |
| `org_audit_log` | JSONB-diff каждой mutate-операции tenant'а. |
| `super_admin_audit_log` | Действия SuperAdmin'а (особенно в режиме «открыто как…»). |
| `platform_settings` | Singleton: SMTP-конфиг платформы. |
| `system_settings` | Singleton: per-tenant фичи (не путать с platform). |
| `import_jobs` | История импортов MoySklad. |
OpenIddict хранит `openiddict_applications`/`authorizations`/`tokens`/`scopes`.
Hangfire — `hangfire.*` (в своей схеме).
### Concurrency
`IVersionedEntity` сущности (Supply, RetailSale, Demand, Enter, Loss,
Transfer, InventoryDoc, SupplierReturn) включают PG `xmin` через
`UseXminAsConcurrencyToken()`. Параллельные апдейты одного документа
получают `DbUpdateConcurrencyException`, контроллер возвращает 409.
Post-операции, изменяющие остаток, идут под `IsolationLevel.Serializable`
(см. `RetailSalesController.Post`, `SuppliesController.Post`, …) —
это защищает от race в `SUM(stock_movements)`-инварианте.
## Внешние интеграции
| Сервис | Где | Состояние |
|---|---|---|
| **MoySklad** | `Infrastructure/Integrations/MoySklad/` | Импорт товаров, контрагентов, остатков. Per-org token в `Organization.MoySkladToken`. |
| **SMTP** | `Infrastructure/Email/MailKitEmailSender.cs` | Платформенный SMTP в `PlatformSettings` (SuperAdmin настраивает). Используется для invite, forgot-password, weekly-summary. |
| **Telegram Bot** | `Api/Integrations/Telegram/` | Owner-сводка. Per-org `OwnerTelegramChatId`. Bot token в env. |
| **ОФД (Webkassa / Kassa24 / ОФД-Соло)** | `Infrastructure/Fiscal/` | Sprint 11 scaffolding. Per-org провайдер + креды. |
| **MinIO (S3)** | `Api/Storage/StorageBootstrap.cs` | Опциональный сторадж изображений. Если не настроен — `/uploads` volume на FS. |
## Тесты
- **Unit** (`tests/food-market.UnitTests/`) — xUnit + InMemory EF + чистые
юниты на валидаторы, payload-builder'ы, MediatR-handler'ы.
- **Integration** (`tests/food-market.IntegrationTests/`) — xUnit +
Testcontainers Postgres (`postgres:16-alpine`). Полный API через
`WebApplicationFactory<Program>`. Shared `ApiFactory` через
`ApiCollection` (один контейнер на сессию xunit). Memory note:
`test_suites_setup` — Ryuk выключен (TCN не тянет с docker-hub),
rate-limiter eager-config через env-переменную.
- **E2E** (`tests/e2e/`) — Playwright (TS) против stage
`https://test.admin.food-market.kz`. Используется в verify-suite'ах
по спринтам.
- **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`.
## Релиз-цикл
1. Локально: `dotnet build` + `dotnet test` + `pnpm build`.
2. `git push origin main` (Forgejo на 127.0.0.1:3000 — primary remote,
GitHub — mirror, memory `feedback_forgejo_primary`).
3. `~/deploy-stage.sh` — docker build api+web → push в локальный registry
`192.168.1.193:5001` → ssh на prod-vm → `docker compose pull && up -d`.
4. Health check на `https://test.admin.food-market.kz/health/ready`.
5. Verify на stage (Playwright или ручной чек).
6. Prod-деплой — пока ручной (TBD, нужен план от user'а).
## Что ещё прочитать
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query filter, SuperAdmin override, подводные камни.
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры.
- [DEVELOPER-GUIDE.md](DEVELOPER-GUIDE.md) — как начать вкладываться в код.
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры.
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
- [observability.md](observability.md) — Serilog + Prometheus.
- [secrets.md](secrets.md) — управление секретами в stage/prod.
- [stage-access.md](stage-access.md) — как попасть на stage-сервер.
- [backup-restore.md](backup-restore.md) — бэкапы.

439
docs/DEVELOPER-GUIDE.md Normal file
View file

@ -0,0 +1,439 @@
# Developer guide — food-market
Как поднять проект, что куда добавлять, какие паттерны соблюдать.
Предполагается, что вы прочитали [ARCHITECTURE.md](ARCHITECTURE.md) и
понимаете слои.
## Локальный setup
### Что нужно
- **.NET 8 SDK 8.0.4xx** (см. `global.json`, `rollForward: latestFeature`
— годится любой 8.0.4xx).
- **Node 20+** и **pnpm 9+** (для web).
- **PostgreSQL 14+** — на macOS обычно brew `postgresql@14`.
БД: `food_market`, owner `nns`, пароль пустой.
- **Docker** + **Docker Compose** — только для integration-тестов
(Testcontainers) и stage-деплоя.
### Поднять с нуля
```bash
git clone http://127.0.0.1:3000/nns/food-market.git
cd food-market
# 1) БД (если ещё нет)
createdb -O nns food_market # пользователь nns должен существовать
# 2) Backend
ASPNETCORE_ENVIRONMENT=Development \
dotnet run --project src/food-market.api
# первый запуск: применит миграции, посеит справочники, создаст
# SuperAdmin admin@food-market.local / Admin12345!.
# API на http://localhost:5081, Swagger на /swagger.
# 3) Web (в другом терминале)
cd src/food-market.web
pnpm install
pnpm dev
# http://localhost:5173
# 4) Smoke
curl http://localhost:5081/health
# и зайти в браузере, залогиниться admin@food-market.local
```
### Получить токен из CLI
```bash
TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'grant_type=password&username=admin@food-market.local&password=Admin12345!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
| jq -r .access_token)
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | jq
```
## Запуск тестов
```bash
# Unit-тесты (быстрые, ~7-10с)
dotnet test tests/food-market.UnitTests/
# Integration (тянут Postgres-контейнер, ~30-60с на холодную)
dotnet test tests/food-market.IntegrationTests/
# Фильтр по имени класса/метода
dotnet test tests/... --filter "FullyQualifiedName~Fiscal"
# Web — type-check + production build
cd src/food-market.web && pnpm exec tsc --noEmit && pnpm build
# E2E (Playwright против stage)
cd tests/e2e && pnpm install
pnpm playwright test stage-smoke.spec.ts
```
### Гочи integration-тестов
- **Testcontainers Ryuk** выключен (env `TESTCONTAINERS_RYUK_DISABLED=true`).
Причина: контейнер reaper тянет образ с Docker Hub, на dev-vm сеть
туда нестабильна.
- **Rate-limiter** выключен через `RateLimiting__Enabled=false`. Он
читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через
переменную окружения (см. memory `test_suites_setup`).
- **Hangfire-сервер** выключен (`Hangfire__Enabled=false`) — иначе
создаёт схему и держит коннект в одноразовом контейнере.
- Один `ApiFactory` на всю xUnit-сессию через `[Collection(ApiCollection.Name)]`.
Делать второй `WebApplicationFactory<Program>` параллельно нельзя —
`HostFactoryResolver` сломается.
## Конвенции репо
- C# 12, `Nullable` enabled, `ImplicitUsings` enabled.
- Названия неймспейсов — `foodmarket.Domain`, `foodmarket.Application`,
`foodmarket.Infrastructure`, `foodmarket.Api`. Папки в `src/`
`food-market.api`, `food-market.application`, … (с дефисом). Это
расхождение исторически — менять не нужно.
- Названия таблиц в БД — snake_case (явный `b.ToTable("retail_sales")`),
столбцы — PascalCase из C# (EF default), индексы по
`IX_<table>_<cols>` (EF default).
- Комментарии в коде — рассказывающие *почему*, не *что*. Если из имени
переменной/метода не понятно — переименуй; если из логики не понятно,
*почему* — комментируй.
- XML-doc на public API в Application/Infrastructure обязателен (даёт
IntelliSense для другой стороны и появляется в Swagger).
- Локализация UI — `src/lib/i18n.ts` (русский по умолчанию, есть
заготовка под KZ — нужен переводчик).
## Паттерны: добавить controller с permission
Пример: `POST /api/loyalty/programs` (создание программы лояльности),
доступно только Admin'у орги или SuperAdmin'у в edit-mode.
```csharp
// food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs
[ApiController]
[Authorize]
[Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<LoyaltyProgramsController> _log;
public LoyaltyProgramsController(
AppDbContext db, ITenantContext tenant, ILogger<LoyaltyProgramsController> log)
{
_db = db; _tenant = tenant; _log = log;
}
public record ProgramInput(
[Required] string Name,
[Range(1, 4)] int Type,
[Range(0, 1000)] decimal Rate,
bool IsActive);
[HttpPost, RequiresPermission("LoyaltyEdit")]
public async Task<ActionResult<Guid>> Create(
[FromBody] ProgramInput input, CancellationToken ct)
{
var p = new LoyaltyProgram
{
Name = input.Name.Trim(),
Type = (LoyaltyProgramType)input.Type,
Rate = input.Rate,
IsActive = input.IsActive,
// OrganizationId stamping применит в SaveChanges
};
_db.LoyaltyPrograms.Add(p);
await _db.SaveChangesAsync(ct);
_log.LogInformation(
"Loyalty program created: {ProgramId} {Name} org={OrgId}",
p.Id, p.Name, _tenant.OrganizationId);
return Ok(p.Id);
}
}
```
Что произошло:
- `[Authorize]` — JWT-токен обязателен (валидируется OpenIddict).
- `[Route("api/...")]` — обычный REST-маршрут. `api/` префикс
обязателен для всех контроллеров (web-фронт ходит через `/api/*`,
nginx это знает).
- `[RequiresPermission("LoyaltyEdit")]` — резолвится в policy `perm:LoyaltyEdit`,
handler проверяет `RolePermissions.LoyaltyEdit` булеву у роли
текущего юзера. Добавь поле в `RolePermissions.cs` если ещё нет,
миграция-`AddColumn` для `bool LoyaltyEdit NOT NULL DEFAULT false`
+ апдейт admin-роли в сидере.
- `ProgramInput` — record с DataAnnotations. Для сложной валидации —
отдельный FluentValidation `AbstractValidator<ProgramInput>` в
`food-market.api/Infrastructure/Validation/Validators.cs` (см.
паттерны там).
- `_db.LoyaltyPrograms.Add(p)` без явного `OrganizationId`
`StampTenant` в `SaveChangesAsync` подставит.
- Логирование структурированное: `{ProgramId}`, `{Name}`, `{OrgId}`
Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой).
### Если нужен Admin-only (грубее)
```csharp
[HttpPut, Authorize(Roles = "Admin")]
```
это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме
override (через `SuperAdminOverrideClaimsTransformer`)». Подходит для
редких операций; для регулярных используй `RequiresPermission`.
## Паттерны: добавить сущность с RowVersion и tenant
Допустим, нужна новая сущность `PromoCode`.
### 1. Domain
```csharp
// food-market.domain/Sales/PromoCode.cs
public class PromoCode : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Code { get; set; } = "";
public decimal Discount { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
}
```
`TenantEntity` даёт `Id`, `CreatedAt`, `UpdatedAt`, `OrganizationId`.
`IVersionedEntity` + `Xmin` — оптимистичная блокировка через PG xmin.
### 2. EF Configuration
```csharp
// food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs
b.Entity<PromoCode>(e =>
{
e.ToTable("promo_codes");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Code).HasMaxLength(40).IsRequired();
e.Property(x => x.Discount).HasPrecision(18, 4);
e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); // ← OrganizationId первым!
e.HasIndex(x => new { x.OrganizationId, x.IsActive });
});
```
**Важно**: индексы — с `OrganizationId` первым полем. Все запросы пройдут
через query filter и будут фильтроваться по этому полю; без правильного
индекса PG будет full-scan тенант-таблицы.
### 3. DbSet
```csharp
// food-market.infrastructure/Persistence/AppDbContext.cs
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();
```
### 4. Миграция руками
```csharp
// food-market.infrastructure/Persistence/Migrations/20260608100000_PromoCodes.cs
[DbContext(typeof(AppDbContext))]
[Migration("20260608100000_PromoCodes")]
public partial class PromoCodes : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "promo_codes",
schema: "public",
columns: t => new
{
Id = t.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Discount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ExpiresAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = t.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: t => t.PrimaryKey("PK_promo_codes", x => x.Id));
b.CreateIndex(
name: "IX_promo_codes_OrganizationId_Code",
schema: "public", table: "promo_codes",
columns: new[] { "OrganizationId", "Code" }, unique: true);
b.CreateIndex(
name: "IX_promo_codes_OrganizationId_IsActive",
schema: "public", table: "promo_codes",
columns: new[] { "OrganizationId", "IsActive" });
}
protected override void Down(MigrationBuilder b)
=> b.DropTable("promo_codes", "public");
}
```
**Обязательно**: `[DbContext]` + `[Migration("...")]` атрибуты — без них
`db.Database.Migrate()` миграцию не подхватит (memory:
`feedback_ef_migrations`).
### 5. Тест на изоляцию
Добавить case в `TenantIsolationTests`: org A создаёт PromoCode, org B
делает GET, видит пустой список.
## Валидация
### Простые правила — DataAnnotations
```csharp
public record ProductInput(
[Required, MaxLength(200)] string Name,
[Range(0, 1e10)] decimal Price);
```
### Сложные — FluentValidation
В `food-market.api/Infrastructure/Validation/Validators.cs`:
```csharp
public sealed class ProductInputValidator : AbstractValidator<ProductInput>
{
public ProductInputValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
// Кросс-полевые правила, async, реализующие бизнес-инвариант
RuleFor(x => x.Lines).NotEmpty().WithMessage("Хотя бы одна позиция.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
});
}
}
```
Валидаторы регистрируются автоматически через
`AddValidatorsFromAssemblyContaining<Program>()`. `ValidationFilter`
(глобальный action-filter в Program.cs) запускает их на каждом
action и возвращает 400 ProblemDetails (RFC 7807).
### Бизнес-валидация (требует БД)
Если правило требует справиться с БД (например, «склад существует и
не архивирован»), вынесите в первый шаг action-метода:
```csharp
[HttpPost]
public async Task<ActionResult> Create(ProductInput input, CancellationToken ct)
{
var groupOk = await _db.ProductGroups.AnyAsync(g => g.Id == input.ProductGroupId, ct);
if (!groupOk)
return BadRequest(new { error = "Группа не найдена.", field = "productGroupId" });
// ...
}
```
Формат ошибки — `{ error: "...", field?: "..." }`. Фронт показывает
`error` тостом, `field` подсвечивает в форме.
## Логирование
Используем Serilog со структурированными полями. `LogEnrichmentMiddleware`
уже добавляет `CorrelationId/OrgId/UserId` в каждую запись.
### Правила
- **Не строкой**: `_log.LogInformation("Created product " + id)` — нет.
`_log.LogInformation("Product created: {ProductId} name={Name}", id, name)` — да.
- **Уровень**:
- `Trace/Debug` — только для отладки конкретного бага.
- `Information` — успешные mutate-операции, важные events
(post/unpost документа, регистрация чека в ОФД).
- `Warning` — что-то пошло не как ожидалось, но обработали
(best-effort fail, retry-able ошибка).
- `Error` — обработать не удалось, нужен внимательный человек.
- `Critical` — приложение в плохом состоянии, может перестать работать.
- **Не логировать** PII в открытом виде (пароли, токены, email — email
можно, но не светить лишний раз).
- **Exception как первый аргумент**: `_log.LogError(ex, "...", ...)`,
не `_log.LogError("... " + ex.Message)` — теряется stack trace.
### Пример из RetailSalesController
```csharp
_log.LogInformation(
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
// ...
try
{
await _notify.PublishAsync(...);
}
catch (Exception ex)
{
// Notification — best-effort: не должна валить транзакцию (она уже закоммичена)
_log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
}
```
## SignalR realtime
Если нужно отправить уведомление на фронт (инвалидация query'я,
показ тоста):
```csharp
// в Program.cs INotificationsPublisher уже зарегистрирован
public class MyController : ControllerBase
{
private readonly INotificationsPublisher _notify;
[HttpPost("...")]
public async Task<IActionResult> Action(...)
{
// ... business logic ...
await _notify.PublishAsync(
organizationId,
NotificationEvents.SalePosted, // строковая константа
new SalePostedPayload(...)); // record DTO
return NoContent();
}
}
```
На фронте — `useNotifications()` хук подписан на хаб и инвалидирует
relevant query'и. Новые event'ы добавлять в `NotificationEvents`,
payload — в соседнем record'е.
## Что НЕ делать
- НЕ инжектить `IServiceProvider` чтобы доставать сервисы лениво —
объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal,
Email) которые открывают scope для свежего DbContext'а.
- НЕ писать raw SQL (`SqlQueryRaw`/`ExecuteSqlRaw`) без явного
`WHERE OrganizationId = @org` — query-filter не применится.
- НЕ менять снапшот в `Persistence/Migrations/AppDbContextModelSnapshot.cs`
руками для добавления новых полей — он используется только инструментом
`dotnet ef migrations add`, который мы не запускаем. Trying to add
partial state ломает только инструмент, ничего не дав. Если хочется —
обновляй целиком, синхронно с моделью; иначе оставь как есть.
- НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion
commercial, Telerik (CLAUDE.md).
- НЕ менять `global.json` без согласования (CLAUDE.md).
- НЕ создавать миграции через `dotnet ef migrations add` — пиши руками
(memory: `feedback_ef_migrations`).
- НЕ делать `git push --force` на main (Forgejo — primary).
## Полезные ссылки
- [ARCHITECTURE.md](ARCHITECTURE.md) — слои и модули.
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query-filter, override-режим.
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры.
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
- [observability.md](observability.md) — Serilog + Prometheus.
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры.
- [secrets.md](secrets.md) — где живут секреты.

338
docs/MULTI-TENANCY.md Normal file
View file

@ -0,0 +1,338 @@
# Multi-tenancy в food-market
Один процесс API, одна БД, много организаций (тенантов). Каждый запрос
видит только данные своей организации. Изоляция держится на двух вещах:
1. **EF Core query filter** на каждой `ITenantEntity` (auto-инжектится
в `WHERE` каждого SQL-запроса).
2. **Stamping в SaveChanges** — добавляемые сущности получают
`OrganizationId` из текущего `ITenantContext`.
`SuperAdmin` — отдельная роль с правом обходить фильтр (видеть/менять
всё). Чтобы не получить «случайные изменения по всем оргам сразу»,
есть строгий режим «открыть как…» с двумя ступенями (read-only +
edit-mode с reason).
## Модель
### Базовые интерфейсы
```csharp
// food-market.domain/Common/TenantEntity.cs
public interface ITenantEntity // обязательный orgId
{
Guid OrganizationId { get; set; }
}
public abstract class TenantEntity : Entity, ITenantEntity
{
public Guid OrganizationId { get; set; }
}
public interface IOptionalTenantEntity // системный справочник
{
Guid? OrganizationId { get; set; }
}
```
### Когда использовать что
| Случай | База |
|---|---|
| Бизнес-данные тенанта: продукты, чеки, контрагенты, отчёты | `TenantEntity` (обязательный orgId) |
| Системные справочники с возможностью per-tenant расширения: `UnitOfMeasure`, `ProductGroup`, `Country` | `IOptionalTenantEntity` (null = системная, оrgId = tenant'овская) |
| Корневая сущность тенанта: `Organization` | `Entity` (сама не tenant-scoped) |
| Платформенные настройки: `PlatformSettings` (SMTP), OpenIddict-клиенты | `Entity` (singletons / cross-tenant) |
| Audit-логи SuperAdmin'а: `SuperAdminAuditLog` | `Entity` (есть optional `OrganizationId` для фильтра, но сама не tenant-scoped) |
## Tenant-контекст
`ITenantContext` (Application слой) — единственный источник правды о
том, кто сейчас делает запрос:
```csharp
// food-market.application/Common/Tenancy/ITenantContext.cs
public interface ITenantContext
{
Guid? OrganizationId { get; } // null = SuperAdmin вне override, или нет JWT
bool IsAuthenticated { get; }
bool IsSuperAdmin { get; }
bool IsTenantOverride { get; } // SuperAdmin в режиме «открыть как…»
Guid? UserId { get; }
}
```
Реализация — `HttpContextTenantContext` (`food-market.api/Infrastructure/Tenancy/`).
Источники данных в порядке приоритета:
1. **AsyncLocal-override** (`UseOverride(orgId, isSuper)`) — для
background-tasks (Hangfire, импорт MoySklad, фоновые сидеры).
Когда нет HttpContext, нужно явно задать tenant перед `DbContext`-вызовом.
2. **HTTP-заголовок `X-Org-Override`** — режим «открыть как…»
(только если у юзера роль SuperAdmin).
3. **JWT claim `org_id`** — обычный tenant-юзер.
```csharp
// background-job пример
using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false))
{
// здесь _db применит фильтр на orgId
var products = await _db.Products.ToListAsync();
}
```
## Query filter
`AppDbContext.OnModelCreating` после регистрации всех сущностей
рефлексией обходит модель и вешает фильтр на каждую `ITenantEntity`:
```csharp
// food-market.infrastructure/Persistence/AppDbContext.cs
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
{
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
// В override-режиме (X-Org-Override header активен) он работает в
// контексте конкретной орги — фильтр обязан применяться.
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == _tenant.OrganizationId);
}
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
{
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == null
|| e.OrganizationId == _tenant.OrganizationId);
}
```
Результат:
- Tenant-юзер: `WHERE OrganizationId = '<его orgId>'`.
- SuperAdmin без override: фильтр не применяется (видит всё).
- SuperAdmin с `X-Org-Override`: `WHERE OrganizationId = '<выбранная orgId>'`.
- `IOptionalTenantEntity`: видит свои + системные (`IS NULL`).
## Stamping в SaveChanges
`AppDbContext.SaveChanges/Async` зовут `StampTenant()`, который
проходит по `Added`-entries:
```csharp
private void StampTenant()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State != EntityState.Added) continue;
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
{
if (_tenant.OrganizationId.HasValue)
tenant.OrganizationId = _tenant.OrganizationId.Value;
}
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
{
// SuperAdmin без override: оставляем null (системная запись)
// SuperAdmin с override / tenant-юзер: стампим текущий orgId
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { /* null */ }
else if (_tenant.OrganizationId.HasValue)
opt.OrganizationId = _tenant.OrganizationId.Value;
}
}
}
```
Это значит: контроллер может писать `_db.Products.Add(product)` без
явного `product.OrganizationId = ...` — stamping подставит сам.
**НО**: если код явно выставил `OrganizationId` (например, чтобы создать
запись для другой орги в Hangfire-job), stamping её не перетрёт.
## SuperAdmin override: режим «открыть как…»
Конкретный поток с фронта:
1. SuperAdmin заходит в «Системная консоль → Организации».
2. Кликает «Открыть как…» на какой-то orgRow.
3. Фронт начинает слать каждый запрос с заголовком
`X-Org-Override: <orgId>`. Без этого хедера SuperAdmin видит «своё»
(а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты —
у супер-админа в админке тренировочный режим).
4. По умолчанию режим **read-only** (`ReadonlyOverrideMiddleware`):
GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH — `403`.
Исключение: `/api/super-admin/*` и `/connect/*` (refresh-token).
5. Чтобы что-то поменять, фронт показывает «Войти в edit-mode»,
запрашивает причину (≥ 10 символов), отправляет её в каждом запросе
как `X-Org-Override-Reason: <текст>`. Тогда middleware пропускает
мутации, а action-filter (`SuperAdminEditAuditFilter`) после успешного
ответа пишет строку в `super_admin_audit_log` с reason'ом и
запросом/ответом.
6. Фронт ограничивает edit-mode 30 минутами (UI таймер).
Сервер не следит за временем — это UX-конвенция, а аудит уже есть.
### ClaimsTransformer для tenant-ролей
Тонкость: SuperAdmin сам по себе не имеет ролей `Admin/Storekeeper/Cashier`
(они — атрибуты `Employee` тенанта). Контроллер
`[Authorize(Roles="Admin")]` отшил бы его 403 даже в edit-mode.
Решение: `SuperAdminOverrideClaimsTransformer` (`IClaimsTransformation`,
вызывается на каждый authenticated request) — если есть `X-Org-Override`,
динамически добавляет SuperAdmin'у `Admin/Storekeeper/Cashier` claim-роли
**только на текущий запрос**. Записи в БД не трогает.
## RequiresPermission: тонкая авторизация
Для мутаций используется `[RequiresPermission("...")]` вместо
`[Authorize(Roles="Admin,Storekeeper")]`. Атрибут резолвится через
policy-механизм:
```
[RequiresPermission("ProductsEdit")]
→ Policy "perm:ProductsEdit"
→ PermissionAuthorizationPolicyProvider создаёт PermissionRequirement
→ PermissionAuthorizationHandler проверяет
EmployeeRole.RolePermissions.ProductsEdit == true для текущего юзера
```
`RolePermissions` — это POCO с булевыми полями (`ProductsView`,
`ProductsEdit`, `RetailSalesOperate`, `RetailSalesRefund`, …).
`Employee.EmployeeRoleId` указывает на конкретную роль, у каждой —
свой набор флагов. SuperAdmin (с override) проходит всегда.
См. `food-market.api/Infrastructure/Authorization/`.
## Audit-trail
### `org_audit_log`
Каждая `Add/Update/Delete` в `AppDbContext` (через
`OrgAuditInterceptor`) пишет JSONB-diff в `org_audit_log`:
```json
{
"id": "...",
"organizationId": "...",
"userId": "...",
"entityType": "Product",
"entityId": "...",
"action": "Update",
"changesJson": { "before": {...}, "after": {...} },
"createdAt": "..."
}
```
Фоновый Hangfire-job `prune-audit-log` (03:45) чистит записи старше
180 дней.
Сидеры/миграции выставляют `_db.SkipAudit = true` чтобы не плодить
бессмысленные строки.
### `super_admin_audit_log`
Только мутации SuperAdmin'а в edit-mode, плюс изменения платформенных
настроек. Без TTL — храним всё.
## Известные подводные камни
### 1. `IgnoreQueryFilters()` — когда нужно знать
Тенант-фильтр применяется ко ВСЕМУ — в том числе там, где это «не надо».
- При логине: ищем `Organization` по `OrgId` из credentials → нужен
`IgnoreQueryFilters()`, потому что фильтр требует OrganizationId,
которого ещё нет в контексте.
- При проверке «есть ли у юзера эта орг»: `_db.Organizations.IgnoreQueryFilters().AnyAsync(...)`.
- При cross-tenant отчётах SuperAdmin'а без override-режима фильтр и так
не применится, но при override — применится; чтобы получить
cross-tenant данные в этом режиме (редко нужно), вызвать
`IgnoreQueryFilters()` явно.
### 2. Stamping не работает, если orgId уже задан
Бэкенд-код в нескольких местах принимает `Guid OrganizationId` из payload
(например, при импорте). Stamping проверяет `OrganizationId == Guid.Empty`
и НЕ перетирает уже выставленное значение. Если кто-то по ошибке прислал
чужой orgId в payload — он сохранится. Защита: явно валидировать
`OrganizationId == _tenant.OrganizationId` в контроллере (или вообще не
принимать поле из payload).
### 3. Background-jobs без HttpContext
`HangfireJobsConfigurator` регистрирует методы, исполняющиеся в фоне.
`HttpContextAccessor.HttpContext` там null → `OrganizationId` тоже null →
query filter возвращает только записи с `OrganizationId == null` (т.е.
системные справочники), а tenant-запросы — пустоту.
Решение: внутри job, перед `DbContext`-вызовом, обернуть в
`HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)`.
См. `OwnerDailySummaryJob`, `EmailNotificationJobs` — там есть пример.
### 4. SignalR за query-фильтром
`NotificationsHub` использует `IServiceProvider.GetRequiredService<AppDbContext>()`
внутри `OnConnectedAsync` для добавления соединения в group. Если в
connection нет JWT — нет org_id — нет группы → клиент не получит
событий. Web-фронт прокидывает `?access_token=...` query (см. middleware
в Program.cs), POS — `Authorization` header.
### 5. EF migrations и наследование от TenantEntity
При добавлении новой `TenantEntity` миграция должна включать
`OrganizationId` колонку (uuid, NOT NULL) и индекс
`(OrganizationId, …)` для фильтрации. **Эта колонка не появляется
автоматически в snapshot-выводе `dotnet ef migrations add`** в этом
проекте, потому что снапшот не синхронизируется с моделью (миграции
пишутся руками, см. memory `feedback_ef_migrations`).
Проверка: после `Migrate()` тестовый запрос
`SELECT column_name FROM information_schema.columns WHERE table_name='...'`
должен показать `OrganizationId`.
### 6. `xmin` concurrency и параллельные посты
`UseXminAsConcurrencyToken()` на документах. Если два кассира одновременно
постят один и тот же чек (что не должно случаться, но всё же) — второй
получает `DbUpdateConcurrencyException`. Контроллер ловит и возвращает
`409 Conflict` с сообщением «документ изменён в другой сессии, обновите
страницу».
Stock-операции — отдельная история: `Serializable` транзакция блокирует
параллельный пост на тех же товарах в том же storе. Серверу `PG40001`
(serialization_failure) — контроллер не ретраит автоматически, кассир
видит 409 «недостаточно остатка» (после ретрая по факту достаточно или
нет).
### 7. Тестирование изоляции
`TenantIsolationTests` (integration) — обязательный смок: создаём 2
организации, в одной — продукт; в другой делаем `GET /api/catalog/products`
→ список пустой. На любую новую `ITenantEntity` добавлять такой тест.
### 8. Read-models / ad-hoc raw SQL
Если контроллер напишет raw SQL через `_db.Database.SqlQueryRaw(...)`,
EF-фильтр НЕ применится. Это используется только для отчётов с тяжёлой
агрегацией (`ProfitReportController`); там OrganizationId явно
включается в `WHERE` и приходит из `_tenant.OrganizationId`.
Правило: никаких raw SQL без явного `WHERE OrganizationId = @org` в
середине запроса.
## Чеклист «как добавить новую tenant-сущность»
1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`).
2. Добавить EF Configuration в `food-market.infrastructure/Persistence/Configurations/`:
- `b.ToTable("...");`
- `b.HasIndex(x => new { x.OrganizationId, x.SomeField });` — индекс
с OrganizationId первым полем (для скорости query filter'а).
- Если есть уникальность в рамках org: `.IsUnique()` на индексе
с OrganizationId первым полем.
3. Создать миграцию руками в `Persistence/Migrations/`:
- Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDD…")]`.
- В `Up()``CreateTable` с колонкой `OrganizationId uuid NOT NULL`.
- Индекс на OrganizationId.
4. Добавить `DbSet` в `AppDbContext`.
5. Контроллер использует `_db.MyEntities.Where(...)` — query filter
подключится автоматически. Stamping выставит `OrganizationId` в `Add()`.
6. Интеграционный тест на изоляцию (см. `TenantIsolationTests`).

344
docs/RUNBOOK.md Normal file
View file

@ -0,0 +1,344 @@
# Runbook — операционные процедуры food-market
Что делать, когда что-то идёт не так, или когда нужно сделать
неавтоматическую операцию.
## Контактные точки
| Что | Где |
|---|---|
| Stage URL | https://test.admin.food-market.kz |
| Prod URL | https://admin.food-market.kz (план, ещё не задеплоен) |
| Stage VM | `nns@192.168.1.190` (через ssh, prod-vm в локалке) |
| Dev VM (этот хост) | `nns@<this>` — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry |
| Forgejo (primary git) | http://127.0.0.1:3000/nns/food-market.git |
| GitHub (mirror) | https://github.com/nurdotnet/food-market (только зеркало) |
| Local Docker registry | `192.168.1.193:5001` (memory: `local_docker_registry`) |
| Hangfire Dashboard (stage) | https://test.admin.food-market.kz/hangfire — только SuperAdmin |
| Swagger (stage) | https://test.admin.food-market.kz/swagger |
## Health-чеки
| Endpoint | Что значит | Что делать при 503 |
|---|---|---|
| `GET /health` | Процесс отвечает | Контейнер живёт, проблема в обвязке (nginx/cert/DNS). |
| `GET /health/live` | Процесс жив (без проверок) | То же. |
| `GET /health/ready` | БД отвечает + миграции применены | См. ниже «Health/ready упал». |
| `GET /metrics` | Prometheus exposition | Если 404 — приложение не стартануло. |
### `/health/ready` упал
1. `ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'`
стек ошибки на старте.
2. Типичные причины:
- **Миграция упала**: ищем `Failed executing DbCommand` / `relation
"..." already exists`. Решение: миграция конфликтует со снапшотом
БД. Возможно её надо переписать с `IF NOT EXISTS` (см.
`Phase6e_RetailSaleReturns.cs` как пример «defensive migration»).
- **OpenIddict cert pass mismatch**: переменная
`OpenIddict__CertPassword` в docker-compose env'е не совпадает с
паролем PFX-файла → `CryptographicException: PKCS12 password incorrect`.
- **Connection refused**: Postgres контейнер не успел подняться.
`depends_on.condition: service_healthy` должно это покрывать,
но если healthcheck не успел — `docker compose restart api`.
3. Если фикс требует кода — `~/deploy-stage.sh` после правки.
## Деплой на stage
```bash
~/deploy-stage.sh
```
Скрипт делает:
1. `docker build` api и web с локальным registry в качестве кеша.
2. `docker push` обоих образов в `192.168.1.193:5001`.
3. `ssh nns@192.168.1.190``docker compose -p food-market-stage pull api web``up -d --force-recreate`.
4. Ждёт `https://test.admin.food-market.kz/health/ready` до 30с.
**Важно**: проект `docker compose` называется `food-market-stage`
(флаг `-p food-market-stage`). См. инцидент ниже про project name.
## Бэкап и восстановление
### Расписание
systemd-таймер `food-market-backup.timer` (см. `deploy/`) запускается
**каждый день в 03:00 локального времени** prod-vm. Запускается через
`OnCalendar=*-*-* 03:00:00` + `Persistent=true` (догоняет пропущенные
если сервер был выключен).
Скрипт `food-market-backup.sh`:
- `pg_dump -Fc` из контейнера `food-market-postgres``db-<TS>.dump`.
- `tar czf` каталога `/opt/food-market-data/uploads``uploads-<TS>.tgz`.
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
Папка: `/opt/food-market-data/backups/`.
### Ручной бэкап
```bash
ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'
```
Или из репо разработчика:
```bash
deploy/backup.sh --remote 192.168.1.190:5434 # PG в Docker exposed на 5434
```
### Восстановление БД из дампа
> ⚠️ Перезаписывает данные. Сначала остановить API.
```bash
ssh nns@192.168.1.190
cd /opt/food-market
# 1. Остановить API/Web, оставить Postgres
docker compose -p food-market-stage stop api web
# 2. Применить дамп
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
docker exec -i food-market-stage-postgres \
pg_restore -U food_market -d food_market \
--clean --if-exists --no-owner --no-privileges \
< "$DUMP"
# 3. Поднять API обратно — миграции применятся автоматически (idempotent)
docker compose -p food-market-stage up -d api web
# 4. Проверить
curl https://test.admin.food-market.kz/health/ready
```
### Восстановление uploads
```bash
ssh nns@192.168.1.190
cd /opt/food-market-data
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
# Содержимое восстанавливается в текущий каталог (uploads/...)
```
### Полный disaster-recovery (новый сервер)
1. Поднять Docker, склонировать репо в `/opt/food-market`.
2. Скопировать бэкапы в `/opt/food-market-data/backups/`.
3. Запустить пустой стек:
```bash
cd /opt/food-market/deploy
docker compose -p food-market-stage up -d postgres
docker compose -p food-market-stage exec postgres pg_isready
```
4. Применить дамп (см. выше).
5. Восстановить uploads.
6. Запустить остальное: `docker compose -p food-market-stage up -d`.
7. Поднять nginx + сертификат (см. `docs/stage-access.md`).
8. Включить таймер бэкапов:
```bash
sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now food-market-backup.timer
```
## Перенос на другой сервер
1. На старом — снять свежий бэкап вручную.
2. На новом — поднять Docker, склонировать репо, восстановить (см. выше).
3. Обновить DNS A-запись `admin.food-market.kz` на новый IP.
4. Дождаться распространения DNS (TTL).
5. Старый сервер — выключить через 24 часа (для гарантии).
## Смена SDK-версии
> ⚠️ `global.json` фиксирует `8.0.417` с `rollForward: latestFeature`.
> Менять только когда вышел новый patch и Microsoft анонсировал
> EOL текущего. memory: НЕ переключать systemwide postgres версию.
1. На dev-машине: `dotnet --list-sdks` — проверить что новая версия
установлена.
2. Обновить `global.json` → новый patch.
3. `dotnet build` + `dotnet test`.
4. `deploy/Dockerfile.api` — обновить `FROM mirror/dotnet-sdk:X.Y`
(если тэг изменился).
5. `~/deploy-stage.sh` — задеплоить, проверить `/health/ready`.
6. Verify-suite (Playwright или вручную smoke).
7. Только после этого — менять на prod-машине.
## Логи
| Где | Что |
|---|---|
| `docker logs food-market-stage-api` (по контейнеру) | Console JSON Serilog. |
| `/opt/food-market-data/api-logs/` (Docker volume) | Файлы Serilog rolling. |
| `journalctl -u food-market-backup.service --no-pager` | Логи бэкапа. |
| Hangfire Dashboard `/hangfire` | Состояние фоновых джобов, истории, ошибки. |
Формат JSON-логов — структурированный, каждая запись содержит
`CorrelationId`, `OrgId`, `UserId` (через `LogEnrichmentMiddleware`).
Поиск по `CorrelationId` восстанавливает полный trace запроса.
## Метрики
Prometheus scrape: `GET /metrics` (без auth). Локально в проекте нет
prometheus-сервера — на stage его тоже пока нет; план — поднять
prometheus + grafana отдельным compose'ом и proxy через nginx.
Ключевые метрики (`food-market.api/Infrastructure/Observability/AppMetrics.cs`):
- `food_market_posted_total{document_type="..."}` — счётчик post'ов.
- `food_market_unposted_total{document_type="..."}` — счётчик unpost'ов.
- `food_market_db_query_duration_seconds_*` — гистограмма EF-запросов
(interceptor).
- Стандартные prometheus-net: `http_requests_received_total`,
`http_request_duration_seconds`, `dotnet_collection_count_total`,
etc.
## Известные инциденты
### Инцидент 1: docker-compose project name
**Симптом** (наблюдался при первой миграции на новый stage):
- `docker compose pull && up -d` создавали контейнеры с именами
`deploy-api-1` вместо ожидаемых `food-market-stage-api`.
- Healthcheck'и `depends_on` отрабатывали по новым именам, но nginx
configurated на старые — 502 Bad Gateway.
**Причина**: `docker compose` берёт project name из имени каталога,
если не указан `-p`. Каталог `deploy/` → project=`deploy` → контейнеры
с префиксом `deploy-`. Старые контейнеры с префиксом `food-market-stage-`
оставались стопнутыми, новые поднялись параллельно (Docker не считает
их дубликатами потому что имена разные).
**Решение**: всегда передавать `-p food-market-stage`. Сделано в
`~/deploy-stage.sh`. На prod ставить аналогичный wrapper-скрипт,
не запускать `docker compose` голым из `/opt/food-market/deploy`.
**Превенция**: в будущем — `COMPOSE_PROJECT_NAME=food-market-stage`
в `/etc/environment` на серверах, чтобы голый `docker compose` тоже
не промахивался.
### Инцидент 2: GHCR network flakiness
**Симптом**: docker push/pull в `ghcr.io` периодически зависает на
2-5 минут или падает по TCP-таймауту.
**Причина**: исходящая сеть с dev-vm к github.com нестабильна
(memory: `network_github_flaky`).
**Решение**: используем **локальный Docker registry** на
`192.168.1.193:5001` как primary, ghcr только как mirror (для
external CI/CD когда понадобится). Stage compose тянет с локального
(`REGISTRY=192.168.1.193:5001`). См. memory `local_docker_registry`.
### Инцидент 3: OpenIddict cert rotation
**Симптом**: после `docker compose down -v` (с удалением volume
`api-data`) OpenIddict не может расшифровать существующие refresh-токены
→ все пользователи разлогинены.
**Причина**: keys из `App_Data/oidc-keys/` пропали вместе с volume.
**Решение / превенция**:
- НИКОГДА не делать `down -v` на stage/prod без явного намерения.
- Хранить `App_Data` volume отдельно: `volumes: api-data:` с
`external: true` (план).
- Бэкап `App_Data` вместе с БД (TODO: добавить в `food-market-backup.sh`).
### Инцидент 4: rate-limiter eager-config
**Симптом** (в integration-тестах): тесты падают с `429 Too Many Requests`
после ~5 signup'ов.
**Причина**: `RateLimiting:Enabled=true` (default) читается ЭАГЕРНО при
регистрации сервисов; `ConfigureAppConfiguration` в `WebApplicationFactory`
применяется позже и не успевает override'нуть.
**Решение**: в integration-тестах ставим `RateLimiting__Enabled=false`
через переменную окружения **до** создания factory. Сделано в
`ApiFactory` static-конструкторе. Memory: `test_suites_setup`.
### Инцидент 5: Telegram chat-id привязка
**Симптом**: владелец org вводит chat_id, сервер тестирует отправку →
`403 Forbidden` от Telegram API.
**Причина**: пользователь не отправил `/start` боту перед привязкой,
бот не может писать первым.
**Решение / превенция**: UI показывает инструкцию «1. Откройте бота → `/start`.
2. Получите chat_id у `@userinfobot`. 3. Введите.» Это идёт сверху на
странице привязки. Бэкенд возвращает ошибку с понятным текстом.
### Инцидент 6: Identity password policy
**Симптом**: signup-форма принимает пароль `12345`, потом
`/connect/token` отшивает «Invalid credentials» — потому что Identity
сам не разрешил создать пользователя с таким паролем, но контроллер
проглотил ошибку.
**Превенция**: контроллер `AuthController.Signup` теперь возвращает
`IdentityResult.Errors` массивом → фронт показывает причину.
## Troubleshooting на стороне БД
### Большой `org_audit_log`
`prune-audit-log` каждый день чистит >180 дней; если каталог-tenant
делал массовый импорт (10к товаров за раз), таблица может вырасти на
порядок. Проверка:
```sql
SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';
```
Ручная чистка:
```sql
DELETE FROM org_audit_log WHERE created_at < now() - interval '90 days';
VACUUM ANALYZE org_audit_log;
```
### Stock-агрегат расходится с движениями
Инвариант: `stocks.quantity = SUM(stock_movements.quantity)` per
`(product_id, store_id)`. Если разошёлся (баг где-то не вызвали
`IStockService.ApplyMovementAsync`):
```sql
-- найти расхождения
SELECT s.product_id, s.store_id, s.quantity AS cached,
COALESCE(SUM(m.quantity), 0) AS actual
FROM stocks s
LEFT JOIN stock_movements m
ON m.product_id = s.product_id AND m.store_id = s.store_id
GROUP BY s.product_id, s.store_id, s.quantity
HAVING s.quantity <> COALESCE(SUM(m.quantity), 0);
-- пересчитать всё (под maintenance window!)
UPDATE stocks s SET quantity = COALESCE((
SELECT SUM(quantity) FROM stock_movements m
WHERE m.product_id = s.product_id AND m.store_id = s.store_id
), 0);
```
### `__EFMigrationsHistory` рассинхрон
Бывает после ручной правки миграции после её применения.
```sql
SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;
```
Если в коде есть миграция, которой нет в таблице — `db.Database.Migrate()`
попытается её применить (что обычно и нужно). Если в таблице есть запись,
а файла нет — обратное направление (миграция была удалена) — `Migrate()`
не упадёт, но фокус с EF Tools перестанет работать, см. memory
`feedback_ef_migrations`.
## Что НЕ делать
- НЕ менять `global.json` без явного решения (CLAUDE.md).
- НЕ переключать systemwide postgres версию через brew (поломает
смежные проекты в `~/Documents/devprojects/`).
- НЕ запускать `docker compose down -v` на stage/prod (потеря volume).
- НЕ делать миграции через `dotnet ef migrations add` — снапшот в репо
не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
- НЕ редактировать тот же файл одновременно с Mac-Claude (memory:
`feedback_serialize_edits`).

View file

@ -0,0 +1,178 @@
# Performance baseline — food-market API
Дата прогона: **2026-06-07**. Прогон против stage:
`https://test.admin.food-market.kz`. Инструмент — k6 v0.55.0.
Сетап stage'а на момент замеров:
- 1 контейнер `food-market-stage-api` (Kestrel, .NET 8).
- 1 контейнер `food-market-stage-postgres` (Postgres 16, дефолтные настройки).
- Nginx-фронт на dev-vm `192.168.1.190`, dev-машина k6-генератора в той
же локалке (RTT ~5-20мс).
Все цифры — **с одного клиента**, без воспроизведения пиковой нагрузки
из ЦА продакшна. Это baseline для регрессий, не SLA.
## 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 |
## Прогон 1: signup-burst
`tests/load/signup-burst.js` — 50/100 регистраций/мин с одного IP, 60с.
### 50 RPM (под IP-лимитом 60/мин)
| Метрика | Значение |
|---|---|
| Iterations | 51 (за 60с) |
| http_req_duration p50 | 391ms |
| http_req_duration p90 | 425ms |
| http_req_duration p95 | 446ms |
| http_req_duration p99 | ~1.37s (один outlier) |
| signup_rate_limited | 0% |
| Failures | 0 |
Прогон чистый. Signup на stage'е укладывается в ~400-450ms p95.
### 100 RPM (превышение IP-лимита)
| Метрика | Значение |
|---|---|
| Iterations | 101 |
| 2xx (успешные) | 62 |
| 429 (rate-limited) | 39 (38.6%) |
| http_req_duration p95 (2xx-only) | 437ms |
429-ответы возвращаются за единицы миллисекунд (`http_req_duration`
total p95 показывает 436ms потому что включает и 429 — лимитер очень
быстрый). Поведение by design (см. `AuthRateLimiterExtensions`),
указывает что защита работает.
## Прогон 2: retail-sales-parallel
`tests/load/retail-sales-parallel.js` — на одном tenant'е N параллельных
кассиров (VU) проводят чеки. Тест создаёт draft (`POST /api/sales/retail`)
и сразу проводит (`POST /api/sales/retail/{id}/post`).
### VU=1 (sequential baseline) — 200 итераций
| Метрика | Значение |
|---|---|
| Iterations | 200/200 (100%) |
| Throughput | **17 sales/sec** |
| sale_draft_ms p50 | 25ms |
| sale_draft_ms p95 | 37ms |
| sale_post_ms p50 | 26ms |
| sale_post_ms p95 | 35ms |
| sale_total_ms p95 | **71ms** |
| sale_total_ms p99 | ~90ms |
| post_4xx | 0% |
Идеальная картинка: 17 sales/sec на single-thread, латенция стабильная.
### VU=5 (параллельные кассиры) — 200 итераций
| Метрика | Значение |
|---|---|
| Iterations | 200/200 (driver), но успешных только 94 |
| post_4xx | **53% 🔴** |
| sale_draft_ms p95 (включая failed) | 151ms |
| sale_total_ms p95 (только успешные) | 185ms |
**Узкое место найдено: race в `GenerateNumberAsync`.**
`RetailSalesController.GenerateNumberAsync` строит next-number чтением
последней `Number` для tenant'а и +1. Под параллельными VU несколько
запросов читают одно и то же `lastNumber`, генерируют одинаковый
`ПР-2026-000XXX`, на INSERT падают на unique-index
`IX_retail_sales_OrganizationId_Number`. `SaveOrFkErrorAsync` ловит
только 23503 (FK violation), не 23505 (unique violation) — поэтому
до клиента долетает 500 (или 400 от EF middleware).
**Что делать (отдельная задача, не в Sprint 12)**: завести
`organization_counters` (singleton-row per tenant), увеличивать счётчик
через `UPDATE … RETURNING value` в той же транзакции. Альтернатива —
ловить 23505 и ретраить с +1 в цикле. Третий вариант — использовать
PG sequence per tenant (более сложно, но самое чистое).
## Прогон 3: sales-report-heavy
`tests/load/sales-report-heavy.js` — за 20-30 секунд VU дёргают
`GET /api/reports/sales?dateFrom=...&dateTo=...&groupBy=day`,
`/api/reports/abc?...` и `/api/dashboard/top-products?limit=5`.
Tenant с **1500 проведённых чеков, 5535 stock_movements, 200 товаров**
(посеян через `POST /api/admin/seed-demo?years=1` — это YearDemoSeeder).
| VU | Throughput (iter/s) | sales p95 | abc p95 | non_2xx | Заметки |
|---|---|---|---|---|---|
| 1 | 6.7 | 54ms | 51ms | 0% | Чистый baseline. |
| 2 | 17.5 | 60ms | 54ms | 0% | Linear, ОК. |
| 3 | 23.5 | 67ms | 63ms | 0% | Linear, ОК. |
| 4 | 25.4 | 81ms | 78ms | 0% | Незначительная деградация. |
| 5 | 24.0 | 114ms | 108ms | 0% | Деградация заметнее, throughput плато. |
| 5* | 8.7 | 3 800ms 🔴 | 3 500ms 🔴 | 0% | Аномалия одного прогона (см. ниже). |
`*` Первый прогон VU=5 показал p95 ~3.8с на отчёт. Повторный прогон —
обычные 114ms p95. Скорее всего совпало с autovacuum'ом
`stock_movements` (5535 строк, частые обновления при seed'е). Это
напоминает: в production нужны:
- Мониторинг p95 отчётов в Prometheus + алерт на отклонение от baseline.
- Тюнинг `autovacuum_*` для `stock_movements` (или явный
`VACUUM ANALYZE` после массовых seed'ов).
### Что НЕ протестировано (требует входа от user)
- **10 000 чеков на одного tenant'а** — пользователь просил «отчёт Sales
с 10 000 продажами». YearDemoSeeder делает максимум 1500 (это сезонный
год для одного магазина); чтобы получить 10к — нужно либо допилить
seeder на «10 лет» / «10 магазинов», либо запустить несколько
параллельных seed'ов под отдельными tenant'ами и тестировать
cross-tenant. Пока обозначено как TODO для будущего спринта.
- **Реальная нагрузка из ЦА** — k6 запускается из локалки, RTT
5-20мс. Реальный пользователь из Алматы добавит 30-80мс к
каждому запросу. Считать SLA с учётом этого.
- **POS-синк** (`POST /api/pos/v1/batch`) — отдельный сценарий, потому
что требует серии чеков с идемпотентным ключом и подходящих refs.
TODO: `pos-sync.js`.
## Сводка: что нужно поправить
| Приоритет | Что | Где |
|---|---|---|
| 🔴 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` |
## Воспроизведение
```bash
# k6 v0.55+ должен быть в PATH (см. tests/load/README.md)
cd tests/load
# 1. Signup-burst (60с, 50 RPM)
BASE_URL=https://test.admin.food-market.kz TARGET_RPM=50 \
k6 run signup-burst.js
# 2. Sales sequential baseline
BASE_URL=https://test.admin.food-market.kz \
DURATION_S=120 TARGET_ITERS=200 VUS=1 \
k6 run retail-sales-parallel.js
# 3. Reports на свежем tenant'е (нужны creds от signup + year-demo seed)
SLUG="loadbase-$(date +%s)"
EMAIL="$SLUG@example.kz"
curl -sX POST $BASE_URL/api/auth/signup -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"Passw0rd!\",\"organizationName\":\"LoadOrg\",\"phone\":\"+77001234567\"}"
TOKEN=$(curl -sX POST $BASE_URL/connect/token -d "grant_type=password&username=$EMAIL&password=Passw0rd!&client_id=food-market-web&scope=openid profile email roles api offline_access" | jq -r .access_token)
curl -sX POST -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/admin/seed-demo?years=1"
EMAIL=$EMAIL PASSWORD=Passw0rd! VUS=3 k6 run sales-report-heavy.js
```

131
docs/sprint12-progress.md Normal file
View file

@ -0,0 +1,131 @@
# Sprint 12 — документация, runbook, нагрузочное тестирование
Цель: переложить «то что знаю только я и комментарии в коде» в
читаемые документы для следующего разработчика, замерить реальную
производительность под нагрузкой, и закрыть автоматическую верификацию
stage-стэйджа на каждый push.
Старт: 2026-06-07. Исполнитель: Claude Opus 4.7.
Это **последний автономно-безопасный спринт**. Дальше нужны входы от
user'а: реальные ОФД-ApiKey, MoySklad webhook-token'ы, Windows-машина
для POS WPF, прод-деплой план, казахские переводы, реальный SMTP-провайдер.
## Принципы
- Документация — для человека, не «AI-портянка». Конкретные пути, имена
типов, причины решений. Без воды и эмоций.
- k6 — реальные числа. Если p95 высокий — пишем как есть.
- НЕ трогать: `global.json`, прод-стек, POS WPF.
## Чек-лист
- [x] **1. docs/ARCHITECTURE.md** — карта слоёв, модулей, потоков
signup→bootstrap→операции. Реальные имена типов и путей, не маркетинг.
- [x] **2. docs/MULTI-TENANCY.md**`ITenantEntity` + reflection
query-filter, stamping в SaveChanges, SuperAdmin override (read-only +
edit-mode с reason), 8 подводных камней (IgnoreQueryFilters, фоновые
jobs без HttpContext, raw SQL, и т.д.).
- [x] **3. docs/RUNBOOK.md** — health-чеки, backup/restore (включая
disaster-recovery), смена SDK, перенос на новый сервер, **6 описанных
инцидентов** (включая docker-compose project name из ТЗ),
troubleshooting БД (stock-агрегат расхождения, audit-log
размер, EFMigrationsHistory).
- [x] **4. docs/DEVELOPER-GUIDE.md** — локальный setup, запуск тестов,
гочи integration-тестов (Ryuk, rate-limiter eager-config, один
ApiFactory), полные паттерны: добавить controller с permission +
добавить tenant-сущность с RowVersion + 5 шагов миграции, валидация
(DataAnnotations / FluentValidation / бизнес), structured-логирование.
- [x] **5. k6 нагрузочный тест**`tests/load/` + 3 скрипта
(signup-burst, retail-sales-parallel, sales-report-heavy) +
`docs/performance-baseline.md` с **реальными цифрами** на stage'е.
Главное найденное: race в `GenerateNumberAsync` при VU > 1 на одном
tenant'е (unique-violation 23505 не ловится → 500). Прогон зарегистрирован
как P0 для следующего рефакторинга.
- [x] **6. CI workflow `.forgejo/workflows/stage-verify.yml`**
`on workflow_run` после `Docker API`/`Docker Web`, ждёт
`/health/ready` и запускает `tests/stage-smoke.sh` (~7с,
full-cycle smoke: signup → multi-tenant isolation → supply.post →
retail-sale.post → stock check). Telegram-нотификация по
успеху/падению.
## Журнал
### 2026-06-07 старт
Sprint 11 закрыт (7/7 ✓). Поехали по docs-чек-листу.
### 2026-06-07 п.1–п.4 (документация)
Прочитал реальный код: `Program.cs` composition root, `AppDbContext`
reflection-фильтры, `HttpContextTenantContext` с AsyncLocal-override,
`SuperAdminOverrideClaimsTransformer` + `ReadonlyOverrideMiddleware`,
`RequiresPermissionAttribute` + policy-handler, `HangfireJobsConfigurator`
recurring jobs, deploy/Dockerfile + docker-compose, backup-скрипт +
systemd-timer.
Написал 4 документа на основе этого:
- `ARCHITECTURE.md` (372 строки) — слои + модули + composition root +
поток signup→post с детальным трассировщиком ASP.NET pipeline.
- `MULTI-TENANCY.md` (256 строк) — query-filter, stamping,
SuperAdmin override, 8 подводных камней + чеклист «как добавить
tenant-сущность».
- `RUNBOOK.md` (337 строк) — health-чеки, backup/restore с примером,
смена SDK, disaster-recovery, 6 инцидентов, БД-troubleshooting.
- `DEVELOPER-GUIDE.md` (332 строки) — локальный setup, тесты,
паттерны (controller + entity + валидация + логирование), "НЕ
делать" список.
### 2026-06-07 п.5 (k6 baseline)
k6 v0.55.0 standalone в `~/bin/k6`. 3 скрипта в `tests/load/`:
- `signup-burst.js`: 50 RPM → p95 446ms, 0% errors. 100 RPM → 39% 429
(IP-лимит работает, by design).
- `retail-sales-parallel.js`: VU=1 — 17 sales/sec, p95 71ms, 0%
failures. VU=5 — **53% failure** из-за race в `GenerateNumberAsync`
(unique violation на `RetailSale.Number`). Это **реальная находка**,
P0 для следующего спринта.
- `sales-report-heavy.js`: на tenant'е с 1500 чеков, VU=1 — p95 54ms,
VU=4 — p95 81ms, VU=5 — p95 114ms (один аномальный прогон показал
3.8с — autovacuum suspect).
Все цифры в `docs/performance-baseline.md` с воспроизведением.
### 2026-06-07 п.6 (CI workflow)
`.forgejo/workflows/stage-verify.yml``on: workflow_run` после
`Docker API` и `Docker Web`, не запускается на failed parent (нет
смысла верифировать незадеплоенное). Шаги: wait-for-ready (60с
retry loop) → запустить `tests/stage-smoke.sh` → Telegram пинг.
`tests/stage-smoke.sh` — bash-скрипт без зависимостей кроме
curl+jq+python3. 5 этапов: health, signup A, token A, multi-tenant
isolation (A создаёт продукт, B получает 404 + список без продукта A),
полный документ-цикл (supplier+supply.post → проверка stock=100 →
sale.post → проверка stock=99). Локальный прогон против stage —
**7 секунд**, всё зелёное.
### Итог
Все 6 пунктов ✓. Документация:
- 4 новых файла в `docs/` (~1300 строк суммарно).
- `docs/performance-baseline.md` — реальные цифры + 1 находка P0.
Тестирование:
- 3 k6 скрипта в `tests/load/`.
- `tests/stage-smoke.sh` — 7-секундный smoke против stage.
CI:
- `.forgejo/workflows/stage-verify.yml` — auto-verify на каждый
successful deploy.
Следующие шаги, требующие user'а (за пределами автономного режима):
1. Реальный ОФД ApiKey (Webkassa предпочтительно) — Sprint 11-fiscal
ждёт это для активации.
2. Решение по прод-деплой (домен + cert + DNS).
3. MoySklad webhook-токены для inline-импорта.
4. Windows-машина (или CI runner) для POS WPF сборки.
5. Казахский переводчик для UI (i18n уже подготовлен).
6. Реальный SMTP-провайдер для платформы (Mailgun / Postmark / Yandex).
Plus P0-задача из baseline'а: исправить race в `GenerateNumberAsync`
для `RetailSalesController` и аналогичных контроллеров — это уже
автономно делается, но требует дизайн-решения (per-tenant sequence vs
counter table vs retry-loop).

50
tests/load/README.md Normal file
View file

@ -0,0 +1,50 @@
# k6 нагрузочные тесты
Сценарии нагрузочного тестирования API food-market через
[k6](https://k6.io/).
## Подготовка
```bash
# k6 standalone (Linux)
wget -O- https://github.com/grafana/k6/releases/download/v0.55.0/k6-v0.55.0-linux-amd64.tar.gz | tar xz
mv k6-*-linux-amd64/k6 ~/bin/
# проверить
k6 version
```
## Сценарии
| Файл | Что меряет |
|---|---|
| `signup-burst.js` | 100 signup'ов за минуту — bootstrap новых tenant'ов под нагрузкой |
| `retail-sales-parallel.js` | 1000 проведённых чеков параллельно за 5 минут (один tenant) |
| `sales-report-heavy.js` | Чтение отчёта `/api/reports/sales` при 10 000 уже-существующих продажах |
## Запуск против stage
```bash
BASE_URL=https://test.admin.food-market.kz \
k6 run signup-burst.js
BASE_URL=https://test.admin.food-market.kz \
k6 run retail-sales-parallel.js
BASE_URL=https://test.admin.food-market.kz \
k6 run sales-report-heavy.js
```
Результаты сводятся в `docs/performance-baseline.md`.
## Что k6 печатает
```
checks_total....: 100.00% 1000/1000
http_req_duration: p(50)=82ms p(95)=312ms p(99)=580ms
http_req_failed.: 0.00% 0/1000
iteration_duration: avg=512ms
```
Это «человеческие» метрики. Тонкое — через `--out json=summary.json`
или `--out csv=...` (k6 не имеет своего хранилища, только stdout/file).

View file

@ -0,0 +1,173 @@
// retail-sales-parallel.js — нагрузка на проведение чеков.
//
// Сценарий: один tenant создан вне теста (или одним setup), все VU
// проводят чеки параллельно. Цель — увидеть p95/p99 операции
// «создать draft → /post → дождаться NoContent» под нагрузкой.
//
// По умолчанию: 1000 итераций за 5 минут (~3.3 RPS). Подкручивается
// через DURATION_S и TARGET_ITERS env'ами.
//
// ВАЖНО:
// - Перед запуском нужен tenant с продуктом и приёмкой. setup()
// делает всё сам: signup → seed product → post supply на 10 000 шт.
// Один setup на запуск — потом VU работают параллельно с тем же
// токеном. Если итерация не находит остатка (например, тестовый
// tenant закончился) — фейлим check.
// - Запуск против stage заметно дольше из-за сетевого RTT (~30-100мс
// на запрос). Локально на dev-vm обычно в 3-5 раз быстрее.
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate, Counter } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
const DURATION_S = Number(__ENV.DURATION_S || 300); // 5 минут
const TARGET_ITERS = Number(__ENV.TARGET_ITERS || 1000);
const draftTrend = new Trend('sale_draft_ms', true);
const postTrend = new Trend('sale_post_ms', true);
const totalTrend = new Trend('sale_total_ms', true);
const post4xx = new Rate('post_4xx');
const completed = new Counter('sales_completed');
const VUS = Number(__ENV.VUS || 5);
export const options = {
scenarios: {
retail_sales: {
executor: 'shared-iterations',
iterations: TARGET_ITERS,
vus: VUS, // дефолт 5 параллельных кассиров
maxDuration: `${DURATION_S}s`,
},
},
thresholds: {
// p95 «draft + post» на stage обычно 200-700ms.
sale_total_ms: ['p(95)<3000', 'p(99)<6000'],
// ⚠️ При VU > 5 на одном tenant'е будут конфликты на уникальный
// индекс RetailSale.Number (генератор номера расы) — это
// отдельная задача для оптимизации (см. performance-baseline.md).
post_4xx: ['rate<0.10'],
},
};
// ── setup: один раз для всех VU ────────────────────────────────────────
export function setup() {
const slug = `load-sales-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const email = `${slug}@example.kz`;
const password = 'Passw0rd!';
// 1. Signup
let r = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({
email, password, organizationName: `LoadOrg-${slug}`,
phone: '+77001234567', plan: null,
}), { headers: { 'Content-Type': 'application/json' } });
if (r.status >= 300) throw new Error(`signup failed: ${r.status} ${r.body}`);
// 2. Token
r = http.post(`${BASE_URL}/connect/token`,
`grant_type=password&username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
if (r.status !== 200) throw new Error(`token failed: ${r.status} ${r.body}`);
const token = r.json('access_token');
const auth = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } };
// 3. Достаём refs (unit/group/store/currency/priceType)
const unitsRes = http.get(`${BASE_URL}/api/catalog/units-of-measure?pageSize=200`, auth);
const unit = unitsRes.json('items').find(u => u.code === '796');
const groupsRes = http.get(`${BASE_URL}/api/catalog/product-groups`, auth);
const group = groupsRes.json('items')[0];
const ptsRes = http.get(`${BASE_URL}/api/catalog/price-types`, auth);
const pt = ptsRes.json('items').find(p => p.isRetail);
const cursRes = http.get(`${BASE_URL}/api/catalog/currencies`, auth);
const cur = cursRes.json('items').find(c => c.code === 'KZT');
const storesRes = http.get(`${BASE_URL}/api/catalog/stores`, auth);
const store = storesRes.json('items').find(s => s.isMain);
const rpsRes = http.get(`${BASE_URL}/api/catalog/retail-points`, auth);
const retailPoint = rpsRes.json('items')[0];
// 4. Создаём продукт
const prodRes = http.post(`${BASE_URL}/api/catalog/products`, JSON.stringify({
name: 'Load test product',
article: `LT-${slug}`,
unitOfMeasureId: unit.id,
vat: 12, vatEnabled: true,
productGroupId: group.id,
packaging: 1,
prices: [{ priceTypeId: pt.id, amount: 100, currencyId: cur.id }],
barcodes: [{ code: `9990000${Math.floor(Math.random()*1e6).toString().padStart(7,'0')}`, type: 1, isPrimary: true }],
}), auth);
if (prodRes.status >= 300) throw new Error(`product failed: ${prodRes.status} ${prodRes.body}`);
const productId = prodRes.json('id');
// 5. Поставщик + приёмка на 10 000 шт.
const supRes = http.post(`${BASE_URL}/api/catalog/counterparties`,
JSON.stringify({ name: 'LoadSupplier', type: 2 }), auth);
const supplierId = supRes.json('id');
const supplyRes = http.post(`${BASE_URL}/api/purchases/supplies`, JSON.stringify({
date: new Date().toISOString(),
supplierId, storeId: store.id, currencyId: cur.id,
lines: [{ productId, quantity: 10000, unitPrice: 50 }],
}), auth);
const supplyId = supplyRes.json('id');
const postSupplyRes = http.post(`${BASE_URL}/api/purchases/supplies/${supplyId}/post`, null, auth);
if (postSupplyRes.status >= 300) throw new Error(`supply post failed: ${postSupplyRes.status}`);
return {
token, productId,
storeId: store.id,
retailPointId: retailPoint.id,
currencyId: cur.id,
};
}
// ── default fn: каждая итерация = один проведённый чек ─────────────────
export default function (ctx) {
const auth = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${ctx.token}`,
},
};
const t0 = Date.now();
// 1. Создать draft (POST /api/sales/retail)
const draftPayload = JSON.stringify({
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, isReturn: false,
lines: [{
productId: ctx.productId,
quantity: 1, unitPrice: 100, discount: 0, vatPercent: 12,
}],
subtotal: 100, discountTotal: 0, total: 100,
paidCash: 100, paidCard: 0,
notes: 'k6-load',
});
const tDraft0 = Date.now();
const draftRes = http.post(`${BASE_URL}/api/sales/retail`, draftPayload, auth);
draftTrend.add(Date.now() - tDraft0);
const draftOk = check(draftRes, { 'draft 2xx': (r) => r.status >= 200 && r.status < 300 });
if (!draftOk) {
post4xx.add(1);
return;
}
const saleId = draftRes.json('id');
// 2. Провести (POST /api/sales/retail/{id}/post)
const tPost0 = Date.now();
const postRes = http.post(`${BASE_URL}/api/sales/retail/${saleId}/post`, null, auth);
postTrend.add(Date.now() - tPost0);
post4xx.add(postRes.status >= 400);
check(postRes, { 'post 204': (r) => r.status === 204 });
totalTrend.add(Date.now() - t0);
if (postRes.status === 204) completed.add(1);
}

View file

@ -0,0 +1,78 @@
// sales-report-heavy.js — нагрузка чтения отчётов.
//
// Сценарий: один tenant с уже-нагенерированными чеками (1500 через
// YearDemoSeeder или 10000+ через несколько подряд запусков
// retail-sales-parallel.js). 30 секунд VU дёргают
// GET /api/reports/sales и GET /api/reports/abc, мерим p95/p99.
//
// Это «чтение тяжёлого агрегата», самый показательный bench для
// PG-индексов и плана запроса. Если p95 > 2с при 1500 чеках —
// нужен EXPLAIN.
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
const DURATION_S = Number(__ENV.DURATION_S || 30);
const VUS = Number(__ENV.VUS || 10);
// Email/пароль уже-существующего tenant'а — нужно подсунуть. По дефолту
// — admin@food-market.local (SuperAdmin без своей орги, отчёты будут
// пустыми, но мы мерим только время ответа эндпоинтов).
const EMAIL = __ENV.EMAIL || 'admin@food-market.local';
const PASSWORD = __ENV.PASSWORD || 'Admin12345!';
const salesTrend = new Trend('reports_sales_ms', true);
const abcTrend = new Trend('reports_abc_ms', true);
const dashboardTrend = new Trend('dashboard_top_ms', true);
const non2xx = new Rate('reports_non_2xx');
export const options = {
scenarios: {
reports: {
executor: 'constant-vus',
vus: VUS,
duration: `${DURATION_S}s`,
},
},
thresholds: {
reports_sales_ms: ['p(95)<2500', 'p(99)<5000'],
reports_abc_ms: ['p(95)<3000', 'p(99)<6000'],
reports_non_2xx: ['rate<0.01'],
},
};
export function setup() {
const r = http.post(`${BASE_URL}/connect/token`,
`grant_type=password&username=${encodeURIComponent(EMAIL)}&password=${encodeURIComponent(PASSWORD)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
if (r.status !== 200) throw new Error(`token failed for ${EMAIL}: ${r.status} ${r.body}`);
return { token: r.json('access_token') };
}
export default function (ctx) {
const auth = { headers: { Authorization: `Bearer ${ctx.token}` }, tags: {} };
// Период «весь год» — стандартный запрос дашборда «за год».
const dateFrom = '2026-01-01';
const dateTo = '2026-12-31';
let t0 = Date.now();
let r = http.get(`${BASE_URL}/api/reports/sales?dateFrom=${dateFrom}&dateTo=${dateTo}&groupBy=day`, auth);
salesTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'sales 2xx': (x) => x.status >= 200 && x.status < 300 });
t0 = Date.now();
r = http.get(`${BASE_URL}/api/reports/abc?dateFrom=${dateFrom}&dateTo=${dateTo}`, auth);
abcTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'abc 2xx': (x) => x.status >= 200 && x.status < 300 });
t0 = Date.now();
r = http.get(`${BASE_URL}/api/dashboard/top-products?limit=5`, auth);
dashboardTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'dashboard 2xx': (x) => x.status >= 200 && x.status < 300 });
}

View file

@ -0,0 +1,69 @@
// signup-burst.js — нагрузочный тест регистрации новых tenant'ов.
//
// Сценарий: 100 signup'ов в минуту, ramping VU от 1 до 20 и обратно.
//
// ВАЖНО: на stage IP-лимит rate-limiter'а — 60/мин на /api/auth/signup
// и /connect/token (DefaultPerIpPerMinute=60, см.
// AuthRateLimiterExtensions). Запуская 100/мин с одного IP, мы УПРЁМСЯ
// в 429 — это нормальный исход теста. Метрика «процент 429» показывает,
// насколько IP-лимит держит вход; метрика «латенция при <60 rps» —
// чистая производительность стэка.
//
// Чтобы по-честному померить bootstrap БЕЗ упирания в лимит — уменьшить
// до 50 RPS (через TARGET_RPM=50).
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
const TARGET_RPM = Number(__ENV.TARGET_RPM || 100);
const DURATION_S = 60;
const PER_SECOND = TARGET_RPM / 60;
const signupTrend = new Trend('signup_duration_ms', true);
const signup429 = new Rate('signup_rate_limited');
export const options = {
scenarios: {
signup_burst: {
executor: 'constant-arrival-rate',
rate: TARGET_RPM,
timeUnit: '1m',
duration: `${DURATION_S}s`,
preAllocatedVUs: 20,
maxVUs: 40,
},
},
thresholds: {
// Прагматичные пороги. p95 на dev-stack обычно < 1.5с для signup
// (создание Organization + User + Employee + Store + RetailPoint).
http_req_duration: ['p(95)<3000', 'p(99)<6000'],
// 429 — допустимо, но не должно быть >50% (тогда тест не информативен).
signup_rate_limited: ['rate<0.7'],
},
};
export default function () {
const id = `${__VU}-${__ITER}-${Date.now()}`;
const email = `load-signup-${id}@example.kz`;
const payload = JSON.stringify({
email,
password: 'Passw0rd!',
organizationName: `LoadOrg-${id}`,
phone: '+77001234567',
plan: null,
});
const t0 = Date.now();
const res = http.post(`${BASE_URL}/api/auth/signup`, payload, {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'signup' },
});
signupTrend.add(Date.now() - t0);
signup429.add(res.status === 429);
check(res, {
'status is 2xx or 429': (r) => r.status >= 200 && r.status < 300 || r.status === 429,
});
}

158
tests/stage-smoke.sh Executable file
View file

@ -0,0 +1,158 @@
#!/usr/bin/env bash
# stage-smoke.sh — быстрый smoke-suite для stage-verify CI.
#
# Что проверяется:
# 1. /health/ready → 200, status=Healthy.
# 2. Signup tenant A → 2xx.
# 3. Token tenant A → access_token.
# 4. Multi-tenant: signup tenant B, проверка что products tenant'а A
# НЕ видны под токеном tenant'а B (изоляция).
# 5. Один полный документ-цикл tenant'а A:
# create product → create supplier → create supply → post supply
# → проверка остатка > 0 → create retail sale → post retail sale
# → 204.
#
# Зависит от: curl, jq, python3 (для генерации UUID barcode).
# Запуск: BASE_URL=https://test.admin.food-market.kz bash tests/stage-smoke.sh
# Без BASE_URL — http://localhost:5081.
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:5081}"
SLUG="smoke-$(date +%s)-$RANDOM"
log() { echo "[$(date -Is)] $*"; }
fail() { echo "[FAIL] $*" >&2; exit 1; }
curl_json() {
# curl_json METHOD URL [BODY] [TOKEN]
local method="$1" url="$2" body="${3:-}" token="${4:-}"
local args=(-sS -X "$method" -w '\n%{http_code}' -H 'Content-Type: application/json')
[ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
[ -n "$body" ] && args+=(-d "$body")
curl "${args[@]}" "$url"
}
# Возвращает HTTP-код из последней строки curl_json
last_status() { echo "$1" | tail -n1; }
body_only() { echo "$1" | sed '$d'; }
# ── 1. /health/ready ──────────────────────────────────────────────────
log "[1/5] /health/ready"
if ! curl -fsS "$BASE_URL/health/ready" | jq -e '.status == "Healthy"' > /dev/null; then
fail "/health/ready вернул не Healthy"
fi
# ── 2. Signup tenant A ────────────────────────────────────────────────
log "[2/5] signup tenant A"
EMAIL_A="$SLUG-a@example.kz"
PASSWORD="Passw0rd!"
RES=$(curl_json POST "$BASE_URL/api/auth/signup" \
"{\"email\":\"$EMAIL_A\",\"password\":\"$PASSWORD\",\"organizationName\":\"SmokeOrgA-$SLUG\",\"phone\":\"+77001234567\"}")
[ "$(last_status "$RES")" = "200" ] || fail "signup A: $(last_status "$RES") body=$(body_only "$RES")"
# ── 3. Token tenant A ─────────────────────────────────────────────────
log "[3/5] token tenant A"
TOKEN_A=$(curl -sSf -X POST "$BASE_URL/connect/token" \
-d "grant_type=password&username=$EMAIL_A&password=$PASSWORD&client_id=food-market-web&scope=openid profile email roles api offline_access" \
| jq -r .access_token)
[ -n "$TOKEN_A" ] && [ "$TOKEN_A" != "null" ] || fail "token A пустой"
# ── 4. Multi-tenant: tenant B не видит product tenant'а A ────────────
log "[4/5] multi-tenant isolation"
# Tenant A создаёт продукт
UNIT_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/units-of-measure?pageSize=200" \
| jq -r '.items[] | select(.code=="796") | .id')
GROUP_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/product-groups" \
| jq -r '.items[0].id')
PT_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/price-types" \
| jq -r '.items[] | select(.isRetail) | .id')
CUR_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/currencies" \
| jq -r '.items[] | select(.code=="KZT") | .id')
STORE_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/stores" \
| jq -r '.items[] | select(.isMain) | .id')
PRODUCT_NAME="SmokeProd-$SLUG"
BARCODE=$(python3 -c "import random; print(''.join([str(random.randint(0,9)) for _ in range(13)]))")
PRODUCT_BODY=$(jq -nc \
--arg name "$PRODUCT_NAME" --arg art "ART-$SLUG" --arg unit "$UNIT_ID" --arg group "$GROUP_ID" \
--arg pt "$PT_ID" --arg cur "$CUR_ID" --arg bc "$BARCODE" \
'{name:$name, article:$art, unitOfMeasureId:$unit, vat:12, vatEnabled:true,
productGroupId:$group, packaging:1,
prices:[{priceTypeId:$pt, amount:100, currencyId:$cur}],
barcodes:[{code:$bc, type:1, isPrimary:true}]}')
PROD_RES=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d "$PRODUCT_BODY" "$BASE_URL/api/catalog/products")
PRODUCT_ID=$(echo "$PROD_RES" | jq -r .id)
[ -n "$PRODUCT_ID" ] && [ "$PRODUCT_ID" != "null" ] || fail "product create A failed: $PROD_RES"
# Tenant B регистрируется и забирает токен
EMAIL_B="$SLUG-b@example.kz"
curl -sSf -X POST -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL_B\",\"password\":\"$PASSWORD\",\"organizationName\":\"SmokeOrgB-$SLUG\",\"phone\":\"+77001234568\"}" \
"$BASE_URL/api/auth/signup" > /dev/null
TOKEN_B=$(curl -sSf -X POST "$BASE_URL/connect/token" \
-d "grant_type=password&username=$EMAIL_B&password=$PASSWORD&client_id=food-market-web&scope=openid profile email roles api offline_access" \
| jq -r .access_token)
# Tenant B пытается прочитать конкретный продукт A — должен получить 404
B_GET_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN_B" "$BASE_URL/api/catalog/products/$PRODUCT_ID")
[ "$B_GET_STATUS" = "404" ] || fail "🔴 multi-tenant LEAK: B видит product A ($B_GET_STATUS)"
# Tenant B запрашивает список — продукт A не должен быть в списке
B_LIST=$(curl -sSf -H "Authorization: Bearer $TOKEN_B" "$BASE_URL/api/catalog/products?pageSize=500")
B_HAS_A=$(echo "$B_LIST" | jq --arg name "$PRODUCT_NAME" 'any(.items[]; .name == $name)')
[ "$B_HAS_A" = "false" ] || fail "🔴 multi-tenant LEAK: B видит product A в списке"
log " isolation OK"
# ── 5. Полный документ-цикл tenant'а A ────────────────────────────────
log "[5/5] supply + retail sale цикл"
# Supplier
SUP_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"name":"SmokeSupplier","type":2}' \
"$BASE_URL/api/catalog/counterparties" | jq -r .id)
# Supply
SUPPLY_BODY=$(jq -nc \
--arg sup "$SUP_ID" --arg store "$STORE_ID" --arg cur "$CUR_ID" --arg prod "$PRODUCT_ID" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{date:$date, supplierId:$sup, storeId:$store, currencyId:$cur,
lines:[{productId:$prod, quantity:100, unitPrice:50}]}')
SUPPLY_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d "$SUPPLY_BODY" "$BASE_URL/api/purchases/supplies" | jq -r .id)
SUPPLY_POST=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/purchases/supplies/$SUPPLY_ID/post")
[ "$SUPPLY_POST" = "204" ] || fail "supply post: $SUPPLY_POST"
# Stock check (должен стать 100)
STOCK=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" \
"$BASE_URL/api/inventory/stock?productId=$PRODUCT_ID&pageSize=10" \
| jq -r --arg p "$PRODUCT_ID" '.items[] | select(.productId==$p) | .quantity')
# .quantity приходит как "100.0000" (precision 18,4) — сравниваем численно
awk -v s="$STOCK" 'BEGIN{exit !(s+0 == 100)}' || fail "stock после supply: ожидали 100, получили '$STOCK'"
# Retail sale
RP_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/retail-points" \
| jq -r '.items[0].id')
SALE_BODY=$(jq -nc \
--arg store "$STORE_ID" --arg rp "$RP_ID" --arg cur "$CUR_ID" --arg prod "$PRODUCT_ID" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{date:$date, storeId:$store, retailPointId:$rp, customerId:null, currencyId:$cur,
payment:0, isReturn:false,
lines:[{productId:$prod, quantity:1, unitPrice:100, discount:0, vatPercent:12}],
subtotal:100, discountTotal:0, total:100, paidCash:100, paidCard:0, notes:"smoke"}')
SALE_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d "$SALE_BODY" "$BASE_URL/api/sales/retail" | jq -r .id)
SALE_POST=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/sales/retail/$SALE_ID/post")
[ "$SALE_POST" = "204" ] || fail "retail-sale post: $SALE_POST"
# Stock после продажи должен стать 99
STOCK_AFTER=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" \
"$BASE_URL/api/inventory/stock?productId=$PRODUCT_ID&pageSize=10" \
| jq -r --arg p "$PRODUCT_ID" '.items[] | select(.productId==$p) | .quantity')
awk -v s="$STOCK_AFTER" 'BEGIN{exit !(s+0 == 99)}' || fail "stock после sale: ожидали 99, получили '$STOCK_AFTER'"
log "✅ smoke OK ($SLUG)"