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