Compare commits
4 commits
87eb48cca9
...
6b959ac59d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b959ac59d | ||
|
|
ff69dec527 | ||
|
|
01b47cc0c8 | ||
|
|
c5ff1ade94 |
|
|
@ -134,3 +134,17 @@ Testcontainers) + headless UI (Avalonia.Headless, 15 проверок) + офл
|
|||
- 08.10 — замеры Linux+Windows (п.9); холодный старт/поиск в цель.
|
||||
- 08.10 — серверная привязка продаж к смене + возвраты; +2 интеграционных теста; +офлайн/checkout/версия unit-тесты (37); headless UI-тест (--uitest).
|
||||
- 08.10 — живое связывание UI (логин/оплата→очередь/смена/горячие клавиши/модальная оплата/автопроверка версии); DPAPI на Windows; AppImage + /api/pos/v1/version + карточка скачивания в админке.
|
||||
|
||||
## Доводка 08.10 (по итогам проверки Mac-Claude)
|
||||
1. [ ] Настоящий GUI-тест на Windows 11 (интерактивная сессия tester) + скриншоты реального окна.
|
||||
2. [ ] Первый запуск на Windows 7.5с → <2с: R2R-сборка, trimming, замеры; Defender — в docs.
|
||||
3. [ ] Выбор точки продаж при регистрации устройства (список активных, сохранение, смена — админ).
|
||||
4. [ ] Быстрый вход кассира по PIN (4–6 цифр), хэш на сервере, блок после 5 ошибок.
|
||||
5. [ ] Возврат по чеку — полноценный экран выбора позиций + тесты.
|
||||
6. [ ] Повторный full-chain (C) + POS smoke Win/Linux зелёные; галерея обновлена.
|
||||
7. [ ] Коммиты в main; push origin.
|
||||
8. [x] Формат чисел KZ/RU: «3 422 ₸» (пробел тысяч), «1,235 кг» (запятая) + единица рядом с кол-вом.
|
||||
Реализовано `PosFormat` (культура ru-RU с NBSP-разделителем), применяется процессно.
|
||||
9. [x] Скидка на строку видна: «1 560 → 1 500 ₸, скидка 60» под позицией (SaleLineVm.DiscountText).
|
||||
10. [x] Кнопки: текст по центру по вертикали/горизонтали (стиль Button.pos), «Очистить» и
|
||||
«Enter · Провести и печать» больше не обрезаются (проверено на 1366×768 и 1920×1080).
|
||||
|
|
|
|||
|
|
@ -138,9 +138,89 @@ public async Task<IActionResult> Exchange()
|
|||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
// Быстрый вход кассира по PIN на зарегистрированном устройстве.
|
||||
// Параметры: retail_point_id (точка устройства) + pin. Находим активного
|
||||
// сотрудника, привязанного к точке, чей PIN совпадает. Брутфорс-защита —
|
||||
// in-memory по точке (5 неверных → блок 5 мин). Пароль остаётся запасным.
|
||||
if (string.Equals(request.GrantType, "pin", StringComparison.Ordinal))
|
||||
{
|
||||
var pointRaw = request["retail_point_id"]?.Value?.ToString();
|
||||
var pin = request["pin"]?.Value?.ToString();
|
||||
if (!Guid.TryParse(pointRaw, out var pointId) || string.IsNullOrWhiteSpace(pin))
|
||||
return BadRequestError(Errors.InvalidRequest, "Нужны retail_point_id и pin.");
|
||||
|
||||
if (PinLockout.IsLocked(pointId, out var until))
|
||||
return BadRequestError(Errors.InvalidGrant, $"pin_locked:{until:O}");
|
||||
|
||||
// Кандидаты: активные сотрудники с PIN, привязанные к этой точке.
|
||||
var candidates = await _db.Employees.IgnoreQueryFilters()
|
||||
.Where(e => e.IsActive && !e.IsDeleted && e.PinHash != null && e.UserId != null
|
||||
&& e.RetailPointAssignments.Any(a => a.RetailPointId == pointId))
|
||||
.Select(e => new { e.UserId, e.PinHash })
|
||||
.ToListAsync();
|
||||
|
||||
var match = candidates.FirstOrDefault(c => foodmarket.Application.Common.PinHasher.Verify(pin, c.PinHash));
|
||||
if (match is null)
|
||||
{
|
||||
var locked = PinLockout.RegisterFailure(pointId);
|
||||
return BadRequestError(Errors.InvalidGrant,
|
||||
locked ? "pin_locked:" + PinLockout.LockedUntil(pointId)?.ToString("O") : "pin_invalid");
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(match.UserId!.Value.ToString());
|
||||
if (user is null || !user.IsActive)
|
||||
return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен.");
|
||||
|
||||
var rejectionPin = await CheckUserStillBelongsToLiveOrgAsync(user);
|
||||
if (rejectionPin is not null) return rejectionPin;
|
||||
|
||||
PinLockout.Reset(pointId);
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await _userManager.UpdateAsync(user);
|
||||
var principalPin = await CreatePrincipal(user, request.GetScopes());
|
||||
return SignIn(principalPin, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
return BadRequestError(Errors.UnsupportedGrantType, "Этот grant type не поддерживается.");
|
||||
}
|
||||
|
||||
/// <summary>In-memory брутфорс-защита PIN-входа по точке продаж: 5 неверных
|
||||
/// подряд → блок на 5 минут. Сбрасывается при успешном входе. Процесс-локально
|
||||
/// (стенд — один инстанс); достаточно против перебора 4–6-значного PIN.</summary>
|
||||
private static class PinLockout
|
||||
{
|
||||
private const int MaxFails = 5;
|
||||
private static readonly TimeSpan LockFor = TimeSpan.FromMinutes(5);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, (int Fails, DateTime? Until)> State = new();
|
||||
|
||||
public static bool IsLocked(Guid point, out DateTime until)
|
||||
{
|
||||
until = default;
|
||||
if (State.TryGetValue(point, out var s) && s.Until is { } u && u > DateTime.UtcNow)
|
||||
{ until = u; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
public static DateTime? LockedUntil(Guid point)
|
||||
=> State.TryGetValue(point, out var s) ? s.Until : null;
|
||||
|
||||
/// <summary>Регистрирует неудачу; возвращает true если точка теперь заблокирована.</summary>
|
||||
public static bool RegisterFailure(Guid point)
|
||||
{
|
||||
var s = State.AddOrUpdate(point, _ => (1, null),
|
||||
(_, cur) => (cur.Until is { } u && u > DateTime.UtcNow ? cur.Fails : cur.Fails + 1, cur.Until));
|
||||
if (s.Fails >= MaxFails && (s.Until is null || s.Until <= DateTime.UtcNow))
|
||||
{
|
||||
var until = DateTime.UtcNow.Add(LockFor);
|
||||
State[point] = (0, until);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void Reset(Guid point) => State.TryRemove(point, out _);
|
||||
}
|
||||
|
||||
/// <summary>SuperAdmin-у org не нужна — он работает на уровне платформы.
|
||||
/// Любой другой пользователь обязан указывать на живую (не архивную)
|
||||
/// Organization. Если связь оборвана (org удалена / архивирована) —
|
||||
|
|
|
|||
|
|
@ -219,6 +219,50 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
|
|||
return new EmployeeCreateResult(dto!, tempPassword);
|
||||
}
|
||||
|
||||
public record SetPinInput(string? Pin);
|
||||
|
||||
/// <summary>Задать/сменить/снять PIN кассира (быстрый вход на кассе).
|
||||
/// Пустой PIN — снять. PIN — 4–6 цифр, уникален среди активных сотрудников
|
||||
/// на пересекающихся точках продаж (иначе вход по PIN неоднозначен).</summary>
|
||||
[HttpPost("{id:guid}/pin"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<IActionResult> SetPin(Guid id, [FromBody] SetPinInput input, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.Employees.Include(x => x.RetailPointAssignments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Pin))
|
||||
{
|
||||
e.PinHash = null;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { ok = true, pinSet = false });
|
||||
}
|
||||
|
||||
var pin = input.Pin.Trim();
|
||||
if (!Application.Common.PinHasher.IsValidPin(pin))
|
||||
return BadRequest(new { error = "PIN должен быть 4–6 цифр.", field = "pin" });
|
||||
|
||||
// Уникальность PIN среди активных сотрудников на тех же точках продаж.
|
||||
var myPoints = e.RetailPointAssignments.Select(a => a.RetailPointId).ToHashSet();
|
||||
if (myPoints.Count > 0)
|
||||
{
|
||||
var others = await _db.Employees
|
||||
.Include(x => x.RetailPointAssignments)
|
||||
.Where(x => x.Id != id && x.IsActive && !x.IsDeleted && x.PinHash != null
|
||||
&& x.RetailPointAssignments.Any(a => myPoints.Contains(a.RetailPointId)))
|
||||
.Select(x => new { x.PinHash, x.FirstName, x.LastName })
|
||||
.ToListAsync(ct);
|
||||
var clash = others.FirstOrDefault(o => Application.Common.PinHasher.Verify(pin, o.PinHash));
|
||||
if (clash is not null)
|
||||
return Conflict(new { error = $"Такой PIN уже у сотрудника {clash.LastName} {clash.FirstName} на этой точке. Выберите другой.", field = "pin", code = "pin-duplicate" });
|
||||
}
|
||||
|
||||
e.PinHash = Application.Common.PinHasher.Hash(pin);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _audit.LogAsync("SetEmployeePin", "Employee", e.Id, new { e.LastName, e.FirstName }, ct);
|
||||
return Ok(new { ok = true, pinSet = true });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin,Admin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,6 +64,40 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
|||
Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m,
|
||||
});
|
||||
|
||||
/// <summary>Чек-основание для возврата: поиск проведённого (не-возвратного)
|
||||
/// чека по номеру/QR + позиции с количеством, доступным к возврату
|
||||
/// (продано − уже возвращено). Для экрана возврата на кассе.</summary>
|
||||
[HttpGet("sales/by-number/{number}")]
|
||||
public async Task<ActionResult<PosReturnLookupDto>> SaleByNumber(string number, CancellationToken ct)
|
||||
{
|
||||
var n = (number ?? "").Trim();
|
||||
if (n.Length == 0) return NotFound();
|
||||
var sale = await _db.RetailSales.AsNoTracking()
|
||||
.Where(s => s.Number == n && !s.IsReturn && s.Status == RetailSaleStatus.Posted)
|
||||
.Select(s => new { s.Id, s.Number, s.Date, s.Payment })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (sale is null)
|
||||
return NotFound(new { error = "Чек не найден или не проведён.", field = "number" });
|
||||
|
||||
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
|
||||
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
|
||||
join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
|
||||
where l.RetailSaleId == sale.Id
|
||||
orderby l.SortOrder
|
||||
select new PosReturnLineDto
|
||||
{
|
||||
ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code,
|
||||
UnitPrice = l.UnitPrice, SoldQty = l.Quantity,
|
||||
ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
return Ok(new PosReturnLookupDto
|
||||
{
|
||||
SaleId = sale.Id, Number = sale.Number, Date = sale.Date,
|
||||
Payment = (int)sale.Payment, Lines = lines,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("sync")]
|
||||
public async Task<ActionResult<PosSyncResponse>> Sync(
|
||||
[FromQuery] DateTime? since,
|
||||
|
|
@ -312,6 +346,25 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
|||
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
|
||||
}
|
||||
}
|
||||
else if (src.ReferenceSaleId is Guid refIdCheck)
|
||||
{
|
||||
// Возврат по чеку: нельзя вернуть больше, чем продано минус уже
|
||||
// возвращено (QtyReturned на исходных строках). Защита от over-return.
|
||||
var refLines = await _db.RetailSaleLines.AsNoTracking()
|
||||
.Where(l => l.RetailSaleId == refIdCheck).ToListAsync(ct);
|
||||
if (refLines.Count == 0)
|
||||
throw new PosSaleRejectedException("Исходный чек не найден.", "referenceSaleId");
|
||||
var remainingByProduct = refLines.GroupBy(l => l.ProductId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity) - g.Sum(x => x.QtyReturned));
|
||||
foreach (var g in src.Lines.GroupBy(l => l.ProductId))
|
||||
{
|
||||
if (!remainingByProduct.TryGetValue(g.Key, out var remaining))
|
||||
throw new PosSaleRejectedException($"Товар {g.Key} не из исходного чека.", "productId");
|
||||
if (g.Sum(x => x.Quantity) > remaining)
|
||||
throw new PosSaleRejectedException(
|
||||
$"Нельзя вернуть больше, чем доступно (к возврату {remaining}).", "quantity");
|
||||
}
|
||||
}
|
||||
|
||||
// Привязка к смене: если указана — проверяем, что она открыта и наша.
|
||||
if (src.CashShiftId is Guid shiftId)
|
||||
|
|
@ -394,6 +447,24 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
|||
UnitCost: l.UnitPrice,
|
||||
OccurredAt: src.OccurredAt), ct);
|
||||
}
|
||||
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
|
||||
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
|
||||
if (src.IsReturn && src.ReferenceSaleId is Guid refIdInc)
|
||||
{
|
||||
var refLines = await _db.RetailSaleLines
|
||||
.Where(l => l.RetailSaleId == refIdInc).ToListAsync(ct);
|
||||
var takenByProduct = src.Lines.GroupBy(x => x.ProductId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
|
||||
foreach (var rl in refLines.OrderBy(l => l.SortOrder))
|
||||
{
|
||||
if (!takenByProduct.TryGetValue(rl.ProductId, out var taken) || taken <= 0) continue;
|
||||
var take = Math.Min(taken, rl.Quantity - rl.QtyReturned);
|
||||
if (take <= 0) continue;
|
||||
rl.QtyReturned += take;
|
||||
takenByProduct[rl.ProductId] = taken - take;
|
||||
}
|
||||
}
|
||||
|
||||
sale.Status = RetailSaleStatus.Posted;
|
||||
sale.PostedAt = DateTime.UtcNow;
|
||||
try
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ static string ApplyDefaultPoolConfig(string? raw)
|
|||
{
|
||||
opts.SetTokenEndpointUris("/connect/token");
|
||||
opts.AllowPasswordFlow().AllowRefreshTokenFlow();
|
||||
// Быстрый вход кассира по PIN на зарегистрированном устройстве
|
||||
// (grant_type=pin, параметры retail_point_id + pin). См. AuthorizationController.
|
||||
opts.AllowCustomFlow("pin");
|
||||
opts.AcceptAnonymousClients();
|
||||
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
||||
|
||||
|
|
@ -721,6 +724,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 +742,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 +774,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// Эффективные права (имя→bool) с учётом роли и простого/расширенного
|
||||
// режима. Клиенты (моб./веб) гейтят UI по этим флагам.
|
||||
permissions,
|
||||
// Точки продаж сотрудника (касса пускает кассира только на свою).
|
||||
retailPointIds,
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ public class OpenIddictClientSeeder : IHostedService
|
|||
public const string WebClientId = "food-market-web";
|
||||
public const string PosClientId = "food-market-pos";
|
||||
|
||||
// Custom grant «pin» — быстрый вход кассира (grant_type=pin). Разрешён
|
||||
// клиенту отдельным permission gt:pin.
|
||||
private const string PinGrantPermission = Permissions.Prefixes.GrantType + "pin";
|
||||
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public OpenIddictClientSeeder(IServiceProvider services)
|
||||
|
|
@ -15,50 +19,54 @@ public OpenIddictClientSeeder(IServiceProvider services)
|
|||
_services = services;
|
||||
}
|
||||
|
||||
private static OpenIddictApplicationDescriptor Descriptor(string clientId, string displayName) => new()
|
||||
{
|
||||
ClientId = clientId,
|
||||
DisplayName = displayName,
|
||||
ClientType = ClientTypes.Public,
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.GrantTypes.Password,
|
||||
Permissions.GrantTypes.RefreshToken,
|
||||
PinGrantPermission,
|
||||
Permissions.Scopes.Email,
|
||||
Permissions.Scopes.Profile,
|
||||
Permissions.Scopes.Roles,
|
||||
Permissions.Prefixes.Scope + "api",
|
||||
}
|
||||
};
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var mgr = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
|
||||
if (await mgr.FindByClientIdAsync(WebClientId, ct) is null)
|
||||
await EnsureClientAsync(mgr, WebClientId, "food-market web admin", ct);
|
||||
await EnsureClientAsync(mgr, PosClientId, "food-market POS client", ct);
|
||||
}
|
||||
|
||||
/// <summary>Создаёт клиента, либо (если уже есть) досоздаёт недостающие
|
||||
/// permissions — например, gt:pin на уже засиженных стендах/проде, чтобы
|
||||
/// добавление нового grant-type не требовало пересоздания клиента.</summary>
|
||||
private static async Task EnsureClientAsync(IOpenIddictApplicationManager mgr, string clientId, string displayName, CancellationToken ct)
|
||||
{
|
||||
var existing = await mgr.FindByClientIdAsync(clientId, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
await mgr.CreateAsync(new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = WebClientId,
|
||||
DisplayName = "food-market web admin",
|
||||
ClientType = ClientTypes.Public,
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.GrantTypes.Password,
|
||||
Permissions.GrantTypes.RefreshToken,
|
||||
Permissions.Scopes.Email,
|
||||
Permissions.Scopes.Profile,
|
||||
Permissions.Scopes.Roles,
|
||||
Permissions.Prefixes.Scope + "api",
|
||||
}
|
||||
}, ct);
|
||||
await mgr.CreateAsync(Descriptor(clientId, displayName), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mgr.FindByClientIdAsync(PosClientId, ct) is null)
|
||||
{
|
||||
await mgr.CreateAsync(new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = PosClientId,
|
||||
DisplayName = "food-market POS client",
|
||||
ClientType = ClientTypes.Public,
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.GrantTypes.Password,
|
||||
Permissions.GrantTypes.RefreshToken,
|
||||
Permissions.Scopes.Email,
|
||||
Permissions.Scopes.Profile,
|
||||
Permissions.Scopes.Roles,
|
||||
Permissions.Prefixes.Scope + "api",
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
// Добавить отсутствующие permissions (идемпотентно).
|
||||
var desc = new OpenIddictApplicationDescriptor();
|
||||
await mgr.PopulateAsync(desc, existing, ct);
|
||||
var want = Descriptor(clientId, displayName).Permissions;
|
||||
var changed = false;
|
||||
foreach (var p in want)
|
||||
if (!desc.Permissions.Contains(p)) { desc.Permissions.Add(p); changed = true; }
|
||||
if (changed)
|
||||
await mgr.UpdateAsync(existing, desc, ct);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
|
|
|||
37
src/food-market.application/Common/PinHasher.cs
Normal file
37
src/food-market.application/Common/PinHasher.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Application.Common;
|
||||
|
||||
/// <summary>Хэширование PIN-кода кассира (PBKDF2-HMAC-SHA256 со случайной солью).
|
||||
/// PIN низкоэнтропийный (4–6 цифр), поэтому защита — хэш + серверный lockout
|
||||
/// (5 неверных → блок). Формат хранения: «base64(salt).base64(key)».</summary>
|
||||
public static class PinHasher
|
||||
{
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private const int Iterations = 100_000;
|
||||
|
||||
public static string Hash(string pin)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var key = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(pin), salt, Iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(key)}";
|
||||
}
|
||||
|
||||
public static bool Verify(string pin, string? stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stored)) return false;
|
||||
var parts = stored.Split('.');
|
||||
if (parts.Length != 2) return false;
|
||||
byte[] salt, key;
|
||||
try { salt = Convert.FromBase64String(parts[0]); key = Convert.FromBase64String(parts[1]); }
|
||||
catch { return false; }
|
||||
var test = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(pin), salt, Iterations, HashAlgorithmName.SHA256, key.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(test, key);
|
||||
}
|
||||
|
||||
/// <summary>PIN — 4–6 цифр.</summary>
|
||||
public static bool IsValidPin(string? pin)
|
||||
=> pin is not null && pin.Length is >= 4 and <= 6 && pin.All(char.IsDigit);
|
||||
}
|
||||
|
|
@ -46,6 +46,13 @@ public class Employee : TenantEntity
|
|||
|
||||
public ICollection<EmployeeRetailPointAssignment> RetailPointAssignments { get; set; }
|
||||
= new List<EmployeeRetailPointAssignment>();
|
||||
|
||||
// ─ Быстрый вход кассира по PIN на зарегистрированном устройстве ─────
|
||||
/// <summary>Хэш PIN-кода (PBKDF2 с солью, base64). null — PIN не задан,
|
||||
/// вход только по паролю. Задаёт администратор в карточке сотрудника.
|
||||
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
|
||||
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
|
||||
public string? PinHash { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Phase36 — быстрый вход кассира по PIN на зарегистрированном
|
||||
/// устройстве. Добавляет в <c>employees</c> <c>PinHash</c> text (PBKDF2-хэш
|
||||
/// PIN, null = не задан). Идемпотентно. Вход по паролю не меняется.
|
||||
/// Брутфорс-защита (5 неверных → блок 5 мин) — in-memory на уровне точки.</summary>
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20261008090000_Phase36_EmployeePin")]
|
||||
public partial class Phase36_EmployeePin : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
=> b.Sql(@"ALTER TABLE public.employees ADD COLUMN IF NOT EXISTS ""PinHash"" text;");
|
||||
|
||||
protected override void Down(MigrationBuilder b)
|
||||
=> b.Sql(@"ALTER TABLE public.employees DROP COLUMN IF EXISTS ""PinHash"";");
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,30 @@ public async Task<AuthTokens> LoginAsync(string serverUrl, string username, stri
|
|||
return Parse(body);
|
||||
}
|
||||
|
||||
/// <summary>Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном
|
||||
/// устройстве: сервер находит кассира, привязанного к точке, по PIN.</summary>
|
||||
public async Task<AuthTokens> PinLoginAsync(string serverUrl, Guid retailPointId, string pin, CancellationToken ct = default)
|
||||
{
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "pin",
|
||||
["retail_point_id"] = retailPointId.ToString(),
|
||||
["pin"] = pin,
|
||||
["client_id"] = "food-market-web",
|
||||
["scope"] = "openid profile email roles api offline_access",
|
||||
});
|
||||
using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var desc = DescribeError(body) ?? "";
|
||||
if (desc.StartsWith("pin_locked")) throw new AuthException("Слишком много неверных попыток. Вход по PIN заблокирован на 5 минут — войдите по паролю.");
|
||||
if (desc.StartsWith("pin_invalid")) throw new AuthException("Неверный PIN.");
|
||||
throw new AuthException(string.IsNullOrEmpty(desc) ? "Вход по PIN не удался." : desc);
|
||||
}
|
||||
return Parse(body);
|
||||
}
|
||||
|
||||
public async Task<AuthTokens> RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ public interface IServerApi
|
|||
[Get("/api/pos/v1/version")]
|
||||
Task<PosVersionDto> VersionAsync(CancellationToken ct = default);
|
||||
|
||||
[Get("/api/pos/v1/sales/by-number/{number}")]
|
||||
Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default);
|
||||
|
||||
[Get("/api/me")]
|
||||
Task<MeDto> MeAsync(CancellationToken ct = default);
|
||||
|
||||
|
|
@ -43,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);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,23 @@
|
|||
<Style Selector="Button.pos">
|
||||
<Setter Property="Height" Value="56" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="Padding" Value="18,0" />
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#2d3748" />
|
||||
<Setter Property="Foreground" Value="#f7fafc" />
|
||||
<!-- Текст строго по центру кнопки (и по вертикали, и по горизонтали). -->
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<!-- Содержимое кнопок кассы не обрезаем: при нехватке ширины переносим,
|
||||
текст по центру. Избавляет от «Esc · Очистит…». -->
|
||||
<Style Selector="Button.pos /template/ ContentPresenter">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.pos TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="#16a34a" />
|
||||
|
|
|
|||
35
src/food-market.pos/PosFormat.cs
Normal file
35
src/food-market.pos/PosFormat.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace foodmarket.Pos;
|
||||
|
||||
/// <summary>Единый формат чисел кассы — как в вебе/мобильном (KZ/RU):
|
||||
/// разделитель тысяч — пробел («3 422 ₸»), десятичный — запятая («1,235 кг»).
|
||||
/// Применяется процессно (CurrentCulture + дефолт новых потоков), чтобы и
|
||||
/// привязки XAML (StringFormat), и Toin-code форматирование совпадали.</summary>
|
||||
public static class PosFormat
|
||||
{
|
||||
public static readonly CultureInfo Culture = BuildCulture();
|
||||
|
||||
private static CultureInfo BuildCulture()
|
||||
{
|
||||
// База — ru-RU (запятая-десятичный). Явно выставляем пробел-разделитель
|
||||
// тысяч (NBSP), чтобы не зависеть от версии ICU/окружения.
|
||||
var c = (CultureInfo)CultureInfo.GetCultureInfo("ru-RU").Clone();
|
||||
const string sep = " "; // неразрывный пробол
|
||||
c.NumberFormat.NumberGroupSeparator = sep;
|
||||
c.NumberFormat.CurrencyGroupSeparator = sep;
|
||||
c.NumberFormat.PercentGroupSeparator = sep;
|
||||
c.NumberFormat.NumberDecimalSeparator = ",";
|
||||
c.NumberFormat.CurrencyDecimalSeparator = ",";
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Выставить кассовую культуру текущему и новым потокам.</summary>
|
||||
public static void Apply()
|
||||
{
|
||||
CultureInfo.CurrentCulture = Culture;
|
||||
CultureInfo.CurrentUICulture = Culture;
|
||||
CultureInfo.DefaultThreadCurrentCulture = Culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = Culture;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ internal static class Program
|
|||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг»)
|
||||
// CLI-режимы (без интерактивного окна):
|
||||
// --smoke : прогон сценария кассы через логику (без UI), печать результатов.
|
||||
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).
|
||||
|
|
|
|||
|
|
@ -56,6 +56,70 @@ public sealed record CommitResult(Guid ClientSaleId, bool PushedNow, int Pending
|
|||
return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr);
|
||||
}
|
||||
|
||||
/// <summary>Оформить возврат по чеку: собрать return-чек из выбранных позиций
|
||||
/// (IsReturn + ReferenceSaleId), записать в очередь, напечатать, попытаться
|
||||
/// выгрузить. Деньги — к выдаче (refundCash/refundCard как в исходном чеке).</summary>
|
||||
public async Task<CommitResult> CommitReturnAsync(
|
||||
IReadOnlyList<PosSaleLineDto> lines, Guid referenceSaleId,
|
||||
decimal refundCash, decimal refundCard,
|
||||
IReadOnlyList<(string Name, string Unit)> display, CancellationToken ct = default)
|
||||
{
|
||||
if (lines.Count == 0) throw new InvalidOperationException("Не выбрано ни одной позиции для возврата.");
|
||||
var method = (refundCard > 0m && refundCash > 0m) ? PosPaymentMethod.Mixed
|
||||
: refundCard > 0m ? PosPaymentMethod.Card : PosPaymentMethod.Cash;
|
||||
var sale = new PosSaleDto
|
||||
{
|
||||
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow,
|
||||
CashierUserId = _svc.Session?.CashierUserId,
|
||||
RetailPointId = CurrentShift?.RetailPointId ?? _svc.Session?.RetailPointId,
|
||||
CashShiftId = CurrentShift?.Id,
|
||||
IsReturn = true, ReferenceSaleId = referenceSaleId,
|
||||
Payment = (int)method, PaidCash = refundCash, PaidCard = refundCard,
|
||||
Lines = lines,
|
||||
};
|
||||
_svc.Store.EnqueueSale(sale);
|
||||
|
||||
bool printed = false; string? printErr = null;
|
||||
try { await _svc.Printer.PrintAsync(BuildReturnReceipt(lines, display, refundCash, refundCard), ct); printed = true; }
|
||||
catch (Exception ex) { printErr = ex.Message; }
|
||||
|
||||
bool pushedNow = false;
|
||||
if (_svc.Sync is not null)
|
||||
{
|
||||
try { var r = await _svc.Sync.PushSalesAsync(ct); pushedNow = r.Accepted > 0; }
|
||||
catch { /* офлайн */ }
|
||||
}
|
||||
return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr);
|
||||
}
|
||||
|
||||
private ReceiptDoc BuildReturnReceipt(IReadOnlyList<PosSaleLineDto> lines,
|
||||
IReadOnlyList<(string Name, string Unit)> display, decimal refundCash, decimal refundCard)
|
||||
{
|
||||
var doc = new ReceiptDoc { Title = "ВОЗВРАТ" };
|
||||
doc.Add("Food Market", center: true, bold: true);
|
||||
if (CurrentShift?.Number is { } sn) doc.Add($"Смена {sn}", center: true);
|
||||
if (_svc.Session?.CashierName is { } c) doc.Add($"Кассир: {c}");
|
||||
doc.Add(DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss"));
|
||||
doc.Separator();
|
||||
decimal total = 0m;
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var l = lines[i];
|
||||
var name = i < display.Count ? display[i].Name : l.ProductId.ToString();
|
||||
var unit = i < display.Count ? display[i].Unit : "шт";
|
||||
var lineTotal = decimal.Round(l.Quantity * l.UnitPrice, 2);
|
||||
total += lineTotal;
|
||||
doc.Add(name);
|
||||
doc.Add($" {l.Quantity:0.###} {unit} x {l.UnitPrice:N0}", $"{lineTotal:N0}");
|
||||
}
|
||||
doc.Separator();
|
||||
doc.Add("К ВОЗВРАТУ", $"{total:N0} ₸", bold: true, big: true);
|
||||
if (refundCash > 0m) doc.Add("Наличными", $"{refundCash:N0}");
|
||||
if (refundCard > 0m) doc.Add("На карту", $"{refundCard:N0}");
|
||||
doc.Separator();
|
||||
return doc;
|
||||
}
|
||||
|
||||
private ReceiptDoc BuildReceipt(Cart cart, decimal paidCash, decimal paidCard, decimal change, bool isReturn)
|
||||
{
|
||||
var t = cart.Totals();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ public static int Run(string[] args)
|
|||
|
||||
var shots = new List<Shot>
|
||||
{
|
||||
new("01-login", "Вход кассира (адрес сервера, логин, PIN)", () => new LoginView { DataContext = LoginViewModel.Sample() }),
|
||||
new("01-login", "Вход кассира (адрес сервера, логин, пароль)", () => new LoginView { DataContext = LoginViewModel.Sample() }),
|
||||
new("01b-login-pin", "Быстрый вход по PIN (зарегистрированное устройство)", () => new LoginView { DataContext = LoginViewModel.SamplePin() }),
|
||||
new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }),
|
||||
new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }),
|
||||
new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }),
|
||||
|
|
|
|||
|
|
@ -137,60 +137,185 @@ 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 const string DeviceUrlKey = "device.serverUrl";
|
||||
private readonly AppServices? _svc;
|
||||
private PosSession? _pending; // сессия, ожидающая выбора точки
|
||||
private bool _canManagePoints;
|
||||
private Guid? _devicePoint;
|
||||
|
||||
[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();
|
||||
|
||||
// Быстрый вход по PIN (на зарегистрированном устройстве).
|
||||
[ObservableProperty] private bool _pinMode;
|
||||
[ObservableProperty] private bool _deviceRegistered;
|
||||
[ObservableProperty] private string _pinEntry = "";
|
||||
public string PinMasked => new string('●', PinEntry.Length);
|
||||
partial void OnPinEntryChanged(string value) => OnPropertyChanged(nameof(PinMasked));
|
||||
|
||||
public Action? OnLoggedIn;
|
||||
|
||||
public LoginViewModel() { }
|
||||
public LoginViewModel(AppServices svc) { _svc = svc; }
|
||||
public LoginViewModel(AppServices svc)
|
||||
{
|
||||
_svc = svc;
|
||||
_devicePoint = Guid.TryParse(svc.Store.GetKv(DevicePointKey), out var dp) ? dp : null;
|
||||
var savedUrl = svc.Store.GetKv(DeviceUrlKey);
|
||||
if (!string.IsNullOrEmpty(savedUrl)) ServerUrl = savedUrl;
|
||||
DeviceRegistered = _devicePoint is not null;
|
||||
PinMode = DeviceRegistered && !string.IsNullOrEmpty(savedUrl);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
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;
|
||||
OnLoggedIn?.Invoke();
|
||||
switch (decision)
|
||||
{
|
||||
case DeviceRegistration.Decision.Proceed:
|
||||
session.RetailPointId = devicePoint;
|
||||
_svc.Store.SetKv(DeviceUrlKey, ServerUrl.Trim()); // для быстрого входа по PIN
|
||||
_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());
|
||||
_svc.Store.SetKv(DeviceUrlKey, ServerUrl.Trim());
|
||||
_pending.RetailPointId = SelectedPoint.Id;
|
||||
_svc.Tokens.Save(_pending); _svc.Session = _pending;
|
||||
RequirePointSelection = false;
|
||||
OnLoggedIn?.Invoke();
|
||||
}
|
||||
|
||||
// ── Быстрый вход по PIN ───────────────────────────────────────────────
|
||||
[RelayCommand] private void PinDigit(string d) { if (PinEntry.Length < 6) PinEntry += d; }
|
||||
[RelayCommand] private void PinBack() { if (PinEntry.Length > 0) PinEntry = PinEntry[..^1]; }
|
||||
[RelayCommand] private void UsePassword() { PinMode = false; Error = null; }
|
||||
[RelayCommand] private void UsePin() { if (DeviceRegistered) { PinMode = true; Error = null; } }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PinLogin()
|
||||
{
|
||||
if (_svc is null || _devicePoint is null) { PinMode = false; return; }
|
||||
if (PinEntry.Length < 4) { Error = "Введите PIN (4–6 цифр)"; return; }
|
||||
Error = null; Busy = true;
|
||||
try
|
||||
{
|
||||
var t = await _svc.Auth.PinLoginAsync(ServerUrl.Trim(), _devicePoint.Value, PinEntry);
|
||||
var session = new PosSession { ServerUrl = ServerUrl.Trim(), AccessToken = t.AccessToken, RefreshToken = t.RefreshToken, RetailPointId = _devicePoint };
|
||||
_svc.ConfigureApi(session);
|
||||
try
|
||||
{
|
||||
var me = await _svc.Api!.MeAsync();
|
||||
session.CashierName = me.name ?? "кассир";
|
||||
session.Roles = me.roles;
|
||||
if (Guid.TryParse(me.sub, out var uid)) session.CashierUserId = uid;
|
||||
}
|
||||
catch { session.CashierName = "кассир"; }
|
||||
_svc.Tokens.Save(session); _svc.Session = session;
|
||||
OnLoggedIn?.Invoke();
|
||||
}
|
||||
catch (AuthException ex) { Error = ex.Message; PinEntry = ""; }
|
||||
catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; PinEntry = ""; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
/// <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 SamplePin() => new() { PinMode = true, DeviceRegistered = true, PinEntry = "1234" };
|
||||
}
|
||||
|
||||
public partial class SaleLineVm : ObservableObject
|
||||
|
|
@ -202,10 +327,29 @@ public partial class SaleLineVm : ObservableObject
|
|||
[ObservableProperty] private decimal _price;
|
||||
[ObservableProperty] private decimal _quantity = 1m;
|
||||
[ObservableProperty] private decimal _discount;
|
||||
public decimal LineTotal => Math.Max(0m, decimal.Round(Price * Quantity, 2) - Discount);
|
||||
public string QtyText => Quantity == decimal.Truncate(Quantity) && Unit == "шт" ? Quantity.ToString("0") : Quantity.ToString("0.###");
|
||||
partial void OnQuantityChanged(decimal value) { OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); }
|
||||
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(LineTotal));
|
||||
public decimal Gross => decimal.Round(Price * Quantity, 2);
|
||||
public decimal LineTotal => Math.Max(0m, Gross - Discount);
|
||||
/// <summary>Количество с единицей измерения: «3 шт», «1,235 кг».</summary>
|
||||
public string QtyText
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = Quantity == decimal.Truncate(Quantity) && Unit == "шт"
|
||||
? Quantity.ToString("0")
|
||||
: Quantity.ToString("0.###");
|
||||
return $"{n} {Unit}";
|
||||
}
|
||||
}
|
||||
public bool HasDiscount => Discount > 0m;
|
||||
/// <summary>Пояснение скидки в строке, чтобы цена×кол-во ≠ сумма не выглядело
|
||||
/// ошибкой: «1 560 → 1 500 ₸, скидка 60».</summary>
|
||||
public string DiscountText => $"{Gross:N0} → {LineTotal:N0} ₸, скидка {Discount:N0}";
|
||||
partial void OnQuantityChanged(decimal value)
|
||||
{ OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); OnPropertyChanged(nameof(Gross)); OnPropertyChanged(nameof(DiscountText)); }
|
||||
partial void OnDiscountChanged(decimal value)
|
||||
{ OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(HasDiscount)); OnPropertyChanged(nameof(DiscountText)); }
|
||||
partial void OnPriceChanged(decimal value)
|
||||
{ OnPropertyChanged(nameof(Gross)); OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(DiscountText)); }
|
||||
}
|
||||
|
||||
/// <summary>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,
|
||||
|
|
@ -510,34 +654,109 @@ private async Task DrawerAsync(int direction, decimal amount, string reason)
|
|||
public static ShiftViewModel SampleClosed() { var s = new ShiftViewModel { IsOpen = false }; return s; }
|
||||
}
|
||||
|
||||
/// <summary>Строка возврата: сколько продано/уже возвращено и сколько кассир
|
||||
/// выбирает к возврату (0..ReturnableQty).</summary>
|
||||
public partial class ReturnLineVm : ObservableObject
|
||||
{
|
||||
public required Guid ProductId { get; init; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _unit = "шт";
|
||||
[ObservableProperty] private decimal _unitPrice;
|
||||
[ObservableProperty] private decimal _soldQty;
|
||||
[ObservableProperty] private decimal _returnableQty;
|
||||
[ObservableProperty] private decimal _qty;
|
||||
public decimal LineRefund => decimal.Round(UnitPrice * Qty, 2);
|
||||
public string QtyText => Qty == decimal.Truncate(Qty) && Unit == "шт" ? Qty.ToString("0") : Qty.ToString("0.###");
|
||||
public string AvailText => $"доступно к возврату: {(ReturnableQty == decimal.Truncate(ReturnableQty) ? ReturnableQty.ToString("0") : ReturnableQty.ToString("0.###"))} {Unit}";
|
||||
partial void OnQtyChanged(decimal value) { OnPropertyChanged(nameof(LineRefund)); OnPropertyChanged(nameof(QtyText)); }
|
||||
}
|
||||
|
||||
/// <summary>Возврат по чеку: найти чек (номер/скан QR), отметить позиции и
|
||||
/// количество (не больше проданного минус уже возвращённого), сумма к выдаче,
|
||||
/// способ (нал/карта как в исходном чеке).</summary>
|
||||
public partial class ReturnViewModel : ViewModelBase
|
||||
{
|
||||
private readonly AppServices? _svc;
|
||||
[ObservableProperty] private string _receiptNumber = "";
|
||||
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
|
||||
[ObservableProperty] private bool _busy;
|
||||
public ObservableCollection<SaleLineVm> Lines { get; } = new();
|
||||
public decimal RefundTotal => Lines.Sum(l => l.LineTotal);
|
||||
[ObservableProperty] private bool _found;
|
||||
private Guid? _saleId;
|
||||
private int _payment; // способ оплаты исходного чека (0=нал,1=карта,…)
|
||||
|
||||
public ObservableCollection<ReturnLineVm> Lines { get; } = new();
|
||||
public decimal RefundTotal => Lines.Sum(l => l.LineRefund);
|
||||
public Action<CheckoutService.CommitResult>? ReturnCommitted;
|
||||
|
||||
public ReturnViewModel() { }
|
||||
public ReturnViewModel(AppServices svc) { _svc = svc; }
|
||||
|
||||
[RelayCommand]
|
||||
private Task Lookup()
|
||||
private async Task Lookup()
|
||||
{
|
||||
// Возврат по чеку: поиск чека-основания по номеру/QR. Серверный разбор
|
||||
// чека выполняется при проведении возврата; здесь — заготовка для
|
||||
// интерактивного выбора позиций (полный UI выбора — вне MVP-задачи).
|
||||
if (string.IsNullOrWhiteSpace(ReceiptNumber)) { Message = "Введите номер чека или отсканируйте QR"; return Task.CompletedTask; }
|
||||
Message = $"Чек {ReceiptNumber}: отметьте возвращаемые позиции и подтвердите возврат";
|
||||
return Task.CompletedTask;
|
||||
var number = ReceiptNumber.Trim();
|
||||
if (number.Length == 0) { Message = "Введите номер чека или отсканируйте QR"; return; }
|
||||
if (_svc?.Api is null) { Message = "Нет связи с сервером"; return; }
|
||||
Busy = true; Found = false; Lines.Clear();
|
||||
try
|
||||
{
|
||||
var r = await _svc.Api.ReturnLookupAsync(number);
|
||||
_saleId = r.SaleId; _payment = r.Payment;
|
||||
foreach (var l in r.Lines.Where(x => x.ReturnableQty > 0))
|
||||
Lines.Add(new ReturnLineVm
|
||||
{
|
||||
ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode,
|
||||
UnitPrice = l.UnitPrice, SoldQty = l.SoldQty, ReturnableQty = l.ReturnableQty, Qty = 0m,
|
||||
});
|
||||
OnPropertyChanged(nameof(RefundTotal));
|
||||
Found = Lines.Count > 0;
|
||||
Message = Found
|
||||
? $"Чек {r.Number} найден — отметьте позиции к возврату"
|
||||
: $"Чек {r.Number}: все позиции уже возвращены";
|
||||
}
|
||||
catch (Refit.ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{ Message = "Чек не найден или не проведён"; }
|
||||
catch (Exception ex) { Message = "Ошибка поиска: " + ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand] private void Inc(ReturnLineVm l) { if (l.Qty < l.ReturnableQty) { l.Qty = Math.Min(l.ReturnableQty, l.Qty + 1m); OnPropertyChanged(nameof(RefundTotal)); } }
|
||||
[RelayCommand] private void Dec(ReturnLineVm l) { if (l.Qty > 0) { l.Qty = Math.Max(0m, l.Qty - 1m); OnPropertyChanged(nameof(RefundTotal)); } }
|
||||
[RelayCommand] private void Full(ReturnLineVm l) { l.Qty = l.ReturnableQty; OnPropertyChanged(nameof(RefundTotal)); }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Submit()
|
||||
{
|
||||
if (_svc is null || _saleId is null) { Message = "Сначала найдите чек"; return; }
|
||||
var chosen = Lines.Where(l => l.Qty > 0m).ToList();
|
||||
if (chosen.Count == 0) { Message = "Отметьте хотя бы одну позицию к возврату"; return; }
|
||||
Busy = true;
|
||||
try
|
||||
{
|
||||
var lines = chosen.Select(l => new foodmarket.Shared.Pos.V1.PosSaleLineDto
|
||||
{ ProductId = l.ProductId, Quantity = l.Qty, UnitPrice = l.UnitPrice, Discount = 0m, VatPercent = 0m }).ToList();
|
||||
var display = chosen.Select(l => (l.Name, l.Unit)).ToList();
|
||||
var refund = RefundTotal;
|
||||
var toCard = _payment == 1; // карта как в исходном чеке, иначе наличные
|
||||
var res = await _svc.Checkout.CommitReturnAsync(lines, _saleId.Value,
|
||||
refundCash: toCard ? 0m : refund, refundCard: toCard ? refund : 0m, display);
|
||||
Message = res.PushedNow
|
||||
? $"Возврат оформлен — к выдаче {refund:N0} ₸"
|
||||
: $"Возврат в очереди — к выдаче {refund:N0} ₸ (отправится при связи)";
|
||||
Lines.Clear(); Found = false; _saleId = null; ReceiptNumber = "";
|
||||
OnPropertyChanged(nameof(RefundTotal));
|
||||
ReturnCommitted?.Invoke(res);
|
||||
}
|
||||
catch (Exception ex) { Message = "Не удалось оформить возврат: " + ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
public static ReturnViewModel Sample()
|
||||
{
|
||||
var vm = new ReturnViewModel { ReceiptNumber = "Ч-2026-000128", Message = "Чек найден — отметьте возвращаемые позиции" };
|
||||
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 });
|
||||
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный»", Unit = "шт", Price = 280m, Quantity = 1 });
|
||||
var vm = new ReturnViewModel { ReceiptNumber = "ПР-2026-000128", Message = "Чек найден — отметьте позиции к возврату", Found = true };
|
||||
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", UnitPrice = 450m, SoldQty = 2, ReturnableQty = 2, Qty = 1 });
|
||||
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный» нарезной", Unit = "шт", UnitPrice = 280m, SoldQty = 1, ReturnableQty = 1, Qty = 1 });
|
||||
vm.Lines.Add(new ReturnLineVm { ProductId = Guid.NewGuid(), Name = "Яблоки «Апорт» (вес)", Unit = "кг", UnitPrice = 690m, SoldQty = 1.235m, ReturnableQty = 1.235m, Qty = 0 });
|
||||
return vm;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="FOOD MARKET" Foreground="#22c55e" FontWeight="Bold" FontSize="22" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Вход кассира" Foreground="#f8fafc" FontSize="18" HorizontalAlignment="Center"/>
|
||||
|
||||
<!-- Быстрый вход по PIN (зарегистрированное устройство) -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding PinMode}">
|
||||
<Border Background="#0b1220" CornerRadius="8" Height="56">
|
||||
<TextBlock Text="{Binding PinMasked}" Foreground="#f8fafc" FontSize="30" LetterSpacing="6" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" TextWrapping="Wrap" HorizontalAlignment="Center" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<Grid ColumnDefinitions="*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" >
|
||||
<Button Grid.Row="0" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="1" Command="{Binding PinDigitCommand}" CommandParameter="1"/>
|
||||
<Button Grid.Row="0" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="2" Command="{Binding PinDigitCommand}" CommandParameter="2"/>
|
||||
<Button Grid.Row="0" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="3" Command="{Binding PinDigitCommand}" CommandParameter="3"/>
|
||||
<Button Grid.Row="1" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="4" Command="{Binding PinDigitCommand}" CommandParameter="4"/>
|
||||
<Button Grid.Row="1" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="5" Command="{Binding PinDigitCommand}" CommandParameter="5"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="6" Command="{Binding PinDigitCommand}" CommandParameter="6"/>
|
||||
<Button Grid.Row="2" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="7" Command="{Binding PinDigitCommand}" CommandParameter="7"/>
|
||||
<Button Grid.Row="2" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="8" Command="{Binding PinDigitCommand}" CommandParameter="8"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="9" Command="{Binding PinDigitCommand}" CommandParameter="9"/>
|
||||
<Button Grid.Row="3" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="18" Content="⌫" Command="{Binding PinBackCommand}"/>
|
||||
<Button Grid.Row="3" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="0" Command="{Binding PinDigitCommand}" CommandParameter="0"/>
|
||||
<Button Grid.Row="3" Grid.Column="2" Classes="pos primary" Margin="3" Height="56" FontSize="18" Content="Войти" Command="{Binding PinLoginCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</Grid>
|
||||
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Вход по логину и паролю" Command="{Binding UsePasswordCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="14" IsVisible="{Binding !PinMode}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Адрес сервера" Foreground="#94a3b8"/>
|
||||
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="16"/>
|
||||
|
|
@ -20,8 +45,22 @@
|
|||
<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" Content="Быстрый вход по PIN" Command="{Binding UsePinCommand}" IsVisible="{Binding DeviceRegistered}"/>
|
||||
|
||||
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Command="{Binding ResetDeviceCommand}" ToolTip.Tip="Сбросить регистрацию устройства. Новую точку выберет только администратор.">
|
||||
<TextBlock Text="Сменить точку продаж (админ)" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@
|
|||
</Border>
|
||||
<Grid ColumnDefinitions="*,2*">
|
||||
<Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/>
|
||||
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="20" Content="Enter · Провести и напечатать чек" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}"/>
|
||||
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="19" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}">
|
||||
<TextBlock Text="Enter · Провести и печать" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/>
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -6,31 +6,50 @@
|
|||
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
|
||||
<StackPanel Grid.Row="0" Spacing="8">
|
||||
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
|
||||
<Grid ColumnDefinitions="*,160">
|
||||
<TextBox Classes="pos" Watermark="Номер чека или скан QR" Text="{Binding ReceiptNumber}"/>
|
||||
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}" IsDefault="True"/>
|
||||
<Grid ColumnDefinitions="*,170">
|
||||
<TextBox Classes="pos" Watermark="Номер чека или скан QR (Enter)" Text="{Binding ReceiptNumber}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding LookupCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Message}" Foreground="#60a5fa"/>
|
||||
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
|
||||
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SaleLineVm">
|
||||
<Grid ColumnDefinitions="Auto,*,120,120" Height="46">
|
||||
<CheckBox IsChecked="True" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Quantity, StringFormat='{}{0:0.###}'}" Foreground="#e5e7eb" HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fff" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<DataTemplate x:DataType="vm:ReturnLineVm">
|
||||
<Grid ColumnDefinitions="*,190,110,120" MinHeight="58">
|
||||
<StackPanel VerticalAlignment="Center" Margin="4,6">
|
||||
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding AvailText}" Foreground="#94a3b8" FontSize="12.5"/>
|
||||
</StackPanel>
|
||||
<!-- Степпер количества к возврату -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="−"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).DecCommand}" CommandParameter="{Binding}"/>
|
||||
<Border Background="#0b1220" CornerRadius="6" Width="58" Height="40">
|
||||
<TextBlock Text="{Binding QtyText}" Foreground="#f8fafc" FontSize="17" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="+"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).IncCommand}" CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="pos" Height="40" FontSize="13" Margin="6,0,0,0" Content="Всё"
|
||||
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).FullCommand}" CommandParameter="{Binding}"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding LineRefund, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontWeight="SemiBold" FontSize="17" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
|
||||
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="24" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<Button Classes="pos danger" Height="56" Content="Оформить возврат"/>
|
||||
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="26" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<Button Classes="pos danger" Height="56" Width="230" Content="Оформить возврат" Command="{Binding SubmitCommand}" IsEnabled="{Binding Found}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,90,120,120" Background="#1f2937" Height="38">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,110,110,120" Background="#1f2937" Height="38">
|
||||
<TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
|
||||
<TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
|
||||
|
|
@ -28,9 +28,11 @@
|
|||
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SaleLineVm">
|
||||
<Grid ColumnDefinitions="*,90,120,120" Height="46">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16"/>
|
||||
<Grid ColumnDefinitions="*,110,110,120" MinHeight="46">
|
||||
<StackPanel VerticalAlignment="Center" Margin="0,4">
|
||||
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="{Binding DiscountText}" Foreground="#fca5a5" FontSize="12.5"
|
||||
IsVisible="{Binding HasDiscount}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
||||
|
|
@ -59,10 +61,16 @@
|
|||
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/>
|
||||
</Grid>
|
||||
<StackPanel Grid.Row="4" Spacing="8">
|
||||
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Content="F12 · ОПЛАТА"/>
|
||||
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Command="{Binding PayCommand}">
|
||||
<TextBlock Text="F12 · ОПЛАТА" TextAlignment="Center"/>
|
||||
</Button>
|
||||
<Grid ColumnDefinitions="*,*" >
|
||||
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Скидка"/>
|
||||
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Esc · Очистить" Command="{Binding ClearCommand}"/>
|
||||
<Button Classes="pos" Margin="0,0,4,0" FontSize="16" HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="Скидка" TextAlignment="Center"/>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" FontSize="16" HorizontalAlignment="Stretch" Command="{Binding ClearCommand}" ToolTip.Tip="Очистить чек (Esc)">
|
||||
<TextBlock Text="Очистить" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -183,3 +183,25 @@ public record PosSaleFailedDto
|
|||
public required string Error { get; init; }
|
||||
public string? Field { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Чек-основание для возврата по номеру/QR: шапка + позиции с
|
||||
/// количеством, доступным к возврату (продано − уже возвращено).</summary>
|
||||
public record PosReturnLookupDto
|
||||
{
|
||||
public required Guid SaleId { get; init; }
|
||||
public required string Number { get; init; }
|
||||
public required DateTime Date { get; init; }
|
||||
public required int Payment { get; init; } // PaymentMethod исходного чека
|
||||
public required IReadOnlyList<PosReturnLineDto> Lines { get; init; }
|
||||
}
|
||||
|
||||
public record PosReturnLineDto
|
||||
{
|
||||
public required Guid ProductId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string UnitCode { get; init; }
|
||||
public required decimal UnitPrice { get; init; }
|
||||
public required decimal SoldQty { get; init; }
|
||||
public required decimal ReturnedQty { get; init; }
|
||||
public required decimal ReturnableQty { get; init; } // SoldQty − ReturnedQty
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
121
tests/food-market.IntegrationTests/PinLoginTests.cs
Normal file
121
tests/food-market.IntegrationTests/PinLoginTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
/// <summary>Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном
|
||||
/// устройстве: PIN задаёт админ, касса входит по (точка + PIN); 5 неверных → блок.</summary>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class PinLoginTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PinLoginTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private async Task<HttpResponseMessage> PinLoginAsync(HttpClient client, string pointId, string pin)
|
||||
=> await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "pin", ["client_id"] = "food-market-web",
|
||||
["retail_point_id"] = pointId, ["pin"] = pin,
|
||||
["scope"] = "openid profile email roles api offline_access",
|
||||
}));
|
||||
|
||||
[Fact]
|
||||
public async Task Cashier_logs_in_by_pin_on_assigned_point()
|
||||
{
|
||||
var admin = new ApiActor(_factory.CreateClient());
|
||||
await admin.SignupAndLoginAsync($"pin-{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 = $"pin-c-{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 empId = (await empResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString();
|
||||
|
||||
// Админ задаёт PIN.
|
||||
var setPin = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "4321" });
|
||||
setPin.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Вход по PIN (точка + PIN) → токен.
|
||||
var anon = _factory.CreateClient();
|
||||
using var ok = await PinLoginAsync(anon, point, "4321");
|
||||
ok.StatusCode.Should().Be(HttpStatusCode.OK, await ok.Content.ReadAsStringAsync());
|
||||
var token = (await ok.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString();
|
||||
token.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Токен рабочий: /api/me отдаёт точку кассира.
|
||||
anon.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
var me = await anon.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).Should().Contain(point);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_pin_is_rejected_and_locks_after_five()
|
||||
{
|
||||
var admin = new ApiActor(_factory.CreateClient());
|
||||
await admin.SignupAndLoginAsync($"pinlock-{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 { cashShiftOperate = true } });
|
||||
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
var email = $"pinlock-c-{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 } });
|
||||
var empId = (await empResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString();
|
||||
(await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "1111" })).EnsureSuccessStatusCode();
|
||||
|
||||
var anon = _factory.CreateClient();
|
||||
// 5 неверных попыток.
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
using var bad = await PinLoginAsync(anon, point, "0000");
|
||||
bad.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
// Теперь точка заблокирована: даже верный PIN не проходит.
|
||||
using var locked = await PinLoginAsync(anon, point, "1111");
|
||||
locked.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
(await locked.Content.ReadAsStringAsync()).Should().Contain("pin_locked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_pin_on_same_point_is_rejected()
|
||||
{
|
||||
var admin = new ApiActor(_factory.CreateClient());
|
||||
await admin.SignupAndLoginAsync($"pindup-{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 { cashShiftOperate = true } });
|
||||
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
|
||||
async Task<string> MakeEmp(string ln)
|
||||
{
|
||||
var r = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
|
||||
{ lastName = ln, firstName = "П", roleId, isActive = true, createAccount = true, email = $"dup-{Guid.NewGuid():N}@example.kz", retailPointIds = new[] { point } });
|
||||
r.EnsureSuccessStatusCode();
|
||||
return (await r.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString()!;
|
||||
}
|
||||
var e1 = await MakeEmp("Первый");
|
||||
var e2 = await MakeEmp("Второй");
|
||||
(await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e1}/pin", new { pin = "2468" })).EnsureSuccessStatusCode();
|
||||
using var dup = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e2}/pin", new { pin = "2468" });
|
||||
dup.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||
}
|
||||
}
|
||||
|
|
@ -304,6 +304,66 @@ private static async Task<string> OpenShiftAsync(ApiActor api, string retailPoin
|
|||
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>Возврат по чеку через кассу: lookup by-number отдаёт доступное к
|
||||
/// возврату; POS-возврат уважает остаток (нельзя вернуть больше проданного
|
||||
/// минус уже возвращённого) и инкрементит QtyReturned.</summary>
|
||||
[Fact]
|
||||
public async Task Pos_return_by_receipt_respects_returnable_quantity()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-retfull-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
await SeedStockAsync(api, refs, p1, 10m);
|
||||
|
||||
// Продажа 3 шт через кассу.
|
||||
var saleBatch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 3m, 100m) } };
|
||||
var saleResp = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", saleBatch)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
saleResp!.Accepted.Should().HaveCount(1);
|
||||
var refId = saleResp.Accepted[0].ServerSaleId;
|
||||
var number = saleResp.Accepted[0].ServerSaleNumber;
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(7m);
|
||||
|
||||
// Lookup по номеру → доступно к возврату 3.
|
||||
var look = await api.Http.GetFromJsonAsync<PosReturnLookupDto>($"/api/pos/v1/sales/by-number/{number}");
|
||||
look!.Lines.Should().ContainSingle();
|
||||
look.Lines[0].SoldQty.Should().Be(3m);
|
||||
look.Lines[0].ReturnedQty.Should().Be(0m);
|
||||
look.Lines[0].ReturnableQty.Should().Be(3m);
|
||||
|
||||
// Возврат 2 шт → принят, остаток +2 = 9.
|
||||
var ret1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 2m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } };
|
||||
(await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", ret1)).Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
|
||||
|
||||
// Теперь доступно к возврату 1.
|
||||
var look2 = await api.Http.GetFromJsonAsync<PosReturnLookupDto>($"/api/pos/v1/sales/by-number/{number}");
|
||||
look2!.Lines[0].ReturnedQty.Should().Be(2m);
|
||||
look2.Lines[0].ReturnableQty.Should().Be(1m);
|
||||
|
||||
// Попытка вернуть ещё 2 (over-return) → в Failed, остаток не меняется.
|
||||
var retOver = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 2m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } };
|
||||
var overResp = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", retOver)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
overResp!.Accepted.Should().BeEmpty();
|
||||
overResp.Failed.Should().ContainSingle();
|
||||
overResp.Failed[0].Error.Should().Contain("больше, чем доступно");
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
|
||||
|
||||
// Возврат оставшейся 1 → принят, остаток 10.
|
||||
var ret2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { MakeSale(p1, 1m, 100m) with { IsReturn = true, ReferenceSaleId = refId } } };
|
||||
(await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", ret2)).Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pos_return_lookup_unknown_number_is_404()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-ret404-{Guid.NewGuid():N}");
|
||||
using var resp = await api.Http.GetAsync("/api/pos/v1/sales/by-number/ПР-2000-999999");
|
||||
resp.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
|
||||
{
|
||||
ClientSaleId = Guid.NewGuid(),
|
||||
|
|
|
|||
40
tests/food-market.UnitTests/PinHasherTests.cs
Normal file
40
tests/food-market.UnitTests/PinHasherTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using FluentAssertions;
|
||||
using foodmarket.Application.Common;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.UnitTests;
|
||||
|
||||
public class PinHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_then_verify_roundtrips()
|
||||
{
|
||||
var hash = PinHasher.Hash("1234");
|
||||
PinHasher.Verify("1234", hash).Should().BeTrue();
|
||||
PinHasher.Verify("1235", hash).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_is_salted_different_each_time()
|
||||
{
|
||||
PinHasher.Hash("4321").Should().NotBe(PinHasher.Hash("4321"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_handles_null_and_garbage()
|
||||
{
|
||||
PinHasher.Verify("1234", null).Should().BeFalse();
|
||||
PinHasher.Verify("1234", "").Should().BeFalse();
|
||||
PinHasher.Verify("1234", "not-a-hash").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1234", true)]
|
||||
[InlineData("123456", true)]
|
||||
[InlineData("123", false)]
|
||||
[InlineData("1234567", false)]
|
||||
[InlineData("12a4", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsValidPin_rules(string? pin, bool expected)
|
||||
=> PinHasher.IsValidPin(pin).Should().Be(expected);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -222,6 +223,7 @@ public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, Cancel
|
|||
}
|
||||
|
||||
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<PosReturnLookupDto> ReturnLookupAsync(string number, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<MeDto> MeAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<PagedDto<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
|
|
@ -403,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