feat(pos): CLI-диагностика оборудования кассы --probe-hw и тест-печать --test-print
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

- ProbeRunner: COM-порты (имя/VID/PID/описание через WMI), open→close (занят/свободен без
  удержания), опрос весов Масса-К+CAS 9600 (таймаут 1.5с), принтеры Windows (имя/порт/драйвер,
  БЕЗ печати), HID-клавиатуры (возможные сканеры). --port COMx — один порт; занятый не трогаем.
- --test-print "<принтер>": отдельный флаг, ESC/POS тест-чек «ТЕСТ ALL MARKET» через spooler
  (winspool RAW). Без флага ничего не печатается.
- WinExe → AttachConsole(parent) для видимого вывода; --out пишет чистый UTF-8 JSON.
- System.Management (WMI, только Windows, под guard'ами). Проверено на Win11 VM: выход <10с,
  COM1/принтер/HID определяются, test-print на несуществующий принтер — мягкая ошибка (код 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 18:45:57 +05:00
parent 8be44055a2
commit 574de0dafb
5 changed files with 400 additions and 0 deletions

View file

@ -71,6 +71,7 @@
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" /> <PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageVersion Include="System.IO.Ports" Version="8.0.0" /> <PackageVersion Include="System.IO.Ports" Version="8.0.0" />
<PackageVersion Include="System.Management" Version="8.0.0" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /> <PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- Testing --> <!-- Testing -->

View file

@ -1,5 +1,7 @@
# Перенос магазина МойСклад → ALL MARKET (с историей) — прогресс # Перенос магазина МойСклад → ALL MARKET (с историей) — прогресс
PROBE BUILD: ~/pos-release/allmarket-pos-win-x64-20261009-1845-probe.zip — касса `foodmarket.Pos.exe --probe-hw [--out <файл>] [--port COMx]` (COM/весы Масса-К·CAS/принтеры/HID, без печати) + `--test-print "<принтер>"` (ESC/POS тест-чек, отдельно); проверено на Win11 VM, выход < 10 с, ничего не трогает.
Решение владельца 09.10: перенести ВСЁ из МойСклада с историей, перейти на ALL MARKET. Решение владельца 09.10: перенести ВСЁ из МойСклада с историей, перейти на ALL MARKET.
Фискализации нет и в МойСкладе (не блокер), маркировка не используется. Фискализации нет и в МойСкладе (не блокер), маркировка не используется.

View file

@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using Avalonia; using Avalonia;
using foodmarket.Pos.Tooling; using foodmarket.Pos.Tooling;
@ -5,10 +6,29 @@ namespace foodmarket.Pos;
internal static class Program internal static class Program
{ {
[DllImport("kernel32.dll")] private static extern bool AttachConsole(int pid);
private const int ATTACH_PARENT_PROCESS = -1;
/// <summary>POS собран как WinExe (GUI-subsystem) — у него нет консоли, поэтому для
/// CLI-режимов на Windows подключаемся к консоли родителя (cmd/PowerShell), иначе вывод
/// Console.* уходит в никуда. На Linux WinExe игнорируется — консоль есть по умолчанию.</summary>
private static void EnsureConsole()
{
// Не переопределяем Console.OutputEncoding: родительская консоль (обычно cp866 на
// RU-Windows) сама отрисует кириллицу; принудительный UTF-8 даёт кракозябры в cp866.
// Чистый UTF-8 артефакт — файл --out (JSON пишется UTF-8 независимо от консоли).
if (OperatingSystem.IsWindows())
{
try { AttachConsole(ATTACH_PARENT_PROCESS); } catch { }
}
}
[STAThread] [STAThread]
public static int Main(string[] args) public static int Main(string[] args)
{ {
PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг») PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг»)
// CLI-режимы оборудования — подключить консоль и UTF-8 до любого вывода.
if (args.Contains("--probe-hw") || args.Contains("--test-print")) EnsureConsole();
// CLI-режимы (без интерактивного окна): // CLI-режимы (без интерактивного окна):
// --smoke : прогон сценария кассы через логику (без UI), печать результатов. // --smoke : прогон сценария кассы через логику (без UI), печать результатов.
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768). // --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).
@ -19,6 +39,15 @@ public static int Main(string[] args)
if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args); if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args);
if (args.Contains("--uitest")) return UiTestRunner.Run(args); if (args.Contains("--uitest")) return UiTestRunner.Run(args);
if (args.Contains("--bench")) return BenchRunner.Run(args); if (args.Contains("--bench")) return BenchRunner.Run(args);
// --probe-hw : диагностика оборудования (COM/весы/принтеры/HID), без окна, без печати.
// --test-print N : отдельный флаг — короткий тестовый чек ESC/POS на принтер Windows.
if (args.Contains("--probe-hw")) return ProbeRunner.Run(args);
if (args.Contains("--test-print"))
{
var i = Array.IndexOf(args, "--test-print");
var name = (i >= 0 && i + 1 < args.Length) ? args[i + 1] : "";
return ProbeRunner.TestPrint(name);
}
// --demo : показать реальное окно с примерами данных (для скриншота на Windows). // --demo : показать реальное окно с примерами данных (для скриншота на Windows).
if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true; if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true;

View file

@ -0,0 +1,365 @@
using System.Diagnostics;
using System.IO.Ports;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using foodmarket.Pos.Hardware;
namespace foodmarket.Pos.Tooling;
/// <summary>Диагностика оборудования кассы БЕЗ интерактивного окна (для проверки
/// сисадмином на ноутбуке кассы, где параллельно открыта «Касса МойСклад»).
///
/// --probe-hw [--out файл] — отчёт: COM-порты (имя, VID/PID, описание, свободен/занят),
/// попытка опроса весов Масса-К и CAS на свободных портах (таймаут 1–2 с), принтеры
/// Windows (имя/порт/драйвер, БЕЗ печати), HID-клавиатуры (возможные сканеры).
/// --probe-hw --port COM22 — опрос конкретного порта; занятый порт НЕ трогаем.
/// --test-print "Имя принтера" — ОТДЕЛЬНО: короткий тестовый чек ESC/POS через очередь Windows.
///
/// Ничего не удерживает (порт открыл→закрыл), ничего не печатает без --test-print,
/// гарантированный выход за &lt; 10 с (общий бюджет + короткие таймауты).</summary>
public static class ProbeRunner
{
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(9);
public static int Run(string[] args)
{
var sw = Stopwatch.StartNew();
var only = GetArg(args, "--port");
var outFile = GetArg(args, "--out");
var report = new ProbeReport
{
GeneratedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
Os = RuntimeInformation.OSDescription,
IsWindows = OperatingSystem.IsWindows(),
};
// 1. COM-порты (имя/VID/PID/описание из WMI + базовый список System.IO.Ports)
var wmiPorts = OperatingSystem.IsWindows() ? QueryComPortsWmi() : new();
var portNames = SerialPort.GetPortNames().Distinct().ToList();
foreach (var w in wmiPorts) if (!portNames.Contains(w.Port, StringComparer.OrdinalIgnoreCase)) portNames.Add(w.Port);
portNames = portNames.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => NaturalComOrder(p)).ToList();
if (only is not null)
portNames = portNames.Where(p => string.Equals(p, only, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var port in portNames)
{
var wi = wmiPorts.FirstOrDefault(x => string.Equals(x.Port, port, StringComparison.OrdinalIgnoreCase));
var p = new ComPortInfo
{
Port = port,
Description = wi?.Description,
Vid = wi?.Vid,
Pid = wi?.Pid,
Manufacturer = wi?.Manufacturer,
};
// открыть→закрыть, без удержания
var (busy, openErr) = TryOpenClose(port);
p.Busy = busy;
p.OpenError = openErr;
// опрос весов только на свободном порту и пока есть бюджет времени
if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3))
p.Scale = ProbeScale(port, sw);
else if (busy)
p.Scale = "порт занят другим приложением — не опрашивался";
else
p.Scale = "пропущен (лимит времени)";
report.Ports.Add(p);
}
// 2. Принтеры Windows (имя/порт/драйвер) — БЕЗ печати
if (OperatingSystem.IsWindows() && only is null)
report.Printers = QueryPrintersWmi();
// 3. HID-клавиатуры (сканер ШК обычно эмулирует клавиатуру — детект без скана невозможен)
if (OperatingSystem.IsWindows() && only is null)
report.Keyboards = QueryKeyboardsWmi();
report.ElapsedMs = (int)sw.ElapsedMilliseconds;
report.Note = OperatingSystem.IsWindows()
? "Детект сканера без фактического скана невозможен — перечислены HID-клавиатуры."
: "Не Windows: WMI недоступен, перечислены только COM-порты (System.IO.Ports).";
// Вывод: текст в stdout; JSON в --out (если задан)
Console.WriteLine(RenderText(report));
if (outFile is not null)
{
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // кириллица как есть
});
File.WriteAllText(outFile, json);
Console.WriteLine($"\nJSON сохранён: {outFile}");
}
return 0;
}
// ───────────────────────── COM ─────────────────────────
private 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); }
}
private static string ProbeScale(string port, Stopwatch sw)
{
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
{
if (sw.Elapsed > Budget - TimeSpan.FromSeconds(1)) return "не опрошены (лимит времени)";
try
{
var scale = new ComScale(port, 9600, proto);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(1500));
var task = scale.ReadWeightKgAsync(cts.Token);
if (task.Wait(1800) && task.IsCompletedSuccessfully)
return $"весы найдены ({proto}, 9600): вес = {task.Result:0.###} кг";
}
catch (AggregateException ae) when (ae.InnerException is UnauthorizedAccessException)
{ return "порт занят другим приложением"; }
catch { /* нет ответа на этом протоколе — пробуем следующий */ }
}
return "весы не ответили (Масса-К/CAS, 9600, 1.5 с)";
}
private static int NaturalComOrder(string p)
=> int.TryParse(Regex.Match(p, @"\d+").Value, out var n) ? n : 9999;
[SupportedOSPlatform("windows")]
private static List<WmiPort> QueryComPortsWmi()
{
var list = new List<WmiPort>();
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() ?? "";
list.Add(new WmiPort
{
Port = m.Groups[1].Value,
Description = name,
Manufacturer = o["Manufacturer"]?.ToString(),
Vid = Regex.Match(pnp, @"VID_([0-9A-Fa-f]{4})").Groups[1].Value is { Length: 4 } vid ? vid : null,
Pid = Regex.Match(pnp, @"PID_([0-9A-Fa-f]{4})").Groups[1].Value is { Length: 4 } pid ? pid : null,
});
}
}
catch { /* WMI недоступен — вернём пусто, порты всё равно возьмём из GetPortNames */ }
return list;
}
[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())
list.Add(new PrinterInfo
{
Name = o["Name"]?.ToString() ?? "",
Port = o["PortName"]?.ToString(),
Driver = o["DriverName"]?.ToString(),
IsDefault = o["Default"] as bool? ?? false,
Offline = o["WorkOffline"] as bool? ?? false,
});
}
catch { }
return list;
}
[SupportedOSPlatform("windows")]
private static List<string> QueryKeyboardsWmi()
{
var list = new List<string>();
try
{
using var s = new System.Management.ManagementObjectSearcher(
"SELECT Name, Description, DeviceID FROM Win32_Keyboard");
foreach (var o in s.Get())
{
var desc = o["Description"]?.ToString() ?? o["Name"]?.ToString() ?? "клавиатура";
var id = o["DeviceID"]?.ToString();
list.Add(string.IsNullOrEmpty(id) ? desc : $"{desc} [{id}]");
}
}
catch { }
return list;
}
// ───────────────────────── тестовая печать ─────────────────────────
public static int TestPrint(string printerName)
{
if (string.IsNullOrWhiteSpace(printerName))
{
Console.Error.WriteLine("Укажите имя принтера: --test-print \"Имя принтера\"");
return 2;
}
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("Тестовая печать через очередь Windows доступна только на Windows.");
return 3;
}
var doc = new ReceiptDoc { Title = "ТЕСТ ALL MARKET", WidthChars = 48 };
doc.Add("Проверка принтера кассы", center: true);
doc.Add(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), center: true);
doc.Separator();
doc.Add("Если этот чек напечатан —", center: true);
doc.Add("принтер и очередь Windows", center: true);
doc.Add("работают корректно.", center: true);
doc.Separator();
doc.Add("ALL MARKET · касса", center: true);
var bytes = EscPos.Build(doc);
try
{
if (!RawPrinter.Send(printerName, bytes, "ТЕСТ ALL MARKET", out var err))
{
Console.Error.WriteLine($"Не удалось напечатать: {err}");
return 4;
}
Console.WriteLine($"Тестовый чек отправлен на принтер «{printerName}» (RAW ESC/POS, 80 мм).");
return 0;
}
catch (Exception ex) { Console.Error.WriteLine("Ошибка печати: " + ex.Message); return 4; }
}
// ───────────────────────── вывод ─────────────────────────
private static string RenderText(ProbeReport r)
{
var sb = new StringBuilder();
sb.AppendLine("=== ПРОВЕРКА ОБОРУДОВАНИЯ КАССЫ (ALL MARKET) ===");
sb.AppendLine($"Время: {r.GeneratedAt} ОС: {r.Os} (выполнено за {r.ElapsedMs} мс)");
sb.AppendLine();
sb.AppendLine($"COM-порты ({r.Ports.Count}):");
if (r.Ports.Count == 0) sb.AppendLine(" (не найдено)");
foreach (var p in r.Ports)
{
var vidpid = (p.Vid is not null || p.Pid is not null) ? $" VID_{p.Vid}/PID_{p.Pid}" : "";
sb.AppendLine($" {p.Port}: {(p.Busy ? "ЗАНЯТ" : "свободен")}{vidpid}");
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($" {p.Description}");
if (p.Scale is not null) sb.AppendLine($" весы: {p.Scale}");
if (p.OpenError is not null) sb.AppendLine($" порт: {p.OpenError}");
}
sb.AppendLine();
sb.AppendLine($"Принтеры Windows ({r.Printers.Count}) — печать НЕ выполнялась:");
if (r.Printers.Count == 0) sb.AppendLine(" (не найдено / не Windows)");
foreach (var pr in r.Printers)
sb.AppendLine($" {pr.Name}{(pr.IsDefault ? " [по умолчанию]" : "")} — порт {pr.Port}, драйвер {pr.Driver}{(pr.Offline ? ", OFFLINE" : "")}");
sb.AppendLine();
sb.AppendLine($"HID-клавиатуры ({r.Keyboards.Count}) — сканер ШК обычно в этом списке:");
if (r.Keyboards.Count == 0) sb.AppendLine(" (не найдено / не Windows)");
foreach (var k in r.Keyboards) sb.AppendLine($" {k}");
sb.AppendLine();
sb.AppendLine(r.Note);
return sb.ToString();
}
private static string? GetArg(string[] args, string key)
{
var i = Array.FindIndex(args, a => string.Equals(a, key, StringComparison.OrdinalIgnoreCase));
return (i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--")) ? args[i + 1] : null;
}
// ── модели отчёта ──
private sealed class ProbeReport
{
public string GeneratedAt { get; set; } = "";
public string Os { get; set; } = "";
public bool IsWindows { get; set; }
public int ElapsedMs { get; set; }
public string Note { get; set; } = "";
public List<ComPortInfo> Ports { get; set; } = new();
public List<PrinterInfo> Printers { get; set; } = new();
public List<string> Keyboards { get; set; } = new();
}
private sealed class ComPortInfo
{
public string Port { get; set; } = "";
public bool Busy { get; set; }
public string? Description { get; set; }
public string? Vid { get; set; }
public string? Pid { get; set; }
public string? Manufacturer { get; set; }
public string? Scale { get; set; }
public string? OpenError { get; set; }
}
private sealed class PrinterInfo
{
public string Name { get; set; } = "";
public string? Port { get; set; }
public string? Driver { get; set; }
public bool IsDefault { get; set; }
public bool Offline { get; set; }
}
private sealed class WmiPort
{
public string Port = "";
public string? Description;
public string? Vid;
public string? Pid;
public string? Manufacturer;
}
}
/// <summary>RAW-печать байтов ESC/POS на именованный принтер Windows через spooler
/// (winspool.drv). Используется только из --test-print под Windows.</summary>
[SupportedOSPlatform("windows")]
internal static class RawPrinter
{
[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, string docName, 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 = docName, pDataType = "RAW" };
var ptr = Marshal.AllocCoTaskMem(bytes.Length);
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
if (!StartDocPrinter(h, 1, ref di)) { error = $"StartDocPrinter: Win32 {Marshal.GetLastWin32Error()}"; return false; }
if (!StartPagePrinter(h)) { error = $"StartPagePrinter: Win32 {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); }
}
}

View file

@ -30,6 +30,9 @@
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" />
<PackageReference Include="Polly" /> <PackageReference Include="Polly" />
<PackageReference Include="System.IO.Ports" /> <PackageReference Include="System.IO.Ports" />
<!-- WMI для детекта оборудования (COM VID/PID, принтеры, HID-клавиатуры) — только Windows,
вызовы в ProbeRunner под OperatingSystem.IsWindows(). -->
<PackageReference Include="System.Management" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" />
</ItemGroup> </ItemGroup>
</Project> </Project>