Commit graph

361 commits

Author SHA1 Message Date
nns 8ff0a56144 feat(bridge): /quiet и /loud команды для управления PreToolUse прогресс-лентой
- /quiet → создаёт /tmp/cc-tg-quiet, pretool-hook сразу выходит,
  Stop hook продолжает работать (финальные ответы летят).
- /loud  → удаляет flag, прогресс-лента возобновляется.
- /ping без изменений.

На стенде bridge перезапущен, setWebhook 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:59:05 +05:00
nns 59983acabd fix(super-admin): новая org через UI получает полный bootstrap (как Demo)
POST /api/super-admin/organizations создавал только Store + Admin role
в inline-коде — у новой орги не было единиц измерения, типов цен,
кастомных ролей шаблонов (Менеджер/Кладовщик/Закупщик/Бухгалтер),
кассы. Юзеру приходилось бы заводить всё руками.

Решение — переиспользовать DevDataSeeder.SeedTenantReferencesAsync,
который уже умеет всё это идемпотентно:
- 5 единиц измерения (штука/кг/л/м/упаковка по ОКЕИ)
- 2 типа цен (Розничная IsSystem+IsRequired+IsRetail / Оптовая)
- «Основной склад» MAIN
- «Касса 1» POS-1
- 6 ролей через SeedEmployeeRolesAsync (2 системных + 4 шаблона)

Helper повышен с private на public static. В контроллере убран
inline Store + AdminRole, после Add(org)+SaveChanges вызывается
seed, потом находим уже созданную «Администратор» роль для линковки
с Employee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:58:23 +05:00
nns 92d2eb0432 feat(infra): PreToolUse hook for Telegram progress feed + rate-limited batching
Hook /usr/local/bin/cc-tg-notify-pretool читает JSON через stdin
(tool_name + tool_input) и шлёт короткую строку прогресса в Telegram
перед каждым tool-вызовом — чтобы юзер видел «он работает» а не
просто ждал финального ответа от Stop hook.

Форматы (по требованию ТЗ):
- Bash       → 🔨 ${description} либо 🔨 Bash: ${command[:80]}
- Edit/Write/Read → ✏️/📝/📖 + basename(file_path)
- Grep/Glob  → 🔍/🌐 + pattern[:30/50]
- WebFetch/Search → 🌍/🔎 + url|query[:60]
- Task       → 🎯 + description[:60]
- TodoWrite  → skip (шумно)
- прочие     → 🔧 ${tool_name}

Дебаунс через flock + фоновый sleep:
- Каждый вызов append'ит строку в /tmp/cc-tg-pretool-buffer.txt и
  бампит /tmp/cc-tg-pretool-last (epoch ms) под flock'ом.
- Спавнит фоновый sleep 1.5s; после пробуждения проверяет,
  чей timestamp в LAST — если не его, выходит молча. Только
  «последний» hook реально шлёт батч одним сообщением.
- Это даёт пачке tool-вызовов один Telegram-апдейт, а не 5–10.
- Если буфер длиннее 20 строк — режется хвостом (свежие важнее).

Off-switch: touch /tmp/cc-tg-quiet — pretool-hook сразу выходит.
Stop hook продолжает работать.

Hook регистрируется в ~/.claude/settings.json под PreToolUse с
matcher="" (все tools без фильтра); фильтр TodoWrite — внутри
скрипта.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:12:11 +05:00
nns a7fcc9f9e1 fix(seed): grant SuperAdmin role to admin@food-market.local
Раздел /super-admin в UI прячется за me.roles.includes('SuperAdmin').
Сидер при создании admin'а назначал только SystemRoles.Admin —
SuperAdmin висел как Identity-роль в роле-каталоге, но никому не был
выдан. Из-за этого SuperAdmin-консоль на стенде была не видна в меню.

Фикс: при создании admin'а сразу AddToRoleAsync(SuperAdmin). Для уже
развёрнутых стендов — догоняющая ветка else if (!IsInRoleAsync(SuperAdmin))
догоняет существующую учётку при следующем рестарте API.

На стенде роль уже выдана вручную через INSERT в AspNetUserRoles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:42 +05:00
nns e3bc5cacfc feat(web): super-admin section + setup wizard + auto-redirect
Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).

Страницы:
- /super-admin            — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
  Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
  (модалка с вводом названия), Восстановить, Удалить навсегда (только
  если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
  (реквизиты + первый админ); в response получаем generatedPassword
  и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log  — таблица журнала с экспортом в CSV.
- /super-admin/setup      — 3-шаговый wizard (welcome → org → admin →
  done с временным паролем). Авто-редирект на этот URL для SuperAdmin
  если /api/super-admin/setup-status возвращает needsSetup=true (в
  системе ноль организаций) — нельзя пропустить пока не создадут.

useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:59:07 +05:00
nns 18eb362702 feat(api): super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard)
SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
  возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
  supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
  EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
  возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
  принадлежит этой орге, лог ChangeOwner с from/to.

Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.

SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
  на orgs для имени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:54:07 +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 d2305b7d40 feat(infra): event-driven Telegram bridge — webhook + Stop hook
Полный отказ от 2-секундного polling tmux'а в пользу реактивной схемы:

OUTBOUND (server-Claude → Telegram) через Stop hook:
- /usr/local/bin/cc-tg-notify-stop (Bash) читает transcript из stdin
  (Claude Code передаёт {transcript_path}), достаёт последнюю
  assistant-запись с непустым text-блоком (jq), чанкует ≤4000 символов
  с префиксом «🤖 [food-market]», POST'ит в Telegram через curl.
  Логи /var/log/cc-tg-notify.log. Если turn без текстового ответа
  (только tool calls) — выходит молча.
- Зарегистрирован в ~/.claude/settings.json под Stop event с пустым
  matcher (все turns).

INBOUND (Telegram → bridge → tmux) через webhook:
- bridge.py переписан с run_polling на run_webhook listening
  127.0.0.1:8765 на /tg-webhook. python-telegram-bot[webhooks]
  (tornado) ставится через pip.
- При старте сам делает setWebhook к Telegram API с secret_token
  из TELEGRAM_WEBHOOK_SECRET (osprandom 24 hex), Telegram присылает
  его обратно в X-Telegram-Bot-Api-Secret-Token — PTB валидирует
  до вызова handler'ов.
- Сохранены: whitelist по chat_id, paste-в-tmux через
  send-keys -l + Enter, /ping команда. Удалён poll_and_forward,
  diff/clean логика, recently_sent_lines дедуп — больше не нужны.

Nginx: новый location = /tg-webhook на food-market-stage.conf,
проксирует на 127.0.0.1:8765 с прокидыванием X-Telegram-Bot-Api-
Secret-Token. Smoke-test: curl с неверным секретом → 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:38:44 +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 d3bcbee8b9 feat(onboarding): welcome dashboard with first-steps cards
Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).

useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок

OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
  категории + заголовок + описание + CTA-кнопка с ArrowRight,
  меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
  /dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:08:03 +05:00
nns f5cccb6f10 feat(web): Employees + Roles pages with permissions matrix
EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
  Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
  Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
  привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
  возвращает generatedPassword один раз, показываем в отдельной
  модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.

EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
  (N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
  блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
  Удаление кнопка только для кастомных.

Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:06:03 +05:00
nns c714ec265c feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password
EmployeeRolesController (/api/organization/employee-roles):
- List/Get/Create/Update/Delete. Системные роли (IsSystem=true) — нельзя
  удалить (409), но имя/описание/permissions редактируются (чтобы можно
  было кастомизировать набор галок). Удаление 409 если роль уже
  используется сотрудниками.

EmployeesController (/api/organization/employees):
- List с поиском по фамилии/имени/email/телефону.
- Create:
  - LastName, FirstName, MiddleName, Position, Email, Phone, RoleId, IsActive
  - RetailPointIds[] — для роли Кассир привязка к нескольким кассам;
    хранится в employee_retail_point_assignments.
  - CreateAccount=true → одновременно создаём User (Identity) с email и
    случайным temp-паролем (12 символов, все классы), возвращаем в
    response.GeneratedPassword один раз — UI покажет «выдайте сотруднику».
- Update — replace assignments wholesale; IsActive false → проставляем
  FiredAt=now (восстановление обнуляет).
- Delete — без проверок на FK документов (на этом этапе нет других
  ссылок на Employee, кроме CASCADE-связи с retail-point assignments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:03:39 +05:00
nns 0ff31d1450 feat(seed): system roles per organization + map admin → Employee
DevDataSeeder.SeedEmployeeRolesAsync — 6 системных ролей с готовыми
наборами Permissions:
- Администратор    — RolePermissions.All() (все 21 флаг)
- Менеджер         — каталог + закупки + контрагенты + отчёты + остатки
- Кладовщик        — приёмки + остатки + view товаров
- Кассир           — продажи + view товаров (привязка к кассе на UI-этапе)
- Закупщик         — закупки + контрагенты + view товаров
- Бухгалтер        — все *View, никаких edit

IsSystem=true, SortOrder сохраняет порядок отображения в селектах.
Сидируется один раз per организацию (anyRole? skip) — чтобы кастомные
правки галок админа не сбрасывались на каждый старт.

SeedAdminEmployeeAsync — после создания admin@food-market.local
(SuperAdmin Identity user) заводит Employee-запись с ролью
«Администратор» в Demo Market организации, чтобы UI «Сотрудники»
сразу показывал учётку, а не пустой список.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:44 +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 cec76ecaaf refactor(retail-points): rename «Точка продаж» → «Касса» + перенос
складов и касс в раздел «Настройки организации»; useOrgInfra хук

UI-переименование:
- RetailPointsPage: title «Кассы», description обновлён, лейблы
  «Новая касса» / «Удалить кассу?»; доменная сущность RetailPoint
  и URL /api/catalog/retail-points сохранены — DTO/БД не трогаем.
- В сайдбаре пункты «Склады» и «Кассы» перенесены из бывшей
  группы «Склады» в группу «Настройки организации» (рядом с
  «Общие»). Старые пункты верхнего уровня убраны.

useOrgInfra() — общий хук:
- возвращает stores, cashRegisters, defaultStoreId, defaultCashId
- showStorePicker / showCashPicker = length > 1 (умное скрытие
  селекторов в формах документов когда инфра одна).
В SupplyEditPage скрытие склада уже работало через
(stores.data?.length ?? 0) > 1 — оставил как есть, новый хук
для будущих документов (продажи, инвентаризации).

Сидер default Store + RetailPoint per Organization уже есть в
DevDataSeeder.cs (Основной склад MAIN + Касса 1 POS-1) —
дополнять не нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:49 +05:00
nns dc4b5360b9 fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)
Старый dropdown использовал position:absolute внутри Section (а у того
overflow-hidden для скруглений), из-за чего он клипался границами
карточки. На некоторых страницах визуально это смотрелось так, будто
список «раздвигает» layout.

Решение — тот же Portal-паттерн что у SupplyLineQuickAdd и DateField:
- dropdown рендерится через createPortal в document.body
- position: fixed, координаты по getBoundingClientRect() trigger'а
- z-[100], sticky-headers/секции не перекроют
- Auto-flip: если внизу <240px и сверху больше — открываем вверх
  (anchor через bottom: window.innerHeight - rect.top)
- Outside-click учитывает обе ноды (wrap + dropdown в portal)
- На window.scroll/resize dropdown закрывается чтобы не «уплывать»
  относительно trigger'а

Фиксит сразу все Select'ы в проекте (тип штрихкода, валюта, страна,
группа товара, тип цены и т.д.) — компонент один.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:56:03 +05:00
nns e96f1cdc86 fix(date-field): theme styles + default to today for new docs
Тема react-datepicker подогнана под Tailwind/slate-стиль остального UI:
- font-family inherit, шрифт системный (не Helvetica дефолт);
- header bg-slate-50, border slate-200, скругления 0.5rem;
- выбранный день — заливка var(--color-brand) (фирменный зелёный);
- сегодня — bold + brand color;
- день под клавиатурой — slate-100 без заливки brand;
- стрелки навигации серые slate-500 (без bootstrap-синего);
- footer «Сегодня» — приглушённый slate-50 с brand-цветом текста;
- треугольник-указатель скрыт;
- крестик clear — slate-400 с hover slate-500;
- весь dark-вариант через prefers-color-scheme — slate-800/900.

