From 195dfb1a2c31c06ea0d4a6f31e4d36710affb6e7 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 11:17:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=81=D0=B5=20=E2=80=94=20=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=20=D1=81=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2/=D0=B2=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BD=D1=83=D1=8E=20+=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Часть 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 --- src/food-market.pos.core/Data/PosStore.cs | 23 ++++- .../Hardware/ScaleMonitor.cs | 70 ++++++++++++++ src/food-market.pos.core/Logic/Cart.cs | 6 +- src/food-market.pos.core/Models/PosModels.cs | 11 ++- src/food-market.pos/Hardware/Scale.cs | 55 ++++++++++- src/food-market.pos/Services/AppServices.cs | 60 ++++++++++++ src/food-market.pos/ViewModels/ViewModels.cs | 95 +++++++++++++++++-- src/food-market.pos/Views/MainWindow.axaml | 9 ++ src/food-market.pos/Views/SaleView.axaml | 22 +++++ tests/food-market.UnitTests/PosCoreTests.cs | 20 +++- .../ScaleMonitorTests.cs | 73 ++++++++++++++ 11 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 src/food-market.pos.core/Hardware/ScaleMonitor.cs create mode 100644 tests/food-market.UnitTests/ScaleMonitorTests.cs 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 @@ + + + + + + + + + + + +