Commit graph

32 commits

Author SHA1 Message Date
nns deef65d819 test(money): unit (MoneyMath 25) + integration (7 на Postgres) + MoneyMath refactor
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
- MoneyMath: вынесены чистые формулы (статус оплаты, ожидаемая наличность,
  разница смены, остаток счёта, долг поставщика, защита от переплаты);
  сервис/SuppliesController/MoneyReportsController теперь зовут их.
- UnitTests/MoneyMathTests: 25 кейсов на формулы + money-флаги RolePermissions.
- IntegrationTests/MoneyModuleTests (реальный Postgres, 7 [Fact]):
  мультитенантность (Б не видит счета А), инвариант остатка, частичная→полная
  оплата приёмки, переплата→409, долг поставщика, продажа без смены→409 +
  после open→ok, расчёт expected/difference при закрытии смены.
- ApiActor: helpers CreateRetailPoint/CreateMoneyAccount/CreatePostedSupply.
- tests/e2e/scrape-moysklad.ts — скрейпер для docs/money-moysklad-reference.md.

Прогон: UnitTests 25/25 ✓, IntegrationTests money 7/7 ✓ (33s, Testcontainers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 22:11:19 +05:00
nns a25b5b3277 feat(money): бэкенд модуля «Деньги» для простого режима (пп.0–6)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API:

- Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток,
  привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance).
- Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы
  парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена
  в Serializable-транзакции через MoneyService).
- Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink,
  SuppliesController отдаёт PaymentStatus + фильтр paymentStatus.
- Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts.
- Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual,
  Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController.
- Отчёт «Деньги»: balances/movement с проверкой инварианта остатков.

Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт
кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей
расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 21:52:04 +05:00
nns e45600df4b feat(invoices+catalog): порт улучшений 06.10.2026 из sklad
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
Основное:

* Program.cs: SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true
  + InvalidModelStateResponseFactory → ValidationProblemDetails + Serilog Warning.
  Пустые необязательные строковые поля в InvoiceLine (Note, BarcodeSource и т.п.)
  больше не роняют запрос в 400. humanizeError на фронте уже умеет этот формат.
* InvoiceModels.cs: Name/Source/FilePath → string?.
* Validators.cs: InvoiceSaveLinesValidator (Lines NotNull), InvoiceApplyValidator
  (Guid.Empty на Store/Currency = ошибка, SupplierId опционален).

Служебный «Поставщик не указан» (адаптация sklad §11 под multi-tenant):

* Phase32b миграция добавляет Counterparty.IsPlaceholder bool + partial index
  по IsPlaceholder=true.
* InvoicesController.Apply: supplierId опционален; если не указан —
  ResolvePlaceholderCounterpartyAsync ищет/создаёт одного служебного per org.
  Имя из накладной в комментарий документа НЕ пишется.
* CounterpartiesController: служебный всегда последним в List сортировках,
  Update и Delete при IsPlaceholder → 409 Conflict.
* CounterpartyDto.IsPlaceholder + Mapster-config обновлены.
* Автоподбора/автозаведения контрагентов нигде не было — специально проверили.

Applied-накладная readOnly (§10):

* InvoiceDetailDto: добавлены supplyNumber + processedAt.
* InvoiceReviewPage: applied-режим — плашка «Черновик приёмки № … создан …»
  со ссылкой на приёмку, шапка и строки readOnly, скан-блок и apply-кнопка
  скрыты. После успешного apply — invalidateQueries, экран сразу readOnly.

Текст ошибки apply (§5) и список накладных (§6):

* ApplyDialog: error → detail → title → (код), __silent на axios.
* Invoices list: parseServerDate подшивает «Z» если его нет, «Загружена DD.MM.YY HH:MM»
  в бейдже статуса (видимо в десктоп-таблице и в мобильной карточке — у нас общая).

Тесты + проверки:

* tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs — 5 кейсов:
  SaveLines со всеми null, apply без поставщика с idempotency по placeholder'у,
  Delete/Update placeholder → 409, applied detail содержит processedAt +
  hasFiles=false, apply с Guid.Empty storeId → ValidationProblemDetails.
  Все 5 ✓.
* stage retest на https://test.admin.food-market.kz: upload+SaveLines(null-fields)+
  apply(без поставщика)+apply(с ИП Бегимбаев)+DELETE placeholder → весь flow
  зелёный, служебный один на орг, hasFiles=false после apply.
* quality-watchdog step 9 (menu-walk): 3/3 passed (3.7m).
* Фото-накладная через Claude CLI не гонял — пайплайн apply→DeleteStored
  закрыт через hasFiles=false в тестах и реальный stage smoke.

docs/supply-inventory.md — шаг 7 расписан под необязательного поставщика,
новый шаг 8 (applied → readOnly), миграция Phase32b в таблице «Файлы».

docs/sklad-0610-progress.md — финал 9/9 с подробным журналом решений.

Пред-существующие (не трогал):

* OrgAuditLogTests.Counterparty_update_diff_contains_before_after → 403 от
  [RequiresAdvancedMode] на /api/admin/audit-log (commit 921bca0, s29);
* ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return —
  регекс-тюнинг доков.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-07 19:50:03 +05:00
nns 6b0fc68f77 feat(invoices+inventory): порт улучшений 05.10.2026 из sklad
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).

Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
  .xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
  IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
  (barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
  убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
  FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
  → Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.

Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
  GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
  суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
  Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.

Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
  после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
  камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
  «в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
  снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).

Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).

Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
  CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
  unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.

Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.

Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-06 00:02:13 +05:00
nns 05e7fdecef feat(s31): CameraScanner + scan-mode приёмки с last-purchase price (3/8)
CameraScanner (components/CameraScanner.tsx) — порт из sklad'a @zxing/
browser + @zxing/library. Непрерывное сканирование: окно не закрывается
между сканами, EAN13/EAN8/UPC/Code128/ITF. Подавление повтора 1.5с (в
кадре товар распознаётся десятки раз в секунду). Вибрация 40мс + flash
зелёный/красный + анимация scanPop/scanRing.

SupplyScanPage (/purchases/supplies/scan):
- Выбор поставщика (search по /counterparty-search), склада, валюты.
- Кнопка 'Сканировать камерой' → <CameraScanner /> на полный экран.
- Alt: аппаратный сканер — <input> в details-блоке, Enter = +1.
- Каждый скан: GET /product-by-barcode → если нашёлся, +1 к
  существующей строке или новая сверху. Цена подставляется last-
  purchase-price (из последней Posted supply-line по этому товару).
- Неизвестный штрихкод: feedback 'Нет в каталоге', вибрация ошибки.
  Товар создавать — отдельный переход на /products/new (future wiring).
- Прогресс сохраняется в localStorage ('fm.supply-scan.rows') —
  перезагрузка не теряет; очищается после успешного сохранения.
- Save → POST /api/purchases/supplies (существующий endpoint), статус
  Draft; редирект в /purchases/supplies/:id для проверки/проведения.

InvoicesController.ProductByBarcode теперь возвращает ещё и
lastPurchasePrice (из последней Posted supply-line).

@zxing/browser@0.1.5 + @zxing/library@0.23.0 добавлены в deps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:36:41 +05:00
nns 11b43aefe5 feat(s31): приёмка по фото — upload → Claude CLI → match → Supply Draft (1/8)
Backend-pipeline по образцу ~/ref/sklad/sklad/Invoices/, но документы
создаются в НАШЕЙ базе (Supply), не в МойСкладе.

Домен (food-market.domain/Purchases/InvoiceUpload.cs):
- InvoiceUpload: файл + статус (pending/parsing/needs_review/applied/failed)
  + распознанная шапка + ParsedJson (jsonb) + ссылка на созданный Supply.
