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
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:
parent
bd5d43fdf5
commit
9dc033d07a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue