feat(pos): весовой товар на кассе — вес с весов/вручную + автопоиск весов
Часть 3 + автопоиск (данные sysadmin: касса МойСклад спамит «Порт не выбран»): - Весовой товар при сканировании/плитке → вес с весов (если на связи), иначе модалка ручного ввода веса. Сумма = цена/кг × вес; подпись «1,235 кг × 690 ₸/кг». - ScaleMonitor (pos.core): опрос ТОЛЬКО по требованию (при весовом товаре), мягкая деградация на ручной ввод, запись о потере/восстановлении связи РОВНО ОДИН раз (не сотни «Таймаут»). Юнит-тесты (100 опросов → 1 запись). - ScaleDiscovery (pos): автоскан всех COM-портов (вкл. USB-COM CH340/Prolific) × протоколы Масса-К/CAS × скорости; кнопка «Найти весы» в верхней панели; найденный порт запоминается (kv) и восстанавливается при старте. - UnitShort («шт»/«кг»/«л») протянут через sync→SQLite→PosProduct/CartLine; цена весового показывается «690 ₸/кг» вместо кода ОКЕИ «690 ₸/796». - Разбор этикеток в кассе — по настройкам орг (по умолчанию ВЫКЛ, точный штрихкод). - Юнит-тесты 321/321 (обновлены PosBarcodeTests под дефолт-off). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d0fee10e96
commit
195dfb1a2c
|
|
@ -49,6 +49,8 @@ private void InitSchema()
|
||||||
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
|
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
|
||||||
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
|
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
|
||||||
TryExec("ALTER TABLE products ADD COLUMN FavoriteOrder INTEGER NOT NULL DEFAULT 0");
|
TryExec("ALTER TABLE products ADD COLUMN FavoriteOrder INTEGER NOT NULL DEFAULT 0");
|
||||||
|
// Короткое имя единицы («шт»/«кг»/«л») для отображения; раньше показывали код ОКЕИ.
|
||||||
|
TryExec("ALTER TABLE products ADD COLUMN UnitShort TEXT NOT NULL DEFAULT 'шт'");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryExec(string sql)
|
private void TryExec(string sql)
|
||||||
|
|
@ -68,11 +70,11 @@ public void ApplySync(PosSyncResponse sync)
|
||||||
// Подготовленные команды с переиспользуемыми параметрами — массовая
|
// Подготовленные команды с переиспользуемыми параметрами — массовая
|
||||||
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
|
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
|
||||||
var up = _cn.CreateCommand(); up.Transaction = tx;
|
var up = _cn.CreateCommand(); up.Transaction = tx;
|
||||||
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder)
|
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,UnitShort,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder)
|
||||||
VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
|
VALUES($id,$n,$nl,$a,$u,$us,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
|
||||||
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
|
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,UnitShort=$us,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
|
||||||
Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up, IsFavorite=$fav, FavoriteOrder=$favo;";
|
Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up, IsFavorite=$fav, FavoriteOrder=$favo;";
|
||||||
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
|
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$us", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
|
||||||
up.Prepare();
|
up.Prepare();
|
||||||
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
|
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
|
||||||
var delP = AddParams(delBc, "$id"); delBc.Prepare();
|
var delP = AddParams(delBc, "$id"); delBc.Prepare();
|
||||||
|
|
@ -85,7 +87,8 @@ public void ApplySync(PosSyncResponse sync)
|
||||||
var hasPrice = priceByProduct.ContainsKey(p.Id);
|
var hasPrice = priceByProduct.ContainsKey(p.Id);
|
||||||
var idStr = p.Id.ToString();
|
var idStr = p.Id.ToString();
|
||||||
upP["$id"].Value = idStr; upP["$n"].Value = p.Name; upP["$nl"].Value = p.Name.ToLowerInvariant();
|
upP["$id"].Value = idStr; upP["$n"].Value = p.Name; upP["$nl"].Value = p.Name.ToLowerInvariant();
|
||||||
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode; upP["$pk"].Value = p.Packaging;
|
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode;
|
||||||
|
upP["$us"].Value = string.IsNullOrWhiteSpace(p.UnitShort) ? "шт" : p.UnitShort; upP["$pk"].Value = p.Packaging;
|
||||||
upP["$vp"].Value = p.VatPercent.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
upP["$vp"].Value = p.VatPercent.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
upP["$ve"].Value = p.VatEnabled ? 1 : 0;
|
upP["$ve"].Value = p.VatEnabled ? 1 : 0;
|
||||||
upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
@ -301,6 +304,15 @@ public void SetKv(string key, string value)
|
||||||
public DateTime? LastSync() => DateTime.TryParse(GetKv("last_sync"), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : null;
|
public DateTime? LastSync() => DateTime.TryParse(GetKv("last_sync"), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : null;
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>UnitShort из строки; у старых БД/запросов колонки может не быть → «шт».</summary>
|
||||||
|
private static string ReadUnitShort(SqliteDataReader r)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < r.FieldCount; i++)
|
||||||
|
if (string.Equals(r.GetName(i), "UnitShort", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return r.IsDBNull(i) ? "шт" : r.GetString(i);
|
||||||
|
return "шт";
|
||||||
|
}
|
||||||
|
|
||||||
private static PosProduct Map(SqliteDataReader r)
|
private static PosProduct Map(SqliteDataReader r)
|
||||||
{
|
{
|
||||||
var id = Guid.Parse(r.GetString(r.GetOrdinal("Id")));
|
var id = Guid.Parse(r.GetString(r.GetOrdinal("Id")));
|
||||||
|
|
@ -310,6 +322,7 @@ private static PosProduct Map(SqliteDataReader r)
|
||||||
Name = r.GetString(r.GetOrdinal("Name")),
|
Name = r.GetString(r.GetOrdinal("Name")),
|
||||||
Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")),
|
Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")),
|
||||||
UnitCode = r.GetString(r.GetOrdinal("UnitCode")),
|
UnitCode = r.GetString(r.GetOrdinal("UnitCode")),
|
||||||
|
UnitShort = ReadUnitShort(r),
|
||||||
Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")),
|
Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")),
|
||||||
VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture),
|
VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture),
|
||||||
VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1,
|
VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1,
|
||||||
|
|
|
||||||
70
src/food-market.pos.core/Hardware/ScaleMonitor.cs
Normal file
70
src/food-market.pos.core/Hardware/ScaleMonitor.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
namespace foodmarket.Pos.Core.Hardware;
|
||||||
|
|
||||||
|
/// <summary>Надстройка над «читателем веса» (делегат, за которым реальные весы по COM
|
||||||
|
/// или ручной ввод). Обеспечивает:
|
||||||
|
/// • опрос ТОЛЬКО по требованию (при весовом товаре), а не постоянный поллинг;
|
||||||
|
/// • мягкую деградацию: нет связи → возвращаем null, кассир вводит вес вручную;
|
||||||
|
/// • запись о потере/восстановлении связи РОВНО ОДИН раз (а не сотни «Таймаут» в лог,
|
||||||
|
/// как в кассе МойСклад у владельца).
|
||||||
|
///
|
||||||
|
/// Не зависит от System.IO.Ports — реальный COM-читатель внедряется делегатом из POS,
|
||||||
|
/// поэтому логику можно покрыть юнит-тестами.</summary>
|
||||||
|
public sealed class ScaleMonitor
|
||||||
|
{
|
||||||
|
private Func<CancellationToken, Task<decimal>>? _reader;
|
||||||
|
private readonly Action<string>? _log;
|
||||||
|
private bool _lostLogged;
|
||||||
|
|
||||||
|
public ScaleMonitor(Action<string>? log = null) => _log = log;
|
||||||
|
|
||||||
|
/// <summary>Есть ли настроенный источник веса (весы выбраны/найдены).</summary>
|
||||||
|
public bool HasReader => _reader is not null;
|
||||||
|
|
||||||
|
/// <summary>Считаем ли мы весы «на связи» (последний опрос успешен).</summary>
|
||||||
|
public bool IsConnected { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Человекочитаемое имя текущего источника (для статуса на кассе).</summary>
|
||||||
|
public string StatusText => _reader is null
|
||||||
|
? "Весы не подключены"
|
||||||
|
: IsConnected ? "Весы на связи" : "Весы не отвечают";
|
||||||
|
|
||||||
|
/// <summary>Назначить источник веса (после автопоиска/выбора порта). null — сброс
|
||||||
|
/// (весов нет → ручной ввод).</summary>
|
||||||
|
public void SetReader(Func<CancellationToken, Task<decimal>>? reader, string? name = null)
|
||||||
|
{
|
||||||
|
_reader = reader;
|
||||||
|
_lostLogged = false;
|
||||||
|
IsConnected = false;
|
||||||
|
if (reader is not null) _log?.Invoke($"Весы: источник назначен{(name is null ? "" : " — " + name)}");
|
||||||
|
else _log?.Invoke("Весы: источник снят (ручной ввод веса)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Снять вес. Нет источника или нет связи → null (кассир вводит вручную).
|
||||||
|
/// Логируем потерю связи один раз; восстановление — тоже один раз.</summary>
|
||||||
|
public async Task<decimal?> TryReadWeightAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var reader = _reader;
|
||||||
|
if (reader is null) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var kg = await reader(ct).ConfigureAwait(false);
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
IsConnected = true;
|
||||||
|
if (_lostLogged) _log?.Invoke("Весы: связь восстановлена");
|
||||||
|
_lostLogged = false;
|
||||||
|
}
|
||||||
|
return kg < 0 ? 0m : kg;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
IsConnected = false;
|
||||||
|
if (!_lostLogged)
|
||||||
|
{
|
||||||
|
_lostLogged = true;
|
||||||
|
_log?.Invoke($"Весы: нет связи — перехожу на ручной ввод веса ({ex.GetType().Name})");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
|
||||||
if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty));
|
if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty));
|
||||||
var wl = new CartLine
|
var wl = new CartLine
|
||||||
{
|
{
|
||||||
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = true,
|
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, UnitShort = p.UnitShort, IsWeighed = true,
|
||||||
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
|
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
|
||||||
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
|
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +39,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
|
||||||
}
|
}
|
||||||
var line = new CartLine
|
var line = new CartLine
|
||||||
{
|
{
|
||||||
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = false,
|
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, UnitShort = p.UnitShort, IsWeighed = false,
|
||||||
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
|
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
|
||||||
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
|
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
|
||||||
};
|
};
|
||||||
|
|
@ -89,7 +89,7 @@ public void Restore(CartSnapshot snapshot)
|
||||||
|
|
||||||
private static CartLine Clone(CartLine l) => new()
|
private static CartLine Clone(CartLine l) => new()
|
||||||
{
|
{
|
||||||
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, IsWeighed = l.IsWeighed,
|
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, UnitShort = l.UnitShort, IsWeighed = l.IsWeighed,
|
||||||
UnitPrice = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, VatPercent = l.VatPercent,
|
UnitPrice = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, VatPercent = l.VatPercent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ public sealed class PosProduct
|
||||||
public string? Article { get; init; }
|
public string? Article { get; init; }
|
||||||
public required IReadOnlyList<string> Barcodes { get; init; }
|
public required IReadOnlyList<string> Barcodes { get; init; }
|
||||||
public required string UnitCode { get; init; }
|
public required string UnitCode { get; init; }
|
||||||
|
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»). По умолчанию «шт».</summary>
|
||||||
|
public string UnitShort { get; init; } = "шт";
|
||||||
public required PosPackaging Packaging { get; init; }
|
public required PosPackaging Packaging { get; init; }
|
||||||
public required decimal VatPercent { get; init; }
|
public required decimal VatPercent { get; init; }
|
||||||
public required bool VatEnabled { get; init; }
|
public required bool VatEnabled { get; init; }
|
||||||
|
|
@ -24,7 +26,7 @@ public sealed class PosProduct
|
||||||
public bool IsFavorite { get; init; }
|
public bool IsFavorite { get; init; }
|
||||||
public bool IsWeighed => Packaging == PosPackaging.Weight;
|
public bool IsWeighed => Packaging == PosPackaging.Weight;
|
||||||
/// <summary>Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного.</summary>
|
/// <summary>Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного.</summary>
|
||||||
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸";
|
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitShort}" : $"{Price:N0} ₸";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
|
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
|
||||||
|
|
@ -34,6 +36,8 @@ public sealed class CartLine
|
||||||
public required Guid ProductId { get; init; }
|
public required Guid ProductId { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public required string UnitCode { get; init; }
|
public required string UnitCode { get; init; }
|
||||||
|
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»).</summary>
|
||||||
|
public string UnitShort { get; init; } = "шт";
|
||||||
public required bool IsWeighed { get; init; }
|
public required bool IsWeighed { get; init; }
|
||||||
public required decimal UnitPrice { get; set; }
|
public required decimal UnitPrice { get; set; }
|
||||||
public decimal Quantity { get; set; } = 1m;
|
public decimal Quantity { get; set; } = 1m;
|
||||||
|
|
@ -42,6 +46,11 @@ public sealed class CartLine
|
||||||
|
|
||||||
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
|
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
|
||||||
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
|
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
|
||||||
|
|
||||||
|
/// <summary>Подпись количества для весового: «1,235 кг × 690 ₸/кг».</summary>
|
||||||
|
public string WeighedQtyText => IsWeighed
|
||||||
|
? $"{Quantity.ToString("0.###", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} {UnitShort} × {UnitPrice:N0} ₸/{UnitShort}"
|
||||||
|
: $"×{Quantity:0}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Состояние открытой смены (локальная проекция серверной CashShift).</summary>
|
/// <summary>Состояние открытой смены (локальная проекция серверной CashShift).</summary>
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,13 @@ public enum Proto { Cas, MassaK }
|
||||||
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
|
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
|
||||||
public string Name => $"Весы {_proto} ({_port})";
|
public string Name => $"Весы {_proto} ({_port})";
|
||||||
|
|
||||||
|
public string Port => _port;
|
||||||
|
public int Baud => _baud;
|
||||||
|
public Proto Protocol => _proto;
|
||||||
|
|
||||||
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1500, NewLine = "\r" };
|
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1200, WriteTimeout = 800, NewLine = "\r" };
|
||||||
sp.Open();
|
sp.Open();
|
||||||
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
|
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
|
||||||
await Task.Delay(120, ct);
|
await Task.Delay(120, ct);
|
||||||
|
|
@ -80,3 +84,52 @@ public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||||
return kg ?? 0m;
|
return kg ?? 0m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific
|
||||||
|
/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому
|
||||||
|
/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день —
|
||||||
|
/// мы сами находим порт и запоминаем его, без ручной возни.</summary>
|
||||||
|
public static class ScaleDiscovery
|
||||||
|
{
|
||||||
|
public sealed record Found(string Port, int Baud, ComScale.Proto Proto, decimal SampleKg);
|
||||||
|
|
||||||
|
// Типовые скорости весов розницы.
|
||||||
|
private static readonly int[] Bauds = { 9600, 4800, 19200, 115200 };
|
||||||
|
|
||||||
|
public static string[] ListPorts()
|
||||||
|
{
|
||||||
|
try { return SerialPort.GetPortNames().Distinct().OrderBy(x => x).ToArray(); }
|
||||||
|
catch { return Array.Empty<string>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Перебрать порты × протоколы × скорости, вернуть первый ответивший
|
||||||
|
/// правдоподобным весом (>= 0). Короткие таймауты, чтобы не висеть.</summary>
|
||||||
|
public static async Task<Found?> DiscoverAsync(Action<string>? log = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
foreach (var port in ListPorts())
|
||||||
|
{
|
||||||
|
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||||
|
{
|
||||||
|
foreach (var baud in Bauds)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scale = new ComScale(port, baud, proto);
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(1500);
|
||||||
|
var kg = await scale.ReadWeightKgAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
if (kg >= 0m)
|
||||||
|
{
|
||||||
|
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {kg:0.###} кг)");
|
||||||
|
return new Found(port, baud, proto, kg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* порт занят/не те весы/нет ответа — пробуем следующую комбинацию */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log?.Invoke("Весы: автопоиск не нашёл весов ни на одном порту");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using foodmarket.Pos.Core.Auth;
|
using foodmarket.Pos.Core.Auth;
|
||||||
using foodmarket.Pos.Core.Data;
|
using foodmarket.Pos.Core.Data;
|
||||||
|
using foodmarket.Pos.Core.Hardware;
|
||||||
using foodmarket.Pos.Core.Sync;
|
using foodmarket.Pos.Core.Sync;
|
||||||
using foodmarket.Pos.Hardware;
|
using foodmarket.Pos.Hardware;
|
||||||
using Refit;
|
using Refit;
|
||||||
|
|
@ -25,6 +26,15 @@ public sealed class AppServices
|
||||||
public IScale Scale { get; set; } = new ManualScale();
|
public IScale Scale { get; set; } = new ManualScale();
|
||||||
public IFiscalProvider Fiscal { get; } = new NullFiscalProvider();
|
public IFiscalProvider Fiscal { get; } = new NullFiscalProvider();
|
||||||
|
|
||||||
|
/// <summary>Монитор весов: опрос по требованию, мягкая деградация на ручной ввод,
|
||||||
|
/// запись о потере связи один раз. Источник (COM-порт) — из автопоиска/настроек.</summary>
|
||||||
|
public ScaleMonitor Scales { get; } = new ScaleMonitor(AppendScaleLog);
|
||||||
|
|
||||||
|
/// <summary>Настройки разбора весовых этикеток организации (по умолчанию ВЫКЛ).
|
||||||
|
/// Касса использует точный штрихкод; этикетки разбирает только если включено.</summary>
|
||||||
|
public foodmarket.Shared.Barcodes.WeightedBarcodeSettings WeightedSettings { get; set; }
|
||||||
|
= foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default;
|
||||||
|
|
||||||
private CheckoutService? _checkout;
|
private CheckoutService? _checkout;
|
||||||
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
|
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
|
||||||
public CheckoutService Checkout => _checkout ??= new CheckoutService(this);
|
public CheckoutService Checkout => _checkout ??= new CheckoutService(this);
|
||||||
|
|
@ -42,6 +52,56 @@ public AppServices()
|
||||||
: new FileTokenStore(FileTokenStore.DefaultPath());
|
: new FileTokenStore(FileTokenStore.DefaultPath());
|
||||||
Session = Tokens.Load();
|
Session = Tokens.Load();
|
||||||
if (Session is not null) ConfigureApi(Session);
|
if (Session is not null) ConfigureApi(Session);
|
||||||
|
RestoreScaleFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Весы ───────────────────────────────────────────────────────────────
|
||||||
|
private static readonly string ScaleLogPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos", "scale.log");
|
||||||
|
|
||||||
|
private static void AppendScaleLog(string msg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(ScaleLogPath)!);
|
||||||
|
File.AppendAllText(ScaleLogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
catch { /* логирование весов не должно мешать кассе */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Поднять сохранённый порт весов (если был выбран ранее).</summary>
|
||||||
|
public void RestoreScaleFromSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var port = Store.GetKv("scale_port");
|
||||||
|
if (string.IsNullOrWhiteSpace(port)) return;
|
||||||
|
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 9600;
|
||||||
|
var proto = Store.GetKv("scale_proto") == "Cas" ? ComScale.Proto.Cas : ComScale.Proto.MassaK;
|
||||||
|
SetScale(port, baud, proto);
|
||||||
|
}
|
||||||
|
catch { /* нет сохранённого порта — работаем с ручным вводом */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Назначить весы на конкретный порт и запомнить выбор.</summary>
|
||||||
|
public void SetScale(string port, int baud, ComScale.Proto proto)
|
||||||
|
{
|
||||||
|
var scale = new ComScale(port, baud, proto);
|
||||||
|
Scale = scale;
|
||||||
|
Scales.SetReader(ct => scale.ReadWeightKgAsync(ct), scale.Name);
|
||||||
|
Store.SetKv("scale_port", port);
|
||||||
|
Store.SetKv("scale_baud", baud.ToString());
|
||||||
|
Store.SetKv("scale_proto", proto.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
|
||||||
|
/// нашлись ли весы.</summary>
|
||||||
|
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var found = await ScaleDiscovery.DiscoverAsync(AppendScaleLog, ct);
|
||||||
|
if (found is null) return false;
|
||||||
|
SetScale(found.Port, found.Baud, found.Proto);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо
|
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ private void StartSession()
|
||||||
// локального SQLite, без сети.
|
// локального SQLite, без сети.
|
||||||
CatalogEmpty = _svc.Store.ProductCount() == 0;
|
CatalogEmpty = _svc.Store.ProductCount() == 0;
|
||||||
LastSyncAt = _svc.Store.LastSync();
|
LastSyncAt = _svc.Store.LastSync();
|
||||||
|
RefreshScaleBadge();
|
||||||
_ = ResolvePointNameAsync();
|
_ = ResolvePointNameAsync();
|
||||||
_sale = new SaleViewModel(_svc)
|
_sale = new SaleViewModel(_svc)
|
||||||
{
|
{
|
||||||
|
|
@ -185,6 +186,27 @@ private static string LocalVersion()
|
||||||
|
|
||||||
private void RefreshStatus() { if (_svc is not null) { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } }
|
private void RefreshStatus() { if (_svc is not null) { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } }
|
||||||
|
|
||||||
|
// Весы: статус в верхней панели + «Найти весы» (автоскан COM-портов).
|
||||||
|
[ObservableProperty] private string _scaleBadge = "Весы: не подключены";
|
||||||
|
[ObservableProperty] private bool _scaleBusy;
|
||||||
|
|
||||||
|
private void RefreshScaleBadge() => ScaleBadge = _svc?.Scales.StatusText ?? "Весы: не подключены";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DiscoverScales()
|
||||||
|
{
|
||||||
|
if (_svc is null || ScaleBusy) return;
|
||||||
|
ScaleBusy = true;
|
||||||
|
ScaleBadge = "Весы: ищу…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ok = await _svc.DiscoverScaleAsync();
|
||||||
|
ScaleBadge = ok ? "Весы: на связи" : "Весы не найдены";
|
||||||
|
}
|
||||||
|
catch { ScaleBadge = "Весы: ошибка поиска"; }
|
||||||
|
finally { ScaleBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
|
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
|
||||||
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
|
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
|
||||||
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
|
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
|
||||||
|
|
@ -459,6 +481,7 @@ public partial class SaleLineVm : ObservableObject
|
||||||
{
|
{
|
||||||
internal CartLine? Model;
|
internal CartLine? Model;
|
||||||
public required Guid ProductId { get; init; }
|
public required Guid ProductId { get; init; }
|
||||||
|
public bool IsWeighed { get; init; }
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _unit = "шт";
|
[ObservableProperty] private string _unit = "шт";
|
||||||
[ObservableProperty] private decimal _price;
|
[ObservableProperty] private decimal _price;
|
||||||
|
|
@ -536,6 +559,14 @@ public partial class SaleViewModel : ViewModelBase
|
||||||
[ObservableProperty] private decimal? _quickCreatePrice;
|
[ObservableProperty] private decimal? _quickCreatePrice;
|
||||||
[ObservableProperty] private bool _quickCreateBusy;
|
[ObservableProperty] private bool _quickCreateBusy;
|
||||||
|
|
||||||
|
// Ручной ввод веса для весового товара (когда весов нет/не ответили).
|
||||||
|
[ObservableProperty] private bool _isWeightEntryOpen;
|
||||||
|
[ObservableProperty] private decimal? _weightEntryValue;
|
||||||
|
[ObservableProperty] private string _weightEntryPriceText = "";
|
||||||
|
[ObservableProperty] private string _weightEntryStatus = "";
|
||||||
|
private PosProduct? WeightEntryProduct { get; set; }
|
||||||
|
public string WeightEntryProductName => WeightEntryProduct?.Name ?? "";
|
||||||
|
|
||||||
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
|
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
|
||||||
public Action<PaymentViewModel>? PaymentRequested;
|
public Action<PaymentViewModel>? PaymentRequested;
|
||||||
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
|
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
|
||||||
|
|
@ -557,7 +588,7 @@ private void LoadFavorites()
|
||||||
OnPropertyChanged(nameof(HasFavorites));
|
OnPropertyChanged(nameof(HasFavorites));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m);
|
[RelayCommand] private async Task AddFavorite(PosProduct p) { if (p.IsWeighed) await AddWeighedAsync(p); else AddProduct(p, 1m); }
|
||||||
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
|
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
|
||||||
|
|
||||||
private void Recalc()
|
private void Recalc()
|
||||||
|
|
@ -573,15 +604,54 @@ public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null)
|
||||||
if (vm is not null) { vm.Quantity = line.Quantity; }
|
if (vm is not null) { vm.Quantity = line.Quantity; }
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitCode, Price = line.UnitPrice, Quantity = line.Quantity, Model = line };
|
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitShort, Price = line.UnitPrice, Quantity = line.Quantity, IsWeighed = line.IsWeighed, Model = line };
|
||||||
Lines.Insert(0, vm);
|
Lines.Insert(0, vm);
|
||||||
}
|
}
|
||||||
SelectedLine = vm;
|
SelectedLine = vm;
|
||||||
UpdateStockWarning(vm);
|
UpdateStockWarning(vm);
|
||||||
Recalc();
|
Recalc();
|
||||||
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
|
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitShort + " × " + line.UnitPrice.ToString("N0") + " ₸/" + p.UnitShort : "×" + line.Quantity.ToString("0"))}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Добавить ВЕСОВОЙ товар: берём вес с весов (если на связи), иначе
|
||||||
|
/// открываем ручной ввод веса. Сумма = цена/кг × вес.</summary>
|
||||||
|
public async Task AddWeighedAsync(PosProduct p)
|
||||||
|
{
|
||||||
|
decimal? kg = _svc is not null ? await _svc.Scales.TryReadWeightAsync() : null;
|
||||||
|
if (kg is { } w && w > 0m) { AddProduct(p, w); return; }
|
||||||
|
// Весов нет/не ответили/нулевой вес → ручной ввод.
|
||||||
|
WeightEntryProduct = p;
|
||||||
|
OnPropertyChanged(nameof(WeightEntryProductName));
|
||||||
|
WeightEntryValue = kg is { } z && z > 0 ? z : null;
|
||||||
|
WeightEntryPriceText = $"{p.Price:N0} ₸/{p.UnitShort}";
|
||||||
|
WeightEntryStatus = _svc?.Scales.StatusText ?? "Весы не подключены";
|
||||||
|
IsWeightEntryOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task WeightEntryReadScale()
|
||||||
|
{
|
||||||
|
if (_svc is null) return;
|
||||||
|
var kg = await _svc.Scales.TryReadWeightAsync();
|
||||||
|
WeightEntryStatus = _svc.Scales.StatusText;
|
||||||
|
if (kg is { } w && w > 0m) WeightEntryValue = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WeightEntryConfirm()
|
||||||
|
{
|
||||||
|
if (WeightEntryProduct is { } p && WeightEntryValue is { } w && w > 0m)
|
||||||
|
{
|
||||||
|
AddProduct(p, w);
|
||||||
|
IsWeightEntryOpen = false; WeightEntryProduct = null; WeightEntryValue = null;
|
||||||
|
}
|
||||||
|
else Message = "Укажите вес больше нуля";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WeightEntryCancel()
|
||||||
|
{ IsWeightEntryOpen = false; WeightEntryProduct = null; WeightEntryValue = null; }
|
||||||
|
|
||||||
/// <summary>Предупреждение «уйдёт в минус» для строки: сравниваем количество в
|
/// <summary>Предупреждение «уйдёт в минус» для строки: сравниваем количество в
|
||||||
/// чеке с локальным остатком (снимок последнего синка, работает офлайн).
|
/// чеке с локальным остатком (снимок последнего синка, работает офлайн).
|
||||||
/// Продажу НЕ блокируем (решение владельца) — только подсвечиваем.</summary>
|
/// Продажу НЕ блокируем (решение владельца) — только подсвечиваем.</summary>
|
||||||
|
|
@ -599,13 +669,16 @@ private void UpdateStockWarning(SaleLineVm vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Scan()
|
private async Task Scan()
|
||||||
{
|
{
|
||||||
var raw = ScanInput.Trim();
|
var raw = ScanInput.Trim();
|
||||||
ScanInput = "";
|
ScanInput = "";
|
||||||
if (raw.Length == 0) return;
|
if (raw.Length == 0) return;
|
||||||
var parsed = BarcodeParser.Parse(raw);
|
// Разбор этикеток по настройкам организации (по умолчанию ВЫКЛ → точный код).
|
||||||
var p = _svc?.Store.FindByBarcode(parsed.Lookup);
|
var parsed = BarcodeParser.Parse(raw, _svc?.WeightedSettings);
|
||||||
|
// Сначала — точное совпадение штрихкода; этикеточный PLU как запасной путь.
|
||||||
|
var p = _svc?.Store.FindByBarcode(raw.Trim())
|
||||||
|
?? _svc?.Store.FindByBarcode(parsed.Lookup);
|
||||||
if (p is null)
|
if (p is null)
|
||||||
{
|
{
|
||||||
// Неизвестный штрихкод: онлайн — предлагаем быстро создать товар
|
// Неизвестный штрихкод: онлайн — предлагаем быстро создать товар
|
||||||
|
|
@ -621,6 +694,12 @@ private void Scan()
|
||||||
else Message = $"Штрихкод {parsed.Lookup} не найден";
|
else Message = $"Штрихкод {parsed.Lookup} не найден";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Весовой товар без веса из этикетки → берём вес с весов/вручную.
|
||||||
|
if (p.IsWeighed && !(parsed.IsWeighted && parsed.EmbeddedWeight is > 0m))
|
||||||
|
{
|
||||||
|
await AddWeighedAsync(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var qty = parsed.IsWeighted && parsed.EmbeddedWeight is { } w ? w : 1m;
|
var qty = parsed.IsWeighted && parsed.EmbeddedWeight is { } w ? w : 1m;
|
||||||
var price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
|
var price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
|
||||||
AddProduct(p, qty, price);
|
AddProduct(p, qty, price);
|
||||||
|
|
@ -786,7 +865,7 @@ private void RebuildLines()
|
||||||
{
|
{
|
||||||
Lines.Clear();
|
Lines.Clear();
|
||||||
foreach (var l in _cart.Lines)
|
foreach (var l in _cart.Lines)
|
||||||
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, Model = l });
|
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitShort, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, IsWeighed = l.IsWeighed, Model = l });
|
||||||
SelectedLine = Lines.FirstOrDefault();
|
SelectedLine = Lines.FirstOrDefault();
|
||||||
Recalc();
|
Recalc();
|
||||||
}
|
}
|
||||||
|
|
@ -809,7 +888,7 @@ public static SaleViewModel Sample()
|
||||||
vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47;
|
vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47;
|
||||||
vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные»)
|
vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные»)
|
||||||
PosProduct Fav(string n, string unit, decimal price, bool weighed) => new()
|
PosProduct Fav(string n, string unit, decimal price, bool weighed) => new()
|
||||||
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
|
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, UnitShort = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
|
||||||
foreach (var f in new[] {
|
foreach (var f in new[] {
|
||||||
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false),
|
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false),
|
||||||
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),
|
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@
|
||||||
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
|
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
<!-- Весы: статус + «Найти весы» (автоскан COM-портов). Клик — поиск. -->
|
||||||
|
<Button Padding="12,6" CornerRadius="999" BorderThickness="0" Background="{DynamicResource PosCard}"
|
||||||
|
Command="{Binding DiscoverScalesCommand}" IsEnabled="{Binding !ScaleBusy}"
|
||||||
|
ToolTip.Tip="Найти весы на COM-портах">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="⚖" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding ScaleBadge}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -237,5 +237,27 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Ручной ввод веса для весового товара (весов нет/не ответили) -->
|
||||||
|
<Border Grid.ColumnSpan="2" Background="#99000000" IsVisible="{Binding IsWeightEntryOpen}">
|
||||||
|
<Border Width="440" Background="{DynamicResource PosCard}" CornerRadius="14" Padding="24"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="Вес товара" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource PosText}"/>
|
||||||
|
<TextBlock Text="{Binding WeightEntryProductName}" Foreground="{DynamicResource PosText}" FontSize="16" FontWeight="Medium"/>
|
||||||
|
<TextBlock Text="{Binding WeightEntryPriceText}" Foreground="{DynamicResource PosTextMuted}"/>
|
||||||
|
<TextBox Watermark="Вес, кг (например 1,235)" FontSize="18" Text="{Binding WeightEntryValue}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding WeightEntryStatus}" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Считать с весов" Padding="12,8" Command="{Binding WeightEntryReadScaleCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Отмена" Padding="18,10" Command="{Binding WeightEntryCancelCommand}"/>
|
||||||
|
<Button Content="Добавить" Padding="18,10" Background="{DynamicResource PosBrand}" Foreground="White"
|
||||||
|
Command="{Binding WeightEntryConfirmCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,23 @@ public void Normal_barcode_is_not_weighted()
|
||||||
r.Lookup.Should().Be("4870204010101");
|
r.Lookup.Should().Be("4870204010101");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// По умолчанию разбор этикеток ВЫКЛ (решение владельца): явно включаем настройками.
|
||||||
|
private static readonly foodmarket.Shared.Barcodes.WeightedBarcodeSettings On =
|
||||||
|
foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default with { Enabled = true };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_disabled_treats_weighted_as_plain()
|
||||||
|
{
|
||||||
|
var r = BarcodeParser.Parse("2112345015007");
|
||||||
|
r.IsWeighted.Should().BeFalse("по умолчанию разбор этикеток выключен");
|
||||||
|
r.Lookup.Should().Be("2112345015007");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Weight_prefix_20_extracts_plu_and_kg()
|
public void Weight_prefix_20_extracts_plu_and_kg()
|
||||||
{
|
{
|
||||||
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
|
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг (при включённом разборе)
|
||||||
var r = BarcodeParser.Parse("2112345015007");
|
var r = BarcodeParser.Parse("2112345015007", On);
|
||||||
r.IsWeighted.Should().BeTrue();
|
r.IsWeighted.Should().BeTrue();
|
||||||
r.Lookup.Should().Be("12345");
|
r.Lookup.Should().Be("12345");
|
||||||
r.EmbeddedWeight.Should().Be(1.500m);
|
r.EmbeddedWeight.Should().Be(1.500m);
|
||||||
|
|
@ -33,8 +45,8 @@ public void Weight_prefix_20_extracts_plu_and_kg()
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Price_prefix_25_extracts_plu_and_price()
|
public void Price_prefix_25_extracts_plu_and_price()
|
||||||
{
|
{
|
||||||
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
|
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ (при включённом разборе)
|
||||||
var r = BarcodeParser.Parse("2554321123456");
|
var r = BarcodeParser.Parse("2554321123456", On);
|
||||||
r.IsWeighted.Should().BeTrue();
|
r.IsWeighted.Should().BeTrue();
|
||||||
r.Lookup.Should().Be("54321");
|
r.Lookup.Should().Be("54321");
|
||||||
r.EmbeddedPrice.Should().Be(123.45m);
|
r.EmbeddedPrice.Should().Be(123.45m);
|
||||||
|
|
|
||||||
73
tests/food-market.UnitTests/ScaleMonitorTests.cs
Normal file
73
tests/food-market.UnitTests/ScaleMonitorTests.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Pos.Core.Hardware;
|
||||||
|
using foodmarket.Pos.Core.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.UnitTests;
|
||||||
|
|
||||||
|
public class ScaleMonitorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task No_reader_returns_null_and_disconnected()
|
||||||
|
{
|
||||||
|
var m = new ScaleMonitor();
|
||||||
|
(await m.TryReadWeightAsync()).Should().BeNull();
|
||||||
|
m.IsConnected.Should().BeFalse();
|
||||||
|
m.HasReader.Should().BeFalse();
|
||||||
|
m.StatusText.Should().Be("Весы не подключены");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reader_weight_is_returned_and_connected()
|
||||||
|
{
|
||||||
|
var m = new ScaleMonitor();
|
||||||
|
m.SetReader(_ => Task.FromResult(1.234m));
|
||||||
|
(await m.TryReadWeightAsync()).Should().Be(1.234m);
|
||||||
|
m.IsConnected.Should().BeTrue();
|
||||||
|
m.StatusText.Should().Be("Весы на связи");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Loss_of_connection_is_logged_exactly_once()
|
||||||
|
{
|
||||||
|
var log = new List<string>();
|
||||||
|
var m = new ScaleMonitor(log.Add);
|
||||||
|
m.SetReader(_ => throw new InvalidOperationException("Порт не выбран"));
|
||||||
|
|
||||||
|
// Сто опросов подряд при оборванной связи — одна запись о потере (не сотни).
|
||||||
|
for (int i = 0; i < 100; i++) (await m.TryReadWeightAsync()).Should().BeNull();
|
||||||
|
|
||||||
|
log.Count(l => l.Contains("нет связи")).Should().Be(1, "потеря связи логируется один раз");
|
||||||
|
m.IsConnected.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Recovery_after_loss_is_logged_once()
|
||||||
|
{
|
||||||
|
var log = new List<string>();
|
||||||
|
var m = new ScaleMonitor(log.Add);
|
||||||
|
var fail = true;
|
||||||
|
m.SetReader(_ => fail ? throw new TimeoutException() : Task.FromResult(0.5m));
|
||||||
|
|
||||||
|
await m.TryReadWeightAsync(); // потеря
|
||||||
|
await m.TryReadWeightAsync(); // ещё раз — не логируем повторно
|
||||||
|
fail = false;
|
||||||
|
(await m.TryReadWeightAsync()).Should().Be(0.5m); // восстановление
|
||||||
|
|
||||||
|
log.Count(l => l.Contains("нет связи")).Should().Be(1);
|
||||||
|
log.Count(l => l.Contains("восстановлена")).Should().Be(1);
|
||||||
|
m.IsConnected.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weighed_line_text_shows_kg_times_price_per_kg()
|
||||||
|
{
|
||||||
|
var line = new CartLine
|
||||||
|
{
|
||||||
|
ProductId = Guid.NewGuid(), Name = "Яблоки", UnitCode = "166", UnitShort = "кг",
|
||||||
|
IsWeighed = true, UnitPrice = 690m, Quantity = 1.235m,
|
||||||
|
};
|
||||||
|
line.WeighedQtyText.Should().Be("1,235 кг × 690 ₸/кг");
|
||||||
|
line.LineTotal.Should().Be(852.15m);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue