Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.
Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».
Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны).
EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD +
Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в
одной Serializable-транзакции; Unpost — обратная пара тоже атомарно.
Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар
мог быть уже расходован).
Web: /inventory/transfers (list+edit) с двумя селекторами складов и
визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission
TransferEdit добавлен в RolePermissions.
Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет
ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock
на FromStore → 409 без побочных эффектов; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).
Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup).
EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE).
Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт
StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус.
Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре
Admin/Storekeeper.
Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double
post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ApiFactory поднимает реальный API на одноразовом postgres:16-alpine (Ryuk off —
сеть к Docker Hub нестабильна, образ закэширован; RateLimiting off через env, т.к.
лимитер читает конфиг эагерно). Program сделан public partial для фабрики.
Сценарии (10 зелёных):
- signup-flow: signup→token→/api/me с org; дубль-signup 400; слабый пароль 400.
- tenant isolation A vs B: контрагент A не виден B (список + прямой GET 404).
- permission: кастомная роль без ProductsEdit → PUT товара 403, GET 200; админ не 403.
- supply post→unpost: остаток 0→10, Cost=70 (скользящее среднее), unpost→0; двойной post 409.
- retail overselling: продажа сверх остатка → 409; недоплата → 400.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OpenIddictKeyConfigurator: dev — прежний RSA-ключ в App_Data (поведение не
менялось, шифрование access-token выключено); prod/stage — отдельные X509
сертификаты подписи и шифрования из конфига (OpenIddict:SigningCertPath /
EncryptionCertPath / CertPassword, можно env). Нет файла → генерируется
persistent self-signed (RSA 2048, 5 лет) и сохраняется в App_Data (volume),
а не dev-ephemeral — токены переживают рестарт.
Проверено: prod выдаёт 5-сегментный JWE, /api/me 200; рестарт → те же
сертификаты (fingerprint совпал), pre-restart токен валиден. dev — 3-сегментный
JWT, /api/me 200. docs/openiddict-keys.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- roles.steps.ts step08: было «задокументированный gap», стало реальная
проверка — кастомная роль без ProductsEdit → 403 на PUT товара, GET → 200.
Сценарий roles зелёный 8/8.
- RateLimiting:* конфиг (Enabled/PerMinute/PerHour): тесты с общим loopback-IP
поднимают/выключают лимит, чтобы повторные логины не упирались в 429.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.
SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).
Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).
Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/health/live — liveness без зависимостей (Predicate=false).
/health/ready — readiness: DatabaseReadyHealthCheck (CanConnect + нет
неприменённых миграций), тег ready, 503 если не готово. JSON-ответ по
каждому чеку. docker-compose api healthcheck + Dockerfile.api → /health/ready,
web ждёт api service_healthy. /health сохранён для обратной совместимости.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sliding window на IP: 5/мин + 20/час (оба окна chained, оба должны
пропустить). Отдельные бакеты на эндпоинт — регистрация не съедает лимит
логинов. Глобальный лимитер с no-op для не-auth путей: двойное окно
per-endpoint policy выразить не может. 429 + JSON-телом, X-Forwarded-For
учитывается за прокси. Проверено curl'ом: 6-я попытка/мин → 429.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Смена владельца организации писала reason в журнал аудита, но проверяла лишь
его непустоту — короткие/мусорные причины («ok») проходили. PlatformSettings
для SMTP уже требует ≥10 символов; приводим change-owner к той же планке
(ТЗ 2.8: «Reason < 10 символов → 400»), чтобы журнал аудита оставался
осмысленным.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Employee.Delete (увольнение и soft-delete) и Employee.Update (деактивация)
меняли только Employee.IsActive, но не трогали связанный AppUser. Логин и
refresh в AuthorizationController гейтятся на User.IsActive — поэтому
уволенный сотрудник продолжал входить и обновлять токены до 30 дней (ТЗ 0.4:
«возможность залогиниться как удалённого сотрудника» = баг, P0).
Добавлен SetLinkedUserActiveAsync: при деактивации сотрудника гасит
User.IsActive и отзывает его valid OpenIddict-токены (как при удалении орг),
при реактивации через Update — возвращает доступ. Вызывается из DELETE (оба
шага) и из Update при смене активности.
Найдено сценарием employees step07 (было: login/refresh уволенного → 200).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MoySkladClient.BaseUrl был константой api.moysklad.ru, из-за чего импорт
нельзя было протестировать без боевого токена. Регистрация HttpClient теперь
берёт BaseAddress из MoySklad:BaseUrl (дефолт — прежний боевой URL), так что
e2e/интеграционные тесты наводят клиент на mock-сервер, не трогая прод.
MoySkladClient не меняем — он уже делает BaseAddress ??= const.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Supply.Post шёл на дефолтной изоляции (Read Committed), а
StockService.ApplyMovementAsync делает read-modify-write по Stock.Quantity
без RowVersion. Под конкуренцией это ломало главный инвариант
Stock.Quantity == Σ StockMovement.Quantity:
- двойное проведение ОДНОЙ приёмки (оба запроса читают Status=Draft до
коммита соседа) применяло остаток дважды — два StockMovement, но Stock
рос лишь на одну партию (lost update);
- две разные приёмки одного товара могли потерять обновление остатка и
посчитать скользящее среднее Cost от устаревшего currentQty.
Переводим проведение на IsolationLevel.Serializable (как RetailSale.Post)
и ловим конфликт сериализации (SQLSTATE 40001/40P01) → 409, чтобы клиент
повторил, а не получал 500. Найдено сценарием stock-concurrency
(step03: было stock=32/sum=39 → стало 32/32, statuses 204+409).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Найдено в 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>