feat(pos-ui): WPF→Avalonia 11, живая касса кассира (Win/Linux)

- Кроссплатформенный 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 04:58:39 +05:00
parent 2b63bc9c66
commit fcd4a69624
31 changed files with 1695 additions and 38 deletions

View file

@ -0,0 +1,35 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:foodmarket.Pos"
x:Class="foodmarket.Pos.App"
RequestedThemeVariant="Dark">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<!-- Крупный кассовый стиль: большие кнопки, читаемые цифры, без анимаций. -->
<Style Selector="Button.pos">
<Setter Property="Height" Value="56" />
<Setter Property="FontSize" Value="18" />
<Setter Property="Padding" Value="18,0" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#2d3748" />
<Setter Property="Foreground" Value="#f7fafc" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="#16a34a" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="#dc2626" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="TextBox.pos">
<Setter Property="FontSize" Value="20" />
<Setter Property="Height" Value="52" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Application.Styles>
</Application>

View file

@ -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();
}
}

View file

@ -1,7 +0,0 @@
<Application x:Class="foodmarket.Pos.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

View file

@ -1,5 +0,0 @@
namespace foodmarket.Pos;
public partial class App : System.Windows.Application
{
}

View file

@ -0,0 +1,20 @@
namespace foodmarket.Pos.Hardware;
public sealed record FiscalResult(bool Fiscalized, string? FiscalNumber, string? QrPayload, string? Error);
/// <summary>Фискализация чека у ОФД-оператора (онлайн-ККМ Казахстана). СЕЙЧАС —
/// только интерфейс и заглушка (<see cref="NullFiscalProvider"/>): фискализацию
/// не делаем, чек проходит без фискального номера. Что потребуется для реальной
/// фискализации KZ — см. docs/pos.md раздел «Фискализация».</summary>
public interface IFiscalProvider
{
bool Enabled { get; }
Task<FiscalResult> RegisterSaleAsync(object sale, CancellationToken ct = default);
}
public sealed class NullFiscalProvider : IFiscalProvider
{
public bool Enabled => false;
public Task<FiscalResult> RegisterSaleAsync(object sale, CancellationToken ct = default)
=> Task.FromResult(new FiscalResult(false, null, null, null));
}

View file

@ -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);
/// <summary>Чек/отчёт в абстрактном виде — рендерится либо в ESC/POS, либо в текст/PDF.</summary>
public sealed class ReceiptDoc
{
public string Title { get; init; } = "";
public List<ReceiptLine> 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);
}
/// <summary>Генератор команд ESC/POS (чистая логика → байты; тестируется).</summary>
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<byte>();
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;
}
}
/// <summary>ESC/POS-принтер: пишет байты в поток (COM/USB raw/сетевой сокет).
/// Транспорт выбирается строкой: "COM1", "/dev/usb/lp0", "tcp:192.168.1.50:9100".</summary>
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);
}
}
}
/// <summary>Запасной «принтер»: сохраняет чек как текстовый файл (для обычного
/// принтера/PDF-экспорта и для headless-проверки). Полноценный PDF — через
/// QuestPDF (MIT) в будущем; сейчас текстовый слип в папке чеков.</summary>
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;
}

View file

@ -0,0 +1,82 @@
using System.Globalization;
using System.IO.Ports;
namespace foodmarket.Pos.Hardware;
public interface IScale
{
string Name { get; }
/// <summary>Считать текущий вес в кг. Для ручных весов — последнее введённое значение.</summary>
Task<decimal> ReadWeightKgAsync(CancellationToken ct = default);
}
/// <summary>Ручной ввод веса (кассир вбивает вес с ярлыка/памяти) — запасной путь,
/// когда весов нет или нет связи с ними.</summary>
public sealed class ManualScale : IScale
{
public decimal LastValue { get; set; }
public string Name => "Ручной ввод веса";
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue);
}
/// <summary>Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы
/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер
/// выделен и тестируется отдельно; реальный COM-поток — в <see cref="ComScale"/>.</summary>
public static class ScaleProtocol
{
/// <summary>CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем
/// первое число, трактуем как кг (если явно граммы — делим).</summary>
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);
}
/// <summary>Масса-К (ASCII-режим): строка с весом в кг, например "1.234".</summary>
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;
}
}
/// <summary>Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт
/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux
/// (System.IO.Ports).</summary>
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<decimal> 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;
}
}

