Commit graph

64 commits

Author SHA1 Message Date
nns 3547e823c3 feat(notifications): задел push под iOS — device_tokens + IPushSender (заглушка)
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
Таблица токенов устройств (device_tokens, Phase40) + POST/DELETE /api/me/device-tokens
для регистрации iOS/Android/Web push-токенов. Контракт IPushSender.SendAsync; в DI
включён StubPushSender (пишет в лог) — реальный APNs/FCM/VAPID подменит заглушку, когда
появятся ключи, без правок вызывающего кода. Описано в docs/mobile-api.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 02:27:05 +05:00
nns 9ba1edb236 feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
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
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным
предупреждением и требованием провести инвентаризацию.

- Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк),
  опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги
  (минус счёта) — по-прежнему запрещены (другое правило).
- NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе
  остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный
  уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер
  документа/автор/время.
- API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»),
  /count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт
  «Остатки» — фильтр negativeOnly.
- Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается,
  крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и
  фильтр «Только минусы».
- Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр);
  full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:18:29 +05:00
nns 0d44cdabaf feat(inventory): модель МойСклада — инвентаризация не двигает остатки
Замечание владельца: инвентаризация стояла «Проведено» без оприходования/списания.
Переделано по МойСкладу (docs/inventory-moysklad-reference.md):

- InventoryStatus Draft→Completed (было Posted; значение 1 то же). Инвентаризация
  остатки НЕ двигает: endpoint complete замораживает строки без движений.
- create-enter (излишки diff>0) / create-loss (недостача diff<0) — создают черновики
  Оприходования/Списания со строками разницы + ссылка InventoryDocId. Повторно нельзя (409).
- Кнопки «Применить результаты» НЕТ (поправка Mac-Claude — не одобрено владельцем).
- unpost («вернуть в черновик») отменяет проведение и удаляет связанные документы.
- post/unpost — временная совместимость для мобильного «FM Склад» (post = завершить +
  создать и провести оба документа); помечено в docs/mobile-api.md.
- Enter/Loss: поле InventoryDocId (+ миграция Phase38) + в DTO «на основании инвентаризации №…».
- InventoryResult (application) — чистая разбивка diff→излишки/недостача (unit-тесты:
  разница/сумма/только посчитанные строки/округление).
- InventoryMigrationController (dry-run/apply): старые Posted+InventoryAdjustment →
  связанные проведённые документы переметкой движений (не задваивает), с проверкой
  неизменности остатков. Скрипт deploy/migrate-inventory-to-docs.sh (backup + запуск).
- Демо-сидеры: инвентаризации теперь Completed без прямых движений.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:34:05 +05:00
nns ac55da8d06 feat(pos): избранные товары (плитки) + переключатель тёмной темы
- Избранное: Product.IsFavorite/FavoriteOrder (миграция Phase37), в POS-синке
  (ProductSyncDto) и в карточке товара (ProductInput). Касса: PosStore хранит
  флаг (+ALTER для старых БД) и GetFavorites(); на экране продажи — сворачиваемая
  сетка плиток «быстрых товаров» (товары без штрихкода: хлеб, овощи на вес),
  тап добавляет в чек. Unit-тест GetFavorites (порядок/фильтр). 44 POS unit зелёные.
- Тёмная тема — опция: кнопка-переключатель в шапке (ToggleTheme), выбор
  сохраняется на устройстве (kv device.theme), применяется при старте. На тех же
  токенах (ThemeDictionaries Light/Dark). По умолчанию — светлая. Кадр «тёмная тема» в галерее.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:35:56 +05:00
nns 6b959ac59d feat(pos): быстрый вход кассира по PIN (grant_type=pin) + lockout
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
- Сервер: Employee.PinHash (PBKDF2, миграция Phase36); POST
  /api/organization/employees/{id}/pin — админ задаёт/снимает PIN (4–6 цифр,
  уникален среди активных сотрудников на пересекающихся точках). Кастомный
  OpenIddict-грант grant_type=pin (retail_point_id + pin): находит кассира,
  привязанного к точке, по PIN и выдаёт токены. Брутфорс-защита — in-memory по
  точке (5 неверных → блок 5 мин). Клиент-сидер досоздаёт gt:pin существующим
  клиентам (без пересоздания). Пароль остаётся запасным.
- Касса: PIN-пад на зарегистрированном устройстве (маскированный ввод, 0–9/⌫/
  Войти), переключение на вход по логину; AuthService.PinLoginAsync.
