Commit graph

104 commits

Author SHA1 Message Date
nns 7fe30bd98d feat(web): supply line retail override column
В таблице строк приёмки добавлена колонка «Розничная (карточка)».
- Значение по умолчанию — текущая дефолтная розничная цена товара
  (берётся из ProductDto.prices при подборе или из SupplyLineDto.
  currentRetailPrice при загрузке существующего документа).
- Любая ручная правка ставит retailPriceManuallyOverridden=true и
  записывает retailPriceOverride. При проведении документа этот
  override применяется к Product.Prices[default] вместо автонаценки.
- В payload PUT/POST шлём retailPriceManuallyOverridden и
  retailPriceOverride (либо null если override снят).
- types.SupplyLineDto расширен полями currentRetailPrice /
  retailPriceManuallyOverridden / retailPriceOverride.
- В addLineFromProduct unitPrice fallbacks теперь учитывают cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:12:12 +05:00
nns a5f0fb83d8 feat(web): price types CRUD visibility + group markup table
- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
  в модалке редактирования с подсказкой про формулу
  ⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
  правка % наценки по группам. Inline TextInput, считается diff,
  кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
  Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:09:10 +05:00
nns e74bec3964 feat(web): product card pricing UI + settings toggles
- OrganizationSettingsPage: 2 новые галки —
  «Несколько типов цен (Опт, VIP и т.п.)» (multiplePriceTypesEnabled)
  и «Показывать «Эталонную цену» на товаре» (showReferencePriceOnProduct).
- ProductEditPage:
  • «Эталонная цена» с подписью «не обязательное поле»; рендерится только
    при showReferencePriceOnProduct=true.
  • «Себестоимость» — readonly MoneyInput, всегда виден; подпись
    «расчётная (скользящее среднее)».
  • Заголовок секции цен меняется: «Цены продажи» при multipleEnabled,
    иначе «Розничная цена».
  • Кнопка «Привести к себестоимости» (только для existing товара) —
    POST /api/catalog/products/{id}/recalc-retail. После 200 — обновляем
    дефолтный PriceType в form.prices, инвалидируем кэш. После 400 —
    показываем сообщение в общий error.
- useOrgSettings.ts: добавлены поля multiplePriceTypesEnabled и
  showReferencePriceOnProduct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:05:56 +05:00
nns de23f5fc7a feat(api): recalc-retail endpoint + 30-day reference price refresh job
POST /api/catalog/products/{id}/recalc-retail (Admin/Manager/Storekeeper):
- Если у Group товара задан MarkupPercent — записывает в дефолтный
  розничный PriceType значение ceil(Cost * (1 + pct/100)). Округление
  под AllowFractionalPrices: до сотых при включённом, до целого иначе.
- Возвращает 400 «У группы не задана наценка. …» если MarkupPercent null.
- Возвращает 400 если нет ни одного активного PriceType.
- Использует Organization.DefaultCurrencyId как fallback при создании
  новой записи цены.

Background/ReferencePriceRefreshJob (IHostedService, PeriodicTimer 24ч):
- Раз в сутки находит товары с LastSupplyAt < now-30d и Cost > 0,
  переписывает ReferencePrice = Cost, обновляет ReferencePriceUpdatedAt.
- IgnoreQueryFilters — работает над всеми organizations.
- Стартовая задержка 5 минут чтобы не пересечься с пендинг-миграцией.
- Зарегистрирован через AddHostedService в Program.cs.
- Hangfire не подключаем как полноценный server — IHostedService даёт
  тот же эффект без отдельной schema/dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:03:44 +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 6acf6b7c03 feat(domain): pricing model rename and new fields (Phase3a)
Подготовка к новой модели цен сторонняя система-style:
- Product.PurchasePrice → ReferencePrice (справочная закупочная,
  не обязательная). + ReferencePriceUpdatedAt для 30-дневного таймера.
- Product.+ Cost numeric(18,4) — себестоимость по скользящему среднему.
- Product.+ LastSupplyAt — UTC последней Posted приёмки.
- ProductGroup.+ MarkupPercent (5,2) — % наценки на cost для авто-розничной.
- Organization.+ MultiplePriceTypesEnabled (default false) и
  ShowReferencePriceOnProduct (default true).
- SupplyLine.+ RetailPriceManuallyOverridden + RetailPriceOverride —
  отметка ручной правки розничной в строке приёмки.

Миграция Phase3a_PricingModel: RENAME + AddColumn'ы. Logic перерасчёта
себестоимости, автонаценки, recalc-endpoint и Hangfire job — следующими
коммитами.

DTO/контроллеры/OtherSystem-импорт/UI поля переименованы в referencePrice
(включая фильтры списка товаров). UI-логика следующего коммита будет
показывать Cost и кнопку «привести розничную к себестоимости»; пока
referencePrice работает как раньше.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:59:09 +05:00
nns b8fd5ec2bd chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:36:14 +05:00
nns a594a433d4 ci(products-fix-ping): одноразовый Telegram-пинг что save товара починен
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:33:52 +05:00
nns fd2da58ad4 fix(products/update): merge barcodes/prices по ключу + 409 на concurrency
Юзер ловил 500 «DbUpdateConcurrencyException: 0 rows affected» при PUT
/api/catalog/products. RemoveRange(всех детей) + Add новых на каждом
сохранении генерирует массовый DELETE/INSERT, при котором EF ожидал N
rows affected, а реальный DELETE возвращал меньше — и весь батч падал
с 500.

Чиню по-человечески:
- Merge by stable key: barcodes по Code, prices по PriceTypeId.
  Совпавшие — обновляем поля, лишние удаляем, новые добавляем. Минимум
  записей в SaveChanges, минимум поводов для 0-affected.
- Catch DbUpdateConcurrencyException → 409 «Товар изменён в другом
  окне или сессии. Перезагрузите страницу и попробуйте снова.» вместо
  непрозрачного 500.
- Удалена мёртвая ветка `if (input.Vat is null) e.Vat = existingVat`:
  Apply уже не присваивает Vat при null, ничего восстанавливать не
  нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:18:23 +05:00
nns ad05f9fe30 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:47:15 +05:00
nns aec5ca5591 ci(runner-fix-ping): одноразовый Telegram-пинг что стенд догнал
Триггер только на изменение самого файла. Уйдёт следующим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:45:04 +05:00
nns 3c576934c7 ci(docker): откатить buildx → docker build (registry connect refused внутри builder)
buildx --driver docker-container запускает builder в изолированном
сетевом namespace, откуда 127.0.0.1:5001 (host registry) недоступен:
ошибка «dial tcp 127.0.0.1:5001: connect: connection refused» в шаге
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0.

Откатываю на классический `docker build` + `docker push`. У host
docker daemon уже есть 127.0.0.1:5001 в insecure-registries, layer-cache
демона между сборками сохраняет dotnet restore / pnpm install при
стабильных манифестах. Path-фильтры (api vs web) остаются — это
основной выигрыш по времени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:39:29 +05:00
nns 8d9cd201b4 ci(docker): split into docker-api.yml + docker-web.yml — независимые pipeline'ы
В предыдущей попытке runner v6.2.2 не подружился с cross-job outputs —
job changes падал «'runs-on' key not defined in Docker Images/changes»
и оба следующих job уходили в skipped. Откатываю на надёжный путь:
два отдельных workflow с paths-фильтром на уровне триггера.

