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();
+ }
}