- Тесты: unit PinHasher (9), integration (3): вход по PIN выдаёт рабочий токен;
  5 неверных → блок; дубль PIN на точке → 409. + скриншот PIN-пада в галерее.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:38:36 +05:00
nns 2a4ba14d38 feat(auth): эффективные права пользователя в /api/me (единый источник)
- RolePermissions.Effective(role, isSuperAdmin, isAdmin, advancedMode) — флаги с
  учётом роли и простого/расширенного режима; AdvancedOnly — единый список
  advanced-only прав (зеркалит [RequiresAdvancedMode]).
- /api/me теперь отдаёт permissions (имя→bool). Мобильное и веб скрывают одно и то же.
- Веб: хук useCan() читает me.permissions (раньше гейтил только по именам ролей).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:37 +05:00
nns 5173eaa07c feat(catalog): штрихкоды товара — добавление + весовые этикетки
- POST /api/catalog/products/{id}/barcodes {code, kind}: добавить штрихкод к
  товару, уникальность в организации, 409 «штрихкод уже у товара X» (+название),
  идемпотентно при повторе тому же товару, автоопределение типа по длине.
- Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе): общий парсер
  food-market.shared/Barcodes/WeightedBarcode (настраиваемые префиксы веса/цены,
  длина PLU); касса pos.core делегирует туда. Настройки организации
  (WeightBarcode*: enabled/префиксы/длина кода) + миграция Phase35.
- GET /api/catalog/products/by-barcode/{value} разбирает весовую этикетку →
  товар + количество (вес) / встроенная цена; обычный ШК — как прежде.
- GET/PUT /api/organization/settings отдаёт/принимает настройки (блок весовых
  применяется только если передан — round-trip-safe для текущей веб-формы).
- Офлайн-каталог ProductSyncDto: явный isWeighed (packaging==2) для мобильного/кассы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:20 +05:00
nns 1e6d7fb2ab fix(money): движения смены попадают в остатки счетов + запрет отрицательного остатка
Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток
кассы (касса уходила в −, эквайринг оставался 0). Теперь при закрытии смены
MoneyService.MaterializeShiftMovementsAsync создаёт Posted-движения money_transactions
по типам (Category): продажи нал/карта, возвраты нал/карта, внесение, выплата,
излишек/недостача — привязанные к смене, идемпотентно. Остаток кассы/эквайринга и
отчёт «Деньги» теперь верны автоматически (всё читает money_transactions).

Отрицательный остаток: расход (CashOut/PaymentOut/TransferOut) больше остатка счёта
и выплата из смены больше наличных смены → 409 insufficient-funds; настройка
Organization.AllowNegativeBalance (по умолч. выкл., меняет только Admin).

Миграция Phase34 (idempotent): money_transactions.Category/.CashShiftId,
organizations.AllowNegativeBalance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:11:09 +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 864d492c93 feat(s32): MoySklad-импорт — upsert по external id, merge по barcode, skip archived, UOM-mapping
Для полной заливки каталога МойСклада в «Демо-магазин» на стейдже
переделал импорт по требованию «идемпотентный + без дублей».

Схема:
- Phase32a_MoySkladExternalId: VARCHAR(64) на products и product_groups
  + partial-unique index (OrgId, MoySkladExternalId) WHERE IS NOT NULL.
  NULL разрешён — товары, созданные вручную, без MS id.

Клиент (MoySkladClient):
- Auto-detect Basic vs Bearer: МойСклад принимает оба, но сторонние
  проекты (sklad на prod-vm) используют Base64(login:password). Строку
  распознаём как base64 с ':' в декоде → ставим Basic, иначе Bearer.
- StreamProductsAsync принимает includeArchived (default true для
  back-compat; импортный путь передаёт false).
- GetAllUomsAsync — грузит справочник единиц измерения один раз.

Сервис (MoySkladImportService):
- MoySkladImportOptions: OverwriteExisting / SkipArchived / MergeByBarcode.
- Resolution order: MS id → barcode → article. Первый найденный
  апдейтится, не плодит дубль.
- Группы — upsert по MS id; pre-существующие группы без MS id с тем же
  именем в том же родителе — подхватываются (MS id проставляется).
- Товары из архива MoySklad (archived=true) полностью пропускаются
  (счётчик SkippedArchivedInSource в результате).
