From fcd4a69624e9428b5537ad595c13341bdc7915dd Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 04:58:39 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos-ui):=20WPF=E2=86=92Avalonia=2011,=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=B0=D1=8F=20=D0=BA=D0=B0=D1=81=D1=81=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=81=D1=81=D0=B8=D1=80=D0=B0=20(Win/Linux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кроссплатформенный Avalonia-UI (net8.0, не net8.0-windows): экраны вход/ продажа/оплата/смена/возврат, тёмная тема, ViewLocator, компилируемые привязки. - Живое связывание: логин→сессия→Refit-API, привязка к точке; продажа (скан/ поиск/количество/весовой/скидки/удаление), модальная оплата → запись чека в офлайн-очередь + печать + попытка выгрузки; смена open/close/drawer на api/money/shifts; горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del (работа без мыши); индикатор связи/очереди + фоновая синхронизация; автопроверка версии. - Оборудование: ESC/POS-билдер (CP866, USB/COM/сеть, 58/80мм) + текст/PDF запасной; весы CAS/Масса-К (COM) + ручной ввод; IFiscalProvider — заглушка. - DpapiTokenStore (Windows, scope CurrentUser) для refresh-токена; 0600 на Linux. - Инструменты CLI: --smoke (замеры/сценарий без UI), --screenshots (headless рендер экранов), --uitest (headless UI-автотест, 15 проверок, Avalonia.Headless). Co-Authored-By: Claude Opus 5.5 --- src/food-market.pos/App.axaml | 35 ++ src/food-market.pos/App.axaml.cs | 23 + src/food-market.pos/App.xaml | 7 - src/food-market.pos/App.xaml.cs | 5 - src/food-market.pos/Hardware/Fiscal.cs | 20 + src/food-market.pos/Hardware/Printing.cs | 105 ++++ src/food-market.pos/Hardware/Scale.cs | 82 +++ src/food-market.pos/MainWindow.xaml | 11 - src/food-market.pos/MainWindow.xaml.cs | 9 - src/food-market.pos/Program.cs | 28 + src/food-market.pos/Services/AppServices.cs | 82 +++ .../Services/CheckoutService.cs | 86 +++ .../Services/DpapiTokenStore.cs | 31 ++ .../Tooling/ScreenshotRunner.cs | 105 ++++ src/food-market.pos/Tooling/SmokeRunner.cs | 104 ++++ src/food-market.pos/Tooling/UiTestRunner.cs | 129 +++++ src/food-market.pos/ViewLocator.cs | 20 + src/food-market.pos/ViewModels/ViewModels.cs | 508 ++++++++++++++++++ src/food-market.pos/Views/LoginView.axaml | 28 + src/food-market.pos/Views/LoginView.axaml.cs | 8 + src/food-market.pos/Views/MainWindow.axaml | 45 ++ src/food-market.pos/Views/MainWindow.axaml.cs | 34 ++ src/food-market.pos/Views/PaymentView.axaml | 37 ++ .../Views/PaymentView.axaml.cs | 8 + src/food-market.pos/Views/ReturnView.axaml | 37 ++ src/food-market.pos/Views/ReturnView.axaml.cs | 8 + src/food-market.pos/Views/SaleView.axaml | 69 +++ src/food-market.pos/Views/SaleView.axaml.cs | 8 + src/food-market.pos/Views/ShiftView.axaml | 36 ++ src/food-market.pos/Views/ShiftView.axaml.cs | 8 + src/food-market.pos/food-market.pos.csproj | 17 +- 31 files changed, 1695 insertions(+), 38 deletions(-) create mode 100644 src/food-market.pos/App.axaml create mode 100644 src/food-market.pos/App.axaml.cs delete mode 100644 src/food-market.pos/App.xaml delete mode 100644 src/food-market.pos/App.xaml.cs create mode 100644 src/food-market.pos/Hardware/Fiscal.cs create mode 100644 src/food-market.pos/Hardware/Printing.cs create mode 100644 src/food-market.pos/Hardware/Scale.cs delete mode 100644 src/food-market.pos/MainWindow.xaml delete mode 100644 src/food-market.pos/MainWindow.xaml.cs create mode 100644 src/food-market.pos/Program.cs create mode 100644 src/food-market.pos/Services/AppServices.cs create mode 100644 src/food-market.pos/Services/CheckoutService.cs create mode 100644 src/food-market.pos/Services/DpapiTokenStore.cs create mode 100644 src/food-market.pos/Tooling/ScreenshotRunner.cs create mode 100644 src/food-market.pos/Tooling/SmokeRunner.cs create mode 100644 src/food-market.pos/Tooling/UiTestRunner.cs create mode 100644 src/food-market.pos/ViewLocator.cs create mode 100644 src/food-market.pos/ViewModels/ViewModels.cs create mode 100644 src/food-market.pos/Views/LoginView.axaml create mode 100644 src/food-market.pos/Views/LoginView.axaml.cs create mode 100644 src/food-market.pos/Views/MainWindow.axaml create mode 100644 src/food-market.pos/Views/MainWindow.axaml.cs create mode 100644 src/food-market.pos/Views/PaymentView.axaml create mode 100644 src/food-market.pos/Views/PaymentView.axaml.cs create mode 100644 src/food-market.pos/Views/ReturnView.axaml create mode 100644 src/food-market.pos/Views/ReturnView.axaml.cs create mode 100644 src/food-market.pos/Views/SaleView.axaml create mode 100644 src/food-market.pos/Views/SaleView.axaml.cs create mode 100644 src/food-market.pos/Views/ShiftView.axaml create mode 100644 src/food-market.pos/Views/ShiftView.axaml.cs diff --git a/src/food-market.pos/App.axaml b/src/food-market.pos/App.axaml new file mode 100644 index 0000000..3ba06eb --- /dev/null +++ b/src/food-market.pos/App.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/src/food-market.pos/App.axaml.cs b/src/food-market.pos/App.axaml.cs new file mode 100644 index 0000000..a24d53a --- /dev/null +++ b/src/food-market.pos/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using foodmarket.Pos.Services; +using foodmarket.Pos.ViewModels; +using foodmarket.Pos.Views; + +namespace foodmarket.Pos; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var services = new AppServices(); + desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(services) }; + } + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/food-market.pos/App.xaml b/src/food-market.pos/App.xaml deleted file mode 100644 index efd927a..0000000 --- a/src/food-market.pos/App.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/src/food-market.pos/App.xaml.cs b/src/food-market.pos/App.xaml.cs deleted file mode 100644 index b0702e5..0000000 --- a/src/food-market.pos/App.xaml.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace foodmarket.Pos; - -public partial class App : System.Windows.Application -{ -} diff --git a/src/food-market.pos/Hardware/Fiscal.cs b/src/food-market.pos/Hardware/Fiscal.cs new file mode 100644 index 0000000..5849bac --- /dev/null +++ b/src/food-market.pos/Hardware/Fiscal.cs @@ -0,0 +1,20 @@ +namespace foodmarket.Pos.Hardware; + +public sealed record FiscalResult(bool Fiscalized, string? FiscalNumber, string? QrPayload, string? Error); + +/// Фискализация чека у ОФД-оператора (онлайн-ККМ Казахстана). СЕЙЧАС — +/// только интерфейс и заглушка (): фискализацию +/// не делаем, чек проходит без фискального номера. Что потребуется для реальной +/// фискализации KZ — см. docs/pos.md раздел «Фискализация». +public interface IFiscalProvider +{ + bool Enabled { get; } + Task RegisterSaleAsync(object sale, CancellationToken ct = default); +} + +public sealed class NullFiscalProvider : IFiscalProvider +{ + public bool Enabled => false; + public Task RegisterSaleAsync(object sale, CancellationToken ct = default) + => Task.FromResult(new FiscalResult(false, null, null, null)); +} diff --git a/src/food-market.pos/Hardware/Printing.cs b/src/food-market.pos/Hardware/Printing.cs new file mode 100644 index 0000000..03e8eaa --- /dev/null +++ b/src/food-market.pos/Hardware/Printing.cs @@ -0,0 +1,105 @@ +using System.Text; + +namespace foodmarket.Pos.Hardware; + +public sealed record ReceiptLine(string Left, string? Right = null, bool Bold = false, bool Big = false, bool Center = false); + +/// Чек/отчёт в абстрактном виде — рендерится либо в ESC/POS, либо в текст/PDF. +public sealed class ReceiptDoc +{ + public string Title { get; init; } = ""; + public List Lines { get; } = new(); + public int WidthChars { get; init; } = 48; // 80мм ≈ 48 симв; 58мм ≈ 32 + public void Add(string left, string? right = null, bool bold = false, bool big = false, bool center = false) + => Lines.Add(new ReceiptLine(left, right, bold, big, center)); + public void Separator() => Lines.Add(new ReceiptLine(new string('-', WidthChars))); +} + +public interface IReceiptPrinter +{ + string Name { get; } + Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default); +} + +/// Генератор команд ESC/POS (чистая логика → байты; тестируется). +public static class EscPos +{ + private static readonly Encoding Cp866 = CodePagesEncodingProvider.Instance.GetEncoding(866) ?? Encoding.UTF8; + + public static byte[] Build(ReceiptDoc doc) + { + var buf = new List(); + buf.AddRange(new byte[] { 0x1B, 0x40 }); // ESC @ — init + buf.AddRange(new byte[] { 0x1B, 0x74, 0x11 }); // codepage CP866 (RU) + void Mode(bool bold, bool big, bool center) + { + buf.AddRange(new byte[] { 0x1B, 0x61, (byte)(center ? 1 : 0) }); // align + buf.AddRange(new byte[] { 0x1B, 0x45, (byte)(bold ? 1 : 0) }); // bold + buf.AddRange(new byte[] { 0x1D, 0x21, (byte)(big ? 0x11 : 0x00) });// size 2x + } + if (!string.IsNullOrEmpty(doc.Title)) { Mode(true, true, true); Line(doc.Title); } + foreach (var l in doc.Lines) + { + Mode(l.Bold, l.Big, l.Center); + Line(l.Right is null ? l.Left : Pad(l.Left, l.Right, doc.WidthChars)); + } + Mode(false, false, false); + buf.AddRange(new byte[] { 0x0A, 0x0A, 0x0A }); // feed + buf.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x00 }); // GS V — full cut + return buf.ToArray(); + + void Line(string s) { buf.AddRange(Cp866.GetBytes(s)); buf.Add(0x0A); } + } + + public static string Pad(string left, string right, int width) + { + var space = Math.Max(1, width - left.Length - right.Length); + if (left.Length + right.Length + 1 > width) left = left.Substring(0, Math.Max(0, width - right.Length - 1)); + return left + new string(' ', Math.Max(1, width - left.Length - right.Length)) + right; + } +} + +/// ESC/POS-принтер: пишет байты в поток (COM/USB raw/сетевой сокет). +/// Транспорт выбирается строкой: "COM1", "/dev/usb/lp0", "tcp:192.168.1.50:9100". +public sealed class EscPosReceiptPrinter : IReceiptPrinter +{ + private readonly string _target; + public EscPosReceiptPrinter(string target) { _target = target; } + public string Name => $"ESC/POS ({_target})"; + + public async Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default) + { + var bytes = EscPos.Build(doc); + if (_target.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase)) + { + var parts = _target[4..].Split(':'); + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync(parts[0], int.Parse(parts[1]), ct); + await tcp.GetStream().WriteAsync(bytes, ct); + } + else + { + await using var fs = new FileStream(_target, FileMode.Open, FileAccess.Write); + await fs.WriteAsync(bytes, ct); + } + } +} + +/// Запасной «принтер»: сохраняет чек как текстовый файл (для обычного +/// принтера/PDF-экспорта и для headless-проверки). Полноценный PDF — через +/// QuestPDF (MIT) в будущем; сейчас текстовый слип в папке чеков. +public sealed class PdfReceiptPrinter : IReceiptPrinter +{ + public string Name => "Текст/PDF (запасной)"; + public async Task PrintAsync(ReceiptDoc doc, CancellationToken ct = default) + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos", "receipts"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"receipt-{DateTime.Now:yyyyMMdd-HHmmss-fff}.txt"); + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(doc.Title)) sb.AppendLine(Center(doc.Title, doc.WidthChars)); + foreach (var l in doc.Lines) sb.AppendLine(l.Right is null ? (l.Center ? Center(l.Left, doc.WidthChars) : l.Left) : EscPos.Pad(l.Left, l.Right, doc.WidthChars)); + await File.WriteAllTextAsync(path, sb.ToString(), ct); + } + private static string Center(string s, int w) => s.Length >= w ? s : new string(' ', (w - s.Length) / 2) + s; +} diff --git a/src/food-market.pos/Hardware/Scale.cs b/src/food-market.pos/Hardware/Scale.cs new file mode 100644 index 0000000..a5b2bf8 --- /dev/null +++ b/src/food-market.pos/Hardware/Scale.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using System.IO.Ports; + +namespace foodmarket.Pos.Hardware; + +public interface IScale +{ + string Name { get; } + /// Считать текущий вес в кг. Для ручных весов — последнее введённое значение. + Task ReadWeightKgAsync(CancellationToken ct = default); +} + +/// Ручной ввод веса (кассир вбивает вес с ярлыка/памяти) — запасной путь, +/// когда весов нет или нет связи с ними. +public sealed class ManualScale : IScale +{ + public decimal LastValue { get; set; } + public string Name => "Ручной ввод веса"; + public Task ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue); +} + +/// Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы +/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер +/// выделен и тестируется отдельно; реальный COM-поток — в . +public static class ScaleProtocol +{ + /// CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем + /// первое число, трактуем как кг (если явно граммы — делим). + public static decimal? ParseCas(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var isGrams = raw.Contains('g', StringComparison.OrdinalIgnoreCase) && !raw.Contains("kg", StringComparison.OrdinalIgnoreCase); + var num = ExtractNumber(raw); + if (num is null) return null; + var kg = isGrams ? num.Value / 1000m : num.Value; + return decimal.Round(kg, 3); + } + + /// Масса-К (ASCII-режим): строка с весом в кг, например "1.234". + public static decimal? ParseMassaK(string raw) + { + var num = ExtractNumber(raw); + return num is null ? null : decimal.Round(num.Value, 3); + } + + private static decimal? ExtractNumber(string s) + { + var chars = new System.Text.StringBuilder(); + bool seen = false; + foreach (var c in s) + { + if (char.IsDigit(c) || c == '.' || c == ',' || (!seen && (c == '+' || c == '-'))) + { chars.Append(c == ',' ? '.' : c); seen = char.IsDigit(c) || seen; } + else if (seen && chars.Length > 0 && (chars[^1] is >= '0' and <= '9')) break; + } + return decimal.TryParse(chars.ToString().Trim('+'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; + } +} + +/// Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт +/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux +/// (System.IO.Ports). +public sealed class ComScale : IScale +{ + public enum Proto { Cas, MassaK } + private readonly string _port; + private readonly int _baud; + private readonly Proto _proto; + public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; } + public string Name => $"Весы {_proto} ({_port})"; + + public async Task ReadWeightKgAsync(CancellationToken ct = default) + { + using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1500, NewLine = "\r" }; + sp.Open(); + if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS) + await Task.Delay(120, ct); + var line = sp.ReadLine(); + var kg = _proto == Proto.Cas ? ScaleProtocol.ParseCas(line) : ScaleProtocol.ParseMassaK(line); + return kg ?? 0m; + } +} diff --git a/src/food-market.pos/MainWindow.xaml b/src/food-market.pos/MainWindow.xaml deleted file mode 100644 index 0fb0bfb..0000000 --- a/src/food-market.pos/MainWindow.xaml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/src/food-market.pos/MainWindow.xaml.cs b/src/food-market.pos/MainWindow.xaml.cs deleted file mode 100644 index c51433c..0000000 --- a/src/food-market.pos/MainWindow.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace foodmarket.Pos; - -public partial class MainWindow : System.Windows.Window -{ - public MainWindow() - { - InitializeComponent(); - } -} diff --git a/src/food-market.pos/Program.cs b/src/food-market.pos/Program.cs new file mode 100644 index 0000000..959a77d --- /dev/null +++ b/src/food-market.pos/Program.cs @@ -0,0 +1,28 @@ +using Avalonia; +using foodmarket.Pos.Tooling; + +namespace foodmarket.Pos; + +internal static class Program +{ + [STAThread] + public static int Main(string[] args) + { + // CLI-режимы (без интерактивного окна): + // --smoke : прогон сценария кассы через логику (без UI), печать результатов. + // --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768). + // --uitest : headless UI-автотест экрана продажи (Avalonia.Headless, для CI/xvfb). + if (args.Contains("--smoke")) return SmokeRunner.Run(args); + if (args.Contains("--screenshots")) return ScreenshotRunner.Run(args); + if (args.Contains("--uitest")) return UiTestRunner.Run(args); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + return 0; + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/food-market.pos/Services/AppServices.cs b/src/food-market.pos/Services/AppServices.cs new file mode 100644 index 0000000..33375fb --- /dev/null +++ b/src/food-market.pos/Services/AppServices.cs @@ -0,0 +1,82 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using foodmarket.Pos.Core.Auth; +using foodmarket.Pos.Core.Data; +using foodmarket.Pos.Core.Sync; +using foodmarket.Pos.Hardware; +using Refit; + +namespace foodmarket.Pos.Services; + +/// Лёгкий контейнер сервисов кассы (без тяжёлого DI — быстрый старт). +/// Держит локальное хранилище, http-клиент, авторизацию, Refit-API (после входа), +/// сервис синхронизации и оборудование (печать/весы/фискализация-заглушка). +public sealed class AppServices +{ + public PosStore Store { get; } + public HttpClient Http { get; } + public AuthService Auth { get; } + public ITokenStore Tokens { get; } + public PosSession? Session { get; set; } + public IServerApi? Api { get; private set; } + public SyncService? Sync { get; private set; } + + public IReceiptPrinter Printer { get; set; } = new PdfReceiptPrinter(); // запасной по умолчанию; ESC/POS выбирается в настройках + public IScale Scale { get; set; } = new ManualScale(); + public IFiscalProvider Fiscal { get; } = new NullFiscalProvider(); + + private CheckoutService? _checkout; + /// Оркестратор завершения чека (очередь → push → печать). + public CheckoutService Checkout => _checkout ??= new CheckoutService(this); + + public AppServices() + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos"); + Directory.CreateDirectory(dir); + Store = new PosStore(Path.Combine(dir, "catalog.db")); + Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + Auth = new AuthService(Http); + // Windows — DPAPI (привязка к учётке), иначе файл с правами 0600. + Tokens = OperatingSystem.IsWindows() + ? new DpapiTokenStore(FileTokenStore.DefaultPath()) + : new FileTokenStore(FileTokenStore.DefaultPath()); + Session = Tokens.Load(); + if (Session is not null) ConfigureApi(Session); + } + + /// Контейнер с готовым хранилищем — для headless UI-тестов/демо + /// (без файлов на диске и без токен-стора). + public AppServices(PosStore store) + { + Store = store; + Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + Auth = new AuthService(Http); + Tokens = new InMemoryTokenStore(); + } + + /// Поднять Refit-клиент на базовый URL сервера с Bearer-токеном сессии. + public void ConfigureApi(PosSession session) + { + Session = session; + var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, new HttpClientHandler())) + { + BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(30), + }; + Api = RestService.For(client); + Sync = new SyncService(Store, Api); + } +} + +/// Подставляет Bearer-токен в каждый запрос. +internal sealed class AuthHeaderHandler : DelegatingHandler +{ + private readonly Func _token; + public AuthHeaderHandler(Func token, HttpMessageHandler inner) : base(inner) { _token = token; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var t = _token(); + if (!string.IsNullOrEmpty(t)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t); + return base.SendAsync(request, ct); + } +} diff --git a/src/food-market.pos/Services/CheckoutService.cs b/src/food-market.pos/Services/CheckoutService.cs new file mode 100644 index 0000000..764dc4d --- /dev/null +++ b/src/food-market.pos/Services/CheckoutService.cs @@ -0,0 +1,86 @@ +using foodmarket.Pos.Core.Logic; +using foodmarket.Pos.Core.Models; +using foodmarket.Pos.Hardware; +using foodmarket.Shared.Pos.V1; + +namespace foodmarket.Pos.Services; + +/// Оркестрация завершения чека: собрать серверный чек из корзины +/// (с привязкой к смене/точке и кассиру), записать в локальную очередь +/// (офлайн-устойчиво), попытаться сразу выгрузить на сервер и напечатать чек. +/// Запись в очередь — источник правды: даже если сеть/печать упали, продажа +/// не теряется и уйдёт при следующей синхронизации. +public sealed class CheckoutService +{ + private readonly AppServices _svc; + public CheckoutService(AppServices svc) => _svc = svc; + + /// Открытая смена кассы (локальная проекция). Без неё продавать нельзя. + public PosShift? CurrentShift { get; set; } + + public bool HasOpenShift => CurrentShift is not null; + + public sealed record CommitResult(Guid ClientSaleId, bool PushedNow, int PendingAfter, bool Printed, string? PrintError); + + /// Завершить продажу/возврат. Возвращает статус (ушло ли сразу, + /// сколько осталось в очереди, напечатался ли чек). + public async Task CommitAsync( + Cart cart, decimal paidCash, decimal paidCard, decimal change, + bool isReturn = false, Guid? referenceSaleId = null, Guid? customerId = null, + CancellationToken ct = default) + { + var ctx = new CheckoutContext( + _svc.Session?.CashierUserId, + CurrentShift?.RetailPointId ?? _svc.Session?.RetailPointId, + CurrentShift?.Id); + + var sale = Checkout.BuildSale(cart, paidCash, paidCard, ctx, + isReturn: isReturn, referenceSaleId: referenceSaleId, customerId: customerId); + + // 1) Очередь — всегда (офлайн-устойчивость). + _svc.Store.EnqueueSale(sale); + + // 2) Печать чека (не критично — ошибку печати не теряем продажу). + bool printed = false; string? printErr = null; + try { await _svc.Printer.PrintAsync(BuildReceipt(cart, paidCash, paidCard, change, isReturn), ct); printed = true; } + catch (Exception ex) { printErr = ex.Message; } + + // 3) Пытаемся сразу выгрузить. Нет сети — останется в очереди. + bool pushedNow = false; + if (_svc.Sync is not null) + { + try { var r = await _svc.Sync.PushSalesAsync(ct); pushedNow = r.Accepted > 0; } + catch { /* офлайн — молча оставляем в очереди */ } + } + + return new CommitResult(sale.ClientSaleId, pushedNow, _svc.Store.PendingCount(), printed, printErr); + } + + private ReceiptDoc BuildReceipt(Cart cart, decimal paidCash, decimal paidCard, decimal change, bool isReturn) + { + var t = cart.Totals(); + var doc = new ReceiptDoc { Title = isReturn ? "ВОЗВРАТ" : "ТОВАРНЫЙ ЧЕК" }; + doc.Add("Food Market", center: true, bold: true); + if (CurrentShift?.Number is { } sn) doc.Add($"Смена {sn}", center: true); + if (_svc.Session?.CashierName is { } c) doc.Add($"Кассир: {c}"); + doc.Add(DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss")); + doc.Separator(); + foreach (var l in cart.Lines) + { + doc.Add(l.Name); + var qty = l.IsWeighed ? l.Quantity.ToString("0.###") : l.Quantity.ToString("0"); + doc.Add($" {qty} {l.UnitCode} x {l.UnitPrice:N0}", $"{l.LineTotal:N0}"); + } + doc.Separator(); + if (t.LineDiscounts + t.CartDiscount > 0m) + doc.Add("Скидка", $"-{t.LineDiscounts + t.CartDiscount:N0}"); + doc.Add("ИТОГО", $"{t.Total:N0} ₸", bold: true, big: true); + if (paidCash > 0m) doc.Add("Наличные", $"{paidCash:N0}"); + if (paidCard > 0m) doc.Add("Карта", $"{paidCard:N0}"); + if (change > 0m) doc.Add("Сдача", $"{change:N0}"); + if (t.Vat > 0m) doc.Add("в т.ч. НДС", $"{t.Vat:N0}"); + doc.Separator(); + doc.Add("Спасибо за покупку!", center: true); + return doc; + } +} diff --git a/src/food-market.pos/Services/DpapiTokenStore.cs b/src/food-market.pos/Services/DpapiTokenStore.cs new file mode 100644 index 0000000..eb70ad5 --- /dev/null +++ b/src/food-market.pos/Services/DpapiTokenStore.cs @@ -0,0 +1,31 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using foodmarket.Pos.Core.Auth; + +namespace foodmarket.Pos.Services; + +/// Хранение сессии кассира под Windows с шифрованием DPAPI (ключ +/// привязан к учётной записи пользователя Windows, scope = CurrentUser). Файл +/// содержит refresh-токен — DPAPI не даёт прочитать его другому пользователю +/// или на другой машине. На Unix используется базовый +/// с правами 0600. +[SupportedOSPlatform("windows")] +public sealed class DpapiTokenStore : FileTokenStore +{ + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("food-market-pos/v1"); + + public DpapiTokenStore(string path) : base(path) { } + + protected override string Protect(string plain) + { + var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), Entropy, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(bytes); + } + + protected override string Unprotect(string stored) + { + var bytes = ProtectedData.Unprotect(Convert.FromBase64String(stored), Entropy, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/src/food-market.pos/Tooling/ScreenshotRunner.cs b/src/food-market.pos/Tooling/ScreenshotRunner.cs new file mode 100644 index 0000000..6e530f9 --- /dev/null +++ b/src/food-market.pos/Tooling/ScreenshotRunner.cs @@ -0,0 +1,105 @@ +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using foodmarket.Pos.ViewModels; +using foodmarket.Pos.Views; + +namespace foodmarket.Pos.Tooling; + +/// Headless-рендер экранов кассы в PNG (без дисплея, Skia). По порядку +/// сценария, в 1920×1080 и 1366×768 → docs/pos-screenshots/ + index.html. +/// Запуск: dotnet run -- --screenshots [outputDir] +public static class ScreenshotRunner +{ + private sealed record Shot(string Name, string Caption, Func Build); + + public static int Run(string[] args) + { + var outDir = args.SkipWhile(a => a != "--screenshots").Skip(1).FirstOrDefault(a => !a.StartsWith("--")) + ?? Path.Combine(FindRepoRoot(), "docs", "pos-screenshots"); + Directory.CreateDirectory(outDir); + + AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) + .WithInterFont() + .SetupWithoutStarting(); + + var shots = new List + { + new("01-login", "Вход кассира (адрес сервера, логин, PIN)", () => new LoginView { DataContext = LoginViewModel.Sample() }), + new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }), + new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }), + new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }), + new("05-payment-cash", "Оплата наличными со сдачей", () => new PaymentView { DataContext = PaymentViewModel.SampleCash() }), + new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => new PaymentView { DataContext = PaymentViewModel.SampleMixed() }), + new("07-return", "Возврат по чеку (выбор позиций)", () => new ReturnView { DataContext = ReturnViewModel.Sample() }), + new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }), + new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()), + new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()), + }; + + var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) }; + var produced = new List<(string file, string caption)>(); + + foreach (var shot in shots) + foreach (var (suffix, w, h) in sizes) + { + var control = shot.Build(); + // Если это уже окно (оболочка) — рендерим его напрямую; иначе кладём в окно. + var window = control as Window ?? new Window { Content = control, SystemDecorations = SystemDecorations.None }; + window.Width = w; window.Height = h; + window.Show(); + Dispatcher.UIThread.RunJobs(); + // дать разметке/рендеру отработать + for (int i = 0; i < 3; i++) { Dispatcher.UIThread.RunJobs(); } + var frame = window.CaptureRenderedFrame(); + var file = $"{shot.Name}-{suffix}.png"; + frame?.Save(Path.Combine(outDir, file)); + produced.Add((file, $"{shot.Caption} ({suffix})")); + window.Close(); + Console.WriteLine($"[shot] {file}"); + } + + WriteGallery(outDir, produced); + Console.WriteLine($"[done] {produced.Count} кадров → {outDir}"); + return 0; + } + + private static Control ShellOffline() + { + var vm = new MainWindowViewModel { IsOnline = false, QueueCount = 3, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; + return new MainWindow { DataContext = vm }; + } + private static Control ShellOnline() + { + var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; + return new MainWindow { DataContext = vm }; + } + + private static void WriteGallery(string dir, List<(string file, string caption)> shots) + { + var sb = new StringBuilder(); + sb.Append("Касса food-market — скриншоты"); + sb.Append(""); + sb.Append($"