todayIso() в SupplyEditPage переписан с toISOString() (UTC) на
локальный YYYY-MM-DD — иначе в часовом поясе KZ (UTC+5) с 03:00
до 05:00 утра новая приёмка получала бы вчерашнюю дату.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:51:19 +05:00
nns 39da12edec feat(date-field): replace native input with react-datepicker — polished UX
Нативный <input type=\"date\"> рендерил американский MM/DD/YYYY и
тонкий браузерный popup, выглядел криво рядом с другими полями.

Используем готовый react-datepicker (10M downloads/week) — никакой
кастомизации, всё из коробки:
- dateFormat=\"dd.MM.yyyy\" + locale=\"ru\" → «25.04.2026», русские
  Январь/Понедельник
- showMonthDropdown + showYearDropdown + dropdownMode=\"select\" →
  быстрый прыжок на любой месяц/год
- todayButton=\"Сегодня\" → кнопка под календарём
- isClearable → крестик в input для очистки
- popperClassName z-[100] чтобы попап не резался z-стеком

ISO YYYY-MM-DD внутрь/наружу собираем вручную из локальных Y/M/D —
не toISOString(), чтобы вечерние даты в часовом поясе KZ (UTC+5)
не сдвигались на день назад.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:39:51 +05:00
nns dc162a6c06 revert(date-field): drop custom react-day-picker — use native input type=date
Кастомный DateField на react-day-picker оказался хрупким (стрелки
навигации не реагировали, dropdown лет вылазил за popover). В проекте
нет shadcn/ui-обёртки над day-picker, а пилить её с нуля под одно
поле — overkill.

Откатил на нативный <input type=\"date\"> с max-w-[180px], чтобы
поле не растягивалось на всю колонку. Браузер сам подтягивает
локаль из ОС/настроек — у пользователя с RU-локалью календарь
будет на русском, формат DD.MM.YYYY (как в его референс-скриншоте).

- Удалён components/DateField.tsx.
- В SupplyEditPage возвращён <TextInput type=\"date\"> с
  className=\"max-w-[180px]\".
- Сняты зависимости react-day-picker и date-fns.

Если когда-нибудь вернёмся к кастомному picker'у — будем ставить
shadcn/ui calendar+popover целиком, не вручную.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:41 +05:00
nns c54c26cf2b fix(date-field): polish calendar UX — dropdown nav, today/clear footer, ru weekdays
Календарь приведён к виду нативного date-picker macOS:

- captionLayout="dropdown" + startMonth/endMonth — в шапке
  селекты «Апрель» и «2026» (можно прыгнуть на любой месяц/год
  без 12 кликов next).
- Стрелки навигации справа в шапке (absolute right-1 top-1),
  компактные 28×28, hover-bg, без ярко-синего акцента.
- footer prop — две inline-ссылки «Очистить» (сбрасывает) и
  «Сегодня» (ставит сегодняшнюю дату); border-t над ними.