- Barcode-дубли внутри MoySklad: первый встреченный выигрывает, остальные
  пропускаются с записью в Errors (счётчик SkippedDuplicateBarcodeInSource).
- UOM: MS uom.id → MS uom.code → наш локальный Code. Весовые товары
  (weighed=true) всегда ставятся в «кг».
- Расширенный MoySkladImportResult: Created / MergedByBarcode /
  SkippedArchivedInSource / SkippedDuplicateBarcodeInSource / GroupsUpdated.

Контроллер (MoySkladImportController):
- ImportRequest получает SkipArchived (default true), MergeByBarcode
  (default true). Токен по-прежнему принимается в body и НЕ сохраняется
  в Organizations.MoySkladToken, если передан в теле запроса.
- Финальный Message прогресса собирает все счётчики.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 23:22:14 +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 4342318c2c feat(s29): Organization.IsAdvancedMode + миграция Phase29a (шаг 1/8)
Simple/Advanced mode: по умолчанию у всех организаций «простой» режим
(IsAdvancedMode=false) — виден только базовый розничный учёт. Включить
«расширенный» режим может только SuperAdmin через отдельный API; в
настройках магазина переключателя нет.

Данные скрытых модулей при переключении НЕ удаляются; при повторном
включении всё снова видно.

- Organization.IsAdvancedMode (bool, default false) + XML-док
- EF-миграция Phase29a — идемпотентный ADD COLUMN IF NOT EXISTS DEFAULT false
- AppDbContextModelSnapshot обновлён
- Применено на dev-БД: 5/5 организаций → IsAdvancedMode=false

docs/simple-mode-progress.md — чек-лист + журнал.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:28:28 +05:00
nns aa83f82dc5 feat(s22): data tooling — export/import + schema docs + anon dump (7 пунктов)
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
1. GDPR org export — domain OrgExport + Phase22a миграция, OrgExportJob
   собирает ZIP с JSON по каждой сущности через IObjectStorage,
   DownloadToken 64-hex + 24h TTL + email-notify.
   POST /api/org/export, GET /api/org/export[/{id}], GET download/{token}.

2. 1C CSV import — POST /api/catalog/products/import/1c-csv:
   Windows-1251/UTF-8 BOM auto-detect, разделитель ;/, русские заголовки
   (Артикул/Наименование/Единица/Цена/Группа/Штрихкод) или английские.
   Нормализация unit-кодов (шт/кг/г/л/мл/упак). Делегирует на ImportCsv
   (транзакция, multi-tenant). docs/imports.md.

3. deploy/anonymize-prod.sh — pg_dump прода → restore во временную БД →
   UPDATE PII (email→user{N}@example.kz, phone→+7700111{N:04}, password→
   тестовый hash, BIN/IIN синтетические, MoySkladToken=NULL, аудиты
   TRUNCATE) → pg_dump → gz файл.

4. DbSchemaDocsJob (weekly вс 05:00 UTC) — information_schema → md с
   таблицами + колонками + FK + mermaid ER-диаграммой (топ-20 таблиц).
   Сохраняет в content-root db-schema-generated.md.

5. POST /api/admin/audit-log/export?format=csv|jsonl — streaming через
   AsAsyncEnumerable. UTF-8 BOM для CSV, JSONL для grep'a. Multi-tenant.

6. GET /api/moysklad/sync-status — агрегат по import_jobs:
   { configured, lastSuccessAt, errorCountLast7Days, pendingCount,
     byKind: { products: KindStatus, counterparties: KindStatus } }.
   Stub если MoySkladToken=null.

