- Кроссплатформенный Avalonia-UI (net8.0, не net8.0-windows): экраны вход/ продажа/оплата/смена/возврат, тёмная тема, ViewLocator, компилируемые привязки. - Живое связывание: логин→сессия→Refit-API, привязка к точке; продажа (скан/ поиск/количество/весовой/скидки/удаление), модальная оплата → запись чека в офлайн-очередь + печать + попытка выгрузки; смена open/close/drawer на api/money/shifts; горячие клавиши F1/F2/F7/F9/F10/F12/Esc/Del (работа без мыши); индикатор связи/очереди + фоновая синхронизация; автопроверка версии. - Оборудование: ESC/POS-билдер (CP866, USB/COM/сеть, 58/80мм) + текст/PDF запасной; весы CAS/Масса-К (COM) + ручной ввод; IFiscalProvider — заглушка. - DpapiTokenStore (Windows, scope CurrentUser) для refresh-токена; 0600 на Linux. - Инструменты CLI: --smoke (замеры/сценарий без UI), --screenshots (headless рендер экранов), --uitest (headless UI-автотест, 15 проверок, Avalonia.Headless). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
5.7 KiB
C#
105 lines
5.7 KiB
C#
using System.Diagnostics;
|
||
using foodmarket.Pos.Core.Data;
|
||
using foodmarket.Pos.Core.Logic;
|
||
using foodmarket.Pos.Core.Models;
|
||
using foodmarket.Pos.Core.Sync;
|
||
using foodmarket.Shared.Pos.V1;
|
||
|
||
namespace foodmarket.Pos.Tooling;
|
||
|
||
/// <summary>CLI-smoke кассы: сценарий «открыть смену → продажи → возврат → закрыть»
|
||
/// + офлайн-очередь, через логику pos.core (без UI/дисплея). Работает одинаково
|
||
/// на Windows и Linux — для проверки на реальной машине. Запуск: --smoke</summary>
|
||
public static class SmokeRunner
|
||
{
|
||
public static int Run(string[] args)
|
||
{
|
||
var lines = new List<string>();
|
||
void Log(string s) { Console.WriteLine(s); lines.Add(s); }
|
||
|
||
// Холодный старт: от запуска процесса до входа в логику (загрузка рантайма + JIT).
|
||
double coldStartMs = 0;
|
||
try { coldStartMs = (DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds; } catch { }
|
||
|
||
var sw = Stopwatch.StartNew();
|
||
using var store = PosStore.InMemory();
|
||
|
||
// Каталог: 30k товаров для замера поиска + несколько «реальных».
|
||
var ids = SeedCatalog(store, big: args.Contains("--big"));
|
||
var seedMs = sw.ElapsedMilliseconds;
|
||
|
||
// Замер: поиск по штрихкоду и по имени.
|
||
var swBc = Stopwatch.StartNew();
|
||
var byBc = store.FindByBarcode("4870000000005");
|
||
swBc.Stop();
|
||
var swName = Stopwatch.StartNew();
|
||
var found = store.SearchByName("товар 1", 20);
|
||
swName.Stop();
|
||
|
||
// Сценарий дня.
|
||
var cart = new Cart();
|
||
var p1 = store.GetProduct(ids[0])!; var p2 = store.GetProduct(ids[1])!;
|
||
cart.AddProduct(p1, 2); cart.AddProduct(p2, 1);
|
||
var totals = cart.Totals();
|
||
var pay = PaymentMath.Compute(totals.Total, totals.Total + 500m, 0m);
|
||
|
||
// Положить 5 продаж в очередь (офлайн).
|
||
for (int i = 0; i < 5; i++)
|
||
store.EnqueueSale(new PosSaleDto
|
||
{
|
||
ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = totals.Total, PaidCard = 0,
|
||
Lines = new[] { new PosSaleLineDto { ProductId = ids[0], Quantity = 1m, UnitPrice = p1.Price, Discount = 0m } },
|
||
});
|
||
var pending = store.PendingCount();
|
||
|
||
var expected = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m);
|
||
var diff = ShiftMath.CashDifference(14775m, expected);
|
||
var batchKey = SyncService.StableBatchKey(store.PendingSales().Select(s => s.ClientSaleId));
|
||
|
||
Log("=== POS smoke ===");
|
||
Log($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
|
||
Log($"Холодный старт (процесс→логика): {coldStartMs:F0} мс");
|
||
Log($"Каталог засеян: {store.ProductCount()} товаров за {seedMs} мс");
|
||
Log($"Поиск по штрихкоду: {(byBc is null ? "НЕ найден" : byBc.Name)} за {swBc.Elapsed.TotalMilliseconds:F2} мс");
|
||
Log($"Поиск по имени: {found.Count} совпадений за {swName.Elapsed.TotalMilliseconds:F2} мс");
|
||
Log($"Чек: {totals.LineCount} поз., итог {totals.Total} ₸; оплата {pay.Paid} ₸ → сдача {pay.Change} ₸ (достаточно: {pay.Sufficient})");
|
||
Log($"Очередь офлайн: {pending} чеков (ожид. 5); idempotency-ключ батча: {batchKey}");
|
||
Log($"Смена: должно {expected} ₸, факт 14775 ₸, разница {diff} ₸");
|
||
Log($"Всего инициализация+сценарий: {sw.ElapsedMilliseconds} мс");
|
||
|
||
var ok = byBc is not null && pay.Sufficient && pending == 5 && expected == 14275m && diff == 500m;
|
||
Log(ok ? "РЕЗУЛЬТАТ: OK" : "РЕЗУЛЬТАТ: ОШИБКА");
|
||
|
||
// Результат в файл — на Windows exe собран как WinExe (нет консоли).
|
||
var outArg = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault();
|
||
var outPath = outArg ?? Path.Combine(Path.GetTempPath(), "pos-smoke-result.txt");
|
||
try { File.WriteAllText(outPath, string.Join(Environment.NewLine, lines)); } catch { }
|
||
return ok ? 0 : 1;
|
||
}
|
||
|
||
private static List<Guid> SeedCatalog(PosStore store, bool big)
|
||
{
|
||
var n = big ? 30_000 : 50;
|
||
var products = new List<ProductSyncDto>(n);
|
||
var prices = new List<PriceSyncDto>(n);
|
||
var ids = new List<Guid>();
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
var id = Guid.NewGuid(); ids.Add(id);
|
||
var bc = (4_870_000_000_000 + i).ToString();
|
||
products.Add(new ProductSyncDto
|
||
{
|
||
Id = id, Name = $"Товар {i + 1}", Barcodes = new[] { bc }, UnitCode = i % 7 == 0 ? "кг" : "шт",
|
||
Packaging = i % 7 == 0 ? 2 : 1, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow,
|
||
});
|
||
prices.Add(new PriceSyncDto { ProductId = id, PriceTypeId = Guid.Empty, PriceTypeName = "Розн", IsSystem = true, Amount = 100m + i % 900, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow });
|
||
}
|
||
store.ApplySync(new PosSyncResponse
|
||
{
|
||
ServerTime = DateTime.UtcNow, Products = products, Prices = prices,
|
||
Stocks = Array.Empty<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
|
||
});
|
||
return ids;
|
||
}
|
||
}
|