feat(pos): отдельная программа «ALL MARKET — Проверка оборудования» (без входа/сервера/БД)
Приоритет владельца: 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 <noreply@anthropic.com>
This commit is contained in:
parent
25c37415bf
commit
ff1580d162
|
|
@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "te
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
{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}
|
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||||
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575} = {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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
|
||||||
58
src/food-market.pos.core/Hardware/BarcodeScan.cs
Normal file
58
src/food-market.pos.core/Hardware/BarcodeScan.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
namespace foodmarket.Pos.Core.Hardware;
|
||||||
|
|
||||||
|
/// <summary>Анализ ввода сканера ШК: валидность контрольной цифры EAN-13/EAN-8 и
|
||||||
|
/// оценка «HID-сканер vs ручной набор» по среднему интервалу между символами.
|
||||||
|
/// Чистая логика — покрыта юнит-тестами.</summary>
|
||||||
|
public static class BarcodeScan
|
||||||
|
{
|
||||||
|
/// <summary>Проверить контрольную цифру EAN-13 или EAN-8. Для прочих длин — false
|
||||||
|
/// (валидность «не применимо»).</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Классифицировать ввод по символам и их временам (мс от начала).
|
||||||
|
/// Сканер печатает быстро и равномерно (обычно < 30 мс/символ); человек —
|
||||||
|
/// медленнее и неравномерно. Порог намеренно консервативный.</summary>
|
||||||
|
public static ScanAnalysis Analyze(string code, IReadOnlyList<long>? 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 => "пусто",
|
||||||
|
_ => "не определено",
|
||||||
|
};
|
||||||
|
}
|
||||||
110
src/food-market.pos.core/Hardware/HardwareProbe.cs
Normal file
110
src/food-market.pos.core/Hardware/HardwareProbe.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Core.Hardware;
|
||||||
|
|
||||||
|
/// <summary>Детект оборудования для диагностики (COM-порты, принтеры Windows).
|
||||||
|
/// Только чтение: порты открываем→закрываем, очередь принтера не трогаем.
|
||||||
|
/// WMI-части — Windows-only (под OperatingSystem.IsWindows()); на Linux вернётся
|
||||||
|
/// базовый список COM из System.IO.Ports.</summary>
|
||||||
|
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<ComPortInfo> 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<ComPortInfo>();
|
||||||
|
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<string> 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;
|
||||||
|
|
||||||
|
/// <summary>Открыть→закрыть без удержания. Busy=true, если занят другой программой.</summary>
|
||||||
|
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<PrinterInfo> ListPrinters()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return new();
|
||||||
|
return QueryPrintersWmi();
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static List<PrinterInfo> QueryPrintersWmi()
|
||||||
|
{
|
||||||
|
var list = new List<PrinterInfo>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<PackageReference Include="System.IO.Ports" />
|
<PackageReference Include="System.IO.Ports" />
|
||||||
|
<!-- WMI для детекта оборудования (COM VID/PID, принтеры) — только Windows;
|
||||||
|
все вызовы под OperatingSystem.IsWindows(). -->
|
||||||
|
<PackageReference Include="System.Management" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="Refit" />
|
<PackageReference Include="Refit" />
|
||||||
<PackageReference Include="Refit.HttpClientFactory" />
|
<PackageReference Include="Refit.HttpClientFactory" />
|
||||||
|
|
|
||||||
22
src/food-market.pos.hwcheck/App.cs
Normal file
22
src/food-market.pos.hwcheck/App.cs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
373
src/food-market.pos.hwcheck/MainWindow.cs
Normal file
373
src/food-market.pos.hwcheck/MainWindow.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Окно «ALL MARKET — Проверка оборудования». Без входа, без сервера, без БД.
|
||||||
|
/// Весы (Масса-К-кадр), COM-порты, принтеры (только чтение), сканер, сохранение отчёта.</summary>
|
||||||
|
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<long> _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<HardwareProbe.ComPortInfo> _portList = new();
|
||||||
|
private List<HardwareProbe.PrinterInfo> _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 = "Буфер обмена недоступен.";
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/food-market.pos.hwcheck/Program.cs
Normal file
28
src/food-market.pos.hwcheck/Program.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.HwCheck;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
/// <summary>Тестовая печать разрешена ТОЛЬКО при запуске с --allow-test-print
|
||||||
|
/// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер
|
||||||
|
/// ничего не отправляется.</summary>
|
||||||
|
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<App>().UsePlatformDetect();
|
||||||
|
// На headless/VM без GPU — ПО-рендеринг, чтобы окно не было чёрным.
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
b = b.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } });
|
||||||
|
return b.WithInterFont().LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/food-market.pos.hwcheck/WinRawPrint.cs
Normal file
60
src/food-market.pos.hwcheck/WinRawPrint.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.HwCheck;
|
||||||
|
|
||||||
|
/// <summary>RAW-печать короткого тестового чека ESC/POS через spooler Windows.
|
||||||
|
/// Используется ТОЛЬКО если программа запущена с --allow-test-print. Очередь принтера
|
||||||
|
/// не чистится, задания не удаляются — просто одно новое задание.</summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class WinRawPrint
|
||||||
|
{
|
||||||
|
public static byte[] BuildTestSlip()
|
||||||
|
{
|
||||||
|
var cp866 = CodePagesEncodingProvider.Instance.GetEncoding(866) ?? Encoding.UTF8;
|
||||||
|
var buf = new List<byte>();
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
Normal file
23
src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>foodmarket.Pos.HwCheck</RootNamespace>
|
||||||
|
<AssemblyName>foodmarket.Pos.HwCheck</AssemblyName>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Только оборудование: ссылается на pos.core РАДИ Hardware/* (весы, детект,
|
||||||
|
сканер). Сервер/БД/авторизация НЕ используются — этот exe не умеет быть кассой. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\food-market.pos.core\food-market.pos.core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
46
tests/food-market.UnitTests/BarcodeScanTests.cs
Normal file
46
tests/food-market.UnitTests/BarcodeScanTests.cs
Normal file
|
|
@ -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<long>();
|
||||||
|
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<long>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue