Compare commits
No commits in common. "7f8750f30ff07608a8629455d6d2ce4b81df6568" and "dc885f36b4a1fec5cdac38e6748fa3f3ab72263f" have entirely different histories.
7f8750f30f
...
dc885f36b4
|
|
@ -1,181 +0,0 @@
|
|||
# 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,7 +13,6 @@ namespace foodmarket.Api.Controllers.Admin;
|
|||
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/admin/audit-log")]
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
|
||||
[HttpPost, RequiresPermission("PriceTypesManage")]
|
||||
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
|
||||
[HttpPut("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input, CancellationToken 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();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage"), RequiresAdvancedMode]
|
||||
[HttpDelete("{id:guid}"), RequiresPermission("PriceTypesManage")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken 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);
|
||||
}
|
||||
|
||||
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit"), RequiresAdvancedMode]
|
||||
[HttpPost("bulk-update"), RequiresPermission("ProductsEdit")]
|
||||
public async Task<ActionResult<BulkUpdateResponse>> BulkUpdate(
|
||||
[FromBody] BulkUpdateRequest req, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Domain.Common;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
|
@ -14,7 +13,6 @@ namespace foodmarket.Api.Controllers.Common;
|
|||
/// контроллера через WHERE UserId = currentUserId.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/user/presets")]
|
||||
public class UserPresetsController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ namespace foodmarket.Api.Controllers.Inventory;
|
|||
/// транзакции.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/inventory/transfers")]
|
||||
public class TransfersController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ namespace foodmarket.Api.Controllers.Loyalty;
|
|||
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/loyalty/cards")]
|
||||
public class LoyaltyCardsController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ namespace foodmarket.Api.Controllers.Loyalty;
|
|||
/// orphan-карты и сломало history.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/loyalty/programs")]
|
||||
public class LoyaltyProgramsController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ namespace foodmarket.Api.Controllers.Promotions;
|
|||
/// контроллере отдаём 409 с понятным текстом если дубль.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/promotions")]
|
||||
public class PromotionsController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Domain.Sales;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
|
@ -24,7 +23,6 @@ namespace foodmarket.Api.Controllers.Reports;
|
|||
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/reports/abc")]
|
||||
public class AbcReportController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ namespace foodmarket.Api.Controllers.Sales;
|
|||
/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/sales/demands")]
|
||||
public class DemandsController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Api.Seed;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Domain.Organizations;
|
||||
|
|
@ -32,8 +31,7 @@ public record OrgRow(
|
|||
Guid Id, string Name, string CountryCode,
|
||||
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
||||
DateTime? LastLoginAt,
|
||||
bool IsAdvancedMode);
|
||||
DateTime? LastLoginAt);
|
||||
|
||||
public record OrgDetail(
|
||||
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||
|
|
@ -41,8 +39,7 @@ public record OrgDetail(
|
|||
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
|
||||
bool IsAdvancedMode);
|
||||
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
|
||||
|
||||
public record OrgInput(
|
||||
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||
|
|
@ -56,7 +53,6 @@ public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string
|
|||
public record ArchiveRequest(string ConfirmationName);
|
||||
public record DeleteRequest(string ConfirmationName);
|
||||
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
||||
public record AdvancedModeRequest(bool Enabled);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<OrgRow>>> List(
|
||||
|
|
@ -81,8 +77,7 @@ public record AdvancedModeRequest(bool Enabled);
|
|||
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
|
||||
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
||||
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
||||
.Max(u => (DateTime?)u.LastLoginAt),
|
||||
o.IsAdvancedMode))
|
||||
.Max(u => (DateTime?)u.LastLoginAt)))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -234,39 +229,6 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
|
|||
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")]
|
||||
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -305,8 +267,7 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
|
|||
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
|
||||
o.IsActive, o.IsArchived, o.ArchivedAt,
|
||||
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
||||
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
|
||||
o.IsAdvancedMode);
|
||||
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
|
||||
}
|
||||
|
||||
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Security.Cryptography;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Api.Integrations.Telegram;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
|
@ -29,7 +28,6 @@ namespace foodmarket.Api.Controllers.Telegram;
|
|||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = "AdminAccess")]
|
||||
[RequiresAdvancedMode]
|
||||
[Route("api/organization/telegram")]
|
||||
public class TelegramBindingController : ControllerBase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
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,21 +632,10 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// получает /no-organization fallback вместо белого экрана на /dashboard.
|
||||
bool hasLiveOrg = 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))
|
||||
{
|
||||
var org = await db.Organizations.IgnoreQueryFilters()
|
||||
.Where(o => o.Id == orgId)
|
||||
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
|
||||
.FirstOrDefaultAsync();
|
||||
if (org is not null)
|
||||
{
|
||||
hasLiveOrg = !org.IsArchived;
|
||||
advancedMode = org.IsAdvancedMode;
|
||||
}
|
||||
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
|
||||
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
|
||||
if (Guid.TryParse(subRaw, out var sub))
|
||||
{
|
||||
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
|
||||
|
|
@ -663,7 +652,6 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
orgId = orgIdRaw,
|
||||
hasLiveOrg,
|
||||
hasActiveEmployee,
|
||||
advancedMode,
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
|
|
|
|||
|
|
@ -87,16 +87,6 @@ public class Organization : Entity
|
|||
/// просто захламляет карточку.</summary>
|
||||
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: ОФД (фискализация чеков) ────────────────────────────
|
||||
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
||||
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
||||
|
|
|
|||
|
|
@ -111,10 +111,6 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
|
||||
b.Property(o => o.Bin).HasMaxLength(20);
|
||||
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.HasIndex(o => o.Name);
|
||||
b.HasIndex(o => o.IsArchived);
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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,10 +1089,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsAdvancedMode")
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTime?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { SuperAdminLayout } from '@/components/SuperAdminLayout'
|
|||
import { TenantRouteGuard } from '@/components/TenantRouteGuard'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { RoleGuard } from '@/components/RoleGuard'
|
||||
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
|
||||
import { Toaster } from '@/components/Toaster'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { FormSkeleton } from '@/components/Skeleton'
|
||||
|
|
@ -156,7 +155,7 @@ export default function App() {
|
|||
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
|
||||
<Route path="/catalog/product-groups" element={lz(ProductGroupsPage)} />
|
||||
<Route path="/catalog/units" element={lz(UnitsOfMeasurePage)} />
|
||||
<Route path="/catalog/price-types" element={<AdvancedModeGuard>{lz(PriceTypesPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/catalog/price-types" element={lz(PriceTypesPage)} />
|
||||
<Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
|
||||
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}>{lz(StoresPage)}</RoleGuard>} />
|
||||
<Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}>{lz(RetailPointsPage)}</RoleGuard>} />
|
||||
|
|
@ -171,9 +170,9 @@ export default function App() {
|
|||
<Route path="/inventory/losses" element={lz(LossesPage)} />
|
||||
<Route path="/inventory/losses/new" element={lz(LossEditPage)} />
|
||||
<Route path="/inventory/losses/:id" element={lz(LossEditPage)} />
|
||||
<Route path="/inventory/transfers" element={<AdvancedModeGuard>{lz(TransfersPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/inventory/transfers/new" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/inventory/transfers/:id" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/inventory/transfers" element={lz(TransfersPage)} />
|
||||
<Route path="/inventory/transfers/new" element={lz(TransferEditPage)} />
|
||||
<Route path="/inventory/transfers/:id" element={lz(TransferEditPage)} />
|
||||
<Route path="/inventory/inventories" element={lz(InventoriesPage)} />
|
||||
<Route path="/inventory/inventories/new" element={lz(InventoryEditPage)} />
|
||||
<Route path="/inventory/inventories/:id" element={lz(InventoryEditPage)} />
|
||||
|
|
@ -183,17 +182,17 @@ export default function App() {
|
|||
<Route path="/reports/sales" element={lz(SalesReportPage)} />
|
||||
<Route path="/reports/stock" element={lz(StockReportPage)} />
|
||||
<Route path="/reports/profit" element={lz(ProfitReportPage)} />
|
||||
<Route path="/reports/abc" element={<AdvancedModeGuard>{lz(AbcReportPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/reports/abc" element={lz(AbcReportPage)} />
|
||||
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
||||
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/demands" element={<AdvancedModeGuard>{lz(DemandsPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/sales/demands/new" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/sales/demands/:id" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
|
||||
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyProgramsPage)}</AdvancedModeGuard></RoleGuard>} />
|
||||
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyCardsPage)}</AdvancedModeGuard></RoleGuard>} />
|
||||
<Route path="/promotions" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(PromotionsPage)}</AdvancedModeGuard></RoleGuard>} />
|
||||
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(OrgAuditLogPage)}</AdvancedModeGuard></RoleGuard>} />
|
||||
<Route path="/sales/demands" element={lz(DemandsPage)} />
|
||||
<Route path="/sales/demands/new" element={lz(DemandEditPage)} />
|
||||
<Route path="/sales/demands/:id" element={lz(DemandEditPage)} />
|
||||
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyProgramsPage)}</RoleGuard>} />
|
||||
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyCardsPage)}</RoleGuard>} />
|
||||
<Route path="/promotions" element={<RoleGuard roles={['Admin']}>{lz(PromotionsPage)}</RoleGuard>} />
|
||||
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}>{lz(OrgAuditLogPage)}</RoleGuard>} />
|
||||
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} />
|
||||
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} />
|
||||
{/* Sprint 17 */}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
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,9 +26,6 @@ interface MeResponse {
|
|||
email: string
|
||||
roles: string[]
|
||||
orgId: string
|
||||
/** Sprint 29 — расширенный режим орг-tenanta. false → простой магазин
|
||||
* (меню фильтруется, скрытые роуты показывают «Недоступно»). */
|
||||
advancedMode?: boolean
|
||||
}
|
||||
|
||||
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
|
||||
|
|
@ -61,14 +58,11 @@ function translateRoles(roles: string[]): string {
|
|||
* Источник правды для прав — серверные `[Authorize(Roles = ...)]` на
|
||||
* каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
|
||||
* неработающие пункты. */
|
||||
function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
||||
function buildNav(roles: string[]): NavSection[] {
|
||||
const isSuperAdmin = roles.includes('SuperAdmin')
|
||||
const isAdmin = roles.includes('Admin') || isSuperAdmin
|
||||
const isCashier = roles.includes('Cashier')
|
||||
const isStorekeeper = roles.includes('Storekeeper')
|
||||
// SuperAdmin видит ВСЕ пункты (даже при advancedMode=false для его org),
|
||||
// чтобы можно было проверить что ничего не сломалось после переключения.
|
||||
const advanced = advancedMode || isSuperAdmin
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{ group: 'nav.section_main', items: [
|
||||
|
|
@ -84,12 +78,8 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
|||
catalog.push(
|
||||
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
|
||||
{ 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 })
|
||||
}
|
||||
|
|
@ -107,10 +97,7 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
|||
stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' })
|
||||
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' })
|
||||
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/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
|
||||
}
|
||||
sections.push({ group: 'nav.section_inventory', items: stock })
|
||||
|
|
@ -128,8 +115,7 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
|||
if (isAdmin || isCashier) {
|
||||
sections.push({ group: 'nav.section_sales', items: [
|
||||
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
|
||||
// Оптовые отгрузки / промо / лояльность — только расширенный.
|
||||
...(isAdmin && advanced ? [
|
||||
...(isAdmin ? [
|
||||
{ to: '/sales/demands', icon: Send, label: 'nav.demands' },
|
||||
{ to: '/promotions', icon: Tag, label: 'nav.promotions' },
|
||||
{ to: '/loyalty/programs', icon: ShieldCheck, label: 'nav.loyaltyPrograms' },
|
||||
|
|
@ -138,15 +124,14 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
|||
]})
|
||||
}
|
||||
|
||||
// Отчёты — Admin и Storekeeper. ABC — только расширенный режим.
|
||||
// Отчёты — Admin и Storekeeper.
|
||||
if (isAdmin || isStorekeeper) {
|
||||
const reports: NavItem[] = [
|
||||
sections.push({ group: 'nav.section_reports', items: [
|
||||
{ to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' },
|
||||
{ to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' },
|
||||
{ to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' },
|
||||
]
|
||||
if (advanced) reports.push({ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' })
|
||||
sections.push({ group: 'nav.section_reports', items: reports })
|
||||
{ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' },
|
||||
]})
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
|
|
@ -159,10 +144,7 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
|||
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' },
|
||||
{ to: '/settings/employees', icon: UserCog, label: 'nav.employees' },
|
||||
{ to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' },
|
||||
// Аудит организации — только в расширенном режиме.
|
||||
...(advanced ? [
|
||||
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
|
||||
] : []),
|
||||
]})
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +169,7 @@ export function AppLayout() {
|
|||
})
|
||||
|
||||
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
|
||||
const nav = buildNav(me?.roles ?? [], !!me?.advancedMode)
|
||||
const nav = buildNav(me?.roles ?? [])
|
||||
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
|
||||
// даёт периферийный сигнал «я не в своей админке».
|
||||
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')
|
||||
|
|
@ -274,11 +256,7 @@ export function AppLayout() {
|
|||
<div className="truncate">{translateRoles(me.roles)}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Переключатель языка — только в расширенном режиме; в простом
|
||||
магазине интерфейс только по-русски. SuperAdmin видит всегда. */}
|
||||
{(me?.advancedMode || isSuperAdmin) && (
|
||||
<div className="px-2 pb-2"><LanguageSwitcher /></div>
|
||||
)}
|
||||
{/* Sprint 17: feedback widget + ссылки на /help и /whats-new.
|
||||
Sprint 18: NotificationCenter — иконка-колокольчик с popover'ом. */}
|
||||
<div className="px-2 pb-2 flex items-center gap-3 flex-wrap">
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ export interface MeResponse {
|
|||
hasLiveOrg: boolean
|
||||
/** Активный Employee текущего AppUser в его Organization. False → orphan. */
|
||||
hasActiveEmployee: boolean
|
||||
/** Sprint 29 — расширенный режим орг-tenanta (SuperAdmin включает
|
||||
* вручную). false → UI скрывает «не для простого магазина»
|
||||
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
|
||||
* пресеты, Telegram-бот, SSO, несколько типов цен. */
|
||||
advancedMode: boolean
|
||||
}
|
||||
|
||||
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { PhoneInput } from '@/components/PhoneInput'
|
|||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
import { useConfirm } from '@/lib/useConfirm'
|
||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||
import { useMe } from '@/lib/useMe'
|
||||
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/counterparties'
|
||||
|
|
@ -57,11 +56,6 @@ const typeLabel: Record<CounterpartyType, string> = {
|
|||
export function CounterpartiesPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(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 [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
|
||||
const { confirm, dialogProps } = useConfirm()
|
||||
|
|
@ -171,11 +165,9 @@ export function CounterpartiesPage() {
|
|||
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: 'Название обязательно'})) }} />
|
||||
</Field>
|
||||
{!simpleMode && (
|
||||
<Field label="Юридическое название" className="col-span-2">
|
||||
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Тип лица">
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
|
||||
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
|
||||
|
|
@ -188,14 +180,12 @@ export function CounterpartiesPage() {
|
|||
<Field label="ИИН (физлицо РК)">
|
||||
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
|
||||
</Field>
|
||||
{!simpleMode && (
|
||||
<Field label="Страна">
|
||||
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Адрес" className="col-span-2">
|
||||
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</Field>
|
||||
|
|
@ -204,15 +194,11 @@ export function CounterpartiesPage() {
|
|||
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})) } }} />
|
||||
</Field>
|
||||
{!simpleMode && (
|
||||
<Field label="Email" error={fieldErrors.email}>
|
||||
<TextInput type="email" value={form.email}
|
||||
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})) } }} />
|
||||
</Field>
|
||||
)}
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<Field label="Банк" className="col-span-2">
|
||||
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
|
||||
</Field>
|
||||
|
|
@ -225,8 +211,6 @@ export function CounterpartiesPage() {
|
|||
<Field label="Контактное лицо" className="col-span-2">
|
||||
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Заметки" className="col-span-2">
|
||||
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ interface MeResponse {
|
|||
email: string
|
||||
roles: string[]
|
||||
orgId: string
|
||||
/** Sprint 29 — расширенный режим орг-tenanta. false → скрыты
|
||||
* продвинутые виджеты (TopProducts, Margin). */
|
||||
advancedMode?: boolean
|
||||
}
|
||||
|
||||
function useCount(url: string) {
|
||||
|
|
@ -211,20 +208,14 @@ export function DashboardPage() {
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* Виджеты. В простом режиме — только «что заканчивается» и
|
||||
«последние продажи». «Топ товаров» и «Маржа» требуют расширенный
|
||||
режим (SuperAdmin видит всегда). */}
|
||||
{/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */}
|
||||
<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" />}>
|
||||
<TopProductsWidget days={7} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
||||
<MarginWidget days={30} />
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
|
||||
<RecentSalesWidget limit={10} />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
|
||||
} from '@/lib/useLookups'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { useMe } from '@/lib/useMe'
|
||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||
import { ProductImageGallery } from '@/components/ProductImageGallery'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
|
|
@ -69,11 +68,6 @@ export function ProductEditPage() {
|
|||
const { confirm, dialogProps } = useConfirm()
|
||||
const priceTypes = usePriceTypes()
|
||||
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({
|
||||
queryKey: ['/api/catalog/products', id],
|
||||
|
|
@ -304,16 +298,12 @@ export function ProductEditPage() {
|
|||
|
||||
<Section title="Основное">
|
||||
<Grid cols={3}>
|
||||
<Field label="Название *" className={simpleMode ? 'col-span-3' : 'col-span-2'}>
|
||||
<Field label="Название *" className="col-span-2">
|
||||
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</Field>
|
||||
{/* Артикул — только расширенный режим; в простом генерируется
|
||||
автоматически (см. useEffect с generateArticle). */}
|
||||
{!simpleMode && (
|
||||
<Field label="Артикул *">
|
||||
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
||||
</Field>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||
|
|
@ -371,7 +361,7 @@ export function ProductEditPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!simpleMode && org.data?.showDescriptionOnProduct && (
|
||||
{org.data?.showDescriptionOnProduct && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||
<Field label="Описание">
|
||||
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
|
|
@ -416,7 +406,7 @@ export function ProductEditPage() {
|
|||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
|
||||
<div className="space-y-3">
|
||||
{!simpleMode && org.data?.showReferencePriceOnProduct && (
|
||||
{org.data?.showReferencePriceOnProduct && (
|
||||
<Field label="Эталонная цена">
|
||||
<MoneyInput
|
||||
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
|
||||
|
|
@ -437,7 +427,7 @@ export function ProductEditPage() {
|
|||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">расчётная (скользящее среднее)</p>
|
||||
</Field>
|
||||
{!simpleMode && org.data?.multiCurrencyEnabled && (
|
||||
{org.data?.multiCurrencyEnabled && (
|
||||
<Field label="Валюта закупки">
|
||||
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
|
|
@ -456,12 +446,7 @@ export function ProductEditPage() {
|
|||
* новая, при стирании — null Amount (UI пустое). Обязательные (IsRequired)
|
||||
* помечаются звёздочкой. */}
|
||||
<div className="space-y-3">
|
||||
{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) => {
|
||||
{priceTypes.data?.slice().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 row = idx >= 0 ? form.prices[idx] : null
|
||||
const required = pt.isRequired
|
||||
|
|
@ -530,7 +515,7 @@ export function ProductEditPage() {
|
|||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
{!simpleMode && org.data?.showCountryOfOriginOnProduct && (
|
||||
{org.data?.showCountryOfOriginOnProduct && (
|
||||
<Grid cols={2}>
|
||||
<Field label="Страна происхождения">
|
||||
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
|
||||
|
|
@ -540,7 +525,7 @@ export function ProductEditPage() {
|
|||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
{!simpleMode && org.data?.showVatEnabledOnProduct && form.vatEnabled && (
|
||||
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
|
||||
<Grid cols={3}>
|
||||
<Field label="Ставка НДС, %">
|
||||
<NumberInput
|
||||
|
|
@ -551,35 +536,19 @@ export function ProductEditPage() {
|
|||
</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">
|
||||
{!simpleMode && org.data?.showVatEnabledOnProduct && (
|
||||
{org.data?.showVatEnabledOnProduct && (
|
||||
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
|
||||
)}
|
||||
{!simpleMode && org.data?.showServiceOnProduct && (
|
||||
{org.data?.showServiceOnProduct && (
|
||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||
)}
|
||||
{!simpleMode && org.data?.showMarkedOnProduct && (
|
||||
{org.data?.showMarkedOnProduct && (
|
||||
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* В простом режиме «Мин. остаток» входит в базовый набор полей
|
||||
карточки (см. 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 && (
|
||||
{org.data?.showMinMaxStock && (
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
<Field label="Минимальный остаток (для уведомления)">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus, Archive, RotateCcw, Trash2, LogIn, Users, Sparkles } from 'lucide-react'
|
||||
import { Plus, Archive, RotateCcw, Trash2, LogIn, Users } from 'lucide-react'
|
||||
import { api, setOrgOverride } from '@/lib/api'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
|
|
@ -18,7 +18,6 @@ interface OrgRow {
|
|||
id: string; name: string; countryCode: string
|
||||
isActive: boolean; isArchived: boolean; archivedAt: string | null
|
||||
createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null
|
||||
isAdvancedMode: boolean
|
||||
}
|
||||
|
||||
export function SuperAdminOrganizationsPage() {
|
||||
|
|
@ -28,22 +27,6 @@ export function SuperAdminOrganizationsPage() {
|
|||
const [archiveOf, setArchiveOf] = useState<OrgRow | null>(null)
|
||||
const [deleteOf, setDeleteOf] = useState<OrgRow | null>(null)
|
||||
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 () => {
|
||||
if (!archiveOf || confirmName !== archiveOf.name) return
|
||||
|
|
@ -124,9 +107,6 @@ export function SuperAdminOrganizationsPage() {
|
|||
{ 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 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) => (
|
||||
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{!r.isArchived && (
|
||||
|
|
@ -141,16 +121,6 @@ export function SuperAdminOrganizationsPage() {
|
|||
className="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded">
|
||||
<Users className="w-4 h-4" />
|
||||
</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 ? (
|
||||
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">
|
||||
|
|
@ -197,42 +167,6 @@ export function SuperAdminOrganizationsPage() {
|
|||
)}
|
||||
</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)}
|
||||
title="Удалить организацию навсегда"
|
||||
footer={<>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { useConfirm } from '@/lib/useConfirm'
|
|||
import { useShortcuts } from '@/lib/useShortcuts'
|
||||
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { useMe } from '@/lib/useMe'
|
||||
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
||||
|
||||
interface LineRow {
|
||||
|
|
@ -69,11 +68,6 @@ export function SupplyEditPage() {
|
|||
const stores = useStores()
|
||||
const currencies = useCurrencies()
|
||||
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 { confirm, dialogProps } = useConfirm()
|
||||
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
|
||||
|
|
@ -374,7 +368,7 @@ export function SupplyEditPage() {
|
|||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
{!simpleMode && org.data?.multiCurrencyEnabled && (
|
||||
{org.data?.multiCurrencyEnabled && (
|
||||
<Field label="Валюта *">
|
||||
<Select value={form.currencyId} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
/**
|
||||
* 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(() => {})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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 видит страницу «Недоступно»
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
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,17 +38,7 @@ static ApiFactory()
|
|||
.WithPassword("food_market_test")
|
||||
.Build();
|
||||
|
||||
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 async Task InitializeAsync() => await _db.StartAsync();
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
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