View file

@ -1,11 +0,0 @@
<Window x:Class="foodmarket.Pos.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="food-market POS" Height="600" Width="900"
WindowStartupLocation="CenterScreen">
<Grid>
<TextBlock Text="food-market POS (каркас)"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="24" />
</Grid>
</Window>

View file

@ -1,9 +0,0 @@
namespace foodmarket.Pos;
public partial class MainWindow : System.Windows.Window
{
public MainWindow()
{
InitializeComponent();
}
}

View file

@ -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<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View file

@ -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;
/// <summary>Лёгкий контейнер сервисов кассы (без тяжёлого DI — быстрый старт).
/// Держит локальное хранилище, http-клиент, авторизацию, Refit-API (после входа),
/// сервис синхронизации и оборудование (печать/весы/фискализация-заглушка).</summary>
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;
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
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);
}
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо
/// (без файлов на диске и без токен-стора).</summary>
public AppServices(PosStore store)
{
Store = store;
Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Auth = new AuthService(Http);
Tokens = new InMemoryTokenStore();
}
/// <summary>Поднять Refit-клиент на базовый URL сервера с Bearer-токеном сессии.</summary>
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<IServerApi>(client);
Sync = new SyncService(Store, Api);
}
}
/// <summary>Подставляет Bearer-токен в каждый запрос.</summary>
internal sealed class AuthHeaderHandler : DelegatingHandler
{
private readonly Func<string?> _token;
public AuthHeaderHandler(Func<string?> token, HttpMessageHandler inner) : base(inner) { _token = token; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var t = _token();
if (!string.IsNullOrEmpty(t)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t);
return base.SendAsync(request, ct);
}
}

View file

@ -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;
/// <summary>Оркестрация завершения чека: собрать серверный чек из корзины
/// (с привязкой к смене/точке и кассиру), записать в локальную очередь
/// (офлайн-устойчиво), попытаться сразу выгрузить на сервер и напечатать чек.
/// Запись в очередь — источник правды: даже если сеть/печать упали, продажа
/// не теряется и уйдёт при следующей синхронизации.</summary>
public sealed class CheckoutService
{
private readonly AppServices _svc;
public CheckoutService(AppServices svc) => _svc = svc;
/// <summary>Открытая смена кассы (локальная проекция). Без неё продавать нельзя.</summary>
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);
/// <summary>Завершить продажу/возврат. Возвращает статус (ушло ли сразу,
/// сколько осталось в очереди, напечатался ли чек).</summary>
public async Task<CommitResult> 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;
}
}

View file

@ -0,0 +1,31 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using foodmarket.Pos.Core.Auth;
namespace foodmarket.Pos.Services;
/// <summary>Хранение сессии кассира под Windows с шифрованием DPAPI (ключ
/// привязан к учётной записи пользователя Windows, scope = CurrentUser). Файл
/// содержит refresh-токен — DPAPI не даёт прочитать его другому пользователю
/// или на другой машине. На Unix используется базовый <see cref="FileTokenStore"/>
/// с правами 0600.</summary>
[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);
}
}

View file

@ -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;
/// <summary>Headless-рендер экранов кассы в PNG (без дисплея, Skia). По порядку
/// сценария, в 1920×1080 и 1366×768 → docs/pos-screenshots/ + index.html.
/// Запуск: dotnet run -- --screenshots [outputDir]</summary>
public static class ScreenshotRunner
{
private sealed record Shot(string Name, string Caption, Func<Control> 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<App>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont()
.SetupWithoutStarting();
var shots = new List<Shot>
{
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("<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\"><title>Касса food-market — скриншоты</title>");
sb.Append("<style>body{font:14px system-ui,Segoe UI,Roboto;margin:0;background:#0f172a;color:#e2e8f0}" +
"header{padding:18px 24px;background:#1e293b}h1{margin:0;font-size:20px}" +
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px;padding:20px}" +
"figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}" +
"img{width:100%;display:block;background:#000;cursor:zoom-in}figcaption{padding:8px 10px;color:#cbd5e1;font-size:13px}</style></head><body>");
sb.Append($"<header><h1>Касса food-market — скриншоты процессов</h1><div style=\"color:#94a3b8\">кадров: {shots.Count}</div></header><div class=\"grid\">");
foreach (var (file, caption) in shots)
sb.Append($"<figure><a href=\"{file}\" target=\"_blank\"><img src=\"{file}\" loading=\"lazy\"></a><figcaption>{System.Net.WebUtility.HtmlEncode(caption)}</figcaption></figure>");
sb.Append("</div></body></html>");
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();
}
}

