Commit graph

71 commits

Author SHA1 Message Date
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 51aae4482f test(ui-deep): items 10-14 — все 59/59 ✓ на стейдже
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
Item 10 (2 specs): OrgAuditLog после seed-demo — записи видны, diff раскрывается.

Item 11 (4 specs): 2FA flow через API (UI 2FA пока не реализован).
Самодельная TOTP-генерация (RFC 6238) на crypto.createHmac sha1 —
без otplib v13 plugin'ов.

Item 12 (4 specs): неверный пароль — читаемая ошибка не «Request failed».
Forgot-password + login OK happy-path. Known: за 10 попыток login не
получили 429 — rate-limit possibly disabled.

Item 13 (5 specs, P0): multi-tenant изоляция HOLDS. GET/PUT/DELETE
товара A с токеном B → все 404/403, UI B не видит имя/данные A.

Item 14 (5 specs): mobile viewport 375x667 — sidebar схлопывается,
drawer открывается+закрывается, products list без horizontal overflow,
ConfirmDialog влезает.

Итого: 59 specs, найдены 6 багов (починены), 2 known issues
(Supply lost-update, login rate-limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:53:57 +05:00
nns 8b6d139e3e test(ui-deep): items 6-9 — Supply/RetailSale/InventoryDocs/Reports
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
Item 6 (3 specs): Supply UI + найден P2 баг lost-update (нет ETag).
Item 7 (4 specs): RetailSale + CustomerReturn — oversell/underpayment.
Item 8 (5 specs): 6 doc-форм Submit state, Transfer From≠To, CSV-import.
Item 9 (6 specs): Sales/Stock/Profit/ABC + CSV download через
waitForEvent + XLSX endpoint validation.

lib/ui.ts: signup timeout=60s + ignore network-flake console errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:37:01 +05:00
nns b9d9174a61 test(ui-deep): items 4-5 specs + docs
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) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Item 4 (4 specs): Контрагенты CRUD через modal + ConfirmDialog, Группы
товаров create, Типы цен create, Единицы smoke.

Item 5 (3 specs): Роли (wizard + create), Сотрудники (owner-record,
create через UI с email чтобы createAccount требование выполнилось),
Owner запись не удаляется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:11:47 +05:00
nns 64cc5b0d10 test(ui-deep): items 2-3 — navigation + Products CRUD
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 (4 specs): 27 sidebar-страниц последовательно открываются без
console-errors и без 5xx. Sidebar labels + active state проверены.

Item 3 (5 specs): Products full CRUD через UI — create+edit+delete с
ConfirmDialog, дубль артикула с понятным toast'ом, поиск, пагинация при
>50 товаров, загрузка картинки через setInputFiles.

