Commit graph

19 commits

Author SHA1 Message Date
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 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