fix(money): кассир открывает смену без доступа к модулю «Деньги» (403)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Инцидент menu-walk 2026-10-08: demo-cashier получал 403 на
GET /api/money/accounts — страница смен (/money/shifts) дёргала его для выбора
кассы при открытии смены, но эндпоинт под MoneyView, а у кассира MoneyView=false
(«кассир не видит денег»), только CashShiftOperate.

- Новый узкий GET /api/money/shifts/accounts (RequiresPermission
  "MoneyView,CashShiftOperate"): только активные наличные кассы + эквайринг,
  без остатков и банковских счетов. Кассиру достаточно для выбора кассы/эквайринга
  при открытии смены, но денег он по-прежнему не видит.
- Веб: CashShiftsPage использует useShiftAccounts() вместо useMoneyAccounts(true).
- Регресс-тесты: кассир 200 на /shifts/accounts (только нал+эквайринг) и 403 на
  /money/accounts; /api/me permissions для кассира (advanced-only скрыт).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 08:28:36 +05:00
parent bd5d43fdf5
commit 9dc033d07a
4 changed files with 125 additions and 2 deletions

View file

@ -34,6 +34,26 @@ public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyServic
_money = money;
}
/// <summary>Минимальный список счетов для выбора при открытии смены:
/// только активные наличные кассы и эквайринг, без остатков и банковских
/// счетов. Доступно и кассиру (право CashShiftOperate) — он открывает смену,
/// но модуль «Деньги» (счета/остатки) ему недоступен, поэтому отдельный
/// узкий эндпоинт вместо /api/money/accounts (тот под MoneyView).</summary>
public record ShiftAccountRow(Guid Id, string Name, MoneyAccountKind Kind,
Guid? StoreId, Guid CurrencyId, string CurrencyCode);
[HttpGet("accounts"), RequiresPermission("MoneyView,CashShiftOperate")]
public async Task<ActionResult<IReadOnlyList<ShiftAccountRow>>> 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,

View file

@ -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<ShiftAccountRow[]>('/api/money/shifts/accounts')).data,
staleTime: 0,
refetchOnMount: 'always',
})
}
/** Активные статьи расходов (для расходных документов). */
export function useExpenseItems(activeOnly = true) {
return useQuery({

View file

@ -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<CashShiftRow>(URL)
const accounts = useMoneyAccounts(true)
const accounts = useShiftAccounts()
const retailPoints = useRetailPointsLookup()
const fmt = useFormatCurrency()
const qc = useQueryClient()

View file

@ -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);
}
/// <summary>Инцидент menu-walk 2026-10-08: кассир (CashShiftOperate без
/// MoneyView — «кассир не видит денег») получал 403 на /api/money/accounts,
/// который дёргает страница смен для выбора кассы. Узкий эндпоинт
/// /api/money/shifts/accounts доступен кассиру и отдаёт только наличные/
/// эквайринг без остатков; /api/money/accounts по-прежнему 403 (инвариант).</summary>
[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<JsonElement>()).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<JsonElement>()).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<JsonElement>();
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("Расчётный счёт", "банковский счёт кассиру в списке смены не нужен");
}
/// <summary>Эффективные права в /api/me для кассира: ProductsView/StocksView
/// есть, advanced-only (DemandsView) скрыто (простой режим).</summary>
[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<JsonElement>()).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<JsonElement>()).GetProperty("generatedPassword").GetString()!;
var cashier = new ApiActor(_factory.CreateClient());
cashier.UseToken(await cashier.TokenAsync(email, pwd));
var me = await cashier.Http.GetFromJsonAsync<JsonElement>("/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();
}
}