watcher: фильтрует Chromium auto-сообщения «Failed to load resource: the
server responded with a status of N» — дубли network-обработчика.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:10 +05:00
nns eb867697d0 test(ui-deep): setup + Item 1 — signup flow (5 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 UI-deep, пункт 1: реальный Chromium через Playwright Test.
Установлены @playwright/test 1.60.0 и otplib (для item 11).
Конфиг tests/e2e/playwright.config.ts — workers=1, traces+screenshots
on-failure, screenshot dir reports/playwright-artifacts/.

Хелперы tests/e2e/lib/ui.ts:
- apiSignup() — быстрый signup через API + login
- attachSession() — кладёт access_token в localStorage, грузит путь
- watchPage() — listener console-errors и network 4xx/5xx
- expectNoErrors() — assert после flow'a

Item 1 (5 specs, все ✓ на стейдже):
- 1.1 attach session → /dashboard, без console-ошибок
- 1.2 создание товара через UI (Empty CTA → форма → Сохранить)
- 1.3 первый контрагент через Modal
- 1.4 создать товар + контрагент через API, открыть форму приёмки,
       smoke на компоненты страницы
- 1.5 OnboardingPage (/) рендерится

Найден 1 реальный баг → починен:
- ProductEditPage: race на currencies.data — если быстро Сохранить,
  цена-MoneyInput добавляет строку с currencyId='' → server 400 с
  криптичным JSON validation. Фикс: MoneyInput disabled пока
  !currencies.data + canSave проверяет row.currencyId.
- Form error display показывал "Request failed with status code 400";
  теперь использует общий humanizeError() (exporting из @/lib/api).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:33:10 +05:00
nns 64af42167b docs(sprint7): пункты 6-7 ✓ + итог по спринту
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
Все 7 пунктов закрыты. Stage прошёл smoke-тест 5/5 после последнего деплоя.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:52:00 +05:00
nns 6fc74f8db6 docs(sprint7): пункт 5 ✓ + empty-state screenshot
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:21:30 +05:00
nns cd83269d3a docs(sprint7): пункт 4 ✓ + skeleton screenshot
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 11:07:54 +05:00
nns 56dd9fb639 docs(sprint7): пункт 3 ✓ + toast screenshot script
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:59:06 +05:00
nns c201625b2b docs(sprint7): пункт 2 ✓ + screenshot script
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:46:52 +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 a0b985178b test(stage): пункт 14 — POS Sync API 7/7 ✓ (sync + sales с idempotency)
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
GET /api/pos/v1/sync — full snapshot products/prices/stocks/counterparties
с serverTime; since-инкремент работает (products пусто после first sync).
POST /api/pos/v1/sales с idempotency:
- batch-level: повтор того же IdempotencyKey → replayedFromCache=true,
  stock не дублирует списание;
- per-sale: новый IdempotencyKey + тот же ClientSaleId → возвращает
  существующий ServerSaleId (маркер в Notes);
- qty > stock → failed-секция с error, accepted=0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:53:08 +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 6b6f27d238 test(stage): пункт 12 — 2FA TOTP 6/6 ✓ (enroll+verify+login flow+disable)
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
TOTP-коды генерируются локально (RFC 6238 / Base32 + HMAC-SHA1)
из sharedKey. End-to-end: signup → enroll → verify (invalid+valid) →
login с otp_code (required+invalid+success) → disable → reset key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:41:22 +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 475c5ca674 test(stage): пункт 9 — Demand 8/8 ✓ (Cash + Credit + post + multi-tenant)
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:23:03 +05:00
nns 74e14ebeb5 test(stage): пункт 8 — SupplierReturn 8/8 ✓ (CRUD+Post+Unpost+ref validation+multi-tenant)
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:21:30 +05:00
nns 9df8e0123e test(stage): пункт 7 — CustomerReturn 6/6 ✓ (создание из чека+walk-in+overreturn+multi-tenant)
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:10:08 +05:00
nns 7d69006a94 test(stage): пункт 6 — Inventory 8/8 ✓ + logic gap по CSV-импорту
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
Auto-populate (lines=null), explicit lines с пересчётом diff, PUT с
изменением actualQty (фикс EF8 nav-collection теперь работает),
Post → корректирующие StockMovement type=InventoryAdjustment, Unpost,
multi-tenant. + Информационный gap: нет CSV-импорта фактических qty,
для оператора склада ввод через JSON-API неудобен.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:05:28 +05:00
nns 24c3ff1635 test(stage): пункт 5 — Transfer 7/7 ✓ (CRUD+atomic post+unpost+multi-tenant)
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:01:48 +05:00
nns d246354c20 test(stage): пункт 4 — Loss 8/8 ✓ (CRUD+Post+Unpost+multi-tenant)
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
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:59:56 +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 0511cfacfd test(stage): smoke + signup на test.admin.food-market.kz
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
stage-smoke (5 шагов): signup happy-path, bootstrap (Store/Roles/
Units/ProductGroup/PriceTypes/RetailPoint), login → access+refresh
+ /api/me с правильным orgId+role=Admin, edge-cases (дубликат
email, короткий пароль, пустое название, кривой телефон), проверка
public-сайта.

Informational gap: stage-public (test.food-market.kz) использует тот
же build что прод-public, поэтому его форма signup POST'ит в прод
admin. Для stage-testing регистрируемся напрямую POST на test.admin.

Чек-лист stage-testing: пункт 1 ✓.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:29:04 +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 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 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
nns 6886e1a92b feat(supplier-returns): возврат поставщику (P1-7)
Domain SupplierReturn+SupplierReturnLine (по аналогии с Supply). Опциональная
ссылка ReferenceSupplyId на исходную приёмку — при наличии валидируется
совпадение поставщика и status=Posted источника.

EF, миграция Phase6f_SupplierReturns. Контроллер
api/purchases/supplier-returns: CRUD + Post/Unpost. Post создаёт
StockMovement тип SupplierReturn с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар обратно.

Web: /purchases/supplier-returns (list+edit). Пункт «Возвраты поставщикам»
в сайдбаре Закупки. Permissions переиспользуют SuppliesEdit/SuppliesPost/Delete.

Тесты: 4 интеграционных (post→stock, over-return→409, mismatch supplier
по reference→400, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:58:29 +05:00
nns ea29ab63c7 feat(returns): возврат от покупателя (CustomerReturn) (P1-6)
Расширение RetailSale: IsReturn (bool) + ReferenceSaleId (Guid?, self-FK) +
RetailSaleLine.QtyReturned (агрегация для защиты от over-return).
Миграция Phase6e_RetailSaleReturns (IF NOT EXISTS, можно гонять на стейдже).

Контроллер api/sales/retail:
• Create принимает isReturn/referenceSaleId; для возврата с reference —
  валидация что reference существует, не сам возврат, и проведён.
• POST /{id}/create-return — создаёт Draft-возврат от проведённого чека,
  копируя строки с qty = (Quantity - QtyReturned).
• Post с IsReturn=true идёт через PostReturnAsync: проверяет лимит
  возврата по reference (Quantity - QtyReturned), создаёт StockMovement
  тип CustomerReturn с +Quantity, инкрементит QtyReturned на источнике.
• Unpost для возврата зеркально откатывает.
• Запрещён unpost исходного чека пока есть проведённые возвраты на него.

Web: кнопка «Создать возврат» на странице проведённой продажи. DTO
расширены полями isReturn/referenceSale*/qtyReturned.

Тесты: 3 интеграционных (полный цикл sale→create-return→post,
standalone-return без reference, блокировка unpost при активных возвратах).
Полный прогон: 27 зелёных интеграционных, 23 зелёных unit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:51:04 +05:00
nns 4285bdee91 feat(inventories): инвентаризация с CSV-импортом факта (P1-4)
Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.

Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».

Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:39:32 +05:00
nns fa1e123327 feat(transfers): атомарное перемещение между складами (P1-3)
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).

Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.

Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:32:32 +05:00
nns 3172b0ea72 feat(losses): списание со склада с указанием причины (P1-2)
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).

Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:24:40 +05:00
nns e392bf8ae9 feat(enters): оприходование товара без поставщика (P1-1)
Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.

Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:18:13 +05:00
nns f2dad91e05 test(integration): Testcontainers.PostgreSql + WebApplicationFactory, 10 тестов (P1-21)
ApiFactory поднимает реальный API на одноразовом postgres:16-alpine (Ryuk off —
сеть к Docker Hub нестабильна, образ закэширован; RateLimiting off через env, т.к.
лимитер читает конфиг эагерно). Program сделан public partial для фабрики.

