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:
parent
2b63bc9c66
commit
fcd4a69624
35
src/food-market.pos/App.axaml
Normal file
35
src/food-market.pos/App.axaml
Normal 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>
|
||||||
23
src/food-market.pos/App.axaml.cs
Normal file
23
src/food-market.pos/App.axaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
namespace foodmarket.Pos;
|
|
||||||
|
|
||||||
public partial class App : System.Windows.Application
|
|
||||||
{
|
|
||||||
}
|
|
||||||
20
src/food-market.pos/Hardware/Fiscal.cs
Normal file
20
src/food-market.pos/Hardware/Fiscal.cs
Normal 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));
|
||||||
|
}
|
||||||
105
src/food-market.pos/Hardware/Printing.cs
Normal file
105
src/food-market.pos/Hardware/Printing.cs
Normal 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;
|
||||||
|
}
|
||||||
82
src/food-market.pos/Hardware/Scale.cs
Normal file
82
src/food-market.pos/Hardware/Scale.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
namespace foodmarket.Pos;
|
|
||||||
|
|
||||||
public partial class MainWindow : System.Windows.Window
|
|
||||||
{
|
|
||||||
public MainWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
src/food-market.pos/Program.cs
Normal file
28
src/food-market.pos/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
82
src/food-market.pos/Services/AppServices.cs
Normal file
82
src/food-market.pos/Services/AppServices.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/food-market.pos/Services/CheckoutService.cs
Normal file
86
src/food-market.pos/Services/CheckoutService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/food-market.pos/Services/DpapiTokenStore.cs
Normal file
31
src/food-market.pos/Services/DpapiTokenStore.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/food-market.pos/Tooling/ScreenshotRunner.cs
Normal file
105
src/food-market.pos/Tooling/ScreenshotRunner.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/food-market.pos/Tooling/SmokeRunner.cs
Normal file
104
src/food-market.pos/Tooling/SmokeRunner.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/food-market.pos/Tooling/UiTestRunner.cs
Normal file
129
src/food-market.pos/Tooling/UiTestRunner.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/food-market.pos/ViewLocator.cs
Normal file
20
src/food-market.pos/ViewLocator.cs
Normal 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;
|
||||||
|
}
|
||||||
508
src/food-market.pos/ViewModels/ViewModels.cs
Normal file
508
src/food-market.pos/ViewModels/ViewModels.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/food-market.pos/Views/LoginView.axaml
Normal file
28
src/food-market.pos/Views/LoginView.axaml
Normal 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>
|
||||||
8
src/food-market.pos/Views/LoginView.axaml.cs
Normal file
8
src/food-market.pos/Views/LoginView.axaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Views;
|
||||||
|
|
||||||
|
public partial class LoginView : UserControl
|
||||||
|
{
|
||||||
|
public LoginView() => InitializeComponent();
|
||||||
|
}
|
||||||
45
src/food-market.pos/Views/MainWindow.axaml
Normal file
45
src/food-market.pos/Views/MainWindow.axaml
Normal 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>
|
||||||
34
src/food-market.pos/Views/MainWindow.axaml.cs
Normal file
34
src/food-market.pos/Views/MainWindow.axaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/food-market.pos/Views/PaymentView.axaml
Normal file
37
src/food-market.pos/Views/PaymentView.axaml
Normal 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>
|
||||||
8
src/food-market.pos/Views/PaymentView.axaml.cs
Normal file
8
src/food-market.pos/Views/PaymentView.axaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Views;
|
||||||
|
|
||||||
|
public partial class PaymentView : UserControl
|
||||||
|
{
|
||||||
|
public PaymentView() => InitializeComponent();
|
||||||
|
}
|
||||||
37
src/food-market.pos/Views/ReturnView.axaml
Normal file
37
src/food-market.pos/Views/ReturnView.axaml
Normal 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>
|
||||||
8
src/food-market.pos/Views/ReturnView.axaml.cs
Normal file
8
src/food-market.pos/Views/ReturnView.axaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Views;
|
||||||
|
|
||||||
|
public partial class ReturnView : UserControl
|
||||||
|
{
|
||||||
|
public ReturnView() => InitializeComponent();
|
||||||
|
}
|
||||||
69
src/food-market.pos/Views/SaleView.axaml
Normal file
69
src/food-market.pos/Views/SaleView.axaml
Normal 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>
|
||||||
8
src/food-market.pos/Views/SaleView.axaml.cs
Normal file
8
src/food-market.pos/Views/SaleView.axaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Views;
|
||||||
|
|
||||||
|
public partial class SaleView : UserControl
|
||||||
|
{
|
||||||
|
public SaleView() => InitializeComponent();
|
||||||
|
}
|
||||||
36
src/food-market.pos/Views/ShiftView.axaml
Normal file
36
src/food-market.pos/Views/ShiftView.axaml
Normal 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>
|
||||||
8
src/food-market.pos/Views/ShiftView.axaml.cs
Normal file
8
src/food-market.pos/Views/ShiftView.axaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Views;
|
||||||
|
|
||||||
|
public partial class ShiftView : UserControl
|
||||||
|
{
|
||||||
|
public ShiftView() => InitializeComponent();
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>foodmarket.Pos</RootNamespace>
|
<RootNamespace>foodmarket.Pos</RootNamespace>
|
||||||
<AssemblyName>foodmarket.Pos</AssemblyName>
|
<AssemblyName>foodmarket.Pos</AssemblyName>
|
||||||
<UseWPF>true</UseWPF>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<!-- Allow building on macOS/Linux for CI; running still requires Windows -->
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ApplicationIcon></ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -17,9 +16,15 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
|
||||||
<PackageReference Include="Refit.HttpClientFactory" />
|
<PackageReference Include="Refit.HttpClientFactory" />
|
||||||
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="System.IO.Ports" />
|
<PackageReference Include="System.IO.Ports" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue