Commit graph

58 commits

Author SHA1 Message Date
nns a06464baeb fix(migrations): чиним P0-блокеры разворачивания на чистой БД
Проблема: на свежей PostgreSQL `dotnet ef database update` падает на пяти
миграциях подряд + рантайм-несовместимость схемы с domain Product/Store/
Counterparty. Невозможно поднять стек ни на dev, ни на новом стейдже.

Найдено и починено:

1. Phase2c4_ReconcileStage пыталась AddColumn IsMarked, который Phase1Catalog
   (после рефакторинга) уже добавляет. Завернули в IF NOT EXISTS.

2. Phase5d_ProductVatDecimal ALTER COLUMN products.Vat падал — Vat теперь
   заменён на FK VatRateId, колонки нет. Завернули в IF EXISTS.

3. Phase5c_UnitsOfMeasureGlobal INSERT канонических ОКЕИ пропускал NOT NULL
   колонку Symbol (а также DecimalPlaces, IsBase, CreatedAt). Дополнили
   полным набором: шт/кг/л/м/уп.

4. Phase5d_DropUnitOfMeasureDescription дропала несуществующую колонку
   (Description в новой схеме отсутствует). Завернули в IF EXISTS.

5. Phase5a_EmployeeSoftDelete и Phase5b_PlatformSettings были написаны
   вручную без атрибутов [Migration] + [DbContext] — EF их игнорировал
   и пропускал применение (см. memory/feedback_ef_migrations.md).
   Добавили атрибуты + сделали идемпотентными.

6. Новая Phase5f_DropStoreKindRudiment: rudimentные колонки stores.Kind и
   counterparties.Kind (NOT NULL без default'а) роняли любой INSERT —
   ни одной организации/контрагента создать нельзя. Дропаем.

7. Новая Phase5g_ProductVatRealign: приводим products в соответствие с
   domain — дропаем FK→vat_rates + колонку VatRateId + IsAlcohol + пустую
   таблицу vat_rates; добавляем products.Vat numeric(5,2) DEFAULT 12 и
   VatEnabled bool DEFAULT true. Без этого ProductsController падает 42703
   при создании любого товара.

Все миграции идемпотентны (DO $$ ... IF EXISTS/NOT EXISTS ...) — повторное
применение на старой стейдж-БД безопасно.

Проверено: E2E full-cycle на свежей dev-БД проходит 12/12 шагов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:13:19 +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 ee127b2785 fix(migrations): добавить [Migration] атрибут для Phase5c — без него Migrate() не находит миграцию
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 55s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m17s
Docker API / Deploy API on stage (push) Successful in 18s
stage api зашёл в crash-loop после деплоя phase5c: DevDataSeeder упал
с «column IsActive does not exist», потому что миграция Phase5c не
была подхвачена db.Database.Migrate(). EF Core ищет миграции по
[MigrationAttribute] на классе (или Designer-файле, который этот
атрибут содержит). Без него миграция в сборке есть, но не известна
runtime-механизму.

Также чиню e2e: URL единиц был /api/catalog/units (404), правильный —
/api/catalog/units-of-measure.
2026-05-08 01:29:51 +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 76e956ea6c feat(platform): IEmailSender + MailKit + PlatformSettingsController
Пункт 2 + 3 пакета SMTP-настроек.

Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
  одного письма; EmailNotConfiguredException — для контроллеров чтобы
  ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
  · регистрируется Singleton, на каждой отправке открывает scope для
    свежего AppDbContext (конфиг перечитывается без рестарта);
  · читает PlatformSettings из БД, расшифровывает пароль через
    IDataProtector("foodmarket.smtp");
  · поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
    оба false → открытое соединение (для dev/MailHog);
  · бросает EmailNotConfiguredException если host или from-email пусты,
    или если расшифровка пароля падает (ключ DataProtection ротировался).

API:
- PlatformSettingsController:
  GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
    (только has-password флаг + updatedAt).
  PUT — принимает Reason (≥10) + все поля + опциональный
    NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
    Пароль шифруется через DataProtection при записи. Audit-log.
  POST /test-send — реальная отправка через текущие настройки;
    ловит EmailNotConfiguredException → 400, остальные → 500
    с message (SuperAdmin-only, diagnostic-info нужна).

DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).

Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
  был через OpenIddict, но Infrastructure нужен явный reference).
