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 { }
/// Оболочка: статус-бар (касса/смена/сеть/очередь), текущая страница,
/// модальная оплата, горячие клавиши и фоновая синхронизация. Логин-гейт:
/// без валидной сессии показывается экран входа.
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 _isOnline = true;
[ObservableProperty] private int _queueCount;
[ObservableProperty] private PaymentViewModel? _payment;
[ObservableProperty] private bool _isPaymentOpen;
[ObservableProperty] private string? _updateMessage;
public string NetBadge => IsOnline ? "● на связи" : "○ нет связи";
public string QueueBadge => QueueCount > 0 ? $"{QueueCount} чек(ов) в очереди" : "очередь пуста";
partial void OnIsOnlineChanged(bool value) { OnPropertyChanged(nameof(NetBadge)); }
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
/// Конструктор для дизайнера/скриншотов (демо-данные).
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000001"; }
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();
_sale = new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() };
CurrentPage = _sale;
StartSyncLoop();
_ = CheckForUpdateAsync();
}
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 ?? "смена не открыта"; } };
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
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; }
/// Горячие клавиши (работа без мыши). Вызывается из code-behind окна.
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(); }
}
}
/// Экран входа: адрес сервера, логин, PIN. Регистрирует кассу на точке
/// продаж (берёт точку из привязки кассира). Хранит refresh-токен защищённо.
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 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();
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();
_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; }
}
/// Сбросить регистрацию устройства (сменить точку). Новую точку
/// сможет выбрать только администратор (кассир после сброса получит
/// «устройство не зарегистрировано»).
[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 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);
/// Количество с единицей измерения: «3 шт», «1,235 кг».
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;
/// Пояснение скидки в строке, чтобы цена×кол-во ≠ сумма не выглядело
/// ошибкой: «1 560 → 1 500 ₸, скидка 60».
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)); }
}
/// Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,
/// итоги, скидка, оплата. Горячие клавиши назначены в оболочке/View.
public partial class SaleViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private readonly Cart _cart = new();
private readonly Stack _parked = new();
public ObservableCollection Lines { get; } = new();
[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 Action? PaymentRequested;
/// Чек проведён — оболочка обновляет индикатор очереди.
public Action? SaleCommitted;
public SaleViewModel() { }
public SaleViewModel(AppServices svc) { _svc = svc; }
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();
}
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); // без оболочки (тест/демо) — оплата наличными «под расчёт»
}
/// Провести оплату: офлайн-устойчивая запись чека + печать + попытка
/// отправки. Очищает корзину.
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 = "Чек очищен"; }
public int ParkedCount => _parked.Count;
/// Отложить текущий чек и начать новый (обслужить другого покупателя).
[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})";
}
/// Вернуть последний отложенный чек (поверх пустого).
[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();
}
/// Пример заполненного чека для скриншотов/демо.
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 Оплата";
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;
partial void OnPaidCashChanged(decimal value) => RaisePay();
partial void OnPaidCardChanged(decimal value) => RaisePay();
partial void OnTotalChanged(decimal value) => RaisePay();
private void RaisePay() { OnPropertyChanged(nameof(Change)); OnPropertyChanged(nameof(Shortfall)); OnPropertyChanged(nameof(Sufficient)); }
/// Вызывается при подтверждении оплаты (cash, card, change).
public Func? OnConfirm;
public Action? OnCancel;
[RelayCommand] private void ExactCash() { PaidCash = Total; PaidCard = 0m; Method = "Наличные"; }
[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 = 3365m, PaidCash = 5000m, Method = "Наличные" };
public static PaymentViewModel SampleMixed() => new() { Total = 3365m, PaidCash = 1365m, PaidCard = 2000m, Method = "Смешанная" };
}
/// Смена: открыть (размен) / закрыть (факт → должно/факт/разница),
/// внесение/выплата, X/Z-отчёты. Живые вызовы идут на серверный модуль «Деньги».
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);
partial void OnOpeningFloatChanged(decimal v) => RaiseCalc();
partial void OnActualCashChanged(decimal v) => RaiseCalc();
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); }
/// Сообщить оболочке новый номер смены (или null — закрыта).
public Action? 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; }
}
/// Строка возврата: сколько продано/уже возвращено и сколько кассир
/// выбирает к возврату (0..ReturnableQty).
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)); }
}
/// Возврат по чеку: найти чек (номер/скан QR), отметить позиции и
/// количество (не больше проданного минус уже возвращённого), сумма к выдаче,
/// способ (нал/карта как в исходном чеке).
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 Lines { get; } = new();
public decimal RefundTotal => Lines.Sum(l => l.LineRefund);
public Action? 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;
}
}