Compare commits

...

7 commits

Author SHA1 Message Date
nns 7f8750f30f test(s29): unit (6) + integration (7) + e2e scenario для simple-mode (шаг 7/8)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Unit — AdvancedModeFilterTests (6/6, ~3с):
- allow when IsAdvancedMode=true
- 403 ProblemDetails с code=advanced-mode-required when simple
- SuperAdmin bypass
- InvalidateCache делает следующее чтение свежим
- unauthenticated — не трогаем ([Authorize] выше)
- authenticated без org — fail-closed 403

Integration — AdvancedModeTests (7/7, ~25с):
- /api/me.advancedMode=false по умолчанию
- GET на скрытый endpoint в простом режиме → 403 ProblemDetails
- org-admin не может переключить → 403
- SuperAdmin переключает → 200 + audit; идемпотент: повторный → changed=false
- /api/me обновилось, demand открылся
- данные пресета сохраняются через on→off→on
- архивную org переключать нельзя → 409

Доп. фикс: ApiFactory теперь прокидывает ConnectionStrings__Default через
Environment.SetEnvironmentVariable в InitializeAsync. ConfigureAppConfiguration
опаздывает к моменту чтения строки в Program.cs:86 (var poolTunedConnString=...)
— интеграционные тесты падали с «Connection refused к 127.0.0.1:5432». env-var
читает CreateBuilder сразу, строка подключения теперь корректно берётся из
Testcontainers. Это общий фикс для всей сюиты.

E2E — tests/e2e/scenarios/stage-simple-mode.{yml,steps.ts} (7 шагов):
sm01 signup→simple default; sm02 org-admin forbidden; sm03 SuperAdmin
toggle + audit; sm04 /api/me reflects + endpoint opens; sm05 data
survives toggle cycle; sm06 archived→409; sm07 Playwright UI показывает
страницу «Недоступно в простом режиме».

Quality-watchdog: GET /api/catalog/price-types не гейтится (только
мутации) — патч смока не требуется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 12:02:44 +05:00
nns 276cba22f8 feat(s29): SuperAdmin UI — бейдж + переключатель расширенного режима (шаг 6/8)
- SuperAdminOrganizationsPage:
  - OrgRow.isAdvancedMode добавлен.
  - Новая колонка «Режим» — бейдж «Простой» (slate) / «Расширенный»
    (violet).
  - Кнопка-экшн (иконка Sparkles) открывает модалку подтверждения
    с разной копией для вкл/выкл; успешный PUT → list.refetch().
  - Выкл-копия напоминает что данные НЕ удаляются и переключение
    обратимо; вкл-копия перечисляет открывающиеся модули.

Переключение из карточки организации — отложено, список-страница
с бейджем и кнопкой закрывает UX.

pnpm run build — ✓ 2.02s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:50:18 +05:00
nns 792ea0fd41 feat(s29): простой режим — упрощённые формы товара/контрагента/приёмки (шаг 5/8)
Везде формула: simpleMode = me && !me.advancedMode && !SuperAdmin.
Значения скрытых полей в state сохраняются и при save на сервер
отправляются как есть; данные НЕ стираются.

- ProductEditPage: скрыты Артикул (auto-gen), Описание, Страна,
  Эталонная цена, Ставка НДС, «В т.ч. НДС», Валюта закупки,
  «Услуга», «Маркируемый», Максимальный остаток, не-retail типы
  цен в блоке «Цены продажи».
- CounterpartiesPage: скрыты Юр.название, Страна, Email, Банк,
  Р.счёт, БИК, Контактное лицо. Остаются: Название, Тип лица,
  БИН/ИИН, Адрес, Телефон, Заметки.
- SupplyEditPage: колонка «Валюта» всегда скрыта в простом режиме
  (одна валюта по умолчанию).

«Поставщик/Покупатель»-галочки отложены — требуют новых колонок
IsSupplier/IsBuyer на Counterparty (новая функциональность).

pnpm run build — ✓ 2.36s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:48:23 +05:00
nns 67c1b590ad feat(s29): web — простой режим скрывает меню / маршруты / виджеты (шаг 4/8)
- AdvancedModeGuard — render-guard: простой режим + не-SuperAdmin → страница
  «Недоступно в простом режиме» с текстом + кнопкой «На главную».
  SuperAdmin пропускается, чтобы можно было проверить работу после
  переключения.
- useMe (+ локальные MeResponse) расширены полем advancedMode: boolean.
- AppLayout.buildNav(roles, advancedMode) — в простом режиме скрыты:
  priceTypes, transfers, demands, promotions, loyaltyPrograms/Cards,
  reportAbc, auditLog, LanguageSwitcher.
- App.tsx — скрытые маршруты обёрнуты в <AdvancedModeGuard>:
  /catalog/price-types, /inventory/transfers*, /sales/demands*,
  /loyalty/programs, /loyalty/cards, /promotions, /audit-log,
  /reports/abc.
- DashboardPage — в простом режиме скрыты виджеты «Топ товаров» и
  «Маржа». Остаются KPI, график продаж, «Последние продажи», «Низкий
  остаток».
- Данные скрытых модулей НЕ удаляются при выключении режима; при
  повторном включении всё видно.

pnpm run build — ✓ 2.6s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:43:10 +05:00
nns 921bca076c feat(s29): [RequiresAdvancedMode] + гейтинг скрытых модулей (шаг 3/8)
- RequiresAdvancedModeAttribute (TypeFilterAttribute → IAsyncAuthorizationFilter):
  читает Organization.IsAdvancedMode по org_id-claim через AppDbContext,
  кэш 30 с, инвалидация после переключения. SuperAdmin bypass. На отказ
  возвращает 403 ProblemDetails с code=advanced-mode-required.
- Атрибут навешен на контроллеры:
  Sales/DemandsController, Inventory/TransfersController,
  Loyalty/LoyaltyCardsController + LoyaltyProgramsController,
  Promotions/PromotionsController, Reports/AbcReportController,
  Admin/OrgAuditLogController, Telegram/TelegramBindingController,
  Common/UserPresetsController.
- И на отдельные действия:
  Catalog/PriceTypesController POST/PUT/DELETE (GET доступен — товары
  используют retail-прайс),
  Catalog/ProductsController POST /bulk-update.
- SuperAdmin toggle (шаг 2) инвалидирует кэш фильтра после SaveChangesAsync.
- SSO (Auth/ExternalAuthController) гейтится UI, а не атрибутом: endpoint
  инициируется анонимом без org_id в токене.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:38:10 +05:00
nns 202763e0d0 feat(s29): SuperAdmin advanced-mode toggle + /api/me.advancedMode (шаг 2/8)
- PUT /api/super-admin/organizations/{id}/advanced-mode { enabled: bool }
  — только SuperAdmin, админ орги → 403 от [Authorize(Roles=...)].
- Idempotent: совпадающее состояние → 200 без записи в аудит; разное →
  запись SuperAdminAuditLog (EnableAdvancedMode / DisableAdvancedMode)
  с before/after + IP. Архивную org переключать нельзя (409).
- /api/me теперь отдаёт advancedMode (bool) — читается из Organization
  с IgnoreQueryFilters (SuperAdmin без org → false — работает через
  /api/super-admin/...).
