food-market/src/food-market.pos/ViewModels/ViewModels.cs
nns d0d6ebcea1 rebrand: касса ALL MARKET (лого, иконка, заголовок, чек)
- Верхняя полоса и экран входа: логотип-изображение ALL MARKET из ресурсов
  (avares Assets/), color на светлой теме и white на тёмной (Brand.ApplyLogo).
- Заголовок окна «ALL MARKET · Касса»; иконка окна и ApplicationIcon = allmarket.ico
  (собрана из фирменных PNG 32/180/192).
- Чек: в шапке — название точки/магазина (резолвится из RetailPoint, PosSession.StoreName),
  внизу мелко «ALL MARKET — система учёта торговли» (был хардкод «Food Market» в шапке).
- AppImage .desktop: Name «ALL MARKET Касса» + фирменная иконка.
- Эталон-макет pos-mockup.html — логотип «all market».
- AssemblyName/exe (foodmarket.Pos) и имена артефактов не менялись (инфраструктура).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:35:05 +05:00

884 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Logic;
using foodmarket.Pos.Core.Models;
using foodmarket.Pos.Core.Sync;
using foodmarket.Pos.Services;
namespace foodmarket.Pos.ViewModels;
public abstract partial class ViewModelBase : ObservableObject { }
/// <summary>Оболочка: статус-бар (касса/смена/сеть/очередь), текущая страница,
/// модальная оплата, горячие клавиши и фоновая синхронизация. Логин-гейт:
/// без валидной сессии показывается экран входа.</summary>
public partial class MainWindowViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private SaleViewModel? _sale;
private System.Timers.Timer? _syncTimer;
[ObservableProperty] private object? _currentPage;
[ObservableProperty] private string _cashierName = "—";
[ObservableProperty] private string _shiftNumber = "смена не открыта";
[ObservableProperty] private bool _isShiftOpen;
[ObservableProperty] private string _storeName = "Магазин";
[ObservableProperty] private string _pointName = "Касса";
[ObservableProperty] private bool _isOnline = true;
[ObservableProperty] private int _queueCount;
[ObservableProperty] private PaymentViewModel? _payment;
[ObservableProperty] private bool _isPaymentOpen;
[ObservableProperty] private string? _updateMessage;
/// <summary>Узкая шапка: сокращать тексты чипов (устанавливается из code-behind по ширине окна).</summary>
[ObservableProperty] private bool _badgesCompact;
public string NetBadge => IsOnline ? "На связи" : "Нет связи";
public string ShiftBadge => IsShiftOpen
? (BadgesCompact ? "Смена открыта" : $"Смена {ShiftNumber}")
: "Смена закрыта";
public string QueueBadge => BadgesCompact ? "" : (QueueCount > 0 ? $"· {QueueCount} в очереди" : "· очередь пуста");
partial void OnIsOnlineChanged(bool value) { OnPropertyChanged(nameof(NetBadge)); }
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge));
partial void OnShiftNumberChanged(string value) => OnPropertyChanged(nameof(ShiftBadge));
partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); }
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Демо-магазин"; PointName = "Касса 1"; }
public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); }
private void ShowLogin()
{
CashierName = "—"; ShiftNumber = "вход…";
CurrentPage = new LoginViewModel(_svc!) { OnLoggedIn = StartSession };
}
private void StartSession()
{
CashierName = _svc!.Session?.CashierName ?? "кассир";
QueueCount = _svc.Store.PendingCount();
_ = ResolvePointNameAsync();
_sale = new SaleViewModel(_svc)
{
PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus(),
NavReturnRequested = () => ShowReturnCommand.Execute(null),
NavShiftRequested = () => ShowShiftCommand.Execute(null),
};
CurrentPage = _sale;
StartSyncLoop();
_ = CheckForUpdateAsync();
}
/// <summary>Резолвит название точки продаж (для шапки чека и верхней полосы).</summary>
private async Task ResolvePointNameAsync()
{
var pid = _svc?.Session?.RetailPointId;
if (_svc?.Api is null || pid is null) return;
try
{
var points = await _svc.Api.RetailPointsAsync();
var name = points.items.FirstOrDefault(p => p.id == pid.Value)?.name;
if (!string.IsNullOrWhiteSpace(name))
{
PointName = name;
StoreName = name;
if (_svc.Session is not null) _svc.Session.StoreName = name;
}
}
catch { /* офлайн — оставляем значения по умолчанию */ }
}
private async Task CheckForUpdateAsync()
{
if (_svc?.Api is null) return;
try
{
var info = await _svc.Api.VersionAsync();
var local = LocalVersion();
if (UpdateCheck.IsNewer(local, info.Version))
{
var url = OperatingSystem.IsWindows() ? info.WindowsUrl : info.LinuxUrl;
UpdateMessage = $"Доступно обновление {info.Version}" + (string.IsNullOrEmpty(url) ? "" : $" — {url}");
}
}
catch { /* проверка версии не критична */ }
}
private static string LocalVersion()
{
try
{
var f = Path.Combine(AppContext.BaseDirectory, "VERSION.txt");
if (File.Exists(f)) return File.ReadAllText(f).Trim();
}
catch { }
return System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0";
}
private void RefreshStatus() { if (_svc is not null) QueueCount = _svc.Store.PendingCount(); }
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
/// <summary>Переключить светлую/тёмную тему (тёмная — опция, по умолчанию светлая).
/// Выбор сохраняется на устройстве.</summary>
[RelayCommand]
private void ToggleTheme()
{
var app = Avalonia.Application.Current;
if (app is null) return;
var dark = app.ActualThemeVariant != Avalonia.Styling.ThemeVariant.Dark;
app.RequestedThemeVariant = dark ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light;
_svc?.Store.SetKv("device.theme", dark ? "Dark" : "Light");
}
private void OpenPayment(PaymentViewModel pvm)
{
pvm.OnCancel = ClosePayment;
var confirm = pvm.OnConfirm;
pvm.OnConfirm = async (cash, card, change) => { if (confirm is not null) await confirm(cash, card, change); ClosePayment(); };
Payment = pvm; IsPaymentOpen = true;
}
private void ClosePayment() { IsPaymentOpen = false; Payment = null; }
/// <summary>Горячие клавиши (работа без мыши). Вызывается из code-behind окна.</summary>
public void HandleHotkey(string key)
{
switch (key)
{
case "F2": if (_sale is not null) _sale.Message = "Введите название или штрихкод"; break;
case "F7": case "F10": ShowShiftCommand.Execute(null); break;
case "F9": ShowReturnCommand.Execute(null); break;
case "F12": if (IsPaymentOpen) Payment?.ConfirmCommand.Execute(null); else _sale?.PayCommand.Execute(null); break;
case "Delete": _sale?.RemoveSelectedCommand.Execute(null); break;
case "Escape": if (IsPaymentOpen) ClosePayment(); else _sale?.ClearCommand.Execute(null); break;
}
}
private void StartSyncLoop()
{
if (_svc?.Sync is null) return;
_syncTimer = new System.Timers.Timer(15_000) { AutoReset = true };
_syncTimer.Elapsed += async (_, _) => await SyncTickAsync();
_syncTimer.Start();
_ = SyncTickAsync();
}
private async Task SyncTickAsync()
{
if (_svc?.Sync is null) return;
try
{
await _svc.Sync.PushSalesAsync();
await _svc.Sync.PullCatalogAsync();
IsOnline = true;
}
catch { IsOnline = false; }
finally { QueueCount = _svc.Store.PendingCount(); }
}
}
/// <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;
_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; 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(); }
// Точка продаж, на которой зарегистрировано это устройство.
Guid? devicePoint = Guid.TryParse(_svc.Store.GetKv(DevicePointKey), out var dp) ? dp : null;
var decision = DeviceRegistration.Evaluate(devicePoint, _canManagePoints, assigned);
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
{
internal CartLine? Model;
public required Guid ProductId { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _unit = "шт";
[ObservableProperty] private decimal _price;
[ObservableProperty] private decimal _quantity = 1m;
[ObservableProperty] private decimal _discount;
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>Подпись цены за единицу: «280 ₸ за шт», «690 ₸ за кг».</summary>
public string UnitPriceText => $"{Price:N0} ₸ за {Unit}";
/// <summary>Строка со скидкой: «520 ₸ за шт · скидка 60 ₸ (1 560 → 1 500 ₸)».</summary>
public string DiscountText => $"{Price:N0} ₸ за {Unit} · скидка {Discount:N0} ₸ ({Gross:N0} → {LineTotal: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>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,
/// итоги, скидка, оплата. Горячие клавиши назначены в оболочке/View.</summary>
public partial class SaleViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private readonly Cart _cart = new();
private readonly Stack<CartSnapshot> _parked = new();
public ObservableCollection<SaleLineVm> Lines { get; } = new();
public ObservableCollection<PosProduct> Favorites { get; } = new();
[ObservableProperty] private bool _favoritesOpen = true;
public bool HasFavorites => Favorites.Count > 0;
[ObservableProperty] private string _scanInput = "";
[ObservableProperty] private string _shiftNumber = "С-2026-000001";
[ObservableProperty] private string _message = "Отсканируйте товар или введите штрихкод";
[ObservableProperty] private decimal _subtotal;
[ObservableProperty] private decimal _discount;
[ObservableProperty] private decimal _total;
[ObservableProperty] private int _itemCount;
[ObservableProperty] private SaleLineVm? _selectedLine;
public bool IsCartEmpty => ItemCount == 0;
public bool HasAnyDiscount => Discount > 0m;
// Блок «Последний чек / Чеков за смену» (информационный, заполняет панель).
[ObservableProperty] private string _lastReceiptNumber = "—";
[ObservableProperty] private string _lastReceiptInfo = "—";
[ObservableProperty] private int _receiptsInShift;
partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty));
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount));
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
public Action<CheckoutService.CommitResult>? SaleCommitted;
/// <summary>Переходы к возврату/смене (обрабатывает оболочка — те же, что F9/F7/F10).</summary>
public Action? NavReturnRequested;
public Action? NavShiftRequested;
[RelayCommand] private void GoReturn() => NavReturnRequested?.Invoke();
[RelayCommand] private void GoShift() => NavShiftRequested?.Invoke();
public SaleViewModel() { }
public SaleViewModel(AppServices svc) { _svc = svc; LoadFavorites(); }
private void LoadFavorites()
{
Favorites.Clear();
if (_svc is not null) foreach (var p in _svc.Store.GetFavorites()) Favorites.Add(p);
OnPropertyChanged(nameof(HasFavorites));
}
[RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m);
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
private void Recalc()
{
var t = _cart.Totals();
Subtotal = t.Subtotal; Discount = t.LineDiscounts + t.CartDiscount; Total = t.Total; ItemCount = t.LineCount;
}
public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null)
{
var line = _cart.AddProduct(p, qty, priceOverride);
var vm = Lines.FirstOrDefault(l => ReferenceEquals(l.Model, line));
if (vm is not null) { vm.Quantity = line.Quantity; }
else
{
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitCode, Price = line.UnitPrice, Quantity = line.Quantity, Model = line };
Lines.Insert(0, vm);
}
SelectedLine = vm;
Recalc();
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
}
[RelayCommand]
private void Scan()
{
var raw = ScanInput.Trim();
ScanInput = "";
if (raw.Length == 0) return;
var parsed = BarcodeParser.Parse(raw);
var p = _svc?.Store.FindByBarcode(parsed.Lookup);
if (p is null) { Message = $"Штрихкод {parsed.Lookup} не найден"; return; }
var qty = parsed.IsWeighted && parsed.EmbeddedWeight is { } w ? w : 1m;
var price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
AddProduct(p, qty, price);
}
[RelayCommand]
private void RemoveSelected()
{
if (SelectedLine is null) return;
if (SelectedLine.Model is { } m) _cart.Remove(m);
Lines.Remove(SelectedLine);
SelectedLine = Lines.FirstOrDefault();
Recalc();
Message = "Позиция удалена";
}
public void SetLineQuantity(SaleLineVm line, decimal qty)
{
if (line.Model is null) return;
_cart.SetQuantity(line.Model, qty);
if (qty <= 0) { Lines.Remove(line); }
else line.Quantity = line.Model.Quantity;
Recalc();
}
[RelayCommand] private void IncLine(SaleLineVm line) { if (line.Model is { } m) SetLineQuantity(line, m.Quantity + 1m); }
[RelayCommand] private void DecLine(SaleLineVm line) { if (line.Model is { } m) SetLineQuantity(line, m.Quantity - 1m); }
[RelayCommand]
private void RemoveLine(SaleLineVm line)
{
if (line.Model is { } m) _cart.Remove(m);
Lines.Remove(line);
if (ReferenceEquals(SelectedLine, line)) SelectedLine = Lines.FirstOrDefault();
Recalc();
}
public void SetCartDiscount(decimal amount) { _cart.SetCartDiscount(amount); Recalc(); Message = $"Скидка на чек: {amount:N0} ₸"; }
[RelayCommand]
private void Pay()
{
if (_cart.IsEmpty) { Message = "Чек пуст — нечего оплачивать"; return; }
if (_svc is not null && !_svc.Checkout.HasOpenShift) { Message = "Смена не открыта — F10, откройте смену"; return; }
var pvm = new PaymentViewModel { Total = Total };
pvm.OnConfirm = (cash, card, change) => CommitPaymentAsync(cash, card, change);
if (PaymentRequested is not null) PaymentRequested(pvm);
else _ = CommitPaymentAsync(Total, 0m, 0m); // без оболочки (тест/демо) — оплата наличными «под расчёт»
}
/// <summary>Провести оплату: офлайн-устойчивая запись чека + печать + попытка
/// отправки. Очищает корзину.</summary>
public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change)
{
if (_cart.IsEmpty) return;
if (_svc is null)
{
_cart.Clear(); Lines.Clear(); Recalc(); Message = "Оплата проведена (демо)"; return;
}
var res = await _svc.Checkout.CommitAsync(_cart, cash, card, change);
_cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc();
Message = res.PushedNow ? "Чек проведён и отправлен на сервер" : $"Чек проведён (в очереди: {res.PendingAfter})";
if (!res.Printed && res.PrintError is not null) Message += " · печать не удалась";
SaleCommitted?.Invoke(res);
}
[RelayCommand] private void Clear() { _cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc(); Message = "Чек очищен"; }
private int _demoParked; // только для скриншотов/демо (Sample)
public int ParkedCount => _parked.Count + _demoParked;
public bool HasParked => ParkedCount > 0;
/// <summary>Отложить текущий чек и начать новый (обслужить другого покупателя).</summary>
[RelayCommand]
private void Park()
{
if (_cart.IsEmpty) { Message = "Нечего откладывать"; return; }
_parked.Push(_cart.Snapshot());
_cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc();
OnPropertyChanged(nameof(ParkedCount));
Message = $"Чек отложен (отложенных: {_parked.Count})";
}
/// <summary>Вернуть последний отложенный чек (поверх пустого).</summary>
[RelayCommand]
private void Resume()
{
if (_parked.Count == 0) { Message = "Нет отложенных чеков"; return; }
if (!_cart.IsEmpty) { Message = "Сначала завершите или отложите текущий чек"; return; }
_cart.Restore(_parked.Pop());
RebuildLines();
OnPropertyChanged(nameof(ParkedCount));
Message = "Отложенный чек возвращён";
}
private void RebuildLines()
{
Lines.Clear();
foreach (var l in _cart.Lines)
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, Model = l });
SelectedLine = Lines.FirstOrDefault();
Recalc();
}
/// <summary>Пример заполненного чека для скриншотов/демо.</summary>
public static SaleViewModel Sample()
{
var vm = new SaleViewModel();
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный» нарезной", Unit = "шт", Price = 280m, Quantity = 2 });
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 = 690m, Quantity = 1.235m });
vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Сахар-песок 1кг", Unit = "шт", Price = 520m, Quantity = 3, Discount = 60m });
vm.Subtotal = vm.Lines.Sum(l => l.Price * l.Quantity);
vm.Discount = 60m;
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
vm.ItemCount = vm.Lines.Count;
vm.Message = "Чек готов к оплате — F12 Оплата";
vm.SelectedLine = vm.Lines[3]; // выбранная строка (Сахар) — зелёный акцент
vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47;
vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные»)
PosProduct Fav(string n, string unit, decimal price, bool weighed) => new()
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
foreach (var f in new[] {
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false),
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),
Fav("Пакет-майка", "шт", 20m, false), Fav("Вода 0,5 л", "шт", 150m, false) })
vm.Favorites.Add(f);
return vm;
}
}
public partial class PaymentViewModel : ViewModelBase
{
[ObservableProperty] private decimal _total;
[ObservableProperty] private decimal _paidCash;
[ObservableProperty] private decimal _paidCard;
[ObservableProperty] private string _method = "Наличные";
public decimal Change => PaymentMath.Compute(Total, PaidCash, PaidCard).Change;
public decimal Shortfall => PaymentMath.Compute(Total, PaidCash, PaidCard).Shortfall;
public bool Sufficient => PaymentMath.Compute(Total, PaidCash, PaidCard).Sufficient;
public bool IsCash => Method == "Наличные";
public bool IsCard => Method == "Карта";
public bool IsMixed => Method == "Смешанная";
partial void OnPaidCashChanged(decimal value) => RaisePay();
partial void OnPaidCardChanged(decimal value) => RaisePay();
partial void OnTotalChanged(decimal value) => RaisePay();
partial void OnMethodChanged(string value) { OnPropertyChanged(nameof(IsCash)); OnPropertyChanged(nameof(IsCard)); OnPropertyChanged(nameof(IsMixed)); }
private void RaisePay() { OnPropertyChanged(nameof(Change)); OnPropertyChanged(nameof(Shortfall)); OnPropertyChanged(nameof(Sufficient)); }
/// <summary>Вызывается при подтверждении оплаты (cash, card, change).</summary>
public Func<decimal, decimal, decimal, Task>? OnConfirm;
public Action? OnCancel;
[RelayCommand] private void ExactCash() { PaidCash = Total; PaidCard = 0m; }
[RelayCommand] private void SelectCash() { Method = "Наличные"; PaidCard = 0m; }
[RelayCommand] private void SelectCard() { Method = "Карта"; PaidCard = Total; PaidCash = 0m; }
[RelayCommand] private void SelectMixed() { Method = "Смешанная"; }
/// <summary>Быстрая сумма наличных: «Без сдачи» (ровно) или номинал.</summary>
[RelayCommand] private void QuickCash(string amount)
{
if (amount == "exact") { PaidCash = Total; return; }
if (decimal.TryParse(amount, out var a)) PaidCash = a;
}
// Экранная цифровая клавиатура для наличных (целые тенге).
[RelayCommand] private void KeyDigit(string d) { if (int.TryParse(d, out var n)) PaidCash = PaidCash * 10m + n; }
[RelayCommand] private void KeyBack() { PaidCash = Math.Floor(PaidCash / 10m); }
[RelayCommand] private void KeyClear() { PaidCash = 0m; }
[RelayCommand]
private async Task Confirm()
{
if (!Sufficient) return;
if (OnConfirm is not null) await OnConfirm(PaidCash, PaidCard, Change);
}
[RelayCommand] private void Cancel() => OnCancel?.Invoke();
public static PaymentViewModel SampleCash() => new() { Total = 3362m, PaidCash = 5000m, Method = "Наличные" };
public static PaymentViewModel SampleMixed() => new() { Total = 3362m, PaidCash = 1362m, PaidCard = 2000m, Method = "Смешанная" };
}
/// <summary>Смена: открыть (размен) / закрыть (факт → должно/факт/разница),
/// внесение/выплата, X/Z-отчёты. Живые вызовы идут на серверный модуль «Деньги».</summary>
public partial class ShiftViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private bool _isOpen = true;
[ObservableProperty] private string _number = "С-2026-000001";
[ObservableProperty] private decimal _openingFloat = 5000m;
[ObservableProperty] private decimal _salesCash = 3775m;
[ObservableProperty] private decimal _salesCard = 2325m;
[ObservableProperty] private decimal _returnsCash = 1500m;
[ObservableProperty] private decimal _drawerIn = 10000m;
[ObservableProperty] private decimal _drawerOut = 3000m;
[ObservableProperty] private decimal _actualCash = 14775m;
[ObservableProperty] private string? _message;
[ObservableProperty] private bool _busy;
public decimal ExpectedCash => ShiftMath.ExpectedCash(OpeningFloat, SalesCash, ReturnsCash, DrawerIn, DrawerOut);
public decimal Difference => ShiftMath.CashDifference(ActualCash, ExpectedCash);
public bool DiffSurplus => Difference > 0m; // излишек
public bool DiffShortage => Difference < 0m; // недостача
public string DiffLabel => Difference == 0m ? "Сходится" : Difference > 0m ? "Излишек" : "Недостача";
partial void OnOpeningFloatChanged(decimal value) => RaiseCalc();
partial void OnActualCashChanged(decimal value) => RaiseCalc();
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); OnPropertyChanged(nameof(DiffSurplus)); OnPropertyChanged(nameof(DiffShortage)); OnPropertyChanged(nameof(DiffLabel)); }
/// <summary>Сообщить оболочке новый номер смены (или null — закрыта).</summary>
public Action<string?>? OnShiftChanged;
public ShiftViewModel() { }
public ShiftViewModel(AppServices svc)
{
_svc = svc; IsOpen = svc.Checkout.HasOpenShift;
if (svc.Checkout.CurrentShift is { } s) { Number = s.Number; OpeningFloat = s.OpeningFloat; }
_ = RefreshAsync();
}
private async Task RefreshAsync()
{
if (_svc?.Api is null || _svc.Session?.RetailPointId is not Guid rp) return;
try
{
var cur = await _svc.Api.CurrentShiftAsync(rp);
if (cur.Content is { open: true, shift: { } sh })
{
IsOpen = true; Number = sh.number; OpeningFloat = sh.openingFloat;
_svc.Checkout.CurrentShift = new PosShift { Id = sh.id, Number = sh.number, RetailPointId = sh.retailPointId, CashAccountId = sh.cashAccountId, AcquiringAccountId = sh.acquiringAccountId, OpeningFloat = sh.openingFloat };
OnShiftChanged?.Invoke(sh.number);
}
else { IsOpen = false; OnShiftChanged?.Invoke(null); }
}
catch (Exception ex) { Message = "Нет связи со сменой: " + ex.Message; }
}
[RelayCommand]
private async Task OpenShift()
{
if (_svc?.Api is null || _svc.Session?.RetailPointId is not Guid rp) { Message = "Нет привязки к точке/серверу"; return; }
Busy = true; Message = null;
try
{
// Денежный счёт точки (наличная касса) — первый наличный привязанный к складу точки.
var accounts = await _svc.Api.MoneyAccountsAsync();
var cash = accounts.items.FirstOrDefault(a => a.kind == 1 && a.isActive) ?? accounts.items.FirstOrDefault(a => a.kind == 1);
if (cash is null) { Message = "Не найдена наличная касса (создайте счёт в админке)"; return; }
var res = await _svc.Api.OpenShiftAsync(new OpenShiftBody(rp, cash.id, null, OpeningFloat));
IsOpen = true; Number = res.number;
_svc.Checkout.CurrentShift = new PosShift { Id = res.id, Number = res.number, RetailPointId = rp, CashAccountId = cash.id, OpeningFloat = OpeningFloat };
OnShiftChanged?.Invoke(res.number);
Message = $"Смена {res.number} открыта";
}
catch (Exception ex) { Message = "Открыть смену не удалось: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task CloseShift()
{
if (_svc?.Api is null || _svc.Checkout.CurrentShift is not { } s) { Message = "Смена не открыта"; return; }
Busy = true; Message = null;
try
{
var res = await _svc.Api.CloseShiftAsync(s.Id, new CloseShiftBody(ActualCash, null));
IsOpen = false;
_svc.Checkout.CurrentShift = null;
OnShiftChanged?.Invoke(null);
Message = $"Смена закрыта. Должно {res.expectedCash:N0} ₸, факт {ActualCash:N0} ₸, разница {res.difference:N0} ₸";
}
catch (Exception ex) { Message = "Закрыть смену не удалось: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task DepositCash() => await DrawerAsync(1, DrawerIn, "Внесение");
[RelayCommand]
private async Task WithdrawCash() => await DrawerAsync(2, DrawerOut, "Выплата/инкассация");
private async Task DrawerAsync(int direction, decimal amount, string reason)
{
if (_svc?.Api is null || _svc.Checkout.CurrentShift is not { } s) { Message = "Смена не открыта"; return; }
try { await _svc.Api.DrawerAsync(s.Id, new DrawerBody(direction, amount, reason)); Message = $"{reason}: {amount:N0} ₸"; }
catch (Exception ex) { Message = "Операция с кассой не удалась: " + ex.Message; }
}
public static ShiftViewModel Sample() => new();
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;
[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 async Task Lookup()
{
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 = "Чек найден — отметьте позиции к возврату", 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;
}
}