From ff1580d1621dfbafff821e026d328b398600e512 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 11 Oct 2026 08:37:55 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BC=D0=B0=20=C2=ABALL=20MARKET=20=E2=80=94=20=D0=9F?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D1=83=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=C2=BB=20?= =?UTF-8?q?(=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0/=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0/=D0=91=D0=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приоритет владельца: sysadmin проверяет весы/принтер/сканер на ноутбуке магазина без входа в кассу. Отдельный exe (food-market.pos.hwcheck), ссылается ТОЛЬКО на pos.core ради Hardware/* — не умеет быть кассой (нет логина, сервера, БД, токенов, сетевых запросов). - pos.core/Hardware/HardwareProbe.cs: детект COM-портов (занят/свободен, VID/PID, описание) и принтеров Windows (имя/порт/в сети/число заданий) — только чтение, очередь не трогаем. WMI под OperatingSystem.IsWindows(); на Linux — базовый COM. - pos.core/Hardware/BarcodeScan.cs: контрольная цифра EAN-13/EAN-8 и оценка «сканер vs ручной набор» по среднему интервалу между символами (чистая логика). - food-market.pos.hwcheck: окно на Avalonia (код, без XAML), светлая тема, русский. Весы (Найти/Прочитать/Запустить-Остановить, live-вес ~350 мс, сырой hex, ручной выбор порта/скорости/протокола), COM-порты с пометкой «занят», принтеры (только чтение), сканер (EAN + тип ввода), «Сохранить/Копировать отчёт». Тестовая печать ВЫКЛ по умолчанию, включается только --allow-test-print. - tests: EAN контрольная цифра (13/8, валид/битый) + классификация ввода. 50/50. Co-Authored-By: Claude Opus 4.8 --- food-market.sln | 7 + .../Hardware/BarcodeScan.cs | 58 +++ .../Hardware/HardwareProbe.cs | 110 ++++++ .../food-market.pos.core.csproj | 3 + src/food-market.pos.hwcheck/App.cs | 22 ++ src/food-market.pos.hwcheck/MainWindow.cs | 373 ++++++++++++++++++ src/food-market.pos.hwcheck/Program.cs | 28 ++ src/food-market.pos.hwcheck/WinRawPrint.cs | 60 +++ .../food-market.pos.hwcheck.csproj | 23 ++ .../food-market.UnitTests/BarcodeScanTests.cs | 46 +++ 10 files changed, 730 insertions(+) create mode 100644 src/food-market.pos.core/Hardware/BarcodeScan.cs create mode 100644 src/food-market.pos.core/Hardware/HardwareProbe.cs create mode 100644 src/food-market.pos.hwcheck/App.cs create mode 100644 src/food-market.pos.hwcheck/MainWindow.cs create mode 100644 src/food-market.pos.hwcheck/Program.cs create mode 100644 src/food-market.pos.hwcheck/WinRawPrint.cs create mode 100644 src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj create mode 100644 tests/food-market.UnitTests/BarcodeScanTests.cs diff --git a/food-market.sln b/food-market.sln index 540d9bc..cfa5179 100644 --- a/food-market.sln +++ b/food-market.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.hwcheck", "src\food-market.pos.hwcheck\food-market.pos.hwcheck.csproj", "{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,6 +72,10 @@ Global {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.Build.0 = Debug|Any CPU {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.ActiveCfg = Release|Any CPU {9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = Release|Any CPU + {18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} @@ -81,5 +87,6 @@ Global {B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} {D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28} {9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28} + {18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870} EndGlobalSection EndGlobal diff --git a/src/food-market.pos.core/Hardware/BarcodeScan.cs b/src/food-market.pos.core/Hardware/BarcodeScan.cs new file mode 100644 index 0000000..89bf0af --- /dev/null +++ b/src/food-market.pos.core/Hardware/BarcodeScan.cs @@ -0,0 +1,58 @@ +namespace foodmarket.Pos.Core.Hardware; + +/// Анализ ввода сканера ШК: валидность контрольной цифры EAN-13/EAN-8 и +/// оценка «HID-сканер vs ручной набор» по среднему интервалу между символами. +/// Чистая логика — покрыта юнит-тестами. +public static class BarcodeScan +{ + /// Проверить контрольную цифру EAN-13 или EAN-8. Для прочих длин — false + /// (валидность «не применимо»). + public static bool IsValidEan(string code) + { + if (string.IsNullOrEmpty(code) || !code.All(char.IsDigit)) return false; + if (code.Length != 13 && code.Length != 8) return false; + int sum = 0; + // Крайняя правая цифра — контрольная; веса чередуются 1/3 справа налево (без контрольной). + for (int i = 0; i < code.Length - 1; i++) + { + int digit = code[code.Length - 2 - i] - '0'; + sum += (i % 2 == 0) ? digit * 3 : digit; + } + int check = (10 - sum % 10) % 10; + return check == (code[^1] - '0'); + } + + public enum InputKind { Empty, Scanner, Manual, Unknown } + + public sealed record ScanAnalysis(string Code, int Length, bool IsEan, bool EanValid, + double AvgIntervalMs, InputKind Kind); + + /// Классифицировать ввод по символам и их временам (мс от начала). + /// Сканер печатает быстро и равномерно (обычно < 30 мс/символ); человек — + /// медленнее и неравномерно. Порог намеренно консервативный. + public static ScanAnalysis Analyze(string code, IReadOnlyList? charTimestampsMs, double scannerThresholdMs = 40) + { + var len = code.Length; + bool isEan = len is 13 or 8 && code.All(char.IsDigit); + bool eanValid = isEan && IsValidEan(code); + double avg = 0; + if (charTimestampsMs is { Count: > 1 }) + { + long span = charTimestampsMs[^1] - charTimestampsMs[0]; + avg = (double)span / (charTimestampsMs.Count - 1); + } + InputKind kind = len == 0 ? InputKind.Empty + : charTimestampsMs is not { Count: > 1 } ? InputKind.Unknown + : avg <= scannerThresholdMs ? InputKind.Scanner + : InputKind.Manual; + return new ScanAnalysis(code, len, isEan, eanValid, avg, kind); + } + + public static string KindRu(InputKind k) => k switch + { + InputKind.Scanner => "похоже на сканер (быстрый равномерный ввод)", + InputKind.Manual => "похоже на ручной набор", + InputKind.Empty => "пусто", + _ => "не определено", + }; +} diff --git a/src/food-market.pos.core/Hardware/HardwareProbe.cs b/src/food-market.pos.core/Hardware/HardwareProbe.cs new file mode 100644 index 0000000..bc5e6f3 --- /dev/null +++ b/src/food-market.pos.core/Hardware/HardwareProbe.cs @@ -0,0 +1,110 @@ +using System.IO.Ports; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +namespace foodmarket.Pos.Core.Hardware; + +/// Детект оборудования для диагностики (COM-порты, принтеры Windows). +/// Только чтение: порты открываем→закрываем, очередь принтера не трогаем. +/// WMI-части — Windows-only (под OperatingSystem.IsWindows()); на Linux вернётся +/// базовый список COM из System.IO.Ports. +public static class HardwareProbe +{ + public sealed record ComPortInfo(string Port, bool Busy, string? Description, + string? Vid, string? Pid, string? Manufacturer, string? OpenError); + + public sealed record PrinterInfo(string Name, string? Port, string? Driver, + bool IsDefault, bool Offline, int QueueJobs); + + public static List ListComPorts() + { + var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new(); + var names = SafePortNames(); + foreach (var w in wmi) if (!names.Contains(w.port, StringComparer.OrdinalIgnoreCase)) names.Add(w.port); + names = names.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(NaturalOrder).ToList(); + + var result = new List(); + foreach (var p in names) + { + var w = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase)); + var (busy, err) = TryOpenClose(p); + result.Add(new ComPortInfo(p, busy, w.desc, w.vid, w.pid, w.manuf, err)); + } + return result; + } + + private static List SafePortNames() + { + try { return SerialPort.GetPortNames().Distinct().ToList(); } + catch { return new(); } + } + + private static int NaturalOrder(string p) + => int.TryParse(Regex.Match(p, @"\d+").Value, out var n) ? n : 9999; + + /// Открыть→закрыть без удержания. Busy=true, если занят другой программой. + public static (bool busy, string? err) TryOpenClose(string port) + { + try { using var sp = new SerialPort(port) { ReadTimeout = 300, WriteTimeout = 300 }; sp.Open(); sp.Close(); return (false, null); } + catch (UnauthorizedAccessException) { return (true, "занят другой программой (Access denied)"); } + catch (Exception ex) { return (false, ex.GetType().Name + ": " + ex.Message); } + } + + [SupportedOSPlatform("windows")] + private static List<(string port, string? desc, string? vid, string? pid, string? manuf)> QueryComWmi() + { + var list = new List<(string, string?, string?, string?, string?)>(); + try + { + using var s = new System.Management.ManagementObjectSearcher( + "SELECT Name, PNPDeviceID, Description, Manufacturer FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'"); + foreach (var o in s.Get()) + { + var name = o["Name"]?.ToString() ?? ""; + var m = Regex.Match(name, @"\((COM\d+)\)"); + if (!m.Success) continue; + var pnp = o["PNPDeviceID"]?.ToString() ?? ""; + var vid = Regex.Match(pnp, @"VID_([0-9A-Fa-f]{4})").Groups[1].Value; + var pid = Regex.Match(pnp, @"PID_([0-9A-Fa-f]{4})").Groups[1].Value; + list.Add((m.Groups[1].Value, name, vid.Length == 4 ? vid : null, pid.Length == 4 ? pid : null, + o["Manufacturer"]?.ToString())); + } + } + catch { /* WMI недоступен — порты всё равно есть из GetPortNames */ } + return list; + } + + public static List ListPrinters() + { + if (!OperatingSystem.IsWindows()) return new(); + return QueryPrintersWmi(); + } + + [SupportedOSPlatform("windows")] + private static List QueryPrintersWmi() + { + var list = new List(); + try + { + using var s = new System.Management.ManagementObjectSearcher( + "SELECT Name, PortName, DriverName, Default, WorkOffline FROM Win32_Printer"); + foreach (var o in s.Get()) + { + var name = o["Name"]?.ToString() ?? ""; + int jobs = 0; + try + { + using var js = new System.Management.ManagementObjectSearcher( + $"SELECT Name FROM Win32_PrintJob WHERE Name LIKE '{name.Replace("'", "''")},%'"); + jobs = js.Get().Count; + } + catch { /* очередь недоступна — оставляем 0, задания НЕ трогаем */ } + list.Add(new PrinterInfo( + name, o["PortName"]?.ToString(), o["DriverName"]?.ToString(), + o["Default"] as bool? ?? false, o["WorkOffline"] as bool? ?? false, jobs)); + } + } + catch { } + return list; + } +} diff --git a/src/food-market.pos.core/food-market.pos.core.csproj b/src/food-market.pos.core/food-market.pos.core.csproj index 49c4776..8666f87 100644 --- a/src/food-market.pos.core/food-market.pos.core.csproj +++ b/src/food-market.pos.core/food-market.pos.core.csproj @@ -12,6 +12,9 @@ + + diff --git a/src/food-market.pos.hwcheck/App.cs b/src/food-market.pos.hwcheck/App.cs new file mode 100644 index 0000000..96b7f7a --- /dev/null +++ b/src/food-market.pos.hwcheck/App.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Styling; +using Avalonia.Themes.Fluent; + +namespace foodmarket.Pos.HwCheck; + +public class App : Application +{ + public override void Initialize() + { + Styles.Add(new FluentTheme()); + RequestedThemeVariant = ThemeVariant.Light; // светлый, читаемо (требование) + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(Program.AllowTestPrint); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/food-market.pos.hwcheck/MainWindow.cs b/src/food-market.pos.hwcheck/MainWindow.cs new file mode 100644 index 0000000..2e8119a --- /dev/null +++ b/src/food-market.pos.hwcheck/MainWindow.cs @@ -0,0 +1,373 @@ +using System.Diagnostics; +using System.Text; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using foodmarket.Pos.Core.Hardware; + +namespace foodmarket.Pos.HwCheck; + +/// Окно «ALL MARKET — Проверка оборудования». Без входа, без сервера, без БД. +/// Весы (Масса-К-кадр), COM-порты, принтеры (только чтение), сканер, сохранение отчёта. +public sealed class MainWindow : Window +{ + private readonly bool _allowTestPrint; + private static readonly (string label, ScaleProto proto)[] Protos = + { + ("Масса-К (кадр, 57600)", ScaleProto.MassaKFrame), + ("Масса-К (ASCII)", ScaleProto.MassaKAscii), + ("CAS (ASCII)", ScaleProto.Cas), + }; + private static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 }; + + private readonly ComboBox _portBox = new() { MinWidth = 220 }; + private readonly ComboBox _baudBox = new() { ItemsSource = Bauds, SelectedIndex = 0, MinWidth = 100 }; + private readonly ComboBox _protoBox = new() { MinWidth = 220 }; + private readonly TextBlock _weight = new() { FontSize = 46, FontWeight = FontWeight.Bold, Text = "— кг" }; + private readonly TextBlock _stable = new() { Foreground = Brushes.Gray }; + private readonly TextBlock _hex = new() { FontFamily = new FontFamily("Consolas, monospace"), TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray }; + private readonly TextBlock _scaleStatus = new() { TextWrapping = TextWrapping.Wrap }; + private readonly ItemsControl _ports = new(); + private readonly ItemsControl _printers = new(); + private readonly TextBox _scan = new() { Watermark = "Наведите сканер и сканируйте ШК сюда…", FontSize = 18 }; + private readonly TextBlock _scanInfo = new() { TextWrapping = TextWrapping.Wrap }; + private readonly Button _testPrint; + private readonly TextBlock _footer = new() { Foreground = Brushes.Gray }; + + private readonly DispatcherTimer _timer; + private bool _reading; + private decimal? _lastKg; + private string _lastPrinterPick = ""; + private readonly List _scanTimes = new(); + private readonly Stopwatch _scanSw = new(); + + public MainWindow(bool allowTestPrint) + { + _allowTestPrint = allowTestPrint; + Title = "ALL MARKET — Проверка оборудования"; + Width = 1000; Height = 720; MinWidth = 820; MinHeight = 560; + Background = Brushes.White; + FontFamily = new FontFamily("Inter, Segoe UI, sans-serif"); + + _protoBox.ItemsSource = Protos.Select(p => p.label).ToArray(); + _protoBox.SelectedIndex = 0; + _testPrint = new Button { Content = "Тестовая печать", IsEnabled = allowTestPrint }; + _testPrint.Click += (_, _) => DoTestPrint(); + + Content = BuildLayout(); + RefreshPorts(); + RefreshPrinters(); + WireScanner(); + + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) }; + _timer.Tick += (_, _) => LiveTick(); + } + + private Control BuildLayout() + { + var header = new StackPanel { Spacing = 2, Margin = new Thickness(0, 0, 0, 10) }; + header.Children.Add(new TextBlock { Text = "ALL MARKET — Проверка оборудования", FontSize = 22, FontWeight = FontWeight.Bold }); + header.Children.Add(new TextBlock + { + Text = "Диагностика весов, принтера и сканера. Без входа в кассу, без сервера и без доступа к данным магазина." + + (_allowTestPrint ? " Тестовая печать РАЗРЕШЕНА (--allow-test-print)." : " Тестовая печать выключена."), + Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap, + }); + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), RowDefinitions = new RowDefinitions("*") }; + var left = new StackPanel { Spacing = 10, Margin = new Thickness(0, 0, 8, 0) }; + left.Children.Add(ScaleCard()); + left.Children.Add(PortsCard()); + var right = new StackPanel { Spacing = 10, Margin = new Thickness(8, 0, 0, 0) }; + right.Children.Add(PrintersCard()); + right.Children.Add(ScannerCard()); + Grid.SetColumn(left, 0); Grid.SetColumn(right, 1); + grid.Children.Add(left); grid.Children.Add(right); + + var footerBar = new DockPanel { Margin = new Thickness(0, 10, 0, 0) }; + var btns = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var save = new Button { Content = "Сохранить отчёт" }; save.Click += (_, _) => SaveReport(); + var copy = new Button { Content = "Копировать отчёт" }; copy.Click += (_, _) => CopyReport(); + btns.Children.Add(save); btns.Children.Add(copy); + DockPanel.SetDock(btns, Dock.Left); + footerBar.Children.Add(btns); + footerBar.Children.Add(_footer); + + var root = new DockPanel { Margin = new Thickness(16) }; + DockPanel.SetDock(header, Dock.Top); + DockPanel.SetDock(footerBar, Dock.Bottom); + root.Children.Add(header); + root.Children.Add(footerBar); + root.Children.Add(new ScrollViewer { Content = grid }); + return root; + } + + private static Border Card(string title, Control body) + { + var panel = new StackPanel { Spacing = 8 }; + panel.Children.Add(new TextBlock { Text = title, FontSize = 16, FontWeight = FontWeight.SemiBold }); + panel.Children.Add(body); + return new Border + { + BorderBrush = new SolidColorBrush(Color.Parse("#E2E8F0")), BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), Padding = new Thickness(14), Child = panel, + Background = new SolidColorBrush(Color.Parse("#FCFCFD")), + }; + } + + private Control ScaleCard() + { + var sel = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), RowDefinitions = new RowDefinitions("Auto,Auto,Auto") }; + void Row(int r, string label, Control c) { var t = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 4, 10, 4) }; c.Margin = new Thickness(0, 4, 0, 4); Grid.SetRow(t, r); Grid.SetColumn(t, 0); Grid.SetRow(c, r); Grid.SetColumn(c, 1); sel.Children.Add(t); sel.Children.Add(c); } + Row(0, "Порт", _portBox); Row(1, "Скорость", _baudBox); Row(2, "Протокол", _protoBox); + + var findBtn = new Button { Content = "Найти весы" }; findBtn.Click += (_, _) => DiscoverScale(); + var readBtn = new Button { Content = "Прочитать вес сейчас" }; readBtn.Click += (_, _) => _ = ReadOnce(); + var startBtn = new Button { Content = "Запустить (вес в реальном времени)" }; startBtn.Click += (_, _) => StartLive(); + var stopBtn = new Button { Content = "Остановить" }; stopBtn.Click += (_, _) => StopLive(); + var row1 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row1.Children.Add(findBtn); row1.Children.Add(readBtn); + var row2 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row2.Children.Add(startBtn); row2.Children.Add(stopBtn); + + var body = new StackPanel { Spacing = 10 }; + body.Children.Add(sel); + body.Children.Add(row1); + body.Children.Add(row2); + body.Children.Add(_weight); + body.Children.Add(_stable); + body.Children.Add(new TextBlock { Text = "Сырой кадр (hex):", Foreground = Brushes.Gray }); + body.Children.Add(_hex); + body.Children.Add(_scaleStatus); + return Card("Весы", body); + } + + private Control PortsCard() + { + var refresh = new Button { Content = "Обновить список портов" }; refresh.Click += (_, _) => RefreshPorts(); + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(refresh); + body.Children.Add(_ports); + return Card("COM-порты", body); + } + + private Control PrintersCard() + { + var refresh = new Button { Content = "Обновить список принтеров" }; refresh.Click += (_, _) => RefreshPrinters(); + var note = new TextBlock { Text = "Только чтение: очередь не очищается, задания не удаляются.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap }; + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(refresh); + body.Children.Add(_printers); + body.Children.Add(_testPrint); + body.Children.Add(note); + return Card("Принтеры Windows", body); + } + + private Control ScannerCard() + { + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(new TextBlock { Text = "Поставьте курсор в поле и отсканируйте штрихкод. Для EAN-13/EAN-8 проверяется контрольная цифра; по скорости ввода отличаем сканер от ручного набора.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap }); + body.Children.Add(_scan); + body.Children.Add(_scanInfo); + return Card("Сканер штрихкодов", body); + } + + // ───────────────────────── Весы ───────────────────────── + private string? SelectedPort => _portBox.SelectedItem as string; + private int SelectedBaud => _baudBox.SelectedItem is int b ? b : 57600; + private ScaleProto SelectedProto => Protos[Math.Max(0, _protoBox.SelectedIndex)].proto; + + private void StartLive() { if (SelectedPort is null) { _scaleStatus.Text = "Выберите порт или нажмите «Найти весы»."; return; } _timer.Start(); _scaleStatus.Text = "Идёт опрос весов…"; } + private void StopLive() { _timer.Stop(); _scaleStatus.Text = "Опрос остановлен."; } + + private void LiveTick() + { + if (_reading || SelectedPort is null) return; + _ = ReadOnce(); + } + + private async Task ReadOnce() + { + var port = SelectedPort; if (port is null) { _scaleStatus.Text = "Порт не выбран."; return; } + var baud = SelectedBaud; var proto = SelectedProto; + _reading = true; + try + { + var (kg, hex, status, stable) = await Task.Run(() => + { + try + { + using var t = new SystemSerialTransport(port, baud); + t.Open(); + var (r1, h1, e1) = SerialScaleReader.ReadDetailed(t, proto); + if (r1 is null) return ((decimal?)null, h1, e1 ?? "нет ответа", false); + // стабильность: второе чтение + Thread.Sleep(160); + var (r2, _, _) = SerialScaleReader.ReadDetailed(t, proto); + bool st = r2 is not null && r2.Value.Kg == r1.Value.Kg; + var rr = r2 ?? r1; + return ((decimal?)rr.Value.Kg, rr.Value.RawHex, $"флаг '{rr.Value.Flag}', ед. {rr.Value.Unit}, control {(rr.Value.Control is null ? "—" : "0x" + rr.Value.Control.Value.ToString("X2"))}", st); + } + catch (UnauthorizedAccessException) { return (null, "", "порт занят другой программой (закройте кассу МойСклад)", false); } + catch (Exception ex) { return (null, "", ex.GetType().Name + ": " + ex.Message, false); } + }); + if (kg is not null) + { + _lastKg = kg; + _weight.Text = $"{kg.Value:0.000} кг"; + _weight.Foreground = Brushes.Black; + _stable.Text = stable ? "стабильно" : "нестабильно (вес меняется)"; + _hex.Text = hex; + _scaleStatus.Text = status; + } + else + { + _weight.Foreground = Brushes.IndianRed; + _scaleStatus.Text = status; + } + } + finally { _reading = false; } + } + + private async void DiscoverScale() + { + StopLive(); + _scaleStatus.Text = "Ищу весы (57600 и Масса-К-кадр — первыми)…"; + var res = await Task.Run(() => ScaleAutodiscovery.Discover()); + if (res.Device is not null) + { + var d = res.Device; + _portBox.SelectedItem = d.Port; + _baudBox.SelectedItem = d.Baud; + _protoBox.SelectedIndex = Array.FindIndex(Protos, p => p.proto == d.Proto); + _weight.Text = $"{d.SampleKg:0.000} кг"; _weight.Foreground = Brushes.Black; + _hex.Text = d.RawHex; + _scaleStatus.Text = $"Найдены: {d.Port}, {d.Baud}, {d.Proto}."; + RefreshPorts(); + } + else + { + _scaleStatus.Text = res.BusyPorts.Count > 0 + ? $"Весы не найдены. Заняты порты: {string.Join(", ", res.BusyPorts)} (закройте кассу МойСклад)." + : "Весы не найдены ни на одном порту."; + } + } + + // ───────────────────────── Порты / принтеры ───────────────────────── + private List _portList = new(); + private List _printerList = new(); + + private void RefreshPorts() + { + _portList = HardwareProbe.ListComPorts(); + _ports.ItemsSource = _portList.Select(p => + $"{p.Port}: {(p.Busy ? "ЗАНЯТ другой программой" : "свободен")}" + + (p.Description is null ? "" : $" — {p.Description}") + + (p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")).ToArray(); + var names = _portList.Select(p => p.Port).ToArray(); + var keep = SelectedPort; + _portBox.ItemsSource = names; + if (keep is not null && names.Contains(keep)) _portBox.SelectedItem = keep; + else if (names.Length > 0) _portBox.SelectedIndex = 0; + } + + private void RefreshPrinters() + { + _printerList = HardwareProbe.ListPrinters(); + _printers.ItemsSource = _printerList.Count == 0 + ? new[] { "(принтеры не найдены / не Windows)" } + : _printerList.Select(p => + $"{p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий в очереди: {p.QueueJobs}").ToArray(); + _lastPrinterPick = _printerList.FirstOrDefault(p => p.IsDefault)?.Name ?? _printerList.FirstOrDefault()?.Name ?? ""; + } + + private void DoTestPrint() + { + if (!_allowTestPrint) { _footer.Text = "Тестовая печать выключена (нет --allow-test-print)."; return; } + if (!OperatingSystem.IsWindows()) { _footer.Text = "Печать доступна только на Windows."; return; } + if (string.IsNullOrEmpty(_lastPrinterPick)) { _footer.Text = "Нет принтера для теста."; return; } + try + { + var ok = WinRawPrint.Send(_lastPrinterPick, WinRawPrint.BuildTestSlip(), out var err); + _footer.Text = ok ? $"Тестовый чек отправлен на «{_lastPrinterPick}»." : $"Ошибка печати: {err}"; + } + catch (Exception ex) { _footer.Text = "Ошибка печати: " + ex.Message; } + } + + // ───────────────────────── Сканер ───────────────────────── + private void WireScanner() + { + _scan.AddHandler(InputElement.KeyDownEvent, (_, e) => + { + if (!_scanSw.IsRunning) { _scanSw.Restart(); _scanTimes.Clear(); } + if (e.Key == Key.Enter) + { + AnalyzeScan(); + e.Handled = true; + return; + } + _scanTimes.Add(_scanSw.ElapsedMilliseconds); + }, RoutingStrategies.Tunnel); + } + + private void AnalyzeScan() + { + var code = (_scan.Text ?? "").Trim(); + var a = BarcodeScan.Analyze(code, _scanTimes.ToList()); + var sb = new StringBuilder(); + sb.Append($"Длина: {a.Length}. "); + sb.Append(a.IsEan ? (a.EanValid ? "EAN контрольная цифра ВЕРНА. " : "EAN контрольная цифра НЕВЕРНА. ") : "Не EAN-13/EAN-8. "); + if (a.AvgIntervalMs > 0) sb.Append($"Средний интервал {a.AvgIntervalMs:0} мс/символ — {BarcodeScan.KindRu(a.Kind)}."); + _scanInfo.Text = sb.ToString(); + _scanSw.Reset(); + } + + // ───────────────────────── Отчёт ───────────────────────── + private string BuildReportText() + { + var sb = new StringBuilder(); + sb.AppendLine("=== ALL MARKET — отчёт проверки оборудования ==="); + sb.AppendLine($"Время: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Компьютер: {Environment.MachineName}"); + sb.AppendLine($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}"); + sb.AppendLine(); + sb.AppendLine($"COM-порты ({_portList.Count}):"); + foreach (var p in _portList) + sb.AppendLine($" {p.Port}: {(p.Busy ? "ЗАНЯТ" : "свободен")}{(p.Description is null ? "" : " — " + p.Description)}{(p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")}"); + sb.AppendLine(); + sb.AppendLine("Весы:"); + sb.AppendLine($" порт {SelectedPort}, скорость {SelectedBaud}, протокол {SelectedProto}"); + sb.AppendLine($" вес: {(_lastKg is null ? "—" : _lastKg.Value.ToString("0.000") + " кг")}; {_stable.Text}"); + sb.AppendLine($" последний кадр (hex): {_hex.Text}"); + sb.AppendLine($" статус: {_scaleStatus.Text}"); + sb.AppendLine(); + sb.AppendLine($"Принтеры ({_printerList.Count}):"); + foreach (var p in _printerList) + sb.AppendLine($" {p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий {p.QueueJobs}"); + sb.AppendLine(); + sb.AppendLine("Сканер:"); + sb.AppendLine($" {( string.IsNullOrEmpty(_scan.Text) ? "(не сканировали)" : _scan.Text )} — {_scanInfo.Text}"); + return sb.ToString(); + } + + private void SaveReport() + { + try + { + var dir = AppContext.BaseDirectory; + var path = Path.Combine(dir, $"hardware-check-{DateTime.Now:yyyyMMdd-HHmmss}.txt"); + File.WriteAllText(path, BuildReportText(), new UTF8Encoding(true)); + _footer.Text = $"Отчёт сохранён: {path}"; + } + catch (Exception ex) { _footer.Text = "Не удалось сохранить отчёт: " + ex.Message; } + } + + private async void CopyReport() + { + var cb = TopLevel.GetTopLevel(this)?.Clipboard; + if (cb is not null) { await cb.SetTextAsync(BuildReportText()); _footer.Text = "Отчёт скопирован в буфер обмена."; } + else _footer.Text = "Буфер обмена недоступен."; + } +} diff --git a/src/food-market.pos.hwcheck/Program.cs b/src/food-market.pos.hwcheck/Program.cs new file mode 100644 index 0000000..41a9554 --- /dev/null +++ b/src/food-market.pos.hwcheck/Program.cs @@ -0,0 +1,28 @@ +using Avalonia; + +namespace foodmarket.Pos.HwCheck; + +internal static class Program +{ + /// Тестовая печать разрешена ТОЛЬКО при запуске с --allow-test-print + /// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер + /// ничего не отправляется. + public static bool AllowTestPrint { get; private set; } + + [STAThread] + public static int Main(string[] args) + { + AllowTestPrint = args.Contains("--allow-test-print"); + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + return 0; + } + + public static AppBuilder BuildAvaloniaApp() + { + var b = AppBuilder.Configure().UsePlatformDetect(); + // На headless/VM без GPU — ПО-рендеринг, чтобы окно не было чёрным. + if (OperatingSystem.IsWindows()) + b = b.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }); + return b.WithInterFont().LogToTrace(); + } +} diff --git a/src/food-market.pos.hwcheck/WinRawPrint.cs b/src/food-market.pos.hwcheck/WinRawPrint.cs new file mode 100644 index 0000000..8af3d6e --- /dev/null +++ b/src/food-market.pos.hwcheck/WinRawPrint.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace foodmarket.Pos.HwCheck; + +/// RAW-печать короткого тестового чека ESC/POS через spooler Windows. +/// Используется ТОЛЬКО если программа запущена с --allow-test-print. Очередь принтера +/// не чистится, задания не удаляются — просто одно новое задание. +[SupportedOSPlatform("windows")] +internal static class WinRawPrint +{ + public static byte[] BuildTestSlip() + { + var cp866 = CodePagesEncodingProvider.Instance.GetEncoding(866) ?? Encoding.UTF8; + var buf = new List(); + buf.AddRange(new byte[] { 0x1B, 0x40 }); // init + buf.AddRange(new byte[] { 0x1B, 0x74, 0x11 }); // CP866 + buf.AddRange(new byte[] { 0x1B, 0x61, 0x01 }); // center + void Line(string s) { buf.AddRange(cp866.GetBytes(s)); buf.Add(0x0A); } + buf.AddRange(new byte[] { 0x1B, 0x45, 0x01 }); Line("ALL MARKET"); buf.AddRange(new byte[] { 0x1B, 0x45, 0x00 }); + Line("Проверка оборудования"); + Line(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + Line(new string('-', 32)); + Line("Если этот чек напечатан —"); + Line("принтер и очередь Windows в порядке."); + buf.AddRange(new byte[] { 0x0A, 0x0A, 0x0A }); + buf.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x00 }); // cut + return buf.ToArray(); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DOCINFO { [MarshalAs(UnmanagedType.LPWStr)] public string pDocName; [MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile; [MarshalAs(UnmanagedType.LPWStr)] public string pDataType; } + [DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool OpenPrinter(string src, out IntPtr h, IntPtr pd); + [DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)] private static extern bool ClosePrinter(IntPtr h); + [DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool StartDocPrinter(IntPtr h, int level, ref DOCINFO di); + [DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)] private static extern bool EndDocPrinter(IntPtr h); + [DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)] private static extern bool StartPagePrinter(IntPtr h); + [DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)] private static extern bool EndPagePrinter(IntPtr h); + [DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)] private static extern bool WritePrinter(IntPtr h, IntPtr buf, int count, out int written); + + public static bool Send(string printer, byte[] bytes, out string? error) + { + error = null; + if (!OpenPrinter(printer, out var h, IntPtr.Zero)) { error = $"OpenPrinter: Win32 {Marshal.GetLastWin32Error()}"; return false; } + var di = new DOCINFO { pDocName = "ALL MARKET тест оборудования", pDataType = "RAW" }; + var ptr = Marshal.AllocCoTaskMem(bytes.Length); + try + { + Marshal.Copy(bytes, 0, ptr, bytes.Length); + if (!StartDocPrinter(h, 1, ref di)) { error = $"StartDocPrinter: {Marshal.GetLastWin32Error()}"; return false; } + if (!StartPagePrinter(h)) { error = $"StartPagePrinter: {Marshal.GetLastWin32Error()}"; EndDocPrinter(h); return false; } + var ok = WritePrinter(h, ptr, bytes.Length, out var written); + EndPagePrinter(h); EndDocPrinter(h); + if (!ok || written != bytes.Length) { error = $"WritePrinter: {written}/{bytes.Length}"; return false; } + return true; + } + finally { Marshal.FreeCoTaskMem(ptr); ClosePrinter(h); } + } +} diff --git a/src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj b/src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj new file mode 100644 index 0000000..5903a63 --- /dev/null +++ b/src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj @@ -0,0 +1,23 @@ + + + WinExe + net8.0 + foodmarket.Pos.HwCheck + foodmarket.Pos.HwCheck + enable + enable + + + + + + + + + + + + + + diff --git a/tests/food-market.UnitTests/BarcodeScanTests.cs b/tests/food-market.UnitTests/BarcodeScanTests.cs new file mode 100644 index 0000000..047084f --- /dev/null +++ b/tests/food-market.UnitTests/BarcodeScanTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using FluentAssertions; +using foodmarket.Pos.Core.Hardware; +using Xunit; + +namespace foodmarket.UnitTests; + +public class BarcodeScanTests +{ + [Theory] + [InlineData("4600051000057", true)] // валидный EAN-13 + [InlineData("4607034170011", true)] + [InlineData("96385074", true)] // валидный EAN-8 + [InlineData("4600051000058", false)] // битая контрольная + [InlineData("1234567890123", false)] + [InlineData("12345", false)] // не EAN-длина + [InlineData("abcdefghabcde", false)] // не цифры + public void IsValidEan_checks_control_digit(string code, bool expected) + => BarcodeScan.IsValidEan(code).Should().Be(expected); + + [Fact] + public void Fast_even_input_is_classified_as_scanner() + { + // 13 символов, ~15 мс между ними + var ts = new List(); + for (int i = 0; i < 13; i++) ts.Add(i * 15); + var a = BarcodeScan.Analyze("4600051000057", ts); + a.Kind.Should().Be(BarcodeScan.InputKind.Scanner); + a.IsEan.Should().BeTrue(); + a.EanValid.Should().BeTrue(); + a.AvgIntervalMs.Should().BeLessThan(40); + } + + [Fact] + public void Slow_input_is_classified_as_manual() + { + var ts = new List(); + for (int i = 0; i < 8; i++) ts.Add(i * 250); + var a = BarcodeScan.Analyze("96385074", ts); + a.Kind.Should().Be(BarcodeScan.InputKind.Manual); + } + + [Fact] + public void Empty_input_is_empty() + => BarcodeScan.Analyze("", null).Kind.Should().Be(BarcodeScan.InputKind.Empty); +}