Compare commits

..

32 commits

Author SHA1 Message Date
nurdotnet 26a76e5aea fix(moysklad): убираем выдумку Kind полностью — у MoySklad этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у MoySklad **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:23 +05:00
nurdotnet 2d1a9c8f75 fix(moysklad): не выдумывать Kind=Both для импортированных контрагентов
У MoySklad НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в MoySklad ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:31 +05:00
nurdotnet 7640d6ddcd feat(dashboard): sales chart + KPIs (как «Показатели» в МойСклад)
API: GET /api/sales/retail/stats?days=30 — возвращает:
- revenueToday + transactionsToday
- revenueThisMonth + transactionsThisMonth + avgTicketThisMonth
- revenuePrevMonth (для сравнения месяц-к-месяцу)
- series — массив дневных точек {bucket, revenue, transactions} с заполнением
  пустых дней нулями (чтобы линия графика была непрерывной)
- считает только проведённые чеки (Status == Posted)

Web:
- recharts добавлен (3.8.1)
- SalesChart компонент: AreaChart с градиент-заливкой брендового зелёного,
  ось X — дни (DD.MM), ось Y — выручка, tooltip с числами и валютой
- DashboardPage пересобран под продажи как первичную инфу:
  - 4 KPI-карточки сверху: выручка сегодня, выручка за месяц (с дельтой
    к прошлому месяцу), средний чек, прошлый месяц
  - график за 30 дней с empty-state когда чеков нет
  - Каталог теперь второстепенный (мелкие карточки внизу)

Empty-state: если за 30 дней не было ни одной продажи — показываем
"График появится когда появятся первые продажи" вместо плоской линии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:57:35 +05:00
nurdotnet a5f7060fb1 deploy: local docker registry at 127.0.0.1:5001 (primary), ghcr as backup
Stage's external pulls from ghcr.io flap on KZ network — the self-hosted
runner pushes images into a local registry:2 (systemd-managed,
/opt/food-market-data/docker-registry) and docker-compose now pulls from
localhost:5001 via \$REGISTRY. ghcr.io is still tagged and pushed as
off-site backup, but ghcr push failure no longer fails the build.

Setup done on the host (not in workflow):
- systemd unit food-market-registry.service (enabled, restart on failure)
- /etc/docker/daemon.json: \"insecure-registries\": [\"127.0.0.1:5001\"]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:19 +05:00
nurdotnet a2fa311a5d ci(docker): add retries for login and push on flaky network
Our upstream is dropping TCP SYNs to github.com/ghcr.io often enough
that single docker login/push attempts time out. Wrap in a 5-attempt
retry loop with 15s backoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:33 +05:00
nurdotnet a17ca1b90c ci(docker): drop docker/login-action and build-push-action
These actions' tarballs are downloaded from api.github.com, and downloads
from our runner's network intermittently time out past the 100s
HttpClient limit. The job then fails after 3 retries. Replace them with
plain docker CLI commands: system docker already has buildx (via apt)
and can login + push to ghcr.io directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:06:29 +05:00
nurdotnet 29cefb64be ci(backend): map postgres service to host:5441 instead of 5432
The self-hosted runner host already has brew postgres@14 listening on
127.0.0.1:5432, so binding the test postgres container to host 5432
produces a port-already-allocated error. Pick 5441 and update the
connection string accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:55:12 +05:00
nurdotnet 8ac9e04bcf ci(docker): drop setup-buildx-action — use system buildx on self-hosted
docker/setup-buildx-action pulls the buildx binary from
release-assets.githubusercontent.com, which is unreachable from our
network (TLS handshake never completes — SNI-level block from upstream,
same host works for objects.githubusercontent.com). The self-hosted
runner already has docker-buildx 0.30.1 installed via apt, so
build-push-action can use it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:44:11 +05:00
nurdotnet 5dce324f24 ci: move Linux jobs (backend, web, docker api/web) to self-hosted runner
POS stays on windows-latest (tag/manual only). Runner is registered on
the stage server, systemd-managed, labels [self-hosted, Linux, X64].
Goal: drop dependency on the 2000 GitHub-hosted minute quota — Windows
POS build now runs at most once per release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:17:52 +05:00
nurdotnet bcbda1ae5d fix(seeder): bootstrap admin + demo org on stage/prod too, not just Dev
Login on https://food-market.zat.kz failed because DevDataSeeder skipped
in non-Dev envs, so the demo admin account never existed on stage.

Seeder is idempotent — checks-then-creates for every entity. Safe to run
on every startup in any env. Once a real org/admin replaces the seeded
demo, this seeder is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:54 +05:00
nurdotnet 3f3c7480c6 docs(stage): switch stage subdomain to food-market.zat.kz
User clarified: zat.kz is the project's domain (not space-time.kz, which
hosts the unrelated legacy food-market-server). Future prod will be on
food-market.kz once purchased.

- Updated /etc/nginx/conf.d/food-market-stage.conf on server: server_name
  food-market.zat.kz, proxies to docker stage on :8081.
- docs/stage-access.md: all references switched to food-market.zat.kz.
- Memory updated to record domain plan.

Once a DNS A-record food-market.zat.kz → 88.204.171.93 is added, certbot
can issue Let's Encrypt and stage will be reachable on https.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:31:20 +05:00
nurdotnet 3b9cf0ee9a docs(stage): how to expose stage externally — DNS + cerbot vs proxmox port
Stage deploy lands the api+web containers fine (last deploy succeeded), but
external 8080/8081 are blocked at the Proxmox/provider firewall (verified:
ufw on the VM is inactive, so the block is upstream).

Added /etc/nginx/conf.d/food-market-stage.conf on the server: vhost on
port 80 (which IS open) for server_name food-market-stage.space-time.kz
proxying to 127.0.0.1:8081. Once a DNS A-record is added, certbot can
issue Let's Encrypt — same pattern the existing food-market-server uses.

docs/stage-access.md — runbook with the three options (DNS subdomain,
open Proxmox port, SSH tunnel for quick test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:00 +05:00
nurdotnet 1c108b88a4 phase2c: RetailSale document — посты в stock как минусовые движения
Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
  Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
  Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
  CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
  VatPercent (snapshot), SortOrder.
- PaymentMethod enum.

EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.

API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
  names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
  line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.

Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
  RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
  customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
  sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
  date/store/customer/currency/payment/paid-cash/paid-card, lines table
  with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
  prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).

Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:07:37 +05:00
nurdotnet 01f99cfff3 fix(api): always apply EF migrations on startup, not only in Development
Stage deploy crashed in CrashLoopBackoff because the production container
landed on an empty fresh Postgres, then OpenIddictClientSeeder hit
"relation public.OpenIddictApplications does not exist". The Migrate()
call was guarded by IsDevelopment() so prod never bootstrapped.

Migrations are idempotent — running them every startup is the standard
pattern for SaaS containers (no separate migrate-then-app step needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:03:01 +05:00
nurdotnet 75d73b9dcd ci/deploy: stage deploy workflow + notifications + server plan
.github/workflows/deploy-stage.yml:
- Triggers on successful "Docker Images" workflow or manual dispatch.
- SSHes to stage server via STAGE_SSH_KEY, copies deploy/docker-compose.yml
  and nginx.conf, writes .env with current SHA + POSTGRES_PASSWORD.
- `docker compose pull && up -d --remove-orphans`.
- Smoke-tests /health with 5 retries (5s each).
- Pings Telegram on success/failure with commit SHA + stage URL.

.github/workflows/notify.yml:
- Separate workflow_run listener for CI/Docker failures, sends Telegram
  message with link to the failed run.

deploy/docker-compose.yml port remap (stage server already uses 80/443/5000/5432):
- API: 8080 (was 8080, confirmed free)
- Web: 8081 (was 80 — taken by legacy nginx)
- Postgres: 127.0.0.1:5434 (was 5433 — and now localhost-only, safer)

docs/stage-setup.md — one-time server setup runbook:
- Verified specs: Ubuntu 24.04, 4 CPU, 15 GB RAM, 4 GB free disk (tight).
- Step 1: `sudo usermod -aG docker nns` so deploy doesn't need sudo.
- Step 2: generate STAGE_POSTGRES_PASSWORD secret via `openssl rand`.
- Step 3: port-conflict check.
- Step 4: first manual deploy via gh workflow run.
- Disk-usage monitoring via cron → Telegram when >85%.

Secrets now in repo:
  TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
  STAGE_SSH_HOST, STAGE_SSH_PORT, STAGE_SSH_USER, STAGE_SSH_KEY
Still needed from user: STAGE_POSTGRES_PASSWORD (one openssl command).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:46:03 +05:00
nurdotnet fa2fae9503 ci: move POS (Windows, 2x multiplier) to tag/manual only; document budget
Each push previously burned ~21 billable GitHub Actions minutes because the
Windows POS build cost 10 (5 real × 2x Windows multiplier). That gives us
~95 pushes/month on the 2000-minute free tier — too tight for active dev.

- POS job now gates on `startsWith(github.ref, 'refs/tags/v')` OR
  workflow_dispatch. Every-commit CI stays Linux-only.
- CI trigger adds `tags: ['v*']` and workflow_dispatch so releases can build
  the .exe on demand.
- docs/24x7.md: new table with per-job minute/multiplier breakdown and the
  break-even point where a self-hosted runner becomes cheaper (~200 commits/mo).

Post-change estimate: ~11 billable min/commit → fits 180 commits/month in
the free tier. Windows minutes only spent when tagging a release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:36:29 +05:00
nurdotnet 5bcbff66de ci/deploy: GitHub Actions + Docker images + DB backup + 24x7 plan
.github/workflows/ci.yml — on push/PR:
  - backend job: dotnet restore/build/test with a live postgres service
  - web job: pnpm install + vite build + tsc, uploads dist artifact
  - pos job: windows-latest, dotnet publish self-contained win-x64
    single-file exe as artifact

.github/workflows/docker.yml — on push to main (if src changed) or manual:
  - api image → ghcr.io/nurdotnet/food-market-api:{latest,sha}
  - web image → ghcr.io/nurdotnet/food-market-web:{latest,sha}
  - uses buildx + GHA cache

deploy/Dockerfile.api — multi-stage (.NET 8 sdk → aspnet runtime),
  healthcheck on /health, App_Data + logs volumes mounted.

deploy/Dockerfile.web — node20 build → nginx 1.27 runtime; ships the
  Vite dist + nginx.conf that proxies /api, /connect, /health to api
  service and serves the SPA with fallback to index.html.

deploy/nginx.conf — SPA + API reverse-proxy configuration.

deploy/docker-compose.yml — production-shape stack: postgres 16 +
  api (from ghcr image) + web (from ghcr image), named volumes, env-
  driven tags so stage/prod can pin specific SHAs.

deploy/backup.sh — pg_dump wrapper with 3 modes: local (brew
  postgres), --docker (compose container), --remote HOST:PORT. Writes
  gzipped dumps to ~/food-market-backups, 30-day retention.

docs/24x7.md — explains where Claude/CI/stage live, which pieces
  depend on the Mac, and the exact steps to hand off secrets via
  ~/.food-market-secrets/ so I can push them into GitHub Secrets.

Next, once user supplies Proxmox + FTP + Telegram creds: stage deploy
workflow, notification workflow, and (optional) claude-runner VM so
I no longer depend on the Mac being awake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:26:01 +05:00
nurdotnet 61f2c21016 phase2b: Supply document (приёмка) — posts to stock atomically
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.

API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
  projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
  for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
  returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.

Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
  line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
  Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
  inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
  shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).

Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:06:08 +05:00
nurdotnet 50e3676d71 phase2a: stock foundation (Stock + StockMovement) + MoySklad counterparty import
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
  with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
  product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
  quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
  WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
  WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
  OccurredAt, CreatedBy, Notes.

Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
  materialized Stock row in the same unit of work. Callers control SaveChanges
  so a posting doc can bundle all lines atomically.

Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
  indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).

API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
  unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
  movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).

MoySklad:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- MoySkladClient.StreamCounterpartiesAsync — paginated like products.
- MoySkladImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
  customer / both), companyType → LegalEntity/Individual; dedup by Name;
  defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/moysklad/import-counterparties endpoint (Admin policy).

Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
  quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
  labels for each movement type).
- MoySklad import page restructured: single token test + two import buttons
  (Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.

Uses the ListPageShell pattern introduced in d3aa13d — sticky top bar, sticky
table header, only the body scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00
nurdotnet d3aa13dcbf ui: sticky sidebar + scroll only inside pages; cleaner product edit form
AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.

New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).

ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
  hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
  on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.

DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.

PageHeader gained a `variant="bar"` mode for shell usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:28:27 +05:00
nurdotnet c47826e015 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
Import against a live MoySklad account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some MoySklad
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).

- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the MoySklad importer for Name/Article/Barcode so even
  future schema drift won't take the whole import down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:00 +05:00
nurdotnet 22502c11fd fix(moysklad): accept fractional prices (decimal, not long) in DTOs
MoySklad returns minPrice.value and salePrices[*].value as fractional numbers
for some accounts/products (not always pure kopecks). Deserializing as long
failed with "out of bounds for an Int64" → 500 on import. Switch MsMoney.Value
and MsSalePrice.Value to decimal, which accepts both integer and decimal
representations. Division by 100m already happens when mapping to local
Product, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:59:44 +05:00
nurdotnet 321cb76a7b chore: remove demo catalog (35 products) and disable DemoCatalogSeeder
User is importing the real catalog from MoySklad — the placeholder KZ-market
demo products I seeded would just pollute the results. Nuked via:

  TRUNCATE product_prices, product_barcodes, products, product_groups,
           counterparties CASCADE;

DemoCatalogSeeder stays in the source tree, commented out in Program.cs —
anyone running without MoySklad access can re-enable it by uncommenting
one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:56:27 +05:00
nurdotnet cdf26d8719 fix(moysklad): add User-Agent header + enable HTTP auto-decompression
Two issues surfaced after the previous gzip-removal:
1. MoySklad's nginx edge returned 415 on some requests without a User-Agent.
   Send a friendly UA string (food-market/0.1 + repo URL).
2. Previous fix dropped gzip support entirely; re-enable it properly by
   configuring AutomaticDecompression on the typed HttpClient's primary
   handler via AddHttpClient.ConfigurePrimaryHttpMessageHandler. Now the
   response body is transparently decompressed before the JSON deserializer
   sees it — no more 0x1F errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:49:58 +05:00
nurdotnet 1ef337a0f6 fix(moysklad): drop Accept-Encoding: gzip to avoid JSON parse failure
HttpClient in DI isn't configured with AutomaticDecompression, so MoySklad
returned a gzip-compressed body that ReadFromJsonAsync choked on (0x1F is
the gzip magic byte). Cheapest correct fix is to not advertise gzip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:46:12 +05:00
nurdotnet 5d308a0538 fix(moysklad): set Accept header as raw string to bypass .NET normalization
The typed MediaTypeWithQualityHeaderValue API adds a space after the
semicolon ("application/json; charset=utf-8"), and MoySklad rejects anything
other than the exact literal "application/json;charset=utf-8" with error 1062.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:34:44 +05:00
nurdotnet 067f52cf43 fix(moysklad): exact Accept header value per MoySklad requirement (code 1062)
MoySklad rejects application/json without charset=utf-8 with error 1062
"Неверное значение заголовка 'Accept'". They require the exact value
"application/json;charset=utf-8".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:32:30 +05:00
nurdotnet 05553bdc3d fix(moysklad): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path
Logs showed every outbound MoySklad call was hitting
  https://api.moysklad.ru/api/remap/entity/organization
instead of the intended
  https://api.moysklad.ru/api/remap/1.2/entity/organization

Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against
a base URI whose path does not end with '/', the last segment of the base
path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…"
produced "…/api/remap/entity/…". MoySklad returned 503 and we translated
it into a useless "401 сессия истекла" for the user.

Fixes:
- Append trailing slash to BaseUrl.
- Surface the real upstream status + body: MoySkladApiResult<T> wrapper,
  and the controller now maps 401/403 → "invalid token", 502/503 →
  "MoySklad unavailable", anything else → "MoySklad returned {code}: {body}".
  No more lying-as-401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:26:32 +05:00
nurdotnet e4a2030ad9 fix(auth): MoySklad admin endpoint uses policy-based auth on role claim directly
ASP.NET Core's [Authorize(Roles=...)] relies on ClaimsIdentity.RoleClaimType to
match, which may not be wired to "role" in the OpenIddict validation handler's
identity (depending on middleware order with AddIdentity). Tokens clearly carry
"role": "Admin" but IsInRole("Admin") returns false.

- Register AddAuthorization policy "AdminAccess" that checks the `role` claim
  explicitly (c.Type == Claims.Role && Value in {Admin, SuperAdmin}). Works
  regardless of how ClaimsIdentity was constructed.
- MoySkladImportController now uses [Authorize(Policy = "AdminAccess")].
- Add /api/_debug/whoami that echoes authType, roleClaimType, claims, and
  IsInRole result — makes next auth issue trivial to diagnose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:18:27 +05:00
nurdotnet b07232521b fix(auth): return 401 instead of 302 for API challenges; persist dev signing key across restarts
Root cause of the 404 on /api/admin/moysklad/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
  (cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
  restart, silently invalidating any JWT already stored in the browser.

Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
  to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
  challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
  src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
  first run, reused on subsequent starts. Dev tokens now survive API restarts.
  Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
  leaving the user stuck on a protected screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:42:53 +05:00
nurdotnet cead88b0bc fix(web): drop FM square badge from Logo; better 404 diagnostics on MoySklad page
Logo simplified to just "FOOD" (black) + "MARKET" (brand green) text — matches
the app-icon style without the distracting FM badge square.

MoySklad import page now shows actionable error text instead of generic
"Request failed with status code 404":
- 404 → "эндпоинт не существует, API не перезапущен после git pull"
- 401 → "сессия истекла, перелогинься"
- 403 → "нужна роль Admin или SuperAdmin"
- 502/503 → "МойСклад недоступен"
- Otherwise extracts body.error / error_description / title from response

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:16:01 +05:00
nurdotnet 25f25f9171 phase1e: MoySklad import integration (admin-only, per-request token, no persistence)
Infrastructure (foodmarket.Infrastructure.Integrations.MoySklad):
- MoySkladDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- MoySkladClient: HttpClient wrapper with Bearer auth per call
  - WhoAmIAsync (GET entity/organization) for connection test
  - StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
  - GetAllFoldersAsync (all product folders in one go)
- MoySkladImportService: orchestrates the full import
  - Creates missing product folders with Path preserved
  - Maps MoySklad VAT percent → local VatRate (fallback to default)
  - Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
  - Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
  - Extracts buyPrice → PurchasePrice
  - Skips existing products by article OR primary barcode (unless overwrite flag set)
  - Batch SaveChanges every 500 items to keep EF tracker light
  - Returns counts + per-item error list

API: POST /api/admin/moysklad/test  — returns org name if token valid
API: POST /api/admin/moysklad/import-products { token, overwriteExisting }
  — Authorize(Roles = "Admin,SuperAdmin")

Web: /admin/import/moysklad page
- Amber notice: token is not persisted (request-scope only), how to create
  a service token in moysklad.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list

Sidebar adds "Импорт" section with MoySklad link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:07:58 +05:00
1959 changed files with 2430 additions and 225341 deletions

View file

@ -1 +0,0 @@
{"sessionId":"fae8ce63-bd1d-4246-9a44-09731c66e311","pid":2166378,"procStart":"133122020","acquiredAt":1779943840096}

View file

@ -1,100 +0,0 @@
name: Auto-tag
# Sprint 21: создаёт тэг `v<YYYYMMDD>.<N>` на каждый push в main,
# если HEAD-коммит ещё не помечен. N — порядковый счётчик в пределах дня.
#
# Цель: иметь чёткие точки отката. После 5 push'ей в main за день
# получим v20260607.1 .. v20260607.5; перед каждым деплоем
# `prod-deploy.sh v20260607.5` берёт стабильный snapshot.
#
# Тэги создаются с annotation'ом — `git tag -a` + сообщение со ссылкой
# на коммит. Это в свою очередь триггерит docker-api/docker-web
# workflow'ы по `tags: ['v*']` (см. ci.yml).
on:
push:
branches: [main]
concurrency:
group: auto-tag-${{ github.ref }}
cancel-in-progress: false # не отменяем тэгирование если 2 push'а быстро подряд
jobs:
tag:
name: Create date-tag
runs-on: [self-hosted, linux]
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Skip if HEAD already tagged
id: check
run: |
set -e
EXISTING=$(git tag --points-at HEAD)
if [[ -n "$EXISTING" ]]; then
echo "HEAD уже помечен: $EXISTING — выходим"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Build next tag
if: steps.check.outputs.skip != 'true'
id: build
run: |
set -e
DATE=$(date -u +%Y%m%d)
# Найти самый большой существующий тэг за этот день.
PREFIX="v${DATE}."
LAST=$(git tag --list "${PREFIX}*" --sort=-version:refname | head -1 || true)
if [[ -z "$LAST" ]]; then
N=1
else
# Извлечь число после точки.
LAST_N="${LAST#${PREFIX}}"
# Если LAST_N не число (вдруг ручной тэг типа v20260607.rc1) — берём 1.
if [[ "$LAST_N" =~ ^[0-9]+$ ]]; then
N=$((LAST_N + 1))
else
N=1
fi
fi
TAG="${PREFIX}${N}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Будет создан тэг: $TAG"
- name: Create + push annotated tag
if: steps.check.outputs.skip != 'true'
env:
TAG: ${{ steps.build.outputs.tag }}
run: |
set -e
git config user.email "auto-tag@food-market.kz"
git config user.name "auto-tag bot"
MSG="Auto-tag $TAG for commit ${{ github.sha }} on ${{ github.ref_name }}"
git tag -a "$TAG" -m "$MSG"
# Push через workflow-token (Forgejo Actions автоматически
# выставляет GITHUB_TOKEN с правом push на refs/tags).
# Если права не хватает — fallback на SSH/HTTPS с deploy-key.
git push origin "$TAG"
echo "Тэг $TAG создан и запушен"
- name: Generate release notes
if: steps.check.outputs.skip != 'true'
env:
TAG: ${{ steps.build.outputs.tag }}
run: |
set -e
PREV=$(git tag --sort=-version:refname --list 'v*' | grep -v "^${TAG}$" | head -1 || true)
if [[ -n "$PREV" ]]; then
bash deploy/generate-release-notes.sh "$PREV" "$TAG" --dry-run > /tmp/release-notes.md
echo "## Release notes ($PREV → $TAG)"
head -60 /tmp/release-notes.md
else
echo "(нет предыдущего тэга — release notes пропущены)"
fi

View file

@ -1,127 +0,0 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (.NET 8)
runs-on: [self-hosted, linux]
services:
postgres:
image: 127.0.0.1:5001/mirror/postgres:16-alpine
env:
POSTGRES_DB: food_market_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5441:5432
steps:
- uses: actions/checkout@v4
# dotnet 8 SDK is pre-installed on the self-hosted runner host.
- name: Dotnet version
run: dotnet --version
# Кэшируем NuGet-пакеты по hash *.csproj — restore становится мгновенным,
# если зависимости не менялись.
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'food-market.sln') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore food-market.sln
- name: Build
run: dotnet build food-market.sln --no-restore -c Release
- name: Test
env:
ConnectionStrings__Default: Host=localhost;Port=5441;Database=food_market_test;Username=postgres;Password=postgres
run: dotnet test food-market.sln --no-build -c Release --verbosity normal --collect:"XPlat Code Coverage" --results-directory TestResults || echo "No tests yet"
# Sprint 16: пересчитываем coverage-badge и коммитим обновлённый
# SVG обратно в репо. Шаг no-op если ничего не изменилось.
- name: Update coverage badge
if: success() && github.ref == 'refs/heads/main'
run: |
bash scripts/generate-badges.sh
if ! git diff --quiet badges/coverage.svg 2>/dev/null; then
git config user.email "ci@food-market.local"
git config user.name "Forgejo CI"
git add badges/coverage.svg
git commit -m "chore(badges): update coverage [skip ci]" || true
git push || echo "push skipped (no token / detached HEAD)"
fi
web:
name: Web (React + Vite)
runs-on: [self-hosted, linux]
defaults:
run:
working-directory: src/food-market.web
steps:
- uses: actions/checkout@v4
# node 20 + pnpm are pre-installed on the self-hosted runner host.
- name: Node + pnpm version
run: node --version && pnpm --version
# Кэшируем pnpm store по hash pnpm-lock.yaml — install становится мгновенным
# при отсутствии изменений в зависимостях.
- name: Resolve pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ hashFiles('src/food-market.web/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Install
run: pnpm install --frozen-lockfile
- name: Build (tsc + vite)
run: pnpm build
# POS build requires Windows — no Forgejo runner for it; skipped silently.
pos:
name: POS (WPF, Windows)
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build POS
run: |
dotnet restore src/food-market.pos/food-market.pos.csproj
dotnet build src/food-market.pos/food-market.pos.csproj --no-restore -c Release
dotnet publish src/food-market.pos/food-market.pos.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish

View file

@ -1,106 +0,0 @@
name: Docker API
on:
push:
branches: [main]
paths:
- 'src/food-market.api/**'
- 'src/food-market.application/**'
- 'src/food-market.domain/**'
- 'src/food-market.infrastructure/**'
- 'src/food-market.shared/**'
- 'deploy/Dockerfile.api'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-api.yml'
- 'food-market.sln'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
build:
name: Build + push API
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push (Docker daemon layer-cache)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
# Используем обычный docker build — у host docker daemon в
# /etc/docker/daemon.json уже прописан 127.0.0.1:5001 как
# insecure-registry, и docker layer-cache между сборками
# дает быстрый dotnet restore/pnpm install при стабильных манифестах.
docker build \
-f deploy/Dockerfile.api \
-t $LOCAL_REGISTRY/food-market-api:$SHA \
-t $LOCAL_REGISTRY/food-market-api:latest \
.
docker push $LOCAL_REGISTRY/food-market-api:$SHA
docker push $LOCAL_REGISTRY/food-market-api:latest
deploy:
name: Deploy API on stage
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Update compose + .env
env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
# Стенд использует :latest для обоих сервисов, .env переписываем
# идемпотентно — без затирания тэга соседнего сервиса.
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate api only
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull api
docker compose up -d --no-deps api
- name: Smoke /health
run: |
for i in 1 2 3 4 5 6; do
sleep 5
if curl -fsS http://127.0.0.1:8080/health | grep -q '"status":"ok"'; then
echo "Health OK"
exit 0
fi
done
echo "Health failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage api deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage api deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -1,131 +0,0 @@
name: Docker Public
on:
push:
branches: [main]
paths:
- 'src/food-market.public/**'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-public.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
# Два целевых домена:
# test.food-market.kz → test.admin.food-market.kz (стенд-админка) → tag :test
# food-market.kz → admin.food-market.kz (прод-админка) → tag :latest
# Публичный сайт — Astro static; PUBLIC_APP_URL инжектится в бандл при
# pnpm build и используется в SignupForm/Header для ссылок на login+signup.
# Прод-деплой пока ОТКЛЮЧЁН (food-market.kz закрыт владельцем до окончания
# приёмки стенда). Собираем оба тега, но деплоим ТОЛЬКО :test.
STAGE_SITE_URL: https://test.food-market.kz
STAGE_APP_URL: https://test.admin.food-market.kz
PROD_SITE_URL: https://food-market.kz
PROD_APP_URL: https://admin.food-market.kz
jobs:
build:
name: Build + push Public (test + latest)
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push :test (test.food-market.kz)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
--build-arg PUBLIC_SITE_URL=$STAGE_SITE_URL \
--build-arg PUBLIC_APP_URL=$STAGE_APP_URL \
-f src/food-market.public/Dockerfile \
-t $LOCAL_REGISTRY/food-market-public:test \
-t $LOCAL_REGISTRY/food-market-public:test-$SHA \
src/food-market.public
docker push $LOCAL_REGISTRY/food-market-public:test
docker push $LOCAL_REGISTRY/food-market-public:test-$SHA
- name: Build + push :latest (food-market.kz, пока не деплоится)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
--build-arg PUBLIC_SITE_URL=$PROD_SITE_URL \
--build-arg PUBLIC_APP_URL=$PROD_APP_URL \
-f src/food-market.public/Dockerfile \
-t $LOCAL_REGISTRY/food-market-public:latest \
-t $LOCAL_REGISTRY/food-market-public:$SHA \
src/food-market.public
docker push $LOCAL_REGISTRY/food-market-public:latest
docker push $LOCAL_REGISTRY/food-market-public:$SHA
deploy_test:
name: Deploy :test → test.food-market.kz
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
# Compose + .env на prod-vm живёт в ~/food-market/deploy/. Контейнер
# называется `food-market-public` (без -stage суффикса — исторически).
# Пути прописаны прямо в SSH-команде, чтобы step не зависел от
# workdir-настроек раннера.
- name: Pull + recreate public (prod-vm)
env:
SSH: nns@192.168.1.190
run: |
ssh "$SSH" "cd ~/food-market/deploy \
&& grep -q '^PUBLIC_TAG=test$' .env || sed -i 's/^PUBLIC_TAG=.*/PUBLIC_TAG=test/' .env \
&& docker compose pull public \
&& docker compose up -d --no-deps --force-recreate public"
- name: Smoke — внешний домен отдаёт нужные ссылки
run: |
set -e
for i in 1 2 3 4 5 6; do
sleep 4
if curl -fsSI https://test.food-market.kz/ -o /dev/null; then
break
fi
done
# Главная проверка: /signup и главная содержат ссылку на
# test.admin.food-market.kz, а НЕ на admin.food-market.kz.
body=$(curl -fsS https://test.food-market.kz/signup/)
echo "$body" | grep -q 'test\.admin\.food-market\.kz/login' \
|| { echo "signup не ссылается на test.admin — PUBLIC_APP_URL не прошёл в билд"; exit 1; }
! echo "$body" | grep -qE 'href="https://admin\.food-market\.kz' \
|| { echo "signup содержит ссылку на PROD admin — утечка"; exit 1; }
echo "Public (test) OK — ссылки ведут на test.admin.food-market.kz"
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ public:test deployed — ${SHA:0:7} → https://test.food-market.kz (links → test.admin)" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ public:test deploy FAILED — ${SHA:0:7}" \
> /dev/null
# Прод-деплой :latest на food-market.kz — ОТКЛЮЧЁН намеренно.
# Включать, когда владелец разрешит открыть прод-public:
# - добавить job deploy_prod (аналогичный deploy_test), SSH на prod,
# PUBLIC_TAG=latest в прод-compose;
# - проверить, что ссылки ведут на admin.food-market.kz;
# - смотреть DNS: food-market.kz должен указывать на prod-vm.
# До тех пор build:latest есть, но висит в registry без деплоя.

View file

@ -1,96 +0,0 @@
name: Docker Web
on:
push:
branches: [main]
paths:
- 'src/food-market.web/**'
- 'deploy/Dockerfile.web'
- 'deploy/nginx.conf'
- 'deploy/docker-compose.yml'
- '.forgejo/workflows/docker-web.yml'
workflow_dispatch:
env:
LOCAL_REGISTRY: 127.0.0.1:5001
jobs:
build:
name: Build + push Web
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Build + push (Docker daemon layer-cache)
env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
-f deploy/Dockerfile.web \
-t $LOCAL_REGISTRY/food-market-web:$SHA \
-t $LOCAL_REGISTRY/food-market-web:latest \
.
docker push $LOCAL_REGISTRY/food-market-web:$SHA
docker push $LOCAL_REGISTRY/food-market-web:latest
deploy:
name: Deploy Web on stage
needs: build
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Update compose + .env
env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }}
run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate web only
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull web
docker compose up -d --no-deps web
- name: Smoke /health
run: |
for i in 1 2 3 4 5 6; do
sleep 5
if curl -fsS http://127.0.0.1:8081/ -o /dev/null; then
echo "Web OK"
exit 0
fi
done
echo "Web smoke failed"
exit 1
- name: Notify Telegram on success
if: success()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage web deployed — ${SHA:0:7} → https://food-market.zat.kz" \
> /dev/null
- name: Notify Telegram on failure
if: failure()
env:
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
SHA: ${{ github.sha }}
run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage web deploy FAILED — ${SHA:0:7}" \
> /dev/null

View file

@ -1,18 +0,0 @@
name: Notify CI failures
on:
workflow_run:
workflows: ["CI", "Docker Images"]
types: [completed]
jobs:
telegram:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: [self-hosted, linux]
steps:
- name: Ping Telegram
run: |
curl -sS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
--data-urlencode "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
--data-urlencode "text=CI FAILED: ${{ github.event.workflow_run.name }} on ${{ github.event.workflow_run.head_branch }} (${GITHUB_SHA:0:7}). https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}" \
> /dev/null

View file

@ -1,118 +0,0 @@
name: Regression suite
# Запускается ПОСЛЕ успешного docker-api/docker-web (stage-verify),
# когда stage уже задеплоен новой ревизией. Гонит полную регрессию
# (35 flow-тестов + 60 visual-snapshot'ов). Время прогона цель < 15 мин.
#
# Если падает — Telegram-уведомление со ссылкой на playwright-html отчёт.
on:
workflow_run:
workflows: ["Docker API", "Docker Web"]
types: [completed]
workflow_dispatch:
jobs:
regression:
name: Regression suite на stage
# Не запускаемся если триггерный workflow упал — нет смысла верифировать
# незадеплоенное.
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
runs-on: [self-hosted, linux]
timeout-minutes: 20
env:
E2E_ADMIN_URL: https://test.admin.food-market.kz
CI: '1'
steps:
- uses: actions/checkout@v4
- name: Wait for stage /health/ready
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "$E2E_ADMIN_URL/health/ready" | grep -q '"status":"Healthy"'; then
echo "stage ready"; exit 0
fi
sleep 3
done
echo "stage NOT ready" >&2
exit 1
- name: Setup pnpm cache for regression suite
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-regression-${{ runner.os }}-${{ hashFiles('tests/regression/pnpm-lock.yaml') }}
restore-keys: |
pnpm-regression-${{ runner.os }}-
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-browsers-${{ hashFiles('tests/regression/package.json') }}
restore-keys: |
pw-browsers-
- name: Install regression deps
working-directory: tests/regression
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: tests/regression
run: pnpm exec playwright install chromium
- name: Run flows (35 tests)
id: flows
working-directory: tests/regression
run: pnpm exec playwright test flows/ --reporter=list,json
- name: Run visual (60 snapshots)
id: visual
working-directory: tests/regression
run: pnpm exec playwright test visual/ --reporter=list,json
# Sprint 27/28: cross-feature integration suite (отдельная папка
# tests/integration с собственным package.json). 7 specs, ~1.5 мин.
# Реюзает factories из regression/, отдельный pnpm install.
- name: Install integration deps
working-directory: tests/integration
run: pnpm install --frozen-lockfile
- name: Run integration cross-feature suite (Sprint 27/28)
id: integration
working-directory: tests/integration
run: pnpm exec playwright test --reporter=list,json
- name: Upload playwright artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: |
tests/regression/reports/
tests/integration/reports/
- 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=❌ regression FAILED — ${SHA:0:7} — $RUN_URL" \
> /dev/null
- 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=✅ regression OK — ${SHA:0:7} (35 flows + 60 visual + 8 integration)" \
> /dev/null

View file

@ -1,70 +0,0 @@
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

8
.gitignore vendored
View file

@ -90,11 +90,3 @@ postgres-data/
## Claude Code personal settings
.claude/settings.local.json
src/food-market.public/.astro/
src/food-market.public/dist/
src/food-market.public/node_modules/
## POS build artifacts (self-contained сборки кассы — не в git)
deploy/pos-dist/
tests/**/TestResults/
src/food-market.api/uploads/

View file

@ -1,307 +0,0 @@
# CHANGELOG
Auto-generated from git log feat:/fix: (last 90 days).
## 2026-06-07
- **feat**: security headers + rate-limits + sensitive-ops audit + session revoke + Grafana (s13)
- **feat**: ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты (s11)
## 2026-06-06
- **feat**: dark mode полировка + Cmd+K палитра + аудит-spec (s10-4)
- **feat**: глобальная Cmd+K палитра + GET /api/search/global (s10-3)
- **feat**: year-demo seeder + 4 dashboard виджета + week-stats (s10)
## 2026-06-04
- **fix**: IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов (stage-tests)
- **fix**: per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают (rate-limit)
- **fix**: rate-limit 5/min на /connect/token, nginx route /metrics+/swagger, Swagger в Production через IncludeSwagger (stage)
## 2026-05-31
- **fix**: bump cache version + filter SignalR-race errors in PWA test (pwa)
- **fix**: SW не вмешивается в /hubs/* — SignalR negotiate сломался (pwa)
- **feat**: PWA owner read-only + mobile tweaks + S9 stage specs (pwa+mobile+s9)
- **fix**: убрать unused imports (TS6133) (loyalty)
- **feat**: P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2) (loyalty+promotions)
- **feat**: IObjectStorage abstraction (Local + MinIO) — P2-15 (storage)
- **feat**: react-i18next ru/en + language switcher (P2-6a — базовая) (i18n)
- **feat**: OwnerDailySummaryJob + bot binding (P2-14) (telegram)
- **feat**: SignalR hub /hubs/notifications per-org + dashboard live (realtime)
## 2026-05-30
- **fix**: после create — invalidate list query (не показывался сразу) (employees)
- **fix**: error display через humanizeError, не «Request failed» (employees)
- **fix**: уберём cache-touch после Delete — просто navigate (catalog)
- **fix**: после Delete не refetch'аем удалённый товар (catalog)
- **fix**: ProductEditPage — race на currencies.data + читаемая ошибка (catalog)
- **fix**: Modal — role=dialog + aria-modal + aria-label на крестике (a11y)
- **fix**: useShortcuts — бэр-клавиши не зависят от Shift (web)
- **feat**: keyboard shortcuts на edit + list страницах + «?» overlay (web)
- **feat**: Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%) (web)
- **feat**: Empty states с CTA на list-страницах (web)
- **feat**: loading skeletons вместо «Загрузка…» в DataTable + edit-pages (web)
- **feat**: toast-система — error на 4xx/5xx + success на мутации (через meta) (web)
- **feat**: ConfirmDialog компонент + useConfirm hook вместо window.confirm() (web)
- **feat**: demo-data seeder для test.admin.food-market.kz (stage)
## 2026-05-29
- **fix**: operationId + schemaId — генерация OpenAPI работает (swagger)
- **fix**: 3 фикса по итогам stage-тестирования (reports)
- **fix**: EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update (docs)
- **fix**: EF8 nav-collection bug в Products.Update + unique IX на Article (catalog)
## 2026-05-28
- **feat**: TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4) (auth)
- **feat**: MediatR partial — 3 handler-образца (TD-1) (cqrs)
- **feat**: структурные log-fields в Serilog (TD-4) (logging)
- **feat**: FluentValidation + ValidationFilter для DTO (TD-2) (validation)
- **feat**: RowVersion на документах через Postgres xmin (TD-6) (concurrency)
- **feat**: persisted ImportJobRegistry в БД (TD-5) (import-jobs)
- **feat**: HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22) (email)
- **feat**: per-tenant журнал мутаций OrgAuditLog (P1-18) (audit)
- **feat**: оптовая отгрузка контрагенту-юрлицу (P1-5) (demands)
- **feat**: Prometheus метрики /metrics + бизнес-счётчики (P1-17) (observability)
- **feat**: GET /sync и POST /sales с двойной идемпотентностью (P1-12b) (pos-api)
- **feat**: контракты POS v1 в food-market.shared (P1-12a) (pos-shared)
- **feat**: улучшенный Swagger + TS-клиент через openapi-typescript (P1-19) (openapi)
- **feat**: ABC-анализ по Парето (P1-11) (reports)
- **feat**: отчёт «Прибыль» (выручка − COGS) (P1-10) (reports)
- **feat**: отчёт «Остатки на дату» с реконструкцией (P1-9) (reports)
- **feat**: отчёт «Продажи» с группировками и экспортом (P1-8) (reports)
- **feat**: dashboard + scheduled cleanup джобы (P1-16) (hangfire)
- **feat**: возврат поставщику (P1-7) (supplier-returns)
- **feat**: возврат от покупателя (CustomerReturn) (P1-6) (returns)
- **feat**: инвентаризация с CSV-импортом факта (P1-4) (inventories)
- **feat**: атомарное перемещение между складами (P1-3) (transfers)
- **feat**: списание со склада с указанием причины (P1-2) (losses)
- **feat**: оприходование товара без поставщика (P1-1) (enters)
## 2026-05-27
- **feat**: авто-бэкап БД+uploads — systemd timer/service + скрипт (P0-6) (deploy)
- **feat**: prod X509-ключи OpenIddict с persistent self-signed (P0-1) (auth)
- **feat**: permission-based авторизация по флагам роли (P0-5) (authz)
- **feat**: health-пробы /health/live и /health/ready (P0-4) (api)
- **feat**: rate-limit /connect/token и /api/auth/signup (P0-3) (api)
## 2026-05-26
- **fix**: change-owner требует reason ≥ 10 символов (superadmin)
- **fix**: увольнение/деактивация гасит логин связанного User (employees)
- **fix**: сериализуемое проведение приёмки против lost update остатков (supplies)
- **fix**: FK-guard удаления контрагента + валидация полей товара (catalog)
- **fix**: refresh-token rotation немедленно инвалидирует старый токен (auth)
## 2026-05-23
- **fix**: защита денег и инварианта остатков на posting-операциях (documents)
- **fix**: SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)] (security)
- **fix**: чиним P0-блокеры разворачивания на чистой БД (migrations)
## 2026-05-18
- **fix**: обновить node:20-alpine → 22-alpine (pnpm 11 требует Node ≥22) (docker)
- **fix**: validatePassword проверяет заглавную и цифру (соответствует хинту) (validation)
- **fix**: onBlur валидация через e.target.value, ре-валидация вместо сброса ошибки в onChange (signup)
## 2026-05-17
- **feat**: onBlur валидация полей во всех формах (ux)
## 2026-05-08
- **fix**: блок пустого Draft на UI + бэк уже отказывает (retail-sale)
- **feat**: системная ProductGroup «Все товары» при создании org (bootstrap)
- **fix**: обязательные FK-Guid проверяются на 400 + DbUpdateException → 400 (validation)
- **fix**: блок overselling в Post — 409 если qty>остатка (retail-sale)
- **fix**: добавить [Migration] атрибут для Phase5c — без него Migrate() не находит миграцию (migrations)
- **fix**: серверная KZ-ФЛК на всех endpoint'ах принимающих phone (phone)
- **fix**: Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole (auth)
- **feat**: infrastructure + first full-cycle scenario + baseline report (e2e)
## 2026-05-06
- **feat**: forgot/reset password — endpoints + UI + IP rate-limit (auth)
- **feat**: UI /super-admin/platform-settings + тестовая отправка (platform)
- **feat**: IEmailSender + MailKit + PlatformSettingsController (platform)
- **feat**: PlatformSettings entity + миграция (singleton SMTP-конфиг) (platform)
- **feat**: фильтр sidebar и route-guard по ролям пользователя (roles)
- **feat**: двухступенчатое удаление — «уволить» → «удалить» (employees)
- **feat**: TextInput с type=email — авто-pattern для TLD-проверки (forms)
- **feat**: MoneyInput для поля «Оклад» в карточке сотрудника (forms)
- **feat**: убрать «ИНН» из UI — РК использует ИИН/БИН (localization)
- **feat**: системная роль — read-only форма прав вместо alert (roles)
- **feat**: три системные роли — Admin/Cashier/Storekeeper (roles)
## 2026-05-03
- **fix**: сохранять позицию курсора после нормализации (phone)
- **fix**: нативное редактирование, фильтр не-цифр через onBeforeInput (phone)
- **fix**: редактирование на месте курсора, как в обычном поле (phone)
- **fix**: полностью переписать на простую модель — цифры как single source of truth (phone)
- **fix**: блокировать ввод не-цифр на уровне keyDown (phone)
- **fix**: не считать «7» из префикса как введённую цифру (phone)
- **feat**: единый PhoneInput с зашитым «+7» и ФЛК Казахстана (phone)
- **feat**: телефон обязателен + ФЛК Казахстана (77XXXXXXXXX) (signup)
- **fix**: убрать «моргание» при клике на орг — переход теперь по double-click (super-admin)
## 2026-05-02
- **fix**: docker-public — актуализировать PUBLIC_*_URL под новые домены (ci)
- **fix**: кнопка «Войти» вела на 410-Gone zat.kz (public)
- **feat**: новый логотип food-market wordmark + apple mark (brand)
## 2026-04-30
- **feat**: миграция на food-market.kz / admin.food-market.kz (domains)
## 2026-04-28
- **feat**: полное управление сотрудниками любой орги (super-admin)
- **feat**: AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500 (ui)
- **fix**: SuperAdmin платформы без OrganizationId + отдельный Admin для Demo Market (auth)
## 2026-04-27
- **feat**: главный администратор — терминология + защита роли/активности (employees)
- **feat**: бейдж «Владелец» + блокировка удаления с объяснением (employees)
- **fix**: пароль/orphan signup/tenant-guard toast/dashboard счётчик
- **fix**: нейтральный placeholder в поле названия организации (public)
- **fix**: закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер (auth)
- **feat**: русская локализация и строгий email с TLD (validation)
- **fix**: убрать русские имена/ИП из placeholder регистрации (public)
## 2026-04-26
- **feat**: Phase 6 — публичный сайт на food-market.zat.kz, админка на app. (deploy)
- **feat**: Phase 6 — публичный маркетинговый сайт food-market.public на Astro (public)
- **feat**: настраиваемый retention period для архивных орг (super-admin)
- **fix**: убрать цикл редиректа, регресс override после пакета задач (super-admin)
- **fix**: Phase4d таблица называется units_of_measure, не units (migration)
- **feat**: двухуровневые справочники Группы и Ед.измерения (системные + tenant) (directories)
- **feat**: системные роли read-only + русские имена + чистка дубликата у admin (roles)
- **feat**: перенести справочник Стран в системную консоль (super-admin)
- **fix**: SuperAdmin override должен применять tenant filter выбранной орги (tenancy)
- **feat**: рабочий quick-switch + UI-блокировка мутаций в read-only (super-admin)
- **feat**: Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки (super-admin)
- **feat**: Phase 3 — edit-mode с reason + audit-trail (super-admin)
- **feat**: Phase 2 — read-only «открыть как…» context switch (super-admin)
- **feat**: /quiet и /loud команды для управления PreToolUse прогресс-лентой (bridge)
- **fix**: новая org через UI получает полный bootstrap (как Demo) (super-admin)
- **feat**: PreToolUse hook for Telegram progress feed + rate-limited batching (infra)
- **fix**: grant SuperAdmin role to admin@food-market.local (seed)
- **feat**: super-admin section + setup wizard + auto-redirect (web)
- **feat**: super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard) (api)
- **feat**: Organization.IsArchived/AccountOwner + SuperAdminAuditLog + migration (domain)
- **feat**: add Salary, TaxNumber, Description, ImageUrl + radio role picker (employee)
- **feat**: permissions matrix grouped by section + clone-from-template flow (roles)
- **fix**: keep only Admin + Cashier as system, demote others to custom + migration (roles)
- **feat**: event-driven Telegram bridge — webhook + Stop hook (infra)
- **fix**: drop Employee.Navigation(RetailPointAssignments) to fix snapshot order (migrations)
- **feat**: welcome dashboard with first-steps cards (onboarding)
- **feat**: Employees + Roles pages with permissions matrix (web)
- **feat**: EmployeesController + EmployeeRolesController + invite-with-temp-password (api)
- **feat**: system roles per organization + map admin → Employee (seed)
- **feat**: Employee, EmployeeRole, RolePermissions entities + migration (domain)
- **fix**: dropdown opens as floating overlay (Portal + absolute) (searchable-select)
- **fix**: theme styles + default to today for new docs (date-field)
- **feat**: replace native input with react-datepicker — polished UX (date-field)
- **fix**: polish calendar UX — dropdown nav, today/clear footer, ru weekdays (date-field)
- **fix**: compact calendar popup — shadcn-style sizing (date-field)
- **fix**: cap width + ru locale + DD.MM.YYYY format (date-fields)
- **fix**: show both article and barcode in line subtitle (supply-lines)
- **fix**: sticky input at viewport bottom + auto-scroll on add (supply-quick-add)
- **fix**: dropdown opens upward + show only N results + create-new at bottom (supply-quick-add)
- **feat**: drop supplier field, reorder sections, add cost column (product-card+list)
- **fix**: keep input focused after scan / clear on add (supply-quick-add)
- **fix**: dropdown not rendering — Portal + fixed position (supply-quick-add)
- **feat**: inline line quick-add — scanner + autocomplete + create-on-fly (supply)
- **feat**: products quick-search + by-barcode endpoints (api)
- **fix**: колонка «Розничная» использует имя системного PriceType (supply)
- **feat**: «Проведено» внутри формы + обязательная дата и ≥1 позиция (supply)
- **fix**: catch-up Phase3b_AddShowDescriptionOnProduct (migrations)
- **feat**: inline-create option in searchable Select (ui)
- **feat**: searchable Select component (drop-in) (ui)
- **feat**: drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide (product-card)
- **fix**: IsRequired применяется сразу, без перезагрузки страницы (price-types)
## 2026-04-25
- **fix**: человечная ошибка 400 + блок Save при незаполненных IsRequired ценах (product-edit)
- **fix**: correct is-system seeder + require value > 0 + system-price filter/sort (price-types)
- **feat**: inputs по справочнику PriceType — без dropdown'a (product-prices)
- **feat**: компонент + inline-наценка в таблице групп (percent-input)
- **feat**: срок годности (shelfLifeDays) + фильтр от/до (product+filters)
- **feat**: чекбокс «Проведено» с confirm + системная розничная в списке (supply+products-list)
- **feat**: drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired (phase3b)
- **feat**: supply line retail override column (web)
- **feat**: price types CRUD visibility + group markup table (web)
- **feat**: product card pricing UI + settings toggles (web)
- **feat**: recalc-retail endpoint + 30-day reference price refresh job (api)
- **feat**: supply posting hook for cost & markup (api)
- **feat**: pricing model rename and new fields (Phase3a) (domain)
- **fix**: merge barcodes/prices по ключу + 409 на concurrency (products/update)
- **fix**: toFixed(2) при allowFractional=true для правильного отображения (money-input)
- **fix**: сохранять промежуточный ввод точки в draft (money-input)
- **fix**: корректное обновление allowFractionalPrices без перелогина (money-input)
- **fix**: уважать AllowFractionalPrices в формах редактирования (money-input)
- **feat**: pre-check на Create/Update + warnings импорта + admin endpoint (barcode-uniqueness)
- **feat**: AllowFractionalPrices — переключатель дробных цен (org-settings)
- **feat**: группа обязательна, ≥1 штрихкод, умные дефолты на новом (product)
- **feat**: MoneyInput/NumberInput + select-пагинация + Range на бэкенде (forms)
## 2026-04-24
- **feat**: авто-генерация числового артикула при создании (products)
- **feat**: настройка ShowMinMaxStock для мин/макс остатков (org-settings)
- **feat**: галки «Услуга»/«Маркируемый» скрываются по умолчанию (org-settings)
- **feat**: авто-генерация EAN-13 при добавлении штрихкода (barcode)
- **feat**: server-side sort by column header click (tables)
- **feat**: валюта read-only, тянется из страны (как НДС) (org-settings)
- **feat**: ставка в стране + опц. переопределение на товаре (vat)
- **feat**: загрузка на диск сервера + галерея с лайтбоксом (product-images)
- **feat**: enum Packaging (штучный/весовой/разливной) вместо IsWeighed (product)
- **feat**: Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек (org-settings)
- **fix**: сделать Token опциональным (other-system/test)
## 2026-04-23
- **feat**: async jobs с прогрессом + токен в настройках (other-system-import)
- **fix**: per-page retry + чаще SaveChanges (other-system/import)
- **feat**: tree-of-groups + фильтры как в OtherSystem (catalog/products)
- **feat**: import archived entities too (as IsActive=false) (other-system)
- **fix**: reconcile stage schema — drop TrackingType, add IsMarked (db)
- **feat**: temp cleanup buttons + fix OtherSystem import duplicates (admin)
- **feat**: strict OtherSystem schema — реплика потерянного f7087e9
- **fix**: убираем выдумку Kind полностью — у OtherSystem этого поля нет (other-system)
- **fix**: не выдумывать Kind=Both для импортированных контрагентов (other-system)
- **feat**: Telegram <-> tmux bridge + local docker-registry unit (ops)
- **feat**: sales chart + KPIs (как «Показатели» в сторонняя система) (dashboard)
## 2026-04-22
- **fix**: bootstrap admin + demo org on stage/prod too, not just Dev (seeder)
- **fix**: always apply EF migrations on startup, not only in Development (api)
- **fix**: widen Article + Barcode.Code to 500 chars for real-world catalogs (catalog)
## 2026-04-21
- **fix**: accept fractional prices (decimal, not long) in DTOs (other-system)
- **fix**: add User-Agent header + enable HTTP auto-decompression (other-system)
- **fix**: drop Accept-Encoding: gzip to avoid JSON parse failure (other-system)
- **fix**: set Accept header as raw string to bypass .NET normalization (other-system)
- **fix**: exact Accept header value per OtherSystem requirement (code 1062) (other-system)
- **fix**: trailing slash on BaseUrl so HttpClient keeps /1.2/ in path (other-system)
- **fix**: OtherSystem admin endpoint uses policy-based auth on role claim directly (auth)
- **fix**: return 401 instead of 302 for API challenges; persist dev signing key across restarts (auth)
- **fix**: drop FM square badge from Logo; better 404 diagnostics on OtherSystem page (web)
- **feat**: rebrand to FOOD MARKET green (#00B207) per mobile app logo (web)
- **fix**: remove TanStack devtools palm icon; restore user profile on dashboard (web)
- **fix**: pin API dev port to 5081 (match Vite proxy config)

View file

@ -7,12 +7,8 @@
<ItemGroup>
<!-- ASP.NET Core 8 -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<!-- EF Core 8 + PostgreSQL -->
@ -31,12 +27,6 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<!-- App services -->
<PackageVersion Include="CsvHelper" Version="33.0.1" />
<PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="WebPush" Version="1.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="FluentValidation" Version="11.11.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
@ -46,16 +36,10 @@
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
<!-- Image processing (Sprint 14: variants thumb/medium + WebP) -->
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.6" />
<!-- Background jobs -->
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.10" />
<!-- Observability / Prometheus -->
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<!-- POS: local storage + API client -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageVersion Include="Refit" Version="7.2.22" />
@ -63,16 +47,7 @@
<PackageVersion Include="Polly" Version="8.5.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<!-- Avalonia 11 — кроссплатформенная касса (Windows/Linux), MIT. -->
<PackageVersion Include="Avalonia" Version="11.1.0" />
<PackageVersion Include="Avalonia.Desktop" Version="11.1.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.1.0" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.1.0" />
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageVersion Include="System.IO.Ports" Version="8.0.0" />
<PackageVersion Include="System.Management" Version="8.0.0" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- Testing -->
<PackageVersion Include="xunit" Version="2.9.3" />
@ -82,7 +57,6 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" />
</ItemGroup>
</Project>

174
README.md
View file

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

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 75 KiB

View file

@ -1,21 +0,0 @@
# CI status badges
Forgejo (primary, обновляется автоматически на каждый workflow run):
```markdown
![CI](http://127.0.0.1:3000/nns/food-market/actions/workflows/ci.yml/badge.svg)
![Docker API](http://127.0.0.1:3000/nns/food-market/actions/workflows/docker-api.yml/badge.svg)
![Regression](http://127.0.0.1:3000/nns/food-market/actions/workflows/regression.yml/badge.svg)
```
GitHub mirror (для external reader'ов):
```markdown
![CI](https://github.com/nurdotnet/food-market/actions/workflows/ci.yml/badge.svg)
```
Coverage (regenerated by `scripts/generate-badges.sh`):
```markdown
![coverage](./badges/coverage.svg)
```

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="20" role="img" aria-label="coverage (app+domain): 80%"><title>coverage (app+domain): 80%</title><g shape-rendering="crispEdges"><rect width="145" height="20" fill="#555"/><rect x="145" width="35" height="20" fill="#67ac09"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="735" y="140" transform="scale(.1)" fill="#fff" textLength="1350">coverage (app+domain)</text><text x="1615" y="140" transform="scale(.1)" fill="#fff" textLength="250">80%</text></g></svg>

Before

Width:  |  Height:  |  Size: 624 B

View file

@ -1,43 +0,0 @@
# food-market — пример переменных окружения для деплоя.
#
# Скопировать в deploy/.env и заполнить значениями (.env в .gitignore — НЕ коммитить).
# cp deploy/.env.example deploy/.env && $EDITOR deploy/.env
#
# docker-compose читает deploy/.env автоматически. Описание секретов и ротация —
# docs/secrets.md. Ключи OpenIddict — docs/openiddict-keys.md.
# ─── Реестр образов и теги (docker-compose) ──────────────────────────────────
# Откуда тянуть образы. Локальный registry на stage — 127.0.0.1:5001 (см. CLAUDE/memory).
REGISTRY=127.0.0.1:5001
API_TAG=latest
WEB_TAG=latest
PUBLIC_TAG=latest
# ─── База данных (ОБЯЗАТЕЛЬНО) ───────────────────────────────────────────────
# Пароль пользователя food_market в Postgres-контейнере. Подставляется и в
# POSTGRES_PASSWORD контейнера БД, и в ConnectionStrings__Default API.
# Сгенерировать: openssl rand -base64 24
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
# ─── OpenIddict / выдача токенов ─────────────────────────────────────────────
# Публичный URL админки = issuer токенов (обязателен за nginx-прокси).
OPENIDDICT_ISSUER=https://admin.food-market.kz/
# Пароль PFX-сертификатов подписи/шифрования. Пусто = без пароля (self-signed
# генерируется автоматически в App_Data, если файлов нет). Подробности — docs/openiddict-keys.md.
OPENIDDICT_CERT_PASSWORD=
# ─── Бэкап (systemd food-market-backup.*) ────────────────────────────────────
# Переопределения для скрипта бэкапа. По умолчанию совпадают с compose — можно не задавать.
# FM_BACKUP_DIR=/opt/food-market-data/backups
# FM_UPLOADS_DIR=/opt/food-market-data/uploads
# FM_BACKUP_RETENTION_DAYS=30
# ─── Прочее (опционально, переопределяет appsettings.json) ───────────────────
# CORS-origins фронта (если отличается от зашитых в appsettings). Индексируется с 0:
# Cors__AllowedOrigins__0=https://admin.food-market.kz
# Антибрутфорс на /connect/token и /api/auth/signup (дефолты 5/мин, 20/час):
# RateLimiting__Enabled=true
# RateLimiting__PerMinute=5
# RateLimiting__PerHour=20
# Интеграция МойСклад (по умолчанию боевой api.moysklad.ru):
# MoySklad__BaseUrl=https://api.moysklad.ru/api/remap/1.2/

View file

@ -1,5 +1,4 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/dotnet-sdk:8.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY food-market.sln global.json Directory.Build.props Directory.Packages.props ./
@ -11,28 +10,18 @@ COPY src/food-market.api/food-market.api.csproj src/food-market.api/
COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/
COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/
COPY src/ src/
# Single-step restore + publish — раздельные шаги в multi-stage cache
# роняли publish с NETSDK1064 (Microsoft.CodeAnalysis.Analyzers 3.3.3 not
# found) когда в csproj добавлялись новые transitive analyzer-зависимости,
# а первый restore не покрывал их. Теперь restore выполняется внутри publish.
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app
RUN dotnet restore src/food-market.api/food-market.api.csproj
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime
COPY src/ src/
RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl openssh-client \
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app .
# Sprint 17: CHANGELOG.md в content-root → WhatsNewController читает его на каждый /api/whats-new.
COPY CHANGELOG.md ./CHANGELOG.md
# VERSION файл создаётся deploy-скриптом (или CI) непосредственно перед docker
# build'ом — содержит короткий SHA коммита. Если отсутствует — fallback на
# AssemblyInformationalVersion. Поэтому COPY с || true (Dockerfile не имеет
# опц-COPY, делаем через RUN с проверкой).
ARG GIT_SHA=dev
RUN echo "$GIT_SHA" > VERSION
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
@ -40,7 +29,7 @@ ENV DOTNET_NOLOGO=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD curl -fsS http://localhost:8080/health/ready || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "foodmarket.Api.dll"]

View file

@ -1,5 +1,4 @@
ARG LOCAL_REGISTRY=127.0.0.1:5001
FROM ${LOCAL_REGISTRY}/mirror/node:22-alpine AS build
FROM node:20-alpine AS build
WORKDIR /src
RUN corepack enable
@ -10,7 +9,7 @@ RUN pnpm install --frozen-lockfile
COPY src/food-market.web/ ./
RUN pnpm build
FROM ${LOCAL_REGISTRY}/mirror/nginx:1.27-alpine AS runtime
FROM nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html

View file

@ -1,189 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 22: создание anonymized stage-dump'a из прод-БД.
#
# Алгоритм:
# 1. pg_dump прода в кастомном формате (-Fc)
# 2. pg_restore во временную staging-БД (`food_market_anon`)
# 3. UPDATE PII-полей в staging:
# - email → user{N}@example.kz
# - phone → +7700111{N:04}
# - passwordHash → один общий тестовый hash для пароля "Test12345!"
# - IIN / БИН → синтетические но валидные
# - имена/адреса → "Test Tester {N}" / "Тестовый адрес {N}"
# 4. pg_dump anonymized → .sql.gz файл
# 5. Удалить staging-БД
#
# Результат используется на dev-vm для воспроизведения багов на реальном
# объёме данных без утечки persistent PII.
#
# Usage:
# deploy/anonymize-prod.sh [--source <conn-uri>] [--target <conn-uri>]
# [--out <file>] [--dry-run]
#
# Default source: ssh prod docker exec food-market-prod-postgres pg_dump
# Default target: local postgres@14 with TEMP DB food_market_anon
# Default out: /home/nns/food-market-anon-YYYYMMDD.sql.gz
#
# ВАЖНО: контейнер food-market-prod-postgres (с 2026-10-05). НЕ shared-postgres —
# тот принадлежит соседнему compose-проекту с чужими боевыми базами (sklad,
# purchase, calcman, calcman_staging, makesales). См. docs/shared-postgres-followup-progress.md.
set -uo pipefail
SOURCE_HOST="${FM_PROD_HOST:-nns@admin.food-market.kz}"
SOURCE_CONTAINER="${FM_PROD_CONT:-food-market-prod-postgres}"
SOURCE_DB="${FM_PG_DB:-food_market}"
SOURCE_USER="${FM_PG_USER:-food_market}"
LOCAL_USER="${PGUSER:-nns}"
LOCAL_HOST="${PGHOST:-localhost}"
LOCAL_PORT="${PGPORT:-5432}"
TARGET_DB="food_market_anon_$$"
OUT="${FM_ANON_OUT:-$HOME/food-market-anon-$(date +%Y%m%d-%H%M%S).sql.gz}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--source-host) SOURCE_HOST="$2"; shift 2 ;;
--source-container) SOURCE_CONTAINER="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
log() { echo "[$(date -Iseconds)] $*"; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*";
else echo "[exec] $*"; "$@"; fi
}
cleanup() {
log "cleanup: drop $TARGET_DB"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \
-c "DROP DATABASE IF EXISTS $TARGET_DB;" 2>/dev/null || true
fi
}
trap cleanup EXIT
# ── 1. pg_dump из прода ──────────────────────────────────────────────
DUMP="/tmp/food-market-prod-$$.dump"
log "Step 1/5: pg_dump from $SOURCE_HOST → $DUMP (Fc format)"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] ssh $SOURCE_HOST docker exec $SOURCE_CONTAINER pg_dump -Fc -U $SOURCE_USER -d $SOURCE_DB > $DUMP"
else
ssh -o ConnectTimeout=10 "$SOURCE_HOST" \
"docker exec $SOURCE_CONTAINER pg_dump -Fc --no-owner --no-privileges -U $SOURCE_USER -d $SOURCE_DB" \
> "$DUMP" || { log "FAIL pg_dump"; exit 1; }
log "dump size: $(du -h "$DUMP" | cut -f1)"
fi
# ── 2. Создать temp-БД и restore ─────────────────────────────────────
log "Step 2/5: create $TARGET_DB + pg_restore"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \
-c "CREATE DATABASE $TARGET_DB;" || { log "FAIL create db"; exit 1; }
pg_restore -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \
--no-owner --no-privileges "$DUMP" 2>/dev/null || \
log "(некритично) pg_restore warnings — продолжаем"
fi
# ── 3. Anonymize PII ─────────────────────────────────────────────────
# Hash для пароля "Test12345!" — генерируется через идентичный
# алгоритм ASP.NET Identity (PBKDF2 SHA256, 10000 iter). Чтобы не
# вводить криптографию в скрипт — берём заранее известный hash.
# Сгенерировать новый можно через `dotnet run --project dev-tools/hash-pass.cs`.
TEST_PASS_HASH='AQAAAAIAAYagAAAAEHJsxbHF3MoBGSe+1bktB4O9aERPI4j5Jt6w0iN4dCqU/5jL+i5xT8E+UEqcVf0Vqg=='
log "Step 3/5: anonymize PII in $TARGET_DB"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" -v ON_ERROR_STOP=1 <<SQL
-- 1. AspNetUsers (Identity): email + phone + password hash + security stamp
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM "AspNetUsers"
)
UPDATE "AspNetUsers" u
SET
"Email" = 'user' || n.rn || '@example.kz',
"NormalizedEmail" = upper('user' || n.rn || '@example.kz'),
"UserName" = 'user' || n.rn || '@example.kz',
"NormalizedUserName" = upper('user' || n.rn || '@example.kz'),
"PhoneNumber" = '+7700111' || lpad(n.rn::text, 4, '0'),
"PasswordHash" = '$TEST_PASS_HASH',
"SecurityStamp" = encode(gen_random_bytes(16), 'hex'),
"ConcurrencyStamp" = gen_random_uuid()::text
FROM numbered n
WHERE u."Id" = n."Id";
-- 2. employees — рабочий email/телефон + полное имя
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM employees
)
UPDATE employees e
SET
"Email" = CASE WHEN e."Email" IS NOT NULL THEN 'emp' || n.rn || '@example.kz' END,
"Phone" = CASE WHEN e."Phone" IS NOT NULL THEN '+7700222' || lpad(n.rn::text, 4, '0') END,
"FirstName" = 'Тест',
"LastName" = 'Тестов' || n.rn,
"MiddleName" = NULL,
"TaxNumber" = NULL
FROM numbered n
WHERE e."Id" = n."Id";
-- 3. counterparties — БИН/ИИН/имена/контакты
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM counterparties
)
UPDATE counterparties c
SET
"Name" = 'Контрагент-' || n.rn,
"LegalName" = CASE WHEN c."LegalName" IS NOT NULL THEN 'ТОО Контрагент-' || n.rn END,
-- Синтетические BIN/IIN (12 цифр, не валидируем checksum здесь).
"Bin" = CASE WHEN c."Bin" IS NOT NULL THEN lpad(n.rn::text, 12, '9') END,
"Iin" = CASE WHEN c."Iin" IS NOT NULL THEN lpad(n.rn::text, 12, '8') END,
"Phone" = CASE WHEN c."Phone" IS NOT NULL THEN '+7700333' || lpad(n.rn::text, 4, '0') END,
"Email" = CASE WHEN c."Email" IS NOT NULL THEN 'cp' || n.rn || '@example.kz' END,
"Address" = CASE WHEN c."Address" IS NOT NULL THEN 'г. Тестовый, ул. Тестовая ' || n.rn END,
"BankAccount" = CASE WHEN c."BankAccount" IS NOT NULL THEN 'KZ000000' || lpad(n.rn::text, 14, '0') END,
"ContactPerson" = CASE WHEN c."ContactPerson" IS NOT NULL THEN 'Контакт ' || n.rn END,
"Notes" = NULL
FROM numbered n
WHERE c."Id" = n."Id";
-- 4. organizations: имя/БИН/телефон владельца/MoySkladToken
UPDATE organizations
SET
"MoySkladToken" = NULL,
"OwnerTelegramChatId" = NULL,
"Bin" = CASE WHEN "Bin" IS NOT NULL THEN '700700700700' END,
"Name" = 'TestOrg-' || substr("Id"::text, 1, 8);
-- 5. refresh tokens revoke all (чтобы старые stage-токены не работали)
UPDATE "OpenIddictTokens" SET "Status" = 'revoked' WHERE "Status" = 'valid';
-- 6. чистим аудит-логи и feedback (могут содержать персональные тексты)
TRUNCATE TABLE org_audit_log;
TRUNCATE TABLE super_admin_audit_log;
SQL
log "anonymize done"
fi
# ── 4. pg_dump anonymized → out ──────────────────────────────────────
log "Step 4/5: pg_dump $TARGET_DB → $OUT"
if [[ $DRY_RUN -eq 0 ]]; then
pg_dump -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
log "anonymized dump: $(du -h "$OUT" | cut -f1) → $OUT"
fi
# ── 5. Cleanup (через trap) ──────────────────────────────────────────
log "Step 5/5: cleanup"
rm -f "$DUMP" 2>/dev/null || true
log "✓ Готово: $OUT"
log "Восстановить можно: gunzip -c $OUT | psql -d food_market_dev"
exit 0

View file

@ -1,171 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: pre-deploy readiness check на prod-vm.
#
# Запускается ПЕРЕД prod-deploy.sh. Проверяет:
# 1. Backup сделан < FM_BACKUP_MAX_AGE_MIN (60) минут назад
# 2. Свободного места ≥ FM_MIN_FREE_GB (5) GB на каждом mount
# 3. Текущий /health/ready возвращает 200
# 4. (опц.) CI-проверки на этом коммите прошли (FM_CHECK_CI=1)
# 5. .env содержит все required переменные (без placeholder'ов
# типа CHANGEME/REPLACE_ME)
#
# Exit 0 — всё хорошо, можно деплоить.
# Exit 1+ — конкретная причина в stderr.
#
# Usage:
# deploy/check-prod-readiness.sh [--dry-run] [--ssh-host HOST]
#
# По умолчанию проверки локальные (предполагается что скрипт запущен НА
# prod-vm). С --ssh-host выполняется через ssh: запускает себя
# удалённо.
set -uo pipefail
# -e снят: хотим прогнать ВСЕ проверки и собрать суммарный отчёт,
# не вылетая на первой.
DRY_RUN=0
SSH_HOST=""
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
BACKUP_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
ENV_FILE="${FM_ENV_FILE:-/home/nns/food-market-prod/deploy/.env}"
MAX_AGE_MIN="${FM_BACKUP_MAX_AGE_MIN:-60}"
MIN_FREE_GB="${FM_MIN_FREE_GB:-5}"
MOUNTS="${FM_MOUNTS:-/opt /var/lib/docker}"
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--ssh-host) SSH_HOST="$2"; shift 2 ;;
--help|-h)
grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -n "$SSH_HOST" ]]; then
# Re-run self on remote host.
echo "[check] proxying to $SSH_HOST"
exec ssh "$SSH_HOST" "FM_BACKUP_DIR='$BACKUP_DIR' FM_ENV_FILE='$ENV_FILE' \
FM_BACKUP_MAX_AGE_MIN='$MAX_AGE_MIN' FM_MIN_FREE_GB='$MIN_FREE_GB' \
FM_MOUNTS='$MOUNTS' PROD_URL='$PROD_URL' bash -s" < "$0" $([[ $DRY_RUN -eq 1 ]] && echo --dry-run)
fi
PASS=0
FAIL=0
ERRORS=()
check() {
local name="$1" status="$2" detail="$3"
if [[ "$status" == "OK" ]]; then
echo "[✓] $name — $detail"
((PASS+=1))
else
echo "[✗] $name — $detail" >&2
ERRORS+=("$name: $detail")
((FAIL+=1))
fi
}
# ── 1. Backup age ────────────────────────────────────────────────────
if [[ ! -d "$BACKUP_DIR" ]]; then
check "backup-age" "FAIL" "backup-dir $BACKUP_DIR не существует"
else
# Самый свежий db-*.dump
LATEST=$(ls -t "$BACKUP_DIR"/db-*.dump 2>/dev/null | head -1)
if [[ -z "$LATEST" ]]; then
check "backup-age" "FAIL" "в $BACKUP_DIR нет db-*.dump файлов"
else
AGE_SEC=$(( $(date +%s) - $(stat -c %Y "$LATEST") ))
AGE_MIN=$(( AGE_SEC / 60 ))
if (( AGE_MIN > MAX_AGE_MIN )); then
check "backup-age" "FAIL" "последний backup $LATEST: $AGE_MIN мин назад (порог $MAX_AGE_MIN)"
else
check "backup-age" "OK" "$AGE_MIN мин назад ($LATEST)"
fi
fi
fi
# ── 2. Free disk space ───────────────────────────────────────────────
for mnt in $MOUNTS; do
if [[ ! -d "$mnt" ]]; then
check "disk:$mnt" "FAIL" "mount не существует"
continue
fi
FREE_KB=$(df --output=avail "$mnt" 2>/dev/null | tail -1 | tr -d ' ')
if [[ -z "$FREE_KB" || "$FREE_KB" -le 0 ]]; then
check "disk:$mnt" "FAIL" "df не вернул avail"
continue
fi
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
if (( FREE_GB < MIN_FREE_GB )); then
check "disk:$mnt" "FAIL" "$FREE_GB GB свободно (порог $MIN_FREE_GB)"
else
check "disk:$mnt" "OK" "$FREE_GB GB свободно"
fi
done
# ── 3. /health/ready ─────────────────────────────────────────────────
HEALTH_BODY=$(curl -fsS --max-time 10 "$PROD_URL/health/ready" 2>/dev/null || echo "")
if echo "$HEALTH_BODY" | grep -q '"status":"Healthy"'; then
check "health-ready" "OK" "$PROD_URL/health/ready=Healthy"
else
check "health-ready" "FAIL" "ответ: ${HEALTH_BODY:-<empty>}"
fi
# ── 4. CI status (опц.) ──────────────────────────────────────────────
if [[ "${FM_CHECK_CI:-0}" == "1" ]]; then
# Берём текущий commit и проверяем что CI workflow прошёл.
# Реализация зависит от наличия Forgejo CLI; пока — manual hint.
CURRENT_SHA=$(cd /home/nns/food-market 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")
if [[ -n "$CURRENT_SHA" ]]; then
check "ci-status" "OK" "skipped (manual check: GET /api/v1/repos/nns/food-market/commits/$CURRENT_SHA/status)"
else
check "ci-status" "OK" "skipped (not a git repo)"
fi
fi
# ── 5. .env complete ─────────────────────────────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
check ".env-file" "FAIL" "$ENV_FILE не существует"
else
# Список обязательных переменных для прод-окружения.
REQUIRED=(
"POSTGRES_PASSWORD"
"OPENIDDICT_ISSUER"
"OPENIDDICT_CERT_PASSWORD"
)
MISSING=()
PLACEHOLDER=()
for key in "${REQUIRED[@]}"; do
VAL=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
if [[ -z "$VAL" ]]; then
MISSING+=("$key")
elif [[ "$VAL" =~ ^(CHANGEME|REPLACE_ME|TODO|dev|food_market_dev)$ ]]; then
PLACEHOLDER+=("$key=$VAL")
fi
done
if (( ${#MISSING[@]} > 0 )); then
check ".env-required" "FAIL" "отсутствуют: ${MISSING[*]}"
fi
if (( ${#PLACEHOLDER[@]} > 0 )); then
check ".env-placeholders" "FAIL" "плейсхолдеры: ${PLACEHOLDER[*]}"
fi
if (( ${#MISSING[@]} == 0 && ${#PLACEHOLDER[@]} == 0 )); then
check ".env-file" "OK" "${#REQUIRED[@]} required key(s) заполнены"
fi
fi
# ── Итог ─────────────────────────────────────────────────────────────
echo
echo "==> $PASS passed, $FAIL failed"
if (( FAIL > 0 )); then
echo "Не готов к деплою:"
for e in "${ERRORS[@]}"; do echo " - $e"; done
exit 1
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run; никаких изменений)"
fi
echo "OK — можно запускать prod-deploy.sh"
exit 0

View file

@ -1,108 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: сравнение схемы БД stage vs prod.
#
# Делает `pg_dump --schema-only` с обеих БД, diff'ит. Если выводит
# непустой diff — миграция не доехала или local-only изменения.
#
# Подразумевает что обе БД доступны (например через SSH-туннель или
# pg_dump --host=<host>). Дефолтные подключения:
# stage = docker exec food-market-stage-postgres pg_dump (через ssh prod-vm)
# prod = docker exec food-market-prod-postgres pg_dump (через ssh prod-vm)
#
# ВАЖНО: прод-контейнер — `food-market-prod-postgres` (с 2026-10-05). Соседний
# `shared-postgres` на той же машине обслуживает ЧУЖИЕ боевые базы (sklad,
# purchase, calcman, calcman_staging, makesales) и в diff не участвует.
# См. docs/shared-postgres-followup-progress.md.
#
# Usage:
# deploy/db-schema-diff.sh [--stage-host HOST] [--prod-host HOST]
# [--quick] # без TOAST/sequence-details
# [--dry-run] # печать только команд
#
# Выход:
# 0 — схемы идентичны
# 1 — есть различия (печатает diff)
# 2 — ошибка получения дампа
set -uo pipefail
STAGE_HOST="${FM_STAGE_HOST:-nns@192.168.1.190}"
PROD_HOST="${FM_PROD_HOST:-nns@admin.food-market.kz}"
STAGE_CONT="${FM_STAGE_CONT:-food-market-stage-postgres}"
PROD_CONT="${FM_PROD_CONT:-food-market-prod-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
QUICK=0
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--stage-host) STAGE_HOST="$2"; shift 2 ;;
--prod-host) PROD_HOST="$2"; shift 2 ;;
--quick) QUICK=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
# Флаги pg_dump для schema-only сравнения. --schema-only + --no-owner +
# --no-privileges чтобы дамп был стабильный без role-mismatch'ей между
# инстансами. --no-comments — выключаем COMMENT'ы (они часто шумят).
PGDUMP_FLAGS="--schema-only --no-owner --no-privileges --no-comments"
if [[ $QUICK -eq 1 ]]; then
PGDUMP_FLAGS="$PGDUMP_FLAGS --exclude-table-data=pg_*"
fi
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
STAGE_SQL="$TMP_DIR/stage.sql"
PROD_SQL="$TMP_DIR/prod.sql"
log() { echo "[$(date -Iseconds)] $*" >&2; }
dump_remote() {
local host="$1" container="$2" out="$3"
log "dump from $host (container $container) → $out"
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] ssh $host docker exec $container pg_dump $PGDUMP_FLAGS -U $DB_USER -d $DB > $out"
touch "$out"
return
fi
ssh -o ConnectTimeout=10 "$host" "docker exec $container pg_dump $PGDUMP_FLAGS -U $DB_USER -d $DB" > "$out" 2>/dev/null \
|| { log "FAIL: dump from $host"; return 2; }
}
dump_remote "$STAGE_HOST" "$STAGE_CONT" "$STAGE_SQL" || exit 2
dump_remote "$PROD_HOST" "$PROD_CONT" "$PROD_SQL" || exit 2
# Нормализация: убираем строки которые всегда отличаются (комментарии,
# даты, version-header'ы, OID'ы):
normalize() {
sed -e '/^-- /d' \
-e '/^SET /d' \
-e '/^SELECT pg_catalog.set_config/d' \
-e '/^[[:space:]]*$/d' "$1"
}
# Применяем нормализацию ин-плейс и сравниваем.
normalize "$STAGE_SQL" > "$TMP_DIR/stage.norm"
normalize "$PROD_SQL" > "$TMP_DIR/prod.norm"
log "comparing…"
if diff -u "$TMP_DIR/prod.norm" "$TMP_DIR/stage.norm" > "$TMP_DIR/diff" 2>&1; then
echo "✓ Схемы идентичны (stage == prod)"
exit 0
fi
LINES=$(wc -l < "$TMP_DIR/diff")
echo "✗ Найдены различия: $LINES строк diff'a"
echo
echo "===== diff (prod → stage) ====="
cat "$TMP_DIR/diff"
echo "==============================="
echo
echo "Если разница — новые миграции stage → prod, применить их перед deploy."
echo "Если разница — local-only изменения на prod, разобраться вручную."
exit 1

View file

@ -1,86 +0,0 @@
#!/usr/bin/env bash
#
# ПРОД food-market — деплой ТОЛЬКО по явной команде (--yes). Делает БЭКАП ДО миграций.
# НЕ встраивать в ночные задачи: прод обновляется вручную.
#
# Запуск (с dev-vm):
# ~/deploy-prod.sh <api-tag> <web-tag> --yes # сборка+push+деплой
# ~/deploy-prod.sh --yes # взять теги из прод .env (не пересобирать)
#
# Guards: прод-compose указывает на food-market-prod-postgres (не shared-postgres),
# .env chmod 600, quick-login выкл, демо-данные выкл, issuer = admin.food-market.kz.
# Публичный сайт (public) НЕ трогается.
set -uo pipefail
PROD_HOST="${PROD_HOST:-nns@192.168.1.190}"
PROD_DIR="${PROD_DIR:-~/food-market/deploy}"
REGISTRY="${REGISTRY:-192.168.1.193:5001}"
REPO="${REPO:-/home/nns/food-market}"
API_TAG=""; WEB_TAG=""; YES=0; BUILD=0
for a in "$@"; do
case "$a" in
--yes) YES=1 ;;
--build) BUILD=1 ;;
-*) echo "Unknown flag: $a" >&2; exit 2 ;;
*) if [[ -z "$API_TAG" ]]; then API_TAG="$a"; elif [[ -z "$WEB_TAG" ]]; then WEB_TAG="$a"; fi ;;
esac
done
if [[ $YES -ne 1 ]]; then
echo "ОТКАЗ: прод-деплой требует явного --yes. Это боевой сервер."
echo "Usage: $0 [<api-tag> <web-tag>] --yes [--build]"
exit 2
fi
log() { echo "[$(date -Is)] [deploy-prod] $*"; }
fail() { echo "[deploy-prod] FAIL: $*" >&2; exit 1; }
# ── Guard 1: прод-compose не ссылается на чужой shared-postgres ──
REMOTE_COMPOSE="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cat $PROD_DIR/docker-compose.yml" 2>/dev/null)" || fail "не прочитать прод-compose"
echo "$REMOTE_COMPOSE" | grep -q 'container_name: food-market-prod-postgres' || fail "в прод-compose нет food-market-prod-postgres"
if echo "$REMOTE_COMPOSE" | grep -qE 'container_name:[[:space:]]*shared-postgres|name:[[:space:]]*food-market-postgres-data[[:space:]]*$|127\.0\.0\.1:5434:5432'; then
fail "прод-compose ссылается на shared-postgres/чужой том/порт 5434 — СТОП"
fi
# ── Guard 2: прод .env санитария ──
REMOTE_ENV="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cat $PROD_DIR/.env" 2>/dev/null)" || fail "не прочитать прод .env"
PERM="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "stat -c %a $PROD_DIR/.env" 2>/dev/null)"
[[ "$PERM" == "600" ]] || fail "прод .env права $PERM (должно 600)"
echo "$REMOTE_ENV" | grep -qiE '^POSTGRES_PASSWORD=food_market_dev$' && fail "прод использует dev-пароль БД — СТОП"
echo "$REMOTE_ENV" | grep -qiE '^QUICK_LOGIN_ENABLED=true' && fail "на проде включён quick-login — СТОП"
echo "$REMOTE_ENV" | grep -qiE '^SEED_DEMO_DATA=false' || fail "на проде не выставлен SEED_DEMO_DATA=false — СТОП"
echo "$REMOTE_ENV" | grep -qiE '^OPENIDDICT_ISSUER=https://admin\.food-market\.kz' || fail "issuer не admin.food-market.kz — СТОП"
log "guards OK"
# ── (опц.) сборка + push релизных образов ──
if [[ $BUILD -eq 1 ]]; then
[[ -n "$API_TAG" && -n "$WEB_TAG" ]] || { API_TAG="$(cd "$REPO" && git rev-parse --short HEAD)"; WEB_TAG="$API_TAG"; }
log "build+push api=$API_TAG web=$WEB_TAG"
( cd "$REPO" && docker build --build-arg LOCAL_REGISTRY=$REGISTRY --build-arg GIT_SHA="$API_TAG" -f deploy/Dockerfile.api -t $REGISTRY/food-market-api:$API_TAG . ) || fail "build api"
( cd "$REPO" && docker build --build-arg LOCAL_REGISTRY=$REGISTRY -f deploy/Dockerfile.web -t $REGISTRY/food-market-web:$WEB_TAG . ) || fail "build web"
docker push $REGISTRY/food-market-api:$API_TAG || fail "push api"
docker push $REGISTRY/food-market-web:$WEB_TAG || fail "push web"
# обновить теги в прод .env
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cd $PROD_DIR && sed -i 's/^API_TAG=.*/API_TAG=$API_TAG/; s/^WEB_TAG=.*/WEB_TAG=$WEB_TAG/' .env"
fi
# ── БЭКАП ДО миграций (обязательно) ──
log "бэкап БД ДО деплоя…"
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "FM_BACKUP_TIER=daily $PROD_DIR/prod-backup.sh" 2>&1 | tail -4 || fail "бэкап не прошёл — деплой остановлен"
# ── деплой: pull + up postgres+api+web (НЕ public) ──
log "pull + up (миграции применяются при старте api)…"
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cd $PROD_DIR && docker compose pull postgres api web && docker compose up -d postgres api web" 2>&1 | tail -10 || fail "compose up"
# ── ждём healthy + smoke ──
log "жду api healthy…"
OK=0
for i in $(seq 1 30); do
if ssh -o StrictHostKeyChecking=no "$PROD_HOST" "curl -fsS http://localhost:8080/health/ready 2>/dev/null | grep -q Healthy"; then OK=1; break; fi
sleep 3
done
[[ $OK -eq 1 ]] || fail "api не стал Healthy — проверь логи food-market-api"
PCODE=$(curl -s -o /dev/null -w '%{http_code}' https://admin.food-market.kz/health/ready 2>/dev/null || echo 000)
log "публичный /health/ready → HTTP $PCODE"
log "✓ прод-деплой завершён (api=$(ssh $PROD_HOST "grep ^API_TAG= $PROD_DIR/.env | cut -d= -f2"))"

View file

@ -1,97 +0,0 @@
# Stage-stack compose (test.admin.food-market.kz).
#
# Деплоится на prod-vm в ~/food-market-stage/deploy/docker-compose.yml.
# Запускается через ~/deploy-stage.sh на dev-vm (билдит из main → push :stage →
# ssh prod-vm `docker compose -p food-market-stage pull/up`).
#
# ВАЖНО: все stage-ресурсы НАЗВАНЫ отдельно от прод-стека (`food-market-stage-*`
# volume'ы, `/opt/food-market-stage-data` для uploads). Guard в ~/deploy-stage.sh
# и в quality-watchdog падает, если здесь появится прод-путь или прод-том — это
# третья утечка stage↔prod и мы её закрыли (см. docs/stage-prod-isolation-progress.md).
#
# Если нужно добавить сервис — делайте это тут и в прод-compose СИММЕТРИЧНО,
# но тома/бинды называйте RAW отдельно, без автодефолта от compose-project'а.
services:
postgres:
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
container_name: food-market-stage-postgres
restart: unless-stopped
environment:
POSTGRES_DB: food_market
POSTGRES_USER: food_market
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "127.0.0.1:5435:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U food_market -d food_market"]
interval: 10s
timeout: 5s
retries: 5
api:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-api:${API_TAG:-stage}
container_name: food-market-stage-api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
# На стенде issuer должен указывать на тестовый домен. Если здесь
# внезапно появится admin.food-market.kz — guard в deploy-stage.sh упадёт.
OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://test.admin.food-market.kz/}
OpenIddict__CertPassword: ${OPENIDDICT_CERT_PASSWORD:-}
# Rate-limits на стенде всегда выше (e2e и smoke генерят много запросов).
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-5000}
RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-50000}
RateLimiting__PerIpPerMinute: ${RATE_PER_IP_MIN:-5000}
RateLimiting__PerIpPerHour: ${RATE_PER_IP_HOUR:-20000}
# Quick-login доступен ТОЛЬКО на стенде; на prod — False.
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456}
# Пароль сидируемого SuperAdmin — ТОЛЬКО из секрета, без дефолта. Без него
# на свежей БД (ASPNETCORE_ENVIRONMENT=Production) SuperAdmin не создаётся с
# известным паролем. На существующей БД пароль не трогается (ротация — вручную,
# см. docs/rebrand-all-market-progress.md и ~/.fm-watchdog/stage-superadmin-password).
Seed__SuperAdminPassword: ${SEED_SUPERADMIN_PASSWORD:-}
# OCR invoice: Claude CLI via ssh на dev-vm.
Invoices__ClaudeCli__RemoteHost: ${INVOICE_REMOTE_HOST:-nns@192.168.1.192}
Invoices__ClaudeCli__Executable: /home/nns/.local/bin/claude
Invoices__ClaudeCli__SshKey: /app/.ssh/invoice-ocr-key
Invoices__ClaudeCli__SshKnownHosts: /tmp/.fm-ssh/known_hosts
ports:
- "8085:8080"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- api-data:/app/App_Data
- api-logs:/app/logs
- /opt/food-market-stage-data/uploads:/app/uploads
- ./secrets/invoice-ocr-key:/app/.ssh/invoice-ocr-key:ro
web:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-stage}
container_name: food-market-stage-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "8086:80"
volumes:
postgres-data:
name: food-market-stage-postgres-data
api-data:
name: food-market-stage-api-data
api-logs:
name: food-market-stage-api-logs

View file

@ -1,32 +1,16 @@
# Прод-compose food-market.
#
# ВАЖНО (2026-10-05): у food-market свой postgres прода — контейнер
# `food-market-prod-postgres`, том `food-market-prod-postgres-data`, порт
# `127.0.0.1:5437`. На prod-vm параллельно существует отдельный compose-проект
# `shared-postgres` (~/shared-postgres/docker-compose.yml, порт 5434, том
# `food-market-postgres-data` *external*) с ЧУЖИМИ боевыми базами (sklad,
# purchase, calcman, calcman_staging, makesales). Он food-market НЕ принадлежит
# и в этот compose возвращаться НЕ должен. См. docs/shared-postgres-followup-progress.md.
#
# Guards в ~/deploy-stage.sh и ~/quality-watchdog.sh падают, если здесь появится
# `shared-postgres`, `food-market-postgres-data`, порт `5434` или старое имя
# контейнера `food-market-postgres`.
services:
postgres:
image: ${REGISTRY:-127.0.0.1:5001}/mirror/postgres:16-alpine
container_name: food-market-prod-postgres
image: postgres:16-alpine
container_name: food-market-postgres
restart: unless-stopped
environment:
POSTGRES_DB: food_market
POSTGRES_USER: food_market
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-food_market_dev}
PGDATA: /var/lib/postgresql/data/pgdata
# prod-vm занят: 5432 — host postgres, 5434 — shared-postgres (чужой),
# 5435 — stage-postgres, 5436 — food-market-server-postgres (сосед).
# Нам остаётся 5437.
# Stage VM already uses 5432 (host postgres) — map ours to 5434 to avoid clash.
ports:
- "127.0.0.1:5437:5432"
- "127.0.0.1:5434:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
@ -45,75 +29,28 @@ services:
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: Host=postgres;Port=5432;Database=food_market;Username=food_market;Password=${POSTGRES_PASSWORD:-food_market_dev}
# Публичный issuer токенов — обязателен за прокси, иначе берётся из запроса
# (или дефолт localhost из appsettings, что неверно для прод).
OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://admin.food-market.kz/}
# Пароль PFX-сертификатов OpenIddict (пусто = сертификаты без пароля).
OpenIddict__CertPassword: ${OPENIDDICT_CERT_PASSWORD:-}
# Sprint 13: rate-limit на signup. На stage'е переопределяется в
# .env'е через RATE_SIGNUP_HOUR / RATE_SIGNUP_DAY для прохождения
# e2e/smoke; в prod'е оставляем дефолты 3/час, 10/сутки.
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3}
RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10}
# Sprint 30: «быстрый вход» на тестовом стенде. По умолчанию выключено.
# На stage включается через QUICK_LOGIN_ENABLED=true в .env.
# QUICK_LOGIN_MASTER_PASSWORD задаётся секрет-паролем стенда (значение
# хранится вне репозитория — на dev-vm в ~/.fm-watchdog/stage-shared-
# password). Если переменная пустая — master-password-режим отключён
# (IsMasterPassword отбивает пустую строку). SPA отправляет публичный
# sentinel '__QUICK_LOGIN_PWLESS__' — реальный пароль в JS не попадает.
# Code имеет safety-override: даже с Enabled=true, если OpenIddict__
# Issuer смотрит на admin.food-market.kz — быстрый вход принудительно
# выключен (см. QuickLoginOptions.EffectivelyEnabled).
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-}
# Прод: начальный SuperAdmin (DevDataSeeder) и отключение демо-данных для
# чистого (пустого) старта. На проде SEED_DEMO_DATA=false → без «Demo Market».
# Пустой SEED_SUPERADMIN_PASSWORD = SuperAdmin не сидируется (безопасный дефолт).
SEED_SUPERADMIN_PASSWORD: ${SEED_SUPERADMIN_PASSWORD:-}
SEED_DEMO_DATA: ${SEED_DEMO_DATA:-}
# Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports:
- "8080:8080" # api
healthcheck:
# Готовность = БД отвечает + миграции применены (см. /health/ready).
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
- api-data:/app/App_Data
- api-logs:/app/logs
- /opt/food-market-data/uploads:/app/uploads
web:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}
container_name: food-market-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
- api
ports:
- "8081:80" # web SPA, not on 80 (legacy nginx holds it)
public:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-public:${PUBLIC_TAG:-latest}
container_name: food-market-public
restart: unless-stopped
ports:
- "8082:80" # marketing astro static
volumes:
postgres-data:
# ВАЖНО: НЕ `food-market-postgres-data` — тот том принадлежит отдельному
# проекту shared-postgres (чужие боевые базы). См. docs/shared-postgres-followup-progress.md.
name: food-market-prod-postgres-data
name: food-market-postgres-data
api-data:
name: food-market-api-data
api-logs:
name: food-market-api-logs

View file

@ -1,19 +0,0 @@
[Unit]
Description=Local Docker Registry for food-market
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStartPre=-/usr/bin/docker rm -f food-market-registry
ExecStart=/usr/bin/docker run --rm --name food-market-registry \
-p 127.0.0.1:5001:5000 \
-v /opt/food-market-data/docker-registry:/var/lib/registry \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
registry:2
ExecStop=/usr/bin/docker stop food-market-registry
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -1,64 +0,0 @@
-- deploy/fix-internal-ean13.sql
-- Исправление контрольной цифры у внутренних EAN-13 (префикс «2»), которые
-- веб-генератор выдавал с НЕверной контрольной цифрой (баг digitsChecksum:
-- веса считались слева, а для 12-значного тела чётность переворачивается).
-- Фикс генератора — в src/food-market.web/src/lib/barcode.ts; этот скрипт
-- чинит УЖЕ ВЫДАННЫЕ коды, не теряя связь с товаром (та же строка product_barcodes,
-- тот же ProductId — меняется только последняя цифра Code).
--
-- Идемпотентно. Пропускает коды, у которых исправленный вариант уже занят
-- в этой организации (во избежание нарушения unique(OrganizationId,Code)) —
-- такие выводятся отдельно для ручного разбора.
--
-- Запуск: ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres \
-- psql -U food_market -d food_market" < deploy/fix-internal-ean13.sql
CREATE OR REPLACE FUNCTION pg_temp.ean13_check(body text) RETURNS int AS $f$
DECLARE s int := 0; w int := 3; i int;
BEGIN
IF body !~ '^[0-9]{12}$' THEN RETURN -1; END IF;
FOR i IN REVERSE 12..1 LOOP
s := s + substr(body,i,1)::int * w;
w := CASE WHEN w=3 THEN 1 ELSE 3 END;
END LOOP;
RETURN (10 - s % 10) % 10;
END $f$ LANGUAGE plpgsql IMMUTABLE;
-- Отчёт: что будет исправлено (старый → новый), с товаром и организацией.
SELECT pb."OrganizationId", pb."ProductId", p."Name" AS product,
pb."Code" AS old_code,
substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text AS new_code
FROM product_barcodes pb
JOIN products p ON p."Id" = pb."ProductId"
WHERE pb."Code" ~ '^2[0-9]{12}$'
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
ORDER BY pb."OrganizationId", p."Name";
-- Конфликты: исправленный код уже занят в этой орге — НЕ трогаем.
SELECT pb."Code" AS old_code,
substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text AS new_code,
pb."OrganizationId"
FROM product_barcodes pb
WHERE pb."Code" ~ '^2[0-9]{12}$'
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
AND EXISTS (
SELECT 1 FROM product_barcodes x
WHERE x."OrganizationId" = pb."OrganizationId"
AND x."Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
AND x."Id" <> pb."Id");
-- Собственно исправление (без конфликтных).
UPDATE product_barcodes pb
SET "Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
WHERE pb."Code" ~ '^2[0-9]{12}$'
AND substr(pb."Code",13,1)::int <> pg_temp.ean13_check(substr(pb."Code",1,12))
AND NOT EXISTS (
SELECT 1 FROM product_barcodes x
WHERE x."OrganizationId" = pb."OrganizationId"
AND x."Code" = substr(pb."Code",1,12) || pg_temp.ean13_check(substr(pb."Code",1,12))::text
AND x."Id" <> pb."Id");
-- Контроль: сколько осталось неверных (должно стать = числу конфликтных).
SELECT count(*) AS remaining_wrong FROM product_barcodes
WHERE "Code" ~ '^2[0-9]{12}$'
AND substr("Code",13,1)::int <> pg_temp.ean13_check(substr("Code",1,12));

View file

@ -1,15 +0,0 @@
[Unit]
Description=food-market: бэкап БД и загруженных файлов
Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md
Wants=docker.service
After=docker.service
[Service]
Type=oneshot
# Опциональные переопределения FM_* (см. шапку скрипта). Знак "-" — файл не
# обязателен. Путь скорректировать под фактический каталог деплоя.
EnvironmentFile=-/opt/food-market/deploy/.env
ExecStart=/opt/food-market/deploy/food-market-backup.sh
# Бэкап не должен мешать основной нагрузке.
Nice=10
IOSchedulingClass=idle

View file

@ -1,70 +0,0 @@
#!/usr/bin/env bash
#
# food-market: ежедневный бэкап БД + загруженных файлов с ротацией.
#
# Дампит Postgres (контейнер food-market-prod-postgres) в custom-формат pg_dump
# (-Fc, пригоден для pg_restore с параллелизмом/выборочным восстановлением) и
# архивирует каталог uploads. Удаляет бэкапы старше RETENTION_DAYS дней.
#
# Запускается из systemd-таймера food-market-backup.timer (ежедневно), либо
# вручную. Конфигурируется переменными окружения (значения по умолчанию
# совпадают с deploy/docker-compose.yml):
#
# FM_PG_CONTAINER имя контейнера Postgres (food-market-prod-postgres)
# FM_PG_DB имя БД (food_market)
# FM_PG_USER пользователь БД (food_market)
# FM_BACKUP_DIR куда складывать бэкапы (/opt/food-market-data/backups)
# FM_UPLOADS_DIR каталог изображений (/opt/food-market-data/uploads)
# FM_BACKUP_RETENTION_DAYS срок хранения, дней (30)
#
# ВАЖНО: НЕ `food-market-postgres` и НЕ `shared-postgres` — это контейнер
# соседнего compose-проекта с ЧУЖИМИ боевыми базами (sklad, purchase, calcman,
# calcman_staging, makesales). См. docs/shared-postgres-followup-progress.md.
set -euo pipefail
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
BACKUP_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
UPLOADS_DIR="${FM_UPLOADS_DIR:-/opt/food-market-data/uploads}"
RETENTION_DAYS="${FM_BACKUP_RETENTION_DAYS:-30}"
TS="$(date +%Y%m%d-%H%M%S)"
DB_FILE="$BACKUP_DIR/db-$TS.dump"
UPLOADS_FILE="$BACKUP_DIR/uploads-$TS.tgz"
log() { echo "[$(date -Is)] $*"; }
mkdir -p "$BACKUP_DIR"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
log "ОШИБКА: контейнер '$CONTAINER' не запущен — бэкап невозможен." >&2
exit 1
fi
log "Дамп БД '$DB' → $DB_FILE"
# Дамп пишем во временный файл и переименовываем по успеху — частичный/битый
# дамп при падении pg_dump не попадёт в ротацию как валидный.
if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB" -Fc > "$DB_FILE.tmp"; then
mv "$DB_FILE.tmp" "$DB_FILE"
log "Готово: $(du -h "$DB_FILE" | cut -f1)"
else
rm -f "$DB_FILE.tmp"
log "ОШИБКА: pg_dump завершился с ошибкой." >&2
exit 1
fi
if [ -d "$UPLOADS_DIR" ]; then
log "Архив uploads '$UPLOADS_DIR' → $UPLOADS_FILE"
tar czf "$UPLOADS_FILE.tmp" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")" \
&& mv "$UPLOADS_FILE.tmp" "$UPLOADS_FILE"
log "Готово: $(du -h "$UPLOADS_FILE" | cut -f1)"
else
log "Каталог uploads '$UPLOADS_DIR' отсутствует — пропуск."
fi
log "Ротация: удаляю бэкапы старше $RETENTION_DAYS дн."
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'db-*.dump' -mtime +"$RETENTION_DAYS" -print -delete
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'uploads-*.tgz' -mtime +"$RETENTION_DAYS" -print -delete
log "Бэкап завершён."

View file

@ -1,14 +0,0 @@
[Unit]
Description=food-market: ежедневный бэкап (03:00)
Documentation=https://github.com/nurdotnet/food-market/blob/main/docs/backup-restore.md
[Timer]
# Каждый день в 03:00 локального времени сервера.
OnCalendar=*-*-* 03:00:00
# Догнать пропущенный запуск, если сервер был выключен в момент срабатывания.
Persistent=true
# Небольшой разброс — на случай нескольких таймеров одновременно.
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View file

@ -1,11 +0,0 @@
[Unit]
Description=Mirror docker base images into local 127.0.0.1:5001 registry
Requires=food-market-registry.service
After=food-market-registry.service docker.service
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-mirror-base-images.sh
StandardOutput=append:/var/log/food-market-mirror-base-images.log
StandardError=append:/var/log/food-market-mirror-base-images.log

View file

@ -1,11 +0,0 @@
[Unit]
Description=Refresh docker base image mirrors daily
[Timer]
OnBootSec=10min
OnUnitActiveSec=24h
Unit=food-market-mirror-base-images.service
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,27 +0,0 @@
services:
forgejo:
image: codeberg.org/forgejo/forgejo:7
container_name: food-market-forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: git.zat.kz
FORGEJO__server__ROOT_URL: https://git.zat.kz/
FORGEJO__server__SSH_DOMAIN: git.zat.kz
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__log__LEVEL: Info
volumes:
- /opt/food-market-data/forgejo/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # HTTP, fronted by nginx on git.zat.kz
- "2222:22" # SSH for git clone/push via ssh://git@git.zat.kz:2222/...

View file

@ -1,7 +0,0 @@
[Unit]
Description=Push Forgejo food-market into GitHub (backup)
[Service]
Type=oneshot
User=nns
ExecStart=/usr/local/bin/food-market-forgejo-mirror.sh

View file

@ -1,10 +0,0 @@
[Unit]
Description=Mirror Forgejo -> GitHub every 10 min
[Timer]
OnBootSec=3min
OnUnitActiveSec=10min
Unit=food-market-forgejo-mirror.service
[Install]
WantedBy=timers.target

View file

@ -1,15 +0,0 @@
[Unit]
Description=food-market Forgejo (primary git)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/home/nns/food-market/deploy/forgejo
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose stop
User=nns
[Install]
WantedBy=multi-user.target

View file

@ -1,40 +0,0 @@
#!/bin/bash
# Mirrors our Forgejo repo into GitHub. Best-effort: if the push fails (flaky
# KZ TCP to github.com), the next tick will retry.
set -euo pipefail
MIRROR_DIR="/opt/food-market-data/forgejo/mirror"
FORGEJO_URL="http://127.0.0.1:3000/nns/food-market.git"
GITHUB_URL="https://github.com/nurdotnet/food-market.git"
GITHUB_TOKEN_FILE="/etc/food-market/github-mirror-token" # 40-char PAT with repo scope
LOG_FILE="/var/log/food-market-forgejo-mirror.log"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG_FILE"; }
if [[ ! -f $GITHUB_TOKEN_FILE ]]; then
log "token file $GITHUB_TOKEN_FILE missing — skipping mirror push"
exit 0
fi
TOKEN=$(tr -d '\n' < "$GITHUB_TOKEN_FILE")
if [[ ! -d $MIRROR_DIR/objects ]]; then
log "bootstrap: cloning $FORGEJO_URL → $MIRROR_DIR"
rm -rf "$MIRROR_DIR"
git clone --mirror "$FORGEJO_URL" "$MIRROR_DIR" >> "$LOG_FILE" 2>&1
fi
cd "$MIRROR_DIR"
# Pull latest from Forgejo (source of truth).
if ! git remote update --prune >> "$LOG_FILE" 2>&1; then
log "forgejo fetch failed — aborting this tick"
exit 0
fi
# Push everything to GitHub, timeout generously (big pushes on flaky link).
GIT_HTTP_LOW_SPEED_LIMIT=1000 \
GIT_HTTP_LOW_SPEED_TIME=60 \
timeout 300 git push --prune "https://x-access-token:$TOKEN@github.com/nurdotnet/food-market.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' >> "$LOG_FILE" 2>&1 \
&& log "pushed to github ok" \
|| log "github push failed (exit=$?), will retry next tick"

View file

@ -1,22 +0,0 @@
server {
listen 80;
server_name git.zat.kz;
location /.well-known/acme-challenge/ { root /var/www/html; }
# Forgejo can serve large pushes; allow big request bodies.
client_max_body_size 512M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
}
# Note: run certbot --nginx -d git.zat.kz to issue a TLS cert — certbot will
# add a TLS server block and rewrite this one to 301->https.

View file

@ -1,129 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: генератор release-notes между двумя тэгами.
#
# Парсит `git log <from>..<to>`, группирует коммиты по prefix:
# feat: → ## Новые возможности
# fix: → ## Исправления
# perf: → ## Производительность
# docs: → ## Документация
# test: → ## Тесты (свёрнуто)
# chore/refactor/build: → ## Внутренние изменения (свёрнуто)
#
# Вывод — markdown, дополнительно сохраняет в:
# docs/release-notes/<to-tag>.md
# Используется при создании git-тега и в /whats-new.
#
# Usage:
# deploy/generate-release-notes.sh <from-tag> <to-tag> [--dry-run]
# deploy/generate-release-notes.sh v20260606.1 v20260607.3 > release.md
set -uo pipefail
FROM="${1:-}"
TO="${2:-HEAD}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$FROM" ]]; then
echo "Usage: $0 <from-tag> <to-tag> [--dry-run]" >&2
exit 2
fi
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# Валидация тэгов: должны существовать в git.
git rev-parse --verify "$FROM" >/dev/null 2>&1 || { echo "FAIL: тэг $FROM не найден"; exit 1; }
git rev-parse --verify "$TO" >/dev/null 2>&1 || { echo "FAIL: тэг $TO не найден"; exit 1; }
# Собираем коммиты в формате `prefix|subject|short-sha`.
# `grep -v Merge` исключает merge-коммиты.
COMMITS=$(git log "$FROM..$TO" --pretty=format:'%s|%h' --no-merges)
if [[ -z "$COMMITS" ]]; then
echo "Нет коммитов между $FROM и $TO"
exit 0
fi
# Группируем через awk. Префикс: feat/fix/perf/docs/test/chore/refactor/build/style.
RENDERED=$(echo "$COMMITS" | awk -F'|' '
function head(label) {
if (!printed[label]) {
print ""
print label
print ""
printed[label] = 1
}
}
{
subject = $1
sha = $2
type = "other"
text = subject
if (match(subject, /^(feat|fix|perf|docs|test|chore|refactor|build|style)(\([^)]+\))?:[[:space:]]*/, m)) {
type = m[1]
scope = m[2]
text = substr(subject, RLENGTH + 1)
}
line = "- " text " (`" sha "`)"
bucket[type] = bucket[type] line "\n"
}
END {
if (bucket["feat"]) { head("## ✨ Новые возможности"); printf "%s", bucket["feat"] }
if (bucket["fix"]) { head("## 🐛 Исправления"); printf "%s", bucket["fix"] }
if (bucket["perf"]) { head("## ⚡ Производительность"); printf "%s", bucket["perf"] }
if (bucket["docs"]) { head("## 📚 Документация"); printf "%s", bucket["docs"] }
if (bucket["test"]) {
print ""
print "<details><summary>🧪 Тесты</summary>"
print ""
printf "%s", bucket["test"]
print ""
print "</details>"
}
if (bucket["refactor"] || bucket["chore"] || bucket["build"] || bucket["style"]) {
print ""
print "<details><summary>🔧 Внутренние изменения</summary>"
print ""
for (k in bucket) if (k == "refactor" || k == "chore" || k == "build" || k == "style") printf "%s", bucket[k]
print ""
print "</details>"
}
if (bucket["other"]) {
print ""
print "<details><summary>📦 Прочее</summary>"
print ""
printf "%s", bucket["other"]
print ""
print "</details>"
}
}
')
DATE=$(date -u +%Y-%m-%d)
COUNT=$(echo "$COMMITS" | wc -l)
HEADER="# Release $TO
Дата: $DATE · Коммитов: $COUNT · С: $FROM"
OUTPUT="$HEADER
$RENDERED"
echo "$OUTPUT"
if [[ $DRY_RUN -eq 0 ]]; then
TARGET="$REPO_ROOT/docs/release-notes/$TO.md"
mkdir -p "$(dirname "$TARGET")"
echo "$OUTPUT" > "$TARGET"
echo "" >&2
echo "[saved] $TARGET" >&2
fi

View file

@ -1,37 +0,0 @@
# Grafana dashboards
Дашборды для food-market — импортируются в Grafana через **Settings → Data
sources → Add Prometheus** + **Dashboards → Import → Upload JSON**.
## Список
| Файл | UID | Назначение |
|---|---|---|
| `food-market.json` | `fm-baseline` | Sprint 13 baseline: HTTP, EF Core, бизнес-метрики |
| `quality-watchdog.json` | `fm-quality-watchdog` | Sprint 26: smoke success / p95 latency / multi-tenant violations / incidents + базовые prom-метрики |
## Зависимости
1. **Prometheus** scrap'ит `/metrics` API'я (см. `deploy/prometheus/prometheus.yml`).
2. **node_exporter** на машине, где живёт `~/quality-watchdog.sh`, с
флагом `--collector.textfile.directory=$HOME/.fm-watchdog/textfile`.
Watchdog туда пишет `quality_watchdog.prom` после каждого прогона.
3. **Alertmanager** для alert'ов из `deploy/prometheus/alerts.yml` —
см. `docs/RUNBOOK.md` для action'ов.
## Использование
```bash
# Local stack для теста дашбордов:
cd deploy
docker run -d -p 3000:3000 grafana/grafana
docker run -d -p 9090:9090 \
-v $PWD/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \
-v $PWD/prometheus/alerts.yml:/etc/prometheus/alerts.yml \
prom/prometheus
# Grafana: admin/admin → Add Prometheus DS → http://host.docker.internal:9090
# Import → upload grafana/dashboards/quality-watchdog.json
```
`${DS_PROMETHEUS}` template variable указывает на выбранный DS — Grafana
подставит ваш Prometheus при импорте.

View file

@ -1,399 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Sprint 13 baseline-dashboard для food-market.api. Объединяет prometheus-net (HTTP), EF Core (DB) и кастомные AppMetrics (бизнес).",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {"legend": false, "tooltip": false, "viz": false},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "never",
"spanNulls": false,
"stacking": {"group": "A", "mode": "none"},
"thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"id": 1,
"options": {
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "sum by (code) (rate(http_requests_received_total[1m]))",
"legendFormat": "{{code}}",
"refId": "A"
}
],
"title": "HTTP — RPS по статус-коду",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 0.5},
{"color": "red", "value": 2}
]
},
"unit": "s"
}
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"id": 2,
"options": {
"legend": {"calcs": ["last", "max"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))",
"legendFormat": "p95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "HTTP — latency p50/p95/p99",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false,
"stacking": {"group": "A", "mode": "normal"}
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "ops"
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"id": 3,
"options": {
"legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "sum by (type) (rate(food_market_documents_posted_total[1m]))",
"legendFormat": "{{type}}",
"refId": "A"
}
],
"title": "Бизнес — документы посчитаны (Post), per-type RPS",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 0.1},
{"color": "red", "value": 1}
]
},
"unit": "ops"
}
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"id": 4,
"options": {
"legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "sum by (type, reason) (rate(food_market_documents_error_total[1m]))",
"legendFormat": "{{type}} / {{reason}}",
"refId": "A"
}
],
"title": "Бизнес — ошибки проведения per-type / reason",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "spectrum", "scheme": "Blues"},
"custom": {
"hideFrom": {"legend": false, "tooltip": false, "viz": false},
"scaleDistribution": {"type": "linear"}
},
"mappings": [],
"unit": "s"
}
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 16},
"id": 5,
"options": {
"calculate": false,
"cellGap": 1,
"color": {"exponent": 0.5, "fill": "dark-orange", "mode": "scheme", "reverse": false, "scale": "exponential", "scheme": "Spectral", "steps": 64},
"exemplars": {"color": "rgba(255,0,255,0.7)"},
"filterValues": {"le": 1e-9},
"legend": {"show": true},
"rowsFrame": {"layout": "auto"},
"tooltip": {"show": true, "yHistogram": false},
"yAxis": {"axisPlacement": "left", "reverse": false, "unit": "s"}
},
"pluginVersion": "10.0.0",
"targets": [
{
"expr": "sum by (le) (rate(food_market_db_query_duration_seconds_bucket[1m]))",
"format": "heatmap",
"legendFormat": "{{le}}",
"refId": "A"
}
],
"title": "DB — длительность EF-запросов (heatmap)",
"type": "heatmap"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 5},
{"color": "red", "value": 20}
]
},
"unit": "percent"
}
},
"gridPos": {"h": 9, "w": 6, "x": 12, "y": 16},
"id": 6,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"expr": "100 * sum(rate(http_requests_received_total{code=~\"5..\"}[5m])) / sum(rate(http_requests_received_total[5m]))",
"refId": "A"
}
],
"title": "HTTP — % 5xx за 5 мин",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 10},
{"color": "red", "value": 30}
]
},
"unit": "percent"
}
},
"gridPos": {"h": 9, "w": 6, "x": 18, "y": 16},
"id": 7,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"expr": "100 * sum(rate(http_requests_received_total{code=~\"4..\"}[5m])) / sum(rate(http_requests_received_total[5m]))",
"refId": "A"
}
],
"title": "HTTP — % 4xx за 5 мин",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never"
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 25},
"id": 8,
"options": {
"legend": {"calcs": ["last", "max"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "process_resident_memory_bytes",
"legendFormat": "RSS",
"refId": "A"
},
{
"expr": "dotnet_total_memory_bytes",
"legendFormat": "Managed heap",
"refId": "B"
}
],
"title": "Процесс — память (RSS + managed)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never"
},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "ops"
}
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 25},
"id": 9,
"options": {
"legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"targets": [
{
"expr": "rate(dotnet_collection_count_total[1m])",
"legendFormat": "Gen {{generation}}",
"refId": "A"
}
],
"title": "GC — сборки в секунду (по поколениям)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["food-market", "api"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "food-market — api / db / business",
"uid": "food-market-api-baseline",
"version": 1,
"weekStart": ""
}

View file

@ -1,350 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Sprint 26: quality-watchdog dashboard. Метрики из ~/quality-watchdog.sh (textfile exporter, см. docs/observability.md) + базовые food-market.api метрики (/metrics).",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"type": "stat",
"title": "Smoke success ratio (7 дней)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 0.80},
{"color": "green", "value": 0.95}
]
},
"unit": "percentunit",
"min": 0,
"max": 1
}
},
"options": {
"graphMode": "area",
"colorMode": "value",
"justifyMode": "center",
"reduceOptions": {"calcs": ["lastNotNull"]}
},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_run_total{result=\"green\"}[7d])) / sum(increase(quality_watchdog_run_total[7d]))",
"legendFormat": "green ratio"
}
]
},
{
"id": 2,
"type": "stat",
"title": "Incidents (7 дней)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 1},
{"color": "red", "value": 3}
]
},
"unit": "short"
}
},
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_incidents_total[7d]))",
"legendFormat": "incidents"
}
]
},
{
"id": 3,
"type": "stat",
"title": "Multi-tenant violations (24h) — должно быть 0",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "red", "value": 1}
]
},
"unit": "short"
}
},
"options": {"colorMode": "value", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "sum(increase(quality_watchdog_step_failure_total{step=\"multi_tenant\"}[24h]))",
"legendFormat": "leaks"
}
]
},
{
"id": 4,
"type": "stat",
"title": "Текущий статус watchdog",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 0},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 0.5},
{"color": "green", "value": 1}
]
},
"unit": "short",
"mappings": [
{"options": {"0": {"text": "🔴 RED"}, "1": {"text": "🟢 GREEN"}}, "type": "value"}
]
}
},
"options": {"colorMode": "background", "reduceOptions": {"calcs": ["lastNotNull"]}},
"targets": [
{
"refId": "A",
"expr": "quality_watchdog_last_run_status",
"legendFormat": "status"
}
]
},
{
"id": 5,
"type": "timeseries",
"title": "p95 latency по endpoint (мс)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 5},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"stacking": {"mode": "none"}
},
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 400},
{"color": "red", "value": 800}
]
}
}
},
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "quality_watchdog_endpoint_p95_ms",
"legendFormat": "{{endpoint}}"
}
]
},
{
"id": 6,
"type": "timeseries",
"title": "Шаги watchdog — pass/fail во времени",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 5},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "bars",
"lineWidth": 1,
"fillOpacity": 60,
"stacking": {"mode": "normal"}
},
"unit": "short"
}
},
"options": {"legend": {"displayMode": "list", "placement": "right"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum by (step) (increase(quality_watchdog_step_failure_total[1h]))",
"legendFormat": "{{step}}"
}
]
},
{
"id": 7,
"type": "timeseries",
"title": "HTTP request rate (rps)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 14},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "reqps"
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum(rate(http_requests_received_total[5m])) by (code)",
"legendFormat": "code={{code}}"
}
]
},
{
"id": 8,
"type": "timeseries",
"title": "DB query duration p95 (food_market_db_query_duration_seconds)",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 14},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "orange", "value": 0.5},
{"color": "red", "value": 1.0}
]
}
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95 DB"
}
]
},
{
"id": 9,
"type": "timeseries",
"title": "Документы проведены / ошибки",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 22},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "ops"
}
},
"options": {"legend": {"displayMode": "table", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "sum(rate(food_market_documents_posted_total[5m])) by (type)",
"legendFormat": "posted {{type}}"
},
{
"refId": "B",
"expr": "sum(rate(food_market_documents_error_total[5m])) by (type)",
"legendFormat": "error {{type}}"
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Свободное место на диске",
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 22},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "bytes",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "orange", "value": 5368709120},
{"color": "green", "value": 10737418240}
]
}
}
},
"options": {"legend": {"displayMode": "list"}, "tooltip": {"mode": "multi"}},
"targets": [
{
"refId": "A",
"expr": "food_market_disk_free_bytes",
"legendFormat": "{{mount}}"
}
]
}
],
"refresh": "1m",
"schemaVersion": 38,
"tags": ["food-market", "quality-watchdog", "sprint26"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {"from": "now-7d", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "food-market — quality-watchdog",
"uid": "fm-quality-watchdog",
"version": 1
}

View file

@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Сборка «ALL MARKET — Проверка оборудования» (food-market.pos.hwcheck) под Windows x64.
# Self-contained + ReadyToRun (без установленного .NET). В zip кладём ярлык-bat и README.
# Обычная касса в этот zip НЕ входит — этим exe нельзя пользоваться как кассой.
set -euo pipefail
cd "$(dirname "$0")/.."
PROJ=src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
OUT=deploy/pos-dist
VER="$(date +%Y.%m.%d)"
RID=win-x64
DIR="$OUT/allmarket-hardware-check-$RID"
mkdir -p "$OUT"
rm -rf "$DIR"
echo "[hwcheck] publish $RID …"
dotnet publish "$PROJ" -c Release -r "$RID" --self-contained true \
-p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \
-o "$DIR" >/tmp/hwcheck-publish.log 2>&1 || { tail -30 /tmp/hwcheck-publish.log; exit 1; }
# Ярлык-bat (без ключей — тестовая печать выключена). Ярлык печати НЕ включаем:
# тестовую печать владелец включит отдельной сборкой/ключом по своему слову.
cat > "$DIR/Проверка оборудования.bat" <<'BAT'
@echo off
start "" "%~dp0foodmarket.Pos.HwCheck.exe"
BAT
cat > "$DIR/README.txt" <<'TXT'
ALL MARKET — Проверка оборудования
1. Распакуйте папку целиком в любое место (например, на Рабочий стол).
2. На время проверки закройте «Кассу МойСклад» (она держит COM-порт весов).
3. Запустите «Проверка оборудования.bat». Вход и пароль НЕ нужны.
4. Весы: нажмите «Найти весы», затем «Запустить» — вес обновляется вживую.
Для проверки с грузом положите гирю/товар и смотрите значение и «стабильно».
5. Принтеры и COM-порты показываются только для чтения.
На принтер НИЧЕГО не печатается (тестовая печать выключена).
6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» —
в буфер обмена. Пришлите отчёт — по нему настроим кассу.
На принтер ничего не печатается. Тестовую печать включает владелец отдельно.
TXT
( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" )
echo "[hwcheck] zip: $OUT/allmarket-hardware-check-$RID-$VER.zip"

View file

@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Миграция старой модели инвентаризации (Posted + InventoryAdjustment-движения)
# в модель МойСклада: связанные ПРОВЕДЁННЫЕ Оприходование/Списание. Движения не
# задваиваются (переметка), остатки остаются неизменными (проверяется в отчёте).
#
# ПОРЯДОК: 1) backup БД 2) dry-run (отчёт) 3) --apply (реально, под каждую орг).
#
# Запуск (стенд):
# TOKEN=<admin-token-организации> BASE=https://test.admin.food-market.kz \
# bash deploy/migrate-inventory-to-docs.sh # dry-run
# ... same ... --apply # применить
#
# Backup стенд-БД (на prod-vm, где живёт food-market-stage-postgres):
# docker exec food-market-stage-postgres pg_dump -U food_market food_market \
# | gzip > ~/stage-backup-inventory-$(date +%Y%m%d-%H%M%S).sql.gz
set -euo pipefail
BASE="${BASE:-https://test.admin.food-market.kz}"
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
if [[ -z "${TOKEN:-}" ]]; then
echo "✗ Нужен TOKEN (admin-токен организации). Миграция выполняется под текущую орг." >&2
echo " Для нескольких организаций — запустить с токеном каждой." >&2
exit 1
fi
if [[ "$APPLY" -eq 1 ]]; then
echo "⚠ ПЕРЕД --apply сделайте backup стенд-БД (см. шапку скрипта)."
echo " Миграция переметит движения inventory → enter/loss и создаст проведённые документы."
echo " Остатки по товарам остаются прежними (проверяется stockUnchanged в отчёте)."
fi
URL="$BASE/api/admin/inventory-migration/run?apply=$([[ $APPLY -eq 1 ]] && echo true || echo false)"
echo "→ POST $URL"
resp=$(curl -sk -m 60 -X POST "$URL" -H "Authorization: Bearer $TOKEN" -H "Content-Length: 0")
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
# Короткий итог
echo "$resp" | python3 - <<'PY' 2>/dev/null || true
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
print(f"\nИтог: applied={d.get('applied')} candidates={d.get('candidates')} "
f"migrated={d.get('migrated')} stockUnchanged={d.get('stockUnchanged')} "
f"stockDiffRows={d.get('stockDiffRows')}")
if d.get('applied') and not d.get('stockUnchanged'):
print("✗ ВНИМАНИЕ: остатки изменились — проверьте вручную и восстановите из backup!")
PY

View file

@ -1,48 +0,0 @@
#!/bin/bash
# Pulls all external base images the food-market builds depend on, then retags
# them into the local registry at 127.0.0.1:5001 under the "mirror/" prefix.
#
# Why: outbound to docker.io / mcr.microsoft.com flaps on KZ network. Once
# mirrored, Dockerfiles and docker-compose reference the local copy and builds
# no longer need the internet at all.
#
# Idempotent — safe to run as often as you want. Scheduled daily via
# food-market-mirror-base-images.timer.
set -euo pipefail
REGISTRY=127.0.0.1:5001
LOG_PREFIX=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# image_ref → local name under mirror/
IMAGES=(
"node:20-alpine|mirror/node:20-alpine"
"nginx:1.27-alpine|mirror/nginx:1.27-alpine"
"postgres:16-alpine|mirror/postgres:16-alpine"
"mcr.microsoft.com/dotnet/sdk:8.0|mirror/dotnet-sdk:8.0"
"mcr.microsoft.com/dotnet/aspnet:8.0|mirror/dotnet-aspnet:8.0"
)
failures=0
for pair in "${IMAGES[@]}"; do
src="${pair%|*}"
dst="${pair#*|}"
echo "$LOG_PREFIX pulling $src"
if ! docker pull "$src"; then
echo "$LOG_PREFIX FAILED: pull $src"
failures=$((failures + 1))
continue
fi
docker tag "$src" "$REGISTRY/$dst"
if ! docker push "$REGISTRY/$dst"; then
echo "$LOG_PREFIX FAILED: push $REGISTRY/$dst"
failures=$((failures + 1))
continue
fi
echo "$LOG_PREFIX ok $src -> $REGISTRY/$dst"
done
if [[ $failures -gt 0 ]]; then
echo "$LOG_PREFIX done, $failures failed — registry still has old mirrored copies"
exit 1
fi
echo "$LOG_PREFIX done, all mirrors fresh"

View file

@ -3,40 +3,6 @@ server {
root /usr/share/nginx/html;
index index.html;
# Sprint 13 — security-заголовки (для SPA HTML; для API те же выставляются
# уже SecurityHeadersMiddleware'ом на api-side). add_header с always
# обеспечивает применение даже на 4xx/5xx (без always — только на 2xx/3xx).
# CSP синхронен с SecurityHeadersOptions.DefaultCsp.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
# Sprint 28: HSTS. Brower honors HSTS only on HTTPS responses, поэтому
# безопасно добавлять unconditionally — если клиент пришёл по HTTP,
# header игнорируется. Без includeSubDomains и без preload — это
# pre-emptive consent: можно безопасно убрать. Когда production stack
# устаканится и admin.food-market.kz будет подан в hstspreload.org,
# увеличить max-age до 31536000 + добавить preload и includeSubDomains.
add_header Strict-Transport-Security "max-age=2592000" always;
# Long-running admin imports (MoySklad etc.) read from upstream for tens of
# minutes. Bump timeouts only on that path so normal API stays snappy.
location /api/admin/import/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60m;
proxy_send_timeout 60m;
proxy_request_buffering off;
proxy_buffering off;
}
# API reverse-proxy — upstream name "api" resolves in the compose network.
location /api/ {
proxy_pass http://api:8080;
@ -55,110 +21,12 @@ server {
proxy_set_header Host $host;
}
# SignalR хаб для live-уведомлений (см. NotificationsHub).
# WebSocket требует upgrade-хедеры и большой read_timeout (иначе nginx
# будет рвать idle-коннекшен каждые 60 сек). access_token приходит как
# query (?access_token=...), Authorization-хедер middleware на API его
# перекладывает в нужный вид до UseAuthentication.
location /hubs/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # 24h — webSocket долгоживущий
proxy_send_timeout 86400;
proxy_buffering off;
}
location /health {
proxy_pass http://api:8080;
}
# Prometheus метрики API. Без этого блока запрос ловится SPA fallback'ом и
# возвращает index.html (947 байт) вместо exposition format. На prod-домене
# имеет смысл закрыть IP-фильтром (allow 192.168.0.0/16; deny all;), на
# stage оставляем открытым — за gateway nginx уже есть auth/TLS-обвязка.
location = /metrics {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Swagger UI + OpenAPI-doc. На контейнере api подключается только когда
# IncludeSwagger=true (env-флаг, см. Program.cs). На prod-домене флаг не
# выставляем, /swagger вернёт 404 от api — это ожидаемо.
location /swagger/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /swagger {
return 301 /swagger/;
}
# Sprint 13: Hangfire Dashboard — внутренний инструмент мониторинга
# фоновых джобов. Доступ только SuperAdmin'у (см. SuperAdminHangfireFilter
# в API). Без этой location'и /hangfire ловился бы SPA-fallback'ом и
# возвращал index.html — что выглядит как «всё ок», но дашборда нет.
location /hangfire {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Статика изображений товаров — api раздаёт /uploads/... из volume.
location /uploads/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# PWA: SW и manifest должны отдаваться с правильным content-type и без
# кеша на самом ответе (внутри SW свой versioned cache). Иначе старый
# SW залипает на клиенте и не подхватывает обновления.
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires off;
try_files /sw.js =404;
}
location = /manifest.webmanifest {
types { } default_type application/manifest+json;
# no-cache: при ребрендинге manifest (name/иконки) должен обновляться сразу.
add_header Cache-Control "no-cache, must-revalidate";
try_files /manifest.webmanifest =404;
}
location = /offline.html {
try_files /offline.html =404;
}
# Хэшированные ассеты Vite (имя содержит контент-хэш) — иммутабельно на год.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Нехэшированные бренд-ассеты (лого/иконки/favicon) — no-cache + ETag, чтобы
# смена логотипа не упиралась в кэш браузера (ETag даёт 304, если не менялось).
location ~* ^/(logo|logo-light|logo-bg|logo-fg|favicon|favicon-32|apple-touch-icon|icon-192|icon-512)\.(svg|png)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
# index.html (SPA-оболочка) — no-cache: всегда свежая, подтягивает новые
# хэшированные бандлы и версии бренд-ассетов. SPA fallback для всех маршрутов.
# SPA fallback — all other routes return index.html
location / {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri $uri/ /index.html;
}
}

View file

@ -1,32 +0,0 @@
# Шаблон nginx-конфига для публичного сайта food-market.public.
# НЕ ПРИМЕНЯТЬ ПОКА ЮЗЕР НЕ ВЫБЕРЕТ ДОМЕН.
#
# Сборка контейнера: docker compose --build food-market-public (см.
# deploy/docker-compose.yml; контейнер слушает на 127.0.0.1:8082).
#
# Использование (когда домен решится):
# 1. Заменить SERVER_NAME ниже на финальный домен.
# 2. Скопировать в /etc/nginx/conf.d/food-market-public.conf.
# 3. sudo certbot --nginx -d <SERVER_NAME>.
# 4. sudo nginx -t && sudo systemctl reload nginx.
#
# Архитектура после переезда (план):
# <PUBLIC_DOMAIN> → этот блок (публичный Astro)
# app.<PUBLIC_DOMAIN> → существующий блок food-market-stage.conf (админка)
# API остаётся на app.* под /api/*.
server {
server_name SERVER_NAME;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 80;
}

View file

@ -1,54 +0,0 @@
# Установка кассы ALL MARKET на Windows (быстрый первый запуск).
#
# Что делает:
# 1. Распаковывает сборку кассы в целевую папку (по умолчанию %LOCALAPPDATA%\all-market-pos).
# 2. Добавляет папку в исключения Microsoft Defender (если запущено от админа) —
# убирает скан свежего неподписанного exe при первом запуске (замер: холодный
# старт 10 c → 4.5 c). Без прав админа — печатает инструкцию, установка продолжается.
# 3. «Прогревает» кассу одним фоновым запуском (--smoke) — наполняет кэши ОС/рантайма,
# последующие запуски из этой папки стартуют за ~0.6 c.
# 4. Создаёт ярлык на рабочем столе.
#
# Запуск (ПКМ → «Запуск от имени администратора» для шага 2):
# powershell -ExecutionPolicy Bypass -File pos-install.ps1 -Zip .\pos-win.zip
param(
[string]$Zip = ".\pos-win.zip",
[string]$Dest = "$env:LOCALAPPDATA\all-market-pos"
)
$ErrorActionPreference = 'Stop'
Write-Host "→ Распаковка в $Dest ..."
if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest }
Expand-Archive -Path $Zip -DestinationPath $Dest -Force
$exe = Join-Path $Dest "foodmarket.Pos.exe"
if (-not (Test-Path $exe)) { throw "Не найден $exe — проверьте архив." }
Write-Host "→ Исключение Microsoft Defender для папки кассы ..."
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($isAdmin) {
try {
Add-MpPreference -ExclusionPath $Dest -ErrorAction Stop
Write-Host " ✓ Папка добавлена в исключения Defender (первый запуск быстрее)."
} catch {
Write-Warning " Не удалось добавить исключение: $($_.Exception.Message)"
}
} else {
Write-Warning " Нет прав администратора — исключение Defender пропущено."
Write-Host " Чтобы ускорить первый запуск, выполните один раз от администратора:"
Write-Host " Add-MpPreference -ExclusionPath `"$Dest`""
}
Write-Host "→ Прогрев (один фоновый запуск) ..."
try { & $exe --smoke | Out-Null } catch { Write-Warning " Прогрев не критичен: $($_.Exception.Message)" }
Write-Host "→ Ярлык на рабочем столе ..."
try {
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) "ALL MARKET Касса.lnk"
$ws = New-Object -ComObject WScript.Shell
$s = $ws.CreateShortcut($lnk); $s.TargetPath = $exe; $s.WorkingDirectory = $Dest; $s.Save()
Write-Host " ✓ $lnk"
} catch { Write-Warning " Ярлык не создан: $($_.Exception.Message)" }
Write-Host "`nГотово. Касса запускается из локальной базы мгновенно; каталог синхронизируется в фоне."
Write-Host "Подпись exe (устранит предупреждения SmartScreen/Defender без исключения) — см. docs/pos.md."

View file

@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Сборка кассы food-market.pos (Avalonia) под Windows x64 и Linux x64.
# Self-contained + ReadyToRun (быстрый старт, без установленного .NET на кассе).
# Trimming выключен по умолчанию (Avalonia/XAML использует рефлексию; AOT/trim —
# отдельная задача, см. docs/pos.md). Результат — zip/tar.gz в deploy/pos-dist/.
#
# Запуск: deploy/pos-publish.sh [win|linux|all] (по умолчанию all)
set -euo pipefail
cd "$(dirname "$0")/.."
PROJ=src/food-market.pos/food-market.pos.csproj
OUT=deploy/pos-dist
VER="$(date +%Y.%m.%d)"
mkdir -p "$OUT"
TARGET="${1:-all}"
publish() {
local rid="$1" label="$2"
echo "[pos-publish] $label ($rid) …"
local dir="$OUT/food-market-pos-$rid"
rm -rf "$dir"
dotnet publish "$PROJ" -c Release -r "$rid" --self-contained true \
-p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \
-o "$dir" >/tmp/pos-publish-$rid.log 2>&1 || { tail -25 /tmp/pos-publish-$rid.log; exit 1; }
# версия для автообновления кассой
echo "$VER" > "$dir/VERSION.txt"
local size; size=$(du -sh "$dir" | cut -f1)
echo "[pos-publish] $label готово: $dir ($size)"
}
# AppImage для Linux (переносимый single-file). Требует appimagetool в PATH;
# если его нет — пропускаем (tar.gz остаётся основным артефактом Linux).
build_appimage() {
local rid="$1" ver="$2"
local src="$OUT/food-market-pos-$rid"
if ! command -v appimagetool >/dev/null 2>&1; then
echo "[pos-publish] appimagetool не найден — AppImage пропущен (есть tar.gz)."
return 0
fi
local appdir="$OUT/FoodMarketPos.AppDir"
rm -rf "$appdir"; mkdir -p "$appdir/usr/bin"
cp -r "$src/." "$appdir/usr/bin/"
cat > "$appdir/foodmarket-pos.desktop" <<DESK
[Desktop Entry]
Name=ALL MARKET Касса
Exec=foodmarket.Pos
Icon=foodmarket-pos
Type=Application
Categories=Office;
DESK
# Фирменная иконка ALL MARKET (зелёный квадрат 512). Фоллбэк — 1x1 PNG.
if [ -f src/food-market.pos/Assets/allmarket-icon-512.png ]; then
cp src/food-market.pos/Assets/allmarket-icon-512.png "$appdir/foodmarket-pos.png"
else
printf '\x89PNG\r\n\x1a\n' > "$appdir/foodmarket-pos.png"
fi
cat > "$appdir/AppRun" <<'RUN'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/usr/bin/foodmarket.Pos" "$@"
RUN
chmod +x "$appdir/AppRun" "$appdir/usr/bin/foodmarket.Pos"
( cd "$OUT" && ARCH=x86_64 appimagetool "FoodMarketPos.AppDir" "food-market-pos-$ver-x86_64.AppImage" >/tmp/pos-appimage.log 2>&1 ) \
&& echo "[pos-publish] AppImage: $OUT/food-market-pos-$ver-x86_64.AppImage" \
|| { echo "[pos-publish] AppImage не собрался (см. /tmp/pos-appimage.log) — tar.gz остаётся."; }
rm -rf "$appdir"
}
if [[ "$TARGET" == "win" || "$TARGET" == "all" ]]; then
publish win-x64 "Windows x64"
( cd "$OUT" && zip -qr "food-market-pos-win-x64-$VER.zip" food-market-pos-win-x64 )
echo "[pos-publish] zip: $OUT/food-market-pos-win-x64-$VER.zip"
fi
if [[ "$TARGET" == "linux" || "$TARGET" == "all" ]]; then
publish linux-x64 "Linux x64"
( cd "$OUT" && tar czf "food-market-pos-linux-x64-$VER.tar.gz" food-market-pos-linux-x64 )
echo "[pos-publish] tar: $OUT/food-market-pos-linux-x64-$VER.tar.gz"
build_appimage linux-x64 "$VER"
fi
echo "[pos-publish] версия $VER собрана."

View file

@ -1,52 +0,0 @@
param([string]$Url, [string]$User, [string]$Pass)
$ErrorActionPreference = 'Continue'
function TMeasure($block) { (Measure-Command $block).TotalMilliseconds }
Write-Output "=== Windows POS startup measurement ==="
Write-Output ("CPU: " + (Get-CimInstance Win32_Processor).Name)
$defAV = (Get-CimInstance -Namespace root/SecurityCenter2 -Class AntiVirusProduct -ErrorAction SilentlyContinue | Select-Object -First 1 -Expand displayName)
Write-Output ("AV: " + $defAV)
$rt = (Get-MpPreference -ErrorAction SilentlyContinue).DisableRealtimeMonitoring
Write-Output ("Defender realtime disabled: " + $rt)
$zip = "C:\Users\tester\pos-win.zip"
function FreshUnpack($dir) {
if (Test-Path $dir) { Remove-Item -Recurse -Force $dir }
Expand-Archive -Path $zip -DestinationPath $dir -Force
}
# 1. COLD: свежая распаковка, первый запуск exe (Defender сканирует неподписанный exe+dll)
FreshUnpack "C:\pos-cold"
$exe = "C:\pos-cold\foodmarket.Pos.exe"
$cold = TMeasure { & $exe --smoke | Out-Null }
Write-Output ("COLD start (--smoke, Defender on, no exclusion): {0:N0} ms" -f $cold)
# 2. WARM: повторные запуски (Defender закэшировал)
$warm = @()
for ($i=0; $i -lt 3; $i++) { $warm += (TMeasure { & $exe --smoke | Out-Null }) }
Write-Output ("WARM start x3 (--smoke): " + (($warm | ForEach-Object { '{0:N0}' -f $_ }) -join ', ') + " ms")
# 3. Defender exclusion (нужны права админа) + свежая распаковка + cold
$exclOk = $true
try { Add-MpPreference -ExclusionPath "C:\pos-excl" -ErrorAction Stop } catch { $exclOk = $false; Write-Output ("Defender exclusion FAILED (нужен админ): " + $_.Exception.Message) }
if ($exclOk) {
FreshUnpack "C:\pos-excl"
$exe2 = "C:\pos-excl\foodmarket.Pos.exe"
$coldExcl = TMeasure { & $exe2 --smoke | Out-Null }
Write-Output ("COLD start WITH Defender exclusion: {0:N0} ms" -f $coldExcl)
}
# 4. Bench каталога (сеть+запись+тёплый+дельта) — нормально и на 1 ядре
if ($Url -and $User -and $Pass) {
$out = "C:\pos-cold\bench.txt"
Write-Output "--- bench NORMAL (all cores) ---"
& $exe --bench $Url $User $Pass 2>&1 | Where-Object { $_ -match 'bench' } | ForEach-Object { Write-Output $_ }
Write-Output "--- bench 1 CPU core (Celeron N4120 прокси) ---"
$psi = Start-Process -FilePath $exe -ArgumentList @('--bench',$Url,$User,$Pass) -PassThru -RedirectStandardOutput $out -NoNewWindow
Start-Sleep -Milliseconds 300
try { $psi.ProcessorAffinity = [IntPtr]1; $psi.PriorityClass = 'BelowNormal' } catch { Write-Output ("affinity set failed: " + $_.Exception.Message) }
$psi.WaitForExit()
Get-Content $out | Where-Object { $_ -match 'bench' } | ForEach-Object { Write-Output $_ }
}
Write-Output "=== done ==="

View file

@ -1,239 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: post-deploy smoke на проде.
#
# Запускается СРАЗУ после prod-deploy.sh. 10 ключевых сценариев на
# https://admin.food-market.kz через временные тестовые credentials
# (создаются через signup → удаляются в конце).
#
# Каждый шаг — отдельный pass/fail. Итог отправляется Telegram'ом
# (если задан FM_TG_TOKEN+FM_TG_CHAT). Exit code = кол-во провалов.
#
# Защита от мусора: после прогона создаваемая org остаётся в БД (delete
# через API ещё не реализовано) — но email содержит метку `smoke-` и
# timestamp, поэтому видно по логам/audit что это.
#
# Usage:
# deploy/post-deploy-smoke.sh [--dry-run] [--url https://admin.food-market.kz]
set -uo pipefail
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--url) PROD_URL="$2"; shift 2 ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) shift ;;
esac
done
log() { echo "[$(date -Iseconds)] $*"; }
notify_telegram() {
local msg="$1"
local tok="${FM_TG_TOKEN:-}"; local chat="${FM_TG_CHAT:-}"
if [[ -z "$tok" || -z "$chat" ]]; then
log "(Telegram disabled — no FM_TG_TOKEN/CHAT)"; return
fi
curl -sS -X POST "https://api.telegram.org/bot$tok/sendMessage" \
--data-urlencode "chat_id=$chat" \
--data-urlencode "text=$msg" > /dev/null || true
}
if [[ $DRY_RUN -eq 1 ]]; then
log "DRY-RUN: показал бы сценарии без запуска API-вызовов"
for s in "signup" "login" "/api/me" "list-products" "create-product" \
"list-counterparties" "list-stores" "list-stock" "delete-product" "logout"; do
log " → $s"
done
exit 0
fi
PASS=0
FAIL=0
FAILED_STEPS=()
step() {
local name="$1" status="$2" detail="$3"
if [[ "$status" == "OK" ]]; then
log "[✓] $name — $detail"
((PASS+=1))
else
log "[✗] $name — $detail"
FAILED_STEPS+=("$name: $detail")
((FAIL+=1))
fi
}
TS=$(date +%s)
EMAIL="smoke-${TS}@food-market.local"
PASS_TEST='Smoke12345!'
ORG_NAME="SmokeOrg-${TS}"
# ── 1. signup ────────────────────────────────────────────────────────
RESP=$(curl -fsS -X POST -H "Content-Type: application/json" \
-d "{\"organizationName\":\"$ORG_NAME\",\"email\":\"$EMAIL\",\"password\":\"$PASS_TEST\",\"phone\":\"+77001234567\"}" \
"$PROD_URL/api/auth/signup" 2>/dev/null || echo "")
if echo "$RESP" | grep -q '"organizationId"\|"id"'; then
step "01-signup" "OK" "$EMAIL"
else
step "01-signup" "FAIL" "resp: ${RESP:-<empty>}"
fi
# ── 2. login ─────────────────────────────────────────────────────────
TOK=$(curl -fsS -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "username=$EMAIL" \
--data-urlencode "password=$PASS_TEST" \
--data-urlencode "client_id=food-market-web" \
--data-urlencode "scope=openid profile email roles api offline_access" \
"$PROD_URL/connect/token" 2>/dev/null \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || echo "")
if [[ -n "$TOK" ]]; then
step "02-login" "OK" "token=${TOK:0:24}…"
else
step "02-login" "FAIL" "no access_token"
# Без токена остальное не запустить — сообщаем и выходим.
notify_telegram "🚨 post-deploy-smoke FAIL: login failed на $PROD_URL"
exit 1
fi
auth() { curl -fsS -H "Authorization: Bearer $TOK" "$@"; }
# Извлечь поле .items[0].id из JSON через python (надёжнее grep'a — JSON
# может содержать другие "id":"..." (organizationId, parentId etc.))
first_item_id() { python3 -c 'import sys,json; d=json.load(sys.stdin); print((d.get("items") or [{}])[0].get("id",""))' 2>/dev/null || true; }
# Поиск первого элемента items[] с условием key=value.
find_item_id() {
python3 -c "
import sys,json
d=json.load(sys.stdin)
key,val='$1','$2'
for it in (d.get('items') or []):
if it.get(key) == True if val=='true' else it.get(key) == val:
print(it.get('id',''))
break
" 2>/dev/null || true
}
# ── 3. /api/me ───────────────────────────────────────────────────────
ME=$(auth "$PROD_URL/api/me" 2>/dev/null || echo "")
if echo "$ME" | grep -q "\"email\":\"$EMAIL\""; then
step "03-me" "OK" "$EMAIL"
else
step "03-me" "FAIL" "resp: ${ME:0:120}"
fi
# ── 4. list products ─────────────────────────────────────────────────
PRODS=$(auth "$PROD_URL/api/catalog/products?pageSize=10" 2>/dev/null || echo "")
if echo "$PRODS" | grep -q '"total"'; then
step "04-list-products" "OK" "$(echo "$PRODS" | grep -oE '"total":[0-9]+' | head -1)"
else
step "04-list-products" "FAIL" "${PRODS:0:120}"
fi
# ── 5. create product ────────────────────────────────────────────────
ROOT_ID=$(auth "$PROD_URL/api/catalog/product-groups" 2>/dev/null | first_item_id)
UNIT_ID=$(auth "$PROD_URL/api/catalog/units-of-measure" 2>/dev/null | first_item_id)
PT_ID=$(auth "$PROD_URL/api/catalog/price-types" 2>/dev/null | first_item_id)
# Currencies endpoint иногда возвращает массив напрямую — python обработает оба.
CURS_RAW=$(auth "$PROD_URL/api/catalog/currencies" 2>/dev/null)
CUR_ID=$(echo "$CURS_RAW" | python3 -c 'import sys,json
d=json.load(sys.stdin)
items=d if isinstance(d,list) else (d.get("items") or [])
print((items or [{}])[0].get("id",""))' 2>/dev/null || true)
if [[ -z "$ROOT_ID" || -z "$UNIT_ID" || -z "$PT_ID" || -z "$CUR_ID" ]]; then
step "05-create-product" "FAIL" "missing refs: root=$ROOT_ID unit=$UNIT_ID pt=$PT_ID cur=$CUR_ID"
else
BC="SMOKE-$TS"
PROD=$(auth -X POST -H "Content-Type: application/json" \
-d "{\"name\":\"smoke-product-$TS\",\"unitOfMeasureId\":\"$UNIT_ID\",\"productGroupId\":\"$ROOT_ID\",\"vat\":0,\"vatEnabled\":true,\"barcodes\":[{\"code\":\"$BC\",\"type\":0,\"isPrimary\":true}],\"prices\":[{\"priceTypeId\":\"$PT_ID\",\"amount\":100,\"currencyId\":\"$CUR_ID\"}]}" \
"$PROD_URL/api/catalog/products" 2>/dev/null || echo "")
PID=$(echo "$PROD" | grep -oE '"id":"[a-f0-9-]+"' | head -1 | cut -d'"' -f4)
if [[ -n "$PID" ]]; then
step "05-create-product" "OK" "$PID"
else
step "05-create-product" "FAIL" "${PROD:0:120}"
fi
fi
# ── 6. list counterparties ──────────────────────────────────────────
CP=$(auth "$PROD_URL/api/catalog/counterparties?pageSize=10" 2>/dev/null || echo "")
if echo "$CP" | grep -q '"total"'; then
step "06-list-counterparties" "OK" "$(echo "$CP" | grep -oE '"total":[0-9]+' | head -1)"
else
step "06-list-counterparties" "FAIL" "${CP:0:120}"
fi
# ── 7. list stores ──────────────────────────────────────────────────
STR=$(auth "$PROD_URL/api/catalog/stores" 2>/dev/null || echo "")
if echo "$STR" | grep -q '"total"'; then
step "07-list-stores" "OK" "$(echo "$STR" | grep -oE '"total":[0-9]+' | head -1)"
else
step "07-list-stores" "FAIL" "${STR:0:120}"
fi
# ── 8. list stock ───────────────────────────────────────────────────
STK=$(auth "$PROD_URL/api/inventory/stock?pageSize=10" 2>/dev/null || echo "")
if echo "$STK" | grep -q '"total"'; then
step "08-list-stock" "OK" "$(echo "$STK" | grep -oE '"total":[0-9]+' | head -1)"
else
step "08-list-stock" "FAIL" "${STK:0:120}"
fi
# ── 9. delete product ───────────────────────────────────────────────
if [[ -n "${PID:-}" ]]; then
DEL=$(auth -X DELETE -o /dev/null -w "%{http_code}" "$PROD_URL/api/catalog/products/$PID" 2>/dev/null || echo "")
if [[ "$DEL" == "204" ]]; then
step "09-delete-product" "OK" "204"
else
step "09-delete-product" "FAIL" "code=$DEL"
fi
else
step "09-delete-product" "FAIL" "skipped (no PID)"
fi
# ── 10. session logout (через /api/me/sessions) ─────────────────────
# OpenIddict /connect/revocation в этой конфигурации не включён, поэтому
# logout = удаление активной сессии через /api/me/sessions/{id} либо
# (если нет sessions API) — проверяем что /api/me ещё работает (sanity).
# Берём первый sessionId юзера и пробуем DELETE; если 401/404 — fallback
# на простую проверку valid-token.
SESS=$(auth "$PROD_URL/api/me/sessions" 2>/dev/null || echo "")
SID=$(echo "$SESS" | python3 -c 'import sys,json
try:
d=json.load(sys.stdin)
arr=d if isinstance(d,list) else (d.get("items") or d.get("sessions") or [])
print((arr or [{}])[0].get("id",""))
except: print("")' 2>/dev/null || true)
if [[ -n "$SID" ]]; then
DEL=$(auth -X DELETE -o /dev/null -w "%{http_code}" "$PROD_URL/api/me/sessions/$SID" 2>/dev/null || echo "")
if [[ "$DEL" == "204" || "$DEL" == "200" ]]; then
step "10-logout-session" "OK" "session $SID revoked"
else
step "10-logout-session" "FAIL" "DELETE code=$DEL"
fi
else
# Fallback: просто sanity-check что токен ещё действителен (full flow OK).
PING=$(auth -o /dev/null -w "%{http_code}" "$PROD_URL/api/me" 2>/dev/null || echo "")
if [[ "$PING" == "200" ]]; then
step "10-token-valid" "OK" "token still alive (no session API)"
else
step "10-token-valid" "FAIL" "code=$PING"
fi
fi
# ── Итог + notify ────────────────────────────────────────────────────
log
log "==> $PASS passed, $FAIL failed"
if (( FAIL > 0 )); then
MSG="🚨 post-deploy-smoke FAIL ($FAIL/10) на $PROD_URL: $(IFS=,; echo "${FAILED_STEPS[*]}")"
notify_telegram "$MSG"
exit "$FAIL"
fi
notify_telegram "✅ post-deploy-smoke OK (10/10) на $PROD_URL"
exit 0

View file

@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Офсайт-копия прод-бэкапов на ДРУГОЙ хост (dev-vm 192.168.1.193). Запускается
# НА dev-vm по таймеру: тянет последний daily-дамп прода (prod-vm) сюда, хранит 30 дн.
# Направление dev→prod (рабочий SSH). Это «другая VM» для DR.
set -uo pipefail
PROD_HOST="${PROD_HOST:-nns@192.168.1.190}"
PROD_BACKUP_DIR="${PROD_BACKUP_DIR:-/opt/food-market-data/backups/daily}"
DEST="${FM_OFFSITE_LOCAL:-$HOME/fm-prod-backups}"
RET_DAYS="${FM_OFFSITE_RETENTION_DAYS:-30}"
mkdir -p "$DEST"
latest=$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "ls -t $PROD_BACKUP_DIR/db-*.dump 2>/dev/null | head -1" 2>/dev/null)
[ -n "$latest" ] || { echo "[offsite] нет daily-дампов на проде"; exit 1; }
base=$(basename "$latest")
if [ -f "$DEST/$base" ]; then echo "[offsite] уже есть: $base"; else
scp -o StrictHostKeyChecking=no "$PROD_HOST:$latest" "$DEST/" && echo "[offsite] скопирован $base → $DEST"
fi
find "$DEST" -maxdepth 1 -name 'db-*.dump' -mtime +"$RET_DAYS" -print -delete
echo "[offsite] готово ($(ls "$DEST"/db-*.dump 2>/dev/null | wc -l) копий)"

View file

@ -1,78 +0,0 @@
#!/usr/bin/env bash
#
# ПРОД food-market: резервная копия БД (pg_dump -Fc) с тирами и офсайт-копией.
#
# Тиры (FM_BACKUP_TIER):
# hourly — каждый час, хранение 48 ч → $FM_BACKUP_DIR/hourly
# daily — ежедневно, хранение 30 дн → $FM_BACKUP_DIR/daily (+ офсайт-копия)
#
# Офсайт: daily-дамп копируется на другой хост/диск (FM_OFFSITE_DEST, scp-назначение
# вида user@host:/path ИЛИ локальный путь на другом диске). Копия ротационно
# хранится FM_OFFSITE_RETENTION_DAYS (по умолчанию 30).
#
# Контейнер БД — food-market-prod-postgres (НЕ shared-postgres с чужими базами!).
set -euo pipefail
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
BASE_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
UPLOADS_DIR="${FM_UPLOADS_DIR:-/opt/food-market-data/uploads}"
TIER="${FM_BACKUP_TIER:-daily}"
OFFSITE_DEST="${FM_OFFSITE_DEST:-}" # напр. nns@192.168.1.193:/home/nns/fm-prod-backups
OFFSITE_RETENTION="${FM_OFFSITE_RETENTION_DAYS:-30}"
case "$TIER" in
hourly) DIR="$BASE_DIR/hourly"; RET_MIN=2880 ;; # 48 ч = 2880 мин
daily) DIR="$BASE_DIR/daily"; RET_MIN=43200 ;; # 30 дн = 43200 мин
*) echo "FM_BACKUP_TIER должен быть hourly|daily (дано: $TIER)" >&2; exit 2 ;;
esac
TS="$(date +%Y%m%d-%H%M%S)"
DB_FILE="$DIR/db-$TS.dump"
log() { echo "[$(date -Is)] [$TIER] $*"; }
mkdir -p "$DIR"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
log "ОШИБКА: контейнер '$CONTAINER' не запущен — бэкап невозможен." >&2
exit 1
fi
log "Дамп '$DB' → $DB_FILE"
if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB" -Fc > "$DB_FILE.tmp"; then
mv "$DB_FILE.tmp" "$DB_FILE"
log "Готово: $(du -h "$DB_FILE" | cut -f1)"
else
rm -f "$DB_FILE.tmp"; log "ОШИБКА: pg_dump завершился с ошибкой." >&2; exit 1
fi
# uploads архивируем только в daily (меняются реже, чем БД).
if [ "$TIER" = "daily" ] && [ -d "$UPLOADS_DIR" ]; then
UP="$DIR/uploads-$TS.tgz"
tar czf "$UP.tmp" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")" && mv "$UP.tmp" "$UP"
log "uploads → $UP ($(du -h "$UP" | cut -f1))"
fi
# Офсайт-копия (только daily).
if [ "$TIER" = "daily" ] && [ -n "$OFFSITE_DEST" ]; then
if [[ "$OFFSITE_DEST" == *:* ]]; then
# удалённый scp-назначение
host="${OFFSITE_DEST%%:*}"; path="${OFFSITE_DEST#*:}"
ssh -o StrictHostKeyChecking=no "$host" "mkdir -p '$path'" 2>/dev/null || true
if scp -o StrictHostKeyChecking=no "$DB_FILE" "$OFFSITE_DEST/" 2>/dev/null; then
log "офсайт-копия → $OFFSITE_DEST"
ssh -o StrictHostKeyChecking=no "$host" "find '$path' -maxdepth 1 -name 'db-*.dump' -mtime +$OFFSITE_RETENTION -delete" 2>/dev/null || true
else
log "ВНИМАНИЕ: офсайт-копия не удалась (scp $OFFSITE_DEST)" >&2
fi
else
mkdir -p "$OFFSITE_DEST"
cp "$DB_FILE" "$OFFSITE_DEST/" && log "офсайт-копия → $OFFSITE_DEST"
find "$OFFSITE_DEST" -maxdepth 1 -name 'db-*.dump' -mtime +"$OFFSITE_RETENTION" -delete 2>/dev/null || true
fi
fi
log "Ротация $DIR: удаляю старше $RET_MIN мин."
find "$DIR" -maxdepth 1 -type f -name 'db-*.dump' -mmin +"$RET_MIN" -print -delete
find "$DIR" -maxdepth 1 -type f -name 'uploads-*.tgz' -mmin +"$RET_MIN" -print -delete
log "Бэкап завершён."

View file

@ -1,220 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: blue-green deploy для prod-vm.
#
# Алгоритм:
# 1. Pull новых images из registry (если их там нет → fail)
# 2. Запуск ВТОРОГО api-контейнера (food-market-api-next) на :8088
# 3. Выполнить миграции БД через временный one-shot контейнер
# 4. Smoke-test на новом api: /health/ready + /api/me с тестовым токеном
# 5. Если ок → переключить prod nginx upstream на :8088 → reload nginx
# 6. Удалить старый api-контейнер; переименовать food-market-api-next → food-market-api
# 7. То же самое для web (но без миграций)
#
# Если smoke не прошёл → откат: убиваем -next, оставляем старый запущенным.
#
# Usage:
# deploy/prod-deploy.sh <api-tag> <web-tag> [--dry-run] [--skip-web]
#
# Подразумевает что nginx стоит на хосте (не в compose) и его upstream
# конфигурируется через include /etc/nginx/upstream.conf, который этот
# скрипт переписывает atomic'ом. Если nginx внутри web-контейнера —
# blue-green становится через docker-swap, а не nginx-reload (подробнее
# в docs/prod-deploy.md).
set -uo pipefail
API_TAG="${1:-}"
WEB_TAG="${2:-}"
DRY_RUN=0
SKIP_WEB=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-web) SKIP_WEB=1; shift ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$API_TAG" || -z "$WEB_TAG" ]]; then
cat <<EOF
Usage: $0 <api-tag> <web-tag> [--dry-run] [--skip-web]
Example:
$0 v20260607.3 v20260607.3
$0 v20260607.3 v20260607.3 --dry-run
Required env (defaults in [brackets]):
REGISTRY [127.0.0.1:5001]
COMPOSE_PATH [/home/nns/food-market-prod/deploy/docker-compose.yml]
NGINX_UPSTREAM_FILE [/etc/nginx/conf.d/food-market-upstream.conf]
API_PORT_BLUE [8080] — текущий
API_PORT_GREEN [8088] — временный для нового контейнера
EOF
exit 2
fi
REGISTRY="${REGISTRY:-127.0.0.1:5001}"
COMPOSE_PATH="${COMPOSE_PATH:-/home/nns/food-market-prod/deploy/docker-compose.yml}"
NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/food-market-upstream.conf}"
API_PORT_BLUE="${API_PORT_BLUE:-8080}"
API_PORT_GREEN="${API_PORT_GREEN:-8088}"
TEST_TOKEN_FILE="${TEST_TOKEN_FILE:-/home/nns/.fm-prod-test-token}"
run() {
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] $*"
else
echo "[exec] $*"
"$@"
fi
}
log() { echo "[$(date -Iseconds)] $*"; }
fail() {
log "FAIL: $*"
exit 1
}
# ── 1. Pull новых images ─────────────────────────────────────────────
log "=== Step 1/7: pull images ==="
API_IMG="$REGISTRY/food-market-api:$API_TAG"
WEB_IMG="$REGISTRY/food-market-web:$WEB_TAG"
run docker pull "$API_IMG" || fail "api image $API_IMG отсутствует в registry"
[[ $SKIP_WEB -eq 0 ]] && (run docker pull "$WEB_IMG" || fail "web image $WEB_IMG отсутствует")
# ── 2. Запуск green-api на :8088 ─────────────────────────────────────
log "=== Step 2/7: start green api on :$API_PORT_GREEN ==="
# Если -next уже есть (от прошлой неудачной попытки) — снести.
run docker rm -f food-market-api-next 2>/dev/null || true
# .env берём из compose dir чтобы получить те же переменные.
ENV_FILE="$(dirname "$COMPOSE_PATH")/.env"
[[ -f "$ENV_FILE" ]] || fail ".env не найден: $ENV_FILE"
# Запускаем second api с теми же volume/env, но на host port 8088
# и подключённый к той же compose-сети (food-market-prod_default).
NETWORK="food-market-prod_default"
run docker run -d \
--name food-market-api-next \
--network "$NETWORK" \
--env-file "$ENV_FILE" \
-e ASPNETCORE_ENVIRONMENT=Production \
-p "127.0.0.1:$API_PORT_GREEN:8080" \
--restart no \
"$API_IMG"
# ── 3. Миграции БД ──────────────────────────────────────────────────
# .NET API мигрирует автоматически на старте через AppDbContext.Database.Migrate()
# (см. Program.cs). Поэтому ждём готовности green-контейнера — если он
# поднялся healthy = миграции прошли.
log "=== Step 3/7: wait for green api ready (migrations) ==="
if [[ $DRY_RUN -eq 0 ]]; then
READY=0
for i in $(seq 1 60); do
sleep 2
if curl -fsS "http://127.0.0.1:$API_PORT_GREEN/health/ready" 2>/dev/null | grep -q '"status":"Healthy"'; then
READY=1
log "green api ready после $((i*2))s"
break
fi
done
if [[ $READY -eq 0 ]]; then
log "FAIL: green api не стал Healthy за 120с — сносим"
docker logs food-market-api-next --tail 50 || true
docker rm -f food-market-api-next || true
fail "green api не поднялся"
fi
else
log "[dry-run] skip wait for ready"
fi
# ── 4. Smoke-test на green ──────────────────────────────────────────
log "=== Step 4/7: smoke green api ==="
if [[ $DRY_RUN -eq 0 ]]; then
# /health/ready уже проверили; ещё /api/me с тестовым токеном.
if [[ -f "$TEST_TOKEN_FILE" ]]; then
TEST_TOKEN=$(cat "$TEST_TOKEN_FILE")
ME=$(curl -fsS -H "Authorization: Bearer $TEST_TOKEN" \
"http://127.0.0.1:$API_PORT_GREEN/api/me" 2>/dev/null || echo "")
if [[ -z "$ME" ]] || ! echo "$ME" | grep -q '"email"'; then
log "FAIL: /api/me с тестовым токеном вернул: $ME"
docker rm -f food-market-api-next || true
fail "smoke-test провалился"
fi
log "smoke /api/me ✓"
else
log "(нет $TEST_TOKEN_FILE — пропускаем /api/me, только health-check)"
fi
else
log "[dry-run] skip smoke"
fi
# ── 5. Switch nginx upstream ────────────────────────────────────────
log "=== Step 5/7: nginx upstream switch :$API_PORT_BLUE → :$API_PORT_GREEN ==="
if [[ $DRY_RUN -eq 0 ]]; then
if [[ ! -f "$NGINX_UPSTREAM_FILE" ]]; then
log "WARN: $NGINX_UPSTREAM_FILE не существует — создаём впервые"
sudo touch "$NGINX_UPSTREAM_FILE"
fi
# Atomic write: новый upstream указывает на green.
TMP="$(mktemp)"
cat > "$TMP" <<NGX
# Generated by prod-deploy.sh $(date -Iseconds)
upstream food_market_api {
server 127.0.0.1:$API_PORT_GREEN;
}
NGX
sudo install -m 644 "$TMP" "$NGINX_UPSTREAM_FILE"
rm "$TMP"
sudo nginx -t || { log "FAIL nginx -t"; fail "nginx config invalid"; }
sudo systemctl reload nginx
log "nginx reloaded"
else
log "[dry-run] would write upstream → 127.0.0.1:$API_PORT_GREEN and reload nginx"
fi
# ── 6. Свернуть старый api, переименовать green → blue ──────────────
log "=== Step 6/7: stop old api, rename green ==="
run docker rm -f food-market-api 2>/dev/null || true
run docker rename food-market-api-next food-market-api
# Обновляем compose-yml tag → для будущих up-d
# (используем docker compose с новой версией; перезапуск НЕ нужен,
# контейнер уже работает).
log "(compose-yml tag update — manual через .env API_TAG=$API_TAG)"
# Переключить nginx обратно на blue-port чтобы соответствовать compose-mapping.
# Контейнер уже на host port $API_PORT_BLUE? нет, мы запускали green на 8088.
# Переключаем upstream обратно на 8080 чтобы dock-compose up в будущем работал.
if [[ $DRY_RUN -eq 0 ]]; then
# Переcоздаём green-контейнер с blue-портом (быстрый stop/start).
docker stop food-market-api && docker rm food-market-api
cd "$(dirname "$COMPOSE_PATH")"
API_TAG="$API_TAG" docker compose up -d api
TMP="$(mktemp)"
cat > "$TMP" <<NGX
upstream food_market_api {
server 127.0.0.1:$API_PORT_BLUE;
}
NGX
sudo install -m 644 "$TMP" "$NGINX_UPSTREAM_FILE"
rm "$TMP"
sudo nginx -t && sudo systemctl reload nginx
fi
# ── 7. Web (тот же flow без миграций) ───────────────────────────────
log "=== Step 7/7: web ==="
if [[ $SKIP_WEB -eq 1 ]]; then
log "skipped (--skip-web)"
else
cd "$(dirname "$COMPOSE_PATH")"
run env WEB_TAG="$WEB_TAG" docker compose up -d web
log "web re-pulled to $WEB_IMG"
fi
log "✓ Deploy complete: api=$API_TAG web=$WEB_TAG"
exit 0

View file

@ -1,80 +0,0 @@
#!/usr/bin/env bash
#
# ПРОД food-market: мониторинг здоровья. Проверяет api/web/БД, место на диске и
# число 5xx в логах api за последний интервал. При проблеме шлёт владельцу в
# Telegram (бот уже есть; token/chat — ~/.fm-watchdog/telegram-{token,chat}).
#
# Запуск из systemd-таймера (каждые ~5 мин). Аргументы:
# (без аргументов) — обычная проверка, уведомляет ТОЛЬКО при проблеме.
# --test — отправляет тестовое уведомление «мониторинг жив» и выходит.
# --heartbeat — всегда шлёт короткий статус (для ручной проверки).
set -uo pipefail
WATCH_DIR="${WATCH_DIR:-$HOME/.fm-watchdog}"
API_URL="${FM_API_URL:-http://localhost:8080/health/ready}"
WEB_URL="${FM_WEB_URL:-http://localhost:8081/}"
PUBLIC_URL="${FM_PUBLIC_HEALTH:-https://admin.food-market.kz/health/ready}"
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
API_CONTAINER="${FM_API_CONTAINER:-food-market-api}"
DISK_PATH="${FM_DISK_PATH:-/opt/food-market-data}"
DISK_WARN_PCT="${FM_DISK_WARN_PCT:-85}"
LOG="${FM_MONITOR_LOG:-$WATCH_DIR/prod-monitor.log}"
log() { echo "[$(date -Is)] $*" | tee -a "$LOG" >/dev/null; }
notify() {
local msg="$1"
local token chat
token="$(cat "$WATCH_DIR/telegram-token" 2>/dev/null || true)"
chat="$(cat "$WATCH_DIR/telegram-chat" 2>/dev/null || true)"
if [[ -z "$token" || -z "$chat" ]]; then
log "telegram token/chat не настроены — notify пропущен: $msg"; return
fi
curl -sS --max-time 10 -X POST "https://api.telegram.org/bot$token/sendMessage" \
--data-urlencode "chat_id=$chat" \
--data-urlencode "text=$msg" >/dev/null 2>&1 \
&& log "notify отправлено: $msg" || log "notify FAILED: $msg"
}
if [[ "${1:-}" == "--test" ]]; then
notify "✅ ALL MARKET прод: тест мониторинга $(date '+%Y-%m-%d %H:%M'). Если видите это — уведомления работают."
exit 0
fi
problems=()
# API /health/ready
if ! curl -fsS --max-time 10 "$API_URL" 2>/dev/null | grep -q '"status":"Healthy"'; then
problems+=("API /health/ready не Healthy ($API_URL)")
fi
# Web SPA
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$WEB_URL" 2>/dev/null || echo 000)
[[ "$code" =~ ^(200|301|302)$ ]] || problems+=("web вернул HTTP $code ($WEB_URL)")
# Публичный вход (через gateway)
pcode=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$PUBLIC_URL" 2>/dev/null || echo 000)
[[ "$pcode" =~ ^(200)$ ]] || problems+=("публичный $PUBLIC_URL → HTTP $pcode")
# БД
if ! docker exec "$CONTAINER" pg_isready -U food_market -d food_market >/dev/null 2>&1; then
problems+=("БД $CONTAINER не отвечает (pg_isready)")
fi
# Диск
pct=$(df --output=pcent "$DISK_PATH" 2>/dev/null | tail -1 | tr -dc '0-9')
if [[ -n "$pct" && "$pct" -ge "$DISK_WARN_PCT" ]]; then
problems+=("диск $DISK_PATH заполнен на ${pct}% (порог ${DISK_WARN_PCT}%)")
fi
# 5xx в логах api за последние 5 минут
fivexx=$(docker logs "$API_CONTAINER" --since 5m 2>&1 | grep -cE ' (50[0-9]) ' || true)
if [[ -n "$fivexx" && "$fivexx" -gt 0 ]]; then
problems+=("$fivexx ответов 5xx в логах api за 5 мин")
fi
if [[ ${#problems[@]} -gt 0 ]]; then
msg="🔴 ALL MARKET прод — проблемы:"$'\n'"$(printf -- '- %s\n' "${problems[@]}")"
log "ПРОБЛЕМЫ: ${problems[*]}"
notify "$msg"
exit 1
fi
log "OK: api/web/public/БД/диск(${pct}%)/5xx=0"
[[ "${1:-}" == "--heartbeat" ]] && notify "🟢 ALL MARKET прод OK $(date '+%H:%M') (диск ${pct}%)"
exit 0

View file

@ -1,56 +0,0 @@
#!/usr/bin/env bash
#
# ПРОД food-market: проверка восстановления бэкапа. Берёт последний daily-дамп
# (или путь из аргумента), ВОССТАНАВЛИВАЕТ во ВРЕМЕННУЮ БД в том же контейнере
# postgres, сверяет число строк ключевых таблиц с боевой БД, затем удаляет
# временную БД. Боевую БД НЕ трогает (только читает count'ы).
#
# Выход 0 — восстановление успешно и counts совпали; !=0 — проблема (для мониторинга).
set -uo pipefail
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
BASE_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
TMP_DB="food_market_restore_test"
DUMP="${1:-}"
log() { echo "[$(date -Is)] [restore-verify] $*"; }
fail() { log "FAIL: $*"; cleanup; exit 1; }
cleanup() { docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -c "DROP DATABASE IF EXISTS $TMP_DB;" >/dev/null 2>&1 || true; }
[ -n "$DUMP" ] || DUMP="$(ls -t "$BASE_DIR"/daily/db-*.dump "$BASE_DIR"/hourly/db-*.dump 2>/dev/null | head -1)"
[ -n "$DUMP" ] && [ -f "$DUMP" ] || fail "дамп не найден (ни в аргументе, ни в $BASE_DIR)"
log "дамп: $DUMP ($(du -h "$DUMP" | cut -f1))"
trap cleanup EXIT
cleanup
log "создаю временную БД $TMP_DB"
docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -c "CREATE DATABASE $TMP_DB;" >/dev/null 2>&1 || fail "CREATE DATABASE"
log "pg_restore → $TMP_DB"
if ! docker exec -i "$CONTAINER" pg_restore -U "$DB_USER" -d "$TMP_DB" --no-owner --no-privileges < "$DUMP" >/tmp/fm-restore.log 2>&1; then
# pg_restore может вернуть !=0 на безобидных warning'ах (owner/extension); проверяем,
# что таблицы реально появились — это решающий критерий.
log "pg_restore завершился с предупреждениями (проверяю таблицы):"
tail -5 /tmp/fm-restore.log 2>/dev/null | sed 's/^/ /' || true
fi
# Сверка числа строк ключевых таблиц: боевая vs восстановленная.
TABLES="users roles organizations products supplies retail_sales money_transactions stocks"
mismatch=0
for t in $TABLES; do
live=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -A -t -c "SELECT count(*) FROM $t;" 2>/dev/null | tr -d '[:space:]')
rest=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d "$TMP_DB" -A -t -c "SELECT count(*) FROM $t;" 2>/dev/null | tr -d '[:space:]')
live="${live:-ERR}"; rest="${rest:-ERR}"
if [ "$live" = "$rest" ] && [ "$live" != "ERR" ]; then
log " $t: $live = $rest ✓"
else
log " $t: боевая=$live восстановлено=$rest ✗"; mismatch=1
fi
done
[ "$mismatch" = 0 ] || fail "число строк не совпало (см. выше)"
log "✓ Восстановление проверено: все ключевые таблицы совпали."
# cleanup выполнит trap EXIT.
exit 0

View file

@ -1,112 +0,0 @@
#!/usr/bin/env bash
#
# Sprint 21: быстрый rollback на предыдущий tag.
#
# Алгоритм:
# 1. Проверить что image нужного tag'a есть в registry (docker pull)
# 2. Перезапустить api/web с этим tag'ом через docker compose
# (через ENV API_TAG/WEB_TAG → compose pick'ает)
# 3. Дождаться /health/ready на новом контейнере
# 4. Если health OK → выйти 0; если не OK → fail (но контейнер уже
# поднят, ручное вмешательство нужно)
#
# Миграции БД rollback скрипт НЕ откатывает: down-migrations EF Core
# поддерживает, но мы их не пишем (см. CLAUDE.md / Phase19a/b — обе
# имеют Down() для DROP'a, но это для прода опасно — данные потеряются).
# Если откат требует down-миграции — отдельный manual review.
#
# Usage:
# deploy/prod-rollback.sh <to-tag> [--dry-run] [--skip-web]
#
# Example:
# deploy/prod-rollback.sh v20260606.5
# deploy/prod-rollback.sh v20260606.5 --dry-run
set -uo pipefail
TO_TAG="${1:-}"
DRY_RUN=0
SKIP_WEB=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-web) SKIP_WEB=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$TO_TAG" ]]; then
echo "Usage: $0 <to-tag> [--dry-run] [--skip-web]" >&2
exit 2
fi
REGISTRY="${REGISTRY:-127.0.0.1:5001}"
COMPOSE_PATH="${COMPOSE_PATH:-/home/nns/food-market-prod/deploy/docker-compose.yml}"
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
log() { echo "[$(date -Iseconds)] $*"; }
fail() { log "FAIL: $*"; exit 1; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*";
else echo "[exec] $*"; "$@"; fi
}
# ── 1. Validate image existence ──────────────────────────────────────
log "=== Step 1/3: validate images ==="
API_IMG="$REGISTRY/food-market-api:$TO_TAG"
WEB_IMG="$REGISTRY/food-market-web:$TO_TAG"
# Сначала пробуем docker image inspect — если уже скачан, не тянем.
if [[ $DRY_RUN -eq 0 ]]; then
if ! docker image inspect "$API_IMG" >/dev/null 2>&1; then
log "api image $API_IMG не скачан, pull'им"
docker pull "$API_IMG" || fail "api image $TO_TAG отсутствует в $REGISTRY"
else
log "api image $TO_TAG уже скачан"
fi
if [[ $SKIP_WEB -eq 0 ]]; then
if ! docker image inspect "$WEB_IMG" >/dev/null 2>&1; then
docker pull "$WEB_IMG" || fail "web image $TO_TAG отсутствует"
fi
fi
else
log "[dry-run] would pull $API_IMG (and $WEB_IMG)"
fi
# ── 2. docker compose up -d с новым tag ─────────────────────────────
log "=== Step 2/3: docker compose up -d ==="
if [[ ! -f "$COMPOSE_PATH" ]]; then
fail "compose не найден: $COMPOSE_PATH"
fi
cd "$(dirname "$COMPOSE_PATH")"
if [[ $DRY_RUN -eq 0 ]]; then
if [[ $SKIP_WEB -eq 1 ]]; then
API_TAG="$TO_TAG" docker compose up -d --force-recreate api
else
API_TAG="$TO_TAG" WEB_TAG="$TO_TAG" docker compose up -d --force-recreate api web
fi
else
log "[dry-run] would run: API_TAG=$TO_TAG WEB_TAG=$TO_TAG docker compose up -d --force-recreate api web"
fi
# ── 3. Wait /health/ready ────────────────────────────────────────────
log "=== Step 3/3: wait /health/ready ==="
if [[ $DRY_RUN -eq 0 ]]; then
for i in $(seq 1 60); do
sleep 2
if curl -fsS --max-time 5 "$PROD_URL/health/ready" 2>/dev/null | grep -q '"status":"Healthy"'; then
log "✓ Rollback complete: $PROD_URL Healthy после $((i*2))s"
exit 0
fi
done
fail "/health/ready не отвечает Healthy за 120с — ручное вмешательство"
else
log "[dry-run] would poll $PROD_URL/health/ready up to 60×2s"
fi
exit 0

View file

@ -1,175 +0,0 @@
# Sprint 26: Prometheus alert rules для food-market.
#
# Загружается через prometheus.yml:
# rule_files:
# - alerts.yml
#
# Каждое правило → Alertmanager → Telegram/email.
# Все runbook-ссылки указывают на docs/RUNBOOK.md в репо.
#
# Группировка: 4 группы по доменам — uptime / errors / database / quality-watchdog.
groups:
- name: food-market.uptime
interval: 30s
rules:
- alert: ApiDown
expr: up{job="food-market-api"} == 0
for: 1m
labels:
severity: critical
runbook: api-down
annotations:
summary: "food-market API не отвечает на /metrics уже 1 минуту"
description: |
Prometheus не может scrap'нуть {{ $labels.instance }} > 1 минуты.
Это означает либо процесс упал, либо порт недоступен.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#api-down"
- alert: RpsDropped50Percent
# RPS за 5 минут упал относительно среднего за час назад (5-минутка часовой давности).
# Защита от ложных в пиках/спадах: только когда фактическая загрузка была заметной (>0.5 rps).
expr: |
sum(rate(http_requests_received_total[5m]))
/ clamp_min(sum(rate(http_requests_received_total[5m] offset 1h)), 0.001)
< 0.5
and
sum(rate(http_requests_received_total[5m] offset 1h)) > 0.5
for: 10m
labels:
severity: warning
runbook: rps-drop
annotations:
summary: "RPS упал >50% относительно того же окна час назад"
description: |
Сейчас RPS = {{ $value | humanize }}, что меньше половины часовой давности.
Возможно: упал процесс, нет трафика от клиентов, потерян DNS.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#rps-drop"
- name: food-market.errors
interval: 30s
rules:
- alert: HttpErrorsSpike
# Доля 5xx-ответов > 10% от общего трафика за 5 минут.
# 10% — порог, выше которого пользователи реально замечают.
expr: |
(sum(rate(http_requests_received_total{code=~"5.."}[5m]))
/ clamp_min(sum(rate(http_requests_received_total[5m])), 0.001))
> 0.10
for: 5m
labels:
severity: critical
runbook: http-errors-spike
annotations:
summary: "Доля HTTP 5xx > 10% уже 5 минут"
description: |
Сейчас {{ $value | humanizePercentage }} от запросов возвращают 5xx.
Скорее всего сломан какой-то контроллер или зависимость.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-spike"
- alert: HttpErrorRateGrowing
# Темп роста 5xx-ошибок > 10%/min.
expr: |
deriv(sum(rate(http_requests_received_total{code=~"5.."}[5m]))[5m:1m])
> 0.10 / 60
for: 10m
labels:
severity: warning
runbook: http-errors-growing
annotations:
summary: "Темп роста 5xx-ошибок > 10%/min на протяжении 10 минут"
description: |
Производная rate(5xx) положительная > 10%/min. Похоже на постепенную
деградацию (не explosion). Проверь логи: что начало падать.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#http-errors-growing"
- alert: DocumentPostingErrors
expr: |
sum(rate(food_market_documents_error_total[5m])) by (type)
> 0.05
for: 5m
labels:
severity: warning
runbook: doc-posting-errors
annotations:
summary: "Документы ({{ $labels.type }}) валятся чаще 1 раза в 20 секунд"
description: |
Тип={{ $labels.type }} даёт {{ $value }} ошибок/сек. Воркфлоу проведения
ломается — посмотри logs или Hangfire-failed-jobs.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#doc-posting-errors"
- name: food-market.database
interval: 30s
rules:
- alert: DbQueryP95High
# p95 DB-запросов > 500ms на протяжении 10 минут.
expr: |
histogram_quantile(0.95,
sum(rate(food_market_db_query_duration_seconds_bucket[5m])) by (le))
> 0.5
for: 10m
labels:
severity: warning
runbook: db-p95-high
annotations:
summary: "DB query p95 > 500ms 10 минут подряд"
description: |
p95 = {{ $value | humanizeDuration }}. Возможно: PG медленный, нет индекса,
ANALYZE устарел, или массовый insert. См. runbook.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#db-p95-high"
- alert: DiskFreeLow
expr: food_market_disk_free_bytes < 5 * 1024 * 1024 * 1024
for: 5m
labels:
severity: critical
runbook: disk-free-low
annotations:
summary: "Свободно < 5 ГБ на {{ $labels.mount }}"
description: |
Свободно: {{ $value | humanize1024 }}B. При достижении 0 БД встанет.
Очисти логи / запусти VACUUM FULL / расширь том.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#disk-free-low"
- name: food-market.quality-watchdog
interval: 1m
rules:
- alert: WatchdogLastRunRed
expr: quality_watchdog_last_run_status == 0
for: 5m
labels:
severity: warning
runbook: watchdog-red
annotations:
summary: "quality-watchdog последний прогон красный (>5 мин)"
description: |
Хотя бы один из 8 шагов упал. Посмотри docs/quality-status.md
или ~/.fm-watchdog/quality.log.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-red"
- alert: MultiTenantViolation
# Multi-tenant leak — самый дорогой баг, alert немедленный.
expr: increase(quality_watchdog_step_failure_total{step="multi_tenant"}[1h]) > 0
for: 1m
labels:
severity: critical
runbook: multi-tenant-violation
annotations:
summary: "🚨 Multi-tenant LEAK обнаружен watchdog'ом"
description: |
Шаг multi_tenant failed в последнем прогоне. Org B видит данные A.
ЭТО P0. Немедленно разверни stage в read-only mode и проверь tenant-filter.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#multi-tenant-violation"
- alert: WatchdogIncidentCreated
expr: increase(quality_watchdog_incidents_total[1h]) > 0
for: 1m
labels:
severity: warning
runbook: watchdog-incident
annotations:
summary: "Watchdog создал incident — 2+ подряд красных прогона"
description: |
Один и тот же шаг упал 2 раза подряд. Server-Claude получит
incident-файл в очередь. Проверь ~/.fm-watchdog/incident-*.txt.
runbook_url: "https://forgejo.local/nns/food-market/src/branch/main/docs/RUNBOOK.md#watchdog-incident"

View file

@ -1,47 +0,0 @@
# Sprint 26: пример конфига Prometheus для food-market.
#
# НЕ деплоится автоматически — это reference для оператора. Под stage:
#
# docker run -d --name prometheus \
# -p 9090:9090 \
# -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
# -v $PWD/alerts.yml:/etc/prometheus/alerts.yml \
# prom/prometheus:latest
#
# Затем Grafana datasource «Prometheus» = http://prometheus:9090.
global:
scrape_interval: 30s
evaluation_interval: 30s
external_labels:
env: stage
rule_files:
- alerts.yml
scrape_configs:
# API exposed via /metrics endpoint
- job_name: food-market-api
metrics_path: /metrics
static_configs:
- targets:
- test.admin.food-market.kz:443 # stage
# - api.food-market.kz:443 # prod
scheme: https
relabel_configs:
- source_labels: [__address__]
target_label: instance
# quality-watchdog textfile exporter (через node_exporter).
# Запускается на машине, где живёт ~/quality-watchdog.sh:
# node_exporter --collector.textfile.directory=$HOME/.fm-watchdog/textfile
- job_name: quality-watchdog
static_configs:
- targets:
- 192.168.1.193:9100 # dev-vm node_exporter
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093

View file

@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Откат ребрендинга ALL MARKET → Food Market.
# По умолчанию dry-run (печатает план). С --apply реально откатывает:
# 1) git revert диапазона pre-rebrand-all-market..HEAD (сохраняет историю),
# 2) пересборка+редеплой стенда (~/deploy-stage.sh).
# Публичный сайт (Astro) откатывается отдельно — см. docs/rebrand-rollback.md.
set -euo pipefail
TAG="pre-rebrand-all-market"
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
cd "$(dirname "$0")/.."
REPO="$(pwd)"
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "✗ Тег $TAG не найден. Нечего откатывать (или тег не создан)." >&2
exit 1
fi
RANGE="$TAG..HEAD"
COMMITS="$(git log --oneline "$RANGE" 2>/dev/null || true)"
COUNT="$(git rev-list --count "$RANGE" 2>/dev/null || echo 0)"
echo "── Откат ребрендинга ALL MARKET → Food Market ──"
echo "Репозиторий: $REPO"
echo "Якорь (тег): $TAG = $(git rev-parse --short "$TAG")"
echo "Диапазон отката: $RANGE (коммитов: $COUNT)"
echo "Коммиты диапазона:"
echo "${COMMITS:- (пусто — откатывать нечего)}" | sed 's/^/ /'
echo
if [[ "$COUNT" -eq 0 ]]; then
echo "Нет коммитов после тега — откат не требуется."
exit 0
fi
CMD_REVERT="git revert --no-edit $RANGE && git push origin main"
CMD_DEPLOY="$HOME/deploy-stage.sh"
if [[ "$APPLY" -eq 0 ]]; then
echo "DRY-RUN. Будет выполнено (запусти с --apply):"
echo " 1) $CMD_REVERT"
echo " 2) $CMD_DEPLOY # пересборка+редеплой стенда api+web"
echo
echo "Жёсткая альтернатива кода (переписывает историю, только по согласованию):"
echo " git reset --hard $TAG && git push --force-with-lease origin main"
exit 0
fi
echo "APPLY: выполняю откат…"
echo "→ git revert --no-edit $RANGE"
git revert --no-edit "$RANGE"
echo "→ git push origin main"
git push origin main
echo "→ $CMD_DEPLOY"
"$HOME/deploy-stage.sh"
echo "✓ Откат кода и стенда выполнен. Публичный сайт откати отдельно."

View file

@ -1,72 +0,0 @@
-- Recovery: orphan AppUser cleanup.
--
-- Применяется один раз вручную на стейдже/проде после деплоя
-- AuthorizationController + SuperAdminOrganizationsController фиксов
-- (audit 2026-04-27 #1, #2, #7).
--
-- Что делает:
-- 1. Находит users у которых OrganizationId указывает на отсутствующую
-- или архивированную организацию.
-- 2. Деактивирует таких users (IsActive=false), сбрасывает OrganizationId.
-- 3. Отзывает все OpenIddict refresh/access токены этих users
-- (Status='revoked') чтобы существующие сессии оборвались.
--
-- Идемпотентен: повторный запуск ничего не ломает.
-- Не удаляет данные — только статусы. Юзер при необходимости может
-- быть восстановлен ручным UPDATE users SET "IsActive"=true.
BEGIN;
WITH orphan_users AS (
SELECT u."Id"
FROM users u
LEFT JOIN organizations o ON o."Id" = u."OrganizationId"
WHERE u."IsActive" = true
AND (
u."OrganizationId" IS NULL
OR o."Id" IS NULL
OR o."IsArchived" = true
)
AND NOT EXISTS (
-- Не трогаем SuperAdmin'ов — у них org=null это норма.
SELECT 1
FROM "AspNetUserRoles" ur
JOIN roles r ON r."Id" = ur."RoleId"
WHERE ur."UserId" = u."Id" AND r."NormalizedName" = 'SUPERADMIN'
)
)
UPDATE users
SET "IsActive" = false,
"OrganizationId" = NULL
WHERE "Id" IN (SELECT "Id" FROM orphan_users);
UPDATE "OpenIddictTokens" t
SET "Status" = 'revoked'
WHERE t."Status" = 'valid'
AND t."Subject" IN (
SELECT u."Id"::text FROM users u
WHERE u."IsActive" = false
);
-- Owner-Employee должен оставаться в роли «Администратор» и быть IsActive=true.
-- Если кто-то сменил роль владельца на Кладовщика/Менеджера/Кассира или
-- деактивировал — возвращаем «Администратор» и активируем.
WITH admin_role_per_org AS (
SELECT r."OrganizationId", r."Id" AS role_id
FROM employee_roles r
WHERE r."IsSystem" = true AND r."Name" = 'Администратор'
)
UPDATE employees e
SET "RoleId" = ar.role_id,
"IsActive" = true,
"FiredAt" = NULL
FROM organizations o
JOIN admin_role_per_org ar ON ar."OrganizationId" = o."Id"
WHERE e."OrganizationId" = o."Id"
AND e."UserId" = o."AccountOwnerUserId"
AND (
e."RoleId" <> ar.role_id
OR e."IsActive" = false
);
COMMIT;

View file

@ -1,17 +0,0 @@
# deploy/shared-postgres/
Снимок стороннего compose-проекта `shared-postgres` с prod-vm, положенный в репу
как справочник. **Food-market этим compose'ом не управляет** — он живёт на
prod-vm в `~/shared-postgres/` и там же обслуживается. Любой скрипт food-market,
который трогает `shared-postgres`, том `food-market-postgres-data`, порт 5434 —
баг (guards падают).
Контекст: 2026-10-05 общий postgres-инстанс с чужими боевыми базами (`sklad`,
`purchase`, `calcman`, `calcman_staging`, `makesales` + мусорная старая
`food_market`) вынесен из compose прода food-market в отдельный compose-проект.
См. [`docs/shared-postgres-followup-progress.md`](../../docs/shared-postgres-followup-progress.md)
и [`docs/RUNBOOK.md`](../../docs/RUNBOOK.md) → раздел «Postgres на prod-vm».
Прод food-market ходит в **свой** postgres: `food-market-prod-postgres`,
том `food-market-prod-postgres-data`, порт `127.0.0.1:5437` — описан в
`deploy/docker-compose.yml`.

View file

@ -1,50 +0,0 @@
# ──────────────────────────────────────────────────────────────────────────
# shared-postgres: ДОКУМЕНТАЦИОННАЯ копия стороннего compose'а на prod-vm.
# ──────────────────────────────────────────────────────────────────────────
#
# Этот файл **не используется** ни одним из скриптов репозитория. Он лежит в
# репе как справочник: именно так на prod-vm выглядит compose-проект
# `shared-postgres`, живущий в `~/shared-postgres/docker-compose.yml`.
#
# Что это такое. С 2026-10-05 общий postgres-инстанс (чужие боевые базы
# `sklad`, `purchase`, `calcman`, `calcman_staging`, `makesales`, плюс
# мусорная старая `food_market` — повреждена, не восстанавливается) вынесен
# из compose прода food-market в отдельный compose-проект. Раньше он жил в
# том же `~/food-market/deploy/docker-compose.yml` под project-name `deploy`,
# и любой `docker compose down` у food-market гасил чужие боевые базы.
#
# Проект называется `shared-postgres`, контейнер — `shared-postgres`, порт —
# `127.0.0.1:5434`, том `food-market-postgres-data` объявлен *external*
# (имя историческое — на нём лежат чужие продакшн-данные соседних систем,
# переименовывать нельзя).
#
# **Food-market НЕ управляет этим compose.** Любые наши скрипты (backup,
# anonymize, schema-diff, deploy, watchdog), включая `docker exec`, не должны
# ссылаться на `shared-postgres` / `food-market-postgres-data` / порт 5434 —
# за этим следят guards в ~/deploy-stage.sh и ~/quality-watchdog.sh.
#
# Нужен dump/бэкап чужой базы — делает её владелец у себя. См.
# docs/shared-postgres-followup-progress.md и docs/RUNBOOK.md.
name: shared-postgres
services:
db:
image: 192.168.1.193:5001/mirror/postgres:16-alpine
container_name: shared-postgres
restart: unless-stopped
env_file: .env
environment:
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "127.0.0.1:5434:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
external: true
name: food-market-postgres-data

View file

@ -1,105 +0,0 @@
#!/usr/bin/env bash
# Витринная организация «Магазин у дома» на стенде + наполнение правдоподобными
# данными для скриншотов публичного сайта (ShowcaseSeeder). Повторяемый и
# идемпотентный: при повторном запуске переиспользует организацию и не задваивает
# данные (сидер проверяет наличие товаров).
#
# Требует задеплоенного на стенд API с эндпоинтом POST /api/admin/seed-demo?showcase=1
# (см. src/food-market.api/Seed/ShowcaseSeeder.cs). Деплой стенда: ~/deploy-stage.sh.
#
# Запуск:
# bash deploy/showcase-seed.sh
# BASE=https://test.admin.food-market.kz bash deploy/showcase-seed.sh
#
# На выходе печатает orgId и e-mail владельца (их же использует scripts/screenshots.mjs
# через SHOWCASE_EMAIL/SHOWCASE_ORG).
set -euo pipefail
BASE="${BASE:-https://test.admin.food-market.kz}"
EMAIL="${SHOWCASE_EMAIL:-vladelec@magazin-u-doma.kz}"
PASSWORD="${SHOWCASE_PASSWORD:-MagazinUDoma2026!}"
ORGNAME="${SHOWCASE_ORG:-Магазин у дома}"
PHONE="+77015550102"
j() { python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get(sys.argv[1],""))' "$1"; }
echo "→ стенд: $BASE организация: «$ORGNAME» владелец: $EMAIL"
# 0. (опц.) RESET=1 — архивировать прежнюю витринную орг. через SuperAdmin, чтобы
# пересоздать её с нуля (signup реактивирует владельца с новой организацией).
# Только стенд: мастер-пароль быстрого входа работает лишь при QuickLogin и не-прод Issuer.
if [[ "${RESET:-0}" == "1" ]]; then
SA_EMAIL="${SUPERADMIN_EMAIL:-admin@food-market.local}"
SA_PASS="${SUPERADMIN_PASSWORD:-__QUICK_LOGIN_PWLESS__}"
echo "→ RESET: архивирую прежнюю «$ORGNAME» (SuperAdmin $SA_EMAIL)…"
SATOKEN=$(curl -sk -m 30 -X POST "$BASE/connect/token" -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=password --data-urlencode "username=$SA_EMAIL" --data-urlencode "password=$SA_PASS" \
--data-urlencode client_id=food-market-web --data-urlencode 'scope=openid profile email roles api' | j access_token)
if [[ -n "$SATOKEN" ]]; then
# orgId прежнего владельца (если он ещё существует и активен).
OLDTOKEN=$(curl -sk -m 30 -X POST "$BASE/connect/token" -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=password --data-urlencode "username=$EMAIL" --data-urlencode "password=$PASSWORD" \
--data-urlencode client_id=food-market-web --data-urlencode 'scope=openid profile email roles api' | j access_token)
if [[ -n "$OLDTOKEN" ]]; then
OLDORG=$(curl -sk -m 30 "$BASE/api/me" -H "Authorization: Bearer $OLDTOKEN" | j orgId)
if [[ -n "$OLDORG" ]]; then
code=$(curl -sk -m 30 -o /dev/null -w '%{http_code}' -X POST "$BASE/api/super-admin/organizations/$OLDORG/archive" \
-H "Authorization: Bearer $SATOKEN" -H 'Content-Type: application/json' \
-d "{\"confirmationName\":\"$ORGNAME\"}")
echo " archive $OLDORG → HTTP $code"
fi
else
echo " прежний владелец не логинится (орг уже архивна?) — пропускаю."
fi
else
echo " ⚠ не удалось получить SuperAdmin-токен — пропускаю RESET."
fi
fi
# 1. Регистрация организации (идемпотентно: если e-mail занят — просто логинимся).
echo "→ signup…"
signup=$(curl -sk -m 60 -X POST "$BASE/api/auth/signup" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"organizationName\":\"$ORGNAME\",\"phone\":\"$PHONE\",\"plan\":\"start\"}" \
-w '\n%{http_code}')
code=$(echo "$signup" | tail -1)
body=$(echo "$signup" | sed '$d')
if [[ "$code" == "200" ]]; then
echo " создана новая организация."
elif echo "$body" | grep -q "уже зарегистрирован"; then
echo " организация уже существует — переиспользуем."
elif [[ "$code" == "429" ]]; then
echo " rate-limit на signup, ждём 15с и повторяем…"
sleep 15
curl -sk -m 60 -X POST "$BASE/api/auth/signup" -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"organizationName\":\"$ORGNAME\",\"phone\":\"$PHONE\",\"plan\":\"start\"}" >/dev/null || true
else
echo " signup вернул $code: $body" >&2
echo " (продолжаем — возможно, организация уже заведена)"
fi
# 2. Токен владельца.
echo "→ получаем токен…"
TOKEN=$(curl -sk -m 60 -X POST "$BASE/connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=password' \
--data-urlencode "username=$EMAIL" \
--data-urlencode "password=$PASSWORD" \
--data-urlencode 'client_id=food-market-web' \
--data-urlencode 'scope=openid profile email roles api offline_access' \
| j access_token)
if [[ -z "$TOKEN" ]]; then echo "✗ не удалось получить токен" >&2; exit 1; fi
ORGID=$(curl -sk -m 30 "$BASE/api/me" -H "Authorization: Bearer $TOKEN" | j orgId)
echo " orgId=$ORGID"
# 3. Наполнение витринными данными.
echo "→ seed showcase (товары/продажи/счета/сотрудники)…"
resp=$(curl -sk -m 300 -X POST "$BASE/api/admin/seed-demo?showcase=1" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Length: 0')
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
echo
echo "✓ готово. Для скриншотов:"
echo " SHOWCASE_EMAIL=$EMAIL SHOWCASE_PASSWORD=*** node src/food-market.public/scripts/screenshots.mjs"
echo " orgId=$ORGID"

View file

@ -1,174 +0,0 @@
-- deploy/stage-prune-autotest.sql
-- Одноразовая зачистка стейджа от автотестового мусора. Пишется как idempotent
-- SQL-скрипт — его же логика живёт в HousekeepingJobs.PruneAutoTestEntitiesAsync
-- (Hangfire 02:45 UTC daily). Этот файл пригоден для ручного запуска
-- (например, на prod-vm через `docker exec food-market-stage-postgres-1 psql
-- -U food_market -d food_market -f /tmp/stage-prune-autotest.sql`).
--
-- Что НЕ трогает:
-- * admin@food-market.local (SuperAdmin — OrganizationId IS NULL)
-- * admin@demo-market.local
-- * market@email.com
-- * demo-*@food-market.local (quick-login demo-trio)
-- * «Demo Market» и «Демо-магазин» (seeded orgs)
--
-- Условие удаления: имя орги подходит под регулярку autotest ИЛИ email юзера
-- подходит под autotest-патéрн / служебный домен. Для свежих прогонов —
-- threshold 3 дня (меняется через psql -v).
\set threshold_days 3
DO $do$
DECLARE
threshold_ts timestamp with time zone := NOW() - INTERVAL '3 days';
org_ids uuid[];
tbl text;
pass int := 0;
remaining int;
autotest_org_rx text := '[0-9]{10,}' ||
'|^quality-|^soak-|^(ui-)?a11y-|^smokeorg[ab]?-|^perfbaseline-|^reg-' ||
'|^rt-ratetest-|^probe\s|^cat3[0-9]-|^visual-|^factory-smoke-|^mt6[0-9]-' ||
'|^doc4[0-9]-|^auth2[0-9]-|^rep5[0-9]-|^rt8[0-9]-|^wiz9[0-9]-' ||
'|^i18n[0-9]+-|^twofa[0-9]+-|^iso[a-z]*[0-9]*-|^mob1[0-9]-|^axdet-' ||
'|^load-sales-|^load-|^s19-test-';
autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)';
autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$';
deleted_orgs int;
deleted_users int;
BEGIN
SELECT array_agg("Id") INTO org_ids FROM public.organizations
WHERE "Name" <> 'Demo Market'
AND "Name" <> 'Демо-магазин'
AND "Name" ~* autotest_org_rx
AND "CreatedAt" < threshold_ts;
IF array_length(org_ids, 1) IS NOT NULL THEN
RAISE NOTICE 'Нашли % autotest-органов для удаления', array_length(org_ids, 1);
FOR pass IN 1..10 LOOP
remaining := 0;
FOR tbl IN
SELECT c.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE c.table_schema = 'public'
AND c.column_name = 'OrganizationId'
AND t.table_type = 'BASE TABLE'
AND c.table_name NOT IN ('organizations', 'users')
LOOP
BEGIN
EXECUTE format('DELETE FROM public.%I WHERE "OrganizationId" = ANY($1)', tbl) USING org_ids;
EXCEPTION WHEN foreign_key_violation THEN
remaining := remaining + 1;
END;
END LOOP;
EXIT WHEN remaining = 0;
END LOOP;
DELETE FROM "AspNetUserRoles" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
DELETE FROM "AspNetUserTokens" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
DELETE FROM "AspNetUserClaims" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
DELETE FROM "AspNetUserLogins" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
DELETE FROM "OpenIddictTokens" WHERE "Subject" IN (
SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN (
SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids)
);
GET DIAGNOSTICS deleted_users = ROW_COUNT;
DELETE FROM public.users WHERE "OrganizationId" = ANY(org_ids);
DELETE FROM public.organizations WHERE "Id" = ANY(org_ids);
GET DIAGNOSTICS deleted_orgs = ROW_COUNT;
RAISE NOTICE 'Удалено orgs=%, users с org=%', deleted_orgs, deleted_users;
ELSE
RAISE NOTICE 'Нет autotest-органов старше %', threshold_ts;
END IF;
-- Orphan autotest-юзеры (без tagged org). Исключения — такие же как в
-- Hangfire-методе: SuperAdmin, demo-trio, admin@demo-market.local, market@.
DELETE FROM "AspNetUserRoles" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM "AspNetUserTokens" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM "AspNetUserClaims" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM "AspNetUserLogins" WHERE "UserId" IN (
SELECT u."Id" FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM "OpenIddictTokens" WHERE "Subject" IN (
SELECT u."Id"::text FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN (
SELECT u."Id"::text FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts
);
DELETE FROM public.users u
WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}')
AND u."Email" NOT LIKE 'demo-%@food-market.local'
AND u."Email" <> 'admin@food-market.local'
AND u."Email" <> 'admin@demo-market.local'
AND u."Email" <> 'market@email.com'
AND u."OrganizationId" IS NOT NULL
AND u."CreatedAt" < threshold_ts;
GET DIAGNOSTICS deleted_users = ROW_COUNT;
RAISE NOTICE 'Удалено orphan autotest-юзеров: %', deleted_users;
END $do$;
-- Итог
SELECT 'users_remaining' AS metric, COUNT(*) AS value FROM users
UNION ALL
SELECT 'orgs_remaining', COUNT(*) FROM organizations;

View file

@ -1,103 +0,0 @@
-- deploy/stage-reset-demo.sql
-- Очистка организации «Демо-магазин» на стенде от ВСЕХ транзакционных данных,
-- КРОМЕ каталога (товары/группы/штрихкоды/цены/ед.изм./картинки), сотрудников/
-- пользователей/ролей и магазина/точек продаж. По поправке владельца 07.10.2026.
--
-- Запуск (с обязательной предварительной резервной копией!):
-- ssh nns@192.168.1.190 "docker exec -i food-market-stage-postgres \
-- psql -U food_market -d food_market -v demo_org=<UUID>" < deploy/stage-reset-demo.sql
--
-- Идемпотентно. KEEP-таблицы НЕ трогаются. Удаление — только по OrganizationId
-- целевой орги; FK-порядок разрешается retry-loop'ом (как в stage-prune-autotest.sql).
\set ON_ERROR_STOP on
-- psql не подставляет :var внутри dollar-quoted DO-блока, поэтому пробрасываем
-- org через GUC и читаем current_setting() внутри блока.
SELECT set_config('fm.demo_org', :'demo_org', false);
DO $do$
DECLARE
org uuid := current_setting('fm.demo_org')::uuid;
keep text[] := ARRAY[
-- каталог
'products','product_prices','product_barcodes','product_groups','product_images',
'org_units_of_measure','price_types',
-- сотрудники / пользователи / роли
'employees','employee_roles','users','roles',
-- магазин и точки продаж
'stores','retail_points','employee_retail_point_assignments',
-- прочий org-scoped reference, который владелец хочет сохранить
'expense_items','user_presets'
];
tbl text;
pass int;
remaining int;
total_deleted bigint := 0;
n bigint;
prod_cnt bigint;
BEGIN
IF org IS NULL THEN
RAISE EXCEPTION 'demo_org не задан (psql -v demo_org=...)';
END IF;
PERFORM 1 FROM organizations WHERE "Id" = org;
IF NOT FOUND THEN
RAISE EXCEPTION 'Орг % не найдена', org;
END IF;
RAISE NOTICE 'Очистка Демо-орг % — KEEP % таблиц', org, array_length(keep,1);
FOR pass IN 1..12 LOOP
remaining := 0;
FOR tbl IN
SELECT c.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema=c.table_schema AND t.table_name=c.table_name
WHERE c.table_schema='public'
AND c.column_name='OrganizationId'
AND t.table_type='BASE TABLE'
AND c.table_name <> ALL(keep)
LOOP
BEGIN
EXECUTE format('DELETE FROM public.%I WHERE "OrganizationId"=$1', tbl) USING org;
GET DIAGNOSTICS n = ROW_COUNT;
total_deleted := total_deleted + n;
EXCEPTION WHEN foreign_key_violation THEN
remaining := remaining + 1;
END;
END LOOP;
EXIT WHEN remaining = 0;
END LOOP;
IF remaining <> 0 THEN
RAISE EXCEPTION 'Остались неудалённые таблицы из-за FK после 12 проходов (remaining=%)', remaining;
END IF;
SELECT count(*) INTO prod_cnt FROM products WHERE "OrganizationId"=org;
RAISE NOTICE 'Удалено строк: %, товаров осталось: %', total_deleted, prod_cnt;
END $do$;
-- Контроль: документы и остатки должны быть 0, каталог/сотрудники/точки целы.
SELECT 'products' m, count(*) v FROM products WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'product_prices', count(*) FROM product_prices WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'product_barcodes', count(*) FROM product_barcodes WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'product_groups', count(*) FROM product_groups WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'employees', count(*) FROM employees WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'users', count(*) FROM users WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'stores', count(*) FROM stores WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'retail_points', count(*) FROM retail_points WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'supplies', count(*) FROM supplies WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'retail_sales', count(*) FROM retail_sales WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'stocks', count(*) FROM stocks WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'stock_movements', count(*) FROM stock_movements WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'money_accounts', count(*) FROM money_accounts WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'money_transactions', count(*) FROM money_transactions WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'cash_shifts', count(*) FROM cash_shifts WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'enters', count(*) FROM enters WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'losses', count(*) FROM losses WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'inventories', count(*) FROM inventories WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'invoice_uploads', count(*) FROM invoice_uploads WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'counterparties', count(*) FROM counterparties WHERE "OrganizationId"=:'demo_org'
UNION ALL SELECT 'org_audit_log', count(*) FROM org_audit_log WHERE "OrganizationId"=:'demo_org'
ORDER BY m;

View file

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

View file

@ -1,9 +0,0 @@
[Unit]
Description=food-market PROD daily DB backup (30d retention + uploads)
After=docker.service
[Service]
Type=oneshot
Environment=FM_BACKUP_TIER=daily
ExecStart=/home/nns/food-market/deploy/prod-backup.sh
ExecStartPost=/home/nns/food-market/deploy/prod-restore-verify.sh
User=nns

View file

@ -1,7 +0,0 @@
[Unit]
Description=Run food-market PROD daily backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,8 +0,0 @@
[Unit]
Description=food-market PROD hourly DB backup (48h retention)
After=docker.service
[Service]
Type=oneshot
Environment=FM_BACKUP_TIER=hourly
ExecStart=/home/nns/food-market/deploy/prod-backup.sh
User=nns

View file

@ -1,8 +0,0 @@
[Unit]
Description=Run food-market PROD hourly backup
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=120
[Install]
WantedBy=timers.target

View file

@ -1,10 +0,0 @@
[Unit]
Description=food-market migration delta sync (MoySklad -> ALL MARKET stage)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /home/nns/food-market/scripts/migration/delta_sync.py
User=nns
# лог прогона — для контроля отставания
StandardOutput=append:/home/nns/.fm-watchdog/migration-delta.log
StandardError=append:/home/nns/.fm-watchdog/migration-delta.log

View file

@ -1,8 +0,0 @@
[Unit]
Description=Run migration delta sync every 10 min (until cutover)
[Timer]
OnBootSec=5min
OnUnitActiveSec=10min
AccuracySec=30s
[Install]
WantedBy=timers.target

View file

@ -1,6 +0,0 @@
[Unit]
Description=food-market PROD offsite backup pull to dev-vm (DR copy)
[Service]
Type=oneshot
ExecStart=/home/nns/pull-prod-backup.sh
User=nns

View file

@ -1,7 +0,0 @@
[Unit]
Description=Pull food-market PROD daily backup offsite (04:00)
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,7 +0,0 @@
[Unit]
Description=food-market PROD health monitor (telegram alerts)
After=docker.service
[Service]
Type=oneshot
ExecStart=/home/nns/food-market/deploy/prod-monitor.sh
User=nns

View file

@ -1,7 +0,0 @@
[Unit]
Description=Run food-market PROD monitor every 5 min
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target

View file

@ -1,180 +0,0 @@
"""Telegram bridge: webhook receiver, paste-to-tmux only.
Refactored from the original 2-second polling loop to a fully event-driven
design: outgoing assistant messages are now pushed by the Claude Code Stop
hook (/usr/local/bin/cc-tg-notify-stop). This bridge only handles the
inbound side — Telegram → tmux paste.
Config (/etc/food-market/telegram.env or env vars):
TELEGRAM_BOT_TOKEN — bot token (required)
TELEGRAM_CHAT_ID — single whitelisted chat id (required)
TELEGRAM_WEBHOOK_URL — public URL Telegram should POST to
(default: https://test.food-market.kz/tg-webhook)
TELEGRAM_WEBHOOK_SECRET — random secret; bridge validates the
X-Telegram-Bot-Api-Secret-Token header on every
incoming request and Telegram sends it back so
third parties can't forge updates
TMUX_SESSION — tmux session to paste into (default: claude)
WEBHOOK_LISTEN_HOST — local bind host (default: 127.0.0.1)
WEBHOOK_LISTEN_PORT — local bind port (default: 8765)
"""
from __future__ import annotations
import asyncio
import logging
import os
import subprocess
import sys
from pathlib import Path
from telegram import Update
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
ENV_FILE = Path("/etc/food-market/telegram.env")
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("WEBHOOK_LISTEN_PORT", "8765"))
WEBHOOK_PATH = "/tg-webhook"
logger = logging.getLogger("bridge")
def load_env(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.exists():
return out
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip('"').strip("'")
return out
async def tmux_send_text(session: str, text: str) -> None:
"""Pastes one Telegram message verbatim into the tmux session, then Enter.
Uses `send-keys -l` for literal paste — no key-binding interpretation,
works for arbitrary text including unicode and special chars.
"""
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "-l", text,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
proc = await asyncio.create_subprocess_exec(
"tmux", "send-keys", "-t", session, "Enter",
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
def _allowed(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
chat_id = context.application.bot_data["chat_id"]
return update.effective_chat is not None and update.effective_chat.id == chat_id
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not _allowed(update, context):
return
await update.message.reply_text(f"pong — webhook mode, tmux session «{TMUX_SESSION}»")
QUIET_FLAG = "/tmp/cc-tg-quiet"
async def cmd_quiet(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Заткнуть PreToolUse прогресс-ленту (Stop hook продолжает работать)."""
if not _allowed(update, context):
return
try:
open(QUIET_FLAG, "w").close()
await update.message.reply_text("🔕 Прогресс-лента отключена. Включить — /loud")
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ {exc}")
async def cmd_loud(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Включить обратно PreToolUse прогресс-ленту."""
if not _allowed(update, context):
return
try:
os.unlink(QUIET_FLAG)
except FileNotFoundError:
pass
except Exception as exc: # noqa: BLE001
await update.message.reply_text(f"⚠️ {exc}")
return
await update.message.reply_text("🔔 Прогресс-лента включена.")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not _allowed(update, context):
return
text = (update.message.text or "").strip() if update.message else ""
if not text:
return
logger.info("inbound message: %d chars", len(text))
try:
await tmux_send_text(TMUX_SESSION, text)
except Exception as exc: # noqa: BLE001
logger.warning("paste to tmux failed: %s", exc)
await update.message.reply_text(f"⚠️ tmux error: {exc}")
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
env = {**os.environ, **load_env(ENV_FILE)}
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
secret = env.get("TELEGRAM_WEBHOOK_SECRET", "").strip()
webhook_url = env.get("TELEGRAM_WEBHOOK_URL", "https://test.food-market.kz/tg-webhook").strip()
if not token or not chat_id_raw:
print("ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr)
return 78
try:
chat_id = int(chat_id_raw)
except ValueError:
print(f"ERROR: TELEGRAM_CHAT_ID must be int, got {chat_id_raw!r}", file=sys.stderr)
return 78
if not secret:
logger.warning("TELEGRAM_WEBHOOK_SECRET is empty — webhook is unauthenticated")
application = ApplicationBuilder().token(token).build()
application.bot_data["chat_id"] = chat_id
application.add_handler(CommandHandler("ping", cmd_ping))
application.add_handler(CommandHandler("quiet", cmd_quiet))
application.add_handler(CommandHandler("loud", cmd_loud))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
logger.info("starting webhook listener on %s:%d → %s", LISTEN_HOST, LISTEN_PORT, webhook_url)
application.run_webhook(
listen=LISTEN_HOST,
port=LISTEN_PORT,
url_path=WEBHOOK_PATH.lstrip("/"),
webhook_url=webhook_url,
secret_token=secret or None,
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=False,
stop_signals=None,
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,124 +0,0 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook: шлёт короткую строку в Telegram перед
# каждым tool-call'ом для ощущения «активности». Дебаунс 1.5с — пока
# tool-вызовы летят пачкой, копим в /tmp буфер и шлём одним сообщением
# через 1.5 секунды тишины.
#
# Конфиг — /etc/food-market/telegram.env. Логи — /var/log/cc-tg-notify.log.
# Off-switch: создать /tmp/cc-tg-quiet — все pretool-уведомления
# скипаются (Stop hook продолжает работать).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
BUF="/tmp/cc-tg-pretool-buffer.txt"
LAST="/tmp/cc-tg-pretool-last"
LOCK="/tmp/cc-tg-pretool.lock"
QUIET_FLAG="/tmp/cc-tg-quiet"
DEBOUNCE_SEC="1.5"
MAX_LINES=20
log() { printf '%s [pretool] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
[[ -f "$QUIET_FLAG" ]] && exit 0
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
[[ -z "$TOKEN" || -z "$CHAT_ID" ]] && exit 0
INPUT_JSON=""
if [[ ! -t 0 ]]; then INPUT_JSON="$(cat)"; fi
[[ -z "$INPUT_JSON" ]] && exit 0
TOOL="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_name // empty' 2>/dev/null)"
[[ -z "$TOOL" || "$TOOL" == "TodoWrite" ]] && exit 0
# Извлекаем поле tool_input под нужный тип. cut -c обрезает многобайтные
# UTF-8 неаккуратно, но для urlencode результат остаётся валидным.
LINE=""
case "$TOOL" in
Bash)
DESC="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | tr '\n' ' ' | head -c 100)"
CMD="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.command // empty' 2>/dev/null | tr '\n' ' ' | head -c 80)"
if [[ -n "$DESC" ]]; then LINE="🔨 $DESC"; else LINE="🔨 Bash: $CMD"; fi
;;
Edit)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="✏️ Edit: $(basename "${FP:-?}")"
;;
Write)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📝 Write: $(basename "${FP:-?}")"
;;
Read)
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
LINE="📖 Read: $(basename "${FP:-?}")"
;;
Grep)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 30)"
LINE="🔍 Grep: \"$P\""
;;
Glob)
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 50)"
LINE="🌐 Glob: $P"
;;
WebFetch)
U="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.url // empty' 2>/dev/null | head -c 60)"
LINE="🌍 Fetch: $U"
;;
WebSearch)
Q="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.query // empty' 2>/dev/null | head -c 60)"
LINE="🔎 Search: $Q"
;;
Task)
D="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | head -c 60)"
LINE="🎯 Task: $D"
;;
*)
LINE="🔧 $TOOL"
;;
esac
[[ -z "$LINE" ]] && exit 0
NOW="$(date +%s%N | cut -c1-13)"
# Append + bump LAST под flock'ом — конкурентные hook'и не теряют строки.
(
flock 9
echo "$LINE" >> "$BUF"
echo "$NOW" > "$LAST"
) 9>"$LOCK"
# Дебаунс-flusher в фоне. Каждый hook спавнит свой sleep, но только
# тот, чей NOW совпал с финальным LAST после задержки, реально шлёт —
# остальные тихо выходят.
(
sleep "$DEBOUNCE_SEC"
(
flock 9
LAST_TS="$(cat "$LAST" 2>/dev/null || echo 0)"
if [[ "$LAST_TS" != "$NOW" ]]; then
# Пришёл более свежий tool — он заfflushит сам.
exit 0
fi
[[ -s "$BUF" ]] || exit 0
# Если буфер длиннее MAX_LINES — режем хвост (свежие строки важнее).
BODY="$(tail -n "$MAX_LINES" "$BUF")"
: > "$BUF"
curl -fsS -m 10 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${BODY}" \
--data-urlencode "disable_notification=true" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed"
) 9>"$LOCK"
) &
# Не ждём фоновую задачу — Claude Code продолжает выполнение tool'а.
disown 2>/dev/null || true
exit 0

View file

@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Claude Code Stop hook: вытаскивает финальный assistant-ответ из transcript'а
# и пушит в Telegram. Устанавливается на /usr/local/bin/cc-tg-notify-stop.
#
# Hook runtime передаёт JSON на stdin с полем .transcript_path; раньше это
# приходило как $CLAUDE_TRANSCRIPT_PATH env-var, но в новых версиях стрим
# переехал в stdin. Поддерживаем оба варианта.
#
# Конфиг — /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
# Логи — /var/log/cc-tg-notify.log (rotated externally).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
PROJECT_TAG="${CC_TG_TAG:-food-market}"
MAX_CHUNK=4000
log() { printf '%s [stop-hook] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
if [[ -z "$TOKEN" || -z "$CHAT_ID" ]]; then
log "missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID"
exit 0
fi
# Читаем JSON со stdin (если пришёл) или берём env-vars (legacy).
INPUT_JSON=""
if [[ -t 0 ]]; then
INPUT_JSON=""
else
INPUT_JSON="$(cat)"
fi
TRANSCRIPT="${CLAUDE_TRANSCRIPT_PATH:-}"
if [[ -z "$TRANSCRIPT" && -n "$INPUT_JSON" ]]; then
TRANSCRIPT="$(printf '%s' "$INPUT_JSON" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
fi
if [[ -z "$TRANSCRIPT" || ! -r "$TRANSCRIPT" ]]; then
log "no transcript path (stdin=${#INPUT_JSON} chars, env=${CLAUDE_TRANSCRIPT_PATH:-unset})"
exit 0
fi
# Последняя assistant-запись с непустым text-блоком. JSONL: одна запись на строку.
TEXT="$(jq -r '
select(.type == "assistant")
| .message.content[]?
| select(.type == "text" and (.text // "" | length) > 0)
| .text
' "$TRANSCRIPT" 2>/dev/null \
| awk 'BEGIN{RS=""}{a=$0} END{print a}')"
# awk выше склеивает все записи в одну; нам нужна именно ПОСЛЕДНЯЯ assistant-запись,
# поэтому делаем второй проход: берём индекс последней записи и достаём её text-блоки.
LAST_TEXT="$(jq -s -r '
map(select(.type == "assistant")) | last as $m
| ($m.message.content // [])
| map(select(.type == "text" and (.text // "" | length) > 0) | .text)
| join("\n")
' "$TRANSCRIPT" 2>/dev/null)"
if [[ -n "$LAST_TEXT" ]]; then TEXT="$LAST_TEXT"; fi
if [[ -z "$TEXT" ]]; then
log "no text in last assistant turn (only tool calls?)"
exit 0
fi
# Чанкуем по строкам с лимитом MAX_CHUNK; первый чанк — с префиксом.
PREFIX="🤖 [${PROJECT_TAG}]"
send_chunk() {
local body="$1"
curl -fsS -m 15 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${body}" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed (curl rc=$?)"
}
CHUNK="$PREFIX"$'\n'
EMITTED=0
while IFS= read -r line; do
if (( ${#CHUNK} + ${#line} + 1 > MAX_CHUNK )); then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
CHUNK=""
fi
CHUNK+="$line"$'\n'
done <<<"$TEXT"
if [[ -n "$CHUNK" ]]; then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
fi
log "sent $EMITTED chunk(s), text=${#TEXT} chars"
exit 0

View file

@ -1 +0,0 @@
python-telegram-bot[rate-limiter]==21.6

View file

@ -1,19 +0,0 @@
[Unit]
Description=food-market Telegram <-> tmux bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nns
Group=nns
WorkingDirectory=/opt/food-market-data/telegram-bridge
EnvironmentFile=-/etc/food-market/telegram.env
ExecStart=/opt/food-market-data/telegram-bridge/venv/bin/python /opt/food-market-data/telegram-bridge/bridge.py
Restart=on-failure
RestartSec=10
# Access tmux sockets under /tmp/tmux-1000/
Environment=TMUX_TMPDIR=/tmp
[Install]
WantedBy=multi-user.target

View file

@ -1,486 +0,0 @@
# food-market — архитектура
Документ для разработчика, который пришёл в проект первый раз. Описывает
слои, модули, ключевые потоки и почему некоторые вещи сделаны именно так.
Старая короткая версия — `docs/architecture.md` (lowercase). Этот файл
заменяет её и расширяет.
## TL;DR
- **Что**: multi-tenant SaaS-аналог МойСклад для розничных магазинов РК.
- **Backend**: .NET 8 LTS, ASP.NET Core, EF Core 8, PostgreSQL 14+ (dev) / 16 (prod).
- **Auth**: OpenIddict 5 (password + refresh) поверх ASP.NET Identity.
- **Web**: React 19 + Vite + TS, Tailwind v4, shadcn/ui, TanStack Query, AG Grid.
- **POS**: WPF на .NET 8 Windows, оффлайн-буфер в SQLite, синк через `/api/pos/v1`.
## Топология deployment
```
┌─────────────────────────────────────────────────────────────────┐
│ Internet / LAN магазина │
└───────────┬───────────────────────┬─────────────────────────────┘
│ HTTPS │ HTTPS (Bearer) + офлайн-буфер
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ food-market.web │ │ food-market.pos (WPF) │
│ React SPA │ │ .NET 8, Windows 10+ │
│ admin.fm.kz │ │ локальная SQLite │
└─────────┬──────────┘ └──────────┬───────────────┘
│ │
│ /api/* │ /api/pos/v1/*
│ /hubs/notifications │
└─────────────┬────────────┘
▼
┌───────────────────────────────────────────┐
│ food-market.api │
│ ASP.NET Core + OpenIddict + SignalR │
│ - tenant query filters per request │
│ - Hangfire scheduler + recurring jobs │
│ - /metrics (Prometheus) /health/{live,ready}│
└────┬──────────┬───────────┬───────────┬───┘
▼ ▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│Postgres│ │ Hangfire│ │ MinIO │ │ Logs │
│ 16 │ │ (jobs) │ │ (S3, opt)│ │ Serilog │
└────────┘ └─────────┘ └──────────┘ └──────────┘
│
▼
локальный FS (/uploads volume)
— если MinIO не настроен
```
Stage и prod крутятся через `deploy/docker-compose.yml` на dev-vm
(`192.168.1.190`). Локальный dev: API на `:5081`, Postgres из
brew (`postgres@14` на `:5432`), web через `pnpm dev` на `:5173`.
## Структура солюшна
```
food-market/
├── src/
│ ├── food-market.domain/ ← POCO, enum, доменные интерфейсы
│ ├── food-market.application/ ← MediatR-handlers, DTO, абстракции
│ ├── food-market.infrastructure/ ← EF Core, Identity, OpenIddict EF, внешние API
│ ├── food-market.api/ ← ASP.NET Core host: controllers, middleware, DI
│ ├── food-market.web/ ← React SPA
│ ├── food-market.shared/ ← DTO-контракты api ↔ pos
│ ├── food-market.public/ ← Astro static (маркетинг food-market.kz)
│ ├── food-market.pos.core/ ← логика POS (без UI)
│ └── food-market.pos/ ← WPF UI (net8.0-windows)
├── tests/
│ ├── food-market.UnitTests/ ← xUnit + InMemoryDB
│ ├── food-market.IntegrationTests/← xUnit + Testcontainers Postgres
│ ├── e2e/ ← Playwright (TS), бьёт по test.admin.food-market.kz
│ └── load/ ← k6 (Sprint 12)
├── deploy/ ← docker-compose, Dockerfile.*, systemd-юниты
└── docs/ ← вы здесь
```
### Слои (Clean Architecture)
| Слой | Зависит от | Что лежит |
|-------------------|---------------------------|----------------------------------------------------------------------------------------|
| **domain** | ничего | POCO-сущности, enum'ы, доменные интерфейсы (`ITenantEntity`, `IVersionedEntity`). |
| **application** | domain + shared | MediatR `IRequest`/`IRequestHandler`, DTO, абстракции (`IFiscalProvider`, `IEmailSender`, `IStockService`, `ITenantContext`), `FluentValidation` валидаторы. |
| **infrastructure**| application + domain | `AppDbContext`, Identity-таблицы, OpenIddict EF store, реализации абстракций, HTTP-клиенты к внешним API (Webkassa, MoySklad, MailKit, Telegram). |
| **api** | всё перечисленное выше | ASP.NET Core host: контроллеры, middleware, DI-проводка, фоновые джобы (Hangfire), Realtime hub'ы (SignalR), сидеры. |
Правило одностороннего направления зависимостей: домен не знает про EF и
ASP.NET, application — про конкретные провайдеры. Это позволило прикрутить
ОФД (Sprint 11) одним интерфейсом + четырьмя реализациями, без правок
контроллеров кроме одной точки вызова.
## Модули backend
### Domain (`src/food-market.domain/`)
- `Common/Entity.cs` — базовая `Entity` с `Id/CreatedAt/UpdatedAt`.
- `Common/TenantEntity.cs` — `ITenantEntity` (обязательный `OrganizationId`),
`TenantEntity` (база), `IOptionalTenantEntity` (системные справочники с
`OrganizationId?`).
- `Common/IVersionedEntity.cs` — оптимистичная блокировка через PG `xmin`
(`Xmin` поле).
- Бизнес-сущности по поддоменам: `Catalog/` (Product, Counterparty,
ProductGroup, …), `Inventory/` (Stock, StockMovement, Loss, Transfer,
Inventory), `Purchases/` (Supply, Enter, SupplierReturn),
`Sales/` (RetailSale, RetailSaleLine, Demand, LoyaltyCard,
LoyaltyProgram, Promotion), `Organizations/` (Organization, Employee,
EmployeeRole, OrgAuditLog, SuperAdminAuditLog), `Platform/`
(PlatformSettings — singleton SMTP-конфиг).
### Application (`src/food-market.application/`)
- **CQRS на MediatR** — пока partial: образцы в `Purchases/Commands/CreateSupplyCommand.cs`,
`Sales/Commands/PostRetailSaleCommand.cs`, `Sales/Queries/GetSalesReportQuery.cs`.
Большинство контроллеров пока «толстые» (исторически до TD-1).
- **Абстракции**:
- `Common/Tenancy/ITenantContext` — `OrganizationId`, `IsSuperAdmin`,
`IsTenantOverride`, `UserId`.
- `Common/Email/IEmailSender` — отправка через текущий SMTP-конфиг.
- `Common/Fiscal/IFiscalProvider` + `IFiscalProviderFactory` (Sprint 11).
- `Inventory/IStockService` — единая точка списания/начисления остатка
(любая операция, меняющая склад, идёт через `ApplyMovementAsync`).
- **FluentValidation** валидаторы рядом с DTO; глобально подключаются
через `AddValidatorsFromAssemblyContaining<Program>()`.
### Infrastructure (`src/food-market.infrastructure/`)
- `Persistence/AppDbContext.cs` — единый DbContext (тенанта + Identity +
OpenIddict EF store). Query-filter применяется через reflection ко всем
`ITenantEntity` (см. [MULTI-TENANCY.md](MULTI-TENANCY.md)).
- `Persistence/Configurations/*.cs` — EF Core fluent configs по поддоменам.
- `Persistence/Migrations/` — миграции пишутся вручную (см. CLAUDE.md /
memory `feedback_ef_migrations`), снапшот не синхронизируется
с моделью (используется только `dotnet ef migrations add`, который
не вызывается в этом проекте).
- `Persistence/OrgAuditInterceptor.cs` — EF `ISaveChangesInterceptor`,
пишет каждую `Add/Update/Delete` в `org_audit_log` (JSONB diff).
- `Identity/` — кастомные `User`, `Role` для ASP.NET Identity.
- `Email/MailKitEmailSender.cs` — SMTP через MailKit, конфиг из
`PlatformSettings` (читается на каждой отправке через scope).
- `Fiscal/` — `IFiscalProvider` реализации: Mock + Webkassa (полный) +
Kassa24/OfdSolo (skeleton). См. [ofd-integration.md](ofd-integration.md).
- `Inventory/StockService.cs` — единственное место, где двигаются остатки.
Бизнес-инвариант: stock = SUM(stock_movements) per (productId, storeId).
- `Integrations/MoySklad/` — HTTP-клиент + конвертер для импорта каталога.
### Api (`src/food-market.api/`)
- `Program.cs` — composition root (~570 строк, поделён логическими
блоками; см. секцию «Composition root» ниже).
- `Controllers/` — REST-API. Структура совпадает с маршрутами:
- `Auth/` — `/api/auth/*` (signup, forgot-password, 2FA).
- `Catalog/` — `/api/catalog/{products,counterparties,…}`.
- `Purchases/` — `/api/purchases/{supplies,supplier-returns}`.
- `Sales/` — `/api/sales/{retail,demands}`.
- `Inventory/` — `/api/inventory/{stock,enters,losses,transfers,inventories}`.
- `Reports/` — `/api/reports/{sales,stock,profit,abc}`.
- `Dashboard/` — `/api/dashboard/{top-products,low-stock,recent-sales,margin}`.
- `Loyalty/`, `Promotions/` — Sprint 9.
- `Organizations/` — настройки орги, сотрудники, роли, ОФД.
- `Pos/` — `/api/pos/v1/*` для WPF POS (sync, idempotency).
- `SuperAdmin/` — `/api/super-admin/*` (управление платформой).
- `Admin/` — `/api/admin/*` (per-org admin tools: cleanup, demo-seed,
moysklad-import, audit-log просмотр).
- `Search/` — глобальный `/api/search/global` (Cmd+K).
- `Telegram/` — bind owner-chat, статус.
- `Uploads/` — multipart upload изображений.
- `Infrastructure/`:
- `Tenancy/HttpContextTenantContext.cs` — реализация `ITenantContext`
через `IHttpContextAccessor` + AsyncLocal-override для background tasks.
- `Tenancy/SuperAdminOverrideClaimsTransformer.cs` — добавляет
`Admin/Cashier/Storekeeper` роли SuperAdmin'у с активным
`X-Org-Override`, чтобы `[Authorize(Roles="Admin")]` не отшил его.
- `Tenancy/ReadonlyOverrideMiddleware.cs` — в режиме override без
`X-Org-Override-Reason` блочит любую мутацию (читать всё, писать
ничего; писать — только в edit-mode с reason).
- `Tenancy/SuperAdminEditAuditFilter.cs` — глобальный action-filter,
при mutate-в-override пишет в `super_admin_audit_log`.
- `Authorization/RequiresPermissionAttribute.cs` + `PermissionAuthorizationPolicyProvider`
+ `PermissionAuthorizationHandler` — permission-based авторизация.
`[RequiresPermission("ProductsEdit")]` → policy `perm:ProductsEdit` →
`RolePermissions.ProductsEdit` булева на `EmployeeRole`.
- `Validation/ValidationFilter.cs` — FluentValidation → 400
ProblemDetails (RFC 7807).
- `RateLimiting/AuthRateLimiterExtensions.cs` — 5/мин + 20/час на
`/connect/token`, `/api/auth/signup` по IP+username.
- `Observability/LogEnrichmentMiddleware.cs` — кладёт
`CorrelationId/OrgId/UserId` в Serilog `LogContext`, каждая запись
в журнале получает эти лейблы.
- `Observability/DbMetricsInterceptor.cs` — EF интерсептор, Prometheus
`food_market_db_query_duration_seconds`.
- `Observability/AppMetrics.cs` — статические Counter'ы (Posted/Unposted
per docType, FiscalRegistered, …).
- `Health/DatabaseReadyHealthCheck.cs` — `SELECT 1` + проверка
`__EFMigrationsHistory`.
- `Security/OpenIddictKeyConfigurator.cs` — в dev — persistent RSA в
`App_Data/oidc-keys/*`; в stage/prod — X509 PFX из конфига
(см. [openiddict-keys.md](openiddict-keys.md)).
- `Realtime/NotificationsHub.cs` + `NotificationsPublisher.cs` —
SignalR-хаб `/hubs/notifications`, группы per-org. События:
`SalePosted`, `LowStock`, `ImportProgress`.
- `Background/`:
- `HangfireJobsConfigurator` — регистрирует recurring jobs при старте:
`prune-stock-movements` (03:30), `prune-audit-log` (03:45),
`weekly-summary` (пн 07:00), `low-stock-alert` (08:00),
`telegram-owner-daily-summary` (06:00).
- `HousekeepingJobs` — pg-cleanup'ы.
- `EmailNotificationJobs` — weekly-summary + low-stock email.
- `OwnerDailySummaryJob` — Telegram-сводка владельцу.
- `ReferencePriceRefreshJob` — пересчёт `Product.ReferencePrice`
каждые 30 дней без приёмок.
- `Seed/`:
- `SystemReferenceSeeder` — справочники (страны, валюты, единицы).
- `OpenIddictClientSeeder` — регистрирует client `food-market-web`.
- `DevDataSeeder` — dev-only admin user (SuperAdmin).
- `DemoTenantSeeder` / `YearDemoSeeder` — заполняют tenant
демо-данными (Sprint 5 / Sprint 10).
### Web (`src/food-market.web/`)
- Vite + React 19 + TS 6, Tailwind v4. Маршрутизация — React Router 6.
- `src/lib/api.ts` — axios instance с auto-refresh токена.
- `src/lib/auth.ts` — login/logout, store токена в `localStorage`.
- `src/components/` — общие виджеты (Field, Button, Skeleton,
CommandPalette, DashboardWidgets).
- `src/pages/` — страницы (один файл per route).
- TanStack Query — кеширование API-вызовов, инвалидация по SignalR.
- AG Grid Community — большие списки (товары, контрагенты, отчёты).
### POS (`src/food-market.pos*/`)
- `pos.core/` — логика без UI: оффлайн-буфер, sync, расчёт чека.
- `pos/` — WPF UI, CommunityToolkit.Mvvm, SQLite, Refit + Polly,
System.IO.Ports для весов CAS.
- Sync: батчем по 50 чеков через `POST /api/pos/v1/batch` с
`Idempotency-Key`. Сервер дедупит через `pos_batch_acks` уникальный
индекс (`OrganizationId, IdempotencyKey`).
## Composition root (`Program.cs`)
Логические блоки в порядке регистрации:
1. **Serilog** bootstrap (до builder).
2. **CORS** (`Cors:AllowedOrigins` из конфига).
3. **HttpContextAccessor** + `ITenantContext` + `IClaimsTransformation`
(SuperAdmin override роли).
4. **EF Core**: `AppDbContext` (Npgsql, OpenIddict, два interceptor'а).
5. **Identity** + **OpenIddict** server (password + refresh, rolling
refresh, leeway = 0).
6. **Authentication/Authorization** policies (`AdminAccess`, `perm:*`).
7. **Rate-limiter** (`/connect/token`, `/api/auth/signup`).
8. **HealthChecks** (`database` тег `ready`).
9. **IEmailSender** (Singleton + scope для DbContext).
10. **IFiscalProvider** + 3 HttpClient + `IFiscalProviderFactory`.
11. **MediatR** (assembly scan), **FluentValidation**.
12. **MoySklad HttpClient** + import service.
13. **Hangfire** server + storage (PG), `HangfireJobsConfigurator` хостед.
14. **SignalR** + `INotificationsPublisher`.
15. **Telegram-бот** HttpClient (если token задан).
16. **Сидеры**: `OpenIddictClientSeeder`, `SystemReferenceSeeder`,
`DevDataSeeder` хостед; `DemoTenantSeeder`/`YearDemoSeeder` scoped.
17. `Build()` → middleware pipeline (Serilog→CORS→HttpMetrics→
RateLimiter→hubs-token-fix→AuthN→AuthZ→LogEnrichment→
ReadonlyOverride→StaticFiles[/uploads]→Swagger→MapControllers→
MapHub→MapMetrics→HangfireDashboard→HealthChecks).
18. На старте: `db.Database.Migrate()` (идемпотентно).
19. `app.Run()`.
## Поток: signup → bootstrap → первая продажа
```
1. POST /api/auth/signup { email, password, organizationName, phone }
─→ создание Organization (Entity, не tenant-scoped)
─→ создание AppUser + добавление в роль "Admin"
─→ создание Employee с AdminRole и всеми permission'ами
─→ создание главного Store (isMain=true) + RetailPoint
─→ создание PriceType "Розничная" (isRetail=true, isSystem=true)
─→ копирование системных UnitOfMeasure (OrganizationId=null) на org
2. POST /connect/token { grant_type=password, username, password }
─→ OpenIddict проверяет, выдаёт access_token + refresh_token
─→ access_token содержит claim org_id и role
3. GET /api/me (web bootstrap)
─→ возвращает { sub, email, roles, orgId, hasLiveOrg, hasActiveEmployee }
─→ фронт роутит на /dashboard или /no-organization (orphan-fallback)
4. POST /api/catalog/products { name, prices, barcodes, ... }
─→ ValidationFilter (FluentValidation)
─→ controller → _db.Products.Add(...) (OrganizationId stamped в SaveChanges)
─→ возвращает Product DTO
5. POST /api/purchases/supplies + POST /{id}/post
─→ post идёт под Serializable tx
─→ для каждой строки StockService.ApplyMovementAsync(+qty, MovementType.Supply)
─→ Stock row для (productId, storeId) либо создаётся, либо обновляется
─→ commit, AppMetrics.IncrementPosted("supply")
─→ SignalR: NotificationsHub → группа org → событие SupplyPosted
6. POST /api/sales/retail + POST /{id}/post
─→ Serializable tx, проверка остатка ≥ 0 для каждой строки
─→ StockService.ApplyMovementAsync(-qty, MovementType.RetailSale)
─→ commit, AppMetrics.IncrementPosted("retail-sale")
─→ best-effort TryFiscalizeAsync (Sprint 11) — отдельно, после commit
─→ SignalR: SalePosted (dashboard виджеты инвалидируют queries)
```
## База данных
Postgres 14+ для dev (brew systemwide), Postgres 16 в Docker для
stage/prod. Названия таблиц snake_case через явный `ToTable("…")`.
### Ключевые таблицы
| Таблица | Назначение |
|---|---|
| `organizations` | Корневой tenant. Не tenant-scoped. |
| `users`, `roles`, `user_roles` | ASP.NET Identity. |
| `employees`, `employee_roles`, `role_permissions` | Сотрудники tenant'а + кастомные роли с булевыми флагами прав. |
| `products`, `product_prices`, `product_barcodes`, `product_images`, `product_groups` | Каталог товаров. |
| `counterparties` | Поставщики + покупатели (тип=Supplier/Individual/Legal). |
| `stores`, `retail_points`, `units_of_measure`, `currencies`, `price_types`, `countries` | Справочники. |
| `stocks`, `stock_movements` | Остатки + история движений. `stocks` — кеш `SUM(stock_movements)`. |
| `supplies`, `supply_lines`, `enters`, `enter_lines`, `supplier_returns`, `supplier_return_lines` | Приходные документы. |
| `losses`, `loss_lines`, `transfers`, `transfer_lines`, `inventory_docs`, `inventory_lines` | Внутренний учёт. |
| `retail_sales`, `retail_sale_lines` | Чеки розницы + строки чека. Sprint 11: ОФД-снапшоты на `retail_sales` (FiscalNumber, FiscalQrCode, …). |
| `demands`, `demand_lines` | Опт-отгрузки. |
| `loyalty_programs`, `loyalty_cards`, `promotions` | Sprint 9. |
| `pos_batch_acks` | Идемпотентность POS-синка (UNIQUE OrganizationId, IdempotencyKey). |
| `org_audit_log` | JSONB-diff каждой mutate-операции tenant'а. |
| `super_admin_audit_log` | Действия SuperAdmin'а (особенно в режиме «открыто как…»). |
| `platform_settings` | Singleton: SMTP-конфиг платформы. |
| `system_settings` | Singleton: per-tenant фичи (не путать с platform). |
| `import_jobs` | История импортов MoySklad. |
OpenIddict хранит `openiddict_applications`/`authorizations`/`tokens`/`scopes`.
Hangfire — `hangfire.*` (в своей схеме).
### Concurrency
`IVersionedEntity` сущности (Supply, RetailSale, Demand, Enter, Loss,
Transfer, InventoryDoc, SupplierReturn) включают PG `xmin` через
`UseXminAsConcurrencyToken()`. Параллельные апдейты одного документа
получают `DbUpdateConcurrencyException`, контроллер возвращает 409.
Post-операции, изменяющие остаток, идут под `IsolationLevel.Serializable`
(см. `RetailSalesController.Post`, `SuppliesController.Post`, …) —
это защищает от race в `SUM(stock_movements)`-инварианте.
## Внешние интеграции
| Сервис | Где | Состояние |
|---|---|---|
| **MoySklad** | `Infrastructure/Integrations/MoySklad/` | Импорт товаров, контрагентов, остатков. Per-org token в `Organization.MoySkladToken`. |
| **SMTP** | `Infrastructure/Email/MailKitEmailSender.cs` | Платформенный SMTP в `PlatformSettings` (SuperAdmin настраивает). Используется для invite, forgot-password, weekly-summary. |
| **Telegram Bot** | `Api/Integrations/Telegram/` | Owner-сводка. Per-org `OwnerTelegramChatId`. Bot token в env. |
| **ОФД (Webkassa / Kassa24 / ОФД-Соло)** | `Infrastructure/Fiscal/` | Sprint 11 scaffolding. Per-org провайдер + креды. |
| **MinIO (S3)** | `Api/Storage/StorageBootstrap.cs` | Опциональный сторадж изображений. Если не настроен — `/uploads` volume на FS. |
## Тесты
- **Unit** (`tests/food-market.UnitTests/`) — xUnit + InMemory EF + чистые
юниты на валидаторы, payload-builder'ы, MediatR-handler'ы.
- **Integration** (`tests/food-market.IntegrationTests/`) — xUnit +
Testcontainers Postgres (`postgres:16-alpine`). Полный API через
`WebApplicationFactory<Program>`. Shared `ApiFactory` через
`ApiCollection` (один контейнер на сессию xunit). Memory note:
`test_suites_setup` — Ryuk выключен (TCN не тянет с docker-hub),
rate-limiter eager-config через env-переменную.
- **E2E** (`tests/e2e/`) — Playwright (TS) против stage
`https://test.admin.food-market.kz`. Используется в verify-suite'ах
по спринтам.
- **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`.
### Sprint 13-22 changes (быстрая сводка)
| Sprint | Что добавлено / изменено |
|---|---|
| **13** (security) | `SecurityHeadersMiddleware` (CSP, X-Frame, HSTS); rate-limit на signup (3/h IP) и forgot-password (3/h email + 10/h IP); `SensitiveOpsAudit` сервис для logged-ops; `POST /api/me/sessions/revoke-all` через `IOpenIddictAuthorizationManager`; Hangfire-dashboard под `SuperAdminHangfireFilter` + nginx /hangfire location. Также: dedicated PG-роль `food_market_server_app` для legacy back.food-market.kz (без superuser). |
| **14** (perf) | Phase14a индексы (composite + partial с INCLUDE на `retail_sales`); N+1 fix в `SalesReportController.FetchAsync`; React.lazy на 30 редких страниц + Recharts lazy → bundle 1456→706 KB (−51%); ImageSharp генерирует thumb/medium WebP при загрузке + `<ProductImage>` с `<picture>` srcset; Npgsql pool (Min=10/Max=100/AutoPrepare=20); `JobTimingFilter` для Hangfire-jobs. |
| **15** (a11y + tests) | `useFocusTrap` (WCAG 2.4.3/2.1.2) на Modal + ConfirmDialog; axe-core spec-suite (10 страниц, 0 critical); aria-label на icon-only back-links + role="alert" на form errors; coverage Application 67%→83%, Domain 11%→79%; property-based tests на StockService (Σ movements ≡ Stock); verified backup-recovery drill RTO ~25s. |
| **16** (regression) | Regression suite 35 Playwright flows + 60 visual snapshots; nightly stage-verify cron; Forgejo workflow regression; README badges; factories для test-data. |
| **17** (onboarding) | `/onboarding-wizard` (4 шага + skip + `localStorage.fm.wizardCompleted`); `HelpTooltip` + 13 topics; `/help` knowledge base из 7 markdown'ов; `FeedbackWidget` (bug/suggestion/question + Telegram fallback); `/admin/diagnostic` (7 параллельных проверок); `/whats-new` из `CHANGELOG.md`; `EmptyStateWithDemo`. |
| **18** (TODO cleanup) | P0 race в `GenerateNumberAsync` через PostgreSQL advisory lock (`pg_advisory_xact_lock(orgHash, docTypeHash)`); `WhatsNewBanner` в AppLayout; color contrast WCAG-AA (19 файлов); `useFormatCurrency()` hook; audit-log UI filters (Кто/Дата с/по); `NotificationCenter` (bell-icon SignalR-popover). |
| **19** (power UX) | Phase19a: Product.IsArchived + IsAvailableForSale (partial-index). `POST /api/catalog/products/bulk-update {ids, op, params}` — 5 операций (price-adjust %/абсолют, change-group, archive/unarchive, toggle-sale) одной транзакцией. `SavedPresets` chips (UserPreset jsonb). `QuickActionsPalette` (Cmd+J отдельно от Cmd+K). `InlinePriceCell` dblclick → input optimistic + revert. CSV import 1000 строк транзакцией. `ExportButton` (CSV/XLSX) на 5 контроллерах. Keyboard nav в DataTable (↑↓/Enter/Space/Delete). |
| **20** (Mapster + maintenance) | TD-3: `MapsterConfig.cs` + `.ProjectToType<TDto>(MapsterConfig.Config)` вместо ручных Select-expression'ов. SSO scaffold: `Microsoft.AspNetCore.Authentication.Google` + `.MicrosoftAccount` (conditional registration); `ExternalAuthController` (503 если не настроено, 501 callback с email для invite-flow). 3 новых cleanup-job'a (org-audit-log >90д, drafts >30д, refresh-tokens revoked >7д). `DatabaseMaintenanceJobs.VacuumTopTablesAsync` (топ-5 таблиц, weekly). `DiskMonitoringJob` ежечасно + Telegram-alert <1GB + Prom-gauge `food_market_disk_free_bytes{mount}`. `~/nightly-perf-check.sh` baseline-comparison через `/metrics`. Astro layout: gtag/Yandex.Metrika placeholders + `docs/analytics.md`. |
| **21** (prod toolchain) | `deploy/check-prod-readiness.sh` (backup<60min, disk≥5GB, /health, .env), `prod-deploy.sh` (blue-green :8088 + nginx upstream switch), `prod-rollback.sh` (atomic), `post-deploy-smoke.sh` (10 шагов JSON через python3 — на stage 10/10 ✓), `db-schema-diff.sh` (pg_dump через ssh+docker exec, sed-нормализация, diff -u), `generate-release-notes.sh` (git log → markdown group by prefix), `.forgejo/workflows/auto-tag.yml` (v<YYYYMMDD>.<N>). Все скрипты — `--dry-run`. |
| **22** (data tooling) | Phase22a: `org_exports` таблица (jsonb config-like, unique download token). `POST /api/org/export` → Hangfire `OrgExportJob` собирает ZIP с JSON-файлами по каждой сущности → IObjectStorage + DownloadToken 64-hex + 24h TTL + email-notify. `POST /api/catalog/products/import/1c-csv` (Windows-1251 + auto-detect разделитель + русские заголовки). `deploy/anonymize-prod.sh` (PII обфускация: email→user{N}@example.kz, phone→+7700111{N:04}, passwords→тестовый hash, BIN/IIN синтетические, MoySkladToken=NULL). `DbSchemaDocsJob` weekly → `db-schema-generated.md` с mermaid ER-диаграммой. `POST /api/admin/audit-log/export?format=csv|jsonl` streaming. `GET /api/moysklad/sync-status` агрегат last-success / last-7d errors / pending. **Итог: финальный ARCHITECTURE.md (этот).** |
## Production readiness (после 22 спринтов)
### Реализовано полностью
- Backend: auth (OpenIddict password+refresh+revoke), multi-tenant (query-filter + advisory locks), все 8 типов документов (Supply/Enter/Loss/Transfer/Inventory/RetailSale/Demand/SupplierReturn+CustomerReturn) с проводкой через Serializable transactions и ОФД-snapshot полями.
- Каталог: products + barcodes + prices + images (thumb/medium WebP), groups (иерархия с Path), counterparties, units, currencies, countries, stores, retail-points.
- Reports: Sales / Stock / Profit / ABC с CSV+XLSX export'ом, all multi-tenant.
- Background: Hangfire с 10 recurring jobs (housekeeping, email-notify, telegram-summary, vacuum, disk-monitor, db-schema-docs).
- Observability: Prometheus `/metrics` (HTTP + DB query duration + business counters + disk gauge), Serilog structured logging, /health/{live,ready} с DB-проверкой.
- A11y: WCAG 2 AA color contrast, focus-trap в modal, axe-core spec-suite 0 critical, keyboard-nav (Cmd+K, Cmd+J, table ↑↓/Enter/Space/Delete).
- Tests: 80%+ coverage на Application, integration tests с Testcontainers, e2e Playwright 44 specs зелёные на stage, k6 load baseline.
- Web: React 19 + Vite + TS, AG Grid Community, TanStack Query, 200 KB initial bundle (gzip), inline-edit, bulk-операции, CSV import/export, SavedPresets, Cmd+J QuickActions, NotificationCenter.
- POS: WPF на .NET 8, оффлайн-буфер SQLite, синк через `/api/pos/v1/*` с идемпотентным batch-ack, ОФД-провайдеры (Mock работает, реальные — scaffolding).
- DevOps: backup-таймер с retention 30d, stage→prod toolchain (7 скриптов из Sprint 21), auto-tag workflow, anonymize-prod для безопасных stage-дампов.
### Scaffolding (готово к подключению, но не активно)
| Что | Где | Что нужно от user'а |
|---|---|---|
| **SSO Google** | `Authentication:Google:ClientId/Secret` | OAuth credentials с Google Cloud Console |
| **SSO Microsoft** | `Authentication:Microsoft:ClientId/Secret` | OAuth credentials с Azure App Registration |
| **ОФД Webkassa** | `OrganizationFiscal.{Endpoint,Login,Password,CashboxId}` | Договор + кассовый аппарат + creds |
| **ОФД Kassa24 / ОФД-Соло** | то же | Договоры с провайдерами |
| **MoySklad sync** | `Organization.MoySkladToken` | Per-org OAuth token у клиента |
| **Telegram alerts** | `Monitoring:SuperAdminTelegramChatIds` | Chat-id'ы суперадминов |
| **Yandex.Metrika / GA4** | env `PUBLIC_YM_ID` / `PUBLIC_GA_ID` | Счётчики у клиента |
| **SMTP** | `PlatformSettings.Smtp*` | SendGrid / Mailgun / Yandex300 креды |
| **MinIO storage** | `Storage:Minio:Endpoint/AccessKey/SecretKey` | S3-совместимый bucket (опц.) |
### Не реализовано (требует отдельного решения)
- **Прод-деплой** — toolchain готов (`deploy/prod-deploy.sh`), но реальный сервер не настроен (DNS, certbot, /etc/nginx/conf.d/food-market-upstream.conf).
- **SSO callback flow** — `/api/auth/external/callback` возвращает 501 с email; нужен invite-flow + tokens-issuance.
- **Kazakh-перевод** — i18n keys на русском; для прод-релиза в РК нужен носитель языка.
- **POS Windows-тест** — POS-проект собирается на macOS/Linux но требует Windows для UI-тестов.
- **Down-миграции** — EF Migration.Down() есть в коде, но не валидированы для прод-данных (data loss risk).
- **Public marketing site SEO** — `food-market.kz` (Astro) собирается, но не задеплоен.
### Файловая структура (актуальная)
```
food-market/
├── src/
│ ├── food-market.domain/ # POCO + interfaces + enums
│ ├── food-market.application/ # DTO + handlers + mapping (Sprint 20)
│ ├── food-market.infrastructure/ # EF + Identity + OpenIddict + integrations
│ ├── food-market.api/ # Controllers + middleware + Hangfire jobs + storage
│ ├── food-market.web/ # React admin SPA
│ ├── food-market.public/ # Astro marketing site
│ ├── food-market.shared/ # POS↔API DTO-контракты
│ ├── food-market.pos.core/ # POS-логика (UI-agnostic)
│ └── food-market.pos/ # WPF (.NET 8 Windows)
├── tests/
│ ├── food-market.UnitTests/ # xUnit + InMemory EF
│ ├── food-market.IntegrationTests/ # xUnit + Testcontainers Postgres
│ ├── e2e/ # Playwright + ad-hoc smoke scenarios
│ └── load/ # k6 (retail-sales-parallel, signup-burst, …)
├── deploy/
│ ├── docker-compose.yml # Postgres + api + web + (registry)
│ ├── Dockerfile.{api,web,public}
│ ├── nginx.conf # SPA + reverse-proxy
│ ├── backup.sh / food-market-backup.* # systemd-timer ежедневный бэкап
│ ├── check-prod-readiness.sh # Sprint 21
│ ├── prod-deploy.sh # Sprint 21
│ ├── prod-rollback.sh # Sprint 21
│ ├── post-deploy-smoke.sh # Sprint 21
│ ├── db-schema-diff.sh # Sprint 21
│ ├── generate-release-notes.sh # Sprint 21
│ └── anonymize-prod.sh # Sprint 22
├── docs/ # 30+ markdown файлов
├── .forgejo/workflows/ # CI (ci.yml, regression.yml, auto-tag.yml, …)
└── food-market.sln
```
## Релиз-цикл
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) — бэкапы.

View file

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

View file

@ -1,416 +0,0 @@
# Multi-tenancy в food-market
Один процесс API, одна БД, много организаций (тенантов). Каждый запрос
видит только данные своей организации. Изоляция держится на двух вещах:
1. **EF Core query filter** на каждой `ITenantEntity` (auto-инжектится
в `WHERE` каждого SQL-запроса).
2. **Stamping в SaveChanges** — добавляемые сущности получают
`OrganizationId` из текущего `ITenantContext`.
`SuperAdmin` — отдельная роль с правом обходить фильтр (видеть/менять
всё). Чтобы не получить «случайные изменения по всем оргам сразу»,
есть строгий режим «открыть как…» с двумя ступенями (read-only +
edit-mode с reason).
## Модель
### Базовые интерфейсы
```csharp
// food-market.domain/Common/TenantEntity.cs
public interface ITenantEntity // обязательный orgId
{
Guid OrganizationId { get; set; }
}
public abstract class TenantEntity : Entity, ITenantEntity
{
public Guid OrganizationId { get; set; }
}
public interface IOptionalTenantEntity // системный справочник
{
Guid? OrganizationId { get; set; }
}
```
### Когда использовать что
| Случай | База |
|---|---|
| Бизнес-данные тенанта: продукты, чеки, контрагенты, отчёты | `TenantEntity` (обязательный orgId) |
| Системные справочники с возможностью per-tenant расширения: `UnitOfMeasure`, `ProductGroup`, `Country` | `IOptionalTenantEntity` (null = системная, оrgId = tenant'овская) |
| Корневая сущность тенанта: `Organization` | `Entity` (сама не tenant-scoped) |
| Платформенные настройки: `PlatformSettings` (SMTP), OpenIddict-клиенты | `Entity` (singletons / cross-tenant) |
| Audit-логи SuperAdmin'а: `SuperAdminAuditLog` | `Entity` (есть optional `OrganizationId` для фильтра, но сама не tenant-scoped) |
## Tenant-контекст
`ITenantContext` (Application слой) — единственный источник правды о
том, кто сейчас делает запрос:
```csharp
// food-market.application/Common/Tenancy/ITenantContext.cs
public interface ITenantContext
{
Guid? OrganizationId { get; } // null = SuperAdmin вне override, или нет JWT
bool IsAuthenticated { get; }
bool IsSuperAdmin { get; }
bool IsTenantOverride { get; } // SuperAdmin в режиме «открыть как…»
Guid? UserId { get; }
}
```
Реализация — `HttpContextTenantContext` (`food-market.api/Infrastructure/Tenancy/`).
Источники данных в порядке приоритета:
1. **AsyncLocal-override** (`UseOverride(orgId, isSuper)`) — для
background-tasks (Hangfire, импорт MoySklad, фоновые сидеры).
Когда нет HttpContext, нужно явно задать tenant перед `DbContext`-вызовом.
2. **HTTP-заголовок `X-Org-Override`** — режим «открыть как…»
(только если у юзера роль SuperAdmin).
3. **JWT claim `org_id`** — обычный tenant-юзер.
```csharp
// background-job пример
using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false))
{
// здесь _db применит фильтр на orgId
var products = await _db.Products.ToListAsync();
}
```
## Query filter
`AppDbContext.OnModelCreating` после регистрации всех сущностей
рефлексией обходит модель и вешает фильтр на каждую `ITenantEntity`:
```csharp
// food-market.infrastructure/Persistence/AppDbContext.cs
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
{
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
// В override-режиме (X-Org-Override header активен) он работает в
// контексте конкретной орги — фильтр обязан применяться.
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == _tenant.OrganizationId);
}
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
{
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == null
|| e.OrganizationId == _tenant.OrganizationId);
}
```
Результат:
- Tenant-юзер: `WHERE OrganizationId = '<его orgId>'`.
- SuperAdmin без override: фильтр не применяется (видит всё).
- SuperAdmin с `X-Org-Override`: `WHERE OrganizationId = '<выбранная orgId>'`.
- `IOptionalTenantEntity`: видит свои + системные (`IS NULL`).
## Stamping в SaveChanges
`AppDbContext.SaveChanges/Async` зовут `StampTenant()`, который
проходит по `Added`-entries:
```csharp
private void StampTenant()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State != EntityState.Added) continue;
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
{
if (_tenant.OrganizationId.HasValue)
tenant.OrganizationId = _tenant.OrganizationId.Value;
}
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
{
// SuperAdmin без override: оставляем null (системная запись)
// SuperAdmin с override / tenant-юзер: стампим текущий orgId
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { /* null */ }
else if (_tenant.OrganizationId.HasValue)
opt.OrganizationId = _tenant.OrganizationId.Value;
}
}
}
```
Это значит: контроллер может писать `_db.Products.Add(product)` без
явного `product.OrganizationId = ...` — stamping подставит сам.
**НО**: если код явно выставил `OrganizationId` (например, чтобы создать
запись для другой орги в Hangfire-job), stamping её не перетрёт.
## SuperAdmin override: режим «открыть как…»
Конкретный поток с фронта:
1. SuperAdmin заходит в «Системная консоль → Организации».
2. Кликает «Открыть как…» на какой-то orgRow.
3. Фронт начинает слать каждый запрос с заголовком
`X-Org-Override: <orgId>`. Без этого хедера SuperAdmin видит «своё»
(а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты —
у супер-админа в админке тренировочный режим).
4. По умолчанию режим **read-only** (`ReadonlyOverrideMiddleware`):
GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH — `403`.
Исключение: `/api/super-admin/*` и `/connect/*` (refresh-token).
5. Чтобы что-то поменять, фронт показывает «Войти в edit-mode»,
запрашивает причину (≥ 10 символов), отправляет её в каждом запросе
как `X-Org-Override-Reason: <текст>`. Тогда middleware пропускает
мутации, а action-filter (`SuperAdminEditAuditFilter`) после успешного
ответа пишет строку в `super_admin_audit_log` с reason'ом и
запросом/ответом.
6. Фронт ограничивает edit-mode 30 минутами (UI таймер).
Сервер не следит за временем — это UX-конвенция, а аудит уже есть.
### ClaimsTransformer для tenant-ролей
Тонкость: SuperAdmin сам по себе не имеет ролей `Admin/Storekeeper/Cashier`
(они — атрибуты `Employee` тенанта). Контроллер
`[Authorize(Roles="Admin")]` отшил бы его 403 даже в edit-mode.
Решение: `SuperAdminOverrideClaimsTransformer` (`IClaimsTransformation`,
вызывается на каждый authenticated request) — если есть `X-Org-Override`,
динамически добавляет SuperAdmin'у `Admin/Storekeeper/Cashier` claim-роли
**только на текущий запрос**. Записи в БД не трогает.
## RequiresPermission: тонкая авторизация
Для мутаций используется `[RequiresPermission("...")]` вместо
`[Authorize(Roles="Admin,Storekeeper")]`. Атрибут резолвится через
policy-механизм:
```
[RequiresPermission("ProductsEdit")]
→ Policy "perm:ProductsEdit"
→ PermissionAuthorizationPolicyProvider создаёт PermissionRequirement
→ PermissionAuthorizationHandler проверяет
EmployeeRole.RolePermissions.ProductsEdit == true для текущего юзера
```
`RolePermissions` — это POCO с булевыми полями (`ProductsView`,
`ProductsEdit`, `RetailSalesOperate`, `RetailSalesRefund`, …).
`Employee.EmployeeRoleId` указывает на конкретную роль, у каждой —
свой набор флагов. SuperAdmin (с override) проходит всегда.
См. `food-market.api/Infrastructure/Authorization/`.
## Audit-trail
### `org_audit_log`
Каждая `Add/Update/Delete` в `AppDbContext` (через
`OrgAuditInterceptor`) пишет JSONB-diff в `org_audit_log`:
```json
{
"id": "...",
"organizationId": "...",
"userId": "...",
"entityType": "Product",
"entityId": "...",
"action": "Update",
"changesJson": { "before": {...}, "after": {...} },
"createdAt": "..."
}
```
Фоновый Hangfire-job `prune-audit-log` (03:45) чистит записи старше
180 дней.
Сидеры/миграции выставляют `_db.SkipAudit = true` чтобы не плодить
бессмысленные строки.
### `super_admin_audit_log`
Только мутации SuperAdmin'а в edit-mode, плюс изменения платформенных
настроек. Без TTL — храним всё.
## Известные подводные камни
### 1. `IgnoreQueryFilters()` — когда нужно знать
Тенант-фильтр применяется ко ВСЕМУ — в том числе там, где это «не надо».
- При логине: ищем `Organization` по `OrgId` из credentials → нужен
`IgnoreQueryFilters()`, потому что фильтр требует OrganizationId,
которого ещё нет в контексте.
- При проверке «есть ли у юзера эта орг»: `_db.Organizations.IgnoreQueryFilters().AnyAsync(...)`.
- При cross-tenant отчётах SuperAdmin'а без override-режима фильтр и так
не применится, но при override — применится; чтобы получить
cross-tenant данные в этом режиме (редко нужно), вызвать
`IgnoreQueryFilters()` явно.
### 2. Stamping не работает, если orgId уже задан
Бэкенд-код в нескольких местах принимает `Guid OrganizationId` из payload
(например, при импорте). Stamping проверяет `OrganizationId == Guid.Empty`
и НЕ перетирает уже выставленное значение. Если кто-то по ошибке прислал
чужой orgId в payload — он сохранится. Защита: явно валидировать
`OrganizationId == _tenant.OrganizationId` в контроллере (или вообще не
принимать поле из payload).
### 3. Background-jobs без HttpContext
`HangfireJobsConfigurator` регистрирует методы, исполняющиеся в фоне.
`HttpContextAccessor.HttpContext` там null → `OrganizationId` тоже null →
query filter возвращает только записи с `OrganizationId == null` (т.е.
системные справочники), а tenant-запросы — пустоту.
Решение: внутри job, перед `DbContext`-вызовом, обернуть в
`HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)`.
См. `OwnerDailySummaryJob`, `EmailNotificationJobs` — там есть пример.
### 4. SignalR за query-фильтром
`NotificationsHub` использует `IServiceProvider.GetRequiredService<AppDbContext>()`
внутри `OnConnectedAsync` для добавления соединения в group. Если в
connection нет JWT — нет org_id — нет группы → клиент не получит
событий. Web-фронт прокидывает `?access_token=...` query (см. middleware
в Program.cs), POS — `Authorization` header.
### 5. EF migrations и наследование от TenantEntity
При добавлении новой `TenantEntity` миграция должна включать
`OrganizationId` колонку (uuid, NOT NULL) и индекс
`(OrganizationId, …)` для фильтрации. **Эта колонка не появляется
автоматически в snapshot-выводе `dotnet ef migrations add`** в этом
проекте, потому что снапшот не синхронизируется с моделью (миграции
пишутся руками, см. memory `feedback_ef_migrations`).
Проверка: после `Migrate()` тестовый запрос
`SELECT column_name FROM information_schema.columns WHERE table_name='...'`
должен показать `OrganizationId`.
### 6. `xmin` concurrency и параллельные посты
`UseXminAsConcurrencyToken()` на документах. Если два кассира одновременно
постят один и тот же чек (что не должно случаться, но всё же) — второй
получает `DbUpdateConcurrencyException`. Контроллер ловит и возвращает
`409 Conflict` с сообщением «документ изменён в другой сессии, обновите
страницу».
Stock-операции — отдельная история: `Serializable` транзакция блокирует
параллельный пост на тех же товарах в том же storе. Серверу `PG40001`
(serialization_failure) — контроллер не ретраит автоматически, кассир
видит 409 «недостаточно остатка» (после ретрая по факту достаточно или
нет).
### 7. Тестирование изоляции
`TenantIsolationTests` (integration) — обязательный смок: создаём 2
организации, в одной — продукт; в другой делаем `GET /api/catalog/products`
→ список пустой. На любую новую `ITenantEntity` добавлять такой тест.
### 8. Read-models / ad-hoc raw SQL
Если контроллер напишет raw SQL через `_db.Database.SqlQueryRaw(...)`,
EF-фильтр НЕ применится. Это используется только для отчётов с тяжёлой
агрегацией (`ProfitReportController`); там OrganizationId явно
включается в `WHERE` и приходит из `_tenant.OrganizationId`.
Правило: никаких raw SQL без явного `WHERE OrganizationId = @org` в
середине запроса.
## Чеклист «как добавить новую tenant-сущность»
Расширенная версия с RowVersion + permission + validation паттернами
(Sprint 15). Минимальный «список из 6 пунктов» оставлен ниже как краткая
форма.
### Domain
1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`).
2. Для **документов** (Supply, RetailSale, Loss…) — добавить
`IVersionedEntity` + `uint Xmin { get; set; }` для оптимистической
блокировки через PG xmin. EF переведёт concurrency-конфликт в
`DbUpdateConcurrencyException`, контроллер вернёт 409.
### Infrastructure (EF Config + миграция)
3. Добавить EF Configuration в
`food-market.infrastructure/Persistence/Configurations/`:
- `b.ToTable("snake_case");`
- Для документа: `b.UseXminAsConcurrencyToken(); b.Ignore(x => x.Xmin);`
- `b.Property(x => x.Number).HasMaxLength(50).IsRequired();` —
явные ограничения вместо EF-defaults.
- `b.Property(x => x.SomeDecimal).HasPrecision(18, 4);` — иначе EF
warning'и про missing precision.
- **Индекс с `OrganizationId` первым полем**:
`b.HasIndex(x => new { x.OrganizationId, x.SomeField });`.
- Уникальность в рамках org: `.IsUnique()` на том же composite-индексе.
- Sprint 14: для статусов-документов, по которым строятся отчёты —
ещё один composite `(OrganizationId, Status, Date)` или partial
индекс `WHERE Status = X AND NOT Y` с `INCLUDE` для covering.
4. Создать миграцию руками в `Persistence/Migrations/`:
- Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDDHHMMSS_NameHere")]`.
**Без них `Migrate()` миграцию не подхватит** (см. memory
`feedback_ef_migrations`).
- В `Up()` — `CreateTable` с колонкой `OrganizationId uuid NOT NULL`.
- Индексы (минимум один на OrganizationId).
- Не использовать `dotnet ef migrations add` — снапшот в репо
не синхронизируется с моделью.
5. Добавить `DbSet<TEntity>` в `AppDbContext`.
### Permission (RolePermissions)
6. Добавить булевый флаг в `RolePermissions.cs`:
`public bool MyEntityEdit { get; set; }` + соответствующая запись в
`All()` фабрике (для системной роли Admin).
7. Миграции для `role_permissions` не нужно — это JSONB-колонка
на `EmployeeRole`.
8. Все Admin-роли уже получат новый permission через `RolePermissions.All()`.
### Validation
9. Для простой валидации — DataAnnotations на input-record'е:
`public record Input([Required, MaxLength(200)] string Name, …);`
10. Для сложной — `FluentValidation` в
`food-market.api/Infrastructure/Validation/Validators.cs`:
- `public sealed class MyInputValidator : AbstractValidator<MyInput>`
с `RuleFor`/`RuleForEach`.
- Регистрируется автоматически (assembly-scan на старте).
- `ValidationFilter` в pipeline'е вызовет валидатор и вернёт 400
ProblemDetails (RFC 7807) до Action'а.
11. Бизнес-валидация (требует БД — «существует ли поставщик»): в начале
action-метода вернуть `BadRequest(new { error, field })`.
### Controller
12. Контроллер использует `_db.MyEntities.Where(...)` — query filter
подключится автоматически. `StampTenant` в `SaveChangesAsync`
выставит `OrganizationId` в `Add()`.
13. Защитить mutating endpoint'ы атрибутом
`[RequiresPermission("MyEntityEdit")]` (резолвится в policy
`perm:MyEntityEdit` → проверяет флаг на `RolePermissions`).
14. Для concurrency-чувствительных мутаций (Post документа):
`await using var tx = await _db.Database.BeginTransactionAsync(
IsolationLevel.Serializable, ct)` — защита от race на остатке.
### Audit (Sprint 13)
15. CRUD автоматически логируется `OrgAuditInterceptor`'ом в
`org_audit_log` (JSON diff).
16. Для sensitive-операций (смена пароля, выдача роли, изменение
permissions) — дополнительно через
`SensitiveOpsAudit.LogAsync()` — она пишет в `org_audit_log` +
Serilog с типизированным action-name.
### Tests
17. **Интеграционный тест на изоляцию** (`TenantIsolationTests`):
org A создаёт, org B делает GET — список пустой, GET-by-id → 404.
18. Если есть concurrency-критика (Post под Serializable):
`RetailOversellingTests`-pattern — два параллельных VU гарантированно
дают 409 на одном из них.
19. Для бизнес-инварианта (типа «Sum(movements) ≡ Stock»):
property-based test (см. `StockServicePropertyTests`, Sprint 15).

View file

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

View file

@ -1,643 +0,0 @@
# Runbook — операционные процедуры food-market
Что делать, когда что-то идёт не так, или когда нужно сделать
неавтоматическую операцию.
## Контактные точки
| Что | Где |
|---|---|
| Stage URL | https://test.admin.food-market.kz |
| Prod URL | https://admin.food-market.kz (план, ещё не задеплоен) |
| Stage VM | `nns@192.168.1.190` (через ssh, prod-vm в локалке) |
| Dev VM (этот хост) | `nns@<this>` — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry |
| Forgejo (primary git) | http://127.0.0.1:3000/nns/food-market.git |
| GitHub (mirror) | https://github.com/nurdotnet/food-market (только зеркало) |
| Local Docker registry | `192.168.1.193:5001` (memory: `local_docker_registry`) |
| Hangfire Dashboard (stage) | https://test.admin.food-market.kz/hangfire — только SuperAdmin |
| Swagger (stage) | https://test.admin.food-market.kz/swagger |
## Health-чеки
| Endpoint | Что значит | Что делать при 503 |
|---|---|---|
| `GET /health` | Процесс отвечает | Контейнер живёт, проблема в обвязке (nginx/cert/DNS). |
| `GET /health/live` | Процесс жив (без проверок) | То же. |
| `GET /health/ready` | БД отвечает + миграции применены | См. ниже «Health/ready упал». |
| `GET /metrics` | Prometheus exposition | Если 404 — приложение не стартануло. |
### `/health/ready` упал
1. `ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'` —
стек ошибки на старте.
2. Типичные причины:
- **Миграция упала**: ищем `Failed executing DbCommand` / `relation
"..." already exists`. Решение: миграция конфликтует со снапшотом
БД. Возможно её надо переписать с `IF NOT EXISTS` (см.
`Phase6e_RetailSaleReturns.cs` как пример «defensive migration»).
- **OpenIddict cert pass mismatch**: переменная
`OpenIddict__CertPassword` в docker-compose env'е не совпадает с
паролем PFX-файла → `CryptographicException: PKCS12 password incorrect`.
- **Connection refused**: Postgres контейнер не успел подняться.
`depends_on.condition: service_healthy` должно это покрывать,
но если healthcheck не успел — `docker compose restart api`.
3. Если фикс требует кода — `~/deploy-stage.sh` после правки.
## Деплой на stage
```bash
~/deploy-stage.sh
```
Скрипт делает:
1. `docker build` api и web с локальным registry в качестве кеша.
2. `docker push` обоих образов в `192.168.1.193:5001`.
3. `ssh nns@192.168.1.190` → `docker compose -p food-market-stage pull api web` → `up -d --force-recreate`.
4. Ждёт `https://test.admin.food-market.kz/health/ready` до 30с.
**Важно**: проект `docker compose` называется `food-market-stage`
(флаг `-p food-market-stage`). См. инцидент ниже про project name.
## Публичный сайт: какой образ ↔ какой домен
Публичный Astro-сайт собирается в **двух** вариантах — build-arg
`PUBLIC_APP_URL` (куда уводит кнопка «Войти» и форма регистрации) инжектит
Astro в бандл при `pnpm build`, поэтому один образ работает для ровно
одного `admin`-хоста. Нельзя переиспользовать прод-образ для стенда и
наоборот.
| Образ (`food-market-public`) | `PUBLIC_SITE_URL` | `PUBLIC_APP_URL` | Хост-назначение | Деплой |
|---|---|---|---|---|
| `:test` + `:test-<sha>` | `https://test.food-market.kz` | `https://test.admin.food-market.kz` | **test.food-market.kz** (стенд публики) | **да** — в CI `.forgejo/workflows/docker-public.yml` job `deploy_test` обновляет контейнер `food-market-public` на prod-vm через `~/food-market/deploy/.env` c `PUBLIC_TAG=test` |
| `:latest` + `:<sha>` | `https://food-market.kz` | `https://admin.food-market.kz` | **food-market.kz** (прод публики) | **нет** — собирается, пушится, но на хост не деплоится; food-market.kz закрыт по решению владельца. Включать job `deploy_prod` когда разрешат. |
Workflow строит оба тега при каждом push в main, затрагивающем
`src/food-market.public/**`. Выбор образа на хосте через `PUBLIC_TAG` в
`~/food-market/deploy/.env` на prod-vm. Если `.env` отредактировали
руками (например, координатор сменил `PUBLIC_TAG=latest → test`), в
следующем CI-прогоне workflow проверит и не трогает, если уже `test`.
Smoke-проверка (jobs `deploy_test` → step «Smoke»):
`curl https://test.food-market.kz/signup/ | grep test.admin` должен
находить ссылки на `test.admin.food-market.kz`, и НЕ должен находить
`admin.food-market.kz`. Если ссылки не те — PUBLIC_APP_URL не прошёл в
билд, образ пересобрать.
Nginx внутри контейнера сделан с `absolute_redirect off; port_in_redirect
off;` — иначе 301 с `/signup` на `/signup/` строился через HTTP-schema и
браузер уходил с https → http. См. `src/food-market.public/deploy/nginx.conf`.
## Postgres на prod-vm
На prod-vm (`admin.food-market.kz`, `192.168.1.190`) **три** Postgres-контейнера.
Food-market владеет только одним из них — остальные **нельзя** трогать нашими
скриптами, бэкапами, миграциями или `docker exec`.
| Контейнер | Compose-проект | Том | Порт | Чьи данные |
|---|---|---|---|---|
| `food-market-prod-postgres` | `food-market` (`~/food-market/deploy/`) | `food-market-prod-postgres-data` | `127.0.0.1:5437` | **наш прод** |
| `food-market-stage-postgres` | `food-market-stage` (`~/food-market-stage/deploy/`) | `food-market-stage-postgres-data` | `127.0.0.1:5435` | наш стенд |
| `shared-postgres` | `shared-postgres` (`~/shared-postgres/`) | `food-market-postgres-data` *external* | `127.0.0.1:5434` | **НЕ наш** — чужие боевые базы `sklad`, `purchase`, `calcman`, `calcman_staging`, `makesales`, плюс мусорная старая `food_market` (повреждена, не восстанавливаем) |
Истоpически (до 2026-10-05) все эти базы жили в одном compose-проекте food-market
под именем контейнера `food-market-postgres` / томом `food-market-postgres-data`.
Любой `docker compose down` у food-market гасил чужие боевые базы. Координатор
физически разделил инстансы с одобрения владельца: food-market съехал в
собственный контейнер и том, а общий инстанс остался в compose-проекте
`shared-postgres` на старом томе (имя тома историческое — переименовывать
нельзя, в нём живые данные соседей). См. `docs/shared-postgres-followup-progress.md`.
**Правила:**
- Прод-бэкап, anonymize, schema-diff, миграции идут **только** через
`food-market-prod-postgres` (defaults скриптов именно такие).
- Контейнер `shared-postgres`, том `food-market-postgres-data`, порт `5434` —
**запрещённые** цели в наших compose/скриптах. Guards в `~/deploy-stage.sh`
и `~/quality-watchdog.sh` падают, если упомянутые паттерны всплывут в
шаблонах stage- или прод-compose в репе или на prod-vm.
- Нужен dump чужой базы — делает её владелец у себя, не из food-market.
- `deploy/shared-postgres/docker-compose.yml` в репе — это **документация**,
не исполняемый шаблон; наш `prod-deploy.sh` его не трогает.
## Бэкап и восстановление
### Расписание
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-prod-postgres` → `db-<TS>.dump`.
- `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-<TS>.tgz`.
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
Папка: `/opt/food-market-data/backups/`.
> Контейнер прод-БД — `food-market-prod-postgres` (порт `127.0.0.1:5437`, том
> `food-market-prod-postgres-data`) с 2026-10-05. Соседний на той же машине
> `shared-postgres` (порт 5434, том `food-market-postgres-data` *external*) —
> **не наш**, в нём чужие боевые базы sklad/purchase/calcman/calcman\_staging/
> makesales. Любые `docker exec`/`pg_dump`/`pg_restore` в `shared-postgres` из
> наших скриптов запрещены, guards бьют. См. раздел «Postgres на prod-vm»
> ниже и `docs/shared-postgres-followup-progress.md`.
### Ручной бэкап
```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:5437 # прод-PG в Docker, exposed на 5437
```
### Recovery drill (RTO ≈ 25 секунд на сегодняшних данных)
Sprint 15 — verified восстановление stage'а в свежий PG-контейнер на dev-vm:
| Шаг | Время |
|---|---|
| `pg_dump -Fc` из stage-postgres | **~2 секунды** (на 1.5k чеков / 200 продуктов) |
| Создать чистый Docker-контейнер `postgres:16-alpine` | ~1 сек |
| `pg_restore --clean --if-exists --no-owner --no-privileges` | **~4 секунды** |
| Поднять API против восстановленной БД | ~19 сек (cold-start dotnet + migrations) |
| `/health/ready` → `{"status":"Healthy"}` | подтверждено |
| **Всего RTO (single-instance)** | **~25 секунд** |
Команды, выполненные в drill'е:
```bash
# 1. Снять бэкап со stage'а
ssh nns@192.168.1.190 'docker exec food-market-stage-postgres \
pg_dump -U food_market -d food_market -Fc -f /tmp/drill.dump'
ssh nns@192.168.1.190 'docker cp food-market-stage-postgres:/tmp/drill.dump /tmp/drill.dump'
scp nns@192.168.1.190:/tmp/drill.dump /tmp/drill.dump
# 2. Чистый PG
docker run -d --name drill-pg \
-e POSTGRES_DB=food_market \
-e POSTGRES_USER=food_market \
-e POSTGRES_PASSWORD=drill_pass \
-p 127.0.0.1:5499:5432 postgres:16-alpine
# 3. Восстановление
docker cp /tmp/drill.dump drill-pg:/tmp/drill.dump
docker exec drill-pg pg_restore -U food_market -d food_market \
--clean --if-exists --no-owner --no-privileges /tmp/drill.dump
# 4. Проверка: API на восстановленной БД
ASPNETCORE_ENVIRONMENT=Production \
ConnectionStrings__Default="Host=localhost;Port=5499;Database=food_market;Username=food_market;Password=drill_pass" \
Hangfire__Enabled=false \
ASPNETCORE_URLS="http://127.0.0.1:5099" \
RateLimiting__Enabled=false \
dotnet run --project src/food-market.api
curl http://127.0.0.1:5099/health/ready
# → {"status":"Healthy", checks:[{"name":"database","status":"Healthy",
# "description":"БД доступна, миграции применены."}]}
# Очистка
docker rm -f drill-pg
```
Для прод-данных большего объёма (50k+ чеков) RTO будет ~2-5 минут — но
порядок остаётся: pg_restore линейно по данным + API startup константный.
### Восстановление БД из дампа
> ⚠️ Перезаписывает данные. Сначала остановить 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`.
## Sprint 26 — Alert response (`deploy/prometheus/alerts.yml`)
Каждый alert в `alerts.yml` имеет `runbook` label — anchor сюда.
Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает
на соответствующий раздел.
### api-down
**Alert:** `ApiDown` — `up{job="food-market-api"} == 0` 1 минуту.
**Что значит:** Prometheus не может scrap'нуть `/metrics`. API либо упал,
либо порт недоступен.
**Действия:**
1. `curl -fsS https://test.admin.food-market.kz/health/ready` —
подтверди что API недоступен (или вернулся).
2. `ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api'` —
контейнер живой?
3. Если контейнер в `Restarting`: `docker logs --tail 200 food-market-stage-api-1`
— стек ошибки старта (часто миграция / OpenIddict cert mismatch).
4. Если контейнер OK, но не отвечает: `docker exec food-market-stage-api-1 curl
-s http://localhost:8080/health` (внутренний порт). Если внутри отвечает,
проблема в nginx/proxy цепочке.
5. Recovery: `cd ~/food-market-stage/deploy && docker compose -p
food-market-stage up -d --force-recreate api`.
6. Если не помогло: `~/deploy-stage.sh` с локального dev-vm (полный
build+push+restart).
### rps-drop
**Alert:** `RpsDropped50Percent` — RPS за 5 мин <50% от того же окна час
назад, при условии что было >0.5 rps.
**Действия:**
1. `curl https://test.admin.food-market.kz/health/ready` — API живой?
2. `ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1'`
— внезапные ошибки на старте/в логе.
3. Проверь DNS из дома/мобильной сети: `dig admin.food-market.kz` —
возможно сломалась запись.
4. Откати последний deploy: `cd ~/food-market-stage/deploy && git log -1
--format=%H && docker compose pull && docker compose up -d`. Если
откат на предыдущий image помог — баг в новом коде, см. логи.
### http-errors-spike
**Alert:** `HttpErrorsSpike` — доля 5xx >10% уже 5 минут.
**Действия:**
1. Logs: `ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'`.
2. Какая ручка валится: `curl https://test.admin.food-market.kz/metrics |
grep 'http_requests_received_total.*code="5'` — топ-3 контроллеров.
3. Hangfire-failed: `curl -u admin /hangfire/jobs/failed` (нужен
SuperAdmin login).
4. Часто — БД упала. См. `db-p95-high` раздел ниже.
5. Если баг локален (только одна ручка валится): найди фикс, deploy.
### http-errors-growing
**Alert:** `HttpErrorRateGrowing` — производная 5xx растёт >10%/min 10 мин.
**Действия:** Постепенная деградация, не emergency. Часто — память течёт
или коннекшен-пул исчерпывается.
1. Memory: `docker stats food-market-stage-api-1` (ratio %).
2. PG connections: `psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state"`
— если `idle in transaction` много, есть leak.
3. Restart api: `docker compose -p food-market-stage restart api` —
куплено время.
4. Найди корень в логах — кто часто получает Exception.
### doc-posting-errors
**Alert:** `DocumentPostingErrors` — типа документов > 0.05 ошибки/сек 5 мин.
**Действия:**
1. `docker logs food-market-stage-api-1 | grep "Posting failed"` —
ищи название документа в логе.
2. Hangfire failed: документы постятся через Hangfire-job — посмотри
`/hangfire/jobs/failed`.
3. Stock-инвариант: `Posting failed: stock would be negative` означает
попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг.
Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
4. Concurrent posting: `Posting failed: serialization conflict` — это
Sprint 23 `SerializationConflictMiddleware` ловит и возвращает 409.
Если не возвращает 409 а 500 — middleware сломался, проверь deploy.
### db-p95-high
**Alert:** `DbQueryP95High` — p95 DB-запросов >500ms 10 минут подряд.
**Действия:**
1. Самые медленные запросы:
```sql
SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
```
2. Если статистика устарела: `ANALYZE` всей БД (`vacuum-top-tables`
Hangfire-job делает это раз в неделю, см. `DatabaseMaintenanceJobs`).
3. Lock'и: `SELECT * FROM pg_locks WHERE NOT granted;` — заблокирована ли
какая-то таблица.
4. Disk: см. `disk-free-low` ниже — если IO упирается в диск.
5. Если новый медленный запрос в логе после deploy — откати relevant
контроллер.
### disk-free-low
**Alert:** `DiskFreeLow` — < 5 ГБ свободно на mount.
**Действия:**
1. `df -h` — какой mount упал.
2. Logs: `du -sh /var/lib/docker/containers/*/`. Логи Docker'a иногда
разрастаются. Truncate: `truncate -s 0 /var/lib/docker/containers/*/.log`.
3. БД growth: `psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))"`.
Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance
(см. ниже).
4. Quality-watchdog test orgs: `PruneQualityTestOrgs` Hangfire-job (cron
02:30 UTC) удаляет старые `quality-{epoch}-*` org'и (см.
`[[sprint25_done]]`). Если не отработал: trigger вручную через
`/hangfire`.
5. Очисти `~/.fm-watchdog/quality.log.*` старее 14 дней (auto-ротация уже есть).
### watchdog-red
**Alert:** `WatchdogLastRunRed` — quality-watchdog последний прогон красный
>5 мин.
**Действия:**
1. Открой `docs/quality-status.md` в репо — сразу видно какой шаг упал.
2. Тот же шаг сам и воспроизведёшь:
```bash
/home/nns/quality-watchdog.sh # сразу прогоняет всё
tail -50 ~/.fm-watchdog/quality.log # детали последнего шага
```
3. Дальше зависит от шага:
- `health` — см. `api-down`.
- `auth_me` / `products` / `signalr` — см. `http-errors-spike`.
- `multi_tenant` — см. `multi-tenant-violation` (КРИТИЧНО).
- `perf` — см. `db-p95-high`.
- `ui_flow` — Playwright-тест. Прогон вручную: `cd tests/regression &&
pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed`.
### multi-tenant-violation 🚨
**Alert:** `MultiTenantViolation` — шаг multi_tenant упал в последнем часу.
**ЭТО P0.** Org B видит данные A — это утечка между арендаторами.
**Действия (немедленные):**
1. Останови stage'е приём новых signup'ов (косвенно — поставь
`RateLimiting__SignupPerIpPerHour=0`, redeploy api).
2. `tail -100 ~/.fm-watchdog/quality.log` — детали leak'а (`get_code`,
`list_total`).
3. В коде проверь `AppDbContext.ApplyTenantQueryFilter` (см. `[[sprint22_done]]`):
```bash
grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
```
Кто-то добавил `IgnoreQueryFilters()` где не надо? Это самая частая
причина leak'ов.
4. Воспроизведи руками: создай 2 org'и (`curl POST /api/auth/signup` × 2),
токен для каждого, попробуй cross-access. Если воспроизводится —
фикс ASAP.
5. После фикса — `~/deploy-stage.sh`, дождись зелёного watchdog'a.
6. Если на prod уже катилось: notify владельцев (через Telegram-summary
job), audit-log за последние 48ч (`/api/admin/audit?since=...`) на
подозрительные cross-org операции.
### watchdog-incident
**Alert:** `WatchdogIncidentCreated` — 2+ подряд красных прогона ⇒ incident-файл.
**Действия:**
1. `ls -lt ~/.fm-watchdog/incident-*.txt | head -3` — последние инциденты.
2. `cat ~/.fm-watchdog/incident-{...}.txt` — описание + действия.
3. Server-Claude автоматически получит этот файл в очередь (через
`~/fm-watchdog.sh` → ротацию queue). Не дублируй — он начнёт фикс сам.
4. Если хочешь форсировать вмешательство: тот же файл sent'нул в
`~/.fm-watchdog/queue/0000-incident-XXX.txt` (uname-prefix `0000` →
первый в очереди).
## Что НЕ делать
- НЕ менять `global.json` без явного решения (CLAUDE.md).
- НЕ переключать systemwide postgres версию через brew (поломает
смежные проекты в `~/Documents/devprojects/`).
- НЕ запускать `docker compose down -v` на stage/prod (потеря volume).
- НЕ делать миграции через `dotnet ef migrations add` — снапшот в репо
не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
- НЕ редактировать тот же файл одновременно с Mac-Claude (memory:
`feedback_serialize_edits`).

View file

@ -1,221 +0,0 @@
# ТЗ на доработку Food Market
> Дата составления: 2026-05-22
> Автор анализа: Claude Opus 4.7
> Базируется на полном обходе кодовой базы `~/food-market` (backend + web + public + tests + deploy).
---
## 1. Текущее состояние системы
### 1.1. Сводная таблица готовности по модулям
| Модуль / слой | Статус | Готовность | Ключевой комментарий |
|---|---|---:|---|
| **food-market.domain** | ✅ готово | 95% | 26 сущностей, мультитенантность через `ITenantEntity`/`IOptionalTenantEntity`, чисто (нет TODO/HACK). |
| **food-market.infrastructure** | ✅ готово | 90% | EF Core 8, query filters, MailKit SMTP, StockService, MoySkladClient, 34 миграции. |
| **food-market.api (контроллеры)** | ✅ готово | 85% | 27 контроллеров, ~120 endpoint'ов, OpenIddict (password + refresh), CRUD полный. |
| **food-market.application** | 🟡 частично | 60% | Только DTO + интерфейсы, нет MediatR handlers — вся логика в контроллерах. |
| **food-market.web (админка)** | ✅ готово | 95% | 35 страниц, темная тема, адаптив, RU-локаль, onBlur-валидация. |
| **food-market.public (сайт)** | ✅ готово | 90% | Astro 4: landing, тарифы, блог, KB, legal; SignupForm → API. |
| **food-market.shared (POS контракты)** | ❌ нет | 0% | Только .csproj, ни одного CS-файла. |
| **food-market.pos.core + food-market.pos** | ❌ скелет | 5% | Пустой WPF-проект, только зависимости в .csproj. |
| **POS Sync API** | ❌ нет | 0% | Нет `/api/pos/sync`, нет `/api/pos/sales bulk`, нет WebSocket. |
| **Documents: Supply / RetailSale** | ✅ готово | 100% | Полный цикл (Draft → Post → Unpost), Stock + Movement, Cost (скользящее среднее). |
| **Documents: Inventory / Loss / Enter / Transfer** | ❌ нет | 0% | Нет контроллеров и страниц. Domain-сущности тоже не определены. |
| **Documents: Demand (оптовая отгрузка)** | ❌ нет | 0% | Только enum `MovementType.WholesaleSale`, контроллера/сущности нет. |
| **Reports** | ❌ нет | 5% | Есть `/api/sales/retail/stats` для дашборда, отдельных отчётов нет. |
| **MoySklad интеграция** | 🟡 частично | 50% | Импорт товаров и контрагентов ✅; нет Demand/Payment sync, нет webhook'ов. |
| **OpenIddict auth** | ✅ готово | 100% | Password + refresh_token; org_id, role, sub claims; persistent dev-ключи. |
| **Multi-tenancy** | ✅ готово | 95% | Query filters + `HttpContextTenantContext`; SuperAdmin override read-only/edit. |
| **Permission-based authz (RolePermissions)** | 🟡 частично | 30% | 30+ флагов в БД, но контроллеры проверяют только Roles (Admin/Cashier и т.д.). |
| **SuperAdmin Console** | ✅ готово | 95% | Organizations CRUD, audit log, archive/restore, platform settings (SMTP); биллинг-KPI заглушка. |
| **Hangfire** | 🟡 частично | 40% | `ReferencePriceRefreshJob` ✅; нет dashboard, нет scheduled cleanup, нет retry. |
| **Email (SMTP)** | ✅ готово | 100% | MailKit, DataProtection-шифрование пароля, forgot-password flow. |
| **Платёжные интеграции (Kaspi/Halyk/Jusan)** | ❌ нет | 0% | Упомянуты только в маркетинге; есть `PaymentMethod` enum, реальных шлюзов нет. |
| **ОФД (фискализация чеков РК)** | ❌ нет | 0% | Поля `FiscalSerial`/`FiscalRegNumber` есть в RetailPoint, отправки чеков нет. |
| **Маркетплейсы (Ozon, Wildberries, Kaspi Magazin)** | ❌ нет | 0% | Только маркетинговые баннеры. |
| **CI/CD (Forgejo Actions)** | ✅ готово | 90% | docker-api/web/public + smoke-тест /health после деплоя; self-hosted runner. |
| **Docker / docker-compose (stage)** | ✅ готово | 95% | postgres:16 + api + web + public + persistent volumes + local registry. |
| **E2E тесты** | 🟡 частично | 40% | Один сценарий `full-cycle` (12 шагов), отчёт в md; нет регрессии и параллелизма. |
| **Backend unit/integration тесты** | ❌ нет | 0% | Совсем. В CI стоит `\|\| echo "No tests yet"`. |
| **Logging / Serilog** | ✅ готово | 90% | Console + File с ротацией 14 дней; нет structured fields для бизнес-событий. |
| **Health checks (детальные)** | 🟡 частично | 20% | Только `/health` → {status:ok}; нет проверки БД, SMTP, диска. |
| **Метрики / observability** | ❌ нет | 0% | Нет Prometheus/AppInsights/OpenTelemetry. |
| **Rate limiting** | 🟡 частично | 15% | Только в `forgot-password` (3/час/IP, in-memory). |
| **Backup БД** | 🟡 частично | 60% | `deploy/backup.sh` есть, но не привязан к cron/timer, restore-скрипта нет. |
### 1.2. Что точно работает (готово к продакшен-использованию)
- **Регистрация → онбординг → ежедневная работа магазина** (товары, цены, приёмки, розничные продажи, остатки).
- **Управление пользователями и ролями**, soft-delete, передача владельца, восстановление пароля по email.
- **SuperAdmin-консоль платформы** (создание/архивирование организаций, SMTP, аудит).
- **Импорт каталога из МойСклад** (товары + контрагенты, асинхронный job с прогрессом).
- **Полный stage-стенд** на docker-compose с локальным registry и автодеплоем через Forgejo Actions.
### 1.3. Где точно не получится запуститься без доработки
- **Невозможно работать с физическим магазином без ККМ-фискализации** (РК требует чеки в ОФД).
- **Невозможно вести полноценный складской учёт** — нет инвентаризации, оприходования, списания, перемещения.
- **Нет аналитики/отчётов** — только сводка на дашборде, ABC-анализа, отчёта по поставщикам/прибыли нет.
- **Нет POS-приложения** — главная ценность проекта (offline-касса на Windows) — пустой проект.
- **Нет защиты от перебора паролей** в основных endpoint'ах (login/signup), только в forgot-password.
---
## 2. ТЗ на доработку по приоритетам
### Приоритет P0 — блокеры запуска в продакшен
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P0-1 | **Production-сертификаты OpenIddict** | Заменить `App_Data/openiddict-dev-key.xml` на реальные RSA/X.509 сертификаты, читать из KeyVault или secrets. | Сейчас токены подписываются dev-ключом без шифрования access-token. В проде это утечка claims. |
| P0-2 | **HTTPS на nginx** | Настроить TLS-termination на reverse-proxy (Let's Encrypt через certbot), форсировать HTTPS-only, добавить HSTS. | OAuth/refresh_token нельзя гонять по HTTP. |
| P0-3 | **Rate limiting на login/signup** | Добавить `Microsoft.AspNetCore.RateLimiting` (sliding window) на `/connect/token`, `/api/auth/signup`. 5 попыток/минута/IP, 20/час/IP. | Перебор паролей и DOS публичного signup. |
| P0-4 | **Health check БД** | Расширить `/health` на `/health/live` (alive) + `/health/ready` (DB ping, миграции применены). Использовать `Microsoft.Extensions.Diagnostics.HealthChecks`. | Сейчас docker-compose `healthcheck` возвращает 200 даже когда БД упала — стейдж не падает корректно. |
| P0-5 | **Permission-based authorization** | В `RolePermissions` (Domain) уже 30+ флагов. Реализовать `PermissionHandler` (IAuthorizationHandler) + атрибут `[RequiresPermission("ProductsEdit")]`, проверять в контроллерах вместо `[Authorize(Roles=...)]`. | Без этого все Admin'ы организации имеют полные права, кастомные роли (Менеджер/Кладовщик/Кассир) — фикция. |
| P0-6 | **Автоматический backup БД** | Создать systemd-timer (`food-market-backup.timer`) на ежедневный запуск `deploy/backup.sh`, добавить restore-инструкцию в `docs/`. Хранить 30 дней локально + копия в S3/MinIO. | Сейчас бэкап делается вручную, восстановления не отрепетировали. |
| P0-7 | **ОФД фискализация РК** | Интегрировать одного оператора (например, Webkassa или ОФД-Соло, КГД РК), отправлять `RetailSale.Post` чек, сохранять QR-код и фискальный номер в `RetailSale.FiscalQrCode`/`FiscalNumber`. | В РК продажа без чека ОФД — административное правонарушение. Без этого нельзя продавать. |
| P0-8 | **.env.example + документация secrets** | Описать все required env-переменные (`ConnectionStrings__DefaultConnection`, `Cors__AllowedOrigins`, `Smtp__*`, `OpenIddict__Issuer`, `OFD__Token`). Обновить `docs/stage-setup.md`. | Сейчас новый деплой не задокументирован. Передача знаний из головы — узкое место. |
| P0-9 | **Чек-листы перед релизом** | Документ `docs/release-checklist.md`: миграции применены, бэкап свежий, smoke-тесты прошли, E2E full-cycle зелёный, мониторинг здоров. | Снижает риск выкатки в проде сломанной версии. |
### Приоритет P1 — важные функциональные пробелы
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P1-1 | **Документ «Оприходование» (Enter)** | Domain-сущность `Enter` + `EnterLine` (как Supply, но без поставщика). Контроллер CRUD + Post/Unpost. UI-страницы `/inventory/enters`. Создаёт `StockMovement` с типом `Enter`. | Нужно вводить начальные остатки и излишки инвентаризации без поставщика. |
| P1-2 | **Документ «Списание» (Loss)** | Domain-сущность `Loss` + `LossLine` (причина: брак, истечение срока, бой, недостача). Контроллер + UI. `StockMovement` тип `WriteOff`. | Списание брака — обязательная функция магазина. |
| P1-3 | **Документ «Перемещение» (Transfer)** | Domain `Transfer` + `TransferLine` (FromStore → ToStore). Контроллер с атомарной транзакцией (списание + поступление). UI-форма. | В сети магазинов товар постоянно перемещается между складами. |
| P1-4 | **Документ «Инвентаризация» (Inventory)** | Domain `Inventory` + `InventoryLine` (book qty, actual qty, diff). Контроллер с импортом текущих остатков + Post создаёт корректирующее движение `InventoryAdjustment`. UI-форма с CSV-импортом фактического количества. | Регулярная сверка остатков — обязательно для розницы. |
| P1-5 | **Документ «Оптовая отгрузка» (Demand)** | Domain `Demand` + `DemandLine` (покупатель, способ оплаты — наличные/безнал, цена опт.). Контроллер. UI-страницы. `StockMovement` тип `WholesaleSale`. | Часть клиентов работает с юрлицами, отгрузка по накладной с НДС. |
| P1-6 | **Возврат от покупателя (CustomerReturn)** | Расширить `RetailSale` опцией «Возврат» (по чеку или без). Domain enum `MovementType.CustomerReturn` уже есть. UI: кнопка «Создать возврат» из посту-проведённой продажи. | Закон о защите прав потребителей в РК требует приёма возвратов. |
| P1-7 | **Возврат поставщику (SupplierReturn)** | По аналогии с CustomerReturn для Supply. UI: «Возврат поставщику» из проведённой приёмки. | Брак, неликвид, отказ от партии. |
| P1-8 | **Отчёт «Продажи»** | `/api/reports/sales` с группировкой по периодам (день/неделя/месяц), товарам, кассирам, кассам, способам оплаты. UI: страница `/reports/sales` с фильтром периода и экспортом в CSV/XLSX. | Без отчёта по продажам управлять бизнесом невозможно. |
| P1-9 | **Отчёт «Остатки на дату»** | `/api/reports/stock` с восстановлением остатков на любую дату через `StockMovement` журнал. UI с экспортом. | Налоговый учёт, инвентаризация. |
| P1-10 | **Отчёт «Прибыль»** | `/api/reports/profit` — выручка - себестоимость по периодам/группам/товарам. Используем `Cost` snapshot из `RetailSaleLine`. | Главный показатель магазина. |
| P1-11 | **Отчёт «ABC-анализ»** | Топ товаров по выручке/прибыли/маржинальности за период. Группа A/B/C по правилу Парето. | Управление ассортиментом. |
| P1-12 | **POS Sync API** | Endpoints: `GET /api/pos/sync?since={ts}` (товары, цены, остатки, контрагенты с изменениями после ts); `POST /api/pos/sales` (батч продаж с idempotency-key). Контракты в `food-market.shared`. | Без этого POS-приложение не может синхронизироваться с сервером. |
| P1-13 | **POS WPF MVP** | Минимальный UI: логин кассира (привязка к RetailPoint), список товаров/поиск по штрихкоду, корзина, оплата (нал/карта), печать чека (с ОФД), оффлайн-буфер на SQLite, фоновая синхронизация. | Главная фича проекта по позиционированию. |
| P1-14 | **MoySklad — Demand sync** | Импорт оптовых отгрузок (демандов) из МойСклад. Расширить `MoySkladImportService`. | Текущая интеграция только односторонняя для каталога; продажи не синхронизируются. |
| P1-15 | **MoySklad — webhook на изменения** | Получать webhook'и от МойСклад при изменении товаров, автоматически обновлять каталог (вместо ручного «Импортировать сейчас»). | Двусторонняя живая синхронизация. |
| P1-16 | **Hangfire dashboard** | Подключить `Hangfire.Dashboard` с авторизацией только для SuperAdmin. Добавить scheduled jobs: ежедневный cleanup `StockMovement` (старше 2 лет), audit-log (старше 90 дней), eтиничные jobs (e.g. рассылка email). | Сейчас jobs запускаются только вручную через AdminCleanupController; нет видимости. |
| P1-17 | **Метрики Prometheus** | Подключить `prometheus-net.AspNetCore` (`/metrics` endpoint). Базовый набор: http_requests_total, http_request_duration, db_query_duration, business: sales_count, supply_posted_count, errors_total. | Без observability нельзя гнать прод. |
| P1-18 | **Аудит мутаций tenant'а** | Расширить `SuperAdminAuditLog` на обычные org-мутации (`OrgAuditLog`): кто, когда, что изменил в Supply/Sale/Product/Counterparty. Хранить diff JSON. | Розница часто судится с сотрудниками по поводу пропавших товаров — нужны доказательства. |
| P1-19 | **OpenAPI спецификация** | Включить `Swashbuckle.AspNetCore`. Опубликовать `/swagger/v1/swagger.json` (только в Dev) и сгенерировать TypeScript-клиент для food-market.web. | Удалит ручной труд по типизации API в фронте и POS. |
| P1-20 | **Unit-тесты критичной логики** | Покрыть xUnit'ом: `StockService.ApplyMovement`, расчёт Cost при `SuppliesController.Post`, расчёт автонаценки по `ProductGroup.MarkupPercent`, валидация платежа `RetailSalesController.Post`, multi-tenant query filter. | Без этих тестов любое изменение логики Supply/Sale = потенциально баг с минусовыми остатками или потерями денег. |
| P1-21 | **Integration-тесты на тестовой БД** | `Testcontainers.PostgreSql` + `WebApplicationFactory`. Покрыть: signup-flow, supply post→unpost, retail sale post с overselling, tenant isolation (org A vs org B), permission проверки. | Регрессия на каждый коммит в CI. |
| P1-22 | **Email-нотификации** | Готовый MailKit-сервис расширить шаблонами: приглашение сотрудника (с временным паролем), еженедельный отчёт владельцу, low-stock alert. Хранить шаблоны в `Resources/EmailTemplates/*.html`. | Сейчас email отправляется только при forgot-password. |
### Приоритет P2 — желательные улучшения
| # | Задача | Что сделать | Зачем |
|---:|---|---|---|
| P2-1 | **Платёжный шлюз Kaspi Pay** | Интеграция Kaspi Pay QR (касса показывает QR, покупатель оплачивает с приложения, callback фиксирует оплату в RetailSale). | Самый популярный способ безнала в РК. |
| P2-2 | **Платёжные шлюзы банков** | Halyk Epay, Jusan Pay, Forte Pay (POS-терминал API или e-commerce). | Альтернативы Kaspi. |
| P2-3 | **Интеграция с маркетплейсами** | Ozon Seller API, Wildberries, Kaspi Magazin — синхронизация остатков и цен (исходящая), импорт заказов (входящая). | Расширение каналов продаж. |
| P2-4 | **2FA для админов** | TOTP (Google Authenticator) для роли Admin и SuperAdmin. Использовать `Identity.AddDefaultTokenProviders` + `AuthenticatorTokenProvider`. | Защита платёжного функционала. |
| P2-5 | **SSO (Google/Microsoft)** | Расширить OpenIddict внешними провайдерами для логина персонала. | UX для офисных сотрудников. |
| P2-6 | **Многоязычность (en/kz)** | Подключить `react-i18next` в web, выделить русские строки в `locales/ru.json`. Перевести интерфейс на казахский (государственное требование). | Государство РК требует госязык в публичных интерфейсах. |
| P2-7 | **WebSocket / SignalR для real-time** | Push-уведомления на дашборд (новая продажа), кассе (изменение цены), импортах (вместо polling). | UX + снижение нагрузки от polling. |
| P2-8 | **Аналитика на public-сайте** | Google Analytics или Yandex.Metrika, A/B тесты pricing'а, события signup-конверсии. | Маркетинг. |
| P2-9 | **Mobile-приложение (PWA или React Native)** | Просмотр остатков, продаж, KPI для владельца. | UX для владельцев. |
| P2-10 | **Распознавание чеков (OCR)** | Загрузка фото чека от поставщика → распознавание → автозаполнение Supply. | Уменьшение ручного ввода. |
| P2-11 | **Электронные счёт-фактуры (ЭСФ)** | Интеграция с ИС ЭСФ КГД РК (выпуск счетов-фактур для юрлиц). | Часть оптовых клиентов требует ЭСФ. |
| P2-12 | **Бонусные программы / скидочные карты** | Domain: `LoyaltyProgram`, `LoyaltyCard`. Списание/начисление в RetailSale. | Удержание клиентов. |
| P2-13 | **Промокоды / акции** | Domain: `Promotion`, правила (категория, период, % скидки). UI-настройка из админки. | Маркетинг для магазина. |
| P2-14 | **Telegram-бот для владельца** | Ежедневная сводка выручки, low-stock alerts. | UX для владельцев. |
| P2-15 | **Multi-storage для изображений** | Сейчас файлы лежат в `/app/uploads` (volume). Перевести на S3-совместимое хранилище (MinIO/Yandex.Cloud). | Масштабируемость, отказоустойчивость. |
---
## 3. Дорожная карта (рекомендованная последовательность)
### Спринт 1 — Стабилизация (2-3 недели)
Цель: безопасно выкатить текущий функционал в прод.
- P0-1 → P0-9 (все блокеры запуска)
- P1-20, P1-21 (юнит/интеграционные тесты на текущую логику)
- P1-18 (аудит мутаций tenant'а)
**Критерий готовности:** прод-стенд работает с HTTPS, rate-limit'ы установлены, бэкап автоматический, фискализация ОФД работает, права RolePermissions проверяются.
### Спринт 2 — Складской учёт (3-4 недели)
Цель: полноценное складское ядро ERP.
- P1-1 (Enter), P1-2 (Loss), P1-3 (Transfer), P1-4 (Inventory)
- P1-6 (CustomerReturn), P1-7 (SupplierReturn)
- P1-16 (Hangfire dashboard + scheduled cleanup)
**Критерий готовности:** магазин может вести полный складской учёт без обходных путей.
### Спринт 3 — Отчёты и аналитика (2 недели)
- P1-8 (Sales report), P1-9 (Stock on date), P1-10 (Profit), P1-11 (ABC)
- P1-19 (OpenAPI / Swagger)
**Критерий готовности:** владелец видит, как идёт бизнес, без выгрузки в Excel.
### Спринт 4 — POS (4-6 недель)
- P1-12 (POS Sync API), `food-market.shared` контракты
- P1-13 (POS WPF MVP)
- P1-17 (метрики Prometheus + Grafana dashboard)
**Критерий готовности:** касса работает оффлайн, синхронизируется с сервером, печатает фискальные чеки.
### Спринт 5 — Оптовые продажи + MoySklad full sync (2-3 недели)
- P1-5 (Demand)
- P1-14 (MoySklad Demand sync), P1-15 (webhook'и)
- P1-22 (email-шаблоны)
**Критерий готовности:** клиент, работающий с юрлицами через МойСклад, может полностью перейти на Food Market.
### Спринт 6+ — Интеграции и фичи (P2)
P2-1 Kaspi Pay → P2-3 маркетплейсы → P2-6 локализация → P2-11 ЭСФ → P2-12/13 лояльность/акции.
---
## 4. Технический долг (для рефакторинга)
Не блокирует функциональность, но затрудняет развитие.
| # | Что | Почему важно |
|---:|---|---|
| TD-1 | **CQRS через MediatR** — перенести бизнес-логику из контроллеров в Command/Query handlers. | Сейчас невозможно переиспользовать логику между API/POS/Hangfire. Контроллеры по 500 строк. |
| TD-2 | **FluentValidation** — заменить inline-валидацию в контроллерах на отдельные `Validator<T>`. | Сейчас валидация перемешана с бизнес-логикой, тестировать сложно. |
| TD-3 | **Mapster** — выделить mapping в отдельные `MapperConfig`. | Сейчас projection'ы инлайнятся в LINQ-запросы, переиспользования нет. |
| TD-4 | **Структурные log-fields в Serilog** — добавить `org_id`, `user_id`, `correlation_id` в log scope. | Сейчас в логах сложно найти конкретного пользователя/организацию. |
| TD-5 | **ImportJobRegistry в БД** — сейчас in-memory `ConcurrentDictionary`. При рестарте API теряется. Перевести на таблицу `ImportJobs`. | Жизненный цикл job'а >5 минут — рестарт обычное дело. |
| TD-6 | **Concurrency-токены на документах** — `RowVersion` (xmin/timestamp) на Supply/RetailSale, чтобы исключить race condition при параллельной правке. | Сейчас два кассира могут испортить один чек. |
---
## 5. Сводка по оценке готовности
```
┌──────────────────────────────────┬──────────────┬─────────────┐
│ Категория │ Готовность │ Состояние │
├──────────────────────────────────┼──────────────┼─────────────┤
│ Авторизация и multi-tenancy │ 95% │ ✅ готово │
│ Каталог товаров │ 95% │ ✅ готово │
│ Документы (Supply, RetailSale) │ 100% │ ✅ готово │
│ Документы (Inventory/Loss/...) │ 0% │ ❌ нет │
│ Отчёты │ 5% │ ❌ нет │
│ POS │ 5% │ ❌ нет │
│ MoySklad │ 50% │ 🟡 частично │
│ Платежи и фискализация │ 0% │ ❌ нет │
│ Инфраструктура (CI/CD, Docker) │ 90% │ ✅ готово │
│ Безопасность (HTTPS, rate-limit) │ 30% │ 🟡 частично │
│ Observability (метрики, аудит) │ 20% │ 🟡 частично │
│ Тестирование │ 40% │ 🟡 частично │
└──────────────────────────────────┴──────────────┴─────────────┘
Общая готовность к продакшен-запуску: 60-65%
- Для MVP "магазин на одном POS-терминале": требуется ОФД + базовые складские документы.
- Для полноценного ERP: требуется выполнение P0+P1.
- Для конкуренции с МойСклад: требуется ещё и P2.
```

View file

@ -1,630 +0,0 @@
# ТЗ на тестирование Food Market
> Дата составления: 2026-05-22
> Автор: Claude Opus 4.7
> Документ парный к [TZ-доработка.md](./TZ-доработка.md). Описывает что и как проверять до и после релизов.
---
## 0. Принципы тестирования
### 0.1. Пирамида тестов
```
▲ E2E (Playwright, full-cycle) ~ 5%
▲▲▲ API integration (axios + Testcontainers) ~ 25%
▲▲▲▲▲ Unit-тесты бизнес-логики (xUnit) ~ 70%
```
Сейчас реальное соотношение **5% / 0% / 0%** — это нужно перевернуть.
### 0.2. Уровни тестирования
| Уровень | Когда запускается | Что проверяет |
|---|---|---|
| **Smoke** | Каждый push, ≤2 мин | Сервис стартует, /health отвечает 200, миграции применены, главные страницы открываются. |
| **Регрессия** | Каждый PR, ≤10 мин | Основные сценарии не сломаны: signup→login, создание продукта/приёмки/продажи, multi-tenant isolation. |
| **E2E full-cycle** | Каждый merge в main, ≤30 мин | Полный путь от создания организации до отчёта о продажах. |
| **Нагрузочные** | Перед мажорными релизами | 1000 одновременных пользователей, 10000 товаров, 50000 движений. |
| **Безопасность** | Перед релизом + регулярно | OWASP Top-10, рейт-лимит, multi-tenant утечки. |
### 0.3. Когда считать «работает корректно»
- **Бэкенд:** код возвращает ожидаемый HTTP-код, тело ответа валидно по схеме, побочные эффекты в БД соответствуют ожиданиям (StockMovement, Stock.Quantity).
- **Фронтенд:** интерфейс отображает корректные данные, формы валидируются (HTML5 + onBlur + submit), ошибки сервера показываются человекочитаемо.
- **Безопасность:** запрещённые операции возвращают 401/403/404 без утечки информации (не «такого пользователя нет», а «неверный логин/пароль»).
- **Мультитенант:** пользователь org A никогда не видит и не изменяет данные org B (ни через UI, ни через прямой API).
### 0.4. Что считать багом
- Любой 500 без log-entry с причиной.
- Отрицательный остаток после провёденной продажи (overselling без контроля).
- Несоответствие `Stock.Quantity` сумме `StockMovement.Quantity` по тому же (Product, Store).
- Возможность увидеть/изменить данные другой организации.
- Возможность залогиниться как заархивированный/удалённый сотрудник.
- Email-flow signup/reset не работает (письма не доходят).
---
## 1. Приоритезация по критичности модулей
| Приоритет | Модули | Что попадает |
|---|---|---|
| **P0 (smoke + регрессия в каждом PR)** | Auth, Supply, RetailSale, Stock, Multi-tenancy | Без этого не работает ничего. |
| **P1 (регрессия перед релизом)** | Catalog (Products, Groups, Counterparties), SuperAdmin Console, MoySklad import, Permissions | Без этого магазин не функционален. |
| **P2 (один раз перед мажорным релизом + после изменений)** | Reports, Email, Health checks, UI-валидация, локализация чисел/дат | Не блокирует, но влияет на UX. |
| **P3 (точечно по требованию)** | Public site (Astro), CI/CD-сценарии | Меняется реже. |
---
## 2. Сценарии тестирования по модулям
### 2.1. Аутентификация (P0)
#### 2.1.1. Регистрация новой организации (`POST /api/auth/signup`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: новая орга** | POST с уникальным email, паролем 8+, org name, валидным KZ-телефоном (+7 7XX...). | 201, в БД: новый Organization, User с ролью Admin, Employee, bootstrap-данные (Stores=1, Roles=4, Units, PriceTypes). |
| **Email уже занят** | Повторный POST с тем же email. | 400 «email уже занят» (без раскрытия деталей о существующей орге). |
| **Слабый пароль (<8 симв.)** | POST с password="abc". | 400, поле password в ошибках. |
| **Невалидный телефон** | POST с phone="+79161234567" (РФ). | 400 «введите корректный номер Казахстана». |
| **Без согласия с офертой** | UI: не отметить чекбокс. | Submit заблокирован, поле agree красное. |
| **Email с лишними пробелами** | " user@example.kz ". | Нормализация: trim, lowercase. 201. |
| **Bootstrap полнота** | После регистрации: GET `/api/catalog/stores` → 1 основной склад; GET `/api/organization/employee-roles` → 4 системных роли (Admin/Manager/Storekeeper/Cashier). | Соответствие. |
#### 2.1.2. Логин (`POST /connect/token`)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: правильный пароль** | grant_type=password, valid creds. | 200, access_token (jwt), refresh_token, claims.org_id, claims.role. |
| **Неверный пароль** | Wrong password. | 400 OAuth error="invalid_grant", без раскрытия «такой email есть» vs «нет». |
| **Заблокированный пользователь** | User.IsActive=false. | 400, токен не выдан. |
| **Архивированная организация** | Organization.IsArchived=true. | 400, токен не выдан, claim org_id отсутствует или вход запрещён. |
| **SuperAdmin без org** | User с ролью SuperAdmin, OrganizationId=null. | 200, токен выдан, claim role="SuperAdmin". |
| **Refresh token flow** | grant_type=refresh_token с действующим refresh. | 200, новый access + refresh (sliding). |
| **Истёкший access токен** | Запрос с истёкшим токеном. | 401, фронт делает refresh автоматически. |
| **Истёкший refresh** | Подождать >30 дней. | 400 на refresh, форс-логаут на фронте. |
| **Rate limit (после P0-3)** | 6+ login-попыток за минуту с одного IP. | 429 Too Many Requests. |
#### 2.1.3. Forgot/Reset password
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Happy: запрос восстановления** | POST `/api/auth/forgot-password` с email существующего. | 200 (всегда), на email приходит ссылка с токеном (1 час). |
| **Несуществующий email** | POST с unknown@example. | 200 (anti-enumeration), без email. |
| **Сброс по токену** | POST `/api/auth/reset-password` с email+token+newPassword. | 200, все refresh_token'ы revoke'нуты. |
| **Просроченный токен (>1 ч)** | POST с истёкшим токеном. | 400 «ссылка устарела». |
| **Rate limit forgot** | 4+ запроса за час с одного IP. | 429 «слишком много попыток». |
---
### 2.2. Multi-tenancy и изоляция данных (P0, КРИТИЧНО)
#### 2.2.1. Изоляция через UI
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Видимость списков** | Залогиниться в org A, создать товар «Хлеб». Залогиниться в org B. | `/catalog/products` org B не содержит «Хлеб». |
| **Переключение организаций** | Один email в двух организациях (если возможно — сейчас нет). | (Не поддерживается — каждый User принадлежит одной org). |
#### 2.2.2. Изоляция через прямой API
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **GET по GUID чужой орги** | Залогиниться в org A, узнать `productId` org B (например, через БД). GET `/api/catalog/products/{productIdOrgB}`. | 404 (не 200). Query filter скрывает. |
| **PUT по GUID чужой орги** | PUT `/api/catalog/products/{productIdOrgB}` с теми же данными. | 404 (не 200, не 200 с записью в чужую). |
| **DELETE по GUID чужой орги** | DELETE того же. | 404. |
| **POST с FK на чужую сущность** | POST Supply в org A с supplierId, принадлежащим org B. | 400 «contact not found» (FK проверка через query filter). |
| **Подделка org_id в JWT** | Сгенерировать токен с org_id чужой орги (без подписи). | 401 (подпись не валидна). |
#### 2.2.3. SuperAdmin override
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **SuperAdmin без override** | GET `/api/super-admin/organizations`. | Видит все организации. |
| **SuperAdmin с override (read)** | GET `/api/catalog/products` с заголовком `X-Org-Override: <orgId>`. | Видит товары org X. |
| **SuperAdmin с override (write)** | PUT `/api/catalog/products/{id}` с `X-Org-Override: <orgId>` без `X-Org-Override-Reason`. | 403 «Read-only mode...». |
| **SuperAdmin с override + reason** | PUT с обоими заголовками (`X-Org-Override-Reason: "Customer support ticket #123, исправляем дубль штрихкода"`). | 200, запись в SuperAdminAuditLog. |
| **Reason слишком короткий** | reason="ok". | 403. |
| **Обычный Admin с X-Org-Override** | Admin org A пытается подделать заголовок и выйти в org B. | 403 «only SuperAdmin can override». |
#### 2.2.4. Глобальные справочники
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Country / Currency видны всем** | Login org A: GET /api/catalog/countries → видит. Login org B: GET → тот же список. | Идентичные списки. |
| **System ProductGroup видна всем** | SuperAdmin создаёт ProductGroup OrganizationId=null. Login любая org: видит. | Видна, но не редактируется обычным Admin. |
| **System UnitOfMeasure (ОКЕИ)** | SuperAdmin: GET /api/super-admin/units-of-measure → видит все global. Admin org A: GET /api/catalog/units-of-measure → видит только enabled через junction. | Корректная фильтрация. |
---
### 2.3. Каталог (P1)
#### 2.3.1. Товары (Products)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание минимального** | POST `/api/catalog/products` с name + unit. | 201, автогенерация артикула (числовой), штрихкода нет. |
| **Создание с штрихкодом EAN13** | POST с barcode value="4607034630092", type=Ean13. | 201, валидация контрольной цифры. |
| **EAN13 с невалидной контрольной цифрой** | barcode="4607034630099". | 400 «невалидный EAN13». |
| **Дубль штрихкода в одной орге** | POST два товара с одинаковым штрихкодом. | 409 на втором. |
| **Дубль штрихкода в разных оргах** | Org A: barcode X. Org B: тот же barcode. | 201 для обеих (per-tenant unique). |
| **Цена по типам** | POST с prices: розничная=1000 KZT, оптовая=800 KZT. | Записи в ProductPrice. |
| **Обязательная розничная цена** | POST без розничной. | 400 «требуется розничная цена» (если PriceType.IsRequired=true). |
| **Артикул вручную** | POST с article="ABC-001". | 201, без автогенерации. |
| **Дубль артикула** | Два товара с article="ABC-001". | 409. |
| **Поиск по barcode** | GET `/api/catalog/products/by-barcode/4607034630092`. | Возвращает товар. |
| **Quick search** | GET `/api/catalog/products/quick-search?q=хле`. | Ранжирование: exact barcode → article → name prefix → name contains. |
| **Фильтр по группе** | GET `?groupId=X`. | Только товары группы X. |
| **Фильтр по упаковке** | `?packaging=Weight`. | Только весовые. |
| **Удаление товара с приёмками** | DELETE товара, на который есть SupplyLine. | 409 «нельзя удалить, есть документы». |
| **Удаление без документов** | DELETE свежесозданного. | 204. |
| **Изображения товара** | POST `/api/catalog/products/{id}/images` с jpg <10MB. | 201, файл в /uploads, ImageUrl обновлён. |
| **Загрузка > 10MB** | POST с 15MB файлом. | 400. |
| **Установка main image** | POST `/images/{imageId}/main`. | Product.ImageUrl ← путь, IsMain переброшен. |
#### 2.3.2. Группы товаров (ProductGroups)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание корневой** | POST с name="Хлебобулочные", parentId=null. | 201, Path="Хлебобулочные". |
| **Создание дочерней** | POST с parentId="<root>", name="Хлеб". | 201, Path="Хлебобулочные/Хлеб". |
| **Циклическая ссылка** | PUT группы с parentId=своим id или потомка. | 400 «цикл». |
| **Удаление с подгруппами** | DELETE родителя у которого есть дети. | 409. |
| **Удаление с товарами** | DELETE группы с привязанными товарами. | 409. |
| **Системная группа SuperAdmin** | SuperAdmin POST с OrganizationId=null. Login org → видна, но Edit/Delete недоступны. | Корректное поведение. |
| **Markup percent** | Группа с MarkupPercent=20%. Cost=1000 → RecalcRetail = 1200. | Round up до целых (по AllowFractionalPrices). |
#### 2.3.3. PriceTypes
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Только один IsRetail** | Создать два PriceType с IsRetail=true. | 409 на втором. |
| **IsSystem нельзя удалить** | DELETE PriceType с IsSystem=true. | 409. |
| **Переименование системного** | PUT name. | 200 (можно). |
| **Toggle IsRequired системного** | PUT IsRequired=false для системного. | 409 (зафиксирован). |
#### 2.3.4. UnitsOfMeasure
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Enable global unit** | POST `/api/catalog/units-of-measure/{id}/enable`. | 204, junction record создан. |
| **Disable enabled unit с ссылками** | DELETE enable у unit'а, который используется товаром. | 409 «нельзя — товары используют». |
| **SuperAdmin: создать global unit** | POST `/api/super-admin/units-of-measure` с code="МЛ". | 201. |
| **SuperAdmin: удалить global unit с ссылками** | DELETE того же если ссылается товар. | 409 со списком орг. |
#### 2.3.5. Counterparties
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Юрлицо с БИН** | POST с type=LegalEntity, bin="123456789012". | 201. |
| **БИН не 12 цифр** | bin="1234". | 400. |
| **Физлицо с ИИН** | type=Individual, iin="850101300123". | 201. |
| **Невалидный KZ телефон** | phone="+79161234567". | 400. |
---
### 2.4. Документы: Приёмки (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST Supply с supplier, store, currency, 2 lines. | 201, status=Draft, Total=sum(qty*price), Stock не изменился. |
| **Posting** | POST `/posts/{id}/post`. | Stock.Quantity += sum(qty). StockMovement +N записей с type=Supply. Cost пересчитан (weighted avg). |
| **Cost weighted average** | До: Stock.Cost=100, qty=10. Приёмка: qty=10, price=120. После: Cost = (10*100+10*120)/20 = 110. | Соответствие. |
| **ReferencePrice при первой приёмке** | Product без приёмок. После Supply.Post: Product.ReferencePrice = UnitPrice. | Соответствие. |
| **Автонаценка розничной** | ProductGroup.MarkupPercent=30, товар без override розницы, Cost=100. После Post: ProductPrice (IsRetail) = 130. | Соответствие. |
| **Unpost** | POST `/{id}/unpost` на провёденной. | StockMovement удалены/инвертированы, Stock.Quantity вернулся, status=Draft. |
| **Edit проведённой** | PUT провёденной (status=Posted). | 409 «нельзя редактировать проведённую». |
| **Delete Draft** | DELETE Draft без посту. | 204. |
| **Delete проведённой** | DELETE Posted. | 409. |
| **Posting → отрицательный остаток после unpost** | Post Supply (+10), затем Sale (-15), затем Unpost Supply. | 409 «нельзя расковать, остаток уйдёт в минус» (по дизайну). |
| **FK на удалённого поставщика** | Supplier удалён (если бы это было возможно), Supply ссылается. | Корректная обработка (запрет удаления поставщика или nullable). |
| **Пустые lines** | POST с lines=[]. | 400. |
| **Quantity ≤ 0** | line.qty=0 или -5. | 400. |
| **Параллельный Post одного Supply** | Два запроса /post одновременно. | Один 200, второй 409 (concurrency-конфликт после P1 RowVersion). |
---
### 2.5. Документы: Розничные продажи (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание Draft** | POST RetailSale с retailPoint, lines, payments. | 201, status=Draft. |
| **Posting с достаточным остатком** | Stock=10. POST с qty=5, /post. | Stock=5, StockMovement type=RetailSale qty=-5. |
| **Posting с overselling** | Stock=3. POST с qty=5, /post. | 409 «недостаточно товара» (по фиксу из git history). |
| **PaidCash + PaidCard ≠ Total** | Total=1000, paidCash=300, paidCard=500. | 400 «суммы не сходятся». |
| **Расчёт суммы со скидкой** | Line qty=2, price=500, discount=100. Subtotal=1000, DiscountTotal=100, Total=900. | Соответствие. |
| **VAT snapshot в строке** | На момент продажи Product.VatPercent=12, после Sale.Post страну поменяли → VAT=0. Старая RetailSaleLine.VatPercent = 12. | Снимок цены/НДС сохраняется. |
| **Cashier из другого RetailPoint** | Cashier привязан к RP1. POST RetailSale с retailPointId=RP2. | 403 (после реализации Permission). |
| **Unpost продажи** | POST /unpost. | Stock возвращается, StockMovement инвертирован. |
| **Stats эндпоинт** | GET /stats?days=30. | Daily series 30 дней, revenueToday, transactionsToday, avgTicketThisMonth корректны. |
| **Возврат по чеку (после P1-6)** | POST CustomerReturn ссылается на RetailSale. | Stock возвращается, новый StockMovement type=CustomerReturn. |
---
### 2.6. Остатки и движения (P0)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Целостность Stock vs Movement** | После N операций: для любых (ProductId, StoreId) `Stock.Quantity == SUM(StockMovement.Quantity)`. | Соответствие (инвариант). |
| **Reserved** | (Если реализуется в P1) Сейчас Reserved=0 везде. | После резервирования через Demand: Reserved += qty. |
| **Доступно (Available)** | Available = Quantity - Reserved. | Корректно. |
| **Фильтр includeZero=false** | Stock.Quantity=0. GET /stock?includeZero=false. | Не включается. |
| **Movements: пагинация** | 1000 движений. GET ?page=1&pageSize=50. | total=1000, items=50. |
| **Сортировка по occurredAt desc** | GET ?sort=-occurredAt. | Свежие сверху. |
| **Фильтр по storeId** | Два склада, по 10 movements в каждом. GET ?storeId=X. | Только 10 из X. |
---
### 2.7. Сотрудники и роли (P1)
#### 2.7.1. CRUD сотрудников
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание без учётки** | POST с createAccount=false. | 201, Employee.UserId=null. |
| **Создание с учёткой** | POST с createAccount=true, email. | 201, User создан с temp password, password возвращён в response (один раз). |
| **Email обязателен при createAccount=true** | POST с email="" и createAccount=true. | 400. |
| **Дубль email** | Два сотрудника, одинаковый email при createAccount. | 409. |
| **Soft-delete (увольнение)** | DELETE employeeId. | IsActive=false, FiredAt=now, IsDeleted=false. User блокируется. |
| **Полное удаление (после увольнения)** | DELETE дважды (после Fired). | IsDeleted=true, DeletedAt=now. |
| **Архивный сотрудник в документах** | После soft-delete: старые Supply показывают «Иванов И. (удалён)». | Подпись сохраняется. |
| **Защита OwnerUser** | DELETE главного администратора (Organization.AccountOwnerUserId == Employee.UserId). | 409 «только SuperAdmin». |
| **Защита самого себя** | Залогинен Иванов, пытается DELETE сам себя. | 409 «нельзя удалить себя». |
#### 2.7.2. Роли и Permission-based authz (после P0-5)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Системные роли созданы** | После signup: 4-6 системных ролей (Admin, Manager, Storekeeper, Cashier, ...). | IsSystem=true, не удаляются. |
| **Кастомная роль** | POST роль "Менеджер по продажам" с permissions {productsView:true, suppliesView:true, all_else:false}. | 201. |
| **Permission DENY** | Employee с ролью "Менеджер по продажам" → POST Product. | 403 «нет права ProductsEdit». |
| **Permission ALLOW** | Тот же → GET /api/catalog/products. | 200. |
| **Изменение прав роли** | PUT permissions роли. | Применяется ко всем сотрудникам этой роли (без revoke токенов). |
| **Удаление роли с сотрудниками** | DELETE используемой роли. | 409. |
| **Cashier → RetailPoint** | Cashier с EmployeeRetailPointAssignment (RP1). POST RetailSale (RP1). | 200. С RP2: 403. |
---
### 2.8. SuperAdmin Console (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание организации** | POST /api/super-admin/organizations с org+admin данными. | 201, temp password возвращён, organization в БД. |
| **Аудит создания** | SELECT FROM super_admin_audit_log WHERE action_type='OrganizationCreated'. | Запись с reason, before/after JSON. |
| **Архивирование** | POST /{id}/archive с confirmationName=правильное имя org. | IsArchived=true, ArchivedAt=now. Логины этой org перестают работать. |
| **Архивирование с неверным confirmationName** | confirmationName="wrong". | 400. |
| **Восстановление** | POST /{id}/restore. | IsArchived=false. |
| **Hard delete после retention period** | Через SystemSettings.ArchiveRetentionDays=0, DELETE архивной. | 204, организация физически удалена (CASCADE). |
| **Hard delete до retention** | DELETE архивной до истечения. | 409 «до удаления ещё N дней». |
| **Смена владельца** | POST /change-owner с newOwnerUserId, reason. | Organization.AccountOwnerUserId обновлён, запись в audit log. |
| **Reason требуется** | POST /change-owner без reason. | 400. |
| **Reason < 10 символов** | POST с reason="ok". | 400. |
| **Audit log фильтр** | GET /audit-log?orgId=X&actionType=Y. | Корректная фильтрация. |
| **Audit log CSV экспорт** | Через UI кнопка «Экспорт». | CSV-файл скачивается. |
---
### 2.9. SuperAdmin Platform Settings — SMTP (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение SMTP** | PUT с host, port, ssl, username, newSmtpPassword="pass123". | 200, hasSmtpPassword=true. Пароль в БД зашифрован. |
| **Получение настроек** | GET. | Все поля кроме password. password не возвращается. |
| **Очистка пароля** | PUT с newSmtpPassword="__clear__". | hasSmtpPassword=false. |
| **Test send** | POST /test-send. | Письмо приходит на адрес супер-админа. |
| **Test send без настроек** | POST /test-send когда SMTP не настроен. | 400 «SMTP не настроен». |
| **Forgot password после настройки** | Юзер делает forgot-password → письмо со ссылкой приходит. | Соответствие. |
---
### 2.10. MoySklad интеграция (P1)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Сохранение токена** | PUT /api/admin/moysklad/settings с token. | Token сохранён в Organization, masked при GET. |
| **Test connection** | POST /test с валидным токеном. | 200, возвращает {organization, inn}. |
| **Test с невалидным токеном** | POST /test с "abc". | 400 или 401 с понятным сообщением. |
| **Import counterparties** | POST /import/counterparties. | 202, jobId возвращён. |
| **Job progress polling** | GET /api/admin/jobs/{id} раз в 1.5 сек. | Status: InProgress → Succeeded. Stage обновляется. |
| **Импортированные контрагенты** | GET /api/catalog/counterparties после import. | N новых контрагентов с правильными BIN, телефонами. |
| **OverwriteExisting=true** | Повторный import с overwrite. | Обновляются по name (case-insensitive), не дубли. |
| **Импорт товаров** | POST /import/products. | Товары + группы + штрихкоды + остатки импортированы. |
| **Архивные товары** | МойСклад имеет archived товары. | Импортируются в Product.IsArchived=true. |
| **Прерывание job (после P1)** | Рестарт API во время импорта. | Job маркируется как Failed, можно перезапустить. |
---
### 2.11. Складские документы (P1, после реализации)
#### 2.11.1. Оприходование (Enter)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание/Post** | POST Enter с lines, /post. | Stock += qty, StockMovement type=Enter. |
| **Без поставщика** | POST без supplierId. | 201 (Enter не требует supplier). |
#### 2.11.2. Списание (Loss)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Списание брака** | POST Loss с reason="брак", lines с qty. | Stock -= qty, StockMovement type=WriteOff. |
| **Списание сверх остатка** | Stock=3, Loss qty=5. | 409 «недостаточно». |
#### 2.11.3. Перемещение (Transfer)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Между складами** | POST Transfer (FromStore=A, ToStore=B), lines. /post. | Stock[A] -= qty, Stock[B] += qty. Два StockMovement (TransferOut + TransferIn). |
| **Атомарность** | Если ToStore Stock записать не удалось (например, БД упала между). | Транзакция откатывается, Stock[A] не изменён. |
#### 2.11.4. Инвентаризация (Inventory)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Создание с импортом текущих** | POST Inventory storeId=X. | InventoryLine на каждый товар склада X с bookQty=Stock.Quantity, actualQty=null. |
| **Заполнение фактических** | PUT с actualQty по каждой строке. | Diff = actual - book. |
| **Post** | POST /post. | StockMovement type=InventoryAdjustment с qty=diff. Stock актуализирован. |
| **CSV-импорт фактических** | UI: загрузка CSV (sku, qty). | Заполнение строк. |
---
### 2.12. Отчёты (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Отчёт по продажам** | GET /api/reports/sales?from=...&to=...&groupBy=day. | Series выручки по дням. |
| **Отчёт по продажам с фильтром по кассиру** | ?cashierId=X. | Только продажи этого кассира. |
| **Отчёт «остатки на дату»** | GET /stock?date=2026-04-01. | Stock восстановлен через `SUM(Movement WHERE occurredAt <= date)`. |
| **Отчёт «прибыль»** | GET /profit?from=...&to=... | Выручка - себестоимость (использует Cost snapshot из RetailSaleLine). |
| **ABC-анализ** | GET /abc?metric=revenue&period=last_quarter. | Топ-20% товаров (группа A), следующие 30% (B), остаток (C). |
| **Экспорт CSV/XLSX** | UI: кнопка «Экспорт». | Скачивается файл с теми же данными. |
| **Большой объём** | 100k продаж в периоде. | <5 секунд ответ (с агрегацией на стороне БД). |
---
### 2.13. POS Sync API (P1, после реализации)
| Сценарий | Шаги | Ожидание |
|---|---|---|
| **Initial sync** | POS первый раз: GET /api/pos/sync?since=0. | Все товары, цены, остатки, контрагенты. |
| **Incremental sync** | GET /api/pos/sync?since=<lastSyncTimestamp>. | Только изменения после timestamp. |
| **Batch upload sales** | POST /api/pos/sales [{idempotencyKey, ...}, ...]. | Все продажи провёдены, idempotency повторных вызовов. |
| **Idempotency** | POST с тем же idempotencyKey дважды. | Второй вызов возвращает оригинальный результат, без дубля. |
| **Offline → online** | POS работает offline 1 час, накопил 20 продаж. После online: upload. | Все 20 синхронизированы корректно. |
| **Conflict: товар удалён** | POS отправил продажу товара, который SuperAdmin удалил. | 409, POS откатывает локально или маркирует как ошибку. |
---
### 2.14. Web-админка UI/UX (P2)
| Сценарий | Что проверить |
|---|---|
| **Темная тема** | Все 35 страниц — переключение dark mode через кнопку. Без артефактов, контраст AAA. |
| **Адаптив** | Каждая страница на мобильном (375px), планшете (768px), десктопе (1280px). |
| **onBlur валидация форм** | После только что внедрённого ФЛК (см. git ff44afc): SignupForm, LoginPage, ResetPasswordPage, ForgotPasswordPage, EmployeesPage, CounterpartiesPage, StoresPage, PriceTypesPage, EmployeeRolesPage, RetailPointsPage, OrganizationSettingsPage, SuperAdminOrgCreatePage. Каждое поле показывает ошибку при потере фокуса. |
| **onChange сбрасывает ошибку** | После показа ошибки, начать вводить — ошибка должна убираться. |
| **Обработка 401 в axios** | Истёкший токен → автоматический refresh → повторный запрос. |
| **Обработка 403** | Понятная страница «нет прав». |
| **Обработка 500** | Не белый экран, а toast/alert. |
| **Loading states** | Спиннеры/skeleton'ы на всех таблицах при загрузке. |
| **Empty state** | Пустые списки показывают «Нет данных», а не пустую таблицу. |
| **Pagination** | Корректные счётчики, переходы. На последней странице нет «next». |
| **Sticky header** | Заголовок таблицы остаётся при скролле длинных списков. |
| **Локализация** | Все строки на русском. Числа в `toLocaleString('ru-KZ')` (1 234,56). Даты в `dd.MM.yyyy`. |
---
### 2.15. Public site (Astro) (P3)
| Сценарий | Что проверить |
|---|---|
| **Маршруты** | /, /pricing, /features, /pos, /import, /integrations, /about, /contacts, /signup, /blog/[slug], /kb/[slug], /legal/[slug] — все открываются. |
| **SignupForm на /signup** | Заполнить корректно → редирект на admin.food-market.kz с токенами. |
| **Tariff builder на /pricing** | Изменение количества касс/складов → перерасчёт стоимости. |
| **SEO** | Каждая страница имеет title, description, canonical, og-image. |
| **sitemap.xml** | GET /sitemap.xml → валидный XML со всеми статичными страницами. |
| **robots.txt** | GET /robots.txt → ожидаемое содержимое. |
| **Lighthouse** | Performance 90+, Accessibility 95+, Best Practices 95+, SEO 100. |
---
### 2.16. Infrastructure / DevOps (P2)
| Сценарий | Что проверить |
|---|---|
| **/health** | 200 OK, JSON {status, time}. |
| **/health/ready (после P0-4)** | 200 если БД и миграции OK. 503 если БД упала. |
| **CI: build на push** | Зелёный pipeline на каждый push в main. |
| **CI: deploy on push api** | После push в `src/food-market.api/*` → docker-api workflow → деплой на stage → smoke /health → 200. |
| **Backup script** | Запуск `deploy/backup.sh local`. Файл *.sql.gz создан, размер > 0. |
| **Backup restore** | Восстановить из бэкапа в чистую БД, поднять API, проверить логин. |
| **Postgres healthcheck в compose** | docker-compose ps → postgres healthy. |
| **Persistent volumes** | После `docker compose down && up` данные сохраняются. |
| **Persistent OpenIddict ключ** | После рестарта API: refresh_token продолжает работать. |
| **Логи Serilog** | tail -f /app/logs/food-market-*.log — структурированные строки. |
| **Ротация логов** | После 14 дней старые логи удаляются. |
| **Локальный docker registry** | curl 127.0.0.1:5001/v2/_catalog → список образов. |
---
### 2.17. Безопасность (P0+P1)
| Сценарий | Что проверить |
|---|---|
| **HTTPS-only (после P0-2)** | HTTP → 301 redirect на HTTPS. HSTS-header установлен. |
| **JWT signature tampering** | Изменить body токена, не подписать. | 401. |
| **JWT expired** | Использовать токен старше 1 часа. | 401, фронт делает refresh. |
| **SQL injection** | Поиск со значением `'; DROP TABLE products;--`. | Безопасно (EF параметризует). |
| **XSS в формах** | Создать товар с name=`<script>alert(1)</script>`. | На UI выводится как текст, не как HTML. |
| **CSRF на /connect/token** | Cookie-based auth не используется, CSRF неактуален. | OK. |
| **CORS** | Запрос из http://evil.com → блокирован. |
| **Path traversal в /uploads** | GET /uploads/../../etc/passwd. | 404. |
| **Unauthenticated endpoints** | Список AllowAnonymous: /health, /api/auth/signup, /api/auth/forgot-password, /connect/token, /uploads/*. | Других — нет. |
| **Rate limit login (после P0-3)** | 10 попыток за минуту → 429. |
| **Перебор email на signup** | 100 signup-запросов с разными email — должен 429 после 20. |
| **Утечка через 404 vs 403** | GET /api/catalog/products/<существующий-чужого-tenant> возвращает 404, не 403 (чтобы не подтверждать существование). |
---
### 2.18. Производительность (P2)
| Сценарий | Цель |
|---|---|
| **GET /api/catalog/products при 10k товаров** | < 500 мс с пагинацией pageSize=50. |
| **POST Supply.Post с 100 lines** | < 2 сек. |
| **GET /api/inventory/movements при 100k записей** | < 1 сек с пагинацией. |
| **Quick search в каталоге** | < 200 мс при 10k товаров. |
| **Dashboard `/stats`** | < 500 мс при 100k продаж. |
| **N+1 запросы** | EF Profiler / Serilog log: на GET /products нет N+1 для prices/barcodes. |
| **Concurrent signup** | 50 параллельных signup → все 201, БД консистентна. |
---
## 3. Регрессионный чек-лист (перед каждым релизом)
```
[ ] Все миграции применяются на чистую БД
[ ] Smoke: GET /health → 200
[ ] Smoke: signup → новая org → login → /api/me → roles[admin]
[ ] Регрессия: создать товар → создать приёмку → /post → Stock обновился
[ ] Регрессия: создать продажу → /post → Stock уменьшился, чек создан
[ ] Регрессия: запрос с другого org_id → 404
[ ] Регрессия: SuperAdmin без override видит все организации
[ ] Регрессия: SuperAdmin с override read-only — мутация 403
[ ] Регрессия: SuperAdmin с reason — мутация 200, audit log запись
[ ] MoySklad: test connection → 200
[ ] Email: forgot password → письмо приходит
[ ] UI: все 35 страниц открываются, onBlur валидация работает
[ ] CI: docker-api workflow зелёный, smoke на /health → 200
[ ] Backup за последние сутки существует, размер > предыдущего > 50%
[ ] Логи за последний час: нет 5xx без log-entry
[ ] Метрики (после P1-17): error_rate < 1%, p95 latency < 1s
```
---
## 4. Стратегия покрытия тестами
### 4.1. Unit-тесты (xUnit, цель 70% coverage критичной логики)
```
food-market.tests.unit/
├── Domain/
│ ├── ProductTests.cs — валидация конструктора, computed properties
│ ├── StockMovementTests.cs — invariants
│ └── RolePermissionsTests.cs — расчёт прав
├── Application/ — после миграции на MediatR
│ ├── SuppliesPostHandlerTests.cs — Cost weighted average, авто-наценка
│ ├── RetailSalePostHandlerTests.cs — Stock update, overselling check
│ └── ImportJobsTests.cs — состояния job'а
└── Infrastructure/
├── TenantFilterTests.cs — query filter включается/исключается правильно
├── StockServiceTests.cs — атомарность ApplyMovement
└── MoySkladClientTests.cs — pagination, error handling (с mock HttpMessageHandler)
```
### 4.2. Integration-тесты (Testcontainers.PostgreSql + WebApplicationFactory)
```
food-market.tests.integration/
├── Auth/
│ ├── SignupFlowTests.cs — POST /signup → bootstrap data
│ └── LoginFlowTests.cs — token + refresh + revoke
├── Catalog/
│ ├── ProductsCrudTests.cs
│ └── ProductGroupsHierarchyTests.cs
├── Documents/
│ ├── SupplyPostUnpostTests.cs — Stock consistency
│ └── RetailSalePostTests.cs — overselling 409
├── Tenancy/
│ ├── MultiTenantIsolationTests.cs — org A не видит org B
│ └── SuperAdminOverrideTests.cs — read-only / edit mode
└── Authorization/
└── PermissionBasedAuthzTests.cs — после P0-5
```
### 4.3. E2E (расширить существующий full-cycle)
```
tests/e2e/scenarios/
├── full-cycle.yml — текущий: signup → import → supply → sale → report
├── multi-tenant-isolation.yml — два юзера, два org, попытки кросс-доступа
├── superadmin-flow.yml — создание org, архив, реcтор, edit-mode
├── permission-checks.yml — кастомные роли, разрешения/отказы
└── moysklad-sync.yml — конец-в-конец импорт + проверки в БД
```
---
## 5. Инструменты тестирования
| Инструмент | Назначение | Готовность |
|---|---|---|
| **xUnit + FluentAssertions** | Backend unit-тесты | ❌ нет (нужно завести проект) |
| **Testcontainers.PostgreSql** | Integration-тесты с реальной БД в Docker | ❌ нет |
| **WebApplicationFactory<Program>** | In-memory test server | ❌ нет |
| **Playwright** | E2E браузерные тесты | ✅ есть (через `tests/e2e/run.sh full-cycle`) |
| **axios + pg (TS)** | E2E API + DB-проверки | ✅ есть (`tests/e2e/lib/`) |
| **Bombardier / k6** | Нагрузочное тестирование | ❌ нет |
| **OWASP ZAP** | Сканер уязвимостей | ❌ нет (рекомендуется использовать перед prod) |
| **Lighthouse CI** | Public site performance | ❌ нет |
| **Codecov / Coverlet** | Coverage report | ❌ нет |
---
## 6. Метрики качества тестирования
```
Цели после внедрения P1-20, P1-21:
Unit-тесты бизнес-логики: ≥ 70% (сейчас 0%)
Integration-тесты API: ≥ 60% (сейчас 0%)
E2E сценариев: ≥ 5 (сейчас 1)
Время прогона полного CI: ≤ 15 мин
Время smoke в PR: ≤ 2 мин
Регрессионный SLA после релиза:
Severity 1 (блокирующие): обнаружение → фикс ≤ 2 часа
Severity 2 (важные): обнаружение → фикс ≤ 1 рабочий день
Severity 3 (косметика): в плановый спринт
```
---
## 7. Финальный чек-лист «готовности к продакшен»
Прежде чем сказать «можно запускать прод»:
```
БЕЗОПАСНОСТЬ
[ ] HTTPS forced, HSTS установлен
[ ] OpenIddict prod-сертификаты
[ ] Rate limiting на login/signup
[ ] Permission-based authorization работает
[ ] Multi-tenant изоляция проверена (см. п. 2.2)
[ ] OWASP Top-10 сканер пройден
ФУНКЦИОНАЛ
[ ] Складские документы (Enter/Loss/Transfer/Inventory) реализованы
[ ] Отчёты (Sales/Stock/Profit/ABC) реализованы
[ ] ОФД фискализация чеков работает
[ ] Email-нотификации настроены и тестированы
ИНФРАСТРУКТУРА
[ ] Backup автоматический (cron/timer)
[ ] Restore-сценарий отрепетирован
[ ] Health checks детальные (ready/live)
[ ] Метрики Prometheus + Grafana dashboard
[ ] Алерты на error_rate, latency p95
ПРОЦЕССЫ
[ ] CI зелёный на main
[ ] Coverage > 70% unit, > 60% integration
[ ] E2E full-cycle зелёный
[ ] Regression checklist пройден
[ ] Release notes написаны
[ ] Документация .env.example актуальна
[ ] Rollback plan описан
```

View file

@ -1,54 +0,0 @@
# Web-analytics на public-сайте
Sprint 20: в `food-market.public` (Astro marketing-сайт) подключены
placeholder'ы для Google Analytics 4 и Яндекс.Метрики. По умолчанию
оба не активны — в HTML рендерятся `<script data-id="REPLACE_ME">`
маркеры. Аналитика включается через env-vars при сборке Astro.
**Зачем placeholder, а не сразу скрипты:**
- Аналитика на marketing-сайте — это PII (IP-адреса посетителей),
по GDPR / 152-ФЗ / казахскому ЗоЗПД требует согласия пользователя
или специальной конфигурации (`anonymize_ip: true` + cookies notice).
- Прод-аккаунт в Google/Yandex заводится отдельно владельцем; коммитить
его в репо неправильно.
## Google Analytics 4
1. Завести **GA4 property** на https://analytics.google.com.
2. **Admin → Data Streams → Web → Add stream** → ввести URL public-сайта.
3. Скопировать **Measurement ID** вида `G-XXXXXXXXXX`.
4. В `deploy/Dockerfile.public` или в env переменной добавить:
```bash
PUBLIC_GA_ID=G-XXXXXXXXXX
```
5. Пересобрать public-image: `cd src/food-market.public && pnpm build`.
6. Открыть https://food-market.kz, проверить в DevTools → Network тегом
`gtag/js?id=G-XXX`.
## Яндекс.Метрика
1. https://metrika.yandex.com → **Создать счётчик**.
2. Скопировать **ID счётчика** (8-значное число).
3. Env: `PUBLIC_YM_ID=12345678`.
4. Аналогично — пересобрать.
## Проверка что НЕ настроено
Открыть https://food-market.kz, View Source, найти:
```html
<script data-analytics="google" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
<script data-analytics="yandex-metrika" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
```
Если эти строки есть — аналитика **не подключена**. Если вместо них
видны `gtag` или `ym(...)` скрипты — настроено.
## Что НЕ собираем
- Никаких событий на админ-сайте `admin.food-market.kz` — это закрытая
система для авторизованных пользователей, тут аналитика будет
собирать persistent activity, что нарушает privacy expectations.
Если потребуется product-analytics в админке — отдельный обсуждение
(можем self-host Plausible / PostHog).
- Никаких user-id в Metrika events — только anonymous traffic.

View file

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

View file

@ -1,134 +0,0 @@
# Системный аудит — 2026-04-27
Полный обход auth, tenant isolation, удаления сущностей, override-режима, локализации, валидации форм. Запущен после прямой жалобы юзера: «удалил себя — могу зайти», «зашёл в SuperAdmin консоль будучи tenant-юзером».
## Корневая диагностика nurnetps@gmail.com
Состояние БД на момент аудита (см. SQL-скрипты в этом отчёте):
```
users.Id = fbe4255a-c1ad-4355-88c1-ef21dfcd6db2
users.IsActive = true
users.OrganizationId = 6237ef17-b720-4076-86d0-0f543023b31a ← удалённая
users.LockoutEnd = null
roles = ['Admin'] ← глобальная Identity-роль
employees = 0 rows
organizations(id) = 0 rows ← удалена
OpenIddictTokens = 3 valid refresh + 3 valid access (TTL до 2026-05-27)
```
**Гипотеза А (`/signup` даёт SuperAdmin) — отклонена.** В `AuthSignupController.cs:79` назначается роль `Admin`, не `SuperAdmin`.
**Гипотеза Г подтверждена:** при удалении Organization из SuperAdmin консоли:
1. Связанные `users` НЕ деактивируются и сохраняют `OrganizationId` указывающий на удалённую org (orphan reference, нет FK с CASCADE).
2. OpenIddict refresh/access tokens НЕ отзываются.
3. `Employees` либо удаляются (вручную перед DELETE org), либо остаются orphan — в любом случае на `/connect/token` это не влияет.
Login повторно проходит, потому что:
- `users.IsActive=true` (поле есть, но никто не сбрасывает на DELETE org).
- Пароль валиден.
- Identity-роль `Admin` глобальная.
- На бэке нет проверки «AppUser.OrganizationId должен указывать на живую Organization».
- На фронте после login нет проверки «активный Employee в орге».
Override-баннер видит обычный tenant-юзер потому что (см. фикс #6) `SuperAdminLayout` рендерится по факту наличия любых Identity-ролей в JWT, а не строго `SuperAdmin`.
## Найденные проблемы
### #1 — DELETE Organization не каскадирует на AppUser/Employees/токены
**Категория:** security / data-integrity
**Серьёзность:** critical
**Воспроизведение:** SuperAdmin удаляет архивированную org → AppUser-ы этой org остаются `IsActive=true` с валидными refresh-tokens; могут логиниться; JWT содержит `org_id` указывающий в никуда.
**Корневая причина:** `SuperAdminOrganizationsController.Delete` (api/Controllers/SuperAdmin) делает `_db.Organizations.Remove(o)` без побочных эффектов; FK от `users.OrganizationId` к `organizations.Id` отсутствует на уровне БД.
**Фикс:** перед `Remove(org)` — `users.IsActive=false` + `Employees.IsActive=false` + revoke всех refresh-tokens юзеров через `IOpenIddictTokenManager`.
### #2 — `/connect/token` не проверяет наличие живой organization
**Категория:** security / auth
**Серьёзность:** critical
**Воспроизведение:** см. nurnetps — login проходит при удалённой org.
**Фикс:** в кастомизации token endpoint (или сразу после signin) проверять что `User.OrganizationId IS NOT NULL` и существует не-архивная Organization, иначе reject с понятным сообщением «Организация не найдена или удалена. Обратитесь к владельцу».
### #3 — `EmployeesController.Delete` — hard-delete без гардов
**Категория:** security / UX
**Серьёзность:** high
**Воспроизведение:** Admin может удалить себя или владельца org через DELETE /api/employees/{id} без сопротивления.
**Фикс:** проверки `e.UserId == currentUserId` → 403, `e.UserId == org.AccountOwnerUserId` → 403, soft-delete (`IsActive=false`) вместо `Remove`.
### #4 — Tenant guard не проверяет активный Employee
**Категория:** security / multi-tenancy
**Серьёзность:** high
**Воспроизведение:** orphan AppUser с `OrganizationId` указывающим на удалённую/несоответствующую org попадает на `/dashboard` и любые tenant-API.
**Фикс:** middleware/filter после `[Authorize]` — `EXISTS(Employee WHERE UserId=@uid AND OrganizationId=@oid AND IsActive=true)`. SuperAdmin override обходит проверку (ему так и надо). Если нет — 403 + специфический код `NoActiveEmployee`, фронт ловит и редиректит на `/no-organization`.
### #5 — Override-баннер показывается не-SuperAdmin
**Категория:** UX / security perception
**Серьёзность:** high
**Воспроизведение:** orphan AppUser с Identity-ролью `Admin` логинится → видит SuperAdmin консоль / override-баннер.
**Фикс:** `SuperAdminLayout` и `OverrideBanner` рендерятся только если в `/api/me` есть `roles` содержащая `SuperAdmin`. Все остальные — на `/dashboard` или `/no-organization`.
### #6 — Logout не отзывает refresh-tokens
**Категория:** security
**Серьёзность:** medium
**Воспроизведение:** юзер выходит, но refresh-token остаётся valid в БД 30 дней.
**Фикс:** POST `/api/auth/logout` — revoke всех refresh-tokens текущего пользователя через OpenIddict; фронт чистит localStorage; LoginPage предупреждает «Вы уже вошли как X» если есть активная сессия.
### #7 — Нет recovery для orphan AppUser
**Категория:** data-integrity
**Серьёзность:** medium
**Воспроизведение:** nurnetps@gmail.com висит в БД с указателем на удалённую org.
**Фикс:** SQL-скрипт `deploy/recovery-restore-orphan-owners.sql` (идемпотентный) — для каждого `users` с `OrganizationId` указывающим на отсутствующую/архивную org → `IsActive=false`, всем refresh-tokens поставить `Status='revoked'`.
### #8 — Эмpty-state «нет активных организаций» отсутствует
**Категория:** UX
**Серьёзность:** medium
**Воспроизведение:** AppUser без активного Employee — после login падает на `/dashboard` и видит белый экран / 403.
**Фикс:** страница `/no-organization` с CTA «Создать организацию» (ведёт на /signup) и «Попросить инвайт» (mailto на support).
## Что было сделано в предыдущих коммитах (не в этом аудите)
- Email validation + i18n native-tooltip (`feat(validation)`, коммит `ff991a7`)
- Russian-names patch — placeholder в SignupForm заменён (`fix(public)`, коммит `1f2cf2a`)
- Чистка имён конкурентов и Масса-К (несколько коммитов в Phase 6)
- Live-наполнение публичного сайта (скриншоты + Unsplash + OG, `dcc3f9d`)
## Решения, принятые без подтверждения юзера
1. **Soft-delete vs hard-delete для Employee:** soft (`IsActive=false`). История операций сохраняется.
2. **Хранение Owner-маркера:** уже есть `Organization.AccountOwnerUserId` — использую его, новой колонки `Employee.IsOwner` не нужно.
3. **Tenant guard и SuperAdmin:** SuperAdmin без override может зайти только на `/super-admin/*`; на tenant-страницы — только через override или прямой URL с tenant data. SuperAdmin override обходит guard «активный Employee».
4. **Logout revoke:** только refresh-tokens; access-tokens живут 15 минут, не парю руки.
5. **Recovery скрипт:** идемпотентный, безопасный к повторному запуску. Не рушит данные — только деактивирует orphan AppUser.
6. **Account page (transfer owner / leave org / delete account):** **не делал в этом раунде** — отдельная задача после критических auth-фиксов.
7. **Onboarding flow (sticky-баннер на шагах):** **не делал** — отдельная задача после auth-фиксов.
## Открытые вопросы (требуют решения юзера)
1. **Employee-маркер «Владелец» в UI:** показывать как бейдж рядом с ФИО на `/employees`? Сейчас Owner определяется через `org.AccountOwnerUserId == employee.UserId` — флаг `IsOwner` на Employee делать **не предлагаю**, чтобы не плодить duplicate state.
2. **Что делать если AppUser стал orphan и пытается логиниться:** мой выбор — отказывать в `/connect/token` с сообщением «Организация удалена». Альтернатива — впускать на `/no-organization` с возможностью создать новую org через wizard (как в Notion). Если нужен второй вариант — потребует UX-проектирования.
3. **Inviting flow** (юзер без org попросил доступ к чужой): не реализовано, не в скоупе аудита.
## Финальные коммиты этого аудита
- `feat(auth)`: `/connect/token` отказывает в login orphan AppUser-у (нет org / архивная org); `SuperAdmin` обходит проверку. Файлы: `AuthorizationController.cs`.
- `fix(super-admin)`: DELETE Organization деактивирует связанных AppUser, обнуляет `OrganizationId`, revoke всех refresh/access OpenIddict-токенов. Файлы: `SuperAdminOrganizationsController.cs`.
- `feat(employees)`: DELETE — soft (IsActive=false, FiredAt) + 403 для self-delete + 403 для удаления Owner (`org.AccountOwnerUserId == employee.UserId`). Файлы: `EmployeesController.cs`.
- `feat(api)`: `/api/me` возвращает `hasLiveOrg` и `hasActiveEmployee` для frontend-fallback'а.
- `feat(web)`: `/no-organization` страница + `TenantRouteGuard` редиректит туда orphan'а (не SuperAdmin без живой org / без активного Employee). Файлы: `App.tsx`, `pages/NoOrganizationPage.tsx`, `components/TenantRouteGuard.tsx`.
- `fix(web)`: `clearTokens()` чистит `superAdminAsOrg` и `superAdminEditMode`; `login()` чистит токены перед запросом; `SuperAdminAsOrgBanner` рендерится только для SuperAdmin. Файлы: `lib/auth.ts`, `lib/api.ts`, `components/SuperAdminAsOrgBanner.tsx`.
- `chore(recovery)`: `deploy/recovery-restore-orphan-owners.sql` — деактивирует orphan AppUser, revoke токены. Применён на стейдже.
### Smoke после фикса
- `nurnetps@gmail.com` → POST /connect/token → `invalid_grant` «Неверный логин или пароль».
- `admin@food-market.local` (SuperAdmin) → login проходит.
- Публичный сайт + админка отдают 200.
- В БД: `users.IsActive=false`, 9 OpenIddict tokens у nurnetps теперь `revoked`.
## Не сделано в рамках аудита (отдельные задачи)
- Серверный middleware tenant-guard (двойная проверка активного Employee на каждом запросе) — текущая защита через `/connect/token` + frontend-redirect закрывает основной вектор; middleware желателен на отдельный коммит.
- Account page (Settings → Аккаунт + смена пароля + удаление аккаунта + покинуть org).
- Transfer-owner UI с модалом передачи прав.
- Onboarding sticky-баннер на шагах.
- Убран `Employee.IsOwner` поле — используем существующий `Organization.AccountOwnerUserId`.
Эти задачи описаны в task-листе и могут быть реализованы отдельной серией коммитов после того как юзер просмотрит результаты текущего аудита.

View file

@ -1,107 +0,0 @@
# Системный аудит авторизации — 2026-05-06
Финальный пункт пакета фиксов по системе ролей. Прохожу цепочку
авторизации от логина до серверной защиты конкретных endpoint-ов
и фиксирую все findings; критичные — сразу починены, в коммитах
этого же дня.
## 1. Логин: OpenIddict /connect/token
`AuthorizationController.cs`:
- Password grant: `_userManager.FindByNameAsync` + `CheckPasswordSignInAsync` + проверка `User.IsActive`.
- После успешного password-grant — дополнительная проверка `CheckUserStillBelongsToLiveOrgAsync` (исключение для SuperAdmin): отказывает в токене, если `User.OrganizationId` указывает на удалённую/архивированную org. Это закрывает orphan-AppUser сценарий из аудита 2026-04-27.
- Refresh grant: повторно проверяет `IsActive` и `BelongsToLiveOrg`.
- Поле `org_id` пишется в JWT-claims как `HttpContextTenantContext.OrganizationClaim`.
**Status: OK.** Проверки покрывают: deactivated user, orphan org, SuperAdmin override.
## 2. JWT cookie vs Bearer
API использует только Bearer-токены через OpenIddict. Cookie-схему AspNetCore Identity подавляет `AddAuthentication` (см. `Program.cs:108-113` — все три схемы переопределены в `OpenIddictValidationAspNetCoreDefaults`). Это критично — иначе `[Authorize]` бы редиректил API-запросы на `/Account/Login`.
**Status: OK.**
## 3. X-Org-Override (impersonation)
`HttpContextTenantContext.cs`:
- `OrgOverrideHeader = "X-Org-Override"`.
- `TryGetHttpOverrideOrg`: возвращает `true` ТОЛЬКО если `User.IsInRole("SuperAdmin")` И header присутствует. Обычный юзер не может задать override (даже если подсунет header — `IsInRole` фильтрует).
- В режиме override `IsTenantOverride=true`. Tenant-фильтр в `AppDbContext.ApplyTenantFilter` строится так:
```
(IsSuperAdmin && !IsTenantOverride) || OrganizationId == _tenant.OrganizationId
```
То есть SuperAdmin без override видит всё; SuperAdmin в override — фильтр обязан применяться к выбранному `OrganizationId`. Ровно так, как нужно.
**Status: OK.** Проверка роли защищает от подделки header'а.
## 4. Tenant query filters
`AppDbContext.cs:109-153`:
- Для каждого `ITenantEntity` через reflection ставится `HasQueryFilter`.
- Для `IOptionalTenantEntity` (системные справочники с nullable `OrganizationId`) — отдельный фильтр: NULL-записи видны всем, остальные — обычная изоляция.
- Все Identity-таблицы (Users/Roles/UserRoles) — НЕ tenant-scoped (они не реализуют ITenantEntity), запросы к ним идут без фильтра. Это by design — Identity управляется через UserManager/RoleManager.
**Status: OK.**
## 5. Smoke (UI) ожидаемое поведение по ролям
Согласно `AppLayout.buildNav` (после step 7) и `RoleGuard` (новый в этом пакете):
| Юзер пытается зайти | Поведение |
|---|---|
| Cashier на `/super-admin/orgs` | TenantRouteGuard / RoleGuard **не пускает** на /super-admin (он под отдельным layout с `[Authorize(Roles = "SuperAdmin")]` на эндпойнтах). Юзер увидит «Нет доступа» из RoleGuard и/или 403 от API. |
| Storekeeper на `/settings/employees` | RoleGuard `roles=['Admin']` → «Нет доступа». |
| Cashier на `/catalog/counterparties` | RoleGuard `roles=['Admin']` → «Нет доступа». |
| Tenant-Admin на `/super-admin/...` | TenantRouteGuard для не-SuperAdmin не редиректит туда (он только tenant-роуты охраняет); сам `/super-admin` под `<SuperAdminLayout>` без guard'а, но все `[Authorize(Roles = "SuperAdmin")]` на endpoint-ах вернут 403. UI покажет 403-страницы пустые таблицы / ошибки. **Findings:** добавить RoleGuard на сам `<Route path="/super-admin">` чтобы Tenant-Admin не видел индиго-sidebar админа платформы. → Не сделано в этом пакете, описано как future. |
| 401 на любом запросе | `api.ts` interceptor: попытка refresh; если refresh упал — `clearTokens()` + редирект на `/login`. |
## 6. Reset пароля и инвалидация токенов
`SuperAdminEmployeesController.ResetPassword`:
- `_userMgr.RemovePasswordAsync` + `AddPasswordAsync(temp)`.
- `UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject = userId AND Status='valid'` — обрывает все активные сессии.
`SuperAdminEmployeesController.ToggleAccountActive` при `IsActive=false`:
- Те же `revoked` для всех valid токенов.
**Status: OK.**
## 7. Catch-22: SuperAdmin блочит свою же учётку
`SuperAdminEmployeesController` оперирует на сущности `Employee` конкретной org (`/api/super-admin/organizations/{orgId}/employees/...`). SuperAdmin платформы — это `User` БЕЗ `OrganizationId` и БЕЗ `Employee`. Через этот контроллер до его учётки не дойти.
Других endpoint-ов, через которые можно `User.IsActive=false` для произвольного user-id — НЕТ. `SuperAdminOrganizationsController.Delete` деактивирует только тех, чей `OrganizationId` совпадает с удаляемой org — SuperAdmin платформы туда не попадает (`u.OrganizationId == null`).
**Status: OK сейчас.** Future risk: если добавится `/api/super-admin/users/...` с возможностью deactivate любого user-id, нужен гард `if (currentUserId == targetUserId) → 403 «нельзя себя»`. Запишу как TODO.
## 8. Findings (зафиксированы / не зафиксированы)
### Critical (зафиксировано в этом пакете)
| # | Описание | Где | Коммит |
|---|---|---|---|
| 1 | `Manager` — лишняя системная роль, путала UI и Authorize-гарды | SystemRoles, 13 контроллеров, DevDataSeeder | `fce9be9` |
| 2 | Системная роль выкидывала alert вместо show-permissions | EmployeeRolesPage | `77de34f` |
| 3 | ИИН-формы маркированы как «ИНН/ИИН» (РФ-термин) | EmployeesPage, Counterparties, SuperAdmin* | `9a31650` |
| 4 | Salary через `<input type=number>` (не учитывал org-настройку копеек) | EmployeesPage | `9f9d273` |
| 5 | type=email не требовал TLD на patternMismatch | TextInput общий | `ed7740e` |
| 6 | Удаление сотрудника одноступенчатое, нельзя «уволить → удалить» отдельно | Employee domain + EmployeesController + UI | `049e847` |
| 7 | Sidebar показывал Cashier/Storekeeper лишние пункты | AppLayout + RoleGuard + App.tsx | `542eff2` |
### High / Medium (не зафиксировано — отдельная серия)
- **Tenant-Admin может открыть `/super-admin` URL** и увидеть пустой индиго-sidebar (API вернёт 403 на каждый запрос). Нет RoleGuard на сам Route `/super-admin/*`. Фикс: обернуть `<Route element={<RoleGuard roles={['SuperAdmin']}><SuperAdminLayout /></RoleGuard>}>` или добавить ранний return в SuperAdminLayout.
- **`Authorize(Policy = "AdminAccess")`** в `MoySkladImportController`/`AdminJobsController`/`AdminCleanupController` — policy в `Program.cs:118-119` пропускает Admin **или** SuperAdmin. SuperAdmin без override проходит — нужен ли он там? Если нет (cleanup задачи tenant-scoped), тогда либо `IsTenantOverride` обязателен, либо policy сузить до Admin. Это не critical, но архитектурно хочется единообразия.
- **Catch-22 защита для будущего `/api/super-admin/users/...`** — если такой endpoint появится, нужно `if (currentUserId == targetUserId) → 403`. Сейчас такого endpoint-а нет, риска тоже нет.
- **Identity-Manager-роль** (`AddToRoleAsync(user, "Manager")`) использовалась только в DevDataSeeder и signup; обе ветки убраны в `fce9be9`. У существующих юзеров Identity-роль `Manager` может остаться в БД (раньше signup её ставил → нет, signup ставил `Admin`, Manager только для dev-сидов). Нужно ли `RoleManager.DeleteAsync(Manager)`? Решение: оставил; роль есть в БД, но нигде не назначается и не используется в коде. Безопасно.
- **DevDataSeeder продолжает создавать Demo Market и admin@food-market.local** на каждом старте API. Для production это лишнее — Demo Market и dev-admin засоряют prod-БД. Не критично сейчас (dev-данные предсказуемы), но стоит вынести seed в `IsDevelopment()`.
- **MoneyInput не используется** в SuperAdminOrgEmployeesPage (там нет поля Salary). При добавлении Salary в SuperAdmin-form'у нужно сразу применять MoneyInput.
### Low (косметика / документация)
- `EmployeesPage.useEffect` имеет логику дефолтной роли «Менеджер ?? roles[0]» — после удаления Manager-сидера всегда упадёт на roles[0]. Не баг, но стоит переписать на `Кассир` или `Кладовщик` как дефолт.
- `EmployeeRole.cs` summary упоминает «Менеджер/Кладовщик/Закупщик/Бухгалтер» — устарело, обновить.
## Итог
Все 8 пунктов задачи закрыты или зафиксированы в этом отчёте. 7 атомарных коммитов между `fce9be9..542eff2`. Билд и API, и web проходят чисто (0 errors). Финальный отчёт по аудиту оставляю в отдельном коммите вместе с обновлённой docs-секцией.

View file

@ -1,464 +0,0 @@
# Аудит наших доменных сущностей vs. MoySklad API
Источник правды — живой MoySklad API `/api/remap/1.2/`. Проверялись ключи на реальных ответах (`?limit=2` на нашем аккаунте). Цель: каждая наша сущность должна либо повторять MoySklad, либо иметь явно оправданное отличие. Никаких «выдуманных» полей.
Условные обозначения:
- **⛔** — у нас есть поле, которого нет у MoySklad → либо оправдать комментарием, либо удалить.
- **➕** — у MoySklad есть поле, которого нет у нас → потенциально добавить.
- **⚠️** — важный нюанс (тип, семантика, обязательность).
---
## Counterparty → `entity/counterparty`
Ключи MoySklad (из ответа API, верхний уровень): `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, created, externalCode, files, group, id, meta, name, notes, owner, salesAmount, shared, state, tags, updated` + расширяемые: `legalTitle, legalAddress, inn, kpp, ogrn, ogrnip, certificateNumber, certificateDate, phone, email, actualAddress, description, discountCardNumber, priceType, sex, salesChannel`.
| Наше поле | MoySklad | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `LegalName` | `legalTitle` | rename? или доп. комментарий-алиас |
| `Kind` (CounterpartyKind) | **нет** | ⛔ уже исправили enum (`Unspecified/Supplier/Customer/Both`), но MoySklad не имеет этого поля вообще — он различает контрагентов через `tags` или через `state` (статус в пайплайне продаж/закупок). **TODO:** либо оставить Kind только как UI-фильтр (не импортировать из MoySklad), либо перейти на теги |
| `Type` (LegalEntity/Individual) | `companyType` | ⚠️ у MoySklad 3 значения: `legal`, `individual`, `entrepreneur` (ИП!). У нас ИП отсутствует — **добавить `IndividualEntrepreneur` в enum** (для РК актуально) |
| `Bin` (БИН, РК) | `inn` (12-значный БИН пишется туда) | ⚠️ MoySklad для всех рынков использует `inn` — 12 цифр это ИИН РФ, 12 цифр РК — БИН. Мы вынесли `Bin` отдельно, при импорте MoySklad кладёт в `inn`. **TODO:** документировать маппинг Bin ↔ inn |
| `Iin` (ИИН, РК) | `inn` (тот же) | ⚠️ same — MoySklad не различает |
| `TaxNumber` | `inn` | дубль |
| `CountryId` | `country` (extended, по `meta`) | ⚠️ MoySklad не на верхнем уровне — тянется при `?expand=country` |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `BankName, BankAccount, Bik` | `accounts` (массив объектов) | ⚠️ у MoySklad это **коллекция счетов** (до нескольких банков). У нас одиночные поля — **либо сделать коллекцию Accounts, либо документировать "берём первый"** |
| `ContactPerson` | `contactpersons` (sub-endpoint) | ⚠️ у MoySklad это отдельный endpoint `counterparty/{id}/contactpersons` — массив. У нас скалярное поле |
| `Notes` | `description` (или `notes` разные в разных версиях API?) | ⚠️ в ответе API было `notes` — ОК |
| `IsActive` | `archived` (inverse) | ОК |
| — | `tags` (массив) | ➕ **добавить** — удобно для классификации (в том числе заменой Kind) |
| — | `state` (ссылка на состояние в пайплайне) | ➕ отложить до Phase N (CRM) |
| — | `bonusPoints, bonusProgram, discountCardNumber` | ➕ отложить до дисконтных карт |
| — | `salesAmount` (вычисляемое) | не храним |
| — | `priceType` (персональный тип цены) | ➕ полезно для опта; добавить `Guid? DefaultPriceTypeId` |
**TODO:**
1. Enum `CounterpartyType`: добавить `IndividualEntrepreneur = 3`.
2. Коллекция `CounterpartyAccount` (BankName/BankAccount/Bik/IsDefault) — или явный комментарий «храним только основной».
3. Коллекция `CounterpartyTag` (string) — для классификации при импорте из MoySklad.
4. Поле `DefaultPriceTypeId` → `PriceType` (для опта/персональной цены).
5. Комментарий на `Bin/Iin/TaxNumber`: при импорте из MoySklad все три могут прилететь из одного поля `inn` — логика различения по длине (12 цифр РК-формат) / по companyType.
---
## Organization → `entity/organization`
Ключи MS: `accountId, accounts, archived, bonusPoints, bonusProgram, companyType, companyVat__ru, created, email, externalCode, group, id, isEgaisEnable, meta, name, owner, payerVat, shared, updated` + extended: `legalTitle, legalAddress, actualAddress, inn, kpp, ogrn, ogrnip, okpo, director, chiefAccountant, phone, fax, utmUrl`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `CountryCode` | **нет** | ⛔ у MoySklad нет — у них multi-tenant через account. У нас — multi-tenant через Organization, но CountryCode неочевиден. Оставить как есть, документировать почему (нам нужно для налоговых/локальных настроек) |
| `Bin` | `inn` | то же что и Counterparty |
| `Address` | `actualAddress` | ОК |
| `Phone` | `phone` | ОК |
| `Email` | `email` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `legalTitle, legalAddress` | ➕ для офиц. документов |
| — | `kpp, ogrn, ogrnip, okpo` | РФ-специфично, пропускаем для РК |
| — | `payerVat` (bool, плательщик НДС) | ➕ полезно — есть ли НДС у нашей организации |
| — | `director, chiefAccountant` | ➕ для подписей на накладных |
| — | `accounts` (банковские) | ➕ аналогично Counterparty |
| — | `isEgaisEnable` | РФ, пропускаем |
**TODO:**
1. `LegalName`, `LegalAddress`, `PayerVat` (bool), `DirectorName`, `ChiefAccountantName` — для накладных/счетов.
2. `CountryCode` оставить + `<see langword="…"/>` комментарий почему у нас есть, а у MS нет.
---
## Product → `entity/product`
Ключи MS: `accountId, archived, barcodes, buyPrice, code, discountProhibited, externalCode, files, group, id, images, isSerialTrackable, meta, minPrice, name, owner, pathName, paymentItemType, productFolder, salePrices, shared, supplier, trackingType, uom, updated, useParentVat, variantsCount, volume, weight` + optional: `article, country, description, effectiveVat, minPrice.currency, taxSystem, vat, tnved, syncId, modifications`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Article` | `article` | ОК |
| `Description` | `description` | ОК |
| `UnitOfMeasureId` | `uom.meta` | ОК |
| `VatRateId` | `vat` (число) + `useParentVat` | ⚠️ у MS НДС хранится как число (20, 10, 12, 0) прямо на товаре. **Мы отдельная сущность VatRate**. Обоснование: нам нужно хранить локализованные названия ("НДС 12%", "Без НДС"), is-default, и позволять разным организациям иметь разные ставки. НО — при импорте надо резолвить число в VatRate по organization_id |
| `ProductGroupId` | `productFolder.meta` | ОК |
| `DefaultSupplierId` | `supplier.meta` | ОК (у MS тоже одиночная ссылка) |
| `CountryOfOriginId` | `country.meta` | ОК |
| `IsService` | `paymentItemType` (одно из значений = "SERVICE") | ⚠️ у MS это enum с ~10 значений; у нас bool. **TODO:** либо enum, либо документировать что мы учитываем только IsService |
| `IsWeighed` | **нет** | ⛔ у MS этого нет; характеристика ритейла, нам нужно для касс с весами. **Оставить, документировать.** |
| `IsAlcohol` | `tnved` (класс товара) или через group | ⚠️ у MS через tnved-код или through type классификаторы. Наше bool — упрощение. **Оставить с комментарием.** |
| `IsMarked` | `trackingType` (enum: NOT_TRACKED, BEER_ALCOHOL, …) | ⚠️ У MS это enum из 10+ вариантов маркировки. Наш `IsMarked: bool` — потеря информации. **TODO:** заменить на enum `TrackingType` (NOT_TRACKED/TOBACCO/ALCOHOL/SHOES/MEDICINE/…) |
| `MinStock, MaxStock` | `minimumBalance` (число), `stock` (runtime) | ⚠️ у MS есть только `minimumBalance` (нижняя граница). MaxStock — наш |
| `PurchasePrice, PurchaseCurrencyId` | `buyPrice.value, buyPrice.currency.meta` | ОК (MS упаковывает в объект, мы разнесли — **одно и то же**) |
| `ImageUrl` | `images` (массив через sub-endpoint) | ⚠️ у MS images коллекция, у нас одна + отдельная ProductImage. ОК, двойная запись для UX |
| `IsActive` | `archived` inverse | ОК |
| `Prices` (collection) | `salePrices` (массив inline в MS) | ⚠️ у MS цены — **массив внутри товара**, у нас — отдельная таблица. Оба норм; просто маппинг при sync |
| `Barcodes` (collection) | `barcodes` (массив inline) | ОК |
| `Images` (collection) | `images` (sub-endpoint) | ОК |
| — | `code` | ➕ внутренний код (отличается от `article`). **Добавить `Code`** |
| — | `externalCode` | ➕ используется при импорте/ERP-интеграциях. **Добавить `ExternalCode`** (актуально для импорта из MoySklad, 1C) |
| — | `discountProhibited` | ➕ «запрет скидок» — полезно на кассе |
| — | `minPrice.value/currency` | ➕ минимальная отпускная цена. **Добавить `MinPrice` + `MinPriceCurrencyId`** |
| — | `paymentItemType` | ➕ для фискализации: «товар/услуга/работа/подарочная карта/…». **Добавить enum `PaymentItemType`** (нужно для 54-ФЗ / КZ fiscal receipts) |
| — | `tnved` | ➕ код ТН ВЭД для трансграничной торговли |
| — | `volume, weight` | ➕ для логистики (доставка) |
| — | `variantsCount` | runtime агрегат, не храним |
| — | `files` | ➕ вложения (паспорта качества, фото упаковки) — отложить |
**TODO:**
1. Добавить `Code`, `ExternalCode` на Product.
2. Заменить `IsMarked` на enum `TrackingType`.
3. Добавить `MinPrice`, `MinPriceCurrencyId`.
4. Добавить enum `PaymentItemType` + поле.
5. Поля `Volume`, `Weight`, `DiscountProhibited`.
6. Запомнить маппинг: `useParentVat` → наследовать НДС от ProductGroup (у нас сейчас не реализовано, надо подумать).
---
## ProductGroup → `entity/productfolder`
Ключи MS: `accountId, archived, externalCode, group, id, meta, name, owner, pathName, shared, updated, useParentVat` + `vat, effectiveVat, productFolder` (родитель).
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `ParentId` | `productFolder.meta` | ОК (MS использует то же имя для родителя что и для самой сущности) |
| `Path` | `pathName` | ОК |
| `SortOrder` | **нет** | ⛔ у MS нет сортировки групп. Оставить, это UX |
| `IsActive` | `archived` inverse | ОК |
| — | `externalCode` | ➕ для импорта |
| — | `vat, useParentVat` | ➕ ставка НДС по умолчанию для товаров группы |
**TODO:**
1. Добавить `ExternalCode`.
2. Добавить `VatRateId?` + `UseParentVat: bool` (для наследования).
---
## ProductBarcode → `product.barcodes[]`
У MS barcode — объект внутри product: `{type: 'ean13'|'ean8'|'code128'|'upc'|'gtin', value: '...'}`. Отдельной сущности нет.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `value` | ОК |
| `Type` | `type` | ⚠️ MS использует строки ('ean13', 'gtin', …) — мы уже enum |
| `IsPrimary` | **нет** | ⛔ у MS нет — первый считается основным. **Оставить с комментарием — у нас явная пометка.** |
OK, расхождений существенных нет.
---
## ProductPrice → `product.salePrices[]`
У MS цены — массив объектов в product: `{value, currency: {meta}, priceType: {meta}}`. Отдельной сущности нет.
Наше — отдельная таблица. Это **нормализованный вариант** — оправдано если цен много и есть выборки по PriceType. **TODO:** маппинг при импорте — проитерировать salePrices и создать ProductPrice per PriceType.
---
## PriceType → `entity/pricetype`
Ключи MS (из context): `id, name, externalCode`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `IsDefault` | **нет** | ⛔ у MS — default определяется порядком или отдельно в настройках аккаунта. **Оставить** |
| `IsRetail` | **нет** | ⛔ наш флаг «используется на кассе». **Оставить** |
| `SortOrder` | **нет** | ⛔ UX. **Оставить** |
| — | `externalCode` | ➕ для импорта |
**TODO:**
1. `ExternalCode`.
---
## Country → `entity/country`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `code` | ⚠️ у MS формат ISO3166-1 **alpha-2 или числовой** — у нас alpha-2 |
| `Name` | `name` | ОК |
| `SortOrder` | **нет** | ⛔ UX |
| — | `description` | ➕ |
| — | `externalCode` | ➕ |
OK, мелочь.
---
## Currency → `entity/currency`
Ключи MS: `archived, code, default, fullName, id, indirect, isoCode, majorUnit, meta, minorUnit, multiplicity, name, rate, rateUpdateType, system`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` | `isoCode` или `code` | ⚠️ у MS `isoCode` (строка "KZT") и `code` (цифровой "398") — у нас `Code` = строка ISO |
| `Name` | `name` | ОК |
| `Symbol` | **нет** | ⛔ у MS нет символа "₸" — но это UX. **Оставить** |
| `MinorUnit` | `minorUnit` | ОК |
| `IsActive` | `archived` inverse | ОК |
| — | `default` (валюта аккаунта) | ➕ |
| — | `rate, rateUpdateType` | ➕ курс к базовой валюте (при мульти-валютности) |
| — | `multiplicity, indirect` | конвертация; если не мульти-валютные — не надо |
| — | `fullName` | ➕ «Тенге Казахстана» vs «KZT» |
**TODO:**
1. Добавить `IsDefault: bool` (ровно одна валюта = true per tenant, или глобально).
2. `Rate, RateUpdateType` + `FullName` — отложить до мульти-валютности.
---
## VatRate — у MoySklad нет `entity/vatrate`
⚠️ У MS **ставки НДС хранятся как числовое поле на товаре** (`vat`). Отдельной таблицы нет — набор значений {0, 5, 7, 10, 12, 18, 20} и «без НДС» встроен в систему.
Наше `VatRate` — отдельная сущность. **Обоснование сохранить:**
1. Локализованное название ("НДС 12%", "Без НДС").
2. IsDefault per organization.
3. Разные организации в разных налоговых режимах (с НДС / УСН).
4. При добавлении новой ставки (например, на случай гипотетического увеличения в РК) — не править перечисление в коде.
Но: **следите**, чтобы у товара хранился `VatRateId`, а не отдельно `vat: decimal`. При импорте из MS мапим число в запись VatRate.
**Комментарий в коде нужен** — явно сказать, что мы отклонились от MoySklad сознательно.
---
## UnitOfMeasure → `entity/uom`
Ключи MS: `code, description, externalCode, id, meta, name, updated`.
| Наше | MS | Комментарий |
|---|---|---|
| `Code` (ОКЕИ) | `code` | ОК, MS использует ОКЕИ-коды (796, 166, 112) |
| `Symbol` | **нет** | ⛔ у MS только `name` ("штука"). Мы вынесли "шт" отдельно для коротких надписей на ценниках/кассовых чеках. **Оставить.** |
| `Name` | `name` | ОК |
| `DecimalPlaces` | **нет** | ⛔ у MS на уровне продукта (`variantsCount`?), а не UoM. Наш `DecimalPlaces` определяет можно ли дробные количества (0=штучный, 3=весовой). **Оставить — важно для UX касс.** |
| `IsBase` | **нет** | ⛔ наше «базовая единица организации». Мелочь, оставить |
| `IsActive` | `archived` inverse (у MS есть `archived` в uom? перепроверить) | ⚠️ в нашем ответе API archived не было — у MS uom этого поля может не быть, потому что единицы системные |
| — | `description` | ➕ |
| — | `externalCode` | ➕ |
**TODO:**
1. `ExternalCode`.
---
## Store → `entity/store`
Ключи MS: `accountId, address, archived, externalCode, group, id, meta, name, owner, pathName, shared, slots, updated, zones`.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode`? или отдельно? | ⚠️ у MS только `externalCode`. **Добавить ExternalCode или rename Code→ExternalCode** |
| `Kind` (Warehouse/RetailFloor) | **нет** | ⛔ у MS такого деления нет. Обоснование: нам нужно отличать «склад» от «торгового зала» для UI и настроек касс. **Оставить с комментарием** |
| `Address` | `address` | ОК |
| `Phone` | **нет** | ⛔ у MS нет. Оставить |
| `ManagerName` | **нет** | ⛔ у MS нет. Оставить |
| `IsMain` | **нет** (но можно проставить через default) | ⛔ Оставить |
| `IsActive` | `archived` inverse | ОК |
| — | `pathName` | ➕ (если будут иерархические склады) |
| — | `slots` (ячейки склада) | ➕ отложить |
| — | `zones` (зоны склада) | ➕ отложить |
**TODO:**
1. `ExternalCode` (или переименовать Code → ExternalCode).
---
## RetailPoint → `entity/retailstore`
У MS это **«Точка продаж» / кассовое место**. Огромное количество полей (~60) — в основном фискальные настройки.
| Наше | MS | Комментарий |
|---|---|---|
| `Name` | `name` | ОК |
| `Code` | `externalCode` | rename or add |
| `StoreId` | `store.meta` | ОК |
| `Address` | **нет** (возможно `organization.actualAddress`) | ⛔ адрес не у точки, а у организации/склада. Пересмотреть, куда класть |
| `Phone` | **нет** | ⛔ |
| `FiscalSerial` | **нет такого поля**; есть `fiscalType`, `fiscalMemoryNumber`?, `ofdEnabled` | ⚠️ у MS фискальные настройки множественные. У нас один скаляр — упрощение. **TODO:** уточнить по мере подключения ККМ |
| `FiscalRegNumber` | `ofdEnabled` + `ofdSettings` | same |
| `IsActive` | `active, archived` | MS различает active и archived — у нас только IsActive |
| — | `priceType.meta` | ➕ тип цены для этой точки — **важно** |
| — | `allowCustomPrice` | ➕ разрешить ручную цену на кассе |
| — | `allowCreateProducts` | ➕ создать товар прямо на кассе |
| — | `discountEnable, discountMaxPercent` | ➕ скидки на кассе |
| — | `cashiers` (коллекция) | ➕ кто может работать за кассой |
| — | `sellReserves` | ➕ продавать резерв |
| — | `receiptTemplate` | ➕ шаблон чека |
| — | `returnFromClosedShiftEnabled` | ➕ возврат из закрытой смены |
| — | `requiredBirthdate/Email/Phone/Fio/Sex/DiscountCardNumber` | ➕ обязательные поля при продаже |
| — | `markingSellingMode, marksCheckMode, sendMarksForCheck` | ➕ маркировка товаров |
**TODO:**
1. Обязательно: `DefaultPriceTypeId` (ссылка на `PriceType`).
2. Настройки кассы (скоп Phase 3 — касса): `AllowCustomPrice`, `AllowCreateProducts`, `SellReserves`, `DiscountMaxPercent`, `RequireCustomer...` — добавлять по мере реализации POS.
3. Коллекция `RetailPointCashier` (user_id, может ли работать).
---
## Supply → `entity/supply` + `supply/{id}/positions`
Document keys: `accountId, agent, applicable, created, externalCode, files, group, id, meta, moment, name, organization, owner, payedSum, positions, printed, published, rate, shared, store, sum, updated, vatEnabled, vatIncluded, vatSum`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ⚠️ у MS «номер документа» = `name`. У нас `Number` — семантически то же |
| `Date` | `moment` | ОК |
| `Status` (Draft/Posted) | `applicable` (bool) | ⚠️ у MS это bool «проведён или нет». У нас enum Draft/Posted → эквивалентно |
| `SupplierId` | `agent.meta` | ⚠️ у MS вместо `supplier` общее слово `agent` (контрагент) |
| `StoreId` | `store.meta` | ОК |
| `CurrencyId` | `rate.currency.meta` | ⚠️ MS упаковывает в rate объект с курсом |
| `SupplierInvoiceNumber` | **нет на верхнем уровне**; есть в `attributes` | ⛔ у MS через custom attributes. Оставить |
| `SupplierInvoiceDate` | same | same |
| `Notes` | `description` | rename или комментарий |
| `Total` | `sum` | ОК |
| `PostedAt` | `updated` (когда applicable ставится true) | ⚠️ у MS нет выделенного поля; мы отдельно фиксируем |
| `PostedByUserId` | `owner.meta` | условно |
| — | `vatEnabled` | ➕ |
| — | `vatIncluded` | ➕ НДС включён в цену |
| — | `vatSum` | ➕ суммарный НДС документа |
| — | `payedSum` | ➕ сколько оплачено |
| — | `organization.meta` | ⚠️ у MS документ привязан к организации. **У нас TenantEntity несёт OrganizationId — уже есть** |
| — | `printed, published` | ➕ распечатан/опубликован |
| — | `overhead` (доп.расходы) | ➕ доставка/таможня — **важно для фактической себестоимости** |
**Supply.Positions (SupplyLine) → supply/{id}/positions:**
Ключи MS: `accountId, assortment, discount, id, meta, overhead, price, quantity, vat, vatEnabled`.
| Наше (SupplyLine) | MS position | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ⚠️ у MS `assortment` = может быть product ИЛИ variant ИЛИ service ИЛИ bundle. Мы только продукт |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ у MS `price` — в копейках (integer `100 = 1.00`). У нас decimal. **Маппинг при импорте: делить на 100** |
| `LineTotal` | **нет** (вычисляется) | ⛔ у MS не хранится |
| `SortOrder` | **нет** | ⛔ наш UX |
| — | `discount` | ➕ строковая скидка |
| — | `vat` | ➕ ставка НДС на позицию |
| — | `vatEnabled` | ➕ |
| — | `overhead` | ➕ доля накладных (для себестоимости) |
**TODO Supply:**
1. Поля: `VatEnabled`, `VatIncluded`, `VatSum`, `PayedSum`, `Overhead`.
2. Lines: `Discount` (decimal), `VatPercent` (snapshot, уже подобное есть в RetailSaleLine), `VatEnabled`.
3. Комментарий: MS `price` в копейках — при импорте делить.
---
## RetailSale → `entity/retaildemand` + `retaildemand/{id}/positions`
Document keys: огромный список, ключевое: `agent, applicable, cashSum, noCashSum, qrSum, prepaymentCashSum, prepaymentNoCashSum, prepaymentQrSum, advancePaymentSum, fiscal, retailShift, retailStore, store, positions, rate, sum, vatEnabled, vatIncluded, vatSum, name, moment, organization, syncId`.
| Наше | MS | Комментарий |
|---|---|---|
| `Number` | `name` | ОК |
| `Date` | `moment` | ОК |
| `Status` | `applicable` | ⚠️ bool vs enum |
| `StoreId` | `store.meta` | ОК |
| `RetailPointId` | `retailStore.meta` | ОК |
| `CustomerId` | `agent.meta` | ОК (nullable если не знаем покупателя) |
| `CashierUserId` | **нет напрямую**; `retailShift` → cashier | ⚠️ |
| `CurrencyId` | `rate.currency.meta` | ОК |
| `Subtotal, DiscountTotal, Total` | `sum` (= Total) | ⚠️ MS **не хранит subtotal и discount total отдельно** — только total. Но цена в позиции уже после скидки? Нет — `positions[].discount` хранится, total = sum(price*qty - discount) |
| `Payment` (PaymentMethod enum) | **cashSum + noCashSum + qrSum** | ⚠️ MS — **не enum, а суммы по видам оплаты**. Т.е. при mixed-оплате можно часть наличными + часть картой. **Наш enum Payment + PaidCash + PaidCard — неполный.** TODO: добавить `PaidQr` + убрать enum в пользу «сколько чем заплачено» |
| `PaidCash` | `cashSum` | ⚠️ у MS в копейках |
| `PaidCard` | `noCashSum` | ⚠️ в копейках |
| `Notes` | `description` | ОК |
| `PostedAt` | — | наш |
| `PostedByUserId` | `owner.meta` | условно |
| — | `qrSum` | ➕ **добавить `PaidQr`** (QR-оплата актуальна для КZ) |
| — | `retailShift.meta` | ➕ кассовая смена (отложить) |
| — | `fiscal` | ➕ пробит ли фискально |
| — | `syncId` | ➕ идентификатор для офлайн-касс (при резинхроне) |
| — | `prepaymentCashSum/NoCashSum/QrSum, advancePaymentSum` | ➕ предоплаты |
**RetailSale.Positions (RetailSaleLine) → retaildemand/{id}/positions:**
Ключи: `accountId, assortment, discount, id, meta, price, quantity, vat, vatEnabled`.
| Наше (RetailSaleLine) | MS | Комментарий |
|---|---|---|
| `ProductId` | `assortment.meta` | ОК |
| `Quantity` | `quantity` | ОК |
| `UnitPrice` | `price` | ⚠️ копейки |
| `Discount` | `discount` | ОК |
| `LineTotal` | вычисляется | наш |
| `VatPercent` | `vat` | ОК (snapshot) |
| `SortOrder` | — | наш UX |
| — | `vatEnabled` | ➕ |
**TODO RetailSale:**
1. Добавить `PaidQr: decimal`.
2. **Убрать `PaymentMethod` enum** в пользу денормализованных `PaidCash, PaidCard, PaidQr, PaidBonus` + computed `Method` (если все кроме одного = 0 → Cash/Card/QR, иначе Mixed). Либо оставить enum + PayHint, но быть готовым к частичной оплате.
3. `VatEnabled, VatIncluded, VatSum` (сумма НДС на документ — вычисляется).
4. Комментарий: MS `price/cashSum/noCashSum` в копейках при импорте.
---
## Stock → `report/stock/bystore`
У MS **нет отдельной сущности "Stock"** — это **отчёт**. Ответ `report/stock/bystore` содержит:
```json
{ "meta": {...}, "stockByStore": [ { "name": "Склад №1", "meta": {...}, "stock": 10.0, "reserve": 2.0, "inTransit": 0.0, "quantity": 12.0 } ] }
```
Т.е. по каждому (product, store) — stock (сколько есть), reserve (резерв), inTransit (в пути), quantity = stock+inTransit.
У нас `Stock` — **материализованный агрегат** (Quantity, ReservedQuantity, computed Available). Это **технически наше решение**, не требование бизнеса.
**TODO:**
1. Комментарий в Stock.cs: объяснить, что это материализация (у MS динамический репорт).
2. **Добавить `InTransit: decimal`** — товар в пути (между складами при перемещении).
---
## StockMovement — у MoySklad такой сущности нет
⚠️ MS **не хранит journal движений** в явном виде — остатки рассчитываются в реальном времени из документов (supply, retaildemand, loss, enter, move и т.д.). Поэтому на вопрос «почему на складе минус 5» MS возвращает историю документов, а не journal.
Наше `StockMovement` — **явный immutable journal**. Обоснование:
1. Мгновенный ответ на «почему такой остаток» без пробегания по всем документам.
2. Атомарные корректировки при баг-фиксах миграций.
3. Упрощённая репликация в офлайн-кассы.
Это **сознательное отклонение** от MS — должно быть задокументировано в коде и в `docs/`. **TODO:** комментарий в StockMovement.cs + упоминание в `CLAUDE.md`.
---
## Свод по приоритетам
### Приоритет 1 — базовая совместимость импорта (на этой неделе):
- Product: `Code`, `ExternalCode`, `TrackingType` (enum) вместо `IsMarked`, `MinPrice`/`MinPriceCurrencyId`, `PaymentItemType` (enum)
- Counterparty: `CounterpartyType.IndividualEntrepreneur`, `ExternalCode`, tags (коллекция)
- ProductGroup: `ExternalCode`
- PriceType: `ExternalCode`
- Country, Currency, UnitOfMeasure, Store: `ExternalCode`
- RetailPoint: `DefaultPriceTypeId`
### Приоритет 2 — смысловые (следующая итерация):
- RetailSale: `PaidQr`, убрать enum PaymentMethod в пользу суммовых полей
- Supply: `Overhead`, `VatSum`, `VatEnabled`, `VatIncluded`
- Organization: `LegalName`, `LegalAddress`, `PayerVat`, `DirectorName`
- Product: `Volume, Weight, DiscountProhibited`
- Stock: `InTransit`
### Приоритет 3 — при необходимости:
- Counterparty: коллекция `Account`, `DefaultPriceType`
- ProductGroup: `VatRateId?` + `UseParentVat`
- RetailPoint: кассовые настройки (allowCustomPrice, discountMaxPercent, cashiers...)
- Store: slots, zones
### Сознательно не копируем MS:
- `CounterpartyKind` (Supplier/Customer/Both) — у нас enum, у MS теги. Оставляем для UX/фильтрации.
- `Store.Kind` (Warehouse vs RetailFloor) — у MS нет, нам нужно.
- `VatRate` как отдельная сущность — у MS число на товаре. У нас справочник ради локализации.
- `StockMovement` journal — у MS нет. Выбор архитектуры.
- `Product.IsWeighed` / `IsAlcohol` — упрощения под ритейл.
- `UnitOfMeasure.Symbol`, `DecimalPlaces` — UX.

View file

@ -1,109 +0,0 @@
# Бэкап и восстановление
Артефакты в репозитории (`deploy/`):
- `food-market-backup.sh` — скрипт бэкапа БД + uploads с ротацией.
- `food-market-backup.service` — systemd oneshot-юнит, запускающий скрипт.
- `food-market-backup.timer` — ежедневный таймер (03:00, с догоном пропущенных).
> Установку на prod-vm выполняет отдельный деплой-шаг (см. ниже) — здесь только
> подготовленные артефакты.
## Что бэкапится
| Что | Как | Файл |
|---|---|---|
| База данных | `pg_dump -Fc` из контейнера `food-market-prod-postgres` | `db-<TS>.dump` (custom-format) |
| Загруженные файлы (картинки товаров) | `tar czf` каталога uploads | `uploads-<TS>.tgz` |
Папка назначения по умолчанию — `/opt/food-market-data/backups`. Хранение —
30 дней (`FM_BACKUP_RETENTION_DAYS`), старые удаляются ротацией. Конфиг —
переменными `FM_*` (см. шапку `food-market-backup.sh`).
> **Контейнер прод-БД** — `food-market-prod-postgres` (с 2026-10-05, порт
> `127.0.0.1:5437`, том `food-market-prod-postgres-data`). Соседний контейнер
> `shared-postgres` (другой compose-проект, порт 5434) обслуживает чужие
> боевые базы соседних систем и food-market не принадлежит. Все `pg_dump`,
> `pg_restore`, `docker exec` в наших скриптах — только в
> `food-market-prod-postgres`. См. `docs/shared-postgres-followup-progress.md`
> и раздел «Postgres на prod-vm» в `docs/RUNBOOK.md`.
## Установка таймера на сервере (деплой-шаг)
Предполагается, что репозиторий выложен в `/opt/food-market` (иначе скорректировать
`ExecStart`/`EnvironmentFile` в `.service` и пути ниже).
```bash
sudo install -m 0755 /opt/food-market/deploy/food-market-backup.sh /opt/food-market/deploy/food-market-backup.sh
sudo cp /opt/food-market/deploy/food-market-backup.service /etc/systemd/system/
sudo cp /opt/food-market/deploy/food-market-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now food-market-backup.timer
# Проверить расписание и последний запуск
systemctl list-timers food-market-backup.timer
# Прогнать бэкап немедленно (разово)
sudo systemctl start food-market-backup.service
journalctl -u food-market-backup.service --no-pager | tail -20
```
## Ручной бэкап
```bash
sudo /opt/food-market/deploy/food-market-backup.sh
# или с переопределением каталога:
FM_BACKUP_DIR=/mnt/backups sudo -E /opt/food-market/deploy/food-market-backup.sh
```
## Восстановление БД
> ⚠️ Восстановление перезаписывает данные. Сначала остановить API, чтобы не было
> записи во время восстановления.
```bash
cd /opt/food-market/deploy
docker compose stop api web
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
# Скопировать дамп внутрь контейнера БД
docker cp "$DUMP" food-market-prod-postgres:/tmp/restore.dump
# Вариант A — восстановить в чистую БД (рекомендуется):
docker exec food-market-prod-postgres psql -U food_market -d postgres -c \
"DROP DATABASE IF EXISTS food_market WITH (FORCE); CREATE DATABASE food_market OWNER food_market;"
docker exec food-market-prod-postgres pg_restore -U food_market -d food_market --no-owner /tmp/restore.dump
# Вариант B — в существующую БД, заменив объекты (без пересоздания БД):
# docker exec food-market-prod-postgres pg_restore -U food_market -d food_market --clean --if-exists --no-owner /tmp/restore.dump
docker exec food-market-prod-postgres rm -f /tmp/restore.dump
docker compose start api web
```
После старта API применит миграции (`Migrate()` идемпотентен) и поднимется. Проверить:
```bash
curl -fsS http://localhost:8080/health/ready
```
## Восстановление uploads
```bash
TGZ=/opt/food-market-data/backups/uploads-YYYYMMDD-HHMMSS.tgz
# tar содержит каталог uploads/ — распаковать в родителя смонтированного пути
sudo tar xzf "$TGZ" -C /opt/food-market-data/
```
(Каталог `/opt/food-market-data/uploads` смонтирован в контейнер api как `/app/uploads`.)
## Проверка дампа без восстановления
```bash
docker cp <dump> food-market-prod-postgres:/tmp/v.dump
docker exec food-market-prod-postgres pg_restore --list /tmp/v.dump | head # TOC валидного архива
docker exec food-market-prod-postgres rm -f /tmp/v.dump
```
Скрипт и формат проверены локально (2026-05-27): дамп `PGDMP`, custom-format,
248 TOC-записей, `pg_restore --list` читает.

View file

@ -1,204 +0,0 @@
# Catalog import МойСклад → «Демо-магазин» (stage)
**Решение владельца.** Стенд закрыт basic-auth'ом на gateway от
интернета (внутренняя сеть 192.168.1.x — без пароля), поэтому
можно залить полный каталог включая закупочные цены.
**Токен МойСклада.** `/var/www/sklad/appsettings.Production.json`,
ключ `MoySklad:Token`, забирается через ssh + sudo на prod-vm. В
логи / коммиты / docs / отчёты токен **никогда** не попадает.
Передаётся только через env-переменную или stdin.
**Границы.** Не трогаем проект `sklad`, прод `admin.food-market.kz`,
`global.json`, `deploy/*/nginx`.
## Чек-лист
- [x] 1. Аудит текущего MoySklad-импорта + добор недостающих полей.
- [x] 2. Импорт в «Демо-магазин» на стейдже без дублей.
- [x] 3. Токен у организации стенда не сохранён.
- [x] 4. Отчёт: группы/товары + выборочная сверка 10 карточек.
- [x] 5. Playwright-проверка каталога изнутри сети + повторный запуск
импорта → 0 новых.
## Протокол выполнения
### 1. Аудит + добор
Было в `MoySkladImportService`:
- ✅ товары, группы, штрихкоды (ean13/ean8/code128/gtin/upca/upce),
цены продажи (первая «Розничная» или первая), закупочные
(`buyPrice` → `ReferencePrice`), весовой признак (`weighed`),
артикул (`article ?? code`).
- ❌ **upsert по внешнему id МойСклада** — был по article, что ломает
идемпотентность для товаров без article.
- ❌ **merge по штрихкоду с локальным дублем** — просто пропускал
с ошибкой «штрихкод занят».
- ❌ **skip архивных** — импортировал их как IsActive=false.
- ❌ **UOM-mapping** — всегда ставил «штука» (`code=796`).
- ❌ **auth-scheme** — только Bearer; sklad на prod-vm использует Basic.
- ⚠️ **фото** — у реального каталога (30 941 товар) `images.meta.size=0`
во всех выборках, фото в МойСкладе не загружены. Пропустили
как «нечего импортировать» (см. gaps ниже).
- ⚠️ **мин. остаток** — проверил MoySklad API /entity/product (keys:
`accountId, archived, barcodes, buyPrice, code, discountProhibited,
externalCode, files, group, id, images, isSerialTrackable, meta,
minPrice, name, owner, pathName, paymentItemType, productFolder,
salePrices, shared, supplier, trackingType, uom, updated,
useParentVat, variantsCount, volume, weight`): поля `minimumBalance`
или аналога у product нет. Остатки живут в report/stock, не в
карточке товара → импортировать нечего.
Добавлено (**Phase32a_MoySkladExternalId**):
- `products.MoySkladExternalId varchar(64)` + partial-unique index
`(OrgId, MoySkladExternalId) WHERE IS NOT NULL`.
- `product_groups.MoySkladExternalId varchar(64)` + аналогичный индекс.
- `MoySkladClient`: auto-detect Basic vs Bearer по форме токена;
`StreamProductsAsync(includeArchived=false)` при `SkipArchived=true`;
`GetAllUomsAsync` для построения карты MS uom.id → MS code → наш code.
- `MoySkladImportService.ImportProductsAsync(options)`:
- приоритет resolution: MS id → штрихкод → article;
- `SkipArchived=true` → архивные товары в MoySklad не попадают в
поток;
- `MergeByBarcode=true` → локальный товар с таким же штрихкодом
апдейтится, не плодится вторым;
- дедуп штрихкодов внутри входа: первый выигрывает, остальные —
в `Errors` + счётчик `SkippedDuplicateBarcodeInSource`;
- UOM: MS uom.id → MS code → наш code; weighed=true → всегда «кг».
- `MoySkladImportResult` расширен: `MergedByBarcode`,
`SkippedArchivedInSource`, `SkippedDuplicateBarcodeInSource`,
`GroupsUpdated`.
- `MoySkladImportController.ImportRequest` — `SkipArchived` и
`MergeByBarcode` (оба default true), токен всё ещё принимается в
body и НЕ сохраняется в `Organizations.MoySkladToken`.
### 2. Импорт в «Демо-магазин»
Токен МойСклада получен безопасно:
```
ssh nns@192.168.1.190 "sudo cat /var/www/sklad/appsettings.Production.json" \
| python3 -c '…MoySklad.Token…' > /tmp/ms-token (0600)
```
В аутентификации — Basic (sklad хранит base64(login:password)), что
auto-detect на нашей стороне подхватил.
Деплой: `~/deploy-stage.sh` (api + web → test.admin.food-market.kz).
Запуск:
```
POST /api/admin/moysklad/import-products
Authorization: Bearer <demo-admin JWT>
{ "Token": "***", "OverwriteExisting": true,
"SkipArchived": true, "MergeByBarcode": true }
→ { "jobId": "8bb5b13e-c675-402d-a30e-edf26b8848f7" }
```
Первый прогон — **376 секунд (6 мин 16 сек)**:
| Метрика | Значение |
|---|---|
| total (все активные в MoySklad) | 30 941 |
| created | 30 894 |
| merge-barcode (в локальный существующий) | 1 |
| skipped (без merge) | 0 |
| архивных в источнике пропущено | 0 (35 архивных отфильтрованы на уровне API-stream) |
| дубль-штрихкод внутри MoySklad | **46** (имена → `errors[]`) |
| группы created | 51 |
| группы updated | 1 (default «Продукты питания» → привязан MS id после совпадения по имени) |
### 3. Токен не сохранён
Проверка:
```
SELECT COALESCE("MoySkladToken" IS NOT NULL, false)
FROM organizations WHERE "Id" = 'b7c403fe-7fc8-48af-9490-4a9a2eafe2c2';
→ f
```
`Organizations.MoySkladToken IS NULL` для «Демо-магазин». Токен был
только в теле `POST /api/admin/moysklad/import-products`, в БД не
записан. Локальный `/tmp/ms-token` после импорта `shred -u`.
### 4. Отчёт
**По БД после первого импорта:**
```
products.count where OrgId=demo = 30 894
product_groups.count where OrgId=demo = 53 (51 из MS + 1 updated + 1 default system)
product_barcodes.count where OrgId=demo = 30 907
distinct barcode codes = 30 907
duplicate rows = 0 ✓
```
**Второй прогон импорта (идемпотентность) — 362 секунды:**
| Метрика | Значение |
|---|---|
| total | 30 941 |
| created | **0** ✓ |
| updated | 30 894 (все существующие refresh'нуты) |
| merge-barcode | 0 |
| groupsCreated | 0 |
| groupsUpdated | 52 |
Второй прогон не создал ни одной новой записи — upsert по MS id
работает как положено.
**Выборочная сверка 10 товаров** (случайный sample по БД → MoySklad
API по тому же MS id, все совпадают 1:1):
| MS id | local name | MS name | штрихкоды | розничная (local → MS) | закупочная (local → MS) |
|---|---|---|---|---|---|
| `47c17be8…` | пошехонский 350гр | пошехонский 350гр | ✓ `4820039807373` | ✓ 2540 → 2540 | ✓ 2030 → 2030 |
| `509f4a29…` | САРДИНЕЛЛА С Д/М 240ГР | САРДИНЕЛЛА С Д/М 240ГР | ✓ `4620004410894` | ✓ 640 → 640 | ✓ 512 → 512 |
| `982113cc…` | бьюти-блендер фрукты груша | бьюти-блендер фрукты груша | ✓ `6978461215094` | ✓ 470 → 470 | ✓ 300 → 300 |
| `785f457f…` | Печенье овсяное протеиновое «флэпджек» ш | Печенье овсяное протеиновое «флэпджек» ш | ✓ `4607018913849` | ✓ 455 → 455 | ✓ 350 → 350 |
| `637dcfee…` | Паста для шугаринга плотная 300гр | Паста для шугаринга плотная 300гр | ✓ `4870219750029` | ✓ 1050 → 1050 | ✓ 700 → 700 |
| `ee17df53…` | шоколад Альпен Гольд черника-йогуртовая | шоколад Альпен Гольд черника-йогуртовая | ✓ `7622201426804` | ✓ 595 → 595 | ✓ 349 → 349 |
| `2a132fa3…` | Пастила детская клубника и яблоко | Пастила детская клубника и яблоко | ✓ `4607942730734` | ✓ 225 → 225 | ✓ 150 → 150 |
| `6f8b9c68…` | Монастырский чай №26 против паразитов | Монастырский чай №26 против паразитов | ✓ `4631142248812` | ✓ 1850 → 1850 | ✓ 1400 → 1400 |
| `36f5aa58…` | Лейс стакс паприка | Лейс стакс паприка | ✓ `4690388108946` | ✓ 1565 → 1565 | ✓ 1250 → 1250 |
| `eddfb85b…` | Айран домашний 0,5л | Айран домашний 0,5л | ✓ `7915178548083` | ✓ 590 → 590 | ✓ 490 → 490 |
Соответствие полное (10/10) по имени, штрихкоду, цене продажи,
закупочной цене.
### 5. Playwright UI
`tests/e2e/scenarios/stage-ui-catalog-import.spec.ts` — 1 тест,
`chromium-desktop`, против `https://test.admin.food-market.kz`:
1. Логин как `demo-admin@food-market.local` (sentinel).
2. Открыть `/catalog/products` → в шапке «30 894 записей» ≥ 30000 ✓.
3. Поиск по частичному названию «шоколад» → ≥ 1 строка ✓.
4. Поиск по штрихкоду `4820039807373` → ≥ 1 строка ✓.
5. Клик по строке → карточка товара → простой режим: «Название»,
«Штрихкоды», «Группа», «Единица измерения», «Розничная цена» видны ✓.
6. В sidebar под простым режимом нет «Типы цен» ✓.
Прогон: `1 passed (9.6s)`. Скриншоты в `tests/e2e/reports/`:
`catalog-import-list.png`, `catalog-import-search-name.png`,
`catalog-import-search-barcode.png`, `catalog-import-product-card.png`.
## Gaps / known-not-implemented
- **Фото товаров**: сам MoySklad в этом аккаунте images не хранит
(все продукты, что проверяли, — `images.meta.size = 0`). Download
+upload в наш storage не реализовали — нет источника. Если
в будущем у клиентов будут картинки, нужно будет добавить:
`/entity/product/{id}/images` → GET `downloadHref` с токеном →
`IObjectStorage.SaveAsync` → `ProductImage`.
- **Минимальный остаток (`minStock`)**: MoySklad не передаёт на
уровне `/entity/product`. Нужно использовать `report/stock/all` или
per-store `storeassortment` — отдельная история, выпал за scope.
## Итог
- Каталог «Демо-магазин» на `https://test.admin.food-market.kz`
содержит **30 894 товара** из МойСклада, **53 группы**,
**30 907 штрихкодов** (0 дублей), все с привязанным
`MoySkladExternalId`. Токен у организации не хранится.
- Повторный запуск импорта (с тем же токеном в body) ничего не
создаёт: `created=0`, только обновляет существующие записи. 46
товаров с дубль-штрихкодом внутри самого МойСклада пропущены с
записью в `errors[]`.
- Прод, `global.json`, `deploy/*/nginx` не трогались.

Some files were not shown because too many files have changed in this diff Show more