View file

@ -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;
/// <summary>CLI-smoke кассы: сценарий «открыть смену → продажи → возврат → закрыть»
/// + офлайн-очередь, через логику pos.core (без UI/дисплея). Работает одинаково
/// на Windows и Linux — для проверки на реальной машине. Запуск: --smoke</summary>
public static class SmokeRunner
{
public static int Run(string[] args)
{
var lines = new List<string>();
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<Guid> SeedCatalog(PosStore store, bool big)
{
var n = big ? 30_000 : 50;
var products = new List<ProductSyncDto>(n);
var prices = new List<PriceSyncDto>(n);
var ids = new List<Guid>();
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<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
});
return ids;
}
}

View file

@ -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;
/// <summary>Headless UI-автотест (Avalonia.Headless + Skia, работает на Linux
/// без дисплея / под xvfb). Прогоняет реальный экран продажи сквозь привязки:
/// сид каталога → ввод штрихкода → команда Scan → в визуальном дереве должна
/// появиться строка с названием товара, а ИТОГО — отражать цену. Любое
/// расхождение → ненулевой код возврата (для CI). Запуск: --uitest</summary>
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<App>()
.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<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(),
DeletedProductIds = Array.Empty<Guid>(),
});
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<TextBlock>().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(); }
/// <summary>Принтер-заглушка для теста (без записи файлов/портов).</summary>
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;
}
}

View file

@ -0,0 +1,20 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using foodmarket.Pos.ViewModels;
namespace foodmarket.Pos;
/// <summary>Сопоставляет ViewModel → View по имени (…ViewModel → …View).</summary>
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;
}

View file

@ -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 { }
/// <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 _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)); }
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
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; }
/// <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 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));
}
/// <summary>Экран продажи — сердце кассы. Пробитие по сканеру/поиску, корзина,
/// итоги, скидка, оплата. Горячие клавиши назначены в оболочке/View.</summary>
public partial class SaleViewModel : ViewModelBase
{
private readonly AppServices? _svc;
private readonly Cart _cart = new();
public ObservableCollection<SaleLineVm> 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;
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
public Action<CheckoutService.CommitResult>? 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); // без оболочки (тест/демо) — оплата наличными «под расчёт»
}
/// <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 = "Чек очищен"; }
/// <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 Оплата";
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)); }
/// <summary>Вызывается при подтверждении оплаты (cash, card, change).</summary>
public Func<decimal, decimal, decimal, Task>? 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 = "Смешанная" };
}
/// <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);
partial void OnOpeningFloatChanged(decimal v) => RaiseCalc();
partial void OnActualCashChanged(decimal v) => RaiseCalc();
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); }
/// <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; }
}
public partial class ReturnViewModel : ViewModelBase
{
private readonly AppServices? _svc;
[ObservableProperty] private string _receiptNumber = "";
[ObservableProperty] private string _message = "Отсканируйте QR чека или введите номер";
[ObservableProperty] private bool _busy;
public ObservableCollection<SaleLineVm> 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;
}
}

View file

@ -0,0 +1,28 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.LoginView"
x:DataType="vm:LoginViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="440" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="FOOD MARKET" Foreground="#22c55e" FontWeight="Bold" FontSize="22" HorizontalAlignment="Center"/>
<TextBlock Text="Вход кассира" Foreground="#f8fafc" FontSize="18" HorizontalAlignment="Center"/>
<StackPanel Spacing="4">
<TextBlock Text="Адрес сервера" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="16"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Логин кассира" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Username}" FontSize="18"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="PIN / пароль" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="56" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class LoginView : UserControl
{
public LoginView() => InitializeComponent();
}

View file

@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1280" Height="800" MinWidth="1024" MinHeight="680"
Background="#0f172a" Title="Касса food-market"
WindowStartupLocation="CenterScreen">
<Panel>
<Grid RowDefinitions="54,*,34">
<!-- Статус-бар -->
<Border Grid.Row="0" Background="#1e293b" Padding="16,0">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="FOOD MARKET · Касса" FontWeight="Bold" Foreground="#22c55e" FontSize="16" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Text="{Binding CashierName}" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ShiftNumber}" Foreground="#93c5fd" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding UpdateMessage}" Foreground="#fde68a" VerticalAlignment="Center" HorizontalAlignment="Center"
IsVisible="{Binding UpdateMessage, Converter={x:Static ObjectConverters.IsNotNull}}" FontSize="12"/>
<TextBlock Grid.Column="3" Margin="18,0" Text="{Binding NetBadge}" VerticalAlignment="Center" Foreground="#34d399"/>
<TextBlock Grid.Column="4" Text="{Binding QueueBadge}" Foreground="#fbbf24" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Текущая страница -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}" Margin="0"/>
<!-- Нижняя панель навигации -->
<Border Grid.Row="2" Background="#1e293b">
<StackPanel Orientation="Horizontal" Spacing="2" HorizontalAlignment="Center">
<TextBlock Text="F1 Помощь · F2 Поиск · F7 Внесение/Выплата · F9 Возврат · F10 Смена · F12 Оплата · Esc Отмена"
Foreground="#94a3b8" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
</Border>
</Grid>
<!-- Модальная оплата поверх экрана продажи -->
<Border Background="#cc0b1220" IsVisible="{Binding IsPaymentOpen}">
<ContentControl Content="{Binding Payment}"/>
</Border>
</Panel>
</Window>

View file

@ -0,0 +1,34 @@
using Avalonia.Controls;
using Avalonia.Input;
using foodmarket.Pos.ViewModels;
namespace foodmarket.Pos.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
AddHandler(KeyDownEvent, OnKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
}
/// <summary>Горячие клавиши кассы (работа без мыши). F-клавиши и Esc/Del
/// обрабатываются оболочкой; ввод в текстовые поля не перехватываем, кроме
/// функциональных клавиш.</summary>
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
string? key = e.Key switch
{
Key.F1 => "F1", Key.F2 => "F2", Key.F7 => "F7", Key.F9 => "F9",
Key.F10 => "F10", Key.F12 => "F12",
Key.Escape => "Escape",
// Delete — только когда фокус не в поле ввода (иначе это правка текста).
Key.Delete when e.Source is not TextBox => "Delete",
_ => null,
};
if (key is null) return;
vm.HandleHotkey(key);
e.Handled = true;
}
}

View file

@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.PaymentView"
x:DataType="vm:PaymentViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="560" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="Оплата чека" FontSize="24" FontWeight="Bold" Foreground="#f8fafc" HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="К оплате" Foreground="#94a3b8" FontSize="18" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="30" FontWeight="Bold"/>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Наличные" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCash}" FontSize="22"/>
</Grid>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Картой" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCard}" FontSize="22"/>
</Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="СДАЧА" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Change, StringFormat='{}{0:N0} ₸'}" Foreground="#fbbf24" FontSize="30" FontWeight="Bold"/>
</Grid>
</Border>
<Grid ColumnDefinitions="*,2*">
<Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/>
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="20" Content="Enter · Провести и напечатать чек" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}"/>
</Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class PaymentView : UserControl
{
public PaymentView() => InitializeComponent();
}

View file

@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ReturnView"
x:DataType="vm:ReturnViewModel">
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
<Grid ColumnDefinitions="*,160">
<TextBox Classes="pos" Watermark="Номер чека или скан QR" Text="{Binding ReceiptNumber}"/>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}" IsDefault="True"/>
</Grid>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa"/>
</StackPanel>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="Auto,*,120,120" Height="46">
<CheckBox IsChecked="True" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Quantity, StringFormat='{}{0:0.###}'}" Foreground="#e5e7eb" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fff" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="24" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Classes="pos danger" Height="56" Content="Оформить возврат"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class ReturnView : UserControl
{
public ReturnView() => InitializeComponent();
}