7. docs/ARCHITECTURE.md — финальный итог 22 спринтов:
   - Sprint 13-22 changes-сводка
   - «Реализовано полностью» секция
   - «Scaffolding» таблица с указанием что нужно от user'а
   - «Не реализовано» секция (прод, SSO callback, KZ-перевод, POS-тест)
   - Актуальная файловая структура

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:00:54 +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 0d3ef81f72 feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.

Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
  FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
  IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
  • MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
    по Sale.Id (используется dev/stage и интеграционными тестами);
  • WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
    JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
  • Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
    RegisterAsync бросает FiscalNotConfiguredException (нужны
    спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
  Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
  NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
  ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
  и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
  stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
  остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
  (опции для select'а) + POST /test-send (фейк-чек к выбранному
  провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
  для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
  в GET — только has-* флаги), спец-значение "__clear__" для снятия,
  кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
  (Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
  не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
  Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
  кредов, retry-сценариями.

Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:27:17 +05:00
nns 91128a7ed0 feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
  MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
  Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
  Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
  PromotionId, PromotionCode (snapshot), PromotionDiscount.

EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
  списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
  в All() для Admin).

API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
  при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
  (POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
  input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
  • Lookup карты, проверка active/blocked/MinSubtotal.
  • Расчёт скидки или баллов в зависимости от Type.
  • Lookup промокода, проверка периода/MinSaleAmount/scope.
  • MatchingSubtotal для Scope=ProductGroups/Products считаем по
    input.Lines (sale.Lines ещё пустой в этот момент).
  • Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
    - PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
  транзакции, чтобы rollback не оставил orphan баллы).

UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
  counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
  (Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.

Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
  пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
  код→400 с понятной message и field=promotionCode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:06:10 +05:00
nns 3088237ea7 feat(telegram): OwnerDailySummaryJob + bot binding (P2-14)
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
Backend:
- Organization.OwnerTelegramChatId (long?) — миграция Phase9a.
- TelegramOptions / TelegramBotClient (Telegram Bot API sendMessage).
  Disabled-mode когда токен пустой (Dev/CI). HTML parse_mode.
- OwnerDailySummaryJob.RunAsync — пробегает по org с привязанным chatId,
  рендерит сводку (выручка вчера, чеков, средний чек, топ-3 по выручке,
  low-stock 5 строк) и шлёт. Best-effort на каждой org.
  RenderSummaryAsync — publicный для тестов.
- HangfireJobsConfigurator: cron "0 6 * * *" UTC = 09:00 МСК.
- TelegramBindingController: GET /status (botEnabled, username, chatId,
  deepLink), PUT /bind (тестовое сообщение → проверка chatId → save),
  DELETE (unbind).

Конфиг:
- Telegram:BotToken — env Telegram__BotToken.
- Telegram:BotUsername — для deep-link.

UI:
- OrganizationSettings.TelegramSection: показывает статус (bot enabled?
  bound?), deep-link к боту, пошаговая инструкция (start → userinfobot →
  ввести chat_id → проверка). Toast на привязку/отвязку через
  meta.successMessage.

Тесты:
- TelegramOwnerSummaryTests: рендер содержит org_name, метрики, HTML. ✓ 1/1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:50:33 +05:00
nns ec0cff7fc4 feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.

Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
  Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
  property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
  не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
  EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.

Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
  shadow xmin загруженного supply через EF.Entry().Property("xmin").
  Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
  legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
  защита: и явная сверка, и EF auto-check в SaveChanges).

Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).

Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
  с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:33:01 +05:00
nns b963adfa2e feat(import-jobs): persisted ImportJobRegistry в БД (TD-5)
Раньше прогресс фоновых импортов жил в ConcurrentDictionary внутри
Singleton-сервиса: рестарт процесса терял всю историю, активные
джобы навсегда оставались в статусе Running.

Теперь:
- Domain.Integrations.ImportJob (TenantEntity) — таблица import_jobs,
  миграция Phase8c_ImportJobs (jsonb для ErrorsJson, индексы по
  OrgId+StartedAt / OrgId+Status / FinishedAt).
- ImportJobRegistry рефакторен: Create() пишет строку немедленно,
  SaveAsync() обновляет, Get/RecentlyFinished читают из БД. API
  совместимое со старой in-memory версией — MoySkladImportService
  и контроллеры не меняются.
- MoySkladImportController.RunInBackgroundAsync теперь:
  * Periodic flush через Timer каждые 2 секунды — UI видит
    реальный progress (Stage/Created/Total), а не Create-snapshot;
  * Финальный flush в finally — обязательный для terminal state.
- AdminCleanupController.WipeAllAsync — то же финальное сохранение.
- SkipAudit=true для import-job записей — служебные, в OrgAuditLog
  не пишем.

Tenant-isolation: query-filter работает прозрачно, B не видит джоб A.

Тесты: 3 интеграционных (survives across scope, RecentlyFinished
читает из БД, tenant-isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:45:08 +05:00
nns a189a5dd6e feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)
Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).

Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.

ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.

Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.

Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.

Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).

Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:26:36 +05:00
nns 47a019dc6d feat(demands): оптовая отгрузка контрагенту-юрлицу (P1-5)
Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.

EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.

ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).

Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.

Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.

Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:18:49 +05:00
nns 640c8d9c22 feat(pos-api): GET /sync и POST /sales с двойной идемпотентностью (P1-12b)
Endpoints:
- GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений
  (Products / Prices / Stocks / Counterparties) после reference time;
  Stocks - всегда полный снимок на момент ответа (POS нужен актуальный
  остаток на полке).
- POST /api/pos/v1/sales - батч продаж с idempotency.

Двойная идемпотентность:
1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) -
   повтор того же батча возвращает кешированный ответ. При параллельном
   race ловим 23505 на уникальном индексе и тоже возвращаем кеш.
2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix
   "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё
   не встречался - если есть, возвращаем существующую продажу. Это
   спасает и при разных batch-ключах с пересекающимися ClientSaleId.

Pre-flight: проверка остатка ДО создания черновика - sale, которая не
влезает в полку, попадает в Failed, остальные в батче проводятся.

Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks
(jsonb для ResponseJson, unique idx).

Контракты v1 из food-market.shared.

Тесты: 7 интеграционных - полная sync, дельта по since, POST батч
списывает stock, replay того же батча no-duplicates, ClientSaleId через
разные batch-keys тоже no-duplicates, недостача попадает в Failed,
tenant-изоляция.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:18:13 +05:00
nns 319a91ff10 feat(bootstrap): системная ProductGroup «Все товары» при создании org
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.

Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
  системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
  org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
  и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
  (400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
  осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
  автоматически выбирают «Все товары» (или единственную группу), чтобы
  пользователь мог сохранить продукт без лишнего клика.
2026-05-08 12:08:28 +05:00
nns bf53629092 refactor(units): drop Description, hide Code from non-SuperAdmin UI
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m24s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m33s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.

Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
  Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
  и форме редактирования.

Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
2026-05-08 11:02:10 +05:00
nns 493ed33fd0 phase5c: единицы измерения — глобальный справочник + junction для орг
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 59s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m26s
Docker Web / Build + push Web (push) Successful in 35s
Docker API / Deploy API on stage (push) Failing after 38s
Docker Web / Deploy Web on stage (push) Successful in 12s
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
в БД на 19 орг — duplication, любой Admin мог их редактировать.
Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга
включает нужные единицы у себя через junction org_units_of_measure.

Backend:
- UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index)
- Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict)
- Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod —
  поднимает по одной строке на (Code, Name) до global, remap'ит
  products.UnitOfMeasureId, наполняет junction по факту существующих
  привязок, удаляет дубликаты.
- /api/catalog/units-of-measure для org Admin: read-only список
  enabled-globals + POST/DELETE /enable для toggle
- /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false)
  с 409 если есть products или active org-junction (со списком орг)
- DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant
  юнитов — auto-enable всех active globals через junction

Frontend:
- /catalog/units — checkbox-список (включить/выключить); CTA на платформу
  для SuperAdmin
- /super-admin/units — full CRUD над глобалами, 409 со списком
  организаций при попытке деактивировать используемую единицу
2026-05-08 01:21:20 +05:00
nns 1456f170eb feat(platform): PlatformSettings entity + миграция (singleton SMTP-конфиг)
Платформенные настройки: один row, не tenant-scoped, видны и меняются
только Супер-администратором. Хранят SMTP-креды для исходящей почты
(forgot-password, нотификации). IMAP к этому отношения не имеет — IMAP
для чтения входящей, нам нужен SMTP.

Поля:
- SmtpHost, SmtpPort
- SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587, по дефолту true)
- SmtpUsername, SmtpPasswordEncrypted (хранится зашифрованным через
  DataProtection API; в API-ответах не выходит, только has-password флаг)
- FromEmail, FromName

Миграция Phase5b_PlatformSettings создаёт таблицу public.platform_settings.
Конфиг EF в AppDbContext: HasMaxLength для строк.
2026-05-06 12:35:48 +05:00
nns e8a28ba1f6 feat(employees): двухступенчатое удаление — «уволить» → «удалить»
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:

  IsActive=true                                  → активный
  IsActive=false + FiredAt                       → уволен (кнопка «Уволить»)
  IsActive=false + IsDeleted=true + DeletedAt    → удалён (кнопка «Удалить»)

— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
  Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
  · если активен — переводит в Fired;
  · если уже уволен — ставит IsDeleted=true + DeletedAt;
  · если уже удалён — 409 Conflict;
  · гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
  active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
  фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
  · фильтр статуса в actions: «Активные и уволенные» (default),
    «Только активные», «Только уволенные», «Только удалённые»,
    «Все, включая удалённых».
  · колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
  · ФИО уволенного помечается «(уволен)», удалённого — line-through
    + «(удалён)».
  · кнопка-действие в модалке: «Уволить» если active, «Удалить» если
    fired, скрыта если уже deleted (заменена на pojaснение).
  · confirm-текст обоих шагов разный — юзер понимает что произойдёт.

Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
2026-05-06 11:32:07 +05:00
nns dcb28a9811 feat(localization): убрать «ИНН» из UI — РК использует ИИН/БИН
ИНН — российское поле. В Казахстане физлица используют ИИН (12 цифр),
юрлица — БИН (12 цифр). Колонок «inn» в БД у нас нет (есть Bin для
юрлица + TaxNumber для ИИН физлица), миграция drop column не нужна —
только лейблы и комментарии.

Поправлено:
- EmployeesPage: «ИИН/ИНН» → «ИИН» (12 цифр, inputMode=numeric).
- CounterpartiesPage: убрано поле «ИНН / другой» — оставлены БИН и ИИН
  с правильными ограничениями (12 цифр, numeric).
- SuperAdminOrgCreatePage / SuperAdminSetupPage: «БИН/ИНН» → «БИН».
- MoySkladImportPage: «(ИНН ...)» в ответе test → «(идентификатор ...)».
- Domain comments в Employee.cs / Counterparty.cs обновлены.

Внутренний MoySklad-DTO `Inn` оставлен — это входящий JSON из российского
API МойСклад, поле там действительно так называется. Маппится в Bin
при импорте контрагента (как и было).
2026-05-06 11:31:42 +05:00
nns fc3f63c49a feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:59:24 +05:00
nns 5c5b231157 feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.

Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
  в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
  Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
  IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
  всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
  override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
  если SuperAdmin без override (системная), иначе подставляется
  tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
  OrganizationId DROP NOT NULL на product_groups и units_of_measure.
  Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
  tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
  OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
  если запись OrganizationId=null и юзер не SuperAdmin (только
  суперадмин может править/удалять системные).

Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
  страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
  и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
  реиспользуют существующие компоненты — для SuperAdmin без override
  фильтр возвращает все записи, что в Phase 4+ можно ужесточить
  отдельным эндпоинтом «только системные» (?orgId=null).

Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:20:47 +05:00
nns e93634fad4 feat(domain): Organization.IsArchived/AccountOwner + SuperAdminAuditLog + migration
Базовый domain-каркас для SuperAdmin console (Phase 1):

Organization:
- IsArchived bool + ArchivedAt DateTime? — архивная орга не видна
  юзерам, но данные сохраняются. Удалить навсегда можно только из
  архива >30 дней (логика в API на следующем коммите).
- AccountOwnerUserId Guid? — главный владелец, не путать с админами
  per-org. SuperAdmin может сменить через action c reason в audit-log.
- HasIndex(IsArchived) для быстрой фильтрации.

SuperAdminAuditLog (новая таблица super_admin_audit_log):
- Не tenant-scoped — лог общий по всей системе.
- ActionType (CreateOrg/EditOrg/ArchiveOrg/RestoreOrg/DeleteOrg/
  ChangeOwner/EditEntity), OrganizationId, EntityType+EntityId,
  Description, Reason, ChangesJson (jsonb), IpAddress.
- Индексы: CreatedAt, (SuperAdminUserId, CreatedAt),
  (OrganizationId, CreatedAt) — типовые запросы фильтра.

Migration Phase4_SuperAdminConsole добавляет 3 колонки в organizations
+ создаёт super_admin_audit_log с тремя композитными индексами.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:51:25 +05:00
nns bf6880fe0f feat(employee): add Salary, TaxNumber, Description, ImageUrl + radio role picker
Domain Employee расширен 4 nullable-полями (по образу сторонняя система):
- Salary numeric(18,2) — оклад в валюте организации
- TaxNumber varchar(20) — ИИН/ИНН
- Description varchar(2000) — комментарий HR'а
- ImageUrl varchar(500) — аватар (на будущее: загрузка через images endpoint
  как у товаров; пока поле для прямой ссылки)

Migration Phase4c_EmployeeExtraFields добавляет 4 nullable колонки
(существующие записи не ломаются). EF config + snapshot обновлены.