- Сегодня = синяя заливка bg-brand text-white (как на референсе);
  выбранная дата = ring-2 ring-brand; если сегодня = выбранный —
  применяются обе (сначала заливка, потом ring).
- Ширина popover'а w-[340px], ячейки 36×36, weekday и dropdown
  capitalize → «Пн Вт Ср …», «Апрель 2026».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:48 +05:00
nns 6a673e536e fix(date-field): compact calendar popup — shadcn-style sizing
Календарь react-day-picker раздувался на ~700px из-за дефолтных
стилей. Теперь plотный shadcn-style ~280–290px:

- CSS-переменные v9 на враппере: --rdp-day-height/width=2rem,
  --rdp-day_button-height/width=2rem, --rdp-weekday-padding=0,
  --rdp-accent-color=brand. Контейнер p-3 text-sm.
- classNames переписаны: ячейка дня h-8 w-8 (32×32) с rounded-md,
  weekday w-8 capitalize, caption_label text-sm font-medium.
  Кнопки навигации 24×24 с hover-bg, chevron 16×16.
- Сегодня: bg-slate-100 + font-semibold (без жирной обводки).
  Выбранная дата: bg-brand text-white. Outside-дни выцветшие.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:19:13 +05:00
nns c31611d6c4 fix(date-fields): cap width + ru locale + DD.MM.YYYY format
Новый компонент <DateField/>:
- Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный
  <input type="date"> растягивался на всю колонку, хотя содержимое
  всегда 10 символов.
- Ввод в формате DD.MM.YYYY с авто-вставкой точек после dd и mm,
  inputMode=numeric для мобилы. Хранит/отдаёт ISO YYYY-MM-DD —
  API-контракт не меняется.
- Иконка календаря справа открывает попап (через portal в body,
  position fixed) с react-day-picker: locale=ru, weekStartsOn=1,
  ISOWeek; caption_label/weekday с capitalize CSS — «Апр 2026»,
  «Пн Вт Ср …». Outside-click закрывает.

Подключено в SupplyEditPage (поле «Дата»). Ставка на единый
компонент DateField — все будущие даты в системе через него.

Зависимости: + react-day-picker ^9, + date-fns ^4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:13:35 +05:00
nns 7d09873be2 fix(supply-lines): show both article and barcode in line subtitle
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns 970a9baec3 fix(supply-quick-add): sticky input at viewport bottom + auto-scroll on add
Quick-add bar теперь не sticky-внутри-Section, а отдельный flex-sibling
формы — всегда прибит к нижнему краю viewport независимо от высоты
содержимого и overflow-hidden у Section'а:

  <form flex flex-col h-full>
    <topbar />
    <body flex-1 overflow-auto>     ← скроллится
    <quick-add bar flex-shrink-0>   ← всегда виден
  </form>

После каждого добавления строки скролл-контейнер тела документа
автоскроллится к низу (smooth, через requestAnimationFrame чтобы
дождаться рендера новой строки) — новая строка всегда появляется
прямо над input'ом и пользователь видит подтверждение скана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:42:24 +05:00
nns b56c499b45 fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
UX как в сторонняя системае:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:33:57 +05:00
nns 95bf2188c6 feat(product-card+list): drop supplier field, reorder sections, add cost column
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO
  оставлены без изменений; в payload отправляется null);
- порядок секций: Основное → Цены → Классификация → Изображения →
  Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное,
  должны быть ближе к названию товара.

Список товаров:
- добавлена колонка «Себестоимость» перед колонкой системной розничной
  цены. Источник — Product.Cost (скользящее среднее, обновляется при
  проведении приёмки). Cost = 0 (приёмок не было) показывается как «—»,
  чтобы визуально отличать «не накопилось» от реальной себестоимости 0.
- API: добавлен сортировочный case sort=cost,asc/desc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:57 +05:00
nns 94d3c4687b fix(supply-quick-add): keep input focused after scan / clear on add
Сценарий — приёмщик подряд сканирует 50 штрихкодов без клика мышью:

