diff --git a/src/food-market.api/Controllers/Money/CashShiftsController.cs b/src/food-market.api/Controllers/Money/CashShiftsController.cs index 6dec6cd..9f03558 100644 --- a/src/food-market.api/Controllers/Money/CashShiftsController.cs +++ b/src/food-market.api/Controllers/Money/CashShiftsController.cs @@ -64,7 +64,7 @@ public record DrawerOpInput( [Range(0.0001, 1e12)] decimal Amount, [Required, MaxLength(500)] string Reason); - [HttpGet, RequiresPermission("MoneyView")] + [HttpGet, RequiresPermission("MoneyView,CashShiftOperate")] public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] CashShiftStatus? status, @@ -115,7 +115,7 @@ public record DrawerOpInput( return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } - [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] + [HttpGet("{id:guid}"), RequiresPermission("MoneyView,CashShiftOperate")] public async Task> Get(Guid id, CancellationToken ct) { var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); @@ -172,7 +172,7 @@ public async Task> Drawer(Guid id, [FromBody] DrawerOpInput } /// Z-отчёт: все цифры смены + внесения/выплаты для печати. - [HttpGet("{id:guid}/report"), RequiresPermission("MoneyView")] + [HttpGet("{id:guid}/report"), RequiresPermission("MoneyView,CashShiftOperate")] public async Task> Report(Guid id, CancellationToken ct) { var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); diff --git a/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs index 4226184..49f52cb 100644 --- a/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs +++ b/src/food-market.api/Infrastructure/Authorization/PermissionAuthorizationHandler.cs @@ -57,7 +57,8 @@ public sealed class PermissionAuthorizationHandler : AuthorizationHandler e.Role) .FirstOrDefaultAsync(); - if (role is not null && HasPermission(role.Permissions, requirement.Permission)) + // ИЛИ-семантика: достаточно любого из перечисленных прав. + if (role is not null && requirement.Permissions.Any(p => HasPermission(role.Permissions, p))) { context.Succeed(requirement); } diff --git a/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs b/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs index ef7da53..8a735ac 100644 --- a/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs +++ b/src/food-market.api/Infrastructure/Authorization/PermissionRequirement.cs @@ -2,12 +2,18 @@ namespace foodmarket.Api.Infrastructure.Authorization; -/// Требование «у текущего пользователя включён флаг права -/// в его роли (RolePermissions)». Имя права = имя -/// boolean-свойства RolePermissions (например, "ProductsEdit"). +/// Требование «у текущего пользователя включён ХОТЯ БЫ ОДИН из флагов +/// прав в его роли (RolePermissions)». Имя права = имя +/// boolean-свойства RolePermissions (например, "ProductsEdit"). Несколько +/// прав через запятую = ИЛИ-семантика (например "MoneyView,CashShiftOperate" — +/// эндпоинт доступен и финансисту, и кассиру для своей смены). public sealed class PermissionRequirement : IAuthorizationRequirement { - public string Permission { get; } + public IReadOnlyList Permissions { get; } - public PermissionRequirement(string permission) => Permission = permission; + /// Первое право — для обратной совместимости/логов. + public string Permission => Permissions[0]; + + public PermissionRequirement(string permission) + => Permissions = permission.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index 20ce0fe..e4bcf7a 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -144,10 +144,10 @@ public async Task StartAsync(CancellationToken ct) } /// Идемпотентный back-fill модуля «Деньги» по всем организациям: - /// системной роли «Кассир» проставляет MoneyView + CashShiftOperate (если - /// ещё не стоят), и гарантирует 5 системных статей расходов в каждой орге. - /// Нужно только для орг, созданных ДО Sprint 33 — у новых всё ставит - /// . + /// системной роли «Кассир» гарантирует CashShiftOperate=true (своя смена) и + /// MoneyView=FALSE (модуль «Деньги» кассиру недоступен — требование владельца), + /// и гарантирует 5 системных статей расходов в каждой орге. Нужно для орг, + /// созданных ДО этого изменения (у них кассиру раньше ставился MoneyView=true). private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, CancellationToken ct) { var cashierRoles = await db.EmployeeRoles.IgnoreQueryFilters() @@ -156,19 +156,38 @@ private static async Task EnsureMoneyDefaultsAsync(AppDbContext db, Cancellation var changed = false; foreach (var role in cashierRoles) { - if (!role.Permissions.MoneyView || !role.Permissions.CashShiftOperate) + if (role.Permissions.MoneyView || !role.Permissions.CashShiftOperate) { // Permissions — owned-тип; мутация скалярных флагов на // отслеживаемой сущности ловится DetectChanges при SaveChanges. // Остальные права кассира (ProductsView/RetailSalesOperate/…) // сохраняются — мы их не трогаем. - role.Permissions.MoneyView = true; + role.Permissions.MoneyView = false; role.Permissions.CashShiftOperate = true; changed = true; } } if (changed) await db.SaveChangesAsync(ct); + // Back-fill прав кладовщика: системной роли «Кладовщик» нужны права на + // инвентаризацию/оприходование/списание (веб их показывает, API должен + // совпадать). Старые орги их не имели → ловили 403. + var keeperRoles = await db.EmployeeRoles.IgnoreQueryFilters() + .Where(r => r.IsSystem && r.Name == "Кладовщик") + .ToListAsync(ct); + var keeperChanged = false; + foreach (var role in keeperRoles) + { + if (!role.Permissions.InventoryEdit || !role.Permissions.EnterEdit || !role.Permissions.LossEdit) + { + role.Permissions.InventoryEdit = true; + role.Permissions.EnterEdit = true; + role.Permissions.LossEdit = true; + keeperChanged = true; + } + } + if (keeperChanged) await db.SaveChangesAsync(ct); + var orgIds = await db.Organizations.IgnoreQueryFilters().Select(o => o.Id).ToListAsync(ct); foreach (var orgId in orgIds) await SeedExpenseItemsAsync(db, orgId, ct); @@ -303,10 +322,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca ProductsView = true, SuppliesView = true, SuppliesEdit = true, SuppliesPost = true, StocksView = true, + // Складская работа кладовщика: инвентаризация, оприходование, + // списание (веб уже показывает эти пункты — API должен совпадать). + InventoryEdit = true, EnterEdit = true, LossEdit = true, // Кладовщику деньги не положены — только видимость оплаченности - // приёмки через SuppliesView (payedSum в DTO). MoneyView=false - // здесь, этим флаг «кладовщик без доступа к деньгам» (простой - // режим, п.7 ТЗ модуля). + // приёмки через SuppliesView (payedSum в DTO). MoneyView=false. }, }; var cashier = new EmployeeRole @@ -325,7 +345,11 @@ private static async Task SeedEmployeeRolesAsync(AppDbContext db, Guid orgId, Ca // вносить/выплачивать деньги в ней. Чужие смены и движения // по банковским счетам ему недоступны (контроллер фильтрует). CashShiftOperate = true, - MoneyView = true, + // Кассир НЕ видит модуль «Деньги» (счета/платежи/долги/чужие + // смены) — требование владельца «кассир не видит денег». Свою + // смену и Z-отчёт он видит через CashShiftOperate (эндпоинты + // shifts List/Get/Report принимают его право по ИЛИ-семантике). + MoneyView = false, }, };