- OrgRow / OrgDetail в SuperAdmin list / detail дополнены IsAdvancedMode
  для бейджа «Простой/Расширенный» (шаг 6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:32:12 +05:00
nns 4342318c2c feat(s29): Organization.IsAdvancedMode + миграция Phase29a (шаг 1/8)
Simple/Advanced mode: по умолчанию у всех организаций «простой» режим
(IsAdvancedMode=false) — виден только базовый розничный учёт. Включить
«расширенный» режим может только SuperAdmin через отдельный API; в
настройках магазина переключателя нет.

Данные скрытых модулей при переключении НЕ удаляются; при повторном
включении всё снова видно.

- Organization.IsAdvancedMode (bool, default false) + XML-док
- EF-миграция Phase29a — идемпотентный ADD COLUMN IF NOT EXISTS DEFAULT false
- AppDbContextModelSnapshot обновлён
- Применено на dev-БД: 5/5 организаций → IsAdvancedMode=false

docs/simple-mode-progress.md — чек-лист + журнал.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 11:28:28 +05:00
33 changed files with 1372 additions and 83 deletions

View file

@ -0,0 +1,181 @@
# Simple Mode — прогресс
Задача: по умолчанию у всех организаций «Простой режим» — только базовые
функции розничного учёта. Включить «Расширенный режим» может только
SuperAdmin и только для конкретной организации. Данные при переключении
не удаляются; при повторном включении всё видно.
Старт: 2026-10-04.
## Чек-лист
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
- [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
- [x] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [x] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [x] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
- [x] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости
- [ ] 8. Деплой стейджа + `docs/simple-mode.md`
## Простой режим — что видно
- Главная: выручка за сегодня, что заканчивается, последние продажи
- Товары и группы товаров
- Контрагенты (единый список — поставщики и покупатели)
- Склад: Приёмка, Оприходование, Списание, Инвентаризация, Возврат поставщику, Остатки
- Продажи: Продажи, Возвраты покупателей
- Отчёты: Продажи, Прибыль, Остатки на дату
- Сотрудники и роли, Настройки магазина (включая ОФД), Импорт товаров
- 2FA в профиле — остаётся
## Расширенный режим — что добавляется
- Оптовые отгрузки (Demand)
- Перемещения между точками
- Лояльность
- Промокоды / акции
- ABC-анализ
- Аудит организации
- Несколько типов цен (в простом — одна цена продажи)
- Bulk-операции и сохранённые пресеты в таблицах
- Telegram-бот владельца
- SSO
- Переключатель языка (в простом только русский)
- Продвинутые виджеты дашборда
## Журнал
### 2026-10-04 — Шаг 1 ✓
- `Organization.IsAdvancedMode` (bool, default false) + XML-док, что переключает только SuperAdmin.
- Миграция `20261004100000_Phase29a_IsAdvancedMode` — идемпотентный `ADD COLUMN IF NOT EXISTS` с `DEFAULT false`; все существующие орги получают `false`.
- Snapshot обновлён (`AppDbContextModelSnapshot.cs`).
- Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`.
- `dotnet build food-market.api` — ✓ (0 error, warnings — старые, не наши).
### 2026-10-04 — Шаг 2 ✓
- `PUT /api/super-admin/organizations/{id}/advanced-mode { "enabled": bool }` в `SuperAdminOrganizationsController` — `[Authorize(Roles="SuperAdmin")]`. Админ орги получает 403 от стандартного Authorize-фильтра (роль Admin ≠ SuperAdmin); в контроллере сверху уже `[Authorize(Roles="SuperAdmin")]` — не нужно отдельного `Forbid()`.
- Запись в `SuperAdminAuditLog`: ActionType `EnableAdvancedMode` / `DisableAdvancedMode`, Description, Changes `{"before":…,"after":…}`, IpAddress. Идемпотентный переключатель: если состояние совпадает — 200 без аудит-записи.
- Архивную орг переключать нельзя — 409.
- `/api/me` отдаёт `advancedMode` (bool). SuperAdmin без org → false (это ОК — он работает через `/api/super-admin/...`).
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
- `dotnet build food-market.api` — ✓ (0 error).
### 2026-10-04 — Шаг 3 ✓
- `src/food-market.api/Infrastructure/Authorization/RequiresAdvancedModeAttribute.cs`:
- `[RequiresAdvancedMode]` — `TypeFilterAttribute` вокруг `AdvancedModeFilter : IAsyncAuthorizationFilter`.
- Читает `IsAdvancedMode` по `org_id`-claim через `AppDbContext` (без доверия к клиентским заголовкам). Кэш in-memory 30 с, инвалидация после переключения.
- SuperAdmin bypass (чтобы он мог проверять скрытые модули через «войти как»).
- Отказ — `403 ProblemDetails` с `type`, `title`, `detail` на русском и `extensions.code = "advanced-mode-required"` — фронт различает это 403 от обычного «нет прав».
- Атрибут навешен на (контроллеры):
- `Sales/DemandsController` (оптовые отгрузки)
- `Inventory/TransfersController` (перемещения)
- `Loyalty/LoyaltyCardsController`, `Loyalty/LoyaltyProgramsController`
- `Promotions/PromotionsController`
- `Reports/AbcReportController`
- `Admin/OrgAuditLogController`
- `Telegram/TelegramBindingController`
- `Common/UserPresetsController` (пресеты фильтров)
- И на отдельные действия:
- `Catalog/PriceTypesController` — POST / PUT / DELETE (чтение остаётся — товары используют системный retail-прайс)
- `Catalog/ProductsController.BulkUpdate` (POST `/products/bulk-update`)
- `Auth/ExternalAuthController` (SSO) — атрибут НЕ навешен: endpoint инициирует OAuth-челлендж для анонима, `org_id`-claim ещё не выставлен. Контроль — на уровне UI (не показывать кнопку «Войти через Google/Microsoft» в простом режиме). Также SSO в простом режиме не ломается при включении — просто кнопка появляется.
- `SuperAdminOrganizationsController.SetAdvancedMode` вызывает `AdvancedModeFilter.InvalidateCache(orgId)` после `SaveChangesAsync`, чтобы следующий запрос этой org сразу увидел новое значение.
- `dotnet build food-market.api` — ✓ (0 error).
### 2026-10-04 — Шаг 4 ✓
- `src/food-market.web/src/components/AdvancedModeGuard.tsx` — render-guard: `me.advancedMode = false` + не-SuperAdmin → страница «Недоступно в простом режиме» с текстом + кнопкой «На главную». SuperAdmin и расширенный режим — пропускает.
- `useMe.ts` + локальные `MeResponse` в AppLayout/DashboardPage расширены полем `advancedMode: boolean`.
- `AppLayout.buildNav(roles, advancedMode)` — в простом режиме скрыто:
- `nav.priceTypes` (справочник типов цен)
- `nav.transfers` (перемещения между точками)
- `nav.demands` / `nav.promotions` / `nav.loyaltyPrograms` / `nav.loyaltyCards`
- `nav.reportAbc`
- `nav.auditLog`
- `LanguageSwitcher` (в простом режиме интерфейс только по-русски)
- `App.tsx` — скрытые маршруты обёрнуты в `<AdvancedModeGuard>`:
- `/catalog/price-types`
- `/inventory/transfers`, `/inventory/transfers/new`, `/inventory/transfers/:id`
- `/sales/demands`, `/sales/demands/new`, `/sales/demands/:id`
- `/loyalty/programs`, `/loyalty/cards`, `/promotions`
- `/audit-log`
- `/reports/abc`
- `DashboardPage` — в простом режиме скрыты виджеты «Топ товаров» и «Маржа». Остаются KPI (выручка today/week/month, средний чек), график продаж, «Последние продажи», «Низкий остаток» — ровно то, что в чек-листе.
- Данные НЕ удаляются при выключении режима: это только UI-гейтинг + серверный 403; при повторном включении всё видно.
- `pnpm run build` — ✓ (2.60s, 0 TS errors).
### 2026-10-04 — Шаг 5 ✓
- Везде формула простого режима: `simpleMode = me && !me.advancedMode && !roles.includes('SuperAdmin')`. SuperAdmin не ощущает — ему доступно всё.
- **Карточка товара** (`ProductEditPage.tsx`) в простом режиме прячет (значения в state сохраняются, при save не стираются):
- «Артикул» (генерируется автоматически, Название на 3 колонки)
- «Описание», «Страна происхождения», «Эталонная цена»
- «Ставка НДС», чекбокс «В том числе НДС»
- «Валюта закупки» (мульти-валюта)
- Чекбоксы «Услуга», «Маркируемый»
- «Максимальный остаток» — остаётся только «Минимальный»
- Все не-retail типы цен в блоке «Цены продажи» — рендерится только retail/system price type.
- **Контрагенты** (`CounterpartiesPage.tsx`) — в простом режиме скрыты Юр. название, Страна, Email, Банк/Р.счёт/БИК, Контактное лицо. Остаются: Название, Тип лица, БИН/ИИН, Адрес, Телефон, Заметки.
- **Приёмка** (`SupplyEditPage.tsx`) — колонка «Валюта» всегда скрыта в простом режиме (одна валюта по умолчанию).
- Другие документы (Enter, Loss, Inventory, SupplierReturn) используют те же `org.ShowXxx` / `org.MultiCurrencyEnabled` флаги — уже скрывают advanced-поля для магазинов с «простым» org-настройками.
Доп. список спрятанного за `simpleMode` (из шагов 4/5, не вошёл напрямую в чек-лист):
- `AppLayout` → `LanguageSwitcher`
- `DashboardPage` → виджеты «Топ товаров» и «Маржа»
Что отложено (новая функциональность, в этой задаче не трогаем):
- `Counterparty.IsSupplier` / `IsBuyer` — задача просит галочки «Поставщик/Покупатель», но на `Counterparty` таких bool-колонок нет. Это новая миграция и расширение API — отдельная задача.
- `pnpm run build` — ✓ (2.36s, 0 TS errors).
### 2026-10-04 — Шаг 6 ✓
- `SuperAdminOrganizationsPage.tsx`:
- В `OrgRow` добавлен `isAdvancedMode: boolean` (бэк уже отдаёт из шага 2).
- Новая колонка **«Режим»** с бейджем «Простой» (slate-пилюля) / «Расширенный» (violet-пилюля).
- Новая кнопка-экшн рядом с «Архивировать» (иконка Sparkles): при клике открывается модалка подтверждения с разной копией для включения/выключения:
- *Включить:* перечисляет, что станет доступно (demand, loyalty, promos, abc, audit, несколько цен, пресеты, bulk-ops, telegram, sso, язык).
- *Выключить:* предупреждает что данные НЕ удаляются и переключение можно вернуть; напоминает проверить активные операции.
- Переключение бьёт `PUT /api/super-admin/organizations/{id}/advanced-mode { enabled }` и после успеха `list.refetch()`.
- Переключение из карточки организации (`SuperAdminOrgEmployeesPage` / detail-view) не делаем в этой итерации — список-страница с бейджем и кнопкой уже закрывает UX.
- `pnpm run build` — ✓ (2.02s, 0 TS errors).
### 2026-10-04 — Шаг 7 ✓
**Unit — `tests/food-market.UnitTests/AdvancedModeFilterTests.cs`** (6 тестов, все зелёные):
- `Allows_when_IsAdvancedMode_true`
- `Forbids_with_ProblemDetails_when_simple_mode` — проверяет ObjectResult + status 403 + `extensions.code=advanced-mode-required`
- `SuperAdmin_bypasses_check_even_in_simple_org`
- `InvalidateCache_makes_next_read_see_fresh_value`
- `Unauthenticated_request_is_skipped_by_filter`
- `Authenticated_without_org_claim_is_forbidden` (fail-closed)
Запуск: `dotnet test tests/food-market.UnitTests --filter "FullyQualifiedName~AdvancedModeFilterTests"` → 6/6 passed, ~3 с.
**Integration — `tests/food-market.IntegrationTests/AdvancedModeTests.cs`** (7 тестов):
- `Me_returns_advancedMode_false_by_default`
- `Simple_mode_gets_403_ProblemDetails_on_hidden_endpoint`
- `Org_admin_cannot_toggle_advanced_mode` (403)
- `SuperAdmin_can_toggle_and_writes_audit_entry` (идемпотентно: повторный PUT → `changed=false`, в аудит не пишется)
- `Me_advancedMode_switches_with_toggle_and_endpoint_opens`
- `Data_in_hidden_module_survives_toggle_cycle` (пресет виден после `on → off → on`)
- `Toggle_fails_for_archived_org_with_409`
Запуск: `dotnet test tests/food-market.IntegrationTests --filter "FullyQualifiedName~AdvancedModeTests"` → 7/7 passed, ~25 с.
Доп. фикс: `tests/food-market.IntegrationTests/Support/ApiFactory.cs` теперь прокидывает `ConnectionStrings__Default` через `Environment.SetEnvironmentVariable` в `InitializeAsync` — `WebApplicationFactory.ConfigureAppConfiguration` опаздывает к моменту чтения строки в `Program.cs:86`, а env-var читается `CreateBuilder` сразу. Благодаря этому вся интеграционная сюита снова нормально работает на этой dev-vm (не только мои тесты).
**E2E — `tests/e2e/scenarios/stage-simple-mode.yml` + `.steps.ts`** (7 шагов, запускаются после деплоя шага 8):
- `sm01_signup_defaults_to_simple` — /api/me.advancedMode=false + demand 403 с кодом
- `sm02_org_admin_cannot_toggle` — админ орги PUT advanced-mode → 403
- `sm03_superadmin_enables_and_audit` — SuperAdmin включает → 200 + запись в audit; идемпотентный повтор
- `sm04_me_reflects_switch_and_opens_endpoint` — /api/me обновилось, demand открылся
- `sm05_data_survives_toggle_cycle` — пресет виден после on→off→on
- `sm06_archived_org_cannot_toggle` — архивную → 409
- `sm07_ui_shows_not_available_page` — Playwright: простой админ на /sales/demands видит текст «Недоступно в простом режиме»
**Quality-watchdog:** проверка показала, что smoke дёргает только `GET /api/catalog/price-types?pageSize=10` (не гейтится — только мутации). Патч смока **не требуется**.

View file

@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Admin;
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary> /// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/admin/audit-log")] [Route("api/admin/audit-log")]
public class OrgAuditLogController : ControllerBase public class OrgAuditLogController : ControllerBase
{ {

View file

@ -50,7 +50,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder); return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
} }
[HttpPost, RequiresPermission("PriceTypesManage")] [HttpPost, RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct) public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{ {
if (input.IsRetail) if (input.IsRetail)
@ -73,7 +73,7 @@ await _db.PriceTypes.Where(p => p.IsRetail)
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder)); new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
} }
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")] [HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
@ -93,7 +93,7 @@ await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")] [HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);

View file

@ -857,7 +857,7 @@ int Idx(params string[] aliases)
return new CsvImportResponse(createdIds.Count, [], createdIds); return new CsvImportResponse(createdIds.Count, [], createdIds);
} }
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit")] [HttpPost("bulk-update"), RequiresPermission("ProductsEdit"), RequiresAdvancedMode]
public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate( public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate(
[FromBody] BulkUpdateRequest req, CancellationToken ct) [FromBody] BulkUpdateRequest req, CancellationToken ct)
{ {

View file

@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Domain.Common; using foodmarket.Domain.Common;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -13,6 +14,7 @@ namespace foodmarket.Api.Controllers.Common;
/// контроллера через WHERE UserId = currentUserId.</summary> /// контроллера через WHERE UserId = currentUserId.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/user/presets")] [Route("api/user/presets")]
public class UserPresetsController : ControllerBase public class UserPresetsController : ControllerBase
{ {

View file

@ -18,6 +18,7 @@ namespace foodmarket.Api.Controllers.Inventory;
/// транзакции.</summary> /// транзакции.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/inventory/transfers")] [Route("api/inventory/transfers")]
public class TransfersController : ControllerBase public class TransfersController : ControllerBase
{ {

View file

@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary> /// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/loyalty/cards")] [Route("api/loyalty/cards")]
public class LoyaltyCardsController : ControllerBase public class LoyaltyCardsController : ControllerBase
{ {

View file

@ -14,6 +14,7 @@ namespace foodmarket.Api.Controllers.Loyalty;
/// orphan-карты и сломало history.</summary> /// orphan-карты и сломало history.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/loyalty/programs")] [Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase public class LoyaltyProgramsController : ControllerBase
{ {

View file

@ -13,6 +13,7 @@ namespace foodmarket.Api.Controllers.Promotions;
/// контроллере отдаём 409 с понятным текстом если дубль.</summary> /// контроллере отдаём 409 с понятным текстом если дубль.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/promotions")] [Route("api/promotions")]
public class PromotionsController : ControllerBase public class PromotionsController : ControllerBase
{ {

View file

@ -1,3 +1,4 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Domain.Sales; using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -23,6 +24,7 @@ namespace foodmarket.Api.Controllers.Reports;
/// Возвраты учтены со знаком: net-метрика для периода.</summary> /// Возвраты учтены со знаком: net-метрика для периода.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/reports/abc")] [Route("api/reports/abc")]
public class AbcReportController : ControllerBase public class AbcReportController : ControllerBase
{ {

View file

@ -17,6 +17,7 @@ namespace foodmarket.Api.Controllers.Sales;
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary> /// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[RequiresAdvancedMode]
[Route("api/sales/demands")] [Route("api/sales/demands")]
public class DemandsController : ControllerBase public class DemandsController : ControllerBase
{ {

View file

@ -1,3 +1,4 @@
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Seed; using foodmarket.Api.Seed;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Domain.Organizations; using foodmarket.Domain.Organizations;
@ -31,7 +32,8 @@ public record OrgRow(
Guid Id, string Name, string CountryCode, Guid Id, string Name, string CountryCode,
bool IsActive, bool IsArchived, DateTime? ArchivedAt, bool IsActive, bool IsArchived, DateTime? ArchivedAt,
DateTime CreatedAt, int EmployeeCount, int ProductCount, DateTime CreatedAt, int EmployeeCount, int ProductCount,
DateTime? LastLoginAt); DateTime? LastLoginAt,
bool IsAdvancedMode);
public record OrgDetail( public record OrgDetail(
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -39,7 +41,8 @@ public record OrgDetail(
bool IsActive, bool IsArchived, DateTime? ArchivedAt, bool IsActive, bool IsArchived, DateTime? ArchivedAt,
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail, Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
DateTime CreatedAt, DateTime? UpdatedAt, DateTime CreatedAt, DateTime? UpdatedAt,
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth); int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
bool IsAdvancedMode);
public record OrgInput( public record OrgInput(
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -53,6 +56,7 @@ public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string
public record ArchiveRequest(string ConfirmationName); public record ArchiveRequest(string ConfirmationName);
public record DeleteRequest(string ConfirmationName); public record DeleteRequest(string ConfirmationName);
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason); public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
public record AdvancedModeRequest(bool Enabled);
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<OrgRow>>> List( public async Task<ActionResult<PagedResult<OrgRow>>> List(
@ -77,7 +81,8 @@ public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id), _db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id), _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null) _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
.Max(u => (DateTime?)u.LastLoginAt))) .Max(u => (DateTime?)u.LastLoginAt),
o.IsAdvancedMode))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -229,6 +234,39 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
return NoContent(); return NoContent();
} }
/// <summary>Sprint 29 — переключатель расширенного режима организации.
/// По умолчанию <c>false</c> → UI/API видят только «простой» набор функций.
/// Переключать может только SuperAdmin; админ организации — 403 (гейтим
/// ролью на уровне контроллера). Idempotent: если состояние совпадает —
/// вернём 200 + <c>IsAdvancedMode</c>, без записи в аудит.</summary>
[HttpPut("{id:guid}/advanced-mode")]
public async Task<IActionResult> SetAdvancedMode(Guid id, [FromBody] AdvancedModeRequest req, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (o.IsArchived)
return Conflict(new { error = "Организация в архиве — сначала восстанови её." });
if (o.IsAdvancedMode == req.Enabled)
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = false });
var before = o.IsAdvancedMode;
o.IsAdvancedMode = req.Enabled;
await _db.SaveChangesAsync(ct);
// Инвалидируем кэш фильтра, чтобы следующий запрос этой org сразу
// увидел новое значение, а не ждал TTL (30 сек).
AdvancedModeFilter.InvalidateCache(o.Id);
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
var description = req.Enabled
? $"Включен расширенный режим «{o.Name}»"
: $"Выключен расширенный режим «{o.Name}»";
await LogAsync(actionType, o.Id, description, null,
$"{{\"before\":{before.ToString().ToLower()},\"after\":{req.Enabled.ToString().ToLower()}}}", ct);
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = true });
}
[HttpPost("{id:guid}/change-owner")] [HttpPost("{id:guid}/change-owner")]
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct) public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
{ {
@ -267,7 +305,8 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
o.DefaultCurrencyId, o.DefaultCurrency?.Code, o.DefaultCurrencyId, o.DefaultCurrency?.Code,
o.IsActive, o.IsArchived, o.ArchivedAt, o.IsActive, o.IsArchived, o.ArchivedAt,
o.AccountOwnerUserId, owner?.FullName, owner?.Email, o.AccountOwnerUserId, owner?.FullName, owner?.Email,
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies); o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
o.IsAdvancedMode);
} }
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)

View file

@ -1,4 +1,5 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Integrations.Telegram; using foodmarket.Api.Integrations.Telegram;
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
@ -28,6 +29,7 @@ namespace foodmarket.Api.Controllers.Telegram;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize(Policy = "AdminAccess")] [Authorize(Policy = "AdminAccess")]
[RequiresAdvancedMode]
[Route("api/organization/telegram")] [Route("api/organization/telegram")]
public class TelegramBindingController : ControllerBase public class TelegramBindingController : ControllerBase
{ {

View file

@ -0,0 +1,113 @@
using System.Collections.Concurrent;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Infrastructure.Authorization;
/// <summary>Sprint 29 — гейтит endpoint на «расширенный режим» организации.
/// Если <c>Organization.IsAdvancedMode = false</c>, возвращается
/// <c>403 ProblemDetails</c> с кодом <c>advanced-mode-required</c>. SuperAdmin
/// bypass'ит проверку (он работает через <c>/api/super-admin/*</c> + режим
/// «войти как», и ему нужно иметь возможность читать скрытые модули).
/// Платформенные SuperAdmin-эндпоинты вовсе не навешивают атрибут.
///
/// Применяется:
/// <list type="bullet">
/// <item>На контроллер — закрывает ВСЕ действия контроллера (Оптовые
/// отгрузки, Перемещения, Лояльность, Промокоды/акции, ABC, Аудит,
/// SSO, Telegram-бот, Пресеты, PriceTypes).</item>
/// <item>На отдельное действие — например, <c>ProductsController.BulkUpdate</c>
/// / <c>BulkDelete</c> когда контроллер в целом «простой».</item>
/// </list>
///
/// Работает как <see cref="TypeFilterAttribute"/>, чтобы фильтр мог
/// внедрять <see cref="AppDbContext"/>. Фильтр читает флаг напрямую из БД
/// по <c>org_id</c>-claim'у (один дешёвый <c>Select</c> + можно позже
/// добавить кэш) — специально не доверяем клиентскому заголовку.</summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class RequiresAdvancedModeAttribute : TypeFilterAttribute
{
public RequiresAdvancedModeAttribute() : base(typeof(AdvancedModeFilter)) { }
}
/// <summary>Внутренняя реализация <see cref="RequiresAdvancedModeAttribute"/>.
/// Не используется напрямую — вешай на контроллер атрибут.</summary>
public sealed class AdvancedModeFilter : IAsyncAuthorizationFilter
{
// Легковесный in-memory snapshot: org -> IsAdvancedMode. Flag меняет
// только SuperAdmin и редко; гонка между переключением и действием —
// в худшем случае 1-2 секунды «неправильного ответа», что приемлемо.
private static readonly ConcurrentDictionary<Guid, (bool Advanced, DateTime At)> Cache = new();
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
private readonly AppDbContext _db;
public AdvancedModeFilter(AppDbContext db) => _db = db;
public async Task OnAuthorizationAsync(AuthorizationFilterContext ctx)
{
var user = ctx.HttpContext.User;
// Аноним / не аутентифицирован — пропускаем, пусть выше по пайплайну
// отработает [Authorize]. Атрибут гейтит ТОЛЬКО по режиму.
if (user?.Identity?.IsAuthenticated != true) return;
// SuperAdmin видит всё.
if (user.IsInRole(HttpContextTenantContext.SuperAdminRole)) return;
var orgRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
if (!Guid.TryParse(orgRaw, out var orgId))
{
// Нет org в токене — не можем сказать «расширено ли» → отказ,
// чтобы не было silent-прохода. [Authorize] уже должен был это
// отловить, но fail-closed.
ctx.Result = Forbidden(ctx);
return;
}
var advanced = await ReadAdvancedAsync(orgId, ctx.HttpContext.RequestAborted);
if (!advanced)
{
ctx.Result = Forbidden(ctx);
}
}
private async Task<bool> ReadAdvancedAsync(Guid orgId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (Cache.TryGetValue(orgId, out var entry) && now - entry.At < CacheTtl)
return entry.Advanced;
var advanced = await _db.Organizations.IgnoreQueryFilters()
.Where(o => o.Id == orgId)
.Select(o => (bool?)o.IsAdvancedMode)
.FirstOrDefaultAsync(ct) ?? false;
Cache[orgId] = (advanced, now);
return advanced;
}
/// <summary>Инвалидирует кэш для организации — вызывает
/// <c>SuperAdminOrganizationsController</c> после переключения,
/// чтобы следующий запрос сразу увидел новое значение, а не ждал TTL.</summary>
public static void InvalidateCache(Guid orgId) => Cache.TryRemove(orgId, out _);
private static ObjectResult Forbidden(AuthorizationFilterContext ctx)
{
var pd = new ProblemDetails
{
Type = "https://food-market.kz/errors/advanced-mode-required",
Title = "Функция доступна в расширенном режиме.",
Detail = "Этот раздел недоступен в простом режиме магазина. Обратитесь к администратору платформы, чтобы включить расширенный режим.",
Status = StatusCodes.Status403Forbidden,
Instance = ctx.HttpContext.Request.Path,
};
pd.Extensions["code"] = "advanced-mode-required";
return new ObjectResult(pd)
{
StatusCode = StatusCodes.Status403Forbidden,
ContentTypes = { "application/problem+json" },
};
}
}

View file

@ -632,10 +632,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// получает /no-organization fallback вместо белого экрана на /dashboard. // получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = false; bool hasLiveOrg = false;
bool hasActiveEmployee = false; bool hasActiveEmployee = false;
// Sprint 29 (simple-mode): флаг расширенного режима организации.
// false по умолчанию → UI показывает только «простое» меню.
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
bool advancedMode = false;
if (Guid.TryParse(orgIdRaw, out var orgId)) if (Guid.TryParse(orgIdRaw, out var orgId))
{ {
hasLiveOrg = await db.Organizations.IgnoreQueryFilters() var org = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived); .Where(o => o.Id == orgId)
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
.FirstOrDefaultAsync();
if (org is not null)
{
hasLiveOrg = !org.IsArchived;
advancedMode = org.IsAdvancedMode;
}
if (Guid.TryParse(subRaw, out var sub)) if (Guid.TryParse(subRaw, out var sub))
{ {
hasActiveEmployee = await db.Employees.IgnoreQueryFilters() hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
@ -652,6 +663,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
orgId = orgIdRaw, orgId = orgIdRaw,
hasLiveOrg, hasLiveOrg,
hasActiveEmployee, hasActiveEmployee,
advancedMode,
}); });
}).RequireAuthorization(); }).RequireAuthorization();

View file

@ -87,6 +87,16 @@ public class Organization : Entity
/// просто захламляет карточку.</summary> /// просто захламляет карточку.</summary>
public bool ShowDescriptionOnProduct { get; set; } public bool ShowDescriptionOnProduct { get; set; }
/// <summary>Расширенный режим для организации. По умолчанию <c>false</c>
/// — виден только «простой» набор функций (базовый розничный учёт).
/// Включить может только SuperAdmin через
/// <c>PUT /api/superadmin/organizations/{id}/advanced-mode</c>; в
/// настройках магазина переключателя нет. При выключении данные
/// скрытых модулей не удаляются; при повторном включении всё видно.
/// Серверная защита — атрибут <c>[RequiresAdvancedMode]</c> на
/// контроллерах скрытых модулей.</summary>
public bool IsAdvancedMode { get; set; }
// ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── // ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret

View file

@ -111,6 +111,10 @@ protected override void OnModelCreating(ModelBuilder builder)
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
b.Property(o => o.Bin).HasMaxLength(20); b.Property(o => o.Bin).HasMaxLength(20);
b.Property(o => o.MoySkladToken).HasMaxLength(200); b.Property(o => o.MoySkladToken).HasMaxLength(200);
// Simple/Advanced mode: default false (простой режим). Миграция
// Phase29a_IsAdvancedMode добавляет колонку, backfilling всех
// существующих организаций в false.
b.Property(o => o.IsAdvancedMode).HasDefaultValue(false);
b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(o => o.Name); b.HasIndex(o => o.Name);
b.HasIndex(o => o.IsArchived); b.HasIndex(o => o.IsArchived);

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase29a — колонка <c>IsAdvancedMode</c> на организациях.
/// Default <c>false</c> → все существующие и новые организации стартуют
/// в «простом» режиме, SuperAdmin переключает вручную через API.
/// Идемпотентно (ADD COLUMN IF NOT EXISTS) и без backfilling — default
/// в CREATE/ALTER COLUMN уже выставляет значение всем строкам.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261004100000_Phase29a_IsAdvancedMode")]
public partial class Phase29a_IsAdvancedMode : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""IsAdvancedMode"" boolean NOT NULL DEFAULT false;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""IsAdvancedMode"";");
}
}
}

View file

@ -1089,6 +1089,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsArchived") b.Property<bool>("IsArchived")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsAdvancedMode")
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTime?>("ArchivedAt") b.Property<DateTime?>("ArchivedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");

View file

@ -28,6 +28,7 @@ import { SuperAdminLayout } from '@/components/SuperAdminLayout'
import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { RoleGuard } from '@/components/RoleGuard' import { RoleGuard } from '@/components/RoleGuard'
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
import { Toaster } from '@/components/Toaster' import { Toaster } from '@/components/Toaster'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { FormSkeleton } from '@/components/Skeleton' import { FormSkeleton } from '@/components/Skeleton'
@ -155,7 +156,7 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} /> <Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={lz(ProductGroupsPage)} /> <Route path="/catalog/product-groups" element={lz(ProductGroupsPage)} />
<Route path="/catalog/units" element={lz(UnitsOfMeasurePage)} /> <Route path="/catalog/units" element={lz(UnitsOfMeasurePage)} />
<Route path="/catalog/price-types" element={lz(PriceTypesPage)} /> <Route path="/catalog/price-types" element={<AdvancedModeGuard>{lz(PriceTypesPage)}</AdvancedModeGuard>} />
<Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} /> <Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}>{lz(StoresPage)}</RoleGuard>} /> <Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}>{lz(StoresPage)}</RoleGuard>} />
<Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}>{lz(RetailPointsPage)}</RoleGuard>} /> <Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}>{lz(RetailPointsPage)}</RoleGuard>} />
@ -170,9 +171,9 @@ export default function App() {
<Route path="/inventory/losses" element={lz(LossesPage)} /> <Route path="/inventory/losses" element={lz(LossesPage)} />
<Route path="/inventory/losses/new" element={lz(LossEditPage)} /> <Route path="/inventory/losses/new" element={lz(LossEditPage)} />
<Route path="/inventory/losses/:id" element={lz(LossEditPage)} /> <Route path="/inventory/losses/:id" element={lz(LossEditPage)} />
<Route path="/inventory/transfers" element={lz(TransfersPage)} /> <Route path="/inventory/transfers" element={<AdvancedModeGuard>{lz(TransfersPage)}</AdvancedModeGuard>} />
<Route path="/inventory/transfers/new" element={lz(TransferEditPage)} /> <Route path="/inventory/transfers/new" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
<Route path="/inventory/transfers/:id" element={lz(TransferEditPage)} /> <Route path="/inventory/transfers/:id" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
<Route path="/inventory/inventories" element={lz(InventoriesPage)} /> <Route path="/inventory/inventories" element={lz(InventoriesPage)} />
<Route path="/inventory/inventories/new" element={lz(InventoryEditPage)} /> <Route path="/inventory/inventories/new" element={lz(InventoryEditPage)} />
<Route path="/inventory/inventories/:id" element={lz(InventoryEditPage)} /> <Route path="/inventory/inventories/:id" element={lz(InventoryEditPage)} />
@ -182,17 +183,17 @@ export default function App() {
<Route path="/reports/sales" element={lz(SalesReportPage)} /> <Route path="/reports/sales" element={lz(SalesReportPage)} />
<Route path="/reports/stock" element={lz(StockReportPage)} /> <Route path="/reports/stock" element={lz(StockReportPage)} />
<Route path="/reports/profit" element={lz(ProfitReportPage)} /> <Route path="/reports/profit" element={lz(ProfitReportPage)} />
<Route path="/reports/abc" element={lz(AbcReportPage)} /> <Route path="/reports/abc" element={<AdvancedModeGuard>{lz(AbcReportPage)}</AdvancedModeGuard>} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/sales/demands" element={lz(DemandsPage)} /> <Route path="/sales/demands" element={<AdvancedModeGuard>{lz(DemandsPage)}</AdvancedModeGuard>} />
<Route path="/sales/demands/new" element={lz(DemandEditPage)} /> <Route path="/sales/demands/new" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
<Route path="/sales/demands/:id" element={lz(DemandEditPage)} /> <Route path="/sales/demands/:id" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyProgramsPage)}</RoleGuard>} /> <Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyProgramsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyCardsPage)}</RoleGuard>} /> <Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyCardsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/promotions" element={<RoleGuard roles={['Admin']}>{lz(PromotionsPage)}</RoleGuard>} /> <Route path="/promotions" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(PromotionsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}>{lz(OrgAuditLogPage)}</RoleGuard>} /> <Route path="/audit-log" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(OrgAuditLogPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} />
{/* Sprint 17 */} {/* Sprint 17 */}

View file

@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import { useMe } from '@/lib/useMe'
import { Lock } from 'lucide-react'
interface Props {
children: React.ReactNode
}
/** Sprint 29 — render-guard для модулей, доступных только в «расширенном
* режиме». Если `me.advancedMode = false` и юзер не SuperAdmin — показывает
* понятную страницу «Недоступно в простом режиме» вместо белого экрана и
* 403 ProblemDetails в консоли.
*
* Серверная защита — атрибут `[RequiresAdvancedMode]`; этот компонент
* просто улучшает UX. */
export function AdvancedModeGuard({ children }: Props) {
const me = useMe()
if (me.isLoading) return null
const isSuperAdmin = me.data?.roles?.includes('SuperAdmin')
// SuperAdmin в режиме «войти как» видит всё — чтобы можно было проверить
// работоспособность после переключения режима.
if (isSuperAdmin) return <>{children}</>
if (me.data?.advancedMode) return <>{children}</>
return (
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-4">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<Lock className="h-7 w-7 text-slate-500" />
</div>
<h1 className="text-2xl font-bold">Недоступно в простом режиме</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Этот раздел включается вместе с расширенным режимом магазина.
Обратитесь к администратору платформы, чтобы включить его для
вашей организации — данные при переключении режимов не теряются.
</p>
<div className="pt-2">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
На главную
</Link>
</div>
</div>
</div>
)
}

View file

@ -26,6 +26,9 @@ interface MeResponse {
email: string email: string
roles: string[] roles: string[]
orgId: string orgId: string
/** Sprint 29 — расширенный режим орг-tenanta. false → простой магазин
* (меню фильтруется, скрытые роуты показывают «Недоступно»). */
advancedMode?: boolean
} }
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean } type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
@ -58,11 +61,14 @@ function translateRoles(roles: string[]): string {
* Источник правды для прав — серверные `[Authorize(Roles = ...)]` на * Источник правды для прав — серверные `[Authorize(Roles = ...)]` на
* каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать * каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
* неработающие пункты. */ * неработающие пункты. */
function buildNav(roles: string[]): NavSection[] { function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
const isSuperAdmin = roles.includes('SuperAdmin') const isSuperAdmin = roles.includes('SuperAdmin')
const isAdmin = roles.includes('Admin') || isSuperAdmin const isAdmin = roles.includes('Admin') || isSuperAdmin
const isCashier = roles.includes('Cashier') const isCashier = roles.includes('Cashier')
const isStorekeeper = roles.includes('Storekeeper') const isStorekeeper = roles.includes('Storekeeper')
// SuperAdmin видит ВСЕ пункты (даже при advancedMode=false для его org),
// чтобы можно было проверить что ничего не сломалось после переключения.
const advanced = advancedMode || isSuperAdmin
const sections: NavSection[] = [ const sections: NavSection[] = [
{ group: 'nav.section_main', items: [ { group: 'nav.section_main', items: [
@ -78,8 +84,12 @@ function buildNav(roles: string[]): NavSection[] {
catalog.push( catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
{ to: '/catalog/units', icon: Ruler, label: 'nav.units' }, { to: '/catalog/units', icon: Ruler, label: 'nav.units' },
{ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' },
) )
// В простом режиме у магазина одна цена продажи — страница управления
// типами цен не нужна. SuperAdmin и расширенный режим — видят.
if (advanced) {
catalog.push({ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' })
}
} }
sections.push({ group: 'nav.section_catalog', items: catalog }) sections.push({ group: 'nav.section_catalog', items: catalog })
} }
@ -97,7 +107,10 @@ function buildNav(roles: string[]): NavSection[] {
stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' }) stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' }) stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'nav.losses' }) stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'nav.losses' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' }) // Перемещения между точками — только в расширенном режиме.
if (advanced) {
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
}
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' }) stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
} }
sections.push({ group: 'nav.section_inventory', items: stock }) sections.push({ group: 'nav.section_inventory', items: stock })
@ -115,7 +128,8 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isCashier) { if (isAdmin || isCashier) {
sections.push({ group: 'nav.section_sales', items: [ sections.push({ group: 'nav.section_sales', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' }, { to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
...(isAdmin ? [ // Оптовые отгрузки / промо / лояльность — только расширенный.
...(isAdmin && advanced ? [
{ to: '/sales/demands', icon: Send, label: 'nav.demands' }, { to: '/sales/demands', icon: Send, label: 'nav.demands' },
{ to: '/promotions', icon: Tag, label: 'nav.promotions' }, { to: '/promotions', icon: Tag, label: 'nav.promotions' },
{ to: '/loyalty/programs', icon: ShieldCheck, label: 'nav.loyaltyPrograms' }, { to: '/loyalty/programs', icon: ShieldCheck, label: 'nav.loyaltyPrograms' },
@ -124,14 +138,15 @@ function buildNav(roles: string[]): NavSection[] {
]}) ]})
} }
// Отчёты — Admin и Storekeeper. // Отчёты — Admin и Storekeeper. ABC — только расширенный режим.
if (isAdmin || isStorekeeper) { if (isAdmin || isStorekeeper) {
sections.push({ group: 'nav.section_reports', items: [ const reports: NavItem[] = [
{ to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' }, { to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' },
{ to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' }, { to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' },
{ to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' }, { to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' },
{ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' }, ]
]}) if (advanced) reports.push({ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' })
sections.push({ group: 'nav.section_reports', items: reports })
} }
if (isAdmin) { if (isAdmin) {
@ -144,7 +159,10 @@ function buildNav(roles: string[]): NavSection[] {
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' }, { to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' },
{ to: '/settings/employees', icon: UserCog, label: 'nav.employees' }, { to: '/settings/employees', icon: UserCog, label: 'nav.employees' },
{ to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' }, { to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' },
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' }, // Аудит организации — только в расширенном режиме.
...(advanced ? [
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
] : []),
]}) ]})
} }
@ -169,7 +187,7 @@ export function AppLayout() {
}) })
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(me?.roles ?? []) const nav = buildNav(me?.roles ?? [], !!me?.advancedMode)
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
// даёт периферийный сигнал «я не в своей админке». // даёт периферийный сигнал «я не в своей админке».
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')
@ -256,7 +274,11 @@ export function AppLayout() {
<div className="truncate">{translateRoles(me.roles)}</div> <div className="truncate">{translateRoles(me.roles)}</div>
</div> </div>
)} )}
<div className="px-2 pb-2"><LanguageSwitcher /></div> {/* Переключатель языка — только в расширенном режиме; в простом
магазине интерфейс только по-русски. SuperAdmin видит всегда. */}
{(me?.advancedMode || isSuperAdmin) && (
<div className="px-2 pb-2"><LanguageSwitcher /></div>
)}
{/* Sprint 17: feedback widget + ссылки на /help и /whats-new. {/* Sprint 17: feedback widget + ссылки на /help и /whats-new.
Sprint 18: NotificationCenter — иконка-колокольчик с popover'ом. */} Sprint 18: NotificationCenter — иконка-колокольчик с popover'ом. */}
<div className="px-2 pb-2 flex items-center gap-3 flex-wrap"> <div className="px-2 pb-2 flex items-center gap-3 flex-wrap">

View file

@ -12,6 +12,11 @@ export interface MeResponse {
hasLiveOrg: boolean hasLiveOrg: boolean
/** Активный Employee текущего AppUser в его Organization. False → orphan. */ /** Активный Employee текущего AppUser в его Organization. False → orphan. */
hasActiveEmployee: boolean hasActiveEmployee: boolean
/** Sprint 29 — расширенный режим орг-tenanta (SuperAdmin включает
* вручную). false → UI скрывает «не для простого магазина»
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
* пресеты, Telegram-бот, SSO, несколько типов цен. */
advancedMode: boolean
} }
/** Текущий залогиненный юзер с ролями. Используется для гейтов /** Текущий залогиненный юзер с ролями. Используется для гейтов

View file

@ -17,6 +17,7 @@ import { PhoneInput } from '@/components/PhoneInput'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useMe } from '@/lib/useMe'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types' import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties' const URL = '/api/catalog/counterparties'
@ -56,6 +57,11 @@ const typeLabel: Record<CounterpartyType, string> = {
export function CounterpartiesPage() { export function CounterpartiesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const me = useMe()
// Sprint 29 — простой режим скрывает редко нужные поля (юр.название,
// банк, страна, контактное лицо, email). Значения существующих
// контрагентов сохраняются и при переключении в расширенный снова видны.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({}) const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
const { confirm, dialogProps } = useConfirm() const { confirm, dialogProps } = useConfirm()
@ -165,9 +171,11 @@ export function CounterpartiesPage() {
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (fieldErrors.name) setFieldErrors(p => ({...p, name: undefined})) }} onChange={(e) => { setForm({ ...form, name: e.target.value }); if (fieldErrors.name) setFieldErrors(p => ({...p, name: undefined})) }}
onBlur={() => { if (!form.name.trim()) setFieldErrors(p => ({...p, name: 'Название обязательно'})) }} /> onBlur={() => { if (!form.name.trim()) setFieldErrors(p => ({...p, name: 'Название обязательно'})) }} />
</Field> </Field>
<Field label="Юридическое название" className="col-span-2"> {!simpleMode && (
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} /> <Field label="Юридическое название" className="col-span-2">
</Field> <TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</Field>
)}
<Field label="Тип лица"> <Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}> <Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option> <option value={CounterpartyType.LegalEntity}>Юрлицо</option>
@ -180,12 +188,14 @@ export function CounterpartiesPage() {
<Field label="ИИН (физлицо РК)"> <Field label="ИИН (физлицо РК)">
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" /> <TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
</Field> </Field>
<Field label="Страна"> {!simpleMode && (
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}> <Field label="Страна">
<option value="">—</option> <Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} <option value="">—</option>
</Select> {countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Field> </Select>
</Field>
)}
<Field label="Адрес" className="col-span-2"> <Field label="Адрес" className="col-span-2">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field> </Field>
@ -194,23 +204,29 @@ export function CounterpartiesPage() {
onChange={(v) => { setForm({ ...form, phone: v }); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }} onChange={(v) => { setForm({ ...form, phone: v }); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { if (form.phone) { const e = validatePhone(form.phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} /> onBlur={() => { if (form.phone) { const e = validatePhone(form.phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} />
</Field> </Field>
<Field label="Email" error={fieldErrors.email}> {!simpleMode && (
<TextInput type="email" value={form.email} <Field label="Email" error={fieldErrors.email}>
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }} <TextInput type="email" value={form.email}
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} /> onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
</Field> onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
<Field label="Банк" className="col-span-2"> </Field>
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} /> )}
</Field> {!simpleMode && (
<Field label="Расчётный счёт"> <>
<TextInput value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} /> <Field label="Банк" className="col-span-2">
</Field> <TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
<Field label="БИК"> </Field>
<TextInput value={form.bik} onChange={(e) => setForm({ ...form, bik: e.target.value })} /> <Field label="Расчётный счёт">
</Field> <TextInput value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} />
<Field label="Контактное лицо" className="col-span-2"> </Field>
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} /> <Field label="БИК">
</Field> <TextInput value={form.bik} onChange={(e) => setForm({ ...form, bik: e.target.value })} />
</Field>
<Field label="Контактное лицо" className="col-span-2">
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
</Field>
</>
)}
<Field label="Заметки" className="col-span-2"> <Field label="Заметки" className="col-span-2">
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /> <TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field> </Field>

View file

@ -26,6 +26,9 @@ interface MeResponse {
email: string email: string
roles: string[] roles: string[]
orgId: string orgId: string
/** Sprint 29 — расширенный режим орг-tenanta. false → скрыты
* продвинутые виджеты (TopProducts, Margin). */
advancedMode?: boolean
} }
function useCount(url: string) { function useCount(url: string) {
@ -208,14 +211,20 @@ export function DashboardPage() {
)} )}
</section> </section>
{/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */} {/* Виджеты. В простом режиме — только «что заканчивается» и
«последние продажи». «Топ товаров» и «Маржа» требуют расширенный
режим (SuperAdmin видит всегда). */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}> {(me.data?.advancedMode || me.data?.roles?.includes('SuperAdmin')) && (
<TopProductsWidget days={7} /> <>
</Suspense> <Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}> <TopProductsWidget days={7} />
<MarginWidget days={30} /> </Suspense>
</Suspense> <Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<MarginWidget days={30} />
</Suspense>
</>
)}
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}> <Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<RecentSalesWidget limit={10} /> <RecentSalesWidget limit={10} />
</Suspense> </Suspense>

View file

@ -9,6 +9,7 @@ import {
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups, useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
} from '@/lib/useLookups' } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery' import { ProductImageGallery } from '@/components/ProductImageGallery'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
@ -68,6 +69,11 @@ export function ProductEditPage() {
const { confirm, dialogProps } = useConfirm() const { confirm, dialogProps } = useConfirm()
const priceTypes = usePriceTypes() const priceTypes = usePriceTypes()
const org = useOrgSettings() const org = useOrgSettings()
const me = useMe()
// Sprint 29 — простой режим: все advanced-only поля скрываются (значения
// сохраняются, при переключении в расширенный снова видно). SuperAdmin
// режим не ощущает — ему доступно всё.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const existing = useQuery({ const existing = useQuery({
queryKey: ['/api/catalog/products', id], queryKey: ['/api/catalog/products', id],
@ -298,12 +304,16 @@ export function ProductEditPage() {
<Section title="Основное"> <Section title="Основное">
<Grid cols={3}> <Grid cols={3}>
<Field label="Название *" className="col-span-2"> <Field label="Название *" className={simpleMode ? 'col-span-3' : 'col-span-2'}>
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Артикул *"> {/* Артикул — только расширенный режим; в простом генерируется
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} /> автоматически (см. useEffect с generateArticle). */}
</Field> {!simpleMode && (
<Field label="Артикул *">
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
)}
</Grid> </Grid>
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800"> <div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
@ -361,7 +371,7 @@ export function ProductEditPage() {
)} )}
</div> </div>
{org.data?.showDescriptionOnProduct && ( {!simpleMode && org.data?.showDescriptionOnProduct && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800"> <div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Field label="Описание"> <Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
@ -406,7 +416,7 @@ export function ProductEditPage() {
<div> <div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
<div className="space-y-3"> <div className="space-y-3">
{org.data?.showReferencePriceOnProduct && ( {!simpleMode && org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена"> <Field label="Эталонная цена">
<MoneyInput <MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)} value={form.referencePrice === '' ? null : Number(form.referencePrice)}
@ -427,7 +437,7 @@ export function ProductEditPage() {
/> />
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">расчётная (скользящее среднее)</p> <p className="text-xs text-slate-500 dark:text-slate-400 mt-1">расчётная (скользящее среднее)</p>
</Field> </Field>
{org.data?.multiCurrencyEnabled && ( {!simpleMode && org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки"> <Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}> <Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value="">—</option> <option value="">—</option>
@ -446,7 +456,12 @@ export function ProductEditPage() {
* новая, при стирании — null Amount (UI пустое). Обязательные (IsRequired) * новая, при стирании — null Amount (UI пустое). Обязательные (IsRequired)
* помечаются звёздочкой. */} * помечаются звёздочкой. */}
<div className="space-y-3"> <div className="space-y-3">
{priceTypes.data?.slice().sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => { {priceTypes.data?.slice()
// В простом режиме у магазина одна цена продажи — показываем
// только retail/system price type, остальные (оптовая, VIP и т.п.)
// скрыты, но их значения сохраняются и не стираются при save.
.filter((pt) => !simpleMode || pt.isRetail || pt.isSystem)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
const idx = form.prices.findIndex(p => p.priceTypeId === pt.id) const idx = form.prices.findIndex(p => p.priceTypeId === pt.id)
const row = idx >= 0 ? form.prices[idx] : null const row = idx >= 0 ? form.prices[idx] : null
const required = pt.isRequired const required = pt.isRequired
@ -515,7 +530,7 @@ export function ProductEditPage() {
</Select> </Select>
</Field> </Field>
</Grid> </Grid>
{org.data?.showCountryOfOriginOnProduct && ( {!simpleMode && org.data?.showCountryOfOriginOnProduct && (
<Grid cols={2}> <Grid cols={2}>
<Field label="Страна происхождения"> <Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}> <Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
@ -525,7 +540,7 @@ export function ProductEditPage() {
</Field> </Field>
</Grid> </Grid>
)} )}
{org.data?.showVatEnabledOnProduct && form.vatEnabled && ( {!simpleMode && org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}> <Grid cols={3}>
<Field label="Ставка НДС, %"> <Field label="Ставка НДС, %">
<NumberInput <NumberInput
@ -536,19 +551,35 @@ export function ProductEditPage() {
</Grid> </Grid>
)} )}
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2"> <div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
{org.data?.showVatEnabledOnProduct && ( {!simpleMode && org.data?.showVatEnabledOnProduct && (
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} /> <Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
)} )}
{org.data?.showServiceOnProduct && ( {!simpleMode && org.data?.showServiceOnProduct && (
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} /> <Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
)} )}
{org.data?.showMarkedOnProduct && ( {!simpleMode && org.data?.showMarkedOnProduct && (
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} /> <Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
)} )}
</div> </div>
</Section> </Section>
{org.data?.showMinMaxStock && ( {/* В простом режиме «Мин. остаток» входит в базовый набор полей
карточки (см. docs/simple-mode.md), поэтому показываем его
всегда — но без «Максимального» (это для автозаказа, продвинутая
фича). В расширенном — обе колонки, при условии org.ShowMinMaxStock. */}
{simpleMode ? (
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">
<NumberInput
value={form.minStock === '' ? null : Number(form.minStock)}
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
</Grid>
</AdvancedSection>
) : org.data?.showMinMaxStock && (
<AdvancedSection> <AdvancedSection>
<Grid cols={4}> <Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)"> <Field label="Минимальный остаток (для уведомления)">

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus, Archive, RotateCcw, Trash2, LogIn, Users } from 'lucide-react' import { Plus, Archive, RotateCcw, Trash2, LogIn, Users, Sparkles } from 'lucide-react'
import { api, setOrgOverride } from '@/lib/api' import { api, setOrgOverride } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
@ -18,6 +18,7 @@ interface OrgRow {
id: string; name: string; countryCode: string id: string; name: string; countryCode: string
isActive: boolean; isArchived: boolean; archivedAt: string | null isActive: boolean; isArchived: boolean; archivedAt: string | null
createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null
isAdvancedMode: boolean
} }
export function SuperAdminOrganizationsPage() { export function SuperAdminOrganizationsPage() {
@ -27,6 +28,22 @@ export function SuperAdminOrganizationsPage() {
const [archiveOf, setArchiveOf] = useState<OrgRow | null>(null) const [archiveOf, setArchiveOf] = useState<OrgRow | null>(null)
const [deleteOf, setDeleteOf] = useState<OrgRow | null>(null) const [deleteOf, setDeleteOf] = useState<OrgRow | null>(null)
const [confirmName, setConfirmName] = useState('') const [confirmName, setConfirmName] = useState('')
// Sprint 29 — модалка подтверждения переключения режима; `toggleMode.target`
// хранит и строку, и целевое состояние (вкл./выкл.).
const [toggleModeOf, setToggleModeOf] = useState<{ row: OrgRow; enable: boolean } | null>(null)
const [togglePending, setTogglePending] = useState(false)
const applyToggleMode = async () => {
if (!toggleModeOf) return
setTogglePending(true)
try {
await api.put(`${URL}/${toggleModeOf.row.id}/advanced-mode`, { enabled: toggleModeOf.enable })
setToggleModeOf(null)
list.refetch()
} finally {
setTogglePending(false)
}
}
const archive = async () => { const archive = async () => {
if (!archiveOf || confirmName !== archiveOf.name) return if (!archiveOf || confirmName !== archiveOf.name) return
@ -107,6 +124,9 @@ export function SuperAdminOrganizationsPage() {
{ header: 'Статус', width: '120px', cell: (r) => r.isArchived { header: 'Статус', width: '120px', cell: (r) => r.isArchived
? <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">Архив</span> ? <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">Архив</span>
: <span className="text-xs text-emerald-600">Активна</span> }, : <span className="text-xs text-emerald-600">Активна</span> },
{ header: 'Режим', width: '130px', cell: (r) => r.isAdvancedMode
? <span className="text-xs px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200">Расширенный</span>
: <span className="text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">Простой</span> },
{ header: '', width: '160px', cell: (r) => ( { header: '', width: '160px', cell: (r) => (
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}> <div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
{!r.isArchived && ( {!r.isArchived && (
@ -121,6 +141,16 @@ export function SuperAdminOrganizationsPage() {
className="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded"> className="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
</button> </button>
{!r.isArchived && (
<button
title={r.isAdvancedMode ? 'Выключить расширенный режим' : 'Включить расширенный режим'}
onClick={() => setToggleModeOf({ row: r, enable: !r.isAdvancedMode })}
className={`p-1.5 rounded ${r.isAdvancedMode
? 'text-violet-600 hover:bg-violet-50 dark:hover:bg-violet-900/20'
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'}`}>
<Sparkles className="w-4 h-4" />
</button>
)}
{!r.isArchived ? ( {!r.isArchived ? (
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }} <button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"> className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">
@ -167,6 +197,42 @@ export function SuperAdminOrganizationsPage() {
)} )}
</Modal> </Modal>
<Modal
open={!!toggleModeOf}
onClose={() => setToggleModeOf(null)}
title={toggleModeOf?.enable ? 'Включить расширенный режим' : 'Выключить расширенный режим'}
footer={<>
<Button variant="secondary" onClick={() => setToggleModeOf(null)} disabled={togglePending}>Отмена</Button>
<Button onClick={applyToggleMode} disabled={togglePending}>
{togglePending ? 'Применяю…' : (toggleModeOf?.enable ? 'Включить' : 'Выключить')}
</Button>
</>}
>
{toggleModeOf && (
<div className="space-y-3 text-sm">
{toggleModeOf.enable ? (
<>
<p>У организации <strong>{toggleModeOf.row.name}</strong> будет доступен полный набор функций:
оптовые отгрузки, перемещения между точками, лояльность, промо/акции,
ABC-анализ, аудит организации, несколько типов цен, bulk-операции и
сохранённые пресеты, Telegram-бот владельца, SSO, выбор языка.</p>
<p className="text-slate-500">Переключение логируется в SuperAdmin audit-log.</p>
</>
) : (
<>
<p>У организации <strong>{toggleModeOf.row.name}</strong> останется только базовый
розничный учёт. Советуем сначала убедиться, что сотрудники не ведут активных
операций в скрываемых модулях.</p>
<p className="text-slate-500">
Данные скрытых модулей <strong>не удаляются</strong> — при повторном включении
всё снова будет видно. Переключение логируется в SuperAdmin audit-log.
</p>
</>
)}
</div>
)}
</Modal>
<Modal open={!!deleteOf} onClose={() => setDeleteOf(null)} <Modal open={!!deleteOf} onClose={() => setDeleteOf(null)}
title="Удалить организацию навсегда" title="Удалить организацию навсегда"
footer={<> footer={<>

View file

@ -15,6 +15,7 @@ import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts' import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups' import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types' import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
interface LineRow { interface LineRow {
@ -68,6 +69,11 @@ export function SupplyEditPage() {
const stores = useStores() const stores = useStores()
const currencies = useCurrencies() const currencies = useCurrencies()
const org = useOrgSettings() const org = useOrgSettings()
const me = useMe()
// Sprint 29 — простой режим: мульти-валюта всегда выключена, независимо
// от org-настроек. В UI колонка валюты скрыта, send-side уже отправляет
// DefaultCurrencyId.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const priceTypes = usePriceTypes() const priceTypes = usePriceTypes()
const { confirm, dialogProps } = useConfirm() const { confirm, dialogProps } = useConfirm()
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе. // Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
@ -368,7 +374,7 @@ export function SupplyEditPage() {
</Select> </Select>
</Field> </Field>
)} )}
{org.data?.multiCurrencyEnabled && ( {!simpleMode && org.data?.multiCurrencyEnabled && (
<Field label="Валюта *"> <Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted} <Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}> onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>

View file

@ -0,0 +1,256 @@
/**
* Sprint 29 — Simple/Advanced mode: e2e-сценарий для stage.
*/
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import { chromium, type Browser } from 'playwright'
type Org = {
id: string
email: string
password: string
token: string
name: string
}
type Ctx = {
apiOnly: boolean
ts: number
org?: Org
saToken?: string
presetId?: string
archivedOrg?: Org
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
const TS = Date.now()
const SA_EMAIL = process.env.SA_EMAIL ?? 'admin@food-market.local'
const SA_PASSWORD = process.env.SA_PASSWORD ?? 'Admin12345!'
function check(step: Step, c: CheckResult) { step.checks.push(c) }
function asString(x: unknown): string {
if (x == null) return ''
if (typeof x === 'string') return x
return JSON.stringify(x)
}
async function bootstrapOrg(suffix: string, phone: string): Promise<Org> {
const api = makeClient()
const email = `stage-sm-${suffix}-${TS}@food-market.local`
const password = 'StageSm12345!'
const name = `Sm ${suffix} ${TS}`
let r = await api.post('/api/auth/signup', { email, password, organizationName: name, phone, plan: 'start' })
for (let i = 0; i < 5 && r.status === 429; i++) {
await new Promise(res => setTimeout(res, 15000))
r = await api.post('/api/auth/signup', { email, password, organizationName: name, phone, plan: 'start' })
}
if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status}`)
const sess = await login(email, password)
return { id: sess.orgId!, email, password, token: sess.accessToken, name }
}
async function getSaToken(): Promise<string> {
const s = await login(SA_EMAIL, SA_PASSWORD)
return s.accessToken
}
// ---------------------------------------------------------------------------
export async function sm01_signup_defaults_to_simple({ ctx, step }: StepCtx) {
ctx.ts = TS
ctx.org = await bootstrapOrg('a', '+77011190011')
const api = makeClient(ctx.org.token)
const me = await api.get('/api/me')
check(step, {
kind: 'api',
description: 'GET /api/me.advancedMode = false у новой org',
ok: me.status === 200 && me.data?.advancedMode === false,
detail: `status=${me.status} advancedMode=${me.data?.advancedMode}`,
})
const demand = await api.get('/api/sales/demands')
check(step, {
kind: 'api',
description: 'GET /api/sales/demands → 403 ProblemDetails code=advanced-mode-required',
ok: demand.status === 403 && demand.data?.code === 'advanced-mode-required',
detail: `status=${demand.status} code=${demand.data?.code}`,
})
}
export async function sm02_org_admin_cannot_toggle({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
const api = makeClient(ctx.org.token)
const r = await api.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Админ орги PUT /api/super-admin/.../advanced-mode → 403',
ok: r.status === 403,
detail: `status=${r.status}`,
})
}
export async function sm03_superadmin_enables_and_audit({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
ctx.saToken = await getSaToken()
const sa = makeClient(ctx.saToken)
const r = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'SuperAdmin PUT advanced-mode → 200 + isAdvancedMode=true + changed=true',
ok: r.status === 200 && r.data?.isAdvancedMode === true && r.data?.changed === true,
detail: `status=${r.status} payload=${asString(r.data)}`,
})
// Повтор — changed=false (idempotent).
const again = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Повторный PUT → 200 + changed=false',
ok: again.status === 200 && again.data?.changed === false,
detail: `status=${again.status} changed=${again.data?.changed}`,
})
// Audit entry.
const audit = await sa.get(`/api/super-admin/audit-log?organizationId=${ctx.org.id}&pageSize=50`)
const found = (audit.data?.items ?? []).some((x: { actionType: string }) =>
x.actionType === 'EnableAdvancedMode')
check(step, {
kind: 'api',
description: 'super_admin_audit_log содержит EnableAdvancedMode',
ok: audit.status === 200 && found,
detail: `status=${audit.status} found=${found}`,
})
}
export async function sm04_me_reflects_switch_and_opens_endpoint({ ctx, step }: StepCtx) {
if (!ctx.org) { step.status = 'skip'; return }
// Кэш в фильтре инвалидируется на бэкенде; access-token свой же — /api/me теперь advancedMode=true.
const api = makeClient(ctx.org.token)
const me = await api.get('/api/me')
check(step, {
kind: 'api',
description: 'GET /api/me.advancedMode = true после включения',
ok: me.status === 200 && me.data?.advancedMode === true,
detail: `advancedMode=${me.data?.advancedMode}`,
})
const demand = await api.get('/api/sales/demands')
check(step, {
kind: 'api',
description: 'GET /api/sales/demands → не 403 после включения',
ok: demand.status !== 403,
detail: `status=${demand.status}`,
})
}
export async function sm05_data_survives_toggle_cycle({ ctx, step }: StepCtx) {
if (!ctx.org || !ctx.saToken) { step.status = 'skip'; return }
const api = makeClient(ctx.org.token)
const sa = makeClient(ctx.saToken)
// Создаём пресет в advanced-режиме.
const presetName = `preset-sm-${TS}`
const cr = await api.post('/api/user/presets', {
pageKey: 'products',
name: presetName,
configJson: '{"q":"test"}',
})
check(step, {
kind: 'api',
description: 'POST /api/user/presets → 200',
ok: cr.status === 200,
detail: `status=${cr.status}`,
})
// Выключаем — пресет недоступен (403).
const off = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: false })
check(step, {
kind: 'api',
description: 'Выключение расширенного режима → 200',
ok: off.status === 200,
detail: `status=${off.status}`,
})
const forb = await api.get('/api/user/presets?pageKey=products')
check(step, {
kind: 'api',
description: 'GET /api/user/presets в простом режиме → 403',
ok: forb.status === 403,
detail: `status=${forb.status}`,
})
// Снова включаем — пресет виден.
const on = await sa.put(`/api/super-admin/organizations/${ctx.org.id}/advanced-mode`, { enabled: true })
check(step, {
kind: 'api',
description: 'Повторное включение → 200',
ok: on.status === 200,
detail: `status=${on.status}`,
})
const list = await api.get('/api/user/presets?pageKey=products')
const hasPreset = (list.data?.items ?? list.data ?? []).some?.((p: { name: string }) => p.name === presetName)
|| JSON.stringify(list.data).includes(presetName)
check(step, {
kind: 'api',
description: 'Пресет сохранён и снова виден после вкл→выкл→вкл',
ok: list.status === 200 && !!hasPreset,
detail: `status=${list.status} present=${hasPreset}`,
})
}
export async function sm06_archived_org_cannot_toggle({ ctx, step }: StepCtx) {
if (!ctx.saToken) { step.status = 'skip'; return }
const sa = makeClient(ctx.saToken)
// Отдельная org, чтобы не трогать ту что используется UI-шагом.
ctx.archivedOrg = await bootstrapOrg('arch', '+77011190012')
const arcName = ctx.archivedOrg.name
const arc = await sa.post(`/api/super-admin/organizations/${ctx.archivedOrg.id}/archive`,
{ confirmationName: arcName })
check(step, {
kind: 'api',
description: 'Архивация тестовой org — 204',
ok: arc.status === 204 || arc.status === 200,
detail: `status=${arc.status}`,
})
const put = await sa.put(`/api/super-admin/organizations/${ctx.archivedOrg.id}/advanced-mode`,
{ enabled: true })
check(step, {
kind: 'api',
description: 'PUT advanced-mode на архивную org → 409',
ok: put.status === 409,
detail: `status=${put.status}`,
})
}
export async function sm07_ui_shows_not_available_page({ ctx, step }: StepCtx) {
if (ctx.apiOnly) { step.status = 'skip'; return }
// Отдельная org в простом режиме; UI-проверка через Playwright.
const org = await bootstrapOrg('ui', '+77011190013')
let browser: Browser | undefined
try {
browser = await chromium.launch({ headless: true })
const ctxBrowser = await browser.newContext({ ignoreHTTPSErrors: true })
const page = await ctxBrowser.newPage()
// Прокидываем access-token через localStorage, чтобы миновать форму логина.
await page.goto(ADMIN_BASE + '/login')
await page.evaluate((tok) => {
localStorage.setItem('accessToken', tok)
}, org.token)
await page.goto(ADMIN_BASE + '/sales/demands')
// Текст страницы «Недоступно в простом режиме» рендерит AdvancedModeGuard.
const notAvailable = await page.waitForSelector('text=Недоступно в простом режиме', { timeout: 10_000 }).catch(() => null)
check(step, {
kind: 'ui',
description: 'UI: /sales/demands в простом режиме показывает страницу «Недоступно»',
ok: notAvailable !== null,
detail: notAvailable ? 'guard page rendered' : 'guard page not found within 10s',
})
} finally {
await browser?.close().catch(() => {})
}
}

View file

@ -0,0 +1,38 @@
name: stage-simple-mode
description: |
Sprint 29 — Simple/Advanced mode: все орги по умолчанию в простом
режиме; расширенный включает только SuperAdmin через
/api/super-admin/organizations/{id}/advanced-mode. Данные скрытых
модулей сохраняются и при повторном включении снова видны.
Что проверяем на стейдже (test.admin.food-market.kz):
1. Новая org стартует в простом режиме (/api/me.advancedMode=false).
2. Админ орги не может переключить режим (403).
3. GET на скрытый эндпоинт в простом режиме → 403 ProblemDetails
с code=advanced-mode-required.
4. SuperAdmin переключает → 200 + запись в super_admin_audit_log.
5. После включения /api/me.advancedMode=true и эндпоинт открылся.
6. Данные пресета сохраняются через цикл выкл→вкл (не удаляются).
7. Архивную org переключать нельзя (409).
8. UI-проверка: админ простой орги на /sales/demands видит
страницу «Недоступно в простом режиме», а не белый экран.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: sm01_signup_defaults_to_simple
title: Новая org → /api/me.advancedMode=false и demand-эндпоинт 403
- id: sm02_org_admin_cannot_toggle
title: Админ орги PUT /api/super-admin/.../advanced-mode → 403
- id: sm03_superadmin_enables_and_audit
title: SuperAdmin включает → 200 + запись EnableAdvancedMode в super-admin audit-log
- id: sm04_me_reflects_switch_and_opens_endpoint
title: /api/me.advancedMode=true и GET /api/sales/demands → не 403
- id: sm05_data_survives_toggle_cycle
title: Пресет создан в advanced → выкл → вкл → пресет на месте
- id: sm06_archived_org_cannot_toggle
title: Архивную org переключать нельзя (409)
- id: sm07_ui_shows_not_available_page
title: UI: простой админ на /sales/demands видит страницу «Недоступно»

View file

@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 29 — Simple/Advanced mode: integration-тесты по чек-листу
/// задачи:
/// <list type="bullet">
/// <item>простая org → GET на скрытый эндпоинт возвращает 403 + ProblemDetails;</item>
/// <item>org-admin не может переключить режим (403);</item>
/// <item>SuperAdmin переключает → 200 + запись в SuperAdminAuditLog;</item>
/// <item><c>/api/me</c> отдаёт корректный <c>advancedMode</c>;</item>
/// <item>данные скрытого модуля сохраняются после выкл→вкл.</item>
/// </list>
/// </summary>
[Collection(ApiCollection.Name)]
public class AdvancedModeTests
{
private readonly ApiFactory _factory;
public AdvancedModeTests(ApiFactory factory) => _factory = factory;
private const string SuperAdminEmail = "admin@food-market.local";
private const string SuperAdminPassword = "Admin12345!";
private async Task<ApiActor> LoginSuperAdminAsync()
{
var sa = new ApiActor(_factory.CreateClient());
sa.UseToken(await sa.TokenAsync(SuperAdminEmail, SuperAdminPassword));
return sa;
}
private static async Task<string> OrgIdFromMeAsync(ApiActor actor)
{
var me = await actor.GetJsonAsync("/api/me");
return me.GetProperty("orgId").GetString()!;
}
[Fact]
public async Task Me_returns_advancedMode_false_by_default()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-default-{Guid.NewGuid():N}");
var me = await admin.GetJsonAsync("/api/me");
me.GetProperty("advancedMode").GetBoolean().Should().BeFalse(
"новая организация стартует в простом режиме");
}
[Fact]
public async Task Simple_mode_gets_403_ProblemDetails_on_hidden_endpoint()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-hidden-{Guid.NewGuid():N}");
using var resp = await admin.Http.GetAsync("/api/sales/demands");
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden,
"demand-эндпоинт скрыт в простом режиме");
// ProblemDetails с нашим кодом.
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("status").GetInt32().Should().Be(403);
body.GetProperty("code").GetString().Should().Be("advanced-mode-required");
}
[Fact]
public async Task Org_admin_cannot_toggle_advanced_mode()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-noadmin-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
using var resp = await admin.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode",
new { enabled = true });
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden,
"SuperAdmin-эндпоинт недоступен обычному админу орги");
}
[Fact]
public async Task SuperAdmin_can_toggle_and_writes_audit_entry()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-toggle-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
using var on = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
on.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await on.Content.ReadFromJsonAsync<JsonElement>();
payload.GetProperty("isAdvancedMode").GetBoolean().Should().BeTrue();
payload.GetProperty("changed").GetBoolean().Should().BeTrue();
// Повторный тот же вызов — changed=false, в аудит не пишется.
using var again = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
again.StatusCode.Should().Be(HttpStatusCode.OK);
(await again.Content.ReadFromJsonAsync<JsonElement>())
.GetProperty("changed").GetBoolean().Should().BeFalse();
// Audit: SuperAdminAuditLog содержит запись EnableAdvancedMode по этой org.
var auditList = await sa.ListAsync($"/api/super-admin/audit-log?organizationId={orgId}&pageSize=50");
auditList.Should().Contain(e =>
e.GetProperty("actionType").GetString() == "EnableAdvancedMode"
&& e.GetProperty("organizationId").GetString() == orgId);
}
[Fact]
public async Task Me_advancedMode_switches_with_toggle_and_endpoint_opens()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-switch-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
// Простой режим — demand 403.
(await admin.Http.GetAsync("/api/sales/demands")).StatusCode
.Should().Be(HttpStatusCode.Forbidden);
var sa = await LoginSuperAdminAsync();
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
// /api/me теперь advancedMode=true.
var me2 = await admin.GetJsonAsync("/api/me");
me2.GetProperty("advancedMode").GetBoolean().Should().BeTrue();
// Demand-эндпоинт открылся.
var open = await admin.Http.GetAsync("/api/sales/demands");
open.StatusCode.Should().NotBe(HttpStatusCode.Forbidden,
"после включения расширенного режима demand-эндпоинт доступен");
}
[Fact]
public async Task Data_in_hidden_module_survives_toggle_cycle()
{
// Поведение «данные не удаляются» проверяется через UserPreset:
// создаём пресет в advanced-режиме, выключаем → заново включаем →
// пресет на месте.
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-cycle-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
// Создаём пресет.
var presetName = $"preset-{Guid.NewGuid():N}";
using var create = await admin.Http.PostAsJsonAsync("/api/user/presets", new
{
pageKey = "products",
name = presetName,
configJson = "{\"q\":\"test\"}",
});
create.StatusCode.Should().Be(HttpStatusCode.OK);
// Выключаем режим — пресет временно недоступен (403), но не удалён.
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = false }))
.EnsureSuccessStatusCode();
(await admin.Http.GetAsync("/api/user/presets?pageKey=products")).StatusCode
.Should().Be(HttpStatusCode.Forbidden);
// Снова включаем — пресет виден.
(await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true }))
.EnsureSuccessStatusCode();
var list = await admin.ListAsync("/api/user/presets?pageKey=products");
list.Should().Contain(e => e.GetProperty("name").GetString() == presetName,
"данные пресета сохраняются при переключении режима");
}
[Fact]
public async Task Toggle_fails_for_archived_org_with_409()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"adv-arch-{Guid.NewGuid():N}");
var orgId = await OrgIdFromMeAsync(admin);
var sa = await LoginSuperAdminAsync();
// Нужно узнать имя для подтверждения архивирования.
var detail = await sa.GetJsonAsync($"/api/super-admin/organizations/{orgId}");
var name = detail.GetProperty("name").GetString()!;
(await sa.Http.PostAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/archive", new { confirmationName = name }))
.EnsureSuccessStatusCode();
using var put = await sa.Http.PutAsJsonAsync(
$"/api/super-admin/organizations/{orgId}/advanced-mode", new { enabled = true });
put.StatusCode.Should().Be(HttpStatusCode.Conflict,
"архивную org переключать нельзя");
}
}

View file

@ -38,7 +38,17 @@ static ApiFactory()
.WithPassword("food_market_test") .WithPassword("food_market_test")
.Build(); .Build();
public async Task InitializeAsync() => await _db.StartAsync(); public async Task InitializeAsync()
{
await _db.StartAsync();
// Program.cs читает ConnectionStrings:Default из builder.Configuration
// ЕЩЁ ДО сборки host'а — поэтому ConfigureAppConfiguration ниже успевает
// только для последующих читателей (например, Hangfire на строке 395).
// Чтобы pool-tuned conn string (строка 86) взял значение контейнера —
// прокидываем через env-var, которую CreateBuilder подхватывает в самом
// начале. Приоритет Environment > appsettings.Development.json.
Environment.SetEnvironmentVariable("ConnectionStrings__Default", _db.GetConnectionString());
}
public new async Task DisposeAsync() public new async Task DisposeAsync()
{ {

View file

@ -0,0 +1,169 @@
using System.Security.Claims;
using FluentAssertions;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using foodmarket.UnitTests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 29 — юнит-тесты фильтра <c>AdvancedModeFilter</c>:
/// принимает/отбивает запросы в зависимости от <c>Organization.IsAdvancedMode</c>,
/// SuperAdmin bypass'ит, кэш инвалидируется по <c>InvalidateCache</c>.</summary>
public class AdvancedModeFilterTests
{
private static AppDbContext NewDb()
{
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"advmode-{Guid.NewGuid():N}")
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
.Options;
// Фильтр читает Organizations через IgnoreQueryFilters, но AppDbContext
// всё равно нужен ITenantContext (нулевой использовать нельзя — выбросит
// при OnModelCreating). Для теста достаточно IsSuperAdmin=true, чтобы
// query-filter не блокировал запись.
var tenant = new FakeTenantContext { IsSuperAdmin = true };
return new AppDbContext(opts, tenant);
}
private static AuthorizationFilterContext MakeCtx(ClaimsPrincipal user, out HttpContext http)
{
http = new DefaultHttpContext { User = user };
http.Request.Path = "/api/sales/demands";
var actionCtx = new ActionContext(http, new RouteData(), new ActionDescriptor());
return new AuthorizationFilterContext(actionCtx, new List<IFilterMetadata>());
}
private static ClaimsPrincipal UserWithOrg(Guid orgId, string role = "Admin")
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(HttpContextTenantContext.OrganizationClaim, orgId.ToString()),
new Claim(ClaimTypes.Role, role),
}, authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
[Fact]
public async Task Allows_when_IsAdvancedMode_true()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = true });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull("advanced=true → фильтр пропускает запрос");
}
[Fact]
public async Task Forbids_with_ProblemDetails_when_simple_mode()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = false });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeOfType<ObjectResult>();
var or = (ObjectResult)ctx.Result!;
or.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
or.Value.Should().BeOfType<ProblemDetails>();
var pd = (ProblemDetails)or.Value!;
pd.Extensions.Should().ContainKey("code");
pd.Extensions["code"].Should().Be("advanced-mode-required");
}
[Fact]
public async Task SuperAdmin_bypasses_check_even_in_simple_org()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
db.Organizations.Add(new Organization { Id = orgId, Name = "A", IsAdvancedMode = false });
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(UserWithOrg(orgId, role: "SuperAdmin"), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull("SuperAdmin пропускается независимо от режима org");
}
[Fact]
public async Task InvalidateCache_makes_next_read_see_fresh_value()
{
var orgId = Guid.NewGuid();
AdvancedModeFilter.InvalidateCache(orgId);
using var db = NewDb();
var org = new Organization { Id = orgId, Name = "A", IsAdvancedMode = false };
db.Organizations.Add(org);
await db.SaveChangesAsync();
var filter = new AdvancedModeFilter(db);
// Первый вызов — фильтр отказывает.
var ctx1 = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx1);
ctx1.Result.Should().NotBeNull();
// Переключаем в БД, инвалидируем кэш — следующий вызов видит правду.
org.IsAdvancedMode = true;
await db.SaveChangesAsync();
AdvancedModeFilter.InvalidateCache(orgId);
var ctx2 = MakeCtx(UserWithOrg(orgId), out _);
await filter.OnAuthorizationAsync(ctx2);
ctx2.Result.Should().BeNull("после инвалидации кэша фильтр читает свежее значение");
}
[Fact]
public async Task Unauthenticated_request_is_skipped_by_filter()
{
using var db = NewDb();
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(new ClaimsPrincipal(new ClaimsIdentity()), out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeNull(
"атрибут гейтит только по режиму; аутентификацию ловит [Authorize] выше");
}
[Fact]
public async Task Authenticated_without_org_claim_is_forbidden()
{
using var db = NewDb();
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Role, "Admin"),
// Нет org_id — fail-closed.
}, authenticationType: "Test");
var user = new ClaimsPrincipal(identity);
var filter = new AdvancedModeFilter(db);
var ctx = MakeCtx(user, out _);
await filter.OnAuthorizationAsync(ctx);
ctx.Result.Should().BeOfType<ObjectResult>();
((ObjectResult)ctx.Result!).StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
}