Commit graph

257 commits

Author SHA1 Message Date
nns 1044818fbb feat(s10): year-demo seeder + 4 dashboard виджета + week-stats
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
S10-1: YearDemoSeeder — POST /api/admin/seed-demo?years=1.
- 8 групп × 25 товаров = 200, 30 контрагентов, 80 приёмок равномерно
  по году, 1500 розничных продаж с месячной сезонностью (Dec пик ×1.6,
  Jul-Aug спад ×0.7), 20 customer-returns, 8 demands, 10 losses, 3
  transfers, 5 inventories.
- Маркер артикулов Y1- (параллельно с DEMO-короткий сидер). Гард на
  существующую активность чтобы не лить хаос поверх ручной работы.
- Bulk StockMovement + переагрегация Stocks в конце транзакции —
  16.5s на dev-vm vs 60+s если бы per-document SaveChanges.

S10-2: DashboardController + 4 виджета:
- GET /api/dashboard/top-products?days&limit — top-N по gross-выручке
  (без net-вычета returns; для точного есть /api/reports/sales).
- GET /api/dashboard/low-stock?limit — Stock.Quantity ≤ Product.MinStock.
- GET /api/dashboard/recent-sales?limit — последние N посt'ed чеков.
- GET /api/dashboard/margin?days — Σ(LineTotal) - Σ(qty × Product.Cost),
  marginPercent к выручке.
- /api/sales/retail/stats расширен revenueThisWeek + transactionsThisWeek.
- Frontend: components/DashboardWidgets.tsx с 4 виджетами через
  React.lazy + Suspense. SignalR SalePosted инвалидирует все 4.
- KPI блок: today / week / month + avg-ticket (4 плитки, prev-month
  стал delta на month-плитке).

Проверено на стэйдже год-демо: top-5 за 365 дн. — «Колбаса сервелат
300г» 286440 ₸ / 32 транзакции. Margin 40% за 30 дн.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 01:03:36 +05:00
nns 43a5552772 fix(stage-tests): IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
После предыдущего фикса 5/мин per-username — per-IP 30/мин всё равно
ломал stage e2e (multi-tenant специ делают 4 signup+token подряд →
накапливается за минуту). Поднял до 60/мин token, 600/час; per-username
5/мин остаётся как анти-bruteforce.

Также: playwright.config.ts добавлен locale: 'ru-RU' — без этого
Chromium шлёт en-US, i18next отдаёт английский sidebar, а тесты ищут
русские лейблы (2.2 'Главная', 6.1 'Поставщик/Склад/Дата').

Verify-spec'и V-14 (POS Sync) и V-15 (Stock race) — починены payload'ы
под актуальную схему API (/api/catalog/stores не /api/inventory/stores,
quantity не qty, unitCost не costPrice, polnyy retail-sale body с
retailPointId/currencyId/payment/isReturn). Проверено:
- V-14: 1-й POS-батч 200 (accepted=1), 2-й replayedFromCache=true с тем
  же serverSaleId; detail GET показывает notes=pos:<csid-N> ✓
- V-15: 5 параллельных Post на остаток=3 → ровно 3 успешных (204), 2
  конфликта (409 'Недостаточно остатка'). Stock=0 после dust settles. ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 17:38:36 +05:00
nns 9d48ca6483 fix(rate-limit): per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Регрессия после ba54155: rate-limit 5/мин per-IP сваливал stage e2e
(75 тестов с одного IP, каждый по /connect/token при apiSignup → 22+
из них падали на 429 после 5-й попытки). Per-IP лимит был
неправильной осью защиты.

Новая стратегия в AuthRateLimiterExtensions:
- Per-username (только /connect/token): 5/мин, 20/час. Защищает от
  перебора пароля к конкретному account независимо от IP атакующего.
  Username вытаскивается form-body peek-middleware'ом перед UseRateLimiter
  (EnableBuffering + ручной парс x-www-form-urlencoded, тело ≤4KB).
- Per-IP (token+signup): 30/мин, 200/час. Защищает от спам-регистрации
  и от 1-IP-перебирает-тысячи-аккаунтов сценария.
- Back-compat: legacy RateLimiting:PerMinute/PerHour мапятся в IP-лимит.

Проверено через https://test.admin.food-market.kz:
- 6 неверных попыток на ОДНУ учётку → 6-я → 429 ✓
- 8 неверных попыток на РАЗНЫЕ учётки с того же IP → все 400 (IP-лимит 30/мин не достигнут) ✓

Также добавлены verify-spec'и stage-ui-verify-pos-sync (п.14) и
stage-ui-verify-stock-race (п.15).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 17:20:28 +05:00
nns ba54155225 fix(stage): rate-limit 5/min на /connect/token, nginx route /metrics+/swagger, Swagger в Production через IncludeSwagger
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Verify-Sprint баги A-D:
- A: на stage docker-compose.yml был "RateLimiting__PerMinute=200" — убран,
  теперь работают дефолты (5/мин, 20/час). 6-я попытка с тем же IP/паролем → 429.
- B: web-контейнер nginx не имел location = /metrics → запрос ловился SPA
  fallback'ом (index.html, 947 байт). Добавлен proxy_pass на api:8080.
- C: web-nginx не имел location /swagger/ → swagger.json возвращал SPA HTML.
  Добавлены /swagger/ + редирект /swagger → /swagger/.
- D: Swagger подключался только в Development. Добавлен флаг IncludeSwagger
  (env IncludeSwagger=true) — Program.cs включает UseSwagger() и в Production
  если флаг выставлен. На prod admin.food-market.kz флаг не ставим.