- Sticky-bar с input'ом теперь ВНЕ Section'а (overflow-hidden родителя
  ломал sticky), прибит к низу скроллируемого тела документа. После
  любого добавления строки — input всегда виден.
- Очистка query и refocus вызываются СРАЗУ после клика/Enter, до
  await на /products/{id}: пока сеть в полёте, юзер уже может начать
  следующий скан. После завершения запроса — повторный refocus
  (двойной guarded focus + requestAnimationFrame), чтобы перебить
  любые ререндеры родителя, которые могут увести фокус.
- Поиск по quick-search теперь через AbortController — устаревший
  ответ при быстром вводе подряд не подменяет свежий список.
- Закрытие ProductQuickCreateModal тоже возвращает фокус в input
  (и при «Создать», и при «Отмена»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:08:49 +05:00
nns f7deecc41c fix(supply-quick-add): dropdown not rendering — Portal + fixed position
Корень проблемы: Section рендерится с класcом overflow-hidden (нужен
для скруглений углов) — absolute-позиционированный dropdown
обрезался границей карточки и был не виден совсем.

Решение: dropdown через React createPortal вынесен в document.body,
позиция вычисляется по getBoundingClientRect() input'а на каждый
open + window scroll/resize. position: fixed, z-[100] — выше любого
sticky-header. Outside-click handler теперь учитывает оба контейнера
(input wrap + portal-узел) — клик по элементу dropdown'а больше
не закрывает его как «снаружи».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 9008939249 chore(web): remove standalone Currencies page (managed via Country FK)
Валюты — закрытый сидируемый список (KZT, USD, EUR, RUB), пользователь
их не создаёт. Currency остаётся в БД как FK для Country и Organization;
GET /api/catalog/currencies продолжает работать для дропдаунов в форме
страны и настройках организации. Отдельная страница в меню избыточна.

- удалена pages/CurrenciesPage.tsx
- из роутера убран /catalog/currencies
- из сайдбара пропал пункт «Валюты», иконка Coins больше не импортируется

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 532c1aaf24 feat(supply): inline line quick-add — scanner + autocomplete + create-on-fly
UX как в сторонняя система: под таблицей строк единый input full-width с
автофокусом, на каждый ввод — debounce 200ms и quick-search в API.
Dropdown показывает артикул + название (с подсветкой матча) +
бэйдж текущего остатка по складу документа (зелёный/красный/серый).

Сценарии:
- Сканер (цифры 8/12/13/14 + Enter) → точный barcode lookup,
  единственный матч добавляется мгновенно. Несколько → пользователь
  выбирает из dropdown.
- Текст + Enter → берёт подсвеченный пункт автокомплита.
- Дубль того же товара → Quantity += 1 у существующей строки,
  всплывает «Кол-во увеличено на 1».
- Ничего не нашлось → пункт «Создать новый товар: «{q}»» в дропдауне
  и при Enter, открывает ProductQuickCreateModal с pre-fill в зависимости
  от типа запроса (barcode/article/name).
- ↑↓ навигация, Esc/Tab закрывают, после добавления input очищается
  и возвращает фокус — для сканирования партии подряд.

Кнопка «+ Добавить из справочника» (правый верх секции) — без изменений,
открывает ProductPicker с фильтрами и multi-select для bulk-добавления.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:55:57 +05:00
nns 48babf0d10 feat(api): products quick-search + by-barcode endpoints
GET /api/catalog/products/quick-search?search=&storeId=&limit=20 —
лёгкий поиск для inline-добавления строк в документы. Ранжирует
по приоритету: точный barcode → точный article → префикс article →
префикс name → name contains. Возвращает QuickSearchItem с stockQty
по storeId (если передан) или сумме по всем складам.

GET /api/catalog/products/by-barcode/{value}?storeId= — точный поиск
для сканера. 404 если 0 совпадений, объект QuickSearchItem если 1,
{ items: [...] } если несколько (для диалога выбора).

Why: новый UX inline-добавления строк в приёмке требует быстрого
поиска по штрихкоду/артикулу/названию с показом остатков прямо в
дропдауне; полный /products endpoint слишком тяжёлый.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:53:07 +05:00
nns 28b264f43b ui(product-card): «Закупка» и «Цены продажи» в две колонки на десктопе
Внутри секции «Цены» теперь двухколоночная сетка (lg:grid-cols-2):
закупка слева, цены продажи справа, с вертикальным разделителем.
На узких экранах (<lg) колонки складываются вертикально, как раньше.

В правой колонке цены продажи переведены на стандартный <Field>
с label сверху, чтобы выравниваться с полями закупки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:35:29 +05:00
nns 168b12345d fix(supply): колонка «Розничная» использует имя системного PriceType
В таблице позиций приёмки заголовок «Розничная (карточка)» теперь берёт
имя из справочника типов цен (priceTypes.find(isSystem)) — так чтобы
название совпадало с тем, что отображается в карточке товара и в
самом справочнике. Если системного типа нет — фолбэк на IsRetail,
а затем «Розничная».

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:29 +05:00
nns 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 2321010608 feat(ui): inline-create option in searchable Select
Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.

Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:04:42 +05:00
nns 196658e548 feat(ui): searchable Select component (drop-in)
Заменили нативный <select> на кастомный комбобокс с поисковой строкой:
кликабельная кнопка-триггер, dropdown с input «Поиск…» и фильтрацией
по подстроке, навигация ↑/↓/Enter/Esc. API совместим — onChange получает
synthetic event с e.target.value, поэтому все 22 существующих <Select>
работают без правок call site. Дочерние <option> парсятся в options
автоматически (поддержка <optgroup>).

Why: справочники быстро растут (поставщики, страны, типы цен) — выбор
из длинного списка через нативный select утомителен. Поиск нужен везде.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:00:06 +05:00
nns 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 fea3498b8b fix(price-types): IsRequired применяется сразу, без перезагрузки страницы
Баг: переключение «Обязательная» в /catalog/price-types не приводило
к валидации в карточке товара — Save шёл и проходил, не требуя цену.

Корневая причина — два разных queryKey:
- useCatalogMutations в PriceTypesPage инвалидирует
  ['/api/catalog/price-types'] (для самой List-страницы),
- но usePriceTypes-хук, которым пользуется ProductEditPage и
  ProductsPage, живёт под ключом ['lookup:price-types'].
В итоге свежий снапшот справочника не доходит до потребителей.

Фикс:
- useLookups: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Любой переход на ProductEditPage
  делает свежий GET и видит актуальный IsRequired/IsRetail.
- PriceTypesPage save/delete: дополнительно вызывают
  qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) —
  потребители тут же перерендериваются, без необходимости F5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:38:15 +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 a8717897b7 fix(product-edit): человечная ошибка 400 + блок Save при незаполненных IsRequired ценах
Сервер 400-нул сохранение товара когда обязательная цена пуста, а UI
показывал «Request failed with status code 400» без указания причины.

- onError save mutation: достаём response.data.error из axios-ответа
  и кладём в setError; общий generic message остаётся как fallback.
- canSave дополнен проверкой что у каждого PriceType с IsRequired=true
  есть строка в form.prices с amount > 0 (та же что делает бэкенд,
  чтобы кнопка не отправляла запрос обречённый на 400).
- Под секцией цен — красная подсказка-список незаполненных обязательных
  типов цен («Заполни обязательные цены: «Розничная2» (значение должно
  быть больше 0).»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:58:49 +05:00
nns c257ee7e88 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:18 +05:00
nns ebdc70bd58 ci(pricetype-fix-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:40:35 +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 d1ebbef671 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:09:18 +05:00
nns 126ff97a11 ci(phase3b-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:06:40 +05:00