- InvoiceAlias: запомненное соответствие (SupplierKey, SourceCode|
  SourceNameNorm) → ProductId. Два частично-уникальных индекса (filter
  SourceCode/SourceNameNorm IS NOT NULL).

Миграция Phase31a (идемпотентная ADD/CREATE IF NOT EXISTS; [Migration]+
[DbContext]).

Infrastructure/Invoices/:
- IInvoiceExtractor + ParsedInvoice/InvoiceLine DTO.
- ClaudeCliInvoiceExtractor — порт из sklad: 'claude -p --output-format
  json', промпт один-в-один (копировать все строки, null если не читается,
  barcode != supplier_code, сверка с «Итого»). Поддерживает local и
  Invoices:ClaudeCli:RemoteHost (ssh+scp+cleanup).
- InvoiceMatcher — barcode → alias → none. По названию НЕ ищем
  (sklad-урок: 2-5 ошибок из 10, подмена хуже пустой строки).
- InvoiceProcessor + InvoiceWorker (BackgroundService):
  pending → parsing → needs_review | failed. SweepAsync раз в час
  удаляет файлы старше Invoices:KeepFilesDays (default 7); строки
  остаются. Multi-tenant через IgnoreQueryFilters + явный OrgId.

Controller api/purchases/invoices:
- POST /upload (SuppliesEdit, 60 MB max): файлы в uploads/invoices/{yyyy-
  MM}/, запись в pending.
- GET /, GET /{id} — список + детали со строками.
- GET /{id}/file — отдать файл.
- DELETE /{id} — удалить запись + файл.
- PUT /{id}/lines — правки шапки + строк + авто-upsert InvoiceAlias
  для каждой строки с MatchedBy=manual|alias (два ключа: по code и по
  name_norm).
- POST /{id}/apply — создать Supply (Draft) с данным поставщиком/
  складом/валютой, статус → applied, SupplyId ссылается, файл удаляется.
- GET /product-by-barcode, /counterparty-search — помощь UI.

Build зелёный. Web + тесты — следующими шагами.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:30:02 +05:00
nns 6940aa40df feat(s19): bulk-операции + presets + power-user UX (7 пунктов)
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
1. Bulk-обновление товаров — Product.IsArchived + IsAvailableForSale
   (Phase19a миграция), POST /api/catalog/products/bulk-update {ids, op, params}
   с операциями price-adjust (% / абсолют), change-group, archive/unarchive,
   toggle-sale. Одной транзакцией, multi-tenant через query-filter.
   Frontend: checkbox-колонка, sticky bulk-bar, модалка.

2. SavedPresets — domain UserPreset (Phase19b: jsonb ConfigJson,
   unique по OrgId+UserId+PageKey+Name). /api/user/presets CRUD per-user.
   <SavedPresets> компонент с chip-bar и сохранением. Применено к /reports/
   sales/stock/profit + /catalog/products.

3. QuickActionsPalette — Cmd+J открывает отдельную палитру с 14 действиями
   + история topa-10 в localStorage.fm.quickActions.recent. ↑↓/Enter/Esc
   keyboard nav. Cmd+K (поиск) и Cmd+J (действия) — разные палитры.

4. Inline-edit цены — PATCH /api/catalog/products/{id}/price новый endpoint
   с RoundIfNeeded. <InlinePriceCell> с dblclick → input, optimistic update
   + revert при ошибке.

5. CSV import товаров — POST /api/catalog/products/import-csv (rows[]).
   Клиент парсит CSV (auto-detect разделитель ,/;), сервер commit'ит
   транзакцией. autoCreateGroup для новых групп. <ProductsCsvImport>
   модалка с preview и подсветкой ошибочных строк.

6. CSV/XLSX export — endpoint'ы /export на 5 контроллерах (products,
   counterparties, stock, retail-sales, supplies). Reuse существующего
   ReportExport.Csv/Xlsx. <ExportButton> dropdown с двумя форматами.