Проверено через https://test.admin.food-market.kz:
- 6 неверных логинов подряд: 1-5 → 400, 6-7 → 429 ✓
- /metrics → 14967 байт prometheus exposition ✓
- /swagger/v1/swagger.json → 422 КБ openapi 3.0.1 ✓
- /swagger/ → swagger-ui (redirect на /swagger/index.html) ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 01:36:42 +05:00
nns 12d833f035 fix(pwa): bump cache version + filter SignalR-race errors in PWA test
Some checks failed
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:33:02 +05:00
nns 6f9dd11b0a fix(pwa): SW не вмешивается в /hubs/* — SignalR negotiate сломался
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
SignalR через web sockets/long-poll стримит данные, его нельзя кешировать.
В пред-версии SW не интерсептировал POST, но GET-fetch для negotiate
проходил через SW pipeline и валился TypeError'ом.

Фикс: явный return на /hubs/* перед всеми стратегиями.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:28:06 +05:00
nns 76a175f491 feat(pwa+mobile+s9): PWA owner read-only + mobile tweaks + S9 stage specs
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Sprint 9 пункт 3 (mobile-адаптация):
- DataTable: min-w-max sm:min-w-[640px] → узкие таблицы (Loyalty, Promotions)
  влезают на 375px без horizontal-scroll, широкие (Products) скроллятся
  внутри overflow-auto родителя.
- Mobile-audit спека (stage-ui-s9-mobile-audit) — 20 screenshot'ов в
  reports/mobile/ (375 + 768 viewport × 10 страниц + seed-demo).
  Smoke: no console-errors, layouts читаемы.

Sprint 9 пункт 4 (P2-9 PWA):
- public/manifest.webmanifest — read-only PWA владельца. Shortcuts:
  Дашборд, Sales/Profit/Stock отчёты. display=standalone (homescreen icon).
- public/sw.js — service worker:
  • SPA navigate: network-first + offline-fallback на /offline.html.
  • GET /api/*: network-first + cache-fallback (read-only кеш).
  • CSS/JS/SVG: stale-while-revalidate.
  • Мутации (POST/PUT/DELETE): не вмешиваемся, сеть.
- public/offline.html — статический fallback с кнопкой «Открыть дашборд».
- index.html: <link rel='manifest'>, apple-touch-meta, lang=ru-KZ.
- main.tsx: navigator.serviceWorker.register('/sw.js') в production only
  (dev hot-reload не мешает).
- deploy/nginx.conf: /sw.js no-cache, /manifest.webmanifest правильный
  content-type, /offline.html static.

Stage e2e:
- stage-ui-s9-loyalty.spec (4/4 ✓): programs/cards/promotions endpoints
  + UI рендер + SALE20 на 500₸ → total=400 (валидно через API).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:22:30 +05:00
nns dc68c997c9 fix(loyalty): убрать unused imports (TS6133)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:10:44 +05:00
nns 91128a7ed0 feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
  MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
  Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
  Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
  PromotionId, PromotionCode (snapshot), PromotionDiscount.

EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
  списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
  в All() для Admin).

API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
  при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
  (POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
  input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
  • Lookup карты, проверка active/blocked/MinSubtotal.
  • Расчёт скидки или баллов в зависимости от Type.
  • Lookup промокода, проверка периода/MinSaleAmount/scope.
  • MatchingSubtotal для Scope=ProductGroups/Products считаем по
    input.Lines (sale.Lines ещё пустой в этот момент).
  • Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
    - PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
  транзакции, чтобы rollback не оставил orphan баллы).

UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
  counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
  (Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.

Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
  пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
  код→400 с понятной message и field=promotionCode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:06:10 +05:00
nns 7de159d5f2 feat(storage): IObjectStorage abstraction (Local + MinIO) — P2-15
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Backend:
- src/food-market.api/Storage/IObjectStorage.cs — абстракция
  (SaveAsync, OpenAsync, DeleteAsync, PublicUrl). Имя реализации (Kind)
  для логов.
- LocalObjectStorage — ContentRoot/uploads/{key}. По умолчанию.
- MinioObjectStorage — S3-совместимый bucket, ключ-объекта совпадает с
  тем что хранится в БД (products/{id:N}/{guid}.png). PutObject с явным
  size (для NetworkStream копируем в MemoryStream).
- StorageOptions: Type=Local|Minio, Endpoint, AccessKey, SecretKey,
  UseSsl, Bucket=food-market-uploads.
- StorageBootstrap.AddObjectStorage — DI-регистрация с runtime fallback
  на Local если MinIO-config пустой; MinioBootstrap (IHostedService)
  создаёт bucket на старте, ловит ошибку и не валит API.
- UploadsController: GET /uploads/{**path} → стримит из IObjectStorage
  (cache-control 7 дней). Нужен когда MinIO активен — для Local nginx
  раздаёт быстрее, но фолбэк работает.
- ProductImagesController отрефакторен на IObjectStorage; URL'ы в БД
  остаются /uploads/products/{id}/{guid}.ext.

Тесты:
- StorageAbstractionTests (3/3 ✓): Local default, round-trip bytes,
  PublicUrl pattern.

Stage-готовность:
- deploy/docker-compose.yml на стейдже обновлён (через scp): добавлен
  minio container, depends_on в api, env переменные Storage__*.
  Bucket автосоздаётся.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:17:10 +05:00
nns 301bf15924 feat(i18n): react-i18next ru/en + language switcher (P2-6a — базовая)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Подключён react-i18next с inline-ресурсами (без fetch'a). Языки ru/en
с полным переводом базовых ключей (common/nav/dashboard/products/
settings/demoSeed/shortcuts/toast). kz пока fallback на ru — нужен
живой переводчик (TODO).

Конфигурация:
- src/lib/i18n.ts — i18next + browser-language-detector. localStorage
  ['fm.lang'] хранит выбор пользователя.
- src/components/LanguageSwitcher.tsx — Languages-иконка + <select>.
- В AppLayout: ниже user-footer'a, рядом с кнопкой Выход.

Переведены пока что:
- AppLayout: 11 sidebar-разделов + 26 nav-ссылок + aria-label'ов.
- DashboardPage: PageHeader, KPI-карточки, mini-cards каталога,
  заголовок графика, пустое состояние.

TODO для следующих итераций (~25 страниц):
- Products/Counterparties/Enters/Losses/Transfers/Inventories/Demands/
  SupplierReturns/RetailSales/Supplies (edit + list — заголовки,
  кнопки, breadcrumbs).
- OrganizationSettings (формы — все labels).
- Shortcuts overlay, EmptyState texts, ConfirmDialog default labels.
- kz перевод требует живого переводчика РК.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:03:33 +05:00
nns 3088237ea7 feat(telegram): OwnerDailySummaryJob + bot binding (P2-14)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Backend:
- Organization.OwnerTelegramChatId (long?) — миграция Phase9a.
- TelegramOptions / TelegramBotClient (Telegram Bot API sendMessage).
  Disabled-mode когда токен пустой (Dev/CI). HTML parse_mode.
- OwnerDailySummaryJob.RunAsync — пробегает по org с привязанным chatId,
  рендерит сводку (выручка вчера, чеков, средний чек, топ-3 по выручке,
  low-stock 5 строк) и шлёт. Best-effort на каждой org.
  RenderSummaryAsync — publicный для тестов.
- HangfireJobsConfigurator: cron "0 6 * * *" UTC = 09:00 МСК.
- TelegramBindingController: GET /status (botEnabled, username, chatId,
  deepLink), PUT /bind (тестовое сообщение → проверка chatId → save),
  DELETE (unbind).

Конфиг:
- Telegram:BotToken — env Telegram__BotToken.
- Telegram:BotUsername — для deep-link.

UI:
- OrganizationSettings.TelegramSection: показывает статус (bot enabled?
  bound?), deep-link к боту, пошаговая инструкция (start → userinfobot →
  ввести chat_id → проверка). Toast на привязку/отвязку через
  meta.successMessage.

Тесты:
- TelegramOwnerSummaryTests: рендер содержит org_name, метрики, HTML. ✓ 1/1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:50:33 +05:00
nns dd2e1e7af2 feat(realtime): SignalR hub /hubs/notifications per-org + dashboard live
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
P2-7 Sprint 8 пункт 1.

Backend:
- src/food-market.api/Realtime/NotificationsHub.cs — SignalR-хаб, группы
  org:{orgId:N}. JWT через Authorization-хедер (стандартно) или через
  query ?access_token=... (для WebSocket — браузерные не могут слать
  кастомные хедеры). SuperAdmin override через ?orgOverride=<id>.
- NotificationsPublisher.cs — singleton, IHubContext-обёртка.
- Program.cs — AddSignalR + MapHub. Middleware копирует ?access_token=
  в Authorization для /hubs/* до UseAuthentication.
- RetailSalesController.Post → публикует SalePosted + LowStockPayload
  если после движения товара остаток < MinStock. Best-effort: notify
  ошибка не валит транзакцию.
- SuppliesController.Post → SupplyPosted.

Events (camelCase в JSON):
- SalePosted { saleId, number, total, storeId, cashierName, retailPointId, postedAt }
- SupplyPosted { supplyId, number, total, supplierId, supplierName, postedAt }
- LowStock { productId, productName, storeId, storeName, quantity, minStock }

Web:
- @microsoft/signalr 10.0.0 client.
- src/lib/useNotificationsHub.ts — hook с автореконнектом, accessTokenFactory.
- DashboardPage:
  • liveRevenueDelta / liveCountDelta — оптимистическое приращение
    «Выручка сегодня» сразу при SalePosted (до refetch stats);
  • toast.info на SupplyPosted; toast.error на LowStock;
  • Wifi/WifiOff индикатор в header.

Тесты:
- SignalRNotificationsTests: A постит retail-sale → A получает SalePosted,
  B (другая org) НЕ получает — multi-tenant. ✓ 1/1 локально.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:29:59 +05:00
nns f36fb146b6 fix(employees): после create — invalidate list query (не показывался сразу)
Найдено через UI-deep: после успешного создания нового сотрудника
EmployeesPage не вызывал refetch/invalidate на list-query, и список
показывал старые данные до ручного refresh страницы. Причина:
direct api.post вместо useCatalogMutations.create (нужен custom response
shape с generatedPassword для one-shot модалки).

Фикс: await qc.invalidateQueries({queryKey:[URL]}) сразу после успеха.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:06:57 +05:00
nns 87e60e7309 fix(employees): error display через humanizeError, не «Request failed»
Найдено через UI-deep: EmployeesPage в catch'е save'a доставал
err.response.data.error || err.message и показывал в модалке. На 400-ках
с ProblemDetails (errors.{field}:[msg]) error отсутствовал и попадал
generic axios «Request failed with status code 400».

Фикс: используем общий humanizeError() (тот же что в toast'е).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:59:37 +05:00
nns 3cdb819331 fix(catalog): уберём cache-touch после Delete — просто navigate
Предыдущий фикс с qc.removeQueries({queryKey:['/api/catalog/products', id]})
+ invalidateQueries(exact:true) — оказался не до конца верным:

1) removeQueries на ещё-mounted ProductEditPage с активной подпиской на этот
key триггерит refetch (TanStack заполняет пустой cache на active subscriber).

2) invalidateQueries({queryKey:['/api/catalog/products'], exact:true}) на
самом деле не матчит ни list (ключ имеет 6 элементов с пагинацией), ни item
(ключ из 2 элементов) — exact=true ищет ровно [...] из 1 элемента.

Правильно: просто navigate('/catalog/products'). React Query refetchOnMount
сам обновит list при заходе на ProductsPage (staleTime=0 default).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:45:56 +05:00
nns 61ca7fee90 fix(catalog): после Delete не refetch'аем удалённый товар
Найдено через UI-deep: после Удалить ProductEditPage делал
qc.invalidateQueries({queryKey:['/api/catalog/products']}) до navigate'a.
React Query refetch'ил конкретно ['/api/catalog/products', id] (тот что
живёт на этой же странице) → 404 → axios interceptor показывал toast
«Не найдено» поверх редиректа на список.

Фикс: сначала navigate('/catalog/products'), потом
qc.removeQueries для item-кеша + invalidate список с exact=true чтобы не
матчить вложенный item-key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:39:39 +05:00
nns cee92d86ce fix(catalog): ProductEditPage — race на currencies.data + читаемая ошибка
Найдено через UI-deep тестирование (Playwright):

Баг 1: race-condition. Если юзер быстро кликает Сохранить до того как
прогрузился справочник currencies, цена-MoneyInput добавляет строку с
currencyId='' (фолбэк `?? ''`). Сервер возвращает 400 с криптичным
JSON-validation: "$.prices[0].currencyId" не парсится.

Фикс: MoneyInput для цен disabled пока !currencies.data; вместо фолбэка
'' возвращаемся из onChange (no-op). canSave дополнительно проверяет
row.currencyId — двойная страховка.

Баг 2: при ошибке сохранения page показывал "Request failed with status
code 400" — generic axios message. Toast при этом показывал
человеко-читаемый текст через humanizeError (api interceptor).

Фикс: exporting humanizeError из @/lib/api, ProductEditPage onError
использует тот же helper. Теперь form-level error == toast-сообщение.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:27:07 +05:00
nns 1418c79b04 fix(a11y): Modal — role=dialog + aria-modal + aria-label на крестике
Найдено через UI-deep тестирование: Modal не имел ARIA-роли, screen
reader не определял его как диалог. Также добавил aria-label='Закрыть'
на X-кнопку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:18:16 +05:00
nns c2ebbcc1bd fix(web): useShortcuts — бэр-клавиши не зависят от Shift
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
'?' на US-раскладке вводится через Shift+/, поэтому при нажатии e.shiftKey=true.
Старая логика требовала wantShift === e.shiftKey и блокировала '?' (wantShift=false).
Теперь для одиночных клавиш (без '+' в spec) сравниваем только e.key — это правильно
и для '/', и для '?', и для 'n', и не ломает 'mod+s'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:47:09 +05:00
nns 76cbe78257 feat(web): keyboard shortcuts на edit + list страницах + «?» overlay
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 7 Sprint 7 — финальный пункт.

Хук: src/lib/useShortcuts.ts — поддерживает 'mod+s' (Ctrl/Cmd), Escape,
одиночные клавиши ('/', 'n', '?'). Бэр-клавиши скипают input/textarea/
contenteditable чтобы не ломать ввод. preventDefault() автоматически,
второй параметр enabled=true для конфликтов с диалогами.

Edit-страницы (9: Product + 8 doc-edit):
- mod+s = save (через canSave/canSubmit и save.isPending)
- Escape = navigate(<list-path>)
- enabled = !dialogProps.open — чтобы Esc не пересекался с ConfirmDialog
  (иначе Esc бы и закрыл диалог, и навигировал на список).

List-страницы (10: Products + 9 doc-list):
- '/' = searchRef.current?.focus()
- 'n' = navigate('/<entity>/new')
  (на CounterpartiesPage — открыть create-modal, т.к. там нет роута)
- SearchBar переведён на forwardRef для ref-проброса в input.

«?»-Overlay: src/components/ShortcutsOverlay.tsx — глобальный модал со
шпаргалкой. Открывается '?', закрывается Esc или кликом снаружи.
Смонтирован в AppLayout один раз.

tsc clean. На стейдже задеплоено.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:41:54 +05:00
nns 821bc4ed8d feat(web): Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 6 Sprint 7 — реюзабельный <Breadcrumbs items={...}> над h1 на 9 edit-pages.

Компонент: src/components/Breadcrumbs.tsx — Lucide ChevronRight как
разделитель, последний item — текущий (semibold темнее, без линка), не-
последние — кликабельные Link'и react-router (если задан to). Truncate с
title-tooltip для длинных названий.

Применено:
- ProductEditPage: Каталог / Товары / <name|Новый товар>
- SupplyEditPage: Закупки / Приёмки / <number|Новая приёмка>
- EnterEditPage / LossEditPage / TransferEditPage / InventoryEditPage:
  Остатки / <тип> / <number>
- SupplierReturnEditPage: Закупки / Возвраты поставщикам / <number>
- DemandEditPage: Продажи / Оптовые отгрузки / <number>
- RetailSaleEditPage: Продажи / Чеки / <number>

Side-effect: на doc-edit pages убрана дублирующая subtitle «Черновик —
товар не списан, пока не проведёшь» (breadcrumbs дают контекст). Зелёная
плашка «Проведён <дата>» сохранилась — она несёт реальную инфу.

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:25:32 +05:00
nns 8d532927e2 feat(web): Empty states с CTA на list-страницах
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 5 Sprint 7 — заменил сухое «Нет данных» в DataTable на дружелюбный
центрированный блок: иконка + заголовок + объяснение + CTA «Создать первый
…». Показывается только когда нет поиска/фильтров (truly fresh org), иначе
обычный fallback DataTable.empty.

Компонент: src/components/EmptyState.tsx — Lucide-иконка, optional
actionLabel + onAction, optional secondaryLabel + onSecondary.

Применено (14 страниц):
- Catalog: Products (Package → /catalog/products/new), Counterparties
  (Users → открыть create-modal через setForm)
- Inventory: Enters (PackagePlus), Losses (Trash2),
  Transfers (ArrowLeftRight), Inventories (ClipboardList)
- Purchases: Supplies (PackagePlus), SupplierReturns (Undo2)
- Sales: Demands (Truck), RetailSales (Receipt)
- Reports: Sales (BarChart3), Stock (Warehouse), Profit (TrendingUp),
  Abc (BarChart3) — без CTA, текст «отчёт построится когда…»

Тексты — конкретные («Списания фиксируют выбытие — просрочка, брак»),
не «нажмите чтобы создать».

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:16:11 +05:00
nns faa13521e8 feat(web): loading skeletons вместо «Загрузка…» в DataTable + edit-pages
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 4 Sprint 7 — shimmer-плейсхолдеры вместо текстовых лоадеров.

Компоненты (src/components/Skeleton.tsx):
- <Skeleton variant='line'|'block'|'circle' /> — базовый pulse-блок.
- <TableSkeleton rows cols /> — 8 строк × N колонок с псевдослучайной
  шириной плейсхолдеров, чтобы превью таблицы выглядело естественно.
- <FormSkeleton /> — заголовок + 2 секции по 6 полей.

DataTable: при isLoading=true теперь рендерит TableSkeleton (а не
«Загрузка…»). На list-страницах layout остаётся стабильным.

Edit-pages: добавил guard
  if (!isNew && existing.isLoading) return <FormSkeleton />
на 9 doc-edit pages (ProductEdit, DemandEdit, EnterEdit, InventoryEdit,
LossEdit, SupplierReturnEdit, TransferEdit, SupplyEdit, RetailSaleEdit) +
OrganizationSettingsPage. До этого они показывали пустые поля formы или
«Загрузка…».

DashboardPage: график выручки во время загрузки теперь Skeleton block
72rem высоты (вместо текста «Загрузка…»).

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:03:08 +05:00
nns 27ce8dddfc feat(web): toast-система — error на 4xx/5xx + success на мутации (через meta)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 3 Sprint 7 — заменил молчаливый rej в src/lib/api.ts на toast.error
для всех 4xx/5xx (кроме 401, где идёт auto-refresh). Тосты успеха —
для мутаций через meta.successMessage, чтобы избежать спама на queries.

Компоненты:
- src/lib/toast.ts — мин singleton API (toast.success/error/info), без deps.
  Дедуп подряд идущих одинаковых сообщений. Autoclose через setTimeout.
- src/components/Toaster.tsx — фиксированный top-right контейнер. На мобиле
  растягивается до экрана с margin. Кнопка X для ручного закрытия.
- src/lib/api.ts — interceptor 4xx/5xx: humanizeError() читает
  ProblemDetails (errors.X[0] → detail → message → title); title по статусу
  («Нет доступа» / «Не найдено» / «Конфликт» / «Проверьте поля» / «Слишком
  много запросов» / «Ошибка сервера»). Опт-аут через config.__silent=true.

Глобальный mutation onSuccess (App.tsx) подтягивает meta.successMessage и
показывает toast. meta.successMessage=false → опт-аут.

Применено (через meta):
- useCatalogMutations: create=«Создано», update=«Сохранено», remove=«Удалено»
  (автоматически для всех list-pages: Counterparties, Stores, Countries,
  PriceTypes, ProductGroups, RetailPoints, EmployeeRoles, ...)
- Doc-edit pages (Demand/Enter/Inventory/Loss/SupplierReturn/Transfer/Supply/
  RetailSale): save=«Сохранено», post=«Проведено», unpost=«Снято с
  проведения», remove=«Удалено».
- ProductEditPage: save=«Сохранено», remove=«Удалено».
- OrganizationSettingsPage save: «Настройки сохранены».

tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:54:14 +05:00
nns 17a6da2f8b feat(web): ConfirmDialog компонент + useConfirm hook вместо window.confirm()
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Item 2 Sprint 7 — заменил все нативные confirm() в фронте на собственный
<ConfirmDialog> с понятной типографикой, Esc=cancel, focus-on-Cancel
(чтобы случайный Enter не подтверждал удаление), tone='danger' | 'warning'.

Компоненты:
- src/components/ConfirmDialog.tsx — UI поверх Modal-overlay, AlertTriangle
  иконка, primary/danger кнопки. Текст description конкретный («Удалить
  товар «Молоко 3.2%»? Действие необратимо»). aria-labelledby выставлен.
- src/lib/useConfirm.ts — хук-обёртка: const { confirm, dialogProps } =
  useConfirm(); if (await confirm({...})) action(). Возвращает Promise<bool>.

Button: переведён на forwardRef, чтобы dialog мог поставить фокус на Cancel.

Применено (17 страниц + 1 компонент):
- ProductEditPage (delete product)
- DemandEditPage / EnterEditPage / InventoryEditPage / LossEditPage /
  SupplierReturnEditPage / TransferEditPage / SupplyEditPage / RetailSaleEditPage:
  delete draft + post + unpost (всего 3 диалога на форму)
- EmployeesPage: уволить (warning) / удалить навсегда (danger), сохранена
  динамика по статусу
- CounterpartiesPage / StoresPage / ProductGroupsPage / RetailPointsPage /
  CountriesPage / PriceTypesPage / EmployeeRolesPage / SuperAdminUnitsOfMeasurePage:
  delete с именем сущности в description
- ProductImageGallery: delete image

tsc --noEmit: clean. Текстов: в описаниях есть имена сущностей (товар,
номер документа), чтобы было ясно что именно удаляется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:38:31 +05:00
nns ad09b56013 feat(stage): demo-data seeder для test.admin.food-market.kz
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
Item 1 Sprint 7 — кнопка «Заполнить демо-данными» в OrganizationSettingsPage.

Что заполняет (за одну транзакцию, ~3с на стейдже):
- 5 групп товаров (Молочные / Хлеб / Напитки / Бакалея / Снеки)
- 50 товаров с барштрихкодами EAN-13 + retail-ценой (article DEMO-NN-MM)
- 10 контрагентов (5 поставщиков + 5 покупателей-юрлиц с BIN)
- Второй склад «Резерв» (если нет) для transfer'a
- 5 приёмок (Posted) за последние 30 дней с moving-average cost
- 30 розничных продаж (Posted) за последний месяц, Cash/Card случайно
- 1 опт-отгрузка (Demand, Posted) с 15% скидкой
- 1 списание (Loss, Posted, причина Expired)
- 1 перемещение (Transfer, Posted) между складами
- 1 инвентаризация (Posted) с небольшим diff +/- 1

Идемпотентность: маркер — наличие Product с Article startsWith "DEMO-".
Повторный POST → возвращает summary без вставок.

API:
- GET /api/admin/seed-demo/status — счётчики (Admin policy)
- POST /api/admin/seed-demo — запустить (Admin policy)

UI: OrganizationSettingsPage.tsx, секция «Демо-данные» с Sparkles-иконкой,
counts grid и кнопкой (disabled когда уже заполнено).

Тесты: tests/e2e/scenarios/stage-demo-seed (5/5 ✓ локально).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:17:49 +05:00
nns 466595b4d5 fix(swagger): operationId + schemaId — генерация OpenAPI работает
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
В Development swagger.json валился двумя ошибками:

1. CustomOperationIds dereferencing api.ActionDescriptor.RouteValues['action']
   для минимальных API (/health, /metrics, /connect/*) кидало
   KeyNotFoundException. Делаем TryGetValue + fallback на RelativePath.

2. CustomSchemaIds с FullName! падал NRE на типах без FullName
   (generic-параметры). Fallback на t.Name через ??.

После фикса: /swagger/v1/swagger.json 200, 117 paths, все 19 новых
модулей (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/
AuditLog/2FA/POS/Signup) присутствуют, schemaId без дубликатов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:51:23 +05:00
nns 6a5bb52b13 test(stage): пункт 11 — OrgAuditLog 7/7 ✓ + UTC fix
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
CRUD продукта генерирует записи create/update/delete с diff'ом
полей; фильтры по entityType/entityId/action работают; multi-tenant
строго (org B не видит логи org A).

Bonus fix: тот же DateTime Kind=Unspecified→UTC что в reports,
применён к from/to в /api/admin/audit-log.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:39:54 +05:00
nns 97d5ae5eb0 fix(reports): 3 фикса по итогам stage-тестирования
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
1. **DateTime Kind=Unspecified → UTC** в ResolveRange / AsUtc.
   ASP.NET парсит 'from=2026-05-29' с Kind=Unspecified, Npgsql 8
   отказывается слать такие в timestamp with time zone (500).
   Принудительно конвертим Unspecified→UTC (трактуем как полночь
   UTC), Local→ToUniversalTime. Применено к Sales/Profit/ABC/Stock.

2. **Enter.Post теперь пересчитывает Product.Cost** по той же
   формуле скользящего среднего что Supply.Post. Без этого товары,
   попавшие в систему через Оприходование (а не через Supply),
   имели Cost=0 — Profit/ABC-отчёты показывали cost=0 и неверную
   маржу. Воспроизведение: Enter 100@30 + RetailSale 10@500 →
   Profit-отчёт показывал revenue=5000, cost=0 (должно cost=300).

3. **ABC report: Парето-граница по cumBefore (а не cumAfter).**
   Единственный товар с cumShare=100% валился в класс C, хотя
   полностью покрывает Парето — должен быть A. Чиним: товар
   принадлежит классу A если он нужен чтобы пересечь порог
   80% (cumBefore < 80%). Стандартный Парето-алгоритм.

stage-reports (8 шагов): Sales/Stock/Profit/ABC + CSV/XLSX
export + edge — все зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:35:31 +05:00
nns 4e15359378 fix(docs): EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Тот же баг что в TD-6 чинили на Supplies/Demands/RetailSales и в pt 2
на Products: добавление/замена line'ов через nav-collection даёт
DbUpdateConcurrencyException «0 rows affected» при следующем UPDATE
родителя. На документах без xmin это становится 500, на InventoryDoc
(с xmin от TD-6) — 409.

Переводим Enters/Losses/Transfers/SupplierReturns.Update на
ExecuteDelete + DbSet.Add (как Supplies). InventoriesController
дополнительно: добавление новых строк через _db.InventoryLines.Add
вместо doc.Lines.Add (RemoveRange/Clear там не было — merge-in-place
по ProductId).

Воспроизведение (на Enters):
1. POST /api/inventory/enters {lines:[A]}
2. PUT … {lines:[A,B]} (одна оставлена, одна новая) → было 500
   DbUpdateConcurrencyException ; стало 204.

stage-enter (10 шагов): CRUD + Post + Unpost + edge + multi-tenant +
concurrent PUT — все зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:57:48 +05:00
nns d54e1cb968 fix(catalog): EF8 nav-collection bug в Products.Update + unique IX на Article
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
1. Products.Update: добавление нового barcode'а к существующему товару
   валилось с DbUpdateConcurrencyException 'Товар изменён в другом окне',
   хотя никакой конкурентной правки не было. Тот же EF8-баг, который в
   TD-6 чинили на Supplies/Demands/RetailSales: nav-collection.Add +
   client-side Id путает EF, UPDATE родителя получает 0 affected. Чиним
   тем же паттерном: ExecuteDelete старых ProductBarcodes/ProductPrices,
   DbSet.Add новых. Воспроизводится: создать товар с 1 barcode, PUT с
   2 barcodes → 409. После фикса → 204.

2. IX_products_OrganizationId_Article был обычным (не уникальным), хотя
   контроллер ловил нарушение по имени индекса и возвращал 'Артикул уже
   занят'. Catch-блок никогда не срабатывал. Делаем индекс уникальным
   миграцией Phase8d. Перед созданием — нумеруем дубликаты по существующим
   данным (если есть). NULL/пустые article остаются distinct (Postgres
   NULL semantics).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:46:10 +05:00
nns 7b7a7091b9 feat(auth): TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4)
ASP.NET Identity AuthenticatorTokenProvider (RFC 6238 — Google
Authenticator, Authy, 1Password OTP). TwoFactorEnabled и SecurityStamp
уже были в users-таблице из Identity-схемы.

Endpoints (Bearer-auth):
- GET /api/me/2fa/status — { enabled: bool }.
- POST /api/me/2fa/enroll — генерирует SecretKey (если ещё нет),
  возвращает otpauth-URI для QR + сам shared-key. Пока 2FA включён,
  enroll возвращает alreadyEnabled=true без секрета.
- POST /api/me/2fa/verify { code } — валидирует и включает 2FA.
- POST /api/me/2fa/disable { code } — выключает + ResetAuthenticatorKeyAsync.
  Требует текущий code как защиту от случайного отключения.

AuthorizationController.Exchange (password grant): после успеха проверки
пароля смотрит TwoFactorEnabledAsync; если true и нет otp_code в
запросе — возвращает invalid_grant с error_description="2fa_required";
если otp_code невалиден — "2fa_invalid"; иначе токен выдаётся.

Опционально для всех ролей — User самостоятельно решает включать или нет.
Для админов рекомендуется (отдельная политика — следующий шаг).

Тесты: 4 интеграционных (enroll+verify+status, неверный code → 400,
token-endpoint require otp_code, disable с code). Тесты сами генерируют
TOTP через ручную RFC 6238 имплементацию (HMAC-SHA1, 30-сек step).

Bonus: добавлены DI-заглушки UnusedSupplyWriter / UnusedRetailSalePoster
для CQRS-handler'ов из TD-1 — handler'ы пока не подключены к
контроллерам, заглушки нужны чтобы DI-validation на старте не падала.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:57:32 +05:00
nns ef8c4a3222 feat(cqrs): MediatR partial — 3 handler-образца (TD-1)
Подключён MediatR в food-market.api с авторегистрацией из сборки
food-market.application. Цель — показать паттерн, не полный
рефакторинг контроллеров (это отдельный спринт).

Образцы handler'ов в food-market.application:
- Purchases/Commands/CreateSupplyCommand + CreateSupplyHandler — создание
  Draft-приёмки с делегированием персистентности через ISupplyWriter
  абстракцию (testable без EF).
- Sales/Commands/PostRetailSaleCommand + PostRetailSaleHandler —
  проведение чека с валидацией платежа (переиспользует RetailPaymentValidator
  из Sprint 1) и делегированием stock-операции через IRetailSalePoster.
- Sales/Queries/GetSalesReportQuery + GetSalesReportHandler — агрегация
  плоских sale-строк по period:day/period:month/product. Pure-функция,
  безопасно тестируется в памяти.

Контроллеры пока используют прежние flow (контроллер → EF напрямую) —
поэтапная миграция, не big-bang. Эти handler'ы — образец, на который
оглядываемся при следующих feature'ах.

Тесты: 6 unit (2 GetSalesReportHandler, 1 CreateSupplyHandler,
3 PostRetailSaleHandler). 57 unit total зелёных.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:51:08 +05:00
nns 443eebe862 feat(logging): структурные log-fields в Serilog (TD-4)
LogEnrichmentMiddleware: после Authentication+Authorization вытягивает из
ClaimsPrincipal OrgId (claim org_id) и UserId (sub/NameIdentifier), плюс
CorrelationId из заголовка X-Correlation-ID (или генерирует Guid). Все три
кладутся в Serilog LogContext через PushProperty — каждая ILogger.Log*
внутри пайплайна автоматически получает эти поля как структурные
properties (не текст), пригодные для фильтрации в Loki/ELK без regex.

Эхо CorrelationId в response-header — клиент видит id для support.

Business-логи (структурные плейсхолдеры, не string interpolation):
- Supply.Post → "Supply posted: {SupplyNumber} supplier={SupplierId}
  store={StoreId} lines={LinesCount} total={Total}".
- RetailSale.Post → "RetailSale posted: {SaleNumber} store={StoreId}
  payment={Payment} lines={LinesCount} total={Total}".

docs/logging.md — паттерн, anti-pattern'ы (string interpolation, PII в
логах, токены/пароли), correlation-id workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:46:17 +05:00
nns eacf7e5cc8 feat(validation): FluentValidation + ValidationFilter для DTO (TD-2)
Подключён FluentValidation (уже был в Directory.Packages.props, теперь
активно используется):
- AddValidatorsFromAssemblyContaining<Program>() — авторегистрация всех
  IValidator<T> из сборки food-market.api.
- ValidationFilter (IAsyncActionFilter) глобально подключён через
  MvcOptions: на каждый action ищет IValidator<TArg> по рантайм-типу
  body-параметра, гоняет, fail → 400 ValidationProblemDetails (RFC 7807).

Не используем FluentValidation.AspNetCore — официально deprecated
(см. docs.fluentvalidation.net/aspnet); current recommendation —
DI-extensions + manual filter, как у нас.

Валидаторы (для 5 DTO):
- SupplyInputValidator — Supplier/Store/Currency ≠ Empty, Date ≤ tomorrow,
  Lines non-empty, line.Quantity > 0, line.UnitPrice ≥ 0.
- RetailSaleInputValidator — Store/Currency ≠ Empty, Date ≤ tomorrow,
  PaidCash/PaidCard ≥ 0, Lines non-empty с per-line проверками.
- ProductInputValidator — Name required, Vat∈[0,100], MinStock ≤ MaxStock.
- CounterpartyInputValidator — Name required, BIN/ИИН regex \d{12},
  Email формат (EmailAddress).
- EmployeeInputValidator — LastName/FirstName required, RoleId ≠ Empty,
  SendInvite → требует CreateAccount + Email, CreateAccount → требует Email.

Сообщения по-русски (фронт ждёт RU).

Тесты: 16 юнит-тестов на валидаторы (5 на SupplyInput, 2 на RetailSaleInput,
4 на ProductInput, 2 на CounterpartyInput, 3 на EmployeeInput). Полный
прогон unit-тестов зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:40:46 +05:00
nns ec0cff7fc4 feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.

Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
  Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
  property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
  не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
  EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.

Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
  shadow xmin загруженного supply через EF.Entry().Property("xmin").
  Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
  legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
  защита: и явная сверка, и EF auto-check в SaveChanges).

Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).

Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
  с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:33:01 +05:00
nns b963adfa2e feat(import-jobs): persisted ImportJobRegistry в БД (TD-5)
Раньше прогресс фоновых импортов жил в ConcurrentDictionary внутри
Singleton-сервиса: рестарт процесса терял всю историю, активные
джобы навсегда оставались в статусе Running.

Теперь:
- Domain.Integrations.ImportJob (TenantEntity) — таблица import_jobs,
  миграция Phase8c_ImportJobs (jsonb для ErrorsJson, индексы по
  OrgId+StartedAt / OrgId+Status / FinishedAt).
- ImportJobRegistry рефакторен: Create() пишет строку немедленно,
  SaveAsync() обновляет, Get/RecentlyFinished читают из БД. API
  совместимое со старой in-memory версией — MoySkladImportService
  и контроллеры не меняются.
- MoySkladImportController.RunInBackgroundAsync теперь:
  * Periodic flush через Timer каждые 2 секунды — UI видит
    реальный progress (Stage/Created/Total), а не Create-snapshot;
  * Финальный flush в finally — обязательный для terminal state.
- AdminCleanupController.WipeAllAsync — то же финальное сохранение.
- SkipAudit=true для import-job записей — служебные, в OrgAuditLog
  не пишем.

Tenant-isolation: query-filter работает прозрачно, B не видит джоб A.

Тесты: 3 интеграционных (survives across scope, RecentlyFinished
читает из БД, tenant-isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:45:08 +05:00
nns 25d92c989a feat(email): HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22)
Application:
- IEmailSender.SendHtmlAsync(html, textFallback) - multipart/alternative
  с plain-text для клиентов без HTML;
- EmailTemplateRenderer - минимальный mustache-light:
  {{key}} (HTML-escape), {{{raw}}} (без escape), {{#key}}...{{/key}}
  (условный блок, truthy если не-null/не-пусто/не-"0"/не-"false");
- EmailTemplates - загрузчик embedded HTML-шаблонов из
  Resources/EmailTemplates/*.html, кеш after-first-read, parse
  "Subject: <тема>" из первой строки, плюс plain-text strip для fallback.

Шаблоны (embedded в food-market.api.dll):
- invite.html - приглашение сотрудника с временным паролем и кнопкой
  «Открыть Food Market».
- weekly-summary.html - выручка/чеки/средний чек за неделю + топ-товары.
- low-stock.html - таблица товаров с stock < MinStock.

EmployeesController.Create принимает SendInvite (требует CreateAccount):
формирует payload из orgName/loginUrl/role и шлёт через SendHtmlAsync.
SMTP-ошибки логируются warning'ом, не блокируют создание (showOnce
tempPassword фронту всё равно отдаётся).

Hangfire recurring jobs (EmailNotificationJobs):
- weekly-summary: cron "0 7 * * 1" (понедельник 07:00 UTC) - по каждой
  активной орге считает revenue/tx/avgTicket/top-5, шлёт Admin'ам;
- low-stock-alert: cron "0 8 * * *" - товары с sum(stock)<MinStock,
  шлёт Admin'ам. AsyncLocal tenant override на каждую орг чтобы
  query-filter работал корректно.

Тесты: 8 unit на EmailTemplateRenderer + EmailTemplates (escape, raw,
условные блоки, invite/low-stock-шаблон-loaders). Все 35 unit
зелёные (27 + 8 новых).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:37:32 +05:00
nns a189a5dd6e feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)
Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).

Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.

ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.

Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.

Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.

Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).

Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:26:36 +05:00
nns 47a019dc6d feat(demands): оптовая отгрузка контрагенту-юрлицу (P1-5)
Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.

EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.

ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).

Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.

Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.

Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:18:49 +05:00
nns 824ef8279c feat(observability): Prometheus метрики /metrics + бизнес-счётчики (P1-17)
prometheus-net.AspNetCore@8.2.1 + EF Core DbCommandInterceptor.

Endpoint: GET /metrics (text exposition, без auth — типичная практика;
на prod закроем nginx allow private-network).

Стандартные метрики (через UseHttpMetrics):
- http_requests_received_total (code/method/controller/action)
- http_request_duration_seconds (histogram, p50/p95/p99 SLO)
- process_cpu_seconds_total / dotnet_total_memory_bytes / GC counters

Кастомные бизнес-метрики (AppMetrics):
- food_market_documents_posted_total{type} — все типы документов
- food_market_sales_posted_total — alias по retail-sale (явно в SLO)
- food_market_supplies_posted_total — alias по supply
- food_market_documents_error_total{type, reason} — ошибки проведения
  с разбивкой по причине (serialization=40001, insufficient_stock,
  number_conflict, validation, other)
- food_market_db_query_duration_seconds{kind} — гистограмма SQL через
  DbMetricsInterceptor (kind=query для SELECT, command для CUD)

Tenant-меток в кастомных метриках НЕТ сознательно: на multi-tenant хосте
раздуло бы cardinality. Per-org разрез — через /api/reports/*.

Counters добавлены в:
- SuppliesController.Post (success + serialization-error)
- RetailSalesController.Post (success)
- PosController.CreateAndPostSaleAsync (success + number_conflict)

docs/observability.md — scrape-конфиг prometheus.yml, образец Grafana
dashboard (4 ряда: Health/Business/Database/Runtime), prometheus rules
с alert'ами (HighErrorRate, DbSerializationContention, NoSalesIn30Min).

Тесты: 3 интеграционных (endpoint доступен и возвращает text/plain с
встроенными метриками; sales counter инкрементится после Post; db_query
гистограмма накапливается).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:20:01 +05:00
nns 640c8d9c22 feat(pos-api): GET /sync и POST /sales с двойной идемпотентностью (P1-12b)
Endpoints:
- GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений
  (Products / Prices / Stocks / Counterparties) после reference time;
  Stocks - всегда полный снимок на момент ответа (POS нужен актуальный
  остаток на полке).
- POST /api/pos/v1/sales - батч продаж с idempotency.

Двойная идемпотентность:
1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) -
   повтор того же батча возвращает кешированный ответ. При параллельном
   race ловим 23505 на уникальном индексе и тоже возвращаем кеш.
2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix
   "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё
   не встречался - если есть, возвращаем существующую продажу. Это
   спасает и при разных batch-ключах с пересекающимися ClientSaleId.

Pre-flight: проверка остатка ДО создания черновика - sale, которая не
влезает в полку, попадает в Failed, остальные в батче проводятся.

Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks
(jsonb для ResponseJson, unique idx).

Контракты v1 из food-market.shared.

Тесты: 7 интеграционных - полная sync, дельта по since, POST батч
списывает stock, replay того же батча no-duplicates, ClientSaleId через
разные batch-keys тоже no-duplicates, недостача попадает в Failed,
tenant-изоляция.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:10:17 +05:00
nns e022db30aa feat(pos-shared): контракты POS v1 в food-market.shared (P1-12a)
Доступные DTO для оффлайн-кассы (food-market.pos):
• ProductSyncDto/PriceSyncDto/StockSyncDto/CounterpartySyncDto — выгрузка
  изменений для последующей пробивки;
• PosSyncResponse — конверт всего sync-ответа с ServerTime (reference
  time против клок-дрейфа кассы) и DeletedProductIds;
• PosSaleDto/PosSaleLineDto/PosSaleBatchDto — батч продаж от кассы.
  PosSaleBatchDto несёт IdempotencyKey + каждая продажа имеет ClientSaleId
  (двойная идемпотентность);
• PosSaleBatchResponse — Accepted/Failed + ReplayedFromCache флаг.

Версионирование на уровне namespace  — для v2 будет рядом без
breaking changes. Required-поля везде where applicable: компилятор обяжет
заполнить новые обязательные поля при появлении v1.X добавок.

Тесты: 3 unit на сериализационный round-trip (компиляция падёт при удалении
любого поля контракта — это и есть тест public API).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:03:04 +05:00
nns dbd08f6fd2 feat(openapi): улучшенный Swagger + TS-клиент через openapi-typescript (P1-19)
API:
• SwaggerGen с OpenAPI info (title/version/description),
  Bearer security-scheme (через OpenIddict JWT),
  стабильные operationId = Controller_VerbAction (HTTP-verb включён
  чтобы избежать коллизии когда ASP.NET стрипает Async-суффикс —
  WipeAll и WipeAllAsync ранее давали одинаковый operationId);
• CustomSchemaIds с префиксом из namespace (одноимённые nested
  record'ы в разных контроллерах больше не схлопываются — StockRow
  есть в Inventory_StockController и Reports_StockReportController).

UI:
• /swagger (UI) и /swagger/v1/swagger.json (документ) — только в Development.
  На prod не раскрываем (endpoint enumeration).

Web:
• Добавлен devDependency openapi-typescript@^7.5.2 + npm-script gen:api,
  читающий http://localhost:5081/swagger/v1/swagger.json.
• src/lib/api.generated.ts — сгенерированные типы (~7700 строк, все
  схемы и operations).
• src/lib/apiClient.ts — тонкая обёртка над axios api, использующая
  типы из generated. Подключена для пары контроллеров (Reports/Sales,
  Reports/ABC, Reports/Profit) как образец постепенной миграции.

docs/openapi.md — workflow генерации (live API или Swashbuckle CLI),
versioning, наставления для нового кода.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:39:22 +05:00
nns dcf8f60b67 feat(reports): ABC-анализ по Парето (P1-11)
GET /api/reports/abc — топ-товары по выбранной метрике с распределением
A/B/C по Парето (A=80%, B=15%, C=5% накопительной метрики).

Параметр metric:
• revenue (по умолчанию) — выручка;
• profit — прибыль (выручка − Quantity·Product.Cost);
• margin — alias для profit (отдельная кнопка в UI).

Граничные случаи: пустой период → пустой набор; товары с net-неположительной
метрикой исключаются (некоторые отдают только возвраты — для ABC не
интересны).

Возвраты учтены со знаком (net-метрика). storeId / productGroupId
фильтры. Export CSV/XLSX.

Web: /reports/abc с цветными плашками класса (A green / B yellow / C red)
и визуальной полосой накопительной доли.

Тесты: 4 интеграционных (Парето на 3 товара 800/150/50 → A/B/C; пустой
период; profit-метрика меняет порядок против revenue; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:24:26 +05:00
nns 3db112cbee feat(reports): отчёт «Прибыль» (выручка − COGS) (P1-10)
GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).

Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).

Export CSV/XLSX через тот же ReportExport.

Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.

Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:19:19 +05:00
nns 63c58ef6c1 feat(reports): отчёт «Остатки на дату» с реконструкцией (P1-9)
GET /api/reports/stock?date=… — восстанавливает остатки (Product, Store)
агрегацией журнала StockMovement до указанной даты. На «сейчас» совпадает
с материализованным Stock (инвариант учёта); на прошлую дату — реальная
реконструкция через Σ движений.

Edge-cases:
• дата в будущем → текущий остаток (движений из будущего нет);
• дата раньше первой операции → пусто (пара не существовала);
• операция с future-датой исключена снимком на «сегодня».

Стоимость: последний UnitCost движения до даты на пару (Product, Store);
fallback на Product.Cost если в журнале не было ни одной партии. Это
приближённая оценка — точный FIFO требует партий.

Параметры: storeId, productGroupId, includeZero (вернуть и нулевые
позиции). Export CSV/XLSX через тот же ReportExport.

Web: /reports/stock — дата, фильтры, экспорт, итоговая стоимость.

Тесты: 5 интеграционных (today=current, before-first-mov→empty, future=current,
date-before-op-excludes-it, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:14:24 +05:00
nns ac77849901 feat(reports): отчёт «Продажи» с группировками и экспортом (P1-8)
GET /api/reports/sales — агрегаты по period:day/week/month, product,
cashier, register, payment. Фильтры: from/to (по умолчанию last 30 days),
storeId, productGroupId. Возвраты включаются с минусом (netto-выручка
для фискальной отчётности).

GET /api/reports/sales/export?format=csv|xlsx — выгрузка через CsvHelper
(BOM UTF-8 + ; разделитель для Excel-RU) и ClosedXML.

Реализация: плоский набор строк проектируется на сервере БД (Join+Where,
EF переводит), агрегация в C#. Сознательный компромисс — EF8 не
переводит «distinct count» внутри group-проекции с join'ами по
nullable-ключам; объёмы отчётов (~десятки тысяч строк/месяц) держатся
в RAM спокойно.

Web: /reports/sales — выбор периода, табы группировки, фильтры, экспорт.
Sidebar: «Отчёты → Продажи» для Admin/Storekeeper.

Bonus: попутно вылечен баг RetailSalesController.Update — DbUpdateConcurrency
«0 affected» воспроизводился при PUT на свеже-созданный возврат
(create-return + immediate edit). Исправлено двумя изменениями:
• Update не делает Include(Lines) — старые строки удаляются ExecuteDelete'ом;
• ApplyLines добавляет новые строки напрямую в DbSet (а не через nav-collection
  sale.Lines.Add) — иначе EF8 путается со state'ом из-за client-side Id (Guid).

Тесты: 5 интеграционных (group by product, group by payment, returns reduce
revenue signed, tenant isolation, CSV export). 37 интеграционных всего зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:09:52 +05:00
nns a74fa114d8 feat(hangfire): dashboard + scheduled cleanup джобы (P1-16)
Hangfire.PostgreSql storage (тот же ConnectionString:Default). Сервер
стартует только когда Hangfire:Enabled (true по умолчанию) — в
интеграционных тестах выключаем через env Hangfire__Enabled=false,
чтобы тесты не плодили служебные таблицы в одноразовом контейнере.

Dashboard на /hangfire с авторизационным фильтром SuperAdminHangfireFilter —
требует роли SuperAdmin (стандартный OpenIddict-токен валидируется
аутентификационным middleware'ом перед этим).

Recurring jobs (HangfireJobsConfigurator):
• prune-stock-movements — ежедневно 03:30 UTC, удаляет StockMovement
  старше 730 дней (Hangfire:Retention:StockMovementDays). За 30 минут
  до бэкапа, чтобы pg_dump не цеплял временные блокировки.
• prune-audit-log — ежедневно 03:45 UTC, удаляет super_admin_audit_log
  старше 90 дней (Hangfire:Retention:AuditLogDays).

Логика очистки в HousekeepingJobs (scoped, использует AppDbContext с
IgnoreQueryFilters — это межтенантная задача).

Тесты: 1 unit (PruneStockMovements удаляет только старые), 1 интеграционный
(dashboard не отвечает без Hangfire-сервера). Полный прогон:
24 unit + 32 integration = 56 зелёных.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:07:14 +05:00