Compare commits
7 commits
dc885f36b4
...
7f8750f30f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8750f30f | ||
|
|
276cba22f8 | ||
|
|
792ea0fd41 | ||
|
|
67c1b590ad | ||
|
|
921bca076c | ||
|
|
202763e0d0 | ||
|
|
4342318c2c |
181
docs/simple-mode-progress.md
Normal file
181
docs/simple-mode-progress.md
Normal 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` (не гейтится — только мутации). Патч смока **не требуется**.
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
50
src/food-market.web/src/components/AdvancedModeGuard.tsx
Normal file
50
src/food-market.web/src/components/AdvancedModeGuard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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' })
|
||||||
|
// Перемещения между точками — только в расширенном режиме.
|
||||||
|
if (advanced) {
|
||||||
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
|
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' },
|
||||||
|
// Аудит организации — только в расширенном режиме.
|
||||||
|
...(advanced ? [
|
||||||
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
|
{ 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>
|
||||||
)}
|
)}
|
||||||
|
{/* Переключатель языка — только в расширенном режиме; в простом
|
||||||
|
магазине интерфейс только по-русски. SuperAdmin видит всегда. */}
|
||||||
|
{(me?.advancedMode || isSuperAdmin) && (
|
||||||
<div className="px-2 pb-2"><LanguageSwitcher /></div>
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
{!simpleMode && (
|
||||||
<Field label="Юридическое название" className="col-span-2">
|
<Field label="Юридическое название" className="col-span-2">
|
||||||
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
||||||
</Field>
|
</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>
|
||||||
|
{!simpleMode && (
|
||||||
<Field label="Страна">
|
<Field label="Страна">
|
||||||
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
|
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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,11 +204,15 @@ 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>
|
||||||
|
{!simpleMode && (
|
||||||
<Field label="Email" error={fieldErrors.email}>
|
<Field label="Email" error={fieldErrors.email}>
|
||||||
<TextInput type="email" value={form.email}
|
<TextInput type="email" value={form.email}
|
||||||
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
|
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
|
||||||
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
|
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
|
{!simpleMode && (
|
||||||
|
<>
|
||||||
<Field label="Банк" className="col-span-2">
|
<Field label="Банк" className="col-span-2">
|
||||||
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
|
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -211,6 +225,8 @@ export function CounterpartiesPage() {
|
||||||
<Field label="Контактное лицо" className="col-span-2">
|
<Field label="Контактное лицо" className="col-span-2">
|
||||||
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
|
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
|
||||||
</Field>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{(me.data?.advancedMode || me.data?.roles?.includes('SuperAdmin')) && (
|
||||||
|
<>
|
||||||
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
||||||
<TopProductsWidget days={7} />
|
<TopProductsWidget days={7} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
||||||
<MarginWidget days={30} />
|
<MarginWidget days={30} />
|
||||||
</Suspense>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
{/* Артикул — только расширенный режим; в простом генерируется
|
||||||
|
автоматически (см. useEffect с generateArticle). */}
|
||||||
|
{!simpleMode && (
|
||||||
<Field label="Артикул *">
|
<Field label="Артикул *">
|
||||||
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
||||||
</Field>
|
</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="Минимальный остаток (для уведомления)">
|
||||||
|
|
|
||||||
|
|
@ -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={<>
|
||||||
|
|
|
||||||
|
|
@ -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 })}>
|
||||||
|
|
|
||||||
256
tests/e2e/scenarios/stage-simple-mode.steps.ts
Normal file
256
tests/e2e/scenarios/stage-simple-mode.steps.ts
Normal 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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
tests/e2e/scenarios/stage-simple-mode.yml
Normal file
38
tests/e2e/scenarios/stage-simple-mode.yml
Normal 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 видит страницу «Недоступно»
|
||||||
204
tests/food-market.IntegrationTests/AdvancedModeTests.cs
Normal file
204
tests/food-market.IntegrationTests/AdvancedModeTests.cs
Normal 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 переключать нельзя");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
169
tests/food-market.UnitTests/AdvancedModeFilterTests.cs
Normal file
169
tests/food-market.UnitTests/AdvancedModeFilterTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue