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 @@
+
+