API EmployeesController: DTO/Input/Create/Update пробрасывают новые
поля сквозь.

Frontend EmployeesPage:
- Поля «Оклад» и «ИИН/ИНН» рядом, ниже — «Описание» textarea.
- Селект роли заменён на radio-список с описанием каждой роли
  (системные сначала, затем кастомные). Под радио — ссылка
  «Настроить права ролей →» на /settings/employee-roles. Это
  по образу МС — пользователь сразу видит за что отвечает каждая
  роль и куда идти если нужно подкрутить.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:27 +05:00
nns 372ca9ec16 feat(roles): permissions matrix grouped by section + clone-from-template flow
RolePermissions расширен с 21 до 32 флагов: добавлены UnitsManage,
DemandsView/Edit/Post (отгрузка контрагенту, не путать с RetailSales),
CounterpartiesDelete, InventoryEdit, LossEdit, EnterEdit (склад-операции),
ReportsFinanceView, ReportsStockView (тонкие отчётные права),
CashRegistersManage и IntegrationsManage (отдельно от OrgSettingsManage).

UI EmployeeRolesPage:
- 7 групп вместо 6: Каталог / Закупки / Продажи / Контрагенты /
  Склад-Остатки / Отчёты / Настройки. Все секции аккордеоном внутри
  модалки (как было — flex-col), но с правильным грануляр-списком.
- Системные роли — чекбоксы disabled (только просмотр; имя/описание
  редактируются).
- При [+ Добавить роль] — сначала открывается модалка выбора шаблона:
  Пустой / Копия Администратора / Копия любой существующей. Дальше
  открывается основная модалка с предзаполненной матрицей.

allPerms() помощник на фронте — зеркало RolePermissions.All() с бэка,
для шаблона «Копия Администратора» в clone-flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:44:53 +05:00
nns f38d34f42d feat(domain): Employee, EmployeeRole, RolePermissions entities + migration
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):

Domain:
- Employee — сотрудник организации (UserId nullable: запись может
  существовать без логина), ФИО + Position + Email/Phone + Role + IsActive
  + FiredAt + RetailPointAssignments.
- EmployeeRole — роль с IsSystem флагом и owned RolePermissions.
- RolePermissions — 21 булев флаг по группам (Каталог/Закупки/Продажи/
  Контрагенты/Отчёты/Настройки) + helper All() для админа.
- EmployeeRetailPointAssignment — ассоциация сотрудника с RetailPoint
  (для роли Кассир — к каким кассам привязан).

Infrastructure:
- OrganizationsHrConfigurations с OwnsOne(...).ToJson("permissions")
  для permissions — JSONB-колонка вместо отдельной таблицы.
- DbSet<EmployeeRole/Employee/EmployeeRetailPointAssignment>.
- Уникальные индексы: (OrgId, RoleName), (OrgId, UserId) с filter
  WHERE UserId IS NOT NULL, (EmployeeId, RetailPointId).

Migration Phase4_EmployeesAndRoles создаёт три таблицы. Сидер
системных ролей и привязка существующего admin'а к Employee —
следующим коммитом, контроллеры и UI — далее.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:00:30 +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 eaf5b7399b feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
- Удаление поля «Срок годности (дней)»:
  • Domain.Product.ShelfLifeDays убран,
  • миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
  • DTO/Input/UI/фильтр в списке товаров — выпилены.

- Перекомпоновка секции «Классификация» в карточке товара:
  • ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
  • ряд 2 (2 col): Основной поставщик | Страна происхождения,
  • Страна происхождения видна только если включена настройка
    Organization.ShowCountryOfOriginOnProduct (default false).
  • Та же миграция добавляет колонку, в OrganizationSettingsPage
    появляется галка «Показывать «Страну происхождения» на товаре»
    с подсказкой про импорт.

- Артикул теперь обязательное поле с авто-генерацией:
  • ProductEditPage: метка «Артикул *», required,
  • генератор generateArticle() (timestamp[-6] + 3 random) — у нового
    товара поле сразу заполнено,
  • canSave требует непустой article. Уникальность подтверждает
    сервер (он также имеет свой fallback-генератор max+1).

- Иконка корзины в секции «Штрихкоды» рендерится только при
  form.barcodes.length > 1 — для единственной строки удаления нет
  (минимум 1 штрихкод обязателен, удалять единственный нельзя).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:05 +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 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 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 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 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