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>
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>
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>
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>
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>
В 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>
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>
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>
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>
Тот же баг что в 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>
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>
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>
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>
Подключён 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>
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>
Подключён 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>
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>
Раньше прогресс фоновых импортов жил в 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>
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>
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>
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>