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:
nns 2026-10-08 09:24:51 +05:00
parent 01b47cc0c8
commit ff69dec527
7 changed files with 209 additions and 17 deletions

View file

@ -721,6 +721,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Sprint (mobile-api): эффективные права текущего пользователя — // Sprint (mobile-api): эффективные права текущего пользователя —
// единый источник для мобильного и веба (скрывают одно и то же). // единый источник для мобильного и веба (скрывают одно и то же).
foodmarket.Domain.Organizations.RolePermissions? rolePerms = null; foodmarket.Domain.Organizations.RolePermissions? rolePerms = null;
List<Guid> retailPointIds = new();
var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole); var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole);
var isAdmin = roles.Contains("Admin"); var isAdmin = roles.Contains("Admin");
if (Guid.TryParse(orgIdRaw, out var orgId)) if (Guid.TryParse(orgIdRaw, out var orgId))
@ -738,10 +739,19 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{ {
var emp = await db.Employees.IgnoreQueryFilters() var emp = await db.Employees.IgnoreQueryFilters()
.Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted) .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(); .FirstOrDefaultAsync();
hasActiveEmployee = emp is not null; hasActiveEmployee = emp is not null;
rolePerms = emp?.Role?.Permissions; 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) с учётом роли и простого/расширенного // Эффективные права (имя→bool) с учётом роли и простого/расширенного
// режима. Клиенты (моб./веб) гейтят UI по этим флагам. // режима. Клиенты (моб./веб) гейтят UI по этим флагам.
permissions, permissions,
// Точки продаж сотрудника (касса пускает кассира только на свою).
retailPointIds,
}); });
}).RequireAuthorization(); }).RequireAuthorization();

View 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;
}
}

View file

@ -46,7 +46,8 @@ public interface IServerApi
Task<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default); 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 PagedDto<T>(IReadOnlyList<T> items, int total);
public record RetailPointDto(Guid id, string name, string? code, Guid? storeId, bool isActive); 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); public record MoneyAccountDto(Guid id, string name, int kind, Guid? storeId, bool isActive);

View file

@ -137,15 +137,31 @@ private async Task SyncTickAsync()
/// <summary>Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке /// <summary>Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке
/// продаж (берёт точку из привязки кассира). Хранит refresh-токен защищённо.</summary> /// продаж (берёт точку из привязки кассира). Хранит 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 public partial class LoginViewModel : ViewModelBase
{ {
private const string DevicePointKey = "device.retailPointId";
private readonly AppServices? _svc; private readonly AppServices? _svc;
private PosSession? _pending; // сессия, ожидающая выбора точки
private bool _canManagePoints;
[ObservableProperty] private string _serverUrl = "https://test.admin.food-market.kz"; [ObservableProperty] private string _serverUrl = "https://test.admin.food-market.kz";
[ObservableProperty] private string _username = ""; [ObservableProperty] private string _username = "";
[ObservableProperty] private string _pin = ""; [ObservableProperty] private string _pin = "";
[ObservableProperty] private string? _error; [ObservableProperty] private string? _error;
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
// Шаг выбора точки продаж (регистрация устройства администратором).
[ObservableProperty] private bool _requirePointSelection;
[ObservableProperty] private RetailPointOption? _selectedPoint;
public ObservableCollection<RetailPointOption> Points { get; } = new();
public Action? OnLoggedIn; public Action? OnLoggedIn;
public LoginViewModel() { } public LoginViewModel() { }
@ -155,41 +171,97 @@ public partial class LoginViewModel : ViewModelBase
private async Task LoginAsync() private async Task LoginAsync()
{ {
if (_svc is null) { OnLoggedIn?.Invoke(); return; } if (_svc is null) { OnLoggedIn?.Invoke(); return; }
Error = null; Busy = true; Error = null; Busy = true; RequirePointSelection = false;
try try
{ {
var t = await _svc.Auth.LoginAsync(ServerUrl.Trim(), Username.Trim(), Pin); var t = await _svc.Auth.LoginAsync(ServerUrl.Trim(), Username.Trim(), Pin);
var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken }; var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken };
_svc.ConfigureApi(session); _svc.ConfigureApi(session);
// Кто вошёл + роли. Guid[] assigned = Array.Empty<Guid>();
try try
{ {
var me = await _svc.Api!.MeAsync(); var me = await _svc.Api!.MeAsync();
session.CashierName = me.name ?? Username.Trim(); session.CashierName = me.name ?? Username.Trim();
session.Roles = me.roles; session.Roles = me.roles;
if (Guid.TryParse(me.sub, out var uid)) session.CashierUserId = uid; 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(); } catch { session.CashierName = Username.Trim(); }
// Привязка к точке продаж (первая активная, к которой есть доступ). // Точка продаж, на которой зарегистрировано это устройство.
try Guid? devicePoint = Guid.TryParse(_svc.Store.GetKv(DevicePointKey), out var dp) ? dp : null;
{ var decision = DeviceRegistration.Evaluate(devicePoint, _canManagePoints, assigned);
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 { /* офлайн — точку подтянем позже */ }
_svc.Tokens.Save(session); switch (decision)
_svc.Session = session; {
OnLoggedIn?.Invoke(); 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 (AuthException ex) { Error = ex.Message; }
catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; } catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; }
finally { Busy = false; } 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" }; public static LoginViewModel Sample() => new() { Username = "cashier@food-market.kz" };
} }

View file

@ -20,8 +20,19 @@
<TextBlock Text="PIN / пароль" Foreground="#94a3b8"/> <TextBlock Text="PIN / пароль" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/> <TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/> <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}"/> <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> </StackPanel>
</Border> </Border>
</Grid> </Grid>

View file

@ -147,4 +147,39 @@ public async Task Me_permissions_for_cashier_role()
// demandsView выдали роли, но орг в простом режиме → advanced-only скрыт. // demandsView выдали роли, но орг в простом режиме → advanced-only скрыт.
perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse(); 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);
}
} }

View file

@ -1,4 +1,5 @@
using FluentAssertions; using FluentAssertions;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data; using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Logic; using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models; using foodmarket.Pos.Core.Models;
@ -404,3 +405,34 @@ public void Snapshot_is_independent_copy()
cart.Totals().Total.Should().Be(2 * 300m - 100m); 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);
}