diff --git a/src/food-market.pos.core/Data/PosStore.cs b/src/food-market.pos.core/Data/PosStore.cs
index fa1de50..2c855d1 100644
--- a/src/food-market.pos.core/Data/PosStore.cs
+++ b/src/food-market.pos.core/Data/PosStore.cs
@@ -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 ────────────────────────────────────────────────────────────────
+ /// UnitShort из строки; у старых БД/запросов колонки может не быть → «шт».
+ 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,
diff --git a/src/food-market.pos.core/Hardware/ScaleMonitor.cs b/src/food-market.pos.core/Hardware/ScaleMonitor.cs
new file mode 100644
index 0000000..d683363
--- /dev/null
+++ b/src/food-market.pos.core/Hardware/ScaleMonitor.cs
@@ -0,0 +1,70 @@
+namespace foodmarket.Pos.Core.Hardware;
+
+/// Надстройка над «читателем веса» (делегат, за которым реальные весы по COM
+/// или ручной ввод). Обеспечивает:
+/// • опрос ТОЛЬКО по требованию (при весовом товаре), а не постоянный поллинг;
+/// • мягкую деградацию: нет связи → возвращаем null, кассир вводит вес вручную;
+/// • запись о потере/восстановлении связи РОВНО ОДИН раз (а не сотни «Таймаут» в лог,
+/// как в кассе МойСклад у владельца).
+///
+/// Не зависит от System.IO.Ports — реальный COM-читатель внедряется делегатом из POS,
+/// поэтому логику можно покрыть юнит-тестами.
+public sealed class ScaleMonitor
+{
+ private Func>? _reader;
+ private readonly Action? _log;
+ private bool _lostLogged;
+
+ public ScaleMonitor(Action? log = null) => _log = log;
+
+ /// Есть ли настроенный источник веса (весы выбраны/найдены).
+ public bool HasReader => _reader is not null;
+
+ /// Считаем ли мы весы «на связи» (последний опрос успешен).
+ public bool IsConnected { get; private set; }
+
+ /// Человекочитаемое имя текущего источника (для статуса на кассе).
+ public string StatusText => _reader is null
+ ? "Весы не подключены"
+ : IsConnected ? "Весы на связи" : "Весы не отвечают";
+
+ /// Назначить источник веса (после автопоиска/выбора порта). null — сброс
+ /// (весов нет → ручной ввод).
+ public void SetReader(Func>? reader, string? name = null)
+ {
+ _reader = reader;
+ _lostLogged = false;
+ IsConnected = false;
+ if (reader is not null) _log?.Invoke($"Весы: источник назначен{(name is null ? "" : " — " + name)}");
+ else _log?.Invoke("Весы: источник снят (ручной ввод веса)");
+ }
+
+ /// Снять вес. Нет источника или нет связи → null (кассир вводит вручную).
+ /// Логируем потерю связи один раз; восстановление — тоже один раз.
+ public async Task 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;
+ }
+ }
+}
diff --git a/src/food-market.pos.core/Logic/Cart.cs b/src/food-market.pos.core/Logic/Cart.cs
index 703afe0..24dfbcf 100644
--- a/src/food-market.pos.core/Logic/Cart.cs
+++ b/src/food-market.pos.core/Logic/Cart.cs
@@ -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,
};
}
diff --git a/src/food-market.pos.core/Models/PosModels.cs b/src/food-market.pos.core/Models/PosModels.cs
index 0fcb44e..1d28bb3 100644
--- a/src/food-market.pos.core/Models/PosModels.cs
+++ b/src/food-market.pos.core/Models/PosModels.cs
@@ -16,6 +16,8 @@ public sealed class PosProduct
public string? Article { get; init; }
public required IReadOnlyList Barcodes { get; init; }
public required string UnitCode { get; init; }
+ /// Короткое имя единицы для отображения («шт»/«кг»/«л»). По умолчанию «шт».
+ 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;
/// Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного.
- public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸";
+ public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitShort}" : $"{Price:N0} ₸";
}
/// Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
@@ -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; }
+ /// Короткое имя единицы для отображения («шт»/«кг»/«л»).
+ 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
/// Сумма строки = цена×кол-во − скидка (не меньше нуля).
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
+
+ /// Подпись количества для весового: «1,235 кг × 690 ₸/кг».
+ public string WeighedQtyText => IsWeighed
+ ? $"{Quantity.ToString("0.###", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} {UnitShort} × {UnitPrice:N0} ₸/{UnitShort}"
+ : $"×{Quantity:0}";
}
/// Состояние открытой смены (локальная проекция серверной CashShift).
diff --git a/src/food-market.pos/Hardware/Scale.cs b/src/food-market.pos/Hardware/Scale.cs
index a5b2bf8..861e5cd 100644
--- a/src/food-market.pos/Hardware/Scale.cs
+++ b/src/food-market.pos/Hardware/Scale.cs
@@ -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 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 ReadWeightKgAsync(CancellationToken ct = default)
return kg ?? 0m;
}
}
+
+/// Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific
+/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому
+/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день —
+/// мы сами находим порт и запоминаем его, без ручной возни.
+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(); }
+ }
+
+ /// Перебрать порты × протоколы × скорости, вернуть первый ответивший
+ /// правдоподобным весом (>= 0). Короткие таймауты, чтобы не висеть.
+ public static async Task DiscoverAsync(Action? 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;
+ }
+}
diff --git a/src/food-market.pos/Services/AppServices.cs b/src/food-market.pos/Services/AppServices.cs
index d3835ab..272d628 100644
--- a/src/food-market.pos/Services/AppServices.cs
+++ b/src/food-market.pos/Services/AppServices.cs
@@ -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();
+ /// Монитор весов: опрос по требованию, мягкая деградация на ручной ввод,
+ /// запись о потере связи один раз. Источник (COM-порт) — из автопоиска/настроек.
+ public ScaleMonitor Scales { get; } = new ScaleMonitor(AppendScaleLog);
+
+ /// Настройки разбора весовых этикеток организации (по умолчанию ВЫКЛ).
+ /// Касса использует точный штрихкод; этикетки разбирает только если включено.
+ public foodmarket.Shared.Barcodes.WeightedBarcodeSettings WeightedSettings { get; set; }
+ = foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default;
+
private CheckoutService? _checkout;
/// Оркестратор завершения чека (очередь → push → печать).
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 { /* логирование весов не должно мешать кассе */ }
+ }
+
+ /// Поднять сохранённый порт весов (если был выбран ранее).
+ 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 { /* нет сохранённого порта — работаем с ручным вводом */ }
+ }
+
+ /// Назначить весы на конкретный порт и запомнить выбор.
+ 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());
+ }
+
+ /// Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
+ /// нашлись ли весы.
+ public async Task 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;
}
/// Контейнер с готовым хранилищем — для headless UI-тестов/демо
diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs
index cfb1e30..9c330f2 100644
--- a/src/food-market.pos/ViewModels/ViewModels.cs
+++ b/src/food-market.pos/ViewModels/ViewModels.cs
@@ -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 ?? "";
+
/// Поднять модальную оплату (обрабатывает оболочка).
public Action? PaymentRequested;
/// Чек проведён — оболочка обновляет индикатор очереди.
@@ -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"))}";
}
+ /// Добавить ВЕСОВОЙ товар: берём вес с весов (если на связи), иначе
+ /// открываем ручной ввод веса. Сумма = цена/кг × вес.
+ 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; }
+
/// Предупреждение «уйдёт в минус» для строки: сравниваем количество в
/// чеке с локальным остатком (снимок последнего синка, работает офлайн).
/// Продажу НЕ блокируем (решение владельца) — только подсвечиваем.
@@ -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(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
+ { Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty(), 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),
diff --git a/src/food-market.pos/Views/MainWindow.axaml b/src/food-market.pos/Views/MainWindow.axaml
index 21bb82e..0555d17 100644
--- a/src/food-market.pos/Views/MainWindow.axaml
+++ b/src/food-market.pos/Views/MainWindow.axaml
@@ -73,6 +73,15 @@
+
+
diff --git a/src/food-market.pos/Views/SaleView.axaml b/src/food-market.pos/Views/SaleView.axaml
index 4d7366f..19b42df 100644
--- a/src/food-market.pos/Views/SaleView.axaml
+++ b/src/food-market.pos/Views/SaleView.axaml
@@ -237,5 +237,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs
index d6f6e87..0376ed4 100644
--- a/tests/food-market.UnitTests/PosCoreTests.cs
+++ b/tests/food-market.UnitTests/PosCoreTests.cs
@@ -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);
diff --git a/tests/food-market.UnitTests/ScaleMonitorTests.cs b/tests/food-market.UnitTests/ScaleMonitorTests.cs
new file mode 100644
index 0000000..895e818
--- /dev/null
+++ b/tests/food-market.UnitTests/ScaleMonitorTests.cs
@@ -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();
+ 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();
+ 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);
+ }
+}