7. Keyboard-first nav в DataTable — ↑↓/Home/End/Enter/Space/Delete props
   keyboardNav/onSelect/onDelete. Подсветка focused-строки. Документация
   в src/help/keyboard-shortcuts.md + 2 новые HelpTopic'a.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:08:48 +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 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 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 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 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 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 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 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
nns 28010fafdb feat(authz): permission-based авторизация по флагам роли (P0-5)
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.

SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).

Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).

Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:37:28 +05:00
nns 15f27fd16e fix(supplies): сериализуемое проведение приёмки против lost update остатков
Supply.Post шёл на дефолтной изоляции (Read Committed), а
StockService.ApplyMovementAsync делает read-modify-write по Stock.Quantity
без RowVersion. Под конкуренцией это ломало главный инвариант
Stock.Quantity == Σ StockMovement.Quantity:

- двойное проведение ОДНОЙ приёмки (оба запроса читают Status=Draft до
  коммита соседа) применяло остаток дважды — два StockMovement, но Stock
  рос лишь на одну партию (lost update);
- две разные приёмки одного товара могли потерять обновление остатка и
  посчитать скользящее среднее Cost от устаревшего currentQty.

Переводим проведение на IsolationLevel.Serializable (как RetailSale.Post)
и ловим конфликт сериализации (SQLSTATE 40001/40P01) → 409, чтобы клиент
повторил, а не получал 500. Найдено сценарием stock-concurrency
(step03: было stock=32/sum=39 → стало 32/32, statuses 204+409).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:16:11 +05:00
nns 7a4b34bc2f fix(documents): защита денег и инварианта остатков на posting-операциях
Два P0-бага выявлены сценарием documents-edge:

BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total.
Касса могла «провести» чек с фактической оплатой 0 — товар уходит
со склада, деньги не получены. Добавлена валидация: paid (округлённое
до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога».
Сдача (PaidCash > Total) остаётся легальной.

BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в
минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost
приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard:
агрегируем reverse-quantity по продукту, сравниваем с текущим
Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных
строк.

Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08
(unpost после sale → 409): обе проверки теперь зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:33:40 +05:00
nns 57168299ac fix(validation): обязательные FK-Guid проверяются на 400 + DbUpdateException → 400
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.

Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
  Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
  ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
  и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
  ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
  ConstraintName и возвращает 400 вместо 500. Защита для случая когда
  Guid не пуст, но указывает на удалённую/чужую запись.

TaskUpdate: closes step08-bug «Supply без supplierId → 500».
2026-05-08 12:05:01 +05:00
nns b073e99ca7 feat(roles): три системные роли — Admin/Cashier/Storekeeper
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.

— SystemRoles.Manager убран. Identity-роли сидируются: SuperAdmin,
  Admin, Cashier, Storekeeper.
— EmployeeRoles tenant-сидер создаёт только три записи (все IsSystem=true,
  все три не редактируются и не удаляются обычным юзером — это правило
  уже работало для Админа/Кассира, теперь покрывает Кладовщика).
— Authorize(Roles = ".. Manager ..") убрано из всех контроллеров (13 файлов):
  Sales/RetailSales, Catalog/{Products,ProductImages,ProductGroups,
  Counterparties,UnitsOfMeasure,RetailPoints,PriceTypes,Stores},
  Purchases/Supplies, Organizations/{Employees,EmployeeRoles,
  OrganizationSettings}.

Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
2026-05-06 11:31:15 +05:00
nns 7d09873be2 fix(supply-lines): show both article and barcode in line subtitle
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).

Формат: «Арт: 17933 · ШК: 4870144022958». Если только одно из двух
— префикс соответствующий, без точки-разделителя. Если ни того ни
другого — subtitle не рендерится. Шрифт мелкий моно серый.

API:
- SupplyLineDto расширен полем ProductBarcode (основной по
  IsPrimary, иначе первый по порядку).
- В проекции GetInternal штрихкод подтягивается через
  p.Barcodes.OrderByDescending(IsPrimary).Select(Code).First().