View file

@ -0,0 +1,69 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.SaleView"
x:DataType="vm:SaleViewModel">
<Grid ColumnDefinitions="*,380" Background="#0f172a">
<!-- Левая часть: чек -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="16,12,8,12">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
<TextBox Grid.Column="0" Classes="pos" Watermark="Штрихкод / поиск товара (Enter)"
Text="{Binding ScanInput}" FontSize="22">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/>
</TextBox.KeyBindings>
</TextBox>
</Grid>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,90,120,120" Background="#1f2937" Height="38">
<TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="3" Text="Сумма " Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
</Grid>
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="*,90,120,120" Height="46">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<TextBlock Grid.Row="2" Text="{Binding Message}" Foreground="#60a5fa" Margin="4,8,0,0" FontSize="14"/>
</Grid>
<!-- Правая часть: итоги + оплата -->
<Border Grid.Column="1" Background="#1e293b" Margin="8,12,16,12" CornerRadius="10" Padding="18">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="6">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Позиций" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ItemCount}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Подытог" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Subtotal, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Скидка" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Discount, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
</StackPanel>
<Separator Grid.Row="1" Margin="0,12" Background="#334155"/>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="ИТОГО" Foreground="#f8fafc" FontSize="22" FontWeight="Bold" VerticalAlignment="Bottom"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/>
</Grid>
<StackPanel Grid.Row="4" Spacing="8">
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Content="F12 · ОПЛАТА"/>
<Grid ColumnDefinitions="*,*" >
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Скидка"/>
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Esc · Очистить" Command="{Binding ClearCommand}"/>
</Grid>
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class SaleView : UserControl
{
public SaleView() => InitializeComponent();
}

View file

@ -0,0 +1,36 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ShiftView"
x:DataType="vm:ShiftViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="620" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Z-отчёт смены" FontSize="24" FontWeight="Bold" Foreground="#f8fafc"/>
<TextBlock Grid.Column="1" Text="{Binding Number}" Foreground="#93c5fd" FontSize="18" VerticalAlignment="Center"/>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Размен (на открытие)" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding OpeningFloat, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCash, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи картой" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCard, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Возвраты наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ReturnsCash, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Внесения" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerIn, StringFormat='+{0:N0} ₸'}" Foreground="#86efac"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Выплаты" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerOut, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Должно быть в кассе" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ExpectedCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Факт (пересчёт)" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ActualCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="12">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Разница" Foreground="#f8fafc" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding Difference, StringFormat='{}{0:+#,0;-#,0;0} ₸'}" Foreground="#fbbf24" FontSize="24" FontWeight="Bold"/></Grid>
</Border>
<Grid ColumnDefinitions="*,*,*">
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Открыть смену" Command="{Binding OpenShiftCommand}" IsEnabled="{Binding !IsOpen}"/>
<Button Grid.Column="1" Classes="pos" Margin="4,0" HorizontalAlignment="Stretch" Content="Внести" Command="{Binding DepositCashCommand}" IsEnabled="{Binding IsOpen}"/>
<Button Grid.Column="2" Classes="pos" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
</Grid>
<Button Classes="pos danger" HorizontalAlignment="Stretch" Height="52" Content="Закрыть смену (Z-отчёт)" Command="{Binding CloseShiftCommand}" IsEnabled="{Binding IsOpen}"/>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace foodmarket.Pos.Views;
public partial class ShiftView : UserControl
{
public ShiftView() => InitializeComponent();
}

View file

@ -1,14 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>foodmarket.Pos</RootNamespace>
<AssemblyName>foodmarket.Pos</AssemblyName>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Allow building on macOS/Linux for CI; running still requires Windows -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ImplicitUsings>enable</ImplicitUsings>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon></ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@ -17,9 +16,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="Polly" />
<PackageReference Include="System.IO.Ports" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup>
</Project>