Добавлен AsyncSelect в Field.tsx: дебаунс 300ms, fetch ?search=&pageSize=20,
поддержка onCreate, getLabel для кастомного отображения (path у групп товаров).
Переведены на AsyncSelect:
- SupplyEditPage: поставщик
- RetailSaleEditPage: покупатель
- ProductEditPage: группа товара
- ProductQuickCreateModal: группа товара
useLookups/useOrgInfra: pageSize 500→100 для малых справочников.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
admin@food-market.local → SuperAdmin (OrganizationId=null, видит все орги)
admin@demo-market.local → Admin Demo Market (новый, для тестов орг-уровня)
Idempotent-фикс для существующих БД: исправляет роль и чистит OrganizationId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
— «Владелец» переименован в «Главный администратор» — терминологически
у нас не «собственник», а тот кто управляет организацией.
Бейдж в таблице, тексты модалок, серверные сообщения 403 — везде
единая формулировка.
— PUT /api/organization/employees/{id}: добавлен гард для главного
администратора:
· Смена RoleId на не-«Администратор» → 403 «Нельзя сменить роль…»
· IsActive=false → 403 «Нельзя деактивировать…»
Раньше юзер мог поменять себе роль на Кладовщик и получить бейдж
«Владелец» с ролью кладовщика — несостыковка.
— EmployeesPage: при редактировании главного администратора
· Селект ролей disabled + amber-плашка-объяснение «роль фиксирована»
· Чекбокс «Активен» disabled + текст «нельзя деактивировать»
· save() ловит ошибки и показывает их в общей модалке (раньше 403
«тихо проваливалось» — модалка зависала).
— recovery-restore-orphan-owners.sql добавлен блок: для всех
Organizations где главный администратор имеет роль не-«Администратор»
или IsActive=false → восстанавливает «Администратор» и активирует.
Идемпотентен. Применён на стейдже (0 пострадавших — текущая БД ОК).
Все изменения главного администратора (роль, ФИО, удаление, передача
управления) по архитектурному решению юзера должны идти через очередь
запросов к Супер-администратору платформы. Эта подсистема — отдельная
большая фича (RequestType / RequestQueue / SuperAdmin approval UI),
её план описан в TG-ответе.
Жалоба юзера: «нажимаю удалить владельца магазина — диалог "удалить
сотрудника?" — нажимаю — ничего не происходит». Раньше кнопка «Удалить»
для Owner была доступна, на сервере отвечала 403 с понятным сообщением,
но фронт ошибку не ловил — модалка зависала.
— EmployeeDto теперь возвращает isOwner (Org.AccountOwnerUserId ==
Employee.UserId) и isSelf (UserId текущего залогиненного юзера).
List + Get обновлены: подгружают AccountOwnerUserId и текущий sub
из JWT, проставляют флаги в проекции.
— Таблица сотрудников: рядом с ФИО владельца — бейдж «Владелец»
(amber-100/800).
— Кнопка «Удалить» в модалке редактирования:
· disabled для Owner и для self с tooltip-объяснением;
· клик по disabled-кнопке через onClick-handler показывает спец-
модалку: «Нельзя удалить владельца магазина — сначала передайте
управление другому пользователю. Организация не может остаться
без владельца. Удалить или деактивировать саму организацию может
только Супер-администратор платформы.»;
· self-delete объясняется отдельным текстом (Настройки → Аккаунт →
Покинуть организацию);
· обычное удаление — confirm с именем сотрудника и пояснением что
это soft-delete (деактивация).
· 403/любая ошибка от сервера ловится в try/catch и показывается
в той же модалке «Не удалось удалить» — больше не «ничего не
происходит».
Smoke: API эмплоя возвращает isOwner=true,isSelf=false для Owner'а в
override-режиме SuperAdmin'а.
- validation: убрана клиентская проверка «должна быть заглавная/цифра»
— она расходилась с серверной политикой Identity и блокировала
валидные пароли. Серверный Identity сам валидирует и возвращает
конкретное сообщение в общий error-bar формы. Клиент проверяет
только длину 8.
- /api/auth/signup: если AppUser с таким email уже есть, но он
orphan (org удалена / архивирована / IsActive=false) — реактивируем
его и привязываем к новой org вместо отказа «уже зарегистрирован».
SuperAdmin не реактивируется (ему сценарий неактуален).
Пароль перезаписывается на тот, что юзер ввёл сейчас.
- TenantRouteGuard: убран alert «Откройте конкретную организацию через
Открыть как…». На каждом редиректе SuperAdmin'а из tenant-роута
всплывал window.alert — раздражал. Поведение редиректа сохранено.
AppLayout чистит legacy ключ из sessionStorage.
- SuperAdmin dashboard: KPI «Пользователей» теперь показывает количество
активных, а в подсказке — сколько деактивированных. Раньше показывал
total (включая orphan) — юзер видел «2», но в реальных списках их не
было, потому что orphan-юзеры уже деактивированы.
Аудит 2026-04-27. Полный отчёт — docs/audit-2026-04-27.md.
Что закрыто:
— /connect/token (AuthorizationController) теперь отказывает в login если
AppUser привязан к удалённой/архивной Organization. SuperAdmin обходит
проверку (ему org не нужна). Жалоба: nurnetps@gmail.com мог логиниться
после удаления своей org из SuperAdmin консоли.
— SuperAdminOrganizationsController.Delete (DELETE org) каскадно
деактивирует всех AppUser привязанных к этой org (IsActive=false,
OrganizationId=null) и помечает Status='revoked' для всех их
OpenIddictTokens. Раньше Org удалялась, а юзеры оставались валидными
с активными refresh-tokens на 30 дней.
— EmployeesController.Delete теперь soft-delete (IsActive=false,
FiredAt). Запрещены: 403 если попытка удалить себя; 403 если
попытка удалить Owner (Organization.AccountOwnerUserId ==
employee.UserId). Сообщения с инструкцией («передайте права»,
«покинуть через настройки»).
— /api/me возвращает hasLiveOrg и hasActiveEmployee — frontend
использует это для редиректа на /no-organization вместо белого экрана.
— Новая страница /no-organization (NoOrganizationPage) — fallback для
orphan AppUser. CTA: создать новую org через публичный /signup
или попросить инвайт. Кнопка «выйти». TenantRouteGuard редиректит
orphan юзеров туда.
— SuperAdminAsOrgBanner: добавлена проверка через useMe — баннер
рендерится только если у текущего юзера есть Identity-роль
SuperAdmin. Lingering localStorage override от прошлой сессии
(другой юзер логинился до этого) автоматически чистится.
— auth.ts: clearTokens() теперь сбрасывает superAdminAsOrg и
superAdminEditMode. login() вызывает clearTokens() ПЕРЕД запросом
чтобы новый юзер не унаследовал override-состояние от предыдущего.
— deploy/recovery-restore-orphan-owners.sql — идемпотентный скрипт
деактивирующий уже накопленных orphan AppUser (как nurnetps) и
revoke их токены. Применён на стейдже: 1 user деактивирован,
9 токенов revoked.
— deploy/Dockerfile.api: убран `--no-restore` из publish — два
раздельных шага роняли build с NETSDK1064 на свежих analyzer-
зависимостях, теперь restore идёт внутри publish.
Smoke (стейдж):
- nurnetps@gmail.com /connect/token → invalid_grant.
- admin@food-market.local /connect/token → access_token выдан.
- food-market.zat.kz/, /signup/, app.../login, /health → 200.
— Общий модуль `src/lib/validation.ts` в обоих пакетах (public + web):
validateEmail (требует TLD ≥2 букв), validatePassword (8+, буква+цифра),
validatePhone (+7/8, 10 цифр), validateRequired, и
localizeNativeValidation — слушатель invalid/input для setCustomValidity
на русском (required, typeMismatch, patternMismatch, tooShort и т.д.).
— public SignupForm: noValidate, валидация на onSubmit с русскими ошибками
под каждым полем; placeholder email→ "name@example.kz", phone→ "+7 700
123 45 67". Старый HTML5 required+minLength+type=email убран —
теперь всё через нашу схему.
— web LoginPage: noValidate + локальная проверка email/password перед
login(); красная подсказка появляется в самом поле.
— web Field/TextInput: useEffect с localizeNativeValidation на ref —
все остальные админские формы (SuperAdminSetup, SuperAdminOrgCreate,
EmployeesPage, CounterpartiesPage) автоматически получают русские
нативные подсказки через общий компонент, без правки каждой страницы.
Acceptance: на /signup/ ввод "name@domain" без TLD блокируется
сообщением «Email должен содержать доменную зону…». Нативные браузерные
подсказки больше не на английском.
В SignupForm.tsx плейсхолдер «ИП Иванов / Магазин у дома» →
«Например: Магазин «Береке»». Не используем выдуманных русских
персонажей в маркетинговых интерфейсах для KZ-аудитории.
Грэп всего публичного пакета и админки на типичные RU-фамилии
(Иванов/Петров/Сидоров и пр.), эл.почты mail.ru/yandex,
формы юр.лиц ОАО/ЗАО — других вхождений не найдено.
— Все материалы (главная, /pos, /about, FAQ, kb, blog) переведены на
нейтральные формулировки: «другие системы учёта», без имён.
— Новая страница /import — единая точка входа по миграции каталога;
описывает Excel/CSV, REST API и выгрузку 1С.
— Удалены публичные kb/blog-статьи, целиком построенные вокруг
миграции с конкретного продукта.
— /migration-from-other-system убран из sitemap; nginx делает 301 на /import.
— blog schema расширена optional-полями (author, category, cover_image),
чтобы новый frontmatter валидировался content collection.
— Грамматические правки: «Импорт из других систем».
Финальный grep по пакету по списку имён конкурирующих SaaS-учётных
продуктов — пусто. Smoke по 9 ключевым URL — 200; старый URL — 301.
Phase 6 контентная часть — частично:
Блог (3 поста из /tmp/content/blog-and-kb.md, content collection):
- launch — запуск Food Market
- import-other-system — пошаговый гид миграции (внутренняя инструкция, frontmatter)
- cash-with-scales — почему касса с весами Масса-К из коробки
База знаний (5 статей, content collection с category + order):
- quickstart, import-other-system, pos-setup, billing, faq
Страницы /blog и /kb перерисованы — рендерят список из коллекций
с группировкой по категориям, отдельные [slug].astro template'ы
рендерят markdown с типографикой prose-md.
/about и /contacts наполнены реальным контентом из
/tmp/content/about-contacts-pricing-features.md (без заглушек).
Контакты — 4 канала (email/phone/чат/реквизиты) с placeholder-ами
для будущих контактов после регистрации юр.лица.
Очистка от упоминаний сторонних систем (по правилу: не сравнивать с
конкретными системами и не выводить чужие цены публично):
- Hero/FAQ на главной — заменено «Импорт из сторонняя система» на «импорт
каталога одной кнопкой», убран FAQ-вопрос «Чем отличаетесь от...»,
заменён на общий «Чем хорош Food Market?».
- Footer: пункт «Импорт из сторонняя система» → «Импорт каталога».
- /migration-from-other-system удалён (содержал сравнительную таблицу
с ценами сторонняя система). Возможен возврат как KB-инструкция позже.
- features/pos/pricing/integrations/changelog/for-grocery/for-pharmacy
— sed-замена «сторонняя система» → «другие системы», ссылки на
/migration-from-other-system → /features.
- В content-collections блог/KB остались упоминания (юзер пришлёт
PATCH-версии текстов отдельно).
Контейнер пересобран и задеплоен на стенд: 30 страниц, smoke на /,
/blog, /kb, /about, /blog/launch — все 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Доменная схема (по решению юзера):
food-market.zat.kz → новый Astro public-сайт (порт 8082, контейнер food-market-public)
app.food-market.zat.kz → существующая админка (food-market.web, порт 8081)
API остаётся на app.* под /api/*.
Изменения:
- docker-compose: добавлен сервис public (image food-market-public:latest,
127.0.0.1:8082:80). На стенде .env дополнен PUBLIC_TAG=latest, контейнер
поднят, smoke на / и /pricing проходит.
- Forgejo workflow .forgejo/workflows/docker-public.yml — отдельный билд
при изменениях в src/food-market.public/**: docker build с
--build-arg PUBLIC_SITE_URL=https://food-market.zat.kz и
--build-arg PUBLIC_APP_URL=https://app.food-market.zat.kz, push в
локальный registry, deploy через docker compose pull+up. TG-пинг.
- Nginx (на стенде вручную, не через репо):
- Новый блок food-market-app.conf для app.food-market.zat.kz —
проксирует на :8081 (web), вместе с /api/admin/import/ и
/tg-webhook путями. Certbot --nginx выпустил SSL.
- Старый food-market-stage.conf переписан на public — проксирует на
:8082, использует существующий SSL для food-market.zat.kz.
- API CORS: добавлены food-market.zat.kz, app.food-market.zat.kz,
food-market.kz, app.food-market.kz в AllowedOrigins (publicу нужен
food-market.zat.kz для signup-запросов, админке нужен app.*).
- JWT cookie domain не настраиваем — проект использует localStorage,
cross-domain auth-bridge через URL fragment (см. AuthBridgePage),
что безопаснее cookie с .food-market.zat.kz.
- Хардкодов food-market.zat.kz в food-market.web/src не нашлось —
всё через относительные URL.
Существующие админ-сессии: токены в localStorage привязаны к
food-market.zat.kz origin. После переезда юзеры увидят на этом
домене публичный сайт без своих токенов — нужно перелогиниться
на app.food-market.zat.kz.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раньше «удалить орг навсегда» было захардкожено на 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>
КОРНЕВАЯ ПРИЧИНА: SuperAdminLayout имел useEffect
if (getOrgOverride()) navigate('/dashboard', { replace: true })
который автоматически выкидывал в tenant-режим при любом заходе на
/super-admin/* если в localStorage остался override (например с прошлой
сессии или после нечистого выхода). Это создавало цикл: SuperAdmin
кликает «Системная консоль» в меню → попадает на /super-admin/... →
useEffect видит override → navigate /dashboard → tenant TenantRouteGuard
проверяет: override есть → пропускает, но юзер видит tenant с баннером
вместо системной консоли.
Если override содержал «зависший» orgId с прошлой сессии и юзер ожидал
вернуться в SuperAdmin консоль — он попадал в tenant-режим, а попытки
вернуться через ссылки sidebar лечились этим же useEffect снова в /
dashboard. Юзер видел тост от TenantRouteGuard в редких случаях когда
localStorage не успевал прочитаться (race с rAF).
Фикс:
- Удалён useEffect (+ unused useNavigate импорт). SuperAdmin может
свободно перемещаться между системной консолью и tenant-режимом
при активном override; снимает override только баннером «Выйти из
режима» либо переключает другую орг через picker.
- TenantRouteGuard: повторная проверка getOrgOverride() через
requestAnimationFrame (защита от любых race с localStorage), и
разовый guard-флаг (useRef) чтобы не редиректить дважды на одной
странице. Console.warn перед редиректом — облегчает диагностику
если регресс повторится.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Опечатка: в 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>
Концепция: 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>
Концепция: «Супер администратор» — платформенная Identity-роль
SuperAdmin. «Администратор» — организационная роль внутри Employee
(IsSystem=true в EmployeeRole). Они НЕ должны дублироваться у
одного юзера.
- Сидер: admin@food-market.local получает только Identity-роль
SuperAdmin. Догоняющая ветка для существующих стендов:
если есть Identity-роль Admin — RemoveFromRoleAsync. На стенде
AspNetUserRoles почищен SQL'ом.
- AppLayout: translateRoles() переводит SuperAdmin → «Супер
администратор», скрывает Identity-роль Admin (org-уровень
показывается через Employee/Role, не через Identity).
- EmployeeRolesPage: клик по строке системной роли → alert
«Системная роль, изменения недоступны». Edit-модалка для
системных была частично defensive (disabled чекбоксы Phase 2c),
теперь точка входа закрыта целиком. Кастомные роли — без изменений.
EmployeeRole.IsSystem поле уже было — миграция не нужна.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Country — глобальный справочник (Entity, не TenantEntity), магазины-
клиенты выбирают страны из готового списка но не управляют ими.
Управление переносится в SuperAdmin консоль.
Изменения:
- API: POST/PUT /api/catalog/countries теперь Authorize(Roles=SuperAdmin)
(раньше был SuperAdmin,Admin). DELETE и так был SuperAdmin.
- GET остаётся [Authorize] без роли — нужен tenant'у для селектов в
формах создания орги/контрагентов/товаров.
- Tenant AppLayout: убран блок «Справочники» с пунктом «Страны».
Иконка Globe больше не импортируется в tenant-меню.
- Tenant роут /catalog/countries удалён из App.tsx.
- В OrganizationSettingsPage ссылка «откройте справочник Страны»
заменена на текст «обратитесь к администратору платформы».
- SuperAdminLayout: новый блок «Справочники» с пунктом «Страны»
(/super-admin/countries). Иконка Globe.
- Роут /super-admin/countries использует существующий CountriesPage —
компонент unchanged, страница теперь рендерится в SuperAdminLayout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. 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>
Food Market — SaaS для розницы, SuperAdmin это владелец платформы,
а не сотрудник магазина. Операционные метрики магазинов («Товаров
29540», «Приёмок за месяц») для него бесполезны — это для tenant
dashboard'а конкретной орги.
KPI блок на /super-admin переработан под кабинет SaaS-провайдера:
Top row (4 карточки):
- Организаций (как было — клиентская база)
- Платящих клиентов — placeholder, accent emerald, muted
- MRR (₸ / мес) — placeholder, accent violet, muted
- Должники — placeholder, accent rose, muted
Second row (2 карточки):
- Пользователей (всего/активных)
- Регистраций за 30 дней — реальное значение
(COUNT Organizations WHERE CreatedAt >= now-30d)
Заглушки получили проп muted=true: фон чуть серее (slate-50/60),
значение «—» более бледным slate-400, иконка остаётся полноцветной
чтобы было видно «здесь будут данные после Phase 4». В hint —
«Скоро · после внедрения биллинга».
API: DashboardStats потерял TotalProducts/TotalSuppliesThisMonth,
получил RegistrationsLast30Days.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'
Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/super-admin переработан:
- Hero на indigo-градиенте: badge «Super Admin Console» + крупный H1
«Системная консоль» + подзаголовок + env+build справа.
- 4 KPI карточки с цветным круглым фоном иконки (indigo/sky/
emerald/amber), label uppercase tracking, значение крупно,
hint мелко серым; hover-shadow.
- Три карточки в ряд (lg:3 cols) вместо дублирующих ссылок-CTA:
«Здоровье системы» (заглушки ✅/Activity для скорых проверок),
«Активные организации» (топ-3 по объёму, ссылка «все»),
«Свежие события» (6 последних audit-записей или empty state
с Inbox-иконкой и текстом «Журнал пуст. Здесь появятся события»).
- Кнопка «Создать организацию» убрана с главной — оставлена
только на /super-admin/organizations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
<Logo> получил пропс variant="dark": в нём «FOOD» рендерится белым
(slate-50) вместо чёрного, «MARKET» — emerald-400 (вместо var brand)
для контраста на indigo-950 фоне SuperAdminLayout. SuperAdminLayout
прокидывает variant="dark" в обоих местах (desktop sidebar + mobile
header). Светлый вариант остался по умолчанию для tenant AppLayout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SuperAdmin при логине больше не попадает на tenant Dashboard
(Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами,
у него отдельная Системная консоль с системным sidebar и системным
дашбордом. Каждый из четырёх кейсов теперь визуально различим:
1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы:
Главная / Организации / Пользователи (скоро) / Журнал /
Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный
«Открыть организацию ▼» dropdown для быстрого override.
Title-suffix « · Super Admin» в browser-tab.
2. Обычный tenant-админ → /dashboard (как раньше).
3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant
sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона
body чтобы периферийным зрением было ясно «не моя админка».
4. SuperAdmin без override руками вбивает /products → TenantRouteGuard
редиректит на /super-admin/organizations + alert «Откройте
конкретную организацию через "Открыть как…"».
Изменения:
- SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись
«Системная консоль», 7 пунктов меню (5 active + 4 «скоро»),
user-карточка с «Super Admin», topbar с org-picker dropdown,
redirect на /dashboard если активен override.
- TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override)
и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз.
- App.tsx: setup wizard вне layout'а; /super-admin/* через
SuperAdminLayout (nested routes); остальное через TenantRouteGuard +
AppLayout.
- AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один
пункт «Системная консоль» как точку возврата. Тонировка фона
amber-50/60 при override.
- LoginPage: после успешного login — если me.roles.SuperAdmin и target
это /, /dashboard → redirect на /super-admin.
- SuperAdminDashboardPage: блок «Последние события» — 10 записей
audit-log + ссылка на полный журнал.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Edit модалка для кастомной роли (не системной, существующей) теперь
показывает amber-плашку перед матрицей прав — напоминает что галка
тут касается N людей сразу. Системные роли — без плашки (там
disabled чекбоксы).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В режиме «открыть как…» SuperAdmin может временно (на 30 минут)
включить редактирование с обязательной причиной — каждая успешная
мутация пишется в SuperAdminAuditLog. Чтобы лог был полезным:
API:
- Header X-Org-Override-Reason. Если присутствует и trimmed >= 10
символов — ReadonlyOverrideMiddleware пропускает мутации (вместо 403).
- SuperAdminEditAuditFilter — глобальный IAsyncActionFilter после
controller'а: при наличии обоих headers + успешном статусе 2xx +
методе POST/PUT/PATCH/DELETE пишет запись ActionType=EditEntity
с reason, описанием «METHOD /path → 200», IP и SuperAdminUserId.
Регистрируется как Scoped + AddService<>() в AddControllers.
Web:
- enableEditMode(reason)/disableEditMode/getEditMode в lib/api.ts —
хранение в localStorage с expiresAt = now + 30мин. Axios interceptor
добавляет header только пока edit активен и не истёк.
- SuperAdminAsOrgBanner расширен: цвет меняется amber→red в edit-mode,
кнопка «Включить редактирование» открывает модалку с textarea
reason (≥10 символов) + чекбокс согласия на аудит. После активации
баннер показывает «EDIT-MODE (N мин)», кнопка «Снять edit» отключает
до истечения таймера.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.
API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
И header X-Org-Override присутствует — возвращаем его как tenant
(вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
(управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
обычный юзер ничего подменить не может.
Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
в actions; клик → setOrgOverride → reload в режим просмотра.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Раздел /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>
Раздел /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>
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>
Базовый 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>
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>
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>
После ревью 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>
В 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>
Дефолтная страница после логина (/) — 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>
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>
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>
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>
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):
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>
складов и касс в раздел «Настройки организации»; 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>
Старый 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>
Нативный <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>
Кастомный 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>
Календарь приведён к виду нативного 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>
Новый компонент <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>
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).
Формат: «Арт: 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>
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>
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>
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/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>
Сценарий — приёмщик подряд сканирует 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>
Корень проблемы: 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>
Валюты — закрытый сидируемый список (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>
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>
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>
Внутри секции «Цены» теперь двухколоночная сетка (lg:grid-cols-2):
закупка слева, цены продажи справа, с вертикальным разделителем.
На узких экранах (<lg) колонки складываются вертикально, как раньше.
В правой колонке цены продажи переведены на стандартный <Field>
с label сверху, чтобы выравниваться с полями закупки.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В таблице позиций приёмки заголовок «Розничная (карточка)» теперь берёт
имя из справочника типов цен (priceTypes.find(isSystem)) — так чтобы
название совпадало с тем, что отображается в карточке товара и в
самом справочнике. Если системного типа нет — фолбэк на IsRetail,
а затем «Розничная».
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Колонку 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>
Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.
Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Заменили нативный <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>
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>
- Удаление поля «Срок годности (дней)»:
• 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>
Баг: переключение «Обязательная» в /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>
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>
Сервер 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>
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>
Раньше каждая цена в карточке товара рендерилась как dropdown «выбор
PriceType» + поле ввода + кнопка удаления. Это было избыточно:
типы цен и так фиксированы справочником, выбирать нечего.
Теперь:
- Идём по справочнику PriceType (отсортирован по SortOrder→Name).
- На каждый PriceType — одна строка: label = pt.Name, поле MoneyInput.
- IsRequired запись помечается красной звёздочкой * после имени.
- Стерев значение — строка убирается из form.prices (UI пусто).
- Введя значение — создаётся новая запись (currency = KZT
fallback из справочника), либо обновляется существующая.
- Кнопка «+ Добавить» и иконка удаления убраны — управление набором
типов цен теперь только через «Настройки → Типы цен».
addPrice/removePrice вспомогательные функции удалены за ненадобностью.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Field.tsx: новый компонент PercentInput (брат MoneyInput) — только
цифры + точка/запятая, до 2 знаков после, суффикс «%». На onBlur
нормализуется в Math.round(n * 100) / 100. null = пусто.
Использует тот же draft-pattern что MoneyInput, чтобы при наборе
«12.» точка не пропадала.
- ProductGroupsPage:
• поле «Наценка %» в модалке заменено на PercentInput,
• в колонке «Наценка» таблицы теперь inline-PercentInput с
автосохранением через PUT /api/catalog/product-groups/{id} и
инвалидацией листинга после ответа сервера. Click stopped — клик
в инпут не открывает модалку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProductEditPage:
- В секции «Основное» добавлено поле «Срок годности (дней)» рядом с
Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле».
- form.shelfLifeDays хранится строкой и сериализуется в payload как
number | null.
ProductsPage filters:
- Добавлен диапазон «Срок годности (дней) — от / до».
- Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation).
ProductsController.List:
- Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет
≥ / ≤ к p.ShelfLifeDays.
- Также теперь принимает referencePriceFrom / referencePriceTo (новые
имена); старые purchasePriceFrom/To работают как алиасы для
обратной совместимости с уже отрендеренным UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supply edit page:
- Кнопки «Провести / Отменить проведение» заменены на чекбокс «Проведено»
у заголовка. При попытке отметки — confirm «После проведения товары
будут оприходованы на склад. Продолжить?», при снятии — confirm
«Снять проведение? Остатки откатятся, себестоимость останется
(пересчитать вручную при необходимости).».
- Чекбокс disabled пока хотя бы одна строка пустая (Quantity ≤ 0
или UnitPrice ≤ 0) или вообще нет строк.
- Backend (post/unpost) уже корректно делает StockMovement и
пересчёт цен из Phase3a — UI просто переключает status.
Products list:
- Колонка «Эталонная цена» заменена на колонку системной розничной.
Заголовок = Name той PriceType что IsSystem=true (если пользователь
переименовал «Розничная цена» → «Продажная цена», заголовок
колонки автоматически становится «Продажная цена»).
- Значение = Product.Prices[ системного PriceType ].Amount.
Если у товара нет такой записи — «—» (тире).
- Подпись фильтра «Закупочная цена» → «Эталонная цена» (поведение
фильтра по диапазону цены остаётся прежним).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
В таблице строк приёмки добавлена колонка «Розничная (карточка)».
- Значение по умолчанию — текущая дефолтная розничная цена товара
(берётся из ProductDto.prices при подборе или из SupplyLineDto.
currentRetailPrice при загрузке существующего документа).
- Любая ручная правка ставит retailPriceManuallyOverridden=true и
записывает retailPriceOverride. При проведении документа этот
override применяется к Product.Prices[default] вместо автонаценки.
- В payload PUT/POST шлём retailPriceManuallyOverridden и
retailPriceOverride (либо null если override снят).
- types.SupplyLineDto расширен полями currentRetailPrice /
retailPriceManuallyOverridden / retailPriceOverride.
- В addLineFromProduct unitPrice fallbacks теперь учитывают cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
в модалке редактирования с подсказкой про формулу
⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
правка % наценки по группам. Inline TextInput, считается diff,
кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- OrganizationSettingsPage: 2 новые галки —
«Несколько типов цен (Опт, VIP и т.п.)» (multiplePriceTypesEnabled)
и «Показывать «Эталонную цену» на товаре» (showReferencePriceOnProduct).
- ProductEditPage:
• «Эталонная цена» с подписью «не обязательное поле»; рендерится только
при showReferencePriceOnProduct=true.
• «Себестоимость» — readonly MoneyInput, всегда виден; подпись
«расчётная (скользящее среднее)».
• Заголовок секции цен меняется: «Цены продажи» при multipleEnabled,
иначе «Розничная цена».
• Кнопка «Привести к себестоимости» (только для existing товара) —
POST /api/catalog/products/{id}/recalc-retail. После 200 — обновляем
дефолтный PriceType в form.prices, инвалидируем кэш. После 400 —
показываем сообщение в общий error.
- useOrgSettings.ts: добавлены поля multiplePriceTypesEnabled и
showReferencePriceOnProduct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/catalog/products/{id}/recalc-retail (Admin/Manager/Storekeeper):
- Если у Group товара задан MarkupPercent — записывает в дефолтный
розничный PriceType значение ceil(Cost * (1 + pct/100)). Округление
под AllowFractionalPrices: до сотых при включённом, до целого иначе.
- Возвращает 400 «У группы не задана наценка. …» если MarkupPercent null.
- Возвращает 400 если нет ни одного активного PriceType.
- Использует Organization.DefaultCurrencyId как fallback при создании
новой записи цены.
Background/ReferencePriceRefreshJob (IHostedService, PeriodicTimer 24ч):
- Раз в сутки находит товары с LastSupplyAt < now-30d и Cost > 0,
переписывает ReferencePrice = Cost, обновляет ReferencePriceUpdatedAt.
- IgnoreQueryFilters — работает над всеми organizations.
- Стартовая задержка 5 минут чтобы не пересечься с пендинг-миграцией.
- Зарегистрирован через AddHostedService в Program.cs.
- Hangfire не подключаем как полноценный server — IHostedService даёт
тот же эффект без отдельной schema/dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
При проведении приёмки (POST /api/purchases/supplies/{id}/post):
- Себестоимость товара пересчитывается по скользящему среднему по
ВСЕМ складам организации:
newCost = (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
При qty_old = 0 или cost_old = 0 → newCost = price_in.
Хранится с 4 знаками (Math.Round AwayFromZero).
- ReferencePrice автозаполняется UnitPrice'ом первой Posted приёмки.
- LastSupplyAt = UtcNow.
- Розничная (дефолтный PriceType, IsDefault → IsRetail → SortOrder/Name):
• если у строки RetailPriceManuallyOverridden=true и есть
RetailPriceOverride — пишем его как розничную (override per-line),
• иначе если у Group задан MarkupPercent — пишем
Math.Ceiling(cost * (1 + pct/100)) с округлением:
при AllowFractionalPrices=true — до сотых, иначе до целого,
• иначе — розничная не трогается.
Если в Product.Prices ещё нет записи под дефолтный PriceType —
создаётся (currency = supply.CurrencyId).
- Всё в одной транзакции, ApplyMovementAsync вызывается ПОСЛЕ
расчёта Cost (currentQty снимается до приёмки).
SupplyLineInput/SupplyLineDto расширены полями RetailPriceManuallyOverridden,
RetailPriceOverride; в DTO дополнительно CurrentRetailPrice — текущая
дефолтная розничная цена товара (для отображения в UI приёмки).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Подготовка к новой модели цен сторонняя система-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>
Юзер ловил 500 «DbUpdateConcurrencyException: 0 rows affected» при PUT
/api/catalog/products. RemoveRange(всех детей) + Add новых на каждом
сохранении генерирует массовый DELETE/INSERT, при котором EF ожидал N
rows affected, а реальный DELETE возвращал меньше — и весь батч падал
с 500.
Чиню по-человечески:
- Merge by stable key: barcodes по Code, prices по PriceTypeId.
Совпавшие — обновляем поля, лишние удаляем, новые добавляем. Минимум
записей в SaveChanges, минимум поводов для 0-affected.
- Catch DbUpdateConcurrencyException → 409 «Товар изменён в другом
окне или сессии. Перезагрузите страницу и попробуйте снова.» вместо
непрозрачного 500.
- Удалена мёртвая ветка `if (input.Vat is null) e.Vat = existingVat`:
Apply уже не присваивает Vat при null, ничего восстанавливать не
нужно.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
formatFromValue использовал String(v) для дробного режима, поэтому
целые значения в БД (1317, 1860) показывались как «1317» / «1860»
даже при включённой галке «Разрешить дробные цены». Теперь:
- fractional=true → v.toFixed(2): 1317 → «1317.00», 1317.5 → «1317.50»;
- fractional=false → String(Math.round(v)): 1317.5 → «1318».
Хелпер используется в init useState, useEffect-синке и commitDraft —
таким образом при onBlur поле всегда возвращается к корректному
формату «1317.00».
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.
Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.
- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
наружу onChange(number) отдаём сразу когда draft парсится в число
(включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
перебивает ввод пользователя.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Главная причина бага: useOrgSettings имел staleTime=5 минут. Когда
пользователь переключал галку в /settings/organization, инвалидация
ключа `/api/organization/settings` помечала кэш stale, но MoneyInput
в открытой форме товара продолжал получать старое значение из кэша
до момента, пока сабскрайбер не делал перефетч. На свежем монтировании
(переход с /settings обратно на /catalog/products/...) перефетча тоже
не происходило, т.к. данные считались всё ещё валидными.
Фикс:
- useOrgSettings: staleTime=0 + refetchOnMount: 'always' +
refetchOnWindowFocus=true. Каждое монтирование компонента — свежие
настройки одним лёгким GET'ом. Цена нулевая, цикл «изменил настройку
— открыл форму — увидел эффект» теперь работает без перезагрузки.
- OrganizationSettingsPage: useEffect синхронизирует form со settings.data
без условия `!form` — при свежем рефетче форма тоже подтягивает актуальное.
Все вызовы MoneyInput в формах уже не передают allowFractional —
компонент сам читает useOrgSettings, что вместе с фиксом выше даёт
корректное поведение.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MoneyInput теперь сам читает useOrgSettings().allowFractionalPrices,
а не только полагается на prop из вызова. Это закрывает два бага:
1. Когда настройка известна и запрещает дробное, но в state товара
лежит дробная цена (например исторические данные из OtherSystem) —
useEffect синхронизирует округлённое значение наружу, чтобы
при сохранении не уходило значение, которого юзер не видит.
2. Пока org.data ещё не загружено, MoneyInput не режет дробь
(fractional трактуется как true до приезда настройки), иначе
в момент гидрации формы из ProductDto с дробной ценой компонент
успевал её обрезать до того как настройка приходила.
Все вызовы MoneyInput в ProductEdit / SupplyEdit / RetailSaleEdit /
ProductsPage filters очищены от избыточного prop allowFractional —
компонент берёт настройку сам. Override через prop остаётся доступным.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Поскольку штрихкод теперь обязательный (минимум 1 у каждого товара),
фильтр «Со штрихкодом» бессмыслен — убран из UI и контроллера.
Вместо него — два MoneyInput «Закупочная цена от/до» в панели
фильтров. Использует символ валюты по умолчанию из настроек
организации и уважает AllowFractionalPrices.
Backend: ProductsController.List принимает purchasePriceFrom /
purchasePriceTo (decimal?), применяет ≥ / ≤ к PurchasePrice;
параметр hasBarcode удалён.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)»
(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>
- 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>
UI:
- Pagination: ввод страницы заменён на select (option 1..totalPages),
по выбору сразу setPage. Стрелки ← → остаются.
- Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и
NumberInput (decimal без валюты). Оба фильтруют ввод регулярно
(только цифры + точка/запятая→точка), при focus — выделяют значение.
- ProductEditPage: purchasePrice / vat / minStock / maxStock / amount
в ценах продаж переведены на новые компоненты; символ валюты —
из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг.
- SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount
в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput
с символом из form.currencyId.
- CountriesPage: vatRate — NumberInput.
API:
- ProductInput / ProductPriceInput / SupplyLineInput /
RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)]
на денежные/количественные поля и [Range(0,100)] на проценты.
ASP.NET автоматически валидирует и возвращает 400 при выходе.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Система теперь корректно работает на узких экранах (<768px):
- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
(Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
горизонтальный padding на мобиле. Родительский overflow-auto даёт
плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
При POST /api/catalog/products если Article пустой — сервер берёт
max(Article::int) среди артикулов текущей организации и ставит +1.
Если числовых артикулов нет — «1». Пользователь может указать
артикул руками, тогда используется его значение.
Конфликт по unique index IX_products_OrganizationId_Article на
SaveChanges перехватывается — возвращается 400 с текстом «Артикул
«X» уже занят в этой организации.» вместо 500. Та же обработка
добавлена в Update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
В таблице товаров:
- «Ед.» → «Фасовка» (packagingLabel из типа товара, sort packaging).
- «Штрихкодов» (count) → «Штрихкод» — первый код монoshrift.
- Убраны колонки «Группа» и «Активен».
- Добавлена «Закупочная цена» с форматированием «305.00 ₸»
(purchasePrice + purchaseCurrencyCode), sort purchasePrice.
На сервере ProductsController.List принимает новые sort keys
packaging и purchasePrice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.
Миграция Phase5f_ShowMinMaxStock добавляет колонку.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды
под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A
с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых).
- ProductEditPage: при смене типа штрихкода в dropdown поле кода
регенерируется под новый формат.
- Field.tsx: единая высота h-10 и leading-none для TextInput/Select
чтобы Страна/Валюта/НДС в настройках были одного размера.
TextArea оставлен с h-auto для multiline.
- Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M»
для прыжка на произвольную страницу (Enter / blur применяют).
- ProductEditPage: блок мин/макс остатков теперь показывается только
при org.showMinMaxStock (сама настройка добавится следующим коммитом).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).
- 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>
Добавлены 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>
Новый штрихкод в товаре сразу получает валидный EAN-13 с префиксом
"2" (зарезервирован под внутренние штрихкоды магазина, не конфликтует
с GTIN производителей). Пользователь может заменить на реальный
считанный — поле остаётся редактируемым.
Утилита lib/barcode.ts::generateEan13InternalPrefix2() генерирует
11 случайных цифр после "2" и дописывает контрольную сумму EAN-13.
Уникальность штрихкода в организации уже обеспечивает
IX_product_barcodes_OrganizationId_Code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Все read-only поля (Валюта, Ставка НДС) теперь единого вида: disabled
TextInput на всю ширину ячейки в grid-cols-2 gap-4, та же высота/padding
что и у editable Select "Страна". Единая подсказка про источник правды
страны вынесена одним параграфом под сеткой.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный default (обычно по Name ASC).
Реализация:
- PagedRequest: добавлены Sort (ключ колонки) и Order ("asc"/"desc"),
плюс удобное свойство Desc.
- DataTable: Column.sortKey + props sortKey/sortOrder/onSortChange,
в заголовке появляется иконка (ArrowUpDown/ArrowUp/ArrowDown).
- useCatalogList: хранит sortKey/sortOrder, отдаёт setSort, шлёт
?sort=&order= в query-string.
- Все 10 List-эндпоинтов (Countries, Currencies, UnitsOfMeasure,
PriceTypes, Stores, RetailPoints, Counterparties, ProductGroups,
Products, Supplies, RetailSales + Stock/Movements) принимают
параметры и применяют switch-based OrderBy по whitelisted ключам.
- Все страницы со списками прокидывают sort state и sortKey на
колонках, где сортировка имеет смысл (тексты/числа/даты).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
- Currency в настройках больше не выбирается, показывается disabled
как "KZT (₸)", источник правды — Country.DefaultCurrencyId.
- Backend: OrgSettingsInput больше не принимает DefaultCurrencyId;
Update синхронизирует Organization.DefaultCurrencyId со страной.
- UX: страна — единственный редактируемый вход, определяет и НДС, и валюту.
- Мульти-валютный режим (Organization.MultiCurrencyEnabled) остаётся
галкой; выбор валюты в закупках/продажах/карточке товара по-прежнему
скрыт когда флаг выключен.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 других этапов — не задеты.
Backend:
- ProductImagesController: GET list / POST multipart upload /
DELETE / POST set-main.
- Файлы лежат в $ContentRoot/uploads/products/{productId}/{guid}.{ext}
(volume /opt/food-market-data/uploads:/app/uploads в compose).
- В БД хранится относительный URL /uploads/products/{id}/{file}.
- UseStaticFiles на /uploads — публичная раздача (без auth).
- Допустимые расширения: jpg/jpeg/png/webp/gif, до 10 МБ.
- При первой загрузке картинка становится основной; Product.ImageUrl
синхронизируется с "основной".
- Удаление основной переводит "основной" флаг на следующую оставшуюся.
Web-nginx: /uploads/ проксируется на api:8080.
Web UI:
- Компонент <ProductImageGallery>: превьюшки 80×80 в грид,
при наведении — кнопки "сделать основным" и "удалить",
клик на превью → fullscreen lightbox с навигацией ←→ и счётчиком.
- В ProductEditPage убран инпут "URL изображения" (был технической
строкой для копипаста), вместо него блок "Изображения" с галереей.
Показывается только для уже сохранённого товара (есть id).
Docker compose: добавлен bind-mount /opt/food-market-data/uploads.
Миграция 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 добавлен раздел 'Настройки → Организация', убран
Ставки НДС (сущность удалена раньше).
В списке товаров колонка с бейджами Услуга/Весовой/Маркируемый только
занимает место и ничем не помогает — в UX OtherSystem такого нет, убираю.
В форме редактирования минимальный/максимальный остаток (для
уведомлений о пополнении и автозаказа) — второстепенные поля, ушли в
раскрывающийся блок "Расширенные параметры" сразу после блока закупки.
Закупочная цена осталась основной.
НДС-колонка теперь рисует "—" если VatEnabled=false.
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
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>
Почему импорт раньше обрывался на ~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 —
существующие товары обновит, недостающие подтянет.
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
переключает фильтр ProductsPage. Клик на "Все товары" — показать весь
каталог. Выбор группы включает её поддерево (матчинг по Path prefix
на бэкенде, чтобы сабгруппы тоже попадали в выборку).
- Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами
(all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом.
Счётчик в кнопке показывает количество активных не-дефолтных фильтров.
- "Сбросить" очищает всё, кроме группы.
API:
- ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`.
`groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной
группы) — это ближе к UX OtherSystem.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Раньше архивных контрагентов/товаров 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
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
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 и пр., которых в коде давно нет).
Локально применилось без ошибок.
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил 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 с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
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 безопасен.
Проверил через 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>
У 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>
API: GET /api/sales/retail/stats?days=30 — возвращает:
- revenueToday + transactionsToday
- revenueThisMonth + transactionsThisMonth + avgTicketThisMonth
- revenuePrevMonth (для сравнения месяц-к-месяцу)
- series — массив дневных точек {bucket, revenue, transactions} с заполнением
пустых дней нулями (чтобы линия графика была непрерывной)
- считает только проведённые чеки (Status == Posted)
Web:
- recharts добавлен (3.8.1)
- SalesChart компонент: AreaChart с градиент-заливкой брендового зелёного,
ось X — дни (DD.MM), ось Y — выручка, tooltip с числами и валютой
- DashboardPage пересобран под продажи как первичную инфу:
- 4 KPI-карточки сверху: выручка сегодня, выручка за месяц (с дельтой
к прошлому месяцу), средний чек, прошлый месяц
- график за 30 дней с empty-state когда чеков нет
- Каталог теперь второстепенный (мелкие карточки внизу)
Empty-state: если за 30 дней не было ни одной продажи — показываем
"График появится когда появятся первые продажи" вместо плоской линии.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Login on https://food-market.zat.kz failed because DevDataSeeder skipped
in non-Dev envs, so the demo admin account never existed on stage.
Seeder is idempotent — checks-then-creates for every entity. Safe to run
on every startup in any env. Once a real org/admin replaces the seeded
demo, this seeder is a no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Stage deploy crashed in CrashLoopBackoff because the production container
landed on an empty fresh Postgres, then OpenIddictClientSeeder hit
"relation public.OpenIddictApplications does not exist". The Migrate()
call was guarded by IsDevelopment() so prod never bootstrapped.
Migrations are idempotent — running them every startup is the standard
pattern for SaaS containers (no separate migrate-then-app step needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.
New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).
ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.
DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.
PageHeader gained a `variant="bar"` mode for shell usage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
User is importing the real catalog from OtherSystem — the placeholder KZ-market
demo products I seeded would just pollute the results. Nuked via:
TRUNCATE product_prices, product_barcodes, products, product_groups,
counterparties CASCADE;
DemoCatalogSeeder stays in the source tree, commented out in Program.cs —
anyone running without OtherSystem access can re-enable it by uncommenting
one line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced after the previous gzip-removal:
1. OtherSystem's nginx edge returned 415 on some requests without a User-Agent.
Send a friendly UA string (food-market/0.1 + repo URL).
2. Previous fix dropped gzip support entirely; re-enable it properly by
configuring AutomaticDecompression on the typed HttpClient's primary
handler via AddHttpClient.ConfigurePrimaryHttpMessageHandler. Now the
response body is transparently decompressed before the JSON deserializer
sees it — no more 0x1F errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HttpClient in DI isn't configured with AutomaticDecompression, so OtherSystem
returned a gzip-compressed body that ReadFromJsonAsync choked on (0x1F is
the gzip magic byte). Cheapest correct fix is to not advertise gzip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The typed MediaTypeWithQualityHeaderValue API adds a space after the
semicolon ("application/json; charset=utf-8"), and OtherSystem rejects anything
other than the exact literal "application/json;charset=utf-8" with error 1062.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OtherSystem rejects application/json without charset=utf-8 with error 1062
"Неверное значение заголовка 'Accept'". They require the exact value
"application/json;charset=utf-8".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs showed every outbound OtherSystem call was hitting
https://api.other-system.ru/api/remap/entity/organization
instead of the intended
https://api.other-system.ru/api/remap/1.2/entity/organization
Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against
a base URI whose path does not end with '/', the last segment of the base
path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…"
produced "…/api/remap/entity/…". OtherSystem returned 503 and we translated
it into a useless "401 сессия истекла" for the user.
Fixes:
- Append trailing slash to BaseUrl.
- Surface the real upstream status + body: OtherSystemApiResult<T> wrapper,
and the controller now maps 401/403 → "invalid token", 502/503 →
"OtherSystem unavailable", anything else → "OtherSystem returned {code}: {body}".
No more lying-as-401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ASP.NET Core's [Authorize(Roles=...)] relies on ClaimsIdentity.RoleClaimType to
match, which may not be wired to "role" in the OpenIddict validation handler's
identity (depending on middleware order with AddIdentity). Tokens clearly carry
"role": "Admin" but IsInRole("Admin") returns false.
- Register AddAuthorization policy "AdminAccess" that checks the `role` claim
explicitly (c.Type == Claims.Role && Value in {Admin, SuperAdmin}). Works
regardless of how ClaimsIdentity was constructed.
- OtherSystemImportController now uses [Authorize(Policy = "AdminAccess")].
- Add /api/_debug/whoami that echoes authType, roleClaimType, claims, and
IsInRole result — makes next auth issue trivial to diagnose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the 404 on /api/admin/other-system/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
(cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
restart, silently invalidating any JWT already stored in the browser.
Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
first run, reused on subsequent starts. Dev tokens now survive API restarts.
Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
leaving the user stuck on a protected screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logo simplified to just "FOOD" (black) + "MARKET" (brand green) text — matches
the app-icon style without the distracting FM badge square.
OtherSystem import page now shows actionable error text instead of generic
"Request failed with status code 404":
- 404 → "эндпоинт не существует, API не перезапущен после git pull"
- 401 → "сессия истекла, перелогинься"
- 403 → "нужна роль Admin или SuperAdmin"
- 502/503 → "сторонняя система недоступен"
- Otherwise extracts body.error / error_description / title from response
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Infrastructure (foodmarket.Infrastructure.Integrations.OtherSystem):
- OtherSystemDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- OtherSystemClient: HttpClient wrapper with Bearer auth per call
- WhoAmIAsync (GET entity/organization) for connection test
- StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
- GetAllFoldersAsync (all product folders in one go)
- OtherSystemImportService: orchestrates the full import
- Creates missing product folders with Path preserved
- Maps OtherSystem VAT percent → local VatRate (fallback to default)
- Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
- Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
- Extracts buyPrice → PurchasePrice
- Skips existing products by article OR primary barcode (unless overwrite flag set)
- Batch SaveChanges every 500 items to keep EF tracker light
- Returns counts + per-item error list
API: POST /api/admin/other-system/test — returns org name if token valid
API: POST /api/admin/other-system/import-products { token, overwriteExisting }
— Authorize(Roles = "Admin,SuperAdmin")
Web: /admin/import/other-system page
- Amber notice: token is not persisted (request-scope only), how to create
a service token in other-system.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list
Sidebar adds "Импорт" section with OtherSystem link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract brand colors from food-market-app/.../AppIcon/appicon.svg
(background #00B207, white FOOD, pale-green E8F5E9 MARKET)
- Add @theme custom colors in index.css:
--color-brand #00B207, --color-brand-hover #009305, --color-brand-dark #007605,
--color-brand-light #E8F5E9, --color-brand-tint #D7F2D9, --color-brand-foreground #FFFFFF
- Replace all violet-* Tailwind classes with var(--color-brand*) in:
LoginPage, Button, Field (input+checkbox), SearchBar, AppLayout (nav active state)
- New Logo component: FM square badge + "FOOD" + "MARKET" typography in brand colors
- Put Logo in sidebar header and on LoginPage
- Replace Vite default favicon with branded SVG (green square + FOOD MARKET)
- Page title "FOOD MARKET", theme-color meta tag for mobile browsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starter experience so the system is usable immediately after git clone → migrate → run.
DemoCatalogSeeder (Development only, runs once — skips if tenant has any products):
- 8 product groups: Напитки (Безалкогольные, Алкогольные), Молочка, Хлеб, Кондитерские,
Бакалея, Снеки — hierarchical Path computed
- 2 demo suppliers: ТОО «Продтрейд» (legal entity, KZ BIN, bank details), ИП Иванов (individual)
- 35 realistic KZ-market products with:
- Demo barcodes in 2xxx internal range (won't collide with real products)
- Retail price + purchase price at 72% of retail
- Country of origin (KZ / RU)
- Хлеб marked as 0% VAT (socially important goods in KZ)
- Сыр «Российский» marked as весовой
- Articles in kebab-case: DR-SOD-001, DAI-MLK-002, SW-CHO-001 etc.
Product form (full page /catalog/products/new and /:id, not modal):
- 5 sections: Основное / Классификация / Остатки и закупка / Цены / Штрихкоды
- Dropdowns for unit, VAT, group, country, supplier, currency via useLookups hooks
- Defaults pre-filled for new product (default VAT, base unit, KZT)
- Prices table: add/remove rows, pick price type + amount + currency
- Barcodes table: EAN-13/8/CODE128/UPC options, "primary" enforces single
- Server-side atomic save (existing Prices+Barcodes replaced on PUT)
Products page: row click → edit page, Add button → new page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop ReactQueryDevtools floating button (the "palm tree" in corner)
- Dashboard now shows: greeting with user name, stat cards, user profile
(name/email/roles/orgId), and roadmap
- Add amber banner when API calls fail (typical cause: API not restarted
after pulling new catalog code) with explicit fix instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Application layer:
- PagedRequest/PagedResult<T> with sane defaults (pageSize 50, max 500)
- CatalogDtos: read DTOs with joined names + input DTOs for upsert
- Product input supports nested Prices[] and Barcodes[] for atomic save
API controllers (api/catalog/…):
- countries, currencies (global, write requires SuperAdmin)
- vat-rates, units-of-measure, price-types, stores (write: Admin/Manager)
- retail-points (references Store, Admin/Manager write)
- product-groups: hierarchy with auto-computed Path, delete guarded against children/products
- counterparties: filter by kind (Supplier/Customer/Both), full join with Country
- products: includes joined lookups, filter by group/isService/isWeighed/isActive,
search by name/article/barcode, write replaces Prices+Barcodes atomically
Role semantics:
- SuperAdmin: mutates global references only
- Admin: mutates/deletes tenant references
- Manager: mutates tenant references (no delete on some)
- Storekeeper: can manage counterparties and products (but not delete)
All endpoints guarded by [Authorize]. Multi-tenant isolation via EF query filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default launchSettings.json from `dotnet new web` picked random port 5039,
which doesn't match the Vite proxy target http://localhost:5081, so the
React app can't reach /connect/token during login.
- Fix http profile to 5081 (HTTPS to 5443)
- Remove IIS Express profile (Mac-only dev, not needed)
- Disable launchBrowser (we run Web separately)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>