From 574de0dafbb0cd1b78649fcdc1782e5471ccae5a Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 18:45:57 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos):=20CLI-=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D1=83=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D1=8B=20--probe-hw=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82-=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8C=20--test-print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Directory.Packages.props | 1 + docs/migration-progress.md | 2 + src/food-market.pos/Program.cs | 29 ++ src/food-market.pos/Tooling/ProbeRunner.cs | 365 +++++++++++++++++++++ src/food-market.pos/food-market.pos.csproj | 3 + 5 files changed, 400 insertions(+) create mode 100644 src/food-market.pos/Tooling/ProbeRunner.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bd50536..c7946fb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -71,6 +71,7 @@ + diff --git a/docs/migration-progress.md b/docs/migration-progress.md index 1fe3d17..32cbb30 100644 --- a/docs/migration-progress.md +++ b/docs/migration-progress.md @@ -1,5 +1,7 @@ # Перенос магазина МойСклад → 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. Фискализации нет и в МойСкладе (не блокер), маркировка не используется. diff --git a/src/food-market.pos/Program.cs b/src/food-market.pos/Program.cs index f8fe576..3fe1839 100644 --- a/src/food-market.pos/Program.cs +++ b/src/food-market.pos/Program.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using Avalonia; using foodmarket.Pos.Tooling; @@ -5,10 +6,29 @@ namespace foodmarket.Pos; internal static class Program { + [DllImport("kernel32.dll")] private static extern bool AttachConsole(int pid); + private const int ATTACH_PARENT_PROCESS = -1; + + /// POS собран как WinExe (GUI-subsystem) — у него нет консоли, поэтому для + /// CLI-режимов на Windows подключаемся к консоли родителя (cmd/PowerShell), иначе вывод + /// Console.* уходит в никуда. На Linux WinExe игнорируется — консоль есть по умолчанию. + 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] public static int Main(string[] args) { PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг») + // CLI-режимы оборудования — подключить консоль и UTF-8 до любого вывода. + if (args.Contains("--probe-hw") || args.Contains("--test-print")) EnsureConsole(); // CLI-режимы (без интерактивного окна): // --smoke : прогон сценария кассы через логику (без UI), печать результатов. // --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("--uitest")) return UiTestRunner.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). if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true; diff --git a/src/food-market.pos/Tooling/ProbeRunner.cs b/src/food-market.pos/Tooling/ProbeRunner.cs new file mode 100644 index 0000000..cfbd934 --- /dev/null +++ b/src/food-market.pos/Tooling/ProbeRunner.cs @@ -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; + +/// Диагностика оборудования кассы БЕЗ интерактивного окна (для проверки +/// сисадмином на ноутбуке кассы, где параллельно открыта «Касса МойСклад»). +/// +/// --probe-hw [--out файл] — отчёт: COM-порты (имя, VID/PID, описание, свободен/занят), +/// попытка опроса весов Масса-К и CAS на свободных портах (таймаут 1–2 с), принтеры +/// Windows (имя/порт/драйвер, БЕЗ печати), HID-клавиатуры (возможные сканеры). +/// --probe-hw --port COM22 — опрос конкретного порта; занятый порт НЕ трогаем. +/// --test-print "Имя принтера" — ОТДЕЛЬНО: короткий тестовый чек ESC/POS через очередь Windows. +/// +/// Ничего не удерживает (порт открыл→закрыл), ничего не печатает без --test-print, +/// гарантированный выход за < 10 с (общий бюджет + короткие таймауты). +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 QueryComPortsWmi() + { + var list = new List(); + 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 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()) + 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 QueryKeyboardsWmi() + { + var list = new List(); + 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 Ports { get; set; } = new(); + public List Printers { get; set; } = new(); + public List 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; + } +} + +/// RAW-печать байтов ESC/POS на именованный принтер Windows через spooler +/// (winspool.drv). Используется только из --test-print под Windows. +[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); } + } +} diff --git a/src/food-market.pos/food-market.pos.csproj b/src/food-market.pos/food-market.pos.csproj index 35a3a6b..45752fb 100644 --- a/src/food-market.pos/food-market.pos.csproj +++ b/src/food-market.pos/food-market.pos.csproj @@ -30,6 +30,9 @@ + +