Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.
Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
disaster-recovery на новый сервер, 6 описанных инцидентов
(включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
полные паттерны (controller с permission + tenant-сущность с
RowVersion + 5 шагов миграции), валидация, structured-логирование,
«НЕ делать» список.
k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
* signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
* retail-sale sequential — 17/sec, p95 71ms;
* retail-sale @ VU>1 — 53% failure из-за race в
GenerateNumberAsync (unique-violation 23505 не ловится в
SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
* reports на 1500 чеков — p95 50-114ms до VU=5.
CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
5 этапов: health, signup, token, multi-tenant изоляция (B → 404
на product A, B → пустой список), полный документ-цикл
(supplier+supply.post → stock=100 → sale.post → stock=99).
Локальный прогон против stage — все этапы зелёные.
Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
26 KiB
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— оптимистичная блокировка через PGxmin(Xminполе).- Бизнес-сущности по поддоменам:
Catalog/(Product, Counterparty, ProductGroup, …),Inventory/(Stock, StockMovement, Loss, Transfer, Inventory),Purchases/(Supply, Enter, SupplierReturn),Sales/(RetailSale, RetailSaleLine, Demand, LoyaltyCard, LoyaltyProgram, Promotion),Organizations/(Organization, Employee, EmployeeRole, OrgAuditLog, SuperAdminAuditLog),Platform/(PlatformSettings — singleton SMTP-конфиг).
Application (src/food-market.application/)
- CQRS на MediatR — пока partial: образцы в
Purchases/Commands/CreateSupplyCommand.cs,Sales/Commands/PostRetailSaleCommand.cs,Sales/Queries/GetSalesReportQuery.cs. Большинство контроллеров пока «толстые» (исторически до TD-1). - Абстракции:
Common/Tenancy/ITenantContext—OrganizationId,IsSuperAdmin,IsTenantOverride,UserId.Common/Email/IEmailSender— отправка через текущий SMTP-конфиг.Common/Fiscal/IFiscalProvider+IFiscalProviderFactory(Sprint 11).Inventory/IStockService— единая точка списания/начисления остатка (любая операция, меняющая склад, идёт черезApplyMovementAsync).
- FluentValidation валидаторы рядом с DTO; глобально подключаются
через
AddValidatorsFromAssemblyContaining<Program>().
Infrastructure (src/food-market.infrastructure/)
Persistence/AppDbContext.cs— единый DbContext (тенанта + Identity + OpenIddict EF store). Query-filter применяется через reflection ко всемITenantEntity(см. MULTI-TENANCY.md).Persistence/Configurations/*.cs— EF Core fluent configs по поддоменам.Persistence/Migrations/— миграции пишутся вручную (см. CLAUDE.md / memoryfeedback_ef_migrations), снапшот не синхронизируется с моделью (используется толькоdotnet ef migrations add, который не вызывается в этом проекте).Persistence/OrgAuditInterceptor.cs— EFISaveChangesInterceptor, пишет каждую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.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+PermissionAuthorizationPolicyProviderPermissionAuthorizationHandler— permission-based авторизация.[RequiresPermission("ProductsEdit")]→ policyperm: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в SerilogLogContext, каждая запись в журнале получает эти лейблы.Observability/DbMetricsInterceptor.cs— EF интерсептор, Prometheusfood_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).
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— регистрирует clientfood-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)
Логические блоки в порядке регистрации:
- Serilog bootstrap (до builder).
- CORS (
Cors:AllowedOriginsиз конфига). - HttpContextAccessor +
ITenantContext+IClaimsTransformation(SuperAdmin override роли). - EF Core:
AppDbContext(Npgsql, OpenIddict, два interceptor'а). - Identity + OpenIddict server (password + refresh, rolling refresh, leeway = 0).
- Authentication/Authorization policies (
AdminAccess,perm:*). - Rate-limiter (
/connect/token,/api/auth/signup). - HealthChecks (
databaseтегready). - IEmailSender (Singleton + scope для DbContext).
- IFiscalProvider + 3 HttpClient +
IFiscalProviderFactory. - MediatR (assembly scan), FluentValidation.
- MoySklad HttpClient + import service.
- Hangfire server + storage (PG),
HangfireJobsConfiguratorхостед. - SignalR +
INotificationsPublisher. - Telegram-бот HttpClient (если token задан).
- Сидеры:
OpenIddictClientSeeder,SystemReferenceSeeder,DevDataSeederхостед;DemoTenantSeeder/YearDemoSeederscoped. Build()→ middleware pipeline (Serilog→CORS→HttpMetrics→ RateLimiter→hubs-token-fix→AuthN→AuthZ→LogEnrichment→ ReadonlyOverride→StaticFiles[/uploads]→Swagger→MapControllers→ MapHub→MapMetrics→HangfireDashboard→HealthChecks).- На старте:
db.Database.Migrate()(идемпотентно). app.Run().
Поток: signup → bootstrap → первая продажа
1. POST /api/auth/signup { email, password, organizationName, phone }
─→ создание Organization (Entity, не tenant-scoped)
─→ создание AppUser + добавление в роль "Admin"
─→ создание Employee с AdminRole и всеми permission'ами
─→ создание главного Store (isMain=true) + RetailPoint
─→ создание PriceType "Розничная" (isRetail=true, isSystem=true)
─→ копирование системных UnitOfMeasure (OrganizationId=null) на org
2. POST /connect/token { grant_type=password, username, password }
─→ OpenIddict проверяет, выдаёт access_token + refresh_token
─→ access_token содержит claim org_id и role
3. GET /api/me (web bootstrap)
─→ возвращает { sub, email, roles, orgId, hasLiveOrg, hasActiveEmployee }
─→ фронт роутит на /dashboard или /no-organization (orphan-fallback)
4. POST /api/catalog/products { name, prices, barcodes, ... }
─→ ValidationFilter (FluentValidation)
─→ controller → _db.Products.Add(...) (OrganizationId stamped в SaveChanges)
─→ возвращает Product DTO
5. POST /api/purchases/supplies + POST /{id}/post
─→ post идёт под Serializable tx
─→ для каждой строки StockService.ApplyMovementAsync(+qty, MovementType.Supply)
─→ Stock row для (productId, storeId) либо создаётся, либо обновляется
─→ commit, AppMetrics.IncrementPosted("supply")
─→ SignalR: NotificationsHub → группа org → событие SupplyPosted
6. POST /api/sales/retail + POST /{id}/post
─→ Serializable tx, проверка остатка ≥ 0 для каждой строки
─→ StockService.ApplyMovementAsync(-qty, MovementType.RetailSale)
─→ commit, AppMetrics.IncrementPosted("retail-sale")
─→ best-effort TryFiscalizeAsync (Sprint 11) — отдельно, после commit
─→ SignalR: SalePosted (dashboard виджеты инвалидируют queries)
База данных
Postgres 14+ для dev (brew systemwide), Postgres 16 в Docker для
stage/prod. Названия таблиц snake_case через явный ToTable("…").
Ключевые таблицы
| Таблица | Назначение |
|---|---|
organizations |
Корневой tenant. Не tenant-scoped. |
users, roles, user_roles |
ASP.NET Identity. |
employees, employee_roles, role_permissions |
Сотрудники tenant'а + кастомные роли с булевыми флагами прав. |
products, product_prices, product_barcodes, product_images, product_groups |
Каталог товаров. |
counterparties |
Поставщики + покупатели (тип=Supplier/Individual/Legal). |
stores, retail_points, units_of_measure, currencies, price_types, countries |
Справочники. |
stocks, stock_movements |
Остатки + история движений. stocks — кеш SUM(stock_movements). |
supplies, supply_lines, enters, enter_lines, supplier_returns, supplier_return_lines |
Приходные документы. |
losses, loss_lines, transfers, transfer_lines, inventory_docs, inventory_lines |
Внутренний учёт. |
retail_sales, retail_sale_lines |
Чеки розницы + строки чека. Sprint 11: ОФД-снапшоты на retail_sales (FiscalNumber, FiscalQrCode, …). |
demands, demand_lines |
Опт-отгрузки. |
loyalty_programs, loyalty_cards, promotions |
Sprint 9. |
pos_batch_acks |
Идемпотентность POS-синка (UNIQUE OrganizationId, IdempotencyKey). |
org_audit_log |
JSONB-diff каждой mutate-операции tenant'а. |
super_admin_audit_log |
Действия SuperAdmin'а (особенно в режиме «открыто как…»). |
platform_settings |
Singleton: SMTP-конфиг платформы. |
system_settings |
Singleton: per-tenant фичи (не путать с platform). |
import_jobs |
История импортов MoySklad. |
OpenIddict хранит openiddict_applications/authorizations/tokens/scopes.
Hangfire — hangfire.* (в своей схеме).
Concurrency
IVersionedEntity сущности (Supply, RetailSale, Demand, Enter, Loss,
Transfer, InventoryDoc, SupplierReturn) включают PG xmin через
UseXminAsConcurrencyToken(). Параллельные апдейты одного документа
получают DbUpdateConcurrencyException, контроллер возвращает 409.
Post-операции, изменяющие остаток, идут под IsolationLevel.Serializable
(см. RetailSalesController.Post, SuppliesController.Post, …) —
это защищает от race в SUM(stock_movements)-инварианте.
Внешние интеграции
| Сервис | Где | Состояние |
|---|---|---|
| MoySklad | Infrastructure/Integrations/MoySklad/ |
Импорт товаров, контрагентов, остатков. Per-org token в Organization.MoySkladToken. |
| SMTP | Infrastructure/Email/MailKitEmailSender.cs |
Платформенный SMTP в PlatformSettings (SuperAdmin настраивает). Используется для invite, forgot-password, weekly-summary. |
| Telegram Bot | Api/Integrations/Telegram/ |
Owner-сводка. Per-org OwnerTelegramChatId. Bot token в env. |
| ОФД (Webkassa / Kassa24 / ОФД-Соло) | Infrastructure/Fiscal/ |
Sprint 11 scaffolding. Per-org провайдер + креды. |
| MinIO (S3) | Api/Storage/StorageBootstrap.cs |
Опциональный сторадж изображений. Если не настроен — /uploads volume на FS. |
Тесты
- Unit (
tests/food-market.UnitTests/) — xUnit + InMemory EF + чистые юниты на валидаторы, payload-builder'ы, MediatR-handler'ы. - Integration (
tests/food-market.IntegrationTests/) — xUnit + Testcontainers Postgres (postgres:16-alpine). Полный API черезWebApplicationFactory<Program>. SharedApiFactoryчерезApiCollection(один контейнер на сессию xunit). Memory note:test_suites_setup— Ryuk выключен (TCN не тянет с docker-hub), rate-limiter eager-config через env-переменную. - E2E (
tests/e2e/) — Playwright (TS) против stagehttps://test.admin.food-market.kz. Используется в verify-suite'ах по спринтам. - Load (
tests/load/) — k6 (Sprint 12). См.docs/performance-baseline.md.
Релиз-цикл
- Локально:
dotnet build+dotnet test+pnpm build. git push origin main(Forgejo на 127.0.0.1:3000 — primary remote, GitHub — mirror, memoryfeedback_forgejo_primary).~/deploy-stage.sh— docker build api+web → push в локальный registry192.168.1.193:5001→ ssh на prod-vm →docker compose pull && up -d.- Health check на
https://test.admin.food-market.kz/health/ready. - Verify на stage (Playwright или ручной чек).
- Prod-деплой — пока ручной (TBD, нужен план от user'а).
Что ещё прочитать
- MULTI-TENANCY.md — query filter, SuperAdmin override, подводные камни.
- RUNBOOK.md — операционные процедуры.
- DEVELOPER-GUIDE.md — как начать вкладываться в код.
- ofd-integration.md — ОФД-провайдеры.
- openapi.md — генерация TS-клиента из Swagger.
- observability.md — Serilog + Prometheus.
- secrets.md — управление секретами в stage/prod.
- stage-access.md — как попасть на stage-сервер.
- backup-restore.md — бэкапы.