From ff69dec527a0aca022caf58a4143e9634027b159 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 09:24:51 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0=20=D0=BD=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6?= =?UTF-8?q?=20+=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=81=D0=B8=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Сервер: /api/me отдаёт retailPointIds (точки, к которым привязан сотрудник). - Касса: DeviceRegistration (чистая логика, 5 unit-тестов) — первый запуск: админ выбирает точку из списка активных (сохраняется на устройстве, kv); кассир на незарегистрированном устройстве → «обратитесь к администратору»; кассир, не привязанный к точке устройства → «вы не привязаны к этой кассе»; админ работает на любой точке. Смена точки — кнопка-сброс (новую выберет только админ). Экран входа: шаг выбора точки (ComboBox активных точек). - Заменяет прежний «берётся первая активная» (ошибка при двух точках). - Тесты: unit (5 решений гейта) + integration (/api/me retailPointIds кассира). Co-Authored-By: Claude Opus 5.5 --- src/food-market.api/Program.cs | 14 ++- .../Auth/DeviceRegistration.cs | 29 ++++++ src/food-market.pos.core/Sync/IServerApi.cs | 3 +- src/food-market.pos/ViewModels/ViewModels.cs | 98 ++++++++++++++++--- src/food-market.pos/Views/LoginView.axaml | 15 ++- .../PermissionTests.cs | 35 +++++++ tests/food-market.UnitTests/PosCoreTests.cs | 32 ++++++ 7 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 src/food-market.pos.core/Auth/DeviceRegistration.cs diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index b88e3bc..7574837 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -721,6 +721,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Sprint (mobile-api): эффективные права текущего пользователя — // единый источник для мобильного и веба (скрывают одно и то же). foodmarket.Domain.Organizations.RolePermissions? rolePerms = null; + List retailPointIds = new(); var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole); var isAdmin = roles.Contains("Admin"); if (Guid.TryParse(orgIdRaw, out var orgId)) @@ -738,10 +739,19 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme { var emp = await db.Employees.IgnoreQueryFilters() .Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted) - .Select(e => new { e.Role }) + .Select(e => new { e.Id, e.Role }) .FirstOrDefaultAsync(); hasActiveEmployee = emp is not null; rolePerms = emp?.Role?.Permissions; + // Точки продаж, к которым привязан сотрудник (для роли Кассир — + // касса пускает только на «свою» точку). Админ не привязан — + // пустой список + право RetailPointsManage → доступ ко всем. + if (emp is not null) + retailPointIds = await db.Set() + .IgnoreQueryFilters() + .Where(a => a.EmployeeId == emp.Id && a.OrganizationId == orgId) + .Select(a => a.RetailPointId) + .ToListAsync(); } } @@ -761,6 +771,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // Эффективные права (имя→bool) с учётом роли и простого/расширенного // режима. Клиенты (моб./веб) гейтят UI по этим флагам. permissions, + // Точки продаж сотрудника (касса пускает кассира только на свою). + retailPointIds, }); }).RequireAuthorization(); diff --git a/src/food-market.pos.core/Auth/DeviceRegistration.cs b/src/food-market.pos.core/Auth/DeviceRegistration.cs new file mode 100644 index 0000000..fbaf31e --- /dev/null +++ b/src/food-market.pos.core/Auth/DeviceRegistration.cs @@ -0,0 +1,29 @@ +namespace foodmarket.Pos.Core.Auth; + +/// Решение о допуске входа с учётом регистрации устройства на точке +/// продаж. Касса привязана к одной точке (сохраняется на устройстве). Выбор/ +/// смену точки делает только администратор (право RetailPointsManage). Кассир +/// пускается только на «свою» точку (к которой он привязан в админке). Чистая +/// логика — тестируется. +public static class DeviceRegistration +{ + public enum Decision + { + /// Устройство зарегистрировано и пользователь допущен — работать. + Proceed, + /// Устройство не зарегистрировано, пользователь — админ: выбрать точку. + NeedSelection, + /// Устройство не зарегистрировано, пользователь не админ — к администратору. + NotRegistered, + /// Пользователь не привязан к точке этого устройства. + NotAssigned, + } + + public static Decision Evaluate(Guid? devicePoint, bool canManagePoints, IReadOnlyCollection assignedPoints) + { + if (devicePoint is null) + return canManagePoints ? Decision.NeedSelection : Decision.NotRegistered; + if (canManagePoints) return Decision.Proceed; // админ работает на любой точке + return assignedPoints.Contains(devicePoint.Value) ? Decision.Proceed : Decision.NotAssigned; + } +} diff --git a/src/food-market.pos.core/Sync/IServerApi.cs b/src/food-market.pos.core/Sync/IServerApi.cs index 4a0aed1..b872f93 100644 --- a/src/food-market.pos.core/Sync/IServerApi.cs +++ b/src/food-market.pos.core/Sync/IServerApi.cs @@ -46,7 +46,8 @@ public interface IServerApi Task ShiftReportAsync(Guid id, CancellationToken ct = default); } -public record MeDto(Guid? orgId, string[] roles, bool advancedMode, string? name, string? email, string sub); +public record MeDto(Guid? orgId, string[] roles, bool advancedMode, string? name, string? email, string sub, + Dictionary? permissions = null, Guid[]? retailPointIds = null); public record PagedDto(IReadOnlyList items, int total); public record RetailPointDto(Guid id, string name, string? code, Guid? storeId, bool isActive); public record MoneyAccountDto(Guid id, string name, int kind, Guid? storeId, bool isActive); diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index baa6468..7c69de3 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -137,15 +137,31 @@ private async Task SyncTickAsync() /// Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке /// продаж (берёт точку из привязки кассира). Хранит refresh-токен защищённо. +public partial class RetailPointOption : ObservableObject +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public override string ToString() => Name; +} + public partial class LoginViewModel : ViewModelBase { + private const string DevicePointKey = "device.retailPointId"; private readonly AppServices? _svc; + private PosSession? _pending; // сессия, ожидающая выбора точки + private bool _canManagePoints; + [ObservableProperty] private string _serverUrl = "https://test.admin.food-market.kz"; [ObservableProperty] private string _username = ""; [ObservableProperty] private string _pin = ""; [ObservableProperty] private string? _error; [ObservableProperty] private bool _busy; + // Шаг выбора точки продаж (регистрация устройства администратором). + [ObservableProperty] private bool _requirePointSelection; + [ObservableProperty] private RetailPointOption? _selectedPoint; + public ObservableCollection Points { get; } = new(); + public Action? OnLoggedIn; public LoginViewModel() { } @@ -155,41 +171,97 @@ public partial class LoginViewModel : ViewModelBase private async Task LoginAsync() { if (_svc is null) { OnLoggedIn?.Invoke(); return; } - Error = null; Busy = true; + Error = null; Busy = true; RequirePointSelection = false; try { var t = await _svc.Auth.LoginAsync(ServerUrl.Trim(), Username.Trim(), Pin); var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken }; _svc.ConfigureApi(session); - // Кто вошёл + роли. + Guid[] assigned = Array.Empty(); try { var me = await _svc.Api!.MeAsync(); session.CashierName = me.name ?? Username.Trim(); session.Roles = me.roles; if (Guid.TryParse(me.sub, out var uid)) session.CashierUserId = uid; + assigned = me.retailPointIds ?? Array.Empty(); + _canManagePoints = me.roles.Contains("Admin") || me.roles.Contains("SuperAdmin") + || (me.permissions?.TryGetValue("RetailPointsManage", out var m) == true && m); } catch { session.CashierName = Username.Trim(); } - // Привязка к точке продаж (первая активная, к которой есть доступ). - try - { - var points = await _svc.Api!.RetailPointsAsync(); - var point = points.items.FirstOrDefault(p => p.isActive) ?? points.items.FirstOrDefault(); - if (point is not null) session.RetailPointId = point.id; - } - catch { /* офлайн — точку подтянем позже */ } + // Точка продаж, на которой зарегистрировано это устройство. + Guid? devicePoint = Guid.TryParse(_svc.Store.GetKv(DevicePointKey), out var dp) ? dp : null; + var decision = DeviceRegistration.Evaluate(devicePoint, _canManagePoints, assigned); - _svc.Tokens.Save(session); - _svc.Session = session; - OnLoggedIn?.Invoke(); + switch (decision) + { + case DeviceRegistration.Decision.Proceed: + session.RetailPointId = devicePoint; + _svc.Tokens.Save(session); _svc.Session = session; + OnLoggedIn?.Invoke(); + break; + + case DeviceRegistration.Decision.NeedSelection: + _pending = session; + await LoadActivePointsAsync(); + RequirePointSelection = true; + Error = null; + break; + + case DeviceRegistration.Decision.NotRegistered: + Error = "Устройство не зарегистрировано на точке продаж. Обратитесь к администратору для регистрации кассы."; + break; + + case DeviceRegistration.Decision.NotAssigned: + var name = await PointNameAsync(devicePoint!.Value); + Error = $"Вы не привязаны к кассе{(name is null ? "" : " «" + name + "»")}. Обратитесь к администратору."; + break; + } } catch (AuthException ex) { Error = ex.Message; } catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; } finally { Busy = false; } } + [RelayCommand] + private void ConfirmPoint() + { + if (_pending is null || SelectedPoint is null) { Error = "Выберите точку продаж"; return; } + _svc!.Store.SetKv(DevicePointKey, SelectedPoint.Id.ToString()); + _pending.RetailPointId = SelectedPoint.Id; + _svc.Tokens.Save(_pending); _svc.Session = _pending; + RequirePointSelection = false; + OnLoggedIn?.Invoke(); + } + + /// Сбросить регистрацию устройства (сменить точку). Новую точку + /// сможет выбрать только администратор (кассир после сброса получит + /// «устройство не зарегистрировано»). + [RelayCommand] + private void ResetDevice() + { + _svc?.Store.SetKv(DevicePointKey, ""); + Error = "Точка продаж сброшена. Войдите под администратором, чтобы выбрать новую."; + RequirePointSelection = false; + } + + private async Task LoadActivePointsAsync() + { + Points.Clear(); + var points = await _svc!.Api!.RetailPointsAsync(); + foreach (var p in points.items.Where(x => x.isActive)) + Points.Add(new RetailPointOption { Id = p.id, Name = p.name }); + SelectedPoint = Points.FirstOrDefault(); + } + + private async Task PointNameAsync(Guid id) + { + try { var points = await _svc!.Api!.RetailPointsAsync(); return points.items.FirstOrDefault(p => p.id == id)?.name; } + catch { return null; } + } + public static LoginViewModel Sample() => new() { Username = "cashier@food-market.kz" }; } diff --git a/src/food-market.pos/Views/LoginView.axaml b/src/food-market.pos/Views/LoginView.axaml index d8fbadd..818f60f 100644 --- a/src/food-market.pos/Views/LoginView.axaml +++ b/src/food-market.pos/Views/LoginView.axaml @@ -20,8 +20,19 @@ - - diff --git a/tests/food-market.IntegrationTests/PermissionTests.cs b/tests/food-market.IntegrationTests/PermissionTests.cs index 6976af6..c92bf8c 100644 --- a/tests/food-market.IntegrationTests/PermissionTests.cs +++ b/tests/food-market.IntegrationTests/PermissionTests.cs @@ -147,4 +147,39 @@ public async Task Me_permissions_for_cashier_role() // demandsView выдали роли, но орг в простом режиме → advanced-only скрыт. perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse(); } + + /// Касса пускает кассира только на «свою» точку: /api/me отдаёт + /// retailPointIds сотрудника (привязка в админке). Касса сверяет с точкой + /// устройства (DeviceRegistration). + [Fact] + public async Task Me_returns_assigned_retail_points_for_cashier() + { + var admin = new ApiActor(_factory.CreateClient()); + await admin.SignupAndLoginAsync($"rp-{Guid.NewGuid():N}"); + var refs = await admin.LoadRefsAsync(); + var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}"); + + var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new + { + name = $"Касса-{Guid.NewGuid():N}", + permissions = new { retailSalesOperate = true, cashShiftOperate = true, productsView = true }, + }); + roleResp.EnsureSuccessStatusCode(); + var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + + var email = $"rp-cashier-{Guid.NewGuid():N}@example.kz"; + var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new + { + lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email, + retailPointIds = new[] { point }, + }); + 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 ids = me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).ToList(); + ids.Should().Contain(point); + } } diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs index 0f92820..d7b6e89 100644 --- a/tests/food-market.UnitTests/PosCoreTests.cs +++ b/tests/food-market.UnitTests/PosCoreTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using foodmarket.Pos.Core.Auth; using foodmarket.Pos.Core.Data; using foodmarket.Pos.Core.Logic; using foodmarket.Pos.Core.Models; @@ -404,3 +405,34 @@ public void Snapshot_is_independent_copy() cart.Totals().Total.Should().Be(2 * 300m - 100m); } } + +public class PosDeviceRegistrationTests +{ + private static readonly Guid A = Guid.NewGuid(); + private static readonly Guid B = Guid.NewGuid(); + + [Fact] + public void Admin_on_fresh_device_is_asked_to_select() + => DeviceRegistration.Evaluate(null, canManagePoints: true, Array.Empty()) + .Should().Be(DeviceRegistration.Decision.NeedSelection); + + [Fact] + public void Cashier_on_fresh_device_is_not_registered() + => DeviceRegistration.Evaluate(null, canManagePoints: false, new[] { A }) + .Should().Be(DeviceRegistration.Decision.NotRegistered); + + [Fact] + public void Admin_always_proceeds_on_registered_device() + => DeviceRegistration.Evaluate(A, canManagePoints: true, Array.Empty()) + .Should().Be(DeviceRegistration.Decision.Proceed); + + [Fact] + public void Cashier_assigned_to_device_point_proceeds() + => DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { A, B }) + .Should().Be(DeviceRegistration.Decision.Proceed); + + [Fact] + public void Cashier_not_assigned_to_device_point_is_blocked() + => DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B }) + .Should().Be(DeviceRegistration.Decision.NotAssigned); +}