feat(pos): регистрация устройства на точке продаж + привязка кассира
- Сервер: /api/me отдаёт retailPointIds (точки, к которым привязан сотрудник). - Касса: DeviceRegistration (чистая логика, 5 unit-тестов) — первый запуск: админ выбирает точку из списка активных (сохраняется на устройстве, kv); кассир на незарегистрированном устройстве → «обратитесь к администратору»; кассир, не привязанный к точке устройства → «вы не привязаны к этой кассе»; админ работает на любой точке. Смена точки — кнопка-сброс (новую выберет только админ). Экран входа: шаг выбора точки (ComboBox активных точек). - Заменяет прежний «берётся первая активная» (ошибка при двух точках). - Тесты: unit (5 решений гейта) + integration (/api/me retailPointIds кассира). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
01b47cc0c8
commit
ff69dec527
|
|
@ -721,6 +721,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// Sprint (mobile-api): эффективные права текущего пользователя —
|
||||
// единый источник для мобильного и веба (скрывают одно и то же).
|
||||
foodmarket.Domain.Organizations.RolePermissions? rolePerms = null;
|
||||
List<Guid> 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<foodmarket.Domain.Organizations.EmployeeRetailPointAssignment>()
|
||||
.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();
|
||||
|
||||
|
|
|
|||
29
src/food-market.pos.core/Auth/DeviceRegistration.cs
Normal file
29
src/food-market.pos.core/Auth/DeviceRegistration.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace foodmarket.Pos.Core.Auth;
|
||||
|
||||
/// <summary>Решение о допуске входа с учётом регистрации устройства на точке
|
||||
/// продаж. Касса привязана к одной точке (сохраняется на устройстве). Выбор/
|
||||
/// смену точки делает только администратор (право RetailPointsManage). Кассир
|
||||
/// пускается только на «свою» точку (к которой он привязан в админке). Чистая
|
||||
/// логика — тестируется.</summary>
|
||||
public static class DeviceRegistration
|
||||
{
|
||||
public enum Decision
|
||||
{
|
||||
/// <summary>Устройство зарегистрировано и пользователь допущен — работать.</summary>
|
||||
Proceed,
|
||||
/// <summary>Устройство не зарегистрировано, пользователь — админ: выбрать точку.</summary>
|
||||
NeedSelection,
|
||||
/// <summary>Устройство не зарегистрировано, пользователь не админ — к администратору.</summary>
|
||||
NotRegistered,
|
||||
/// <summary>Пользователь не привязан к точке этого устройства.</summary>
|
||||
NotAssigned,
|
||||
}
|
||||
|
||||
public static Decision Evaluate(Guid? devicePoint, bool canManagePoints, IReadOnlyCollection<Guid> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,8 @@ public interface IServerApi
|
|||
Task<ShiftReportDto> 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<string, bool>? permissions = null, Guid[]? retailPointIds = null);
|
||||
public record PagedDto<T>(IReadOnlyList<T> 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);
|
||||
|
|
|
|||
|
|
@ -137,15 +137,31 @@ private async Task SyncTickAsync()
|
|||
|
||||
/// <summary>Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке
|
||||
/// продаж (берёт точку из привязки кассира). Хранит refresh-токен защищённо.</summary>
|
||||
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<RetailPointOption> 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<Guid>();
|
||||
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<Guid>();
|
||||
_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;
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Сбросить регистрацию устройства (сменить точку). Новую точку
|
||||
/// сможет выбрать только администратор (кассир после сброса получит
|
||||
/// «устройство не зарегистрировано»).</summary>
|
||||
[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<string?> 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" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,19 @@
|
|||
<TextBlock Text="PIN / пароль" Foreground="#94a3b8"/>
|
||||
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="56" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}"/>
|
||||
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" TextWrapping="Wrap" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="56" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}" IsVisible="{Binding !RequirePointSelection}"/>
|
||||
|
||||
<!-- Регистрация устройства: выбор точки продаж (только администратор) -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding RequirePointSelection}">
|
||||
<TextBlock Text="Регистрация кассы — выберите точку продаж:" Foreground="#f8fafc"/>
|
||||
<ComboBox ItemsSource="{Binding Points}" SelectedItem="{Binding SelectedPoint}" HorizontalAlignment="Stretch" FontSize="16"/>
|
||||
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="52" Content="Зарегистрировать на этой точке" Command="{Binding ConfirmPointCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Command="{Binding ResetDeviceCommand}" ToolTip.Tip="Сбросить регистрацию устройства. Новую точку выберет только администратор.">
|
||||
<TextBlock Text="Сменить точку продаж (админ)" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -147,4 +147,39 @@ public async Task Me_permissions_for_cashier_role()
|
|||
// demandsView выдали роли, но орг в простом режиме → advanced-only скрыт.
|
||||
perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Касса пускает кассира только на «свою» точку: /api/me отдаёт
|
||||
/// retailPointIds сотрудника (привязка в админке). Касса сверяет с точкой
|
||||
/// устройства (DeviceRegistration).</summary>
|
||||
[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<JsonElement>()).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<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 ids = me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||
ids.Should().Contain(point);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Guid>())
|
||||
.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<Guid>())
|
||||
.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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue