Compare commits

..

No commits in common. "7f8750f30ff07608a8629455d6d2ce4b81df6568" and "dc885f36b4a1fec5cdac38e6748fa3f3ab72263f" have entirely different histories.

33 changed files with 83 additions and 1372 deletions

View file

@ -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` (не гейтится — только мутации). Патч смока **не требуется**.

View file

@ -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
{

View file

@ -50,7 +50,7 @@ public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
}
[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);

View file

@ -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)
{

View file

@ -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
{

View file

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

View file

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

View file

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

View file

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

View file

@ -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
{

View file

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

View file

@ -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)

View file

@ -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
{

View file

@ -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" },
};
}
}

View file

@ -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();

View file

@ -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

View file

@ -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);

View file

@ -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"";");
}
}
}

View file

@ -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");

View file

@ -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 */}

View file

@ -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>
)
}

View file

@ -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/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' },
] : []),
{ 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>
)}
<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">

View file

@ -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
}
/** Текущий залогиненный юзер с ролями. Используется для гейтов

View file

@ -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="Юридическое название" 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="Страна">
<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,29 +194,23 @@ 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>
<Field label="Расчётный счёт">
<TextInput value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} />
</Field>
<Field label="БИК">
<TextInput value={form.bik} onChange={(e) => setForm({ ...form, bik: e.target.value })} />
</Field>
<Field label="Контактное лицо" className="col-span-2">
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
</Field>
</>
)}
<Field label="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>
<Field label="Банк" className="col-span-2">
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
</Field>
<Field label="Расчётный счёт">
<TextInput value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} />
</Field>
<Field label="БИК">
<TextInput value={form.bik} onChange={(e) => setForm({ ...form, bik: e.target.value })} />
</Field>
<Field label="Контактное лицо" className="col-span-2">
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
</Field>
<Field label="Заметки" className="col-span-2">
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>

View file

@ -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" />}>
<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>

View file

@ -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>
)}
<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="Минимальный остаток (для уведомления)">

View file

@ -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={<>

View file

@ -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 })}>

View file

@ -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(() => {})
}
}

View file

@ -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 видит страницу «Недоступно»

View file

@ -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 переключать нельзя");
}
}

View file

@ -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()
{

View file

@ -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);
}
}