Касса food-market — скриншоты процессов

кадров: {shots.Count}
"); + foreach (var (file, caption) in shots) + sb.Append($"
{System.Net.WebUtility.HtmlEncode(caption)}
"); + sb.Append("
"); + File.WriteAllText(Path.Combine(dir, "index.html"), sb.ToString()); + } + + private static string FindRepoRoot() + { + var d = new DirectoryInfo(AppContext.BaseDirectory); + while (d is not null && !File.Exists(Path.Combine(d.FullName, "food-market.sln"))) d = d.Parent; + return d?.FullName ?? Directory.GetCurrentDirectory(); + } +} diff --git a/src/food-market.pos/Tooling/SmokeRunner.cs b/src/food-market.pos/Tooling/SmokeRunner.cs new file mode 100644 index 0000000..3bd1d39 --- /dev/null +++ b/src/food-market.pos/Tooling/SmokeRunner.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using foodmarket.Pos.Core.Data; +using foodmarket.Pos.Core.Logic; +using foodmarket.Pos.Core.Models; +using foodmarket.Pos.Core.Sync; +using foodmarket.Shared.Pos.V1; + +namespace foodmarket.Pos.Tooling; + +/// CLI-smoke кассы: сценарий «открыть смену → продажи → возврат → закрыть» +/// + офлайн-очередь, через логику pos.core (без UI/дисплея). Работает одинаково +/// на Windows и Linux — для проверки на реальной машине. Запуск: --smoke +public static class SmokeRunner +{ + public static int Run(string[] args) + { + var lines = new List(); + void Log(string s) { Console.WriteLine(s); lines.Add(s); } + + // Холодный старт: от запуска процесса до входа в логику (загрузка рантайма + JIT). + double coldStartMs = 0; + try { coldStartMs = (DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds; } catch { } + + var sw = Stopwatch.StartNew(); + using var store = PosStore.InMemory(); + + // Каталог: 30k товаров для замера поиска + несколько «реальных». + var ids = SeedCatalog(store, big: args.Contains("--big")); + var seedMs = sw.ElapsedMilliseconds; + + // Замер: поиск по штрихкоду и по имени. + var swBc = Stopwatch.StartNew(); + var byBc = store.FindByBarcode("4870000000005"); + swBc.Stop(); + var swName = Stopwatch.StartNew(); + var found = store.SearchByName("товар 1", 20); + swName.Stop(); + + // Сценарий дня. + var cart = new Cart(); + var p1 = store.GetProduct(ids[0])!; var p2 = store.GetProduct(ids[1])!; + cart.AddProduct(p1, 2); cart.AddProduct(p2, 1); + var totals = cart.Totals(); + var pay = PaymentMath.Compute(totals.Total, totals.Total + 500m, 0m); + + // Положить 5 продаж в очередь (офлайн). + for (int i = 0; i < 5; i++) + store.EnqueueSale(new PosSaleDto + { + ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = totals.Total, PaidCard = 0, + Lines = new[] { new PosSaleLineDto { ProductId = ids[0], Quantity = 1m, UnitPrice = p1.Price, Discount = 0m } }, + }); + var pending = store.PendingCount(); + + var expected = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m); + var diff = ShiftMath.CashDifference(14775m, expected); + var batchKey = SyncService.StableBatchKey(store.PendingSales().Select(s => s.ClientSaleId)); + + Log("=== POS smoke ==="); + Log($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}"); + Log($"Холодный старт (процесс→логика): {coldStartMs:F0} мс"); + Log($"Каталог засеян: {store.ProductCount()} товаров за {seedMs} мс"); + Log($"Поиск по штрихкоду: {(byBc is null ? "НЕ найден" : byBc.Name)} за {swBc.Elapsed.TotalMilliseconds:F2} мс"); + Log($"Поиск по имени: {found.Count} совпадений за {swName.Elapsed.TotalMilliseconds:F2} мс"); + Log($"Чек: {totals.LineCount} поз., итог {totals.Total} ₸; оплата {pay.Paid} ₸ → сдача {pay.Change} ₸ (достаточно: {pay.Sufficient})"); + Log($"Очередь офлайн: {pending} чеков (ожид. 5); idempotency-ключ батча: {batchKey}"); + Log($"Смена: должно {expected} ₸, факт 14775 ₸, разница {diff} ₸"); + Log($"Всего инициализация+сценарий: {sw.ElapsedMilliseconds} мс"); + + var ok = byBc is not null && pay.Sufficient && pending == 5 && expected == 14275m && diff == 500m; + Log(ok ? "РЕЗУЛЬТАТ: OK" : "РЕЗУЛЬТАТ: ОШИБКА"); + + // Результат в файл — на Windows exe собран как WinExe (нет консоли). + var outArg = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault(); + var outPath = outArg ?? Path.Combine(Path.GetTempPath(), "pos-smoke-result.txt"); + try { File.WriteAllText(outPath, string.Join(Environment.NewLine, lines)); } catch { } + return ok ? 0 : 1; + } + + private static List SeedCatalog(PosStore store, bool big) + { + var n = big ? 30_000 : 50; + var products = new List(n); + var prices = new List(n); + var ids = new List(); + for (int i = 0; i < n; i++) + { + var id = Guid.NewGuid(); ids.Add(id); + var bc = (4_870_000_000_000 + i).ToString(); + products.Add(new ProductSyncDto + { + Id = id, Name = $"Товар {i + 1}", Barcodes = new[] { bc }, UnitCode = i % 7 == 0 ? "кг" : "шт", + Packaging = i % 7 == 0 ? 2 : 1, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow, + }); + prices.Add(new PriceSyncDto { ProductId = id, PriceTypeId = Guid.Empty, PriceTypeName = "Розн", IsSystem = true, Amount = 100m + i % 900, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow }); + } + store.ApplySync(new PosSyncResponse + { + ServerTime = DateTime.UtcNow, Products = products, Prices = prices, + Stocks = Array.Empty(), Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), + }); + return ids; + } +} diff --git a/src/food-market.pos/Tooling/UiTestRunner.cs b/src/food-market.pos/Tooling/UiTestRunner.cs new file mode 100644 index 0000000..e86078f --- /dev/null +++ b/src/food-market.pos/Tooling/UiTestRunner.cs @@ -0,0 +1,129 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Avalonia.VisualTree; +using foodmarket.Pos.Core.Data; +using foodmarket.Pos.Core.Models; +using foodmarket.Pos.Services; +using foodmarket.Pos.ViewModels; +using foodmarket.Pos.Views; +using foodmarket.Shared.Pos.V1; + +namespace foodmarket.Pos.Tooling; + +/// Headless UI-автотест (Avalonia.Headless + Skia, работает на Linux +/// без дисплея / под xvfb). Прогоняет реальный экран продажи сквозь привязки: +/// сид каталога → ввод штрихкода → команда Scan → в визуальном дереве должна +/// появиться строка с названием товара, а ИТОГО — отражать цену. Любое +/// расхождение → ненулевой код возврата (для CI). Запуск: --uitest +public static class UiTestRunner +{ + public static int Run(string[] args) + { + int failures = 0; + void Check(bool ok, string label) + { + Console.WriteLine($"[uitest] {(ok ? "OK " : "FAIL")} {label}"); + if (!ok) failures++; + } + + AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) + .WithInterFont() + .SetupWithoutStarting(); + + // 1) Каталог в памяти с одним товаром + штрихкодом. + using var store = PosStore.InMemory(); + var pid = Guid.NewGuid(); + store.ApplySync(new PosSyncResponse + { + ServerTime = DateTime.UtcNow, + Products = new[] { new ProductSyncDto + { + Id = pid, Name = "Кофе молотый 250г", Barcodes = new[] { "4870001112223" }, + UnitCode = "шт", Packaging = 1, VatPercent = 12m, VatEnabled = true, + IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow, + } }, + Prices = new[] { new PriceSyncDto + { + ProductId = pid, PriceTypeId = Guid.Empty, PriceTypeName = "Розн", + IsSystem = true, Amount = 1290m, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow, + } }, + Stocks = Array.Empty(), Counterparties = Array.Empty(), + DeletedProductIds = Array.Empty(), + }); + + var svc = new AppServices(store); + var vm = new SaleViewModel(svc); + var view = new SaleView { DataContext = vm }; + var window = new Window { Content = view, Width = 1366, Height = 768 }; + window.Show(); + Pump(); + + Check(vm.Lines.Count == 0, "стартовый чек пуст"); + + // 2) Эмуляция сканера: штрихкод «набран» в поле, команда Scan (Enter). + vm.ScanInput = "4870001112223"; + vm.ScanCommand.Execute(null); + Pump(); + + Check(vm.Lines.Count == 1, "после скана 1 позиция в корзине"); + Check(vm.Lines.Count == 1 && vm.Lines[0].Name == "Кофе молотый 250г", "название позиции верное"); + Check(vm.Total == 1290m, $"ИТОГО = 1290, факт {vm.Total}"); + Check(vm.ItemCount == 1, "счётчик позиций = 1"); + + // 3) Привязка дошла до визуального дерева: ищем TextBlock с названием. + var texts = view.GetVisualDescendants().OfType().Select(t => t.Text).ToList(); + Check(texts.Any(t => t == "Кофе молотый 250г"), "название отрисовано в дереве"); + // Разделитель групп зависит от культуры (пробел/NBSP/запятая) — нормализуем. + static string Digits(string? s) => new string((s ?? "").Where(char.IsDigit).ToArray()); + Check(texts.Any(t => t != null && t.Contains('₸') && Digits(t) == "1290"), "ИТОГО отрисовано (1290 ₸)"); + + // 4) Повторный скан того же товара → штучный инкремент (2 шт), не вторая строка. + vm.ScanInput = "4870001112223"; + vm.ScanCommand.Execute(null); + Pump(); + Check(vm.Lines.Count == 1 && vm.Lines[0].Quantity == 2m, "повторный скан штучного = +1 к количеству"); + Check(vm.Total == 2580m, $"ИТОГО после 2 шт = 2580, факт {vm.Total}"); + + // 5) Рендер кадра не пустой (Skia headless реально отрисовал экран). + var frame = window.CaptureRenderedFrame(); + Check(frame is Bitmap b && b.PixelSize.Width == 1366 && b.PixelSize.Height == 768, "кадр отрисован 1366×768"); + + // 6) Очистка чека (Esc). + vm.ClearCommand.Execute(null); + Pump(); + Check(vm.Lines.Count == 0 && vm.Total == 0m, "очистка чека обнуляет корзину"); + + // 7) Оплата → запись чека в офлайн-очередь с привязкой к смене. + svc.Printer = new NullPrinter(); + var shiftId = Guid.NewGuid(); var rpId = Guid.NewGuid(); + svc.Checkout.CurrentShift = new PosShift { Id = shiftId, Number = "С-TEST-1", RetailPointId = rpId, CashAccountId = Guid.NewGuid(), OpeningFloat = 0m }; + vm.ScanInput = "4870001112223"; + vm.ScanCommand.Execute(null); + Pump(); + vm.CommitPaymentAsync(2000m, 0m, 710m).GetAwaiter().GetResult(); + Pump(); + Check(store.PendingCount() == 1, "после оплаты 1 чек в офлайн-очереди"); + var queued = store.PendingSales().FirstOrDefault(); + Check(queued is not null && queued.CashShiftId == shiftId && queued.RetailPointId == rpId, "чек привязан к смене и точке"); + Check(queued is not null && queued.PaidCash == 2000m, "сумма оплаты сохранена"); + Check(vm.Lines.Count == 0, "корзина очищена после оплаты"); + + window.Close(); + Console.WriteLine(failures == 0 ? "[uitest] РЕЗУЛЬТАТ: OK" : $"[uitest] РЕЗУЛЬТАТ: ПРОВАЛЕНО ({failures})"); + return failures == 0 ? 0 : 1; + } + + private static void Pump() { for (int i = 0; i < 4; i++) Dispatcher.UIThread.RunJobs(); } + + /// Принтер-заглушка для теста (без записи файлов/портов). + private sealed class NullPrinter : foodmarket.Pos.Hardware.IReceiptPrinter + { + public string Name => "null"; + public Task PrintAsync(foodmarket.Pos.Hardware.ReceiptDoc doc, CancellationToken ct = default) => Task.CompletedTask; + } +} diff --git a/src/food-market.pos/ViewLocator.cs b/src/food-market.pos/ViewLocator.cs new file mode 100644 index 0000000..2bdda33 --- /dev/null +++ b/src/food-market.pos/ViewLocator.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using foodmarket.Pos.ViewModels; + +namespace foodmarket.Pos; + +/// Сопоставляет ViewModel → View по имени (…ViewModel → …View). +public sealed class ViewLocator : IDataTemplate +{ + public Control Build(object? data) + { + if (data is null) return new TextBlock { Text = "null" }; + var name = data.GetType().FullName!.Replace("ViewModels", "Views").Replace("ViewModel", "View"); + var type = Type.GetType(name); + return type is not null ? (Control)Activator.CreateInstance(type)! : new TextBlock { Text = "Не найдено: " + name }; + } + + public bool Match(object? data) => data is ViewModelBase; +} diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs new file mode 100644 index 0000000..1111155 --- /dev/null +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -0,0 +1,508 @@ +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 LoginViewModel : ViewModelBase +{ + private readonly AppServices? _svc; + [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; + + public Action? OnLoggedIn; + + public LoginViewModel() { } + public LoginViewModel(AppServices svc) { _svc = svc; } + + [RelayCommand] + private async Task LoginAsync() + { + if (_svc is null) { OnLoggedIn?.Invoke(); return; } + Error = null; Busy = true; + 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); + + // Кто вошёл + роли. + 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; + } + catch { session.CashierName = Username.Trim(); } + + // Привязка к точке продаж (первая активная, к которой есть доступ). + try + { + var points = await _svc.Api!.RetailPointsAsync(); + var point = points.items.FirstOrDefault(p => p.isActive) ?? points.items.FirstOrDefault(); + if (point is not null) session.RetailPointId = point.id; + } + catch { /* офлайн — точку подтянем позже */ } + + _svc.Tokens.Save(session); + _svc.Session = session; + OnLoggedIn?.Invoke(); + } + catch (AuthException ex) { Error = ex.Message; } + catch (Exception ex) { Error = "Нет связи с сервером: " + ex.Message; } + finally { Busy = false; } + } + + public static LoginViewModel Sample() => new() { Username = "cashier@food-market.kz" }; +} + +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 LineTotal => Math.Max(0m, decimal.Round(Price * Quantity, 2) - Discount); + public string QtyText => Quantity == decimal.Truncate(Quantity) && Unit == "шт" ? Quantity.ToString("0") : Quantity.ToString("0.###"); + partial void OnQuantityChanged(decimal value) { OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); } + partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(LineTotal)); +} + +/// Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина, +/// итоги, скидка, оплата. Горячие клавиши назначены в оболочке/View. +public partial class SaleViewModel : ViewModelBase +{ + private readonly AppServices? _svc; + private readonly Cart _cart = 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 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; } +} + +public partial class ReturnViewModel : ViewModelBase +{ + private readonly AppServices? _svc; + [ObservableProperty] private string _receiptNumber = ""; + [ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер"; + [ObservableProperty] private bool _busy; + public ObservableCollection Lines { get; } = new(); + public decimal RefundTotal => Lines.Sum(l => l.LineTotal); + + public ReturnViewModel() { } + public ReturnViewModel(AppServices svc) { _svc = svc; } + + [RelayCommand] + private Task Lookup() + { + // Возврат по чеку: поиск чека-основания по номеру/QR. Серверный разбор + // чека выполняется при проведении возврата; здесь — заготовка для + // интерактивного выбора позиций (полный UI выбора — вне MVP-задачи). + if (string.IsNullOrWhiteSpace(ReceiptNumber)) { Message = "Введите номер чека или отсканируйте QR"; return Task.CompletedTask; } + Message = $"Чек {ReceiptNumber}: отметьте возвращаемые позиции и подтвердите возврат"; + return Task.CompletedTask; + } + + public static ReturnViewModel Sample() + { + var vm = new ReturnViewModel { ReceiptNumber = "Ч-2026-000128", Message = "Чек найден — отметьте возвращаемые позиции" }; + vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Молоко 2.5% 1л", Unit = "шт", Price = 450m, Quantity = 1 }); + vm.Lines.Add(new SaleLineVm { ProductId = Guid.NewGuid(), Name = "Хлеб «Семейный»", Unit = "шт", Price = 280m, Quantity = 1 }); + return vm; + } +} diff --git a/src/food-market.pos/Views/LoginView.axaml b/src/food-market.pos/Views/LoginView.axaml new file mode 100644 index 0000000..d8fbadd --- /dev/null +++ b/src/food-market.pos/Views/LoginView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + +