Найдено в catalog-edge:
- DELETE контрагента, на которого ссылаются supplies/retail-sales/products
(DefaultSupplier), отдавал 500 (DbUpdateException 23503) вместо понятного
409. Добавлен явный чек использования → Conflict со списком где занят.
- POST товара с пустым Name проходил до FK-проверки и падал неинформативно;
теперь явный 400 с указанием поля. На ProductInput навешены
[Required]/[MinLength]/[StringLength] на Name/Article/ImageUrl — отсекаем
пустые и сверхдлинные значения на уровне модели.
catalog-edge: 12/12.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ
после ротации (auth-edge step03):
1. AuthorizationController прокидывал в новый principal только
AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId
из подписываемого principal, чтобы пометить погашаемый refresh как
Redeemed — без него старый токен оставался valid.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный
reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем
окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует
старый refresh сразу.
auth-edge: 10/10 (было 9/10).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Два P0-бага выявлены сценарием documents-edge:
BUG #11 (high): RetailSale.Post не проверял PaidCash+PaidCard ≥ Total.
Касса могла «провести» чек с фактической оплатой 0 — товар уходит
со склада, деньги не получены. Добавлена валидация: paid (округлённое
до 2 знаков) ≥ total, иначе 400 «Сумма оплаты меньше итога».
Сдача (PaidCash > Total) остаётся легальной.
BUG #12 (critical): Supply.Unpost не проверял, не уйдёт ли Stock в
минус после реверса. Сценарий: приёмка 10шт → продажа 5шт → unpost
приёмки ⇒ stock = -5. Это нарушение инварианта учёта. Добавлен guard:
агрегируем reverse-quantity по продукту, сравниваем с текущим
Stock.Quantity, при недостаче возвращаем 409 со списком конфликтных
строк.
Покрыто E2E documents-edge step05 (PaidCash<Total → 4xx) и step08
(unpost после sale → 409): обе проверки теперь зелёные.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Проблема: в режиме «открыть как…» (SuperAdmin + X-Org-Override) с reason ≥10
символов ReadonlyOverrideMiddleware пропускает PUT/POST/DELETE, но затем
контроллер падает 403 на атрибуте [Authorize(Roles="Admin,Storekeeper")] —
у SuperAdmin'а нет роли Admin тенанта. Результат: edit-mode фактически
не работает ни на одном tenant-эндпоинте.
Симптом, обнаруженный E2E:
step11_superadmin_edit_override_with_reason: PUT → 403 «Forbidden»,
super_admin_audit_log не растёт.
Фикс: новый SuperAdminOverrideClaimsTransformer (IClaimsTransformation).
При каждом запросе с заголовком X-Org-Override и ролью SuperAdmin
временно добавляет роли Admin/Storekeeper/Cashier в principal — только
для этого запроса. Изоляция и аудит остаются:
- query filter всё равно скоупится через X-Org-Override (см.
HttpContextTenantContext.TryGetHttpOverrideOrg).
- SuperAdminEditAuditFilter пишет SuperAdminAuditLog с reason
при успешном 2xx ответе.
Проверено E2E multi-tenant-isolation: 12/12 шагов проходят.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.
Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
(400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
автоматически выбирают «Все товары» (или единственную группу), чтобы
пользователь мог сохранить продукт без лишнего клика.
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.
Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
ConstraintName и возвращает 400 вместо 500. Защита для случая когда
Guid не пуст, но указывает на удалённую/чужую запись.
TaskUpdate: closes step08-bug «Supply без supplierId → 500».
Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204
[Posted] и стоки уходили в минус. Это критично — кассир мог провести
чек на товар, которого нет, и в БД появлялся отрицательный остаток.
Что изменено:
- В Post перед SaveChanges собираем сумму запрошенного qty по каждому
productId (учитываем дубль одного товара в нескольких строках чека).
- Читаем stocks.Quantity для всех затронутых productId на конкретном
StoreId одним запросом.
- Если хоть по одной строке available < requested — возвращаем 409
с body {error, lines:[{productId, productName, qty, available}]},
не делаем SaveChanges.
- Всё под BeginTransactionAsync(Serializable): защищает от race condition
между двумя одновременными post'ами на один товар (без блокировки оба
бы прочли «5», списали по 3, получили бы −1).
Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.
Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
и форме редактирования.
Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
Было: каждая орга держала свои 5 копий («штука», «кг», ...). 95 строк
в БД на 19 орг — duplication, любой Admin мог их редактировать.
Стало: 5 globals (OrganizationId=NULL), CRUD только у SuperAdmin. Орга
включает нужные единицы у себя через junction org_units_of_measure.
Backend:
- UnitOfMeasure: добавлен IsActive (для soft-delete с filtered unique index)
- Новый OrgUnitOfMeasure (junction PK Organization+Unit, FK Restrict)
- Migration Phase5c_UnitsOfMeasureGlobal: безопасная для prod —
поднимает по одной строке на (Code, Name) до global, remap'ит
products.UnitOfMeasureId, наполняет junction по факту существующих
привязок, удаляет дубликаты.
- /api/catalog/units-of-measure для org Admin: read-only список
enabled-globals + POST/DELETE /enable для toggle
- /api/super-admin/units-of-measure: full CRUD; DELETE soft (IsActive=false)
с 409 если есть products или active org-junction (со списком орг)
- DevDataSeeder.SeedTenantReferencesAsync вместо создания per-tenant
юнитов — auto-enable всех active globals через junction
Frontend:
- /catalog/units — checkbox-список (включить/выключить); CTA на платформу
для SuperAdmin
- /super-admin/units — full CRUD над глобалами, 409 со списком
организаций при попытке деактивировать используемую единицу
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст
в Phone — серверной валидации ФЛК не было (только в /api/auth/signup).
Это позволяло сохранить «abc» в Organization.Phone и невалидные номера
для контрагентов и сотрудников.
— Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz +
IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»;
валидно: 11 цифр, начинается с «77» (мобильный код KZ).
— SuperAdminOrganizationsController.Create/Update: 400 если phone не
парсится; в БД пишется нормализованная форма «+77001234567».
— CounterpartiesController.Create/Update: то же. Apply() нормализует.
— EmployeesController.Create/Update: то же.
— SuperAdminEmployeesController.Create/Update: то же.
— AuthSignupController: убран локальный NormalizeKzPhone, используется
shared. Сообщение об ошибке унифицировано.
Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone).
Незаполненный phone остаётся валидным для опциональных полей —
контроллер сам решает требовать или нет.
Найдено в e2e-прогоне (отчёт reports/full-cycle-2026-05-07-baseline.md):
- GET /api/organization/employees вернул 200 для Cashier (ожидалось 403).
- Cashier у созданного через POST /employees вообще не получает
Identity-роли — серверная авторизация не работает.
Корни:
1. EmployeesController имел class-level [Authorize] без roles,
List/Get не имели per-method [Authorize(Roles=...)] — поэтому любой
аутентифицированный юзер мог читать список сотрудников.
2. EmployeesController.Create при createAccount=true вызывал
_userMgr.CreateAsync, но НЕ вызывал AddToRoleAsync — у созданного
юзера не было ни одной Identity-роли.
Фиксы:
- Class-level `[Authorize(Roles = "SuperAdmin,Admin")]` на
EmployeesController. Теперь List/Get/Create/Update/Delete все
требуют Admin (или SuperAdmin override). Per-method дубль убран.
- Новый helper `Api/Infrastructure/IdentityRoleMapper.cs`:
Администратор → Admin, Кладовщик → Storekeeper, Кассир → Cashier.
Кастомные orgRole не получают Identity-роли (это by-design — они
дают UI-permissions внутри org, без доступа к role-locked endpoint'ам).
- EmployeesController.Create вызывает AddToRoleAsync с замапленной
Identity-ролью если такая есть.
- SuperAdminEmployeesController.Create аналогично — вместо хардкод
"Admin" использует mapper с fallback на "Admin" (по запросу юзера
при создании учётки SuperAdmin'ом).
После фикса в e2e:
- Cashier → GET /api/organization/employees → 403 (было 200).
- /connect/token → /api/me содержит roles=["Cashier"].
- Cashier → /api/sales/retail-sales → 200 (рабочая авторизация).
Пункты 5 + 6 пакета SMTP-настроек.
API (AuthForgotPasswordController, anonymous):
- POST /api/auth/forgot-password { email }
· IP rate-limit: 3 попытки в час (in-memory ConcurrentDictionary
с per-IP списком timestamps; для одного API-инстанса хватает,
при scale-out — Redis).
· ВСЕГДА возвращает 200 (анти-юзер-энумерация). Реально шлёт письмо
только если юзер найден И активен И имеет email; иначе тихо
логирует и отдаёт 200.
· Использует UserManager.GeneratePasswordResetTokenAsync (Identity
AddDefaultTokenProviders уже подключён в Program.cs).
· Письмо: ссылка вида https://admin.food-market.kz/reset-password
?email=...&token=... (1 час валидна).
· Если SMTP не настроен — ловит EmailNotConfiguredException,
логирует и всё равно отдаёт 200 (UX-friendly).
- POST /api/auth/reset-password { email, token, newPassword }
· UserManager.ResetPasswordAsync. На InvalidToken — понятный
«Ссылка недействительна или истекла».
· После успеха revoke всех valid-OpenIddict tokens юзера
(UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject=...).
UI:
- /forgot-password — anonymous, форма с email; submit → 200 «проверьте
почту» (одинаковый текст независимо от существования email).
- /reset-password — anonymous, читает email/token из query-string;
поля «новый пароль» + «повторите»; после успеха — auto-redirect
через 2.5 секунды на /login.
- LoginPage: добавлена ссылка «Забыли пароль?» под кнопкой «Войти».
Smoke-флоу:
1. SuperAdmin → /super-admin/platform-settings → SMTP creds + test-send.
2. Юзер → /login → «Забыли пароль?» → /forgot-password → email.
3. Письмо с ссылкой → /reset-password?email&token → новый пароль.
4. Login со старым паролем — отказ (revoked refresh + новый pwd).
5. Login с новым паролем → норма.
Пункт 2 + 3 пакета SMTP-настроек.
Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
одного письма; EmailNotConfiguredException — для контроллеров чтобы
ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
· регистрируется Singleton, на каждой отправке открывает scope для
свежего AppDbContext (конфиг перечитывается без рестарта);
· читает PlatformSettings из БД, расшифровывает пароль через
IDataProtector("foodmarket.smtp");
· поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
оба false → открытое соединение (для dev/MailHog);
· бросает EmailNotConfiguredException если host или from-email пусты,
или если расшифровка пароля падает (ключ DataProtection ротировался).
API:
- PlatformSettingsController:
GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
(только has-password флаг + updatedAt).
PUT — принимает Reason (≥10) + все поля + опциональный
NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
Пароль шифруется через DataProtection при записи. Audit-log.
POST /test-send — реальная отправка через текущие настройки;
ловит EmailNotConfiguredException → 400, остальные → 500
с message (SuperAdmin-only, diagnostic-info нужна).
DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).
Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
был через OpenIddict, но Infrastructure нужен явный reference).
Полное физическое удаление сотрудника невозможно — у него FK из
retail_sales и supplies. Поэтому теперь два шага:
IsActive=true → активный
IsActive=false + FiredAt → уволен (кнопка «Уволить»)
IsActive=false + IsDeleted=true + DeletedAt → удалён (кнопка «Удалить»)
— Domain: Employee получил поля IsDeleted/DeletedAt + миграция
Phase5a_EmployeeSoftDelete (drop column возможен через Down).
- API EmployeesController.Delete:
· если активен — переводит в Fired;
· если уже уволен — ставит IsDeleted=true + DeletedAt;
· если уже удалён — 409 Conflict;
· гарды Owner и self применяются на ОБОИХ шагах.
- API EmployeesController.List: новый query-param ?status=
active|fired|deleted|all (default: всё кроме deleted).
- DTO дополнен полями isDeleted, deletedAt, status (active/fired/deleted) —
фронтэнд использует для бейджа и логики кнопок.
- UI EmployeesPage:
· фильтр статуса в actions: «Активные и уволенные» (default),
«Только активные», «Только уволенные», «Только удалённые»,
«Все, включая удалённых».
· колонка «Статус» теперь с цветным бейджем (emerald/amber/rose).
· ФИО уволенного помечается «(уволен)», удалённого — line-through
+ «(удалён)».
· кнопка-действие в модалке: «Уволить» если active, «Удалить» если
fired, скрыта если уже deleted (заменена на pojaснение).
· confirm-текст обоих шагов разный — юзер понимает что произойдёт.
Существующие связанные документы (продажи, поставки) ссылаются на
employees по FK; имена для UI берутся из employee.LastName/FirstName +
status — отображение «Иванов И.И. (удалён)» работает автоматически.
Менеджер/Закупщик/Бухгалтер сидились как кастомные шаблоны вместе с
организацией, но при этом числились системными в DevDataSeeder
(IsSystem=false), что путало UI (где-то нельзя было менять, где-то
можно было). Юзер хочет: при создании новой орги только три
системные роли (Admin, Storekeeper, Cashier), все остальные роли
администратор создаёт сам.
— SystemRoles.Manager убран. Identity-роли сидируются: SuperAdmin,
Admin, Cashier, Storekeeper.
— EmployeeRoles tenant-сидер создаёт только три записи (все IsSystem=true,
все три не редактируются и не удаляются обычным юзером — это правило
уже работало для Админа/Кассира, теперь покрывает Кладовщика).
— Authorize(Roles = ".. Manager ..") убрано из всех контроллеров (13 файлов):
Sales/RetailSales, Catalog/{Products,ProductImages,ProductGroups,
Counterparties,UnitsOfMeasure,RetailPoints,PriceTypes,Stores},
Purchases/Supplies, Organizations/{Employees,EmployeeRoles,
OrganizationSettings}.
Существующие организации с уже созданными «Менеджер/Закупщик/
Бухгалтер» записями НЕ затрагиваются — сидер пропускает org если в ней
уже есть роли (anyRole short-circuit). При желании админ может удалить
эти кастомные роли через UI.
Фронт:
- SignupForm: убрал «(необязательно)» из лейбла, добавил required + autoComplete=tel.
- validation.ts: validatePhone теперь возвращает ошибку при пустом значении и валидирует строго KZ-мобильный (^77\d{9}$); ведущая «8» нормализуется в «7». «79…» (РФ) отвергается.
Бэк:
- AuthSignupController: SignupInput.Phone теперь string (не nullable). Добавлен NormalizeKzPhone — единая нормализация на сервере, защита от обхода фронтового валидатора. На запись в Organization.Phone уходит каноничная форма «+7XXXXXXXXXX».
Публичный сайт ещё в разработке — выносим его с food-market.kz на
test.food-market.kz. На корневом домене food-market.kz пока 404.
admin.food-market.kz остаётся как есть.
— Заменены https-URL в src/** и deploy/:
https://food-market.kz → https://test.food-market.kz
(admin.food-market.kz, app.food-market.kz и emails @food-market.kz
не трогаем — sed строго по https-префиксу).
— public Dockerfile ARG PUBLIC_SITE_URL → test.food-market.kz.
— SignupForm/Header/NoOrganizationPage указывают на admin.food-market.kz
для API (без изменений с прошлого коммита).
— appsettings.json CORS: test + admin.food-market.kz.
Nginx (на сервере):
- /etc/nginx/conf.d/test.food-market.kz.conf — новый, серт LE issued.
- food-market.kz.conf — apex теперь 404 (HTTPS), серт переиспользует
пару (food-market.kz + admin.food-market.kz).
- food-market.zat.kz и app.food-market.zat.kz — 301 на test/admin
соответственно.
Smoke: test/, /signup/, admin/health, admin/login = 200; apex = 404;
zat → test/admin 301; sitemap.xml отдаёт https://test.food-market.kz/.
- Заменил все хардкоды URL в src/** и deploy/:
food-market.zat.kz → food-market.kz (публичный сайт)
app.food-market.zat.kz → admin.food-market.kz (админ-API + SPA)
- public/SignupForm и Header: дефолт PUBLIC_APP_URL теперь
https://admin.food-market.kz (раньше указывал на сам публичный домен,
что было багом — фронт стучался не туда после переезда зон).
- public/Dockerfile ARG PUBLIC_APP_URL → admin.food-market.kz.
- API appsettings.json CORS — оставил только два прода-origin (localhost
для dev живёт там же).
- Program.cs: добавил opts.SetIssuer(uri) если задан OpenIddict:Issuer
в конфиге — иначе iss вычислялся из текущего HTTP-запроса и ломался
при nginx-прокси без X-Forwarded-Proto.
- docker-compose стейджа: env OpenIddict__Issuer=https://admin.food-market.kz/
+ Cors__AllowedOrigins[0,1].
Nginx (на сервере, не в репе):
- /etc/nginx/conf.d/food-market.kz.conf, admin.food-market.kz.conf —
новые конфиги с certbot-выданными сертификатами на оба домена
(LetsEncrypt --webroot, действителен до 2026-07-29).
- Старые food-market.zat.kz / app.food-market.zat.kz переведены в
301-редирект на новые домены (HTTP+HTTPS), серты zat.kz пока
оставлены чтобы handshake шёл нормально.
API: SuperAdminEmployeesController на /api/super-admin/organizations/{orgId}/employees
- GET (с пагинацией, поиском, includeInactive=true по умолчанию)
- GET /{id} — детали + флаги isOwner/hasAccount/accountActive
- POST — создать сотрудника, опционально с учёткой (генерация temp password)
- PUT /{id} — изменить ФИО/контакты/роль/активность БЕЗ tenant-гардов
(можно править главного администратора)
- DELETE /{id}?reason=… — soft-delete; если удаляем главного администратора —
снимаем org.AccountOwnerUserId
- POST /{id}/toggle-active — активировать/деактивировать запись Employee
- POST /{id}/account/toggle-active — заблокировать/разблокировать AppUser
(revoke valid OpenIddictTokens при блокировке)
- POST /{id}/reset-password — сгенерировать новый temp password,
revoke все active токены, вернуть one-shot
Все мутации требуют reason ≥ 10 символов и пишутся в SuperAdminAuditLog
(actionType: SA_CreateEmployee / SA_EditEmployee / SA_ActivateEmployee /
SA_DeactivateEmployee / SA_ActivateAccount / SA_DeactivateAccount /
SA_ResetPassword / SA_DeleteEmployee). Эндпойнты [Authorize(Roles=SuperAdmin)],
обходят tenant-фильтр через IgnoreQueryFilters().
Web: новая страница SuperAdminOrgEmployeesPage по
`/super-admin/organizations/:id/employees`. Таблица сотрудников орги
(включая неактивных), бейдж «Главный администратор», статус учётки
(активна/заблокирована/нет). Иконки: редактировать, сбросить пароль,
блокировка учётки, активность сотрудника. Каждое действие открывает
модалку с обязательным полем «Причина» (≥10 символов) — она уходит
в audit-log. Сгенерированный пароль показывается one-shot с copy-кнопкой.
Кнопка «Сотрудники» (Users-icon) добавлена в actions колонку
SuperAdminOrganizationsPage — переход на страницу прямо из списка орг.
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.
Доменная схема (по решению юзера):
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>
Концепция: 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>
В режиме «открыть как…» 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>
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 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>
После ревью 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>
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>
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).
Формат: «Арт: 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>
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/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>
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>
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>
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>
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>
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>
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>