Compare commits

..

4 commits

Author SHA1 Message Date
nns 6b959ac59d feat(pos): быстрый вход кассира по PIN (grant_type=pin) + lockout
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
- Сервер: Employee.PinHash (PBKDF2, миграция Phase36); POST
  /api/organization/employees/{id}/pin — админ задаёт/снимает PIN (4–6 цифр,
  уникален среди активных сотрудников на пересекающихся точках). Кастомный
  OpenIddict-грант grant_type=pin (retail_point_id + pin): находит кассира,
  привязанного к точке, по PIN и выдаёт токены. Брутфорс-защита — in-memory по
  точке (5 неверных → блок 5 мин). Клиент-сидер досоздаёт gt:pin существующим
  клиентам (без пересоздания). Пароль остаётся запасным.
- Касса: PIN-пад на зарегистрированном устройстве (маскированный ввод, 0–9/⌫/
  Войти), переключение на вход по логину; AuthService.PinLoginAsync.
- Тесты: unit PinHasher (9), integration (3): вход по PIN выдаёт рабочий токен;
  5 неверных → блок; дубль PIN на точке → 409. + скриншот PIN-пада в галерее.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:38:36 +05:00
nns ff69dec527 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>
2026-10-08 09:24:51 +05:00
nns 01b47cc0c8 feat(pos): возврат по чеку — экран выбора позиций + защита от over-return
- Сервер: GET /api/pos/v1/sales/by-number/{number} — чек-основание с позициями
  и доступным к возврату (продано − уже возвращено, по QtyReturned). POS-путь
  возврата теперь валидирует over-return (нельзя вернуть больше остатка) и
  инкрементит QtyReturned на исходных строках (как RetailSalesController).
- Контракт: PosReturnLookupDto/PosReturnLineDto; IServerApi.ReturnLookupAsync.
- Касса: экран возврата — найти чек по номеру/скану QR, степпер количества по
  каждой позиции (0..доступно, кнопка «Всё»), сумма к выдаче, способ (нал/карта
  как в исходном чеке); CheckoutService.CommitReturnAsync пишет возврат в офлайн-
  очередь + печать + выгрузка.
- Тесты: integration — lookup by-number, возврат 2 из 3, over-return в Failed,
  довозврат 1, QtyReturned/остатки корректны; 404 на неизвестный номер. 11/11 PosSyncTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:16:48 +05:00
nns c5ff1ade94 fix(pos-ui): формат чисел KZ/RU, скидка в строке, центровка/обрезка кнопок
По итогам проверки Mac-Claude (скриншоты):
- Формат чисел как в вебе/мобильном: «3 422 ₸» (пробел-разделитель тысяч),
  вес «1,235 кг» (запятая), единица измерения рядом с количеством («3 шт»).
  PosFormat — культура ru-RU с NBSP-разделителем, применяется процессно.
- Скидка на строку видна: «1 560 → 1 500 ₸, скидка 60» под позицией, чтобы
  цена×кол-во ≠ сумма не выглядело ошибкой (SaleLineVm.DiscountText/HasDiscount).
- Кнопки: текст по центру по вертикали и горизонтали (стиль Button.pos +
  ContentPresenter); «Очистить» и «Enter · Провести и печать» больше не
  обрезаются на 1366×768 и 1920×1080 (проверено скриншотами).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:07:29 +05:00
28 changed files with 1160 additions and 92 deletions

View file

@ -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).

View file

@ -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 удалена / архивирована) —

View file

@ -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)
{

View file

@ -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

View file

@ -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();

View file

@ -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;

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

View file

@ -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'ам

View file

@ -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"";");
}
}

View file

@ -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>

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

@ -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);

View file

@ -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" />

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

View file

@ -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).

View file

@ -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();

View file

@ -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() }),

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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
}

View file

@ -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);
}
}

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

View file

@ -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(),

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

View file

@ -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);
}