2026-05-06 12:39:18 +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 b073e99ca7 feat(roles): три системные роли — Admin/Cashier/Storekeeper
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.

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

Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
2026-05-06 11:31:15 +05:00
nns 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 79016579c2 fix(migration): Phase4d таблица называется units_of_measure, не units
Опечатка: в Up() миграции стояло table:\"units\" — реальное имя в БД
units_of_measure (по EF snapshot). API падал при старте на стенде:
«relation public.units does not exist», 502 на login.

На стенде SQL ALTER COLUMN накачен вручную + запись в
__EFMigrationsHistory; миграция fix только для будущих чистых баз.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:32:52 +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 61cb97a1b7 fix(tenancy): SuperAdmin override должен применять tenant filter выбранной орги
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. SuperAdmin в режиме «открыть как Demo Market»
видел товары FOOD MARKET (29540 чужих записей вместо 0 своих).

Корень проблемы — query-filter в AppDbContext:

  e => _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId

IsSuperAdmin → весь предикат становится true → все записи всех орг.
В режиме override OrganizationId уже корректно подменялся на
выбранную орг, НО bypass через IsSuperAdmin делал подмену
бессмысленной — фильтр всё равно пропускал всё.

Фикс — добавил IsTenantOverride флаг в ITenantContext и переписал:

  e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
       || e.OrganizationId == _tenant.OrganizationId

То есть SuperAdmin обходит фильтр ТОЛЬКО когда не в override. В
override-режиме он работает в контексте выбранной орги как обычный
юзер — фильтр применяется.

HttpContextTenantContext.IsTenantOverride возвращает true когда
текущий запрос — HTTP, юзер в роли SuperAdmin и присутствует header
X-Org-Override с валидным GUID. AsyncLocal-override (background-задачи
импорта/Hangfire) намеренно НЕ считается tenant-override — там
IsSuper=false по умолчанию и фильтр и так применяется.

Smoke-test ДО фикса (воспроизведение):
  GET /products X-Org-Override=DemoId   → total 29540 (баг: чужие)
  GET /products X-Org-Override=FoodId   → total 29540
  GET /products без header              → total 29540 (legit super)

После деплоя ожидается:
  GET /products X-Org-Override=DemoId   → 0 (Demo Market пуст)
  GET /products X-Org-Override=FoodId   → 29540 (своих)
  GET /products без header              → 29540 (legit super bypass)

Затронуты ВСЕ tenant-сущности (фильтр применяется через reflection
ко всем ITenantEntity): products, counterparties, supplies, stocks,
movements, retail-sales и т.д.