- docker-api.yml: триггерится на src/food-market.api/**,
  application/**, domain/**, infrastructure/**, shared/**, sln,
  Dockerfile.api, compose. Билдит buildx с registry-cache, пуллит
  и пересоздаёт ТОЛЬКО api (`docker compose up -d --no-deps api`).
- docker-web.yml: триггерится на src/food-market.web/**, Dockerfile.web,
  nginx.conf, compose. Делает то же для web.
- .env стенда теперь идемпотентный — оба тэга всегда :latest, после
  push образа buildx обновляет latest, pull тянет свежий.
- Telegram отдельные сообщения «stage api deployed» / «stage web
  deployed» с SHA — понятно что именно прилетело.

Push, который не задевает ни api/ ни web/ исходники (только md/docs),
вообще не запускает workflow — экономия очевидна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:22:31 +05:00
nns 9077c07584 fix(money-input): toFixed(2) при allowFractional=true для правильного отображения
formatFromValue использовал String(v) для дробного режима, поэтому
целые значения в БД (1317, 1860) показывались как «1317» / «1860»
даже при включённой галке «Разрешить дробные цены». Теперь:
- fractional=true → v.toFixed(2): 1317 → «1317.00», 1317.5 → «1317.50»;
- fractional=false → String(Math.round(v)): 1317.5 → «1318».

Хелпер используется в init useState, useEffect-синке и commitDraft —
таким образом при onBlur поле всегда возвращается к корректному
формату «1317.00».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:55:33 +05:00
nns 71b749fb35 ci: path filters + buildkit cache для ускорения сборки
Цель: типичный push должен катиться за 30-60 сек вместо 3-5 мин.

docker.yml — две оптимизации:
1. Job changes детектит что изменилось (api/web) через `git diff
   HEAD~1 HEAD`. Образ пересобирается только если затронуты его
   директории; `paths-ignore` отсекает docs/*.md/.github/**.
2. Сборка через `docker buildx build` с registry-cache:
   --cache-from / --cache-to type=registry,ref=...:buildcache,mode=max.
   Локальный 127.0.0.1:5001 уже разрешает DELETE, так что mutable
   buildcache работает. dotnet restore / pnpm install теперь почти
   мгновенные при отсутствии изменений в *.csproj / pnpm-lock.yaml.
3. deploy-stage запускается только если api или web реально
   пересобирался; для пропущенного образа в .env пишется :latest,
   compose pull тянет последний успешный slim. Telegram-сообщение
   указывает что именно деплоилось ([api web] / [web] / только compose).

ci.yml — actions/cache для NuGet (~/.nuget/packages по hash *.csproj)
и pnpm store (по hash pnpm-lock.yaml). paths-ignore такое же.

POS-job (windows-latest) не трогаем — он и так fires только на
тег v* / workflow_dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:54:57 +05:00
nns 87271281b0 fix(money-input): сохранять промежуточный ввод точки в draft
Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.

Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.

- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
  наружу onChange(number) отдаём сразу когда draft парсится в число
  (включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
  перебивает ввод пользователя.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:44:18 +05:00
nns 7d86b7ed73 fix(money-input): корректное обновление allowFractionalPrices без перелогина
Главная причина бага: useOrgSettings имел staleTime=5 минут. Когда
пользователь переключал галку в /settings/organization, инвалидация
ключа `/api/organization/settings` помечала кэш stale, но MoneyInput
в открытой форме товара продолжал получать старое значение из кэша
до момента, пока сабскрайбер не делал перефетч. На свежем монтировании
(переход с /settings обратно на /catalog/products/...) перефетча тоже
не происходило, т.к. данные считались всё ещё валидными.

Фикс:
- useOrgSettings: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Каждое монтирование компонента — свежие
  настройки одним лёгким GET'ом. Цена нулевая, цикл «изменил настройку
  — открыл форму — увидел эффект» теперь работает без перезагрузки.
- OrganizationSettingsPage: useEffect синхронизирует form со settings.data
  без условия `!form` — при свежем рефетче форма тоже подтягивает актуальное.

Все вызовы MoneyInput в формах уже не передают allowFractional —
компонент сам читает useOrgSettings, что вместе с фиксом выше даёт
корректное поведение.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:39:25 +05:00
nns 8a0f8c20f9 fix(money-input): уважать AllowFractionalPrices в формах редактирования
MoneyInput теперь сам читает useOrgSettings().allowFractionalPrices,
а не только полагается на prop из вызова. Это закрывает два бага:

1. Когда настройка известна и запрещает дробное, но в state товара
   лежит дробная цена (например исторические данные из OtherSystem) —
   useEffect синхронизирует округлённое значение наружу, чтобы
   при сохранении не уходило значение, которого юзер не видит.

2. Пока org.data ещё не загружено, MoneyInput не режет дробь
   (fractional трактуется как true до приезда настройки), иначе
   в момент гидрации формы из ProductDto с дробной ценой компонент
   успевал её обрезать до того как настройка приходила.

Все вызовы MoneyInput в ProductEdit / SupplyEdit / RetailSaleEdit /
ProductsPage filters очищены от избыточного prop allowFractional —
компонент берёт настройку сам. Override через prop остаётся доступным.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:30:00 +05:00
nns 53fa4d2deb feat(barcode-uniqueness): pre-check на Create/Update + warnings импорта + admin endpoint
Pre-check:
- ProductsController.FindBarcodeConflictAsync ищет штрихкоды,
  принадлежащие другим товарам организации; на Create/Update при
  конфликте возвращается 400 «Штрихкод 1234 уже используется
  товаром «Кока-кола 0.5л».» вместо 500 от unique index.

OtherSystem-импорт:
- При попытке привязать уже занятый штрихкод — пишется warning
  «{товар}: штрихкод {код} уже занят, пропущен.» в errors[],
  товар остаётся, дубль не сохраняется.
- В конце импорта проходит финальный SELECT по дубликатам в БД
  (если есть исторические) — warnings типа «Внимание: штрихкод X
  привязан к нескольким товарам — почисти вручную.».

Admin-endpoint:
- GET /api/catalog/products/barcode-duplicates (Admin/Manager)
  возвращает массив { code, products: [{productId, productName,
  article}, ...] } для будущей UI-чистки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:26:20 +05:00
nns d1a7e1e647 ui(products-list): убрать фильтр «Со штрихкодом», добавить «Закупочная цена от/до»
Поскольку штрихкод теперь обязательный (минимум 1 у каждого товара),
фильтр «Со штрихкодом» бессмыслен — убран из UI и контроллера.

Вместо него — два MoneyInput «Закупочная цена от/до» в панели
фильтров. Использует символ валюты по умолчанию из настроек
организации и уважает AllowFractionalPrices.

Backend: ProductsController.List принимает purchasePriceFrom /
purchasePriceTo (decimal?), применяет ≥ / ≤ к PurchasePrice;
параметр hasBarcode удалён.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:23:45 +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 38f7725593 feat(product): группа обязательна, ≥1 штрихкод, умные дефолты на новом
- Product.ProductGroupId теперь NOT NULL (Guid вместо Guid?). Миграция
  Phase5g_RequiredProductGroup делает backfill: создаёт «Продукты
  питания» в каждой организации, у которой есть товары без группы,
  переносит туда null-значения, потом ALTER COLUMN NOT NULL.
- ProductDto/ProductInput: ProductGroupId/Name без `?`.
- ProductsController.Create/Update: 400 если barcodes пустой.
- OtherSystem-импорт: при отсутствии productFolder у товара ставится
  defaultGroupId — id «Продукты питания» (создаётся при необходимости).
- DemoCatalogSeeder: «Продукты питания» добавлена в seed-набор групп.
- ProductEditPage:
  • новый товар сразу получает 1 EAN-13 в barcodes,
  • Single-select Единица измерения и Группа лишились опции «—»,
  • дефолт unitOfMeasureId — id единицы code='796' (штука),
  • дефолт productGroupId — «Продукты питания» (или первая),
  • Save disabled пока имя/единица/группа/≥1 штрихкод не заполнены,
  • если штрихкоды удалены — красная подсказка вместо нейтральной.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:24:10 +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 adf2c90904 chore: remove one-shot pack8-ping workflow
pack8-ping.yml #64 отработал (Telegram уведомление отправлено),
одноразовый workflow больше не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:18:21 +05:00
nns 68ccc9fa1d ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Система теперь корректно работает на узких экранах (<768px):

- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
  (Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
  кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
  min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
  горизонтальный padding на мобиле. Родительский overflow-auto даёт
  плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
  md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
  отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:17:56 +05:00
nns 38f117b2a4 ci(pack8-ping): одноразовый workflow для Telegram-пинга
Триггерится только при изменении самого файла — после выполнения
будет удалён следующим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:11:52 +05:00
nns 6468186ed5 feat(products): авто-генерация числового артикула при создании
При POST /api/catalog/products если Article пустой — сервер берёт
max(Article::int) среди артикулов текущей организации и ставит +1.
Если числовых артикулов нет — «1». Пользователь может указать
артикул руками, тогда используется его значение.

Конфликт по unique index IX_products_OrganizationId_Article на
SaveChanges перехватывается — возвращается 400 с текстом «Артикул
«X» уже занят в этой организации.» вместо 500. Та же обработка
добавлена в Update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:04:57 +05:00
nns 9c70de9b3d ui(products-list): колонки Штрихкод/Фасовка/Закупочная цена
В таблице товаров:
- «Ед.» → «Фасовка» (packagingLabel из типа товара, sort packaging).
- «Штрихкодов» (count) → «Штрихкод» — первый код монoshrift.
- Убраны колонки «Группа» и «Активен».
- Добавлена «Закупочная цена» с форматированием «305.00 ₸»
  (purchasePrice + purchaseCurrencyCode), sort purchasePrice.

На сервере ProductsController.List принимает новые sort keys
packaging и purchasePrice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:03:45 +05:00
nns 9886b5dee1 feat(org-settings): настройка ShowMinMaxStock для мин/макс остатков
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.

Миграция Phase5f_ShowMinMaxStock добавляет колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:53 +05:00
nns 195ca2e2bb ui: barcode регенерация / h-10 поля / прыжок на страницу / min-max скрытие
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды
  под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A
  с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых).
- ProductEditPage: при смене типа штрихкода в dropdown поле кода
  регенерируется под новый формат.
- Field.tsx: единая высота h-10 и leading-none для TextInput/Select
  чтобы Страна/Валюта/НДС в настройках были одного размера.
  TextArea оставлен с h-auto для multiline.
- Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M»
  для прыжка на произвольную страницу (Enter / blur применяют).
- ProductEditPage: блок мин/макс остатков теперь показывается только
  при org.showMinMaxStock (сама настройка добавится следующим коммитом).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:39 +05:00
nns efeeb61e42 refactor(currencies): убрать IsActive и MinorUnit из UI/API
- Currency.IsActive удалён полностью (domain/DTO/API/web/миграция).
  Валюты — глобальный справочник; «архивировать» USD глобально
  бессмысленно, а per-tenant видимости у валют нет.
- MinorUnit остаётся в БД (нужен для форматирования цен), но скрыт
  из UI: убран CurrencyDto.MinorUnit, CurrencyInput.MinorUnit,
  колонка «Знаки» из списка.
- Форма валюты — 3 поля: Код / Название / Символ.
- Миграция Phase5e_DropCurrencyIsActive дропает колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:01:13 +05:00
nns 24dc7fc619 refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).

- Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет
  тип колонки на numeric(5,2)).
- ProductDto/ProductInput: decimal? Vat.
- ResolveDefaultVatAsync, seeders, OtherSystem import — decimal.
- OtherSystem import: если vatEnabled пришёл — уважаем, иначе прежний
  fallback «vat=0 → без НДС».
- UI: вместо жёсткого Select [0,10,12,16,20] — TextInput number step=0.01;
  поле рендерится только когда form.vatEnabled=true; дефолт для нового
  товара подставляется из Country.VatRate организации.
- В таблице товаров ставка печатается с 2 знаками (16.00%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:51:11 +05:00
nns 781f268089 feat(org-settings): галки «Услуга»/«Маркируемый» скрываются по умолчанию
Добавлены organizations.ShowServiceOnProduct и ShowMarkedOnProduct
(оба default false). В UI карточки товара чекбоксы «Услуга» и
«Маркируемый» рендерятся только если соответствующий флаг включен;
в фильтрах списка товаров Tri-фильтры тоже прячутся. В БД поля
IsService/IsMarked у Product сохраняются как обычно — просто UI их
не показывает.

Это параллель к ShowVatEnabledOnProduct: по умолчанию UI максимально
простой, а нишевые фичи включаются через настройки магазина.

Миграция Phase5c_ShowServiceMarkedOnProduct добавляет обе колонки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:06 +05:00
nns 42a3d2aa50 feat(barcode): авто-генерация EAN-13 при добавлении штрихкода
Новый штрихкод в товаре сразу получает валидный EAN-13 с префиксом
"2" (зарезервирован под внутренние штрихкоды магазина, не конфликтует
с GTIN производителей). Пользователь может заменить на реальный
считанный — поле остаётся редактируемым.

Утилита lib/barcode.ts::generateEan13InternalPrefix2() генерирует
11 случайных цифр после "2" и дописывает контрольную сумму EAN-13.

Уникальность штрихкода в организации уже обеспечивает
IX_product_barcodes_OrganizationId_Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:35:40 +05:00
nns 1227fbdfa5 ui(org-settings): симметричный layout — Страна/Валюта/НДС одной сеткой
Все read-only поля (Валюта, Ставка НДС) теперь единого вида: disabled
TextInput на всю ширину ячейки в grid-cols-2 gap-4, та же высота/padding
что и у editable Select "Страна". Единая подсказка про источник правды
страны вынесена одним параграфом под сеткой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:34:44 +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
nns 31d528d5c2 refactor(countries): drop SortOrder, sort by Name, auto-width columns
- Country.SortOrder удалено из домена/DTO/API/seeder/web/UI.
- Миграция Phase5b_DropCountrySortOrder дропает колонку.
- Список стран сортируется по Name ASC.
- В форме: поле «Порядок» убрано.
- В таблице: убрана колонка «Порядок», ширины колонок сжаты по
  содержимому (Код 80px, Валюта 120px, НДС 100px, Название flex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:14:01 +05:00
nns 5bea852b94 feat(org-settings): валюта read-only, тянется из страны (как НДС)
- Currency в настройках больше не выбирается, показывается disabled
  как "KZT (₸)", источник правды — Country.DefaultCurrencyId.
- Backend: OrgSettingsInput больше не принимает DefaultCurrencyId;
  Update синхронизирует Organization.DefaultCurrencyId со страной.
- UX: страна — единственный редактируемый вход, определяет и НДС, и валюту.
- Мульти-валютный режим (Organization.MultiCurrencyEnabled) остаётся
  галкой; выбор валюты в закупках/продажах/карточке товара по-прежнему
  скрыт когда флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:08:30 +05:00
nurdotnet 6979599791 feat(vat): ставка в стране + опц. переопределение на товаре
Phase5_VatAsCountryProperty:
- countries.VatRate (numeric(5,2)) — ставка страны, источник правды.
  Seed: KZ=16, RU=20, BY=20, DE=19, CN=13, TR=18, UZ=12, KG=12, KR=10,
  IT=22, PL=23, US=0.
- organizations.ShowVatEnabledOnProduct (bool, default false) — флаг
  отображения на карточке товара.
- organizations.DefaultVat удалён (заменён страной).
- products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко =
  0%) и фискальный чек требует ставку на каждой позиции.

Country domain: + DefaultCurrency / VatRate (уже было DefaultCurrencyId
из Phase4, сейчас дополнено).

Organization domain: DefaultVat убран, ShowVatEnabledOnProduct добавлен.

Backend:
- ProductInput.Vat теперь int? — если UI скрывает поле и прислал null,
  ProductsController берёт дефолт из страны организации (Country.VatRate
  при создании; при update сохраняет прежнее значение).
- CountriesController.List/Get/Create/Update возвращает/принимает
  DefaultCurrency и VatRate.
- OtherSystem импорт: дефолт Vat загружается из страны организации.
- SystemReferenceSeeder: новые валюты BYN/UZS/KGS/TRY/KRW/PLN, seed
  country-currency-vat для всех 12 стран.
- OrganizationSettingsController: VatRate read-only из страны,
  ShowVatEnabledOnProduct редактируется.

Web:
- Country type + CountriesPage форма редактирования (валюта, ставка НДС).
- OrganizationSettingsPage: "Ставка НДС" read-only
  (берётся из страны, ссылка на /catalog/countries), галочка
  "Указывать ставку НДС на товаре".
- ProductEditPage: блок Ставка НДС % + галка "В том числе НДС" теперь
  показываются только если showVatEnabledOnProduct=true. В payload
  при save.mutate отправляется vat=null если скрыто.
- ProductsPage: колонка НДС показывается только при включённом флаге.

Galleries/products/settings других этапов — не задеты.
2026-04-24 11:56:28 +05:00
nurdotnet e16375ccf6 feat(product-images): загрузка на диск сервера + галерея с лайтбоксом
Backend:
- ProductImagesController: GET list / POST multipart upload /
  DELETE / POST set-main.
- Файлы лежат в $ContentRoot/uploads/products/{productId}/{guid}.{ext}
  (volume /opt/food-market-data/uploads:/app/uploads в compose).
- В БД хранится относительный URL /uploads/products/{id}/{file}.
- UseStaticFiles на /uploads — публичная раздача (без auth).
- Допустимые расширения: jpg/jpeg/png/webp/gif, до 10 МБ.
- При первой загрузке картинка становится основной; Product.ImageUrl
  синхронизируется с "основной".
- Удаление основной переводит "основной" флаг на следующую оставшуюся.

Web-nginx: /uploads/ проксируется на api:8080.

Web UI:
- Компонент <ProductImageGallery>: превьюшки 80×80 в грид,
  при наведении — кнопки "сделать основным" и "удалить",
  клик на превью → fullscreen lightbox с навигацией ←→ и счётчиком.
- В ProductEditPage убран инпут "URL изображения" (был технической
  строкой для копипаста), вместо него блок "Изображения" с галереей.
  Показывается только для уже сохранённого товара (есть id).

Docker compose: добавлен bind-mount /opt/food-market-data/uploads.
2026-04-24 11:12:27 +05:00
nurdotnet d93edcae2c feat(product): enum Packaging (штучный/весовой/разливной) вместо IsWeighed
Миграция Phase4b_ProductPackaging:
  products.IsWeighed (bool) → products.Packaging (int enum)
  1=Piece (default), 2=Weight, 3=Liquid
Backfill: прежние весовые товары → Weight.

Domain/DTO/Input/Controller/Seeder/OtherSystemImport — всё обновлено.

Web:
- Packaging enum в types.ts.
- ProductEditPage: select "Фасовка" вместо checkbox "Весовой".
- Подпись чекбокса НДС уточнена: "НДС применяется (ставка выше)" —
  ссылается на поле Vat на товаре.
- Удалён IsMarked checkbox текст → "Маркируемый (Честный знак / Datamatrix)".
- ProductsPage фильтр: select Packaging вместо Tri(IsWeighed).
2026-04-24 11:08:43 +05:00
nurdotnet 337e790eab feat(org-settings): Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек
Миграция Phase4_CountryCurrencyOrgDefaults:
- countries.DefaultCurrencyId (FK → currencies)
- organizations.DefaultCurrencyId, MultiCurrencyEnabled, DefaultVat
- Seed: KZ→KZT, RU→RUB, BY→BYN, US→USD, DE→EUR, CN→CNY, TR→TRY
- Default для org: KZT, vat=16

Backend:
- Organization сущность получила DefaultCurrency/MultiCurrencyEnabled/DefaultVat.
- OrganizationSettingsController: GET/PUT /api/organization/settings.
- DevDataSeeder при создании/backfill орга выставляет KZT + vat=16.

Web:
- /settings/organization: форма с выбором страны (авто-подтягивает валюту),
  чекбоксом multi-currency, ставкой НДС по умолчанию.
- useOrgSettings() хук.
- SupplyEditPage / RetailSaleEditPage / ProductEditPage: select валюты
  показывается только если multiCurrencyEnabled=true, иначе
  подтягивается DefaultCurrency организации и рисуется символ валюты
  справа от цены.
- ProductEditPage при создании нового товара берёт VAT из org.DefaultVat.
- В sidebar добавлен раздел 'Настройки → Организация', убран
  Ставки НДС (сущность удалена раньше).
2026-04-24 11:03:25 +05:00
nurdotnet a8b3ef40ce ui(products): убрать колонку "Тип" из списка, спрятать min/max stock в "Расширенные"
В списке товаров колонка с бейджами Услуга/Весовой/Маркируемый только
занимает место и ничем не помогает — в UX OtherSystem такого нет, убираю.
В форме редактирования минимальный/максимальный остаток (для
уведомлений о пополнении и автозаказа) — второстепенные поля, ушли в
раскрывающийся блок "Расширенные параметры" сразу после блока закупки.
Закупочная цена осталась основной.

НДС-колонка теперь рисует "—" если VatEnabled=false.
2026-04-24 10:53:32 +05:00
nurdotnet a3b3caa2d3 fix(other-system/test): сделать Token опциональным
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
2026-04-24 00:25:24 +05:00
nurdotnet ce0c3acdd6 feat(other-system-import): async jobs с прогрессом + токен в настройках
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/other-system/import-products и
  /api/admin/cleanup/all/async возвращают jobId, реальная работа
  в Task.Run. GET /api/admin/jobs/{id} — статус +
  Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- OtherSystemImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.OtherSystemToken + миграция
  Phase3_OrganizationOtherSystemToken. Endpoints:
  GET/PUT /api/admin/other-system/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
  для background tasks (HttpContext там нет, а query-filter'у нужен
  orgId — ставим через override).

Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).

Web:
- OtherSystemImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:11 +05:00
nurdotnet 9ce22dee26 fix(other-system/import): per-page retry + чаще SaveChanges
Почему импорт раньше обрывался на ~9500/29500 товаров:
- StreamPagedAsync бросал исключение при любом сетевом глюке или
  таймауте HttpClient (90s) на одной из страниц и весь цикл сыпался.
- Флаш делался раз в 500 товаров, так что при обрыве на 9500-м можно
  было потерять последние 499.

Фиксы:
- Per-page retry до 5 раз с exp-backoff (2,4,8,16с) — обрабатываем
  только сетевые ошибки (HttpRequestException / TaskCanceledException /
  IOException). API-ошибки типа 4xx проходят наверх как есть.
- SaveChangesAsync теперь каждые 100 товаров вместо 500 — меньше
  вероятность потерять при внезапном обрыве на границе.
- При исчерпании retries — бросаем осмысленное исключение с offset'ом.

Пользователь сейчас имеет 9500 из 29509 товаров (группа "Алкоголь" — 20
из 518). Нужно перезапустить импорт в UI с overwriteExisting=true —
существующие товары обновит, недостающие подтянет.
2026-04-23 23:30:37 +05:00
nurdotnet 15ea4b9e86 feat(catalog/products): tree-of-groups + фильтры как в OtherSystem
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
  переключает фильтр ProductsPage. Клик на "Все товары" — показать весь
  каталог. Выбор группы включает её поддерево (матчинг по Path prefix
  на бэкенде, чтобы сабгруппы тоже попадали в выборку).
- Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами
  (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом.
  Счётчик в кнопке показывает количество активных не-дефолтных фильтров.
- "Сбросить" очищает всё, кроме группы.

API:
- ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`.
  `groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной
  группы) — это ближе к UX OtherSystem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:18:50 +05:00
nurdotnet c172cfda5e feat(other-system): import archived entities too (as IsActive=false)
Раньше архивных контрагентов/товаров OtherSystem API по умолчанию не
возвращает (default filter = active only). Для полной синхронизации
OtherSystem → food-market теперь делаем 2 прохода: сначала активных
(default), затем filter=archived=true — отдаём всё одним потоком.

В service убран skip-if-archived; archived записи импортируются
c IsActive=false (уже было в ApplyCounterparty / ApplyProduct;
продублировал для product folders: IsActive = !f.Archived).

Клиент: рефакторинг — один generic StreamPagedAsync<T>(path, archivedOnly)
вместо трёх копий постраничного цикла.

Теперь пользовательский OtherSystem-каталог мапится в food-market 1:1
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
2026-04-23 21:35:44 +05:00
nurdotnet 188114d193 fix(db): reconcile stage schema — drop TrackingType, add IsMarked
Phase2c2_OtherSystemAlignment и Phase2c3_MsStrict остались в
__EFMigrationsHistory на стейдже, но .cs-файлы были удалены при откате
кода (654a8ba). В результате:
- снапшот не соответствовал актуальной БД
- колонка TrackingType висела в БД, а код ждал IsMarked
- /api/admin/other-system/import-products валился с 42703

Эта миграция:
1. Добавляет IsMarked bool NOT NULL DEFAULT false
2. Если TrackingType есть — бэкфиллит IsMarked = (TrackingType <> 0)
   и удаляет колонку (idempotent через information_schema check)
3. Auto-scaffold также синхронизировал snapshot (был устаревшим —
   содержал VatRate/IsAlcohol/Kind/Symbol и пр., которых в коде давно нет).

Локально применилось без ошибок.
2026-04-23 21:23:45 +05:00
nurdotnet 405fe62475 feat(admin): temp cleanup buttons + fix OtherSystem import duplicates
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил Add() новой сущности вместо Update() существующей, порождая
дубликаты. Исправил оба потока — теперь по ключу (Name для контрагентов,
Article для товаров) ищем существующую запись и обновляем её на месте.
Коллекции (цены/штрихкоды товара) при апдейте не трогаем, чтобы не
затереть ручные правки пользователя.

Временные админские кнопки для разбора последствий прошлых импортов:
- DELETE /api/admin/cleanup/counterparties — сносит контрагентов + зависимые поставки + их stock-movements (RetailSale.CustomerId обнуляется, Product.DefaultSupplierId обнуляется)
- DELETE /api/admin/cleanup/all — сносит всё tenant-scoped (товары/группы/контрагенты/поставки/чеки/остатки/движения). Организация, пользователи, справочники (единицы, страны, валюты, типы цен, склады, точки продаж) остаются.
- GET /api/admin/cleanup/stats — превью с количеством записей.

UI: секция «Опасная зона» внизу страницы /admin/import/other-system с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
2026-04-23 20:58:59 +05:00