Frontend:
- types.ts.SupplyLineDto, LineRow в SupplyEditPage и AddedProduct
  в SupplyLineQuickAdd получили поле productBarcode/barcode.
- При добавлении строки через ProductPicker, sticky-input или
  quick-create — primary barcode достаётся из p.barcodes одинаковой
  логикой (sort by IsPrimary desc, [0]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns b7288bac1b feat(supply): «Проведено» внутри формы + обязательная дата и ≥1 позиция
UI:
- Чекбокс «Проведено» переехал из шапки в секцию «Реквизиты документа»,
  чтобы было визуально как в сторонняя система. С хинтом «только проведённый
  документ влияет на остатки и себестоимость».
- Поле «Дата» помечено как обязательное (звёздочка + required).
- canSave требует form.lines.length > 0; пустое состояние секции
  «Позиции» теперь красное «должна быть хотя бы одна позиция».
- onError приёмки достаёт сообщение из response.data.error.

API:
- Create/Update приёмки 400-ят без позиций
  («Приёмка должна содержать хотя бы одну позицию.»).
- Post (проведение) уже валидирует это; теперь и на этапе сохранения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:29 +05:00
nns 306153d128 phase3b: product card cleanup + supply form simplification
Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».

Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
  (избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
  — для большинства мелких магазинов лишний клик не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:00:06 +05:00
nns 4649a624c3 chore(price-types): drop IsDefault flag + rename IsRetail label + uniqueness
PriceType: убран флаг IsDefault — он семантически дублировал IsSystem
(защищённая запись «по умолчанию»). Остаются IsSystem / IsRequired /
IsRetail.

- Domain.Catalog.PriceType: удалено поле IsDefault.
- Миграция Phase3b_DropPriceTypeIsDefault: DROP COLUMN.
- DTO/Input (PriceTypeDto, PriceTypeInput) — без IsDefault.
- PriceTypesController:
  • убрана логика uniqueness IsDefault на Create/Update,
  • IsRetail теперь enforce'ит уникальность: при установке IsRetail=true
    у других записей сбрасывается,
  • при удалении единственной IsRetail записи (если она не системная)
    IsRetail автоматически переезжает на IsSystem-запись — у организации
    всегда остаётся один POS-кандидат.
- ProductsController.RecalcRetail и SuppliesController.SetDefaultRetail —
  поиск дефолтной розничной идёт по IsSystem → IsRetail → SortOrder → Name
  (ранее ThenByDescending(IsDefault) — выпилено).
- DevDataSeeder: поле IsDefault убрано.
- web types.ts: убрано isDefault из PriceType.
- PriceTypesPage:
  • убран чекбокс «По умолчанию»,
  • лейбл «Розничная (используется на кассе)» → «Используется на кассе»,
  • Form/blankForm/onRowClick без isDefault.
- ProductsPage / ProductEditPage: фоллбэк дефолтной цены теперь
  IsSystem → IsRetail → первая.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:15:29 +05:00
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:46:34 +05:00
nns 38040b4ec7 feat(api): supply posting hook for cost & markup
При проведении приёмки (POST /api/purchases/supplies/{id}/post):

- Себестоимость товара пересчитывается по скользящему среднему по
  ВСЕМ складам организации:
  newCost = (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
  При qty_old = 0 или cost_old = 0 → newCost = price_in.
  Хранится с 4 знаками (Math.Round AwayFromZero).
- ReferencePrice автозаполняется UnitPrice'ом первой Posted приёмки.
- LastSupplyAt = UtcNow.
- Розничная (дефолтный PriceType, IsDefault → IsRetail → SortOrder/Name):
  • если у строки RetailPriceManuallyOverridden=true и есть
    RetailPriceOverride — пишем его как розничную (override per-line),
  • иначе если у Group задан MarkupPercent — пишем
    Math.Ceiling(cost * (1 + pct/100)) с округлением:
    при AllowFractionalPrices=true — до сотых, иначе до целого,
  • иначе — розничная не трогается.
  Если в Product.Prices ещё нет записи под дефолтный PriceType —
  создаётся (currency = supply.CurrencyId).
- Всё в одной транзакции, ApplyMovementAsync вызывается ПОСЛЕ
  расчёта Cost (currentQty снимается до приёмки).

SupplyLineInput/SupplyLineDto расширены полями RetailPriceManuallyOverridden,
RetailPriceOverride; в DTO дополнительно CurrentRetailPrice — текущая
дефолтная розничная цена товара (для отображения в UI приёмки).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:02:00 +05:00
nns 4f4a751d26 feat(org-settings): AllowFractionalPrices — переключатель дробных цен
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)»
(default false). Когда выключено — все денежные поля принимают и
сохраняют только целые числа.

- Organization.AllowFractionalPrices + миграция Phase5h.
- OrgSettings DTO/Input + UI настроек (галка с подсказкой).
- MoneyInput получил prop allowFractional: при false запрещает ввод
  точки/запятой и форматирует целым числом, при true — две цифры
  после запятой как раньше.
- ProductEditPage / SupplyEditPage / RetailSaleEditPage передают
  org.allowFractionalPrices во все MoneyInput.
- Списки Products / Supplies / RetailSales форматируют суммы по
  настройке (с .00 или без).
- Сервер защищён от обхода UI: ProductsController / SuppliesController /
  RetailSalesController при сохранении округляют purchasePrice /
  price.amount / unitPrice / discount / paidCash / paidCard до целого
  если флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:21:04 +05:00
nns 23e29be21b feat(forms): MoneyInput/NumberInput + select-пагинация + Range на бэкенде
UI:
- Pagination: ввод страницы заменён на select (option 1..totalPages),
  по выбору сразу setPage. Стрелки ← → остаются.
- Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и
  NumberInput (decimal без валюты). Оба фильтруют ввод регулярно
  (только цифры + точка/запятая→точка), при focus — выделяют значение.
- ProductEditPage: purchasePrice / vat / minStock / maxStock / amount
  в ценах продаж переведены на новые компоненты; символ валюты —
  из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг.
- SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount
  в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput
  с символом из form.currencyId.
- CountriesPage: vatRate — NumberInput.

API:
- ProductInput / ProductPriceInput / SupplyLineInput /
  RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)]
  на денежные/количественные поля и [Range(0,100)] на проценты.
  ASP.NET автоматически валидирует и возвращает 400 при выходе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:17:32 +05:00