DesignTimeTenantContext получил IsTenantOverride=false (он только для
EF tooling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:55:04 +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 1d9fd7297c fix(roles): keep only Admin + Cashier as system, demote others to custom + migration
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:

- Администратор (IsSystem=true) — RolePermissions.All().
- Кассир (IsSystem=true) — POS-only набор:
  ProductsView + StocksView + RetailSalesOperate. Без RetailSalesRefund
  (админ включит при необходимости). Это маркер для будущего POS-app —
  не имеет доступа к веб-админке.
- Менеджер / Кладовщик / Закупщик / Бухгалтер — IsSystem=false
  (кастомные). Можно удалить если не нужны или подкрутить под себя.

Сидер на чистой БД сразу создаёт роли в правильных статусах. Для
существующих установок миграция Phase4b_RolesSimplify идемпотентно
делает UPDATE: демоутит лишние и приводит permissions Кассира к
правильному набору. Down() — no-op (юзер мог переименовать).

На стенде sql применил вручную + записал в __EFMigrationsHistory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:41:13 +05:00
nns 5737c65215 fix(migrations): drop Employee.Navigation(RetailPointAssignments) to fix snapshot order
В snapshot/Designer я вручную добавил b.Navigation(\"RetailPointAssignments\")
в блоке Employee — но эта обратная навигация регистрируется через
WithMany(\"RetailPointAssignments\") у EmployeeRetailPointAssignment.HasOne(...),
который выполняется ПОЗЖЕ. Из-за этого BuildTargetModel падал с
«Navigation … was not found», и API на стенде не мог применить миграцию.

Убрал лишнюю строку в обоих местах. Свойство Employee.RetailPointAssignments
никуда не делось — обратную навигацию EF создаёт автоматически из конфига
EmployeeRetailPointAssignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:11:13 +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 72e602f4ca fix(migrations): catch-up Phase3b_AddShowDescriptionOnProduct
Колонку organizations.ShowDescriptionOnProduct логически вводила миграция
Phase3b_DropProductShelfLifeDays, но я дописал её туда задним числом
после того как миграция уже применилась на стенде. EF проверяет только
__EFMigrationsHistory.MigrationId, не содержимое — поэтому колонка
не создалась, а после деплоя API падал в DevDataSeeder с
«column o.ShowDescriptionOnProduct does not exist».

Правильное лекарство — отдельная догоняющая миграция с idempotent
ALTER TABLE … ADD COLUMN IF NOT EXISTS. На стенде колонка уже добавлена
вручную и запись в истории миграций есть; пайплайн пройдёт мимо неё.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:10:36 +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 db3be5bbca fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort
Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.

Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная):
- Снимает IsSystem со всех записей.
- Помечает IsSystem=true + IsRequired=true тому PriceType, у которого
  максимум связанных ProductPrice (приоритет — фактически
  использующейся цене); при равенстве — самая старая (CreatedAt ASC).
- Если у организации вообще нет PriceType — создаёт «Розничная цена»
  (IsSystem=true, IsRequired=true).

DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены
IsSystem=true / IsRequired=true; работает только если у организации
ноль PriceType — больше не шлёпает дубль.

API валидация (ProductsController.Create/Update):
- FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true
  проверяет, что в input.Prices есть запись с Amount > 0. Иначе
  возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.».

API фильтр+сортировка по системной цене:
- ProductsController.List: query parameters systemPriceFrom / systemPriceTo
  применяют ≥ / ≤ к Prices.Where(IsSystem).Amount.
- Sort key 'systemPrice' — OrderBy / OrderByDescending по той же
  системной цене.

Web ProductsPage:
- Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры
  systemPriceFrom/To.
- Подпись фильтра — динамическое имя системного PriceType (имя из
  справочника, обновляется при переименовании).
- Колонка системной цены получила sortKey='systemPrice'.

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

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

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

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

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

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
2026-04-23 17:32:02 +05:00
nurdotnet e1f724098f fix(other-system): убираем выдумку Kind полностью — у OtherSystem этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у OtherSystem **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 3b6ca0316c fix(other-system): не выдумывать Kind=Both для импортированных контрагентов
У OtherSystem НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в OtherSystem ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 61558179e3 phase2c: RetailSale document — посты в stock как минусовые движения
Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
  Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
  Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
  CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
  VatPercent (snapshot), SortOrder.
- PaymentMethod enum.

EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.

API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
  names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
  line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.

Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
  RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
  customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
  sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
  date/store/customer/currency/payment/paid-cash/paid-card, lines table
  with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
  prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).

Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:07:37 +05:00
nurdotnet e726cba5d8 phase2b: Supply document (приёмка) — posts to stock atomically
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:06:08 +05:00
nurdotnet 9052d76871 phase2a: stock foundation (Stock + StockMovement) + OtherSystem counterparty import
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
  with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
  product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
  quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
  WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
  WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
  OccurredAt, CreatedBy, Notes.

Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
  materialized Stock row in the same unit of work. Callers control SaveChanges
  so a posting doc can bundle all lines atomically.

Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
  indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).

API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
  unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
  movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).

OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
  customer / both), companyType → LegalEntity/Individual; dedup by Name;
  defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/other-system/import-counterparties endpoint (Admin policy).

Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
  quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
  labels for each movement type).
- OtherSystem import page restructured: single token test + two import buttons
  (Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.

Uses the ListPageShell pattern introduced in 447ac65 — sticky top bar, sticky
table header, only the body scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00
nurdotnet 7431afa620 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
Import against a live OtherSystem account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some OtherSystem
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).

- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the OtherSystem importer for Name/Article/Barcode so even
  future schema drift won't take the whole import down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:00 +05:00
nurdotnet eec9cef856 fix(other-system): accept fractional prices (decimal, not long) in DTOs
OtherSystem returns minPrice.value and salePrices[*].value as fractional numbers
for some accounts/products (not always pure kopecks). Deserializing as long
failed with "out of bounds for an Int64" → 500 on import. Switch MsMoney.Value
and MsSalePrice.Value to decimal, which accepts both integer and decimal
representations. Division by 100m already happens when mapping to local
Product, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:59:44 +05:00