diff --git a/.forgejo/workflows/stage-verify.yml b/.forgejo/workflows/stage-verify.yml new file mode 100644 index 0000000..2839fd8 --- /dev/null +++ b/.forgejo/workflows/stage-verify.yml @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..49ea6ae --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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()`. + +### 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`. 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) — бэкапы. diff --git a/docs/DEVELOPER-GUIDE.md b/docs/DEVELOPER-GUIDE.md new file mode 100644 index 0000000..c70f4d3 --- /dev/null +++ b/docs/DEVELOPER-GUIDE.md @@ -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` параллельно нельзя — + `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__` (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 _log; + + public LoyaltyProgramsController( + AppDbContext db, ITenantContext tenant, ILogger 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> 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` в + `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(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 PromoCodes => Set(); +``` + +### 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(type: "uuid", nullable: false), + OrganizationId = t.Column(type: "uuid", nullable: false), + Code = t.Column(type: "character varying(40)", maxLength: 40, nullable: false), + Discount = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + ExpiresAt = t.Column(type: "timestamp with time zone", nullable: true), + IsActive = t.Column(type: "boolean", nullable: false, defaultValue: true), + CreatedAt = t.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = t.Column(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 +{ + 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()`. `ValidationFilter` +(глобальный action-filter в Program.cs) запускает их на каждом +action и возвращает 400 ProblemDetails (RFC 7807). + +### Бизнес-валидация (требует БД) + +Если правило требует справиться с БД (например, «склад существует и +не архивирован»), вынесите в первый шаг action-метода: + +```csharp +[HttpPost] +public async Task 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 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) — где живут секреты. diff --git a/docs/MULTI-TENANCY.md b/docs/MULTI-TENANCY.md new file mode 100644 index 0000000..37d04c5 --- /dev/null +++ b/docs/MULTI-TENANCY.md @@ -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(ModelBuilder builder) where T : class, ITenantEntity +{ + // SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…». + // В override-режиме (X-Org-Override header активен) он работает в + // контексте конкретной орги — фильтр обязан применяться. + builder.Entity().HasQueryFilter(e => + (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) + || e.OrganizationId == _tenant.OrganizationId); +} + +private void ApplyOptionalTenantFilter(ModelBuilder builder) where T : class, IOptionalTenantEntity +{ + builder.Entity().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: `. Без этого хедера 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()` +внутри `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`). diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 0000000..15d85a9 --- /dev/null +++ b/docs/RUNBOOK.md @@ -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@` — здесь крутится локальный 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-.dump`. +- `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-.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`). diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..e9a01e6 --- /dev/null +++ b/docs/performance-baseline.md @@ -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 +``` diff --git a/docs/sprint12-progress.md b/docs/sprint12-progress.md new file mode 100644 index 0000000..84413ac --- /dev/null +++ b/docs/sprint12-progress.md @@ -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). diff --git a/tests/load/README.md b/tests/load/README.md new file mode 100644 index 0000000..482c5bf --- /dev/null +++ b/tests/load/README.md @@ -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). diff --git a/tests/load/retail-sales-parallel.js b/tests/load/retail-sales-parallel.js new file mode 100644 index 0000000..199aaa4 --- /dev/null +++ b/tests/load/retail-sales-parallel.js @@ -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); +} diff --git a/tests/load/sales-report-heavy.js b/tests/load/sales-report-heavy.js new file mode 100644 index 0000000..51546f3 --- /dev/null +++ b/tests/load/sales-report-heavy.js @@ -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 }); +} diff --git a/tests/load/signup-burst.js b/tests/load/signup-burst.js new file mode 100644 index 0000000..087bca3 --- /dev/null +++ b/tests/load/signup-burst.js @@ -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, + }); +} diff --git a/tests/stage-smoke.sh b/tests/stage-smoke.sh new file mode 100755 index 0000000..fb654b3 --- /dev/null +++ b/tests/stage-smoke.sh @@ -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)"