diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs index e77604c..f7091c1 100644 --- a/src/food-market.api/Controllers/Money/CashShiftsController.cs +++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs @@ -34,6 +34,26 @@ public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyServic _money = money; } + /// Минимальный список счетов для выбора при открытии смены: + /// только активные наличные кассы и эквайринг, без остатков и банковских + /// счетов. Доступно и кассиру (право CashShiftOperate) — он открывает смену, + /// но модуль «Деньги» (счета/остатки) ему недоступен, поэтому отдельный + /// узкий эндпоинт вместо /api/money/accounts (тот под MoneyView). + public record ShiftAccountRow(Guid Id, string Name, MoneyAccountKind Kind, + Guid? StoreId, Guid CurrencyId, string CurrencyCode); + + [HttpGet("accounts"), RequiresPermission("MoneyView,CashShiftOperate")] + public async Task>> ShiftAccounts(CancellationToken ct) + { + var rows = await (from a in _db.MoneyAccounts.AsNoTracking() + join cu in _db.Currencies on a.CurrencyId equals cu.Id + where a.IsActive && (a.Kind == MoneyAccountKind.Cash || a.Kind == MoneyAccountKind.Acquiring) + orderby a.Kind, a.Name + select new ShiftAccountRow(a.Id, a.Name, a.Kind, a.StoreId, cu.Id, cu.Code)) + .ToListAsync(ct); + return Ok(rows); + } + public record CashShiftRow( Guid Id, string Number, CashShiftStatus Status, Guid RetailPointId, string RetailPointName, diff --git a/src/food-market.web/src/lib/money.ts b/src/food-market.web/src/lib/money.ts index 043b884..3f7af6c 100644 --- a/src/food-market.web/src/lib/money.ts +++ b/src/food-market.web/src/lib/money.ts @@ -161,6 +161,23 @@ export function useMoneyAccounts(activeOnly = false) { }) } +/** Счета для выбора при открытии смены (наличные + эквайринг, без остатков). + * Доступно кассиру (CashShiftOperate) — в отличие от useMoneyAccounts, который + * под MoneyView и кассиру отдаёт 403 (кассир «не видит денег»). */ +export interface ShiftAccountRow { + id: string; name: string; kind: MoneyAccountKind; + storeId: string | null; currencyId: string; currencyCode: string +} +export function useShiftAccounts() { + return useQuery({ + queryKey: ['lookup:shift-accounts'], + queryFn: async () => + (await api.get('/api/money/shifts/accounts')).data, + staleTime: 0, + refetchOnMount: 'always', + }) +} + /** Активные статьи расходов (для расходных документов). */ export function useExpenseItems(activeOnly = true) { return useQuery({ diff --git a/src/food-market.web/src/pages/money/CashShiftsPage.tsx b/src/food-market.web/src/pages/money/CashShiftsPage.tsx index a8ee3a6..db800ae 100644 --- a/src/food-market.web/src/pages/money/CashShiftsPage.tsx +++ b/src/food-market.web/src/pages/money/CashShiftsPage.tsx @@ -12,14 +12,14 @@ import { api } from '@/lib/api' import { useFormatCurrency } from '@/lib/useFormatCurrency' import { CashShiftStatus, DrawerDirection, MoneyAccountKind, - useMoneyAccounts, useRetailPointsLookup, type CashShiftRow, type DrawerOpRow, + useShiftAccounts, useRetailPointsLookup, type CashShiftRow, type DrawerOpRow, } from '@/lib/money' const URL = '/api/money/shifts' export function CashShiftsPage() { const { data, isLoading, page, setPage } = useCatalogList(URL) - const accounts = useMoneyAccounts(true) + const accounts = useShiftAccounts() const retailPoints = useRetailPointsLookup() const fmt = useFormatCurrency() const qc = useQueryClient() diff --git a/tests/food-market.IntegrationTests/PermissionTests.cs b/tests/food-market.IntegrationTests/PermissionTests.cs index bca291e..6976af6 100644 --- a/tests/food-market.IntegrationTests/PermissionTests.cs +++ b/tests/food-market.IntegrationTests/PermissionTests.cs @@ -61,4 +61,90 @@ public async Task Admin_passes_permission_gate() using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { }); put.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); } + + /// Инцидент menu-walk 2026-10-08: кассир (CashShiftOperate без + /// MoneyView — «кассир не видит денег») получал 403 на /api/money/accounts, + /// который дёргает страница смен для выбора кассы. Узкий эндпоинт + /// /api/money/shifts/accounts доступен кассиру и отдаёт только наличные/ + /// эквайринг без остатков; /api/money/accounts по-прежнему 403 (инвариант). + [Fact] + public async Task Cashier_can_list_shift_accounts_but_not_money_accounts() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"shiftacc-{Guid.NewGuid():N}"); + var refs = await admin.LoadRefsAsync(); + + // Счета: наличная касса, эквайринг и банковский (последний кассиру не положен). + await admin.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m); + await admin.CreateMoneyAccountAsync("Эквайринг", 4, refs.CurrencyId, refs.StoreId, 0m); + await admin.CreateMoneyAccountAsync("Расчётный счёт", 3, refs.CurrencyId, null, 0m, accountNumber: "KZ123"); + + // Роль «как кассир»: CashShiftOperate без MoneyView. + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { + name = $"Касса-{Guid.NewGuid():N}", description = "shift only", + permissions = new { retailSalesOperate = true, cashShiftOperate = true, moneyView = false, productsView = true }, + }); + roleResp.EnsureSuccessStatusCode(); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + var email = $"cashier-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { + lastName = "Кассиров", firstName = "Пётр", roleId, + isActive = true, createAccount = true, email, + }); + empResp.EnsureSuccessStatusCode(); + var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; + + var cashier = new ApiActor(_factory.CreateClient()); + cashier.UseToken(await cashier.TokenAsync(email, pwd)); + + // Инвариант: модуль «Деньги» кассиру недоступен. + using var accounts = await cashier.Http.GetAsync("/api/money/accounts?pageSize=100"); + accounts.StatusCode.Should().Be(HttpStatusCode.Forbidden); + + // Но счета для открытия смены — доступны (только нал + эквайринг, без банка). + using var shiftAccounts = await cashier.Http.GetAsync("/api/money/shifts/accounts"); + shiftAccounts.StatusCode.Should().Be(HttpStatusCode.OK); + var rows = await shiftAccounts.Content.ReadFromJsonAsync(); + var kinds = rows.EnumerateArray().Select(r => r.GetProperty("kind").GetInt32()).ToList(); + kinds.Should().OnlyContain(k => k == 1 || k == 4, "только наличные (1) и эквайринг (4)"); + kinds.Should().Contain(1).And.Contain(4); + rows.EnumerateArray().Select(r => r.GetProperty("name").GetString()) + .Should().NotContain("Расчётный счёт", "банковский счёт кассиру в списке смены не нужен"); + } + + /// Эффективные права в /api/me для кассира: ProductsView/StocksView + /// есть, advanced-only (DemandsView) скрыто (простой режим). + [Fact] + public async Task Me_permissions_for_cashier_role() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"meperm-{Guid.NewGuid():N}"); + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { + name = $"Касса-{Guid.NewGuid():N}", + permissions = new { productsView = true, stocksView = true, cashShiftOperate = true, demandsView = true }, + }); + roleResp.EnsureSuccessStatusCode(); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + var email = $"meperm-c-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { + lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email, + }); + empResp.EnsureSuccessStatusCode(); + var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; + var cashier = new ApiActor(_factory.CreateClient()); + cashier.UseToken(await cashier.TokenAsync(email, pwd)); + + var me = await cashier.Http.GetFromJsonAsync("/api/me"); + var perms = me.GetProperty("permissions"); + perms.GetProperty("ProductsView").GetBoolean().Should().BeTrue(); + perms.GetProperty("StocksView").GetBoolean().Should().BeTrue(); + perms.GetProperty("ProductsEdit").GetBoolean().Should().BeFalse(); + // demandsView выдали роли, но орг в простом режиме → advanced-only скрыт. + perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse(); + } }