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:
nns 2026-10-09 11:17:35 +05:00
parent d0fee10e96
commit 195dfb1a2c
11 changed files with 422 additions and 22 deletions

View file

@ -49,6 +49,8 @@ private void InitSchema()
// 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 FavoriteOrder INTEGER NOT NULL DEFAULT 0");
// Короткое имя единицы («шт»/«кг»/«л») для отображения; раньше показывали код ОКЕИ.
TryExec("ALTER TABLE products ADD COLUMN UnitShort TEXT NOT NULL DEFAULT 'шт'");
}
private void TryExec(string sql)
@ -68,11 +70,11 @@ public void ApplySync(PosSyncResponse sync)
// Подготовленные команды с переиспользуемыми параметрами — массовая
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
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)
VALUES($id,$n,$nl,$a,$u,$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,
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,$us,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
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;";
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();
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
var delP = AddParams(delBc, "$id"); delBc.Prepare();
@ -85,7 +87,8 @@ public void ApplySync(PosSyncResponse sync)
var hasPrice = priceByProduct.ContainsKey(p.Id);
var idStr = p.Id.ToString();
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["$ve"].Value = p.VatEnabled ? 1 : 0;
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;
// ── 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)
{
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")),
Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")),
UnitCode = r.GetString(r.GetOrdinal("UnitCode")),
UnitShort = ReadUnitShort(r),
Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")),
VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture),
VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1,

View 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;
}
}
}

View file

@ -23,7 +23,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty));
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,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
@ -39,7 +39,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
}
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,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
@ -89,7 +89,7 @@ public void Restore(CartSnapshot snapshot)
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,
};
}

View file

@ -16,6 +16,8 @@ public sealed class PosProduct
public string? Article { get; init; }
public required IReadOnlyList<string> Barcodes { get; init; }
public required string UnitCode { get; init; }
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»). По умолчанию «шт».</summary>
public string UnitShort { get; init; } = "шт";
public required PosPackaging Packaging { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
@ -24,7 +26,7 @@ public sealed class PosProduct
public bool IsFavorite { get; init; }
public bool IsWeighed => Packaging == PosPackaging.Weight;
/// <summary>Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного.</summary>
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸";
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitShort}" : $"{Price:N0} ₸";
}
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
@ -34,6 +36,8 @@ public sealed class CartLine
public required Guid ProductId { get; init; }
public required string Name { get; init; }
public required string UnitCode { get; init; }
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»).</summary>
public string UnitShort { get; init; } = "шт";
public required bool IsWeighed { get; init; }
public required decimal UnitPrice { get; set; }
public decimal Quantity { get; set; } = 1m;
@ -42,6 +46,11 @@ public sealed class CartLine
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
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>

View file

@ -69,9 +69,13 @@ public enum Proto { Cas, MassaK }
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
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)
{
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();
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
await Task.Delay(120, ct);
@ -80,3 +84,52 @@ public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
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>Перебрать порты × протоколы × скорости, вернуть первый ответивший
/// правдоподобным весом (&gt;= 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;
}
}

View file

@ -2,6 +2,7 @@
using System.Net.Http.Headers;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Hardware;
using foodmarket.Pos.Core.Sync;
using foodmarket.Pos.Hardware;
using Refit;
@ -25,6 +26,15 @@ public sealed class AppServices
public IScale Scale { get; set; } = new ManualScale();
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;
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
public CheckoutService Checkout => _checkout ??= new CheckoutService(this);
@ -42,6 +52,56 @@ public AppServices()
: new FileTokenStore(FileTokenStore.DefaultPath());
Session = Tokens.Load();
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-тестов/демо

View file

@ -122,6 +122,7 @@ private void StartSession()
// локального SQLite, без сети.
CatalogEmpty = _svc.Store.ProductCount() == 0;
LastSyncAt = _svc.Store.LastSync();
RefreshScaleBadge();
_ = ResolvePointNameAsync();
_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(); } }
// Весы: статус в верхней панели + «Найти весы» (автоскан 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 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);
@ -459,6 +481,7 @@ public partial class SaleLineVm : ObservableObject
{
internal CartLine? Model;
public required Guid ProductId { get; init; }
public bool IsWeighed { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _unit = "шт";
[ObservableProperty] private decimal _price;
@ -536,6 +559,14 @@ public partial class SaleViewModel : ViewModelBase
[ObservableProperty] private decimal? _quickCreatePrice;
[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>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
@ -557,7 +588,7 @@ private void LoadFavorites()
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;
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; }
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);
}
SelectedLine = vm;
UpdateStockWarning(vm);
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>
@ -599,13 +669,16 @@ private void UpdateStockWarning(SaleLineVm vm)
}
[RelayCommand]
private void Scan()
private async Task Scan()
{
var raw = ScanInput.Trim();
ScanInput = "";
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)
{
// Неизвестный штрихкод: онлайн — предлагаем быстро создать товар
@ -621,6 +694,12 @@ private void Scan()
else Message = $"Штрихкод {parsed.Lookup} не найден";
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 price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
AddProduct(p, qty, price);
@ -786,7 +865,7 @@ private void RebuildLines()
{
Lines.Clear();
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();
Recalc();
}
@ -809,7 +888,7 @@ public static SaleViewModel Sample()
vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47;
vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные»)
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[] {
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false),
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),

View file

@ -73,6 +73,15 @@
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
</StackPanel>
</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>
</Grid>
</Border>

View file

@ -237,5 +237,27 @@
</StackPanel>
</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>
</UserControl>

View file

@ -19,11 +19,23 @@ public void Normal_barcode_is_not_weighted()
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]
public void Weight_prefix_20_extracts_plu_and_kg()
{
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
var r = BarcodeParser.Parse("2112345015007");
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг (при включённом разборе)
var r = BarcodeParser.Parse("2112345015007", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("12345");
r.EmbeddedWeight.Should().Be(1.500m);
@ -33,8 +45,8 @@ public void Weight_prefix_20_extracts_plu_and_kg()
[Fact]
public void Price_prefix_25_extracts_plu_and_price()
{
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
var r = BarcodeParser.Parse("2554321123456");
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ (при включённом разборе)
var r = BarcodeParser.Parse("2554321123456", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("54321");
r.EmbeddedPrice.Should().Be(123.45m);

View 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);
}
}