Сценарии (10 зелёных):
- signup-flow: signup→token→/api/me с org; дубль-signup 400; слабый пароль 400.
- tenant isolation A vs B: контрагент A не виден B (список + прямой GET 404).
- permission: кастомная роль без ProductsEdit → PUT товара 403, GET 200; админ не 403.
- supply post→unpost: остаток 0→10, Cost=70 (скользящее среднее), unpost→0; двойной post 409.
- retail overselling: продажа сверх остатка → 409; недоплата → 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:14:01 +05:00
nns f3d517f257 test(unit): xUnit-проект food-market.UnitTests, 23 теста (P1-20)
Чистая логика вынесена в Application для тестируемости и используется контроллерами:
- MovingAverageCost.Compute (скользящее среднее себестоимости) ← SuppliesController.Post
- RetailPaymentValidator.IsSufficient (достаточность оплаты) ← RetailSalesController.Post

Тесты:
- MovingAverageCost: первая приёмка, средневзвешенное, округление до 4 знаков, totalQty=0.
- RetailPaymentValidator: ровно/переплата/недоплата, округление до 2 знаков.
- StockService.ApplyMovement (SQLite in-memory): материализация Stock+движение,
  инкремент, отрицательное списание, throw без tenant.
- Мультитенантный query-filter AppDbContext: tenant видит своё; чужой не видит;
  SuperAdmin без override — всё; с override — только выбранную оргу.

Все 23 зелёные. EF8 SQLite поддерживает ToJson (EmployeeRole.Permissions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 03:01:56 +05:00