nns 5a06e15924 feat(tables): server-side sort by column header click
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный default (обычно по Name ASC).

Реализация:
- PagedRequest: добавлены Sort (ключ колонки) и Order ("asc"/"desc"),
  плюс удобное свойство Desc.
- DataTable: Column.sortKey + props sortKey/sortOrder/onSortChange,
  в заголовке появляется иконка (ArrowUpDown/ArrowUp/ArrowDown).
- useCatalogList: хранит sortKey/sortOrder, отдаёт setSort, шлёт
  ?sort=&order= в query-string.
- Все 10 List-эндпоинтов (Countries, Currencies, UnitsOfMeasure,
  PriceTypes, Stores, RetailPoints, Counterparties, ProductGroups,
  Products, Supplies, RetailSales + Stock/Movements) принимают
  параметры и применяют switch-based OrderBy по whitelisted ключам.
- Все страницы со списками прокидывают sort state и sortKey на
  колонках, где сортировка имеет смысл (тексты/числа/даты).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:05 +05:00
nurdotnet 654a8ba87d feat: strict OtherSystem schema — реплика потерянного f7087e9
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в OtherSystem — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.

Добавлено как в OtherSystem:
- Product.Vat (int) + Product.VatEnabled — OtherSystem держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в OtherSystemImportService когда товар не принёс свой vat.

OtherSystemImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.

Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
2026-04-23 17:32:02 +05:00
nurdotnet e726cba5d8 phase2b: Supply document (приёмка) — posts to stock atomically
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:06:08 +05:00