- Верхняя полоса и экран входа: логотип-изображение 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>
884 lines
46 KiB
C#
884 lines
46 KiB
C#
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;
